fix(sessions): leave a pending idle alert to the user through a keep-alive turn

Only a human spends an idle alert, but the keep-alive turn resolved an unseen one three ways: its working transition, its stop hook, and its own idle prompt replacing the item with a pre-acknowledged one. While the turn runs, a pending idle item is now left exactly as found, the page's stop handler returns on the keep-alive reason before clearing hooks or raising Response Complete, and session:working/session:idle carry keepAlive so the stuck-detection timer neither restarts nor re-arms.

The window that marks the turn closes on its own idle prompt or any other submit instead of a fixed three minutes, which a slow turn's idle prompt (sent a minute after the turn ends) could outlast. The service keeps the default and cap in memory, read through a strict reader so a mid-write read is retried rather than kept, and the cap is clamped before the strict settings save.
This commit is contained in:
JD committed 2026-10-19 01:42:00 -04:00
1 parent 71a51df2e2
commit 5db6c633d3
22 files changed
+441 -41

No files matched your search

+7 -4
View File
@@ -3470,8 +3470,10 @@ class CodemanApp {
this.sendPendingCtrlL(data.id);
if (data.id === this.activeSessionId) this._updateLocalEchoState();
}
// Start stuck detection timer (only if no respawn running)
if (!this.respawnStatus[data.id]?.enabled) {
// Start stuck detection timer (only if no respawn running). Not at the end of Codeman's
// own keep-alive turn (`keepAlive`): the session has been waiting since the last real
// turn, whose timer is still running or has already fired.
if (!data.keepAlive && !this.respawnStatus[data.id]?.enabled) {
const threshold = this.notificationManager?.preferences?.stuckThresholdMs || 600000;
clearTimeout(this.idleTimers.get(data.id));
this.idleTimers.set(data.id, setTimeout(() => {
@@ -3493,8 +3495,9 @@ class CodemanApp {
this.sendPendingCtrlL(data.id);
if (data.id === this.activeSessionId) this._updateLocalEchoState();
}
// Clear stuck detection timer
const timer = this.idleTimers.get(data.id);
// Clear stuck detection timer, unless this is Codeman's own keep-alive turn, which is
// no sign of life from the agent's side.
const timer = data.keepAlive ? null : this.idleTimers.get(data.id);
if (timer) {
clearTimeout(timer);
this.idleTimers.delete(data.id);
+11 -5
View File
@@ -778,15 +778,21 @@ function formatKeepWarmStatus(status, now, statusLine) {
? `stops ${clock(status.budgetEndsAt)}`
: 'window closed'
: '';
// Keep-warm switched on after launch: the `idleCompaction` opt-out rides only a launch.
// Keep-warm switched on or off after launch: the `idleCompaction` opt-out rides only a launch.
const idleCompaction =
status.enabled && status.idleCompactionOff === false ? 'idle compaction on until relaunch' : '';
status.enabled && status.idleCompactionOff === false
? 'idle compaction on until relaunch'
: !status.enabled && status.idleCompactionOff === true
? 'idle compaction off until relaunch'
: '';
let parts;
switch (status.state) {
case 'off':
if (!status.reason || status.reason === 'off') return '';
parts = ['off', status.reason];
case 'off': {
const reason = status.reason === 'off' ? '' : status.reason;
if (!reason && !idleCompaction) return '';
parts = ['off', reason, idleCompaction];
break;
}
case 'armed':
parts = [`next ${clock(status.nextCheckAt)}`, window, idleCompaction];
break;
+6 -1
View File
@@ -72,6 +72,10 @@ Object.assign(CodemanApp.prototype, {
},
_onHookStop(data) {
// The end of Codeman's own keep-alive turn (prompt-cache keep-warm): nothing finished
// that a person asked for, and an idle alert still pending is theirs to spend, so this
// neither clears it nor raises "Response Complete".
if (data.acknowledgedReason) return;
// Clear all pending hooks when Claude finishes responding
if (data.sessionId) {
this.clearPendingHooks(data.sessionId);
@@ -2765,7 +2769,8 @@ Object.assign(CodemanApp.prototype, {
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
promptCacheKeepWarm: document.getElementById('appSettingsPromptCacheKeepWarm').checked,
promptCacheKeepWarmMaxHours:
parseFloat(document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value) || 2,
// Clamped to the schema's range: one value outside it fails the whole save.
Math.min(24, Math.max(0.5, parseFloat(document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value) || 2)),
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked,
+12 -2
View File
@@ -190,8 +190,15 @@ export function registerHookEventRoutes(
const keepAliveReason =
session?.keepAliveTurnActive && (event === 'stop' || event === 'idle_prompt') ? 'cache keep-alive' : undefined;
const approvalKind = APPROVAL_KIND_BY_EVENT[event];
// Only a human spends an idle alert, so a keep-alive turn leaves a pending idle item exactly
// as it found it: its stop does not resolve it, and its idle prompt does not replace it (a
// replacement would forget the item was unseen, or that a person had seen it). With no idle
// item pending, the keep-alive's idle prompt opens a pre-acknowledged one.
const keepsPendingIdle = keepAliveReason !== undefined && approvalInbox.getForSession(sessionId)?.kind === 'idle';
if (session && hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
if (approvalKind) {
if (approvalKind === 'idle' && keepsPendingIdle) {
// Left in place: no new item, and the broadcast below carries the keep-alive reason.
} else if (approvalKind) {
const toolInput =
safeData.tool_input && typeof safeData.tool_input === 'object'
? (safeData.tool_input as Record<string, unknown>)
@@ -226,12 +233,15 @@ export function registerHookEventRoutes(
});
approvalId = item.id;
acknowledgedReason = item.acknowledgedReason;
} else if (APPROVAL_RESOLVING_EVENTS.has(event)) {
} else if (APPROVAL_RESOLVING_EVENTS.has(event) && !keepsPendingIdle) {
approvalInbox.resolveForSession(sessionId, 'resolved_in_terminal');
}
}
acknowledgedReason ??= keepAliveReason;
// The keep-alive turn's own idle prompt is the last thing it sends, so the window ends here
// rather than on a fixed clock that a slow turn could outlast.
if (keepAliveReason && event === 'idle_prompt') session?.endKeepAliveTurn();
ctx.broadcast(`hook:${event}`, {
sessionId,
+2 -2
View File
@@ -1111,8 +1111,8 @@ export function registerSystemRoutes(
// drift. Same convention as the tmuxHistoryLimit block above.
// Handle subagent tracking toggle dynamically
toggleService((merged.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
// The prompt-cache keep-warm reads its default and cap fresh on every decision; a changed
// default or cap re-evaluates every session now rather than at its next cache report.
// The prompt-cache keep-warm keeps its default and cap in memory; a changed default or cap
// makes it re-read them and re-evaluate every session now rather than at its next cache report.
if ('promptCacheKeepWarm' in settingsToStore || 'promptCacheKeepWarmMaxHours' in settingsToStore) {
ctx.keepWarm.rescheduleAll();
}
+3 -3
View File
@@ -1871,13 +1871,13 @@ export const AutoCompactSchema = z.object({
});
/** POST /api/sessions/:id/auto-resume */
/** `POST /api/sessions/:id/keep-warm`: `null` clears the session's choice so the app-wide default applies. */
export const KeepWarmSchema = z.object({ enabled: z.boolean().nullable() });
export const AutoResumeSchema = z.object({
enabled: z.boolean(),
});
/** `POST /api/sessions/:id/keep-warm`: `null` clears the session's choice so the app-wide default applies. */
export const KeepWarmSchema = z.object({ enabled: z.boolean().nullable() });
/** POST /api/sessions/:id/pin (COD-139) — explicit pin state for idempotency. */
export const PinSessionSchema = z.object({
pinned: z.boolean(),
+2 -2
View File
@@ -99,7 +99,7 @@ import { getLifecycleLog } from '../session-lifecycle-log.js';
import {
applyWorkspaceHooks,
pruneAgentSessionPreambles,
readKeepWarmSettings,
readKeepWarmSettingsOrThrow,
removeAgentSessionPreamble,
} from '../hooks-config.js';
import { PushSubscriptionStore } from '../push-store.js';
@@ -332,7 +332,7 @@ export class WebServer extends EventEmitter {
private readonly keepWarmService = new PromptCacheKeepWarmService({
getSession: (id) => this.sessions.get(id),
sessionIds: () => this.sessions.keys(),
readSettings: readKeepWarmSettings,
readSettings: readKeepWarmSettingsOrThrow,
isAutomationActive: (id) => {
const session = this.sessions.get(id);
if (!session) return true;
+8 -3
View File
@@ -240,7 +240,8 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
// kind: `working` is heuristic and can flap mid-turn, so clearing a
// pending permission/question dialog on the signal ALONE would
// false-clear real approvals.
approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']);
// Not for Codeman's own keep-alive turn: only a human spends an idle alert.
if (!session.keepAliveTurnActive) approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']);
// A permission/question dialog gets the pane-VERIFIED variant instead:
// the signal only decides when to look, `verifyStillAnswerable` re-reads
// the screen and resolves only when the dialog is really gone. Without
@@ -250,7 +251,11 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
// open. `stop` was the first thing to clear it, which on a long turn is
// minutes away.
approvalInbox.resolveIfDialogGone(session.id);
deps.broadcast(SseEvent.SessionWorking, { id: session.id });
// `keepAlive` tells the page this turn is Codeman's own keep-alive (stuck detection ignores it).
deps.broadcast(SseEvent.SessionWorking, {
id: session.id,
...(session.keepAliveTurnActive && { keepAlive: true }),
});
// Full state ride-along: the home screens sort the running group on
// lastSubmitAt, and without this the browser keeps the stamp it loaded
// with (a turn started after page load ranks by the PREVIOUS turn's
@@ -266,7 +271,7 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
/** Broadcasts `session:idle` — Claude finished processing, waiting for input */
idle: () => {
sessionWaits.notifySignal(session.id, 'idle');
deps.broadcast(SseEvent.SessionIdle, { id: session.id });
deps.broadcast(SseEvent.SessionIdle, { id: session.id, ...(session.keepAliveTurnActive && { keepAlive: true }) });
deps.broadcastSessionStateDebounced(session.id);
deps.keepWarmSchedule?.(session.id);
const tracker = deps.getRunSummaryTracker(session.id);