mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29: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
+25
-5
@@ -1148,14 +1148,34 @@ export async function readPinCliSessionNameEnabled(): Promise<boolean> {
|
||||
/**
|
||||
* The prompt-cache keep-warm's app-wide default (`promptCacheKeepWarm`, absent = OFF) and its
|
||||
* cap (`promptCacheKeepWarmMaxHours`, hours after the last real prompt). Read FRESH like
|
||||
* {@link readPinCliSessionNameEnabled}: by the service on every evaluation, and at every claude
|
||||
* create and respawn, which decides the launch's `idleCompaction` opt-out.
|
||||
* {@link readPinCliSessionNameEnabled} at every claude create and respawn, which decides the
|
||||
* launch's `idleCompaction` opt-out.
|
||||
*/
|
||||
export async function readKeepWarmSettings(): Promise<KeepWarmSettings> {
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
|
||||
const hours = settings.promptCacheKeepWarmMaxHours;
|
||||
return keepWarmSettingsFrom(await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The same two keys for the keep-warm service, which keeps what it reads until the next
|
||||
* settings change. A missing file is the defaults, but any other read or parse failure rejects
|
||||
* rather than falling back: settings.json is rewritten in place, so a read can land mid-write,
|
||||
* and a fallback kept from it would hold keep-warm off until the next keep-warm settings change.
|
||||
*/
|
||||
export async function readKeepWarmSettingsOrThrow(): Promise<KeepWarmSettings> {
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(SETTINGS_PATH, 'utf-8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return keepWarmSettingsFrom({});
|
||||
throw err;
|
||||
}
|
||||
return keepWarmSettingsFrom(JSON.parse(text) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function keepWarmSettingsFrom(settings: Record<string, unknown>): KeepWarmSettings {
|
||||
const hours = settings?.promptCacheKeepWarmMaxHours;
|
||||
return {
|
||||
keepWarmDefault: settings.promptCacheKeepWarm === true,
|
||||
keepWarmDefault: settings?.promptCacheKeepWarm === true,
|
||||
maxHours: typeof hours === 'number' && Number.isFinite(hours) && hours > 0 ? hours : DEFAULT_KEEP_WARM_MAX_HOURS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,8 +45,13 @@ export const KEEP_WARM_LAST_CHANCE_MS = 60_000;
|
||||
export const KEEP_WARM_MIN_TOKENS = 20_000;
|
||||
/** A person typed this recently: they may be about to submit, which refreshes the cache for free. */
|
||||
export const KEEP_WARM_RECENT_INPUT_MS = 3 * 60_000;
|
||||
/** How long after sending the nudge its own stop hook and idle prompt stay pre-acknowledged. */
|
||||
export const KEEP_WARM_TURN_WINDOW_MS = 3 * 60_000;
|
||||
/**
|
||||
* Ceiling on the keep-alive window. The window normally closes on the turn's own idle prompt
|
||||
* (Claude Code sends it about a minute after the turn ends, so a slow turn must not outlast a
|
||||
* short fixed window) or earlier, at any other submit; the ceiling covers a turn whose idle
|
||||
* prompt never arrives, such as a workspace without Codeman's hooks.
|
||||
*/
|
||||
export const KEEP_WARM_TURN_WINDOW_MS = 10 * 60_000;
|
||||
/** Default cap: hours after the last real prompt during which nudges are sent. */
|
||||
export const DEFAULT_KEEP_WARM_MAX_HOURS = 2;
|
||||
/** Longest delay `setTimeout` honours; Node fires anything longer after 1 ms. */
|
||||
@@ -197,7 +202,7 @@ export interface KeepWarmPorts {
|
||||
export interface KeepWarmScheduler {
|
||||
/** Re-evaluate a session: after a cache report, a flag change, or a settings change. Resolves once the status is published. */
|
||||
schedule(sessionId: string): Promise<void>;
|
||||
/** Re-evaluate every session (the app-wide default or cap changed). */
|
||||
/** Re-read the app-wide default and cap, then re-evaluate every session (either one changed). */
|
||||
rescheduleAll(): void;
|
||||
/** The session is gone. */
|
||||
remove(sessionId: string): void;
|
||||
@@ -208,6 +213,12 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler {
|
||||
private readonly tallies = new Map<string, { nudges: number; lastNudgeAt?: number }>();
|
||||
/** One evaluation per session; `remove()` and `stop()` cancel it at its next await. */
|
||||
private readonly inFlight = new Map<string, symbol>();
|
||||
/**
|
||||
* The default and cap, read once and kept: every statusline report schedules an evaluation,
|
||||
* and reading settings.json on each would cost every install, the feature off included.
|
||||
* `rescheduleAll()` drops it, and the settings PUT calls that whenever either key changes.
|
||||
*/
|
||||
private settings: Promise<KeepWarmSettings> | undefined;
|
||||
private stopped = false;
|
||||
|
||||
constructor(private readonly ports: KeepWarmPorts) {}
|
||||
@@ -220,6 +231,7 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler {
|
||||
}
|
||||
|
||||
rescheduleAll(): void {
|
||||
this.settings = undefined;
|
||||
for (const id of this.ports.sessionIds()) void this.schedule(id);
|
||||
}
|
||||
|
||||
@@ -234,6 +246,18 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler {
|
||||
for (const id of [...this.timers.keys()]) this.clearTimer(id);
|
||||
}
|
||||
|
||||
private readSettings(): Promise<KeepWarmSettings> {
|
||||
if (!this.settings) {
|
||||
const read = this.ports.readSettings();
|
||||
this.settings = read;
|
||||
// A failed read is not kept: the next evaluation tries again.
|
||||
read.catch(() => {
|
||||
if (this.settings === read) this.settings = undefined;
|
||||
});
|
||||
}
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
private clearTimer(id: string): void {
|
||||
const t = this.timers.get(id);
|
||||
if (t) clearTimeout(t);
|
||||
@@ -258,7 +282,7 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler {
|
||||
this.clearTimer(id);
|
||||
const session = this.ports.getSession(id);
|
||||
if (!session) return;
|
||||
const settings = await this.ports.readSettings();
|
||||
const settings = await this.readSettings();
|
||||
if (cancelled()) return;
|
||||
const tally = this.tallies.get(id) ?? { nudges: 0 };
|
||||
const facts: KeepWarmFacts = {
|
||||
|
||||
+20
-2
@@ -456,6 +456,15 @@ export interface ClaudeMessage {
|
||||
duration_ms?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a programmatic write leaves the keep-alive turn running: the nudge itself, or a bare
|
||||
* Enter (which submits nothing new). Anything else carrying an Enter starts a turn of its own.
|
||||
*/
|
||||
function isKeepAliveWrite(data: string): boolean {
|
||||
const text = data.replace(/[\r\n]/g, '').trim();
|
||||
return text === '' || text === KEEP_WARM_NUDGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event signatures emitted by the Session class.
|
||||
* Subscribe using `session.on('eventName', handler)`.
|
||||
@@ -1562,8 +1571,10 @@ export class Session extends EventEmitter {
|
||||
/**
|
||||
* True while the running turn is Codeman's own keep-alive nudge: its stop hook and idle
|
||||
* prompt open pre-acknowledged, and its submit moves `lastSubmitAt` (it IS a submit, and the
|
||||
* response viewer's conversation correlation needs it) but not `lastRealPromptAt`. Any
|
||||
* `fromUser` write ends it early, so a person's prompt right after a nudge counts and alerts normally.
|
||||
* response viewer's conversation correlation needs it) but not `lastRealPromptAt`. The turn's
|
||||
* own idle prompt ends it (`endKeepAliveTurn()`), and so does any other submit: a `fromUser`
|
||||
* write, a programmatic write of anything but the nudge, or a `prompt_submitted` hook for
|
||||
* another prompt, so a prompt right after a nudge counts and alerts normally.
|
||||
*/
|
||||
get keepAliveTurnActive(): boolean {
|
||||
return Date.now() < this._keepAliveTurnUntil;
|
||||
@@ -1573,6 +1584,10 @@ export class Session extends EventEmitter {
|
||||
this._keepAliveTurnUntil = Date.now() + windowMs;
|
||||
}
|
||||
|
||||
endKeepAliveTurn(): void {
|
||||
this._keepAliveTurnUntil = 0;
|
||||
}
|
||||
|
||||
/** Last keystroke a person sent (`fromUser` write), epoch ms; 0 when none this process. */
|
||||
get lastHumanInputAt(): number {
|
||||
return this._lastHumanInputAt;
|
||||
@@ -4741,6 +4756,9 @@ export class Session extends EventEmitter {
|
||||
this._lastHumanInputAt = Date.now();
|
||||
// A person is here: whatever turn follows is theirs, not the keep-alive's.
|
||||
this._keepAliveTurnUntil = 0;
|
||||
} else if (hasEnter && !isKeepAliveWrite(data)) {
|
||||
// Anything else submitted (an agent, respawn, auto-resume) starts a turn of its own.
|
||||
this._keepAliveTurnUntil = 0;
|
||||
}
|
||||
if (hasEnter) this._stampSubmit();
|
||||
return submitted;
|
||||
|
||||
@@ -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