mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
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:
1 parent
71a51df2e2
commit
5db6c633d3
22 files changed
+441
-41
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user