Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
196 lines
5.7 KiB
JavaScript
196 lines
5.7 KiB
JavaScript
"use strict";
|
|
|
|
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 30;
|
|
const MIN_SESSION_IDLE_TIMEOUT_MINUTES = 1;
|
|
const MAX_SESSION_IDLE_TIMEOUT_MINUTES = 24 * 60;
|
|
|
|
function normalizeSessionIdleTimeoutMinutes(value) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed)) return DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES;
|
|
return Math.min(
|
|
MAX_SESSION_IDLE_TIMEOUT_MINUTES,
|
|
Math.max(MIN_SESSION_IDLE_TIMEOUT_MINUTES, Math.round(parsed)),
|
|
);
|
|
}
|
|
|
|
function createSessionIdleManager(options = {}) {
|
|
const DateImpl = options.Date || Date;
|
|
const setTimeoutImpl = options.setTimeout || setTimeout;
|
|
const clearTimeoutImpl = options.clearTimeout || clearTimeout;
|
|
const onIdle = typeof options.onIdle === "function" ? options.onIdle : async () => {};
|
|
const entries = new Map();
|
|
let timeoutMinutes = normalizeSessionIdleTimeoutMinutes(options.timeoutMinutes);
|
|
|
|
function clearTimer(entry) {
|
|
if (entry.timer == null) return;
|
|
clearTimeoutImpl(entry.timer);
|
|
entry.timer = null;
|
|
}
|
|
|
|
function schedule(entry) {
|
|
clearTimer(entry);
|
|
if (entry.activeCount > 0 || entry.checking || entry.closing) return;
|
|
const expiresAt = entry.lastActivityAt + timeoutMinutes * 60 * 1000;
|
|
const delay = Math.max(0, expiresAt - DateImpl.now());
|
|
entry.timer = setTimeoutImpl(() => {
|
|
entry.timer = null;
|
|
const current = entries.get(entry.sessionId);
|
|
if (current !== entry || entry.activeCount > 0 || entry.checking || entry.closing) return;
|
|
const latestExpiresAt = entry.lastActivityAt + timeoutMinutes * 60 * 1000;
|
|
if (DateImpl.now() < latestExpiresAt) {
|
|
schedule(entry);
|
|
return;
|
|
}
|
|
entry.checking = true;
|
|
const activityVersion = entry.activityVersion;
|
|
Promise.resolve(onIdle({
|
|
chatSessionId: entry.chatSessionId,
|
|
sessionId: entry.sessionId,
|
|
}, activityVersion)).catch(() => {
|
|
resume(entry.sessionId);
|
|
});
|
|
}, delay);
|
|
entry.timer?.unref?.();
|
|
}
|
|
|
|
function track(chatSessionId, sessionId) {
|
|
if (!chatSessionId || !sessionId) return false;
|
|
const existing = entries.get(sessionId);
|
|
if (existing) clearTimer(existing);
|
|
const entry = {
|
|
chatSessionId,
|
|
sessionId,
|
|
lastActivityAt: DateImpl.now(),
|
|
activityVersion: 0,
|
|
activeCount: 0,
|
|
checking: false,
|
|
closing: false,
|
|
timer: null,
|
|
};
|
|
entries.set(sessionId, entry);
|
|
schedule(entry);
|
|
return true;
|
|
}
|
|
|
|
function touch(chatSessionId, sessionId) {
|
|
const entry = entries.get(sessionId);
|
|
if (!entry || entry.closing) return false;
|
|
entry.checking = false;
|
|
entry.activityVersion += 1;
|
|
entry.lastActivityAt = DateImpl.now();
|
|
// Keep an existing timer instead of recreating it for every output chunk.
|
|
// Its callback rechecks lastActivityAt and extends the deadline as needed.
|
|
if (entry.timer == null) schedule(entry);
|
|
return true;
|
|
}
|
|
|
|
function beginActivity(chatSessionId, sessionId) {
|
|
const entry = entries.get(sessionId);
|
|
if (!entry || entry.closing) return false;
|
|
entry.checking = false;
|
|
entry.activityVersion += 1;
|
|
entry.activeCount += 1;
|
|
entry.lastActivityAt = DateImpl.now();
|
|
clearTimer(entry);
|
|
return true;
|
|
}
|
|
|
|
function endActivity(chatSessionId, sessionId) {
|
|
const entry = entries.get(sessionId);
|
|
if (!entry || entry.closing) return false;
|
|
entry.activityVersion += 1;
|
|
entry.activeCount = Math.max(0, entry.activeCount - 1);
|
|
entry.lastActivityAt = DateImpl.now();
|
|
schedule(entry);
|
|
return true;
|
|
}
|
|
|
|
function beginClose(sessionId) {
|
|
const entry = entries.get(sessionId);
|
|
if (!entry || entry.closing) return false;
|
|
entry.checking = false;
|
|
entry.closing = true;
|
|
clearTimer(entry);
|
|
return true;
|
|
}
|
|
|
|
function beginIdleClose(sessionId, activityVersion) {
|
|
if (!isIdleCheckCurrent(sessionId, activityVersion)) return false;
|
|
return beginClose(sessionId);
|
|
}
|
|
|
|
function isIdleCheckCurrent(sessionId, activityVersion) {
|
|
const entry = entries.get(sessionId);
|
|
return Boolean(
|
|
entry
|
|
&& entry.checking
|
|
&& !entry.closing
|
|
&& entry.activeCount === 0
|
|
&& entry.activityVersion === activityVersion
|
|
);
|
|
}
|
|
|
|
function resume(sessionId) {
|
|
const entry = entries.get(sessionId);
|
|
if (!entry) return false;
|
|
entry.closing = false;
|
|
entry.checking = false;
|
|
entry.activityVersion += 1;
|
|
entry.activeCount = 0;
|
|
entry.lastActivityAt = DateImpl.now();
|
|
schedule(entry);
|
|
return true;
|
|
}
|
|
|
|
function forgetSession(sessionId) {
|
|
const entry = entries.get(sessionId);
|
|
if (!entry) return false;
|
|
clearTimer(entry);
|
|
entries.delete(sessionId);
|
|
return true;
|
|
}
|
|
|
|
function setTimeoutMinutes(value) {
|
|
timeoutMinutes = normalizeSessionIdleTimeoutMinutes(value);
|
|
for (const entry of entries.values()) schedule(entry);
|
|
return timeoutMinutes;
|
|
}
|
|
|
|
function clearAll() {
|
|
for (const entry of entries.values()) clearTimer(entry);
|
|
entries.clear();
|
|
}
|
|
|
|
function scopeCleared() {
|
|
// Intentionally keep timers alive. A deleted/interrupted AI scope is one of
|
|
// the cases where the idle fallback is needed most.
|
|
}
|
|
|
|
return {
|
|
track,
|
|
touch,
|
|
beginActivity,
|
|
endActivity,
|
|
beginClose,
|
|
beginIdleClose,
|
|
resume,
|
|
forgetSession,
|
|
isTracked: (sessionId) => entries.has(sessionId),
|
|
hasActivity: (sessionId) => Boolean(entries.get(sessionId)?.activeCount),
|
|
isIdleCheckCurrent,
|
|
isClosing: (sessionId) => Boolean(entries.get(sessionId)?.closing),
|
|
setTimeoutMinutes,
|
|
getTimeoutMinutes: () => timeoutMinutes,
|
|
clearAll,
|
|
scopeCleared,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES,
|
|
MIN_SESSION_IDLE_TIMEOUT_MINUTES,
|
|
MAX_SESSION_IDLE_TIMEOUT_MINUTES,
|
|
normalizeSessionIdleTimeoutMinutes,
|
|
createSessionIdleManager,
|
|
};
|