From cb9149879d659698d032201454b8f88441191cbe Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 20:32:41 +0200 Subject: [PATCH] restore the activity stamp across restarts: the quiet ordering no longer flattens on deploy Root cause of the reviewer's mass-bump measurement (17 of 17 sessions with an identical lastActivityAt): every restart restamps all sessions in the constructor loop, and the boot auto-attach's repaint re-bumps the rest within the same second. A 12-minute steady-state sample shows NO ambient mass bump, so restarts are the whole story, and Codeman restarts on every deploy. The stamp now has a display twin: recovery threads the previous run's lastActivityAt from state.json into the wire-visible stamp (getter + toState), and a 15s settle window keeps the attach repaint from overwriting it. Real actions (input, task assignment, respawn) always write through. The private stamp keeps its boot-anchored semantics untouched, because the idle confirmation reads it as how long the pane has been quiet. Co-Authored-By: Claude Fable 5 --- src/session.ts | 61 +++++++++++++++++++++++++++++------ src/web/server.ts | 5 +++ test/session-activity.test.ts | 42 ++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 10 deletions(-) diff --git a/src/session.ts b/src/session.ts index 17aac27c..42dcac62 100644 --- a/src/session.ts +++ b/src/session.ts @@ -135,6 +135,14 @@ const MUX_STARTUP_DELAY_MS = 300; /** Delay before declaring session idle after last output (2 seconds) */ const IDLE_DETECTION_DELAY_MS = 2000; +// How long after construction a RECOVERED session's wire activity stamp keeps +// its restored previous-run value. Recovery attaches every pane at boot and the +// attach repaint arrives as ordinary PTY output; without this window that +// repaint would overwrite every restored stamp within the same second, which is +// exactly the restart flattening the restore exists to prevent. Real actions +// (input, task assignment, respawn) always stamp through it. +const WIRE_ACTIVITY_SETTLE_MS = 15_000; + // Note: Auto-compact/clear timing constants moved to session-auto-ops.ts /** Graceful shutdown delay when stopping session (100ms) */ @@ -392,6 +400,12 @@ export class Session extends EventEmitter { private _textOutput = new BufferAccumulator(MAX_TEXT_OUTPUT_SIZE, TEXT_OUTPUT_TRIM_SIZE); private _errorBuffer: string = ''; private _lastActivityAt: number; + // Display twin of _lastActivityAt, reported by toState()/the getter. It can + // lag behind on recovery: the restored previous-run stamp survives the attach + // repaint (see _markActivity), so a restart does not flatten the home + // screens' quiet ordering. Idle detection never reads it. + private _wireActivityAt: number; + private _wireActivitySettleUntil: number; private _claudeSessionId: string | null = null; private _totalCost: number = 0; private _messages: ClaudeMessage[] = []; @@ -592,6 +606,8 @@ export class Session extends EventEmitter { attachmentHistory?: SessionAttachmentHistoryItem[]; /** Restored wall-clock ms of the pane's last Enter (see `lastSubmitAt`). */ lastSubmitAt?: number; + /** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */ + lastActivityAt?: number; /** Remote execution metadata for sessions launched through SSH inside local tmux. */ remote?: SessionRemote; /** Docker execution metadata for sessions launched inside a container via local tmux. */ @@ -620,9 +636,18 @@ export class Session extends EventEmitter { // NOW, not `createdAt`: recovery passes the ORIGINAL creation time of a // days-old tmux session, and seeding last-activity from it would report a // freshly re-attached pane as having been silent for days, which the idle - // confirmation reads as "already quiet" and the home screens print as its - // idle duration. For a genuinely new session the two are the same instant. + // confirmation reads as "already quiet". For a genuinely new session the + // two are the same instant. this._lastActivityAt = Date.now(); + // The WIRE copy of the stamp is allowed to be older: recovery threads the + // previous run's value so a restart does not flatten the home screens' + // most-recently-quiet ordering (every stamp otherwise resets to boot time, + // and the attach repaint re-bumps the rest within the same second). The + // settle window in _markActivity() carries the restored value through that + // repaint; the private stamp above stays boot-anchored because the idle + // confirmation reads it as "how long has the pane been quiet". + this._wireActivityAt = config.lastActivityAt || Date.now(); + this._wireActivitySettleUntil = config.lastActivityAt ? Date.now() + WIRE_ACTIVITY_SETTLE_MS : 0; // Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one. this._claudeSessionId = config.resumeSessionId || this.id; // Restored from state.json on boot recovery. start() resets _claudeSessionId @@ -794,7 +819,21 @@ export class Session extends EventEmitter { } get lastActivityAt(): number { - return this._lastActivityAt; + return this._wireActivityAt; + } + + /** + * Stamp activity NOW. The private stamp (idle detection's "how long has the + * pane been quiet") always moves; the wire stamp holds its restored value + * through the post-recovery attach-repaint window unless the activity is a + * real action (input, task assignment, respawn), which always writes through. + */ + private _markActivity(realAction = false): void { + this._lastActivityAt = Date.now(); + if (realAction || Date.now() >= this._wireActivitySettleUntil) { + this._wireActivityAt = this._lastActivityAt; + this._wireActivitySettleUntil = 0; + } } get claudeSessionId(): string | null { @@ -1219,7 +1258,9 @@ export class Session extends EventEmitter { parentSessionId: this._parentSessionId, currentTaskId: this._currentTaskId, createdAt: this.createdAt, - lastActivityAt: this._lastActivityAt, + // The wire twin, not the private stamp: it survives the post-recovery + // attach repaint, so the home screens' quiet ordering survives a restart. + lastActivityAt: this._wireActivityAt, name: this._name, mode: this.mode, autoClearEnabled: this._autoOps.autoClearEnabled, @@ -1585,7 +1626,7 @@ export class Session extends EventEmitter { // BufferAccumulator handles auto-trimming when max size exceeded this._terminalBuffer.append(data); - this._lastActivityAt = Date.now(); + this._markActivity(); this.emit('terminal', data); this.emit('output', data); } @@ -2484,7 +2525,7 @@ export class Session extends EventEmitter { this._messages = []; this._lineBuffer = ''; this._altScreenSeqCarry = ''; - this._lastActivityAt = Date.now(); + this._markActivity(true); } private _clearAllTimers(): void { @@ -3083,7 +3124,7 @@ export class Session extends EventEmitter { // Legacy method for sending input - wraps runPrompt async sendInput(input: string): Promise { this._status = 'busy'; - this._lastActivityAt = Date.now(); + this._markActivity(true); this.runPrompt(input).catch((err) => { const errorMsg = getErrorMessage(err); // Clean up task state so the task queue doesn't get stuck @@ -3091,7 +3132,7 @@ export class Session extends EventEmitter { const taskId = this._currentTaskId; this._currentTaskId = null; this._status = 'idle'; - this._lastActivityAt = Date.now(); + this._markActivity(true); this.emit('taskError', taskId, errorMsg); } else { this._status = 'idle'; @@ -3252,13 +3293,13 @@ export class Session extends EventEmitter { this._textOutput.clear(); this._errorBuffer = ''; this._messages = []; - this._lastActivityAt = Date.now(); + this._markActivity(true); } clearTask(): void { this._currentTaskId = null; this._status = 'idle'; - this._lastActivityAt = Date.now(); + this._markActivity(true); } getOutput(): string { diff --git a/src/web/server.ts b/src/web/server.ts index 62f1acec..49a4a503 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -2657,6 +2657,11 @@ export class WebServer extends EventEmitter { // the launch conversation until the user types again, even though // the re-attached CLI is on a post-`/clear` one. lastSubmitAt: savedState?.lastSubmitAt, + // The pane's last output, previous run's value. Without it every + // restart restamped all sessions "now" (constructor + the attach + // repaint within the same second), flattening the home screens' + // most-recently-quiet ordering to tab order after each deploy. + lastActivityAt: savedState?.lastActivityAt, // Remote SSH metadata must round-trip on recovery: without it the // attach cwd falls back to the (nonexistent-locally) remote path and // respawn rebuilds a LOCAL command, breaking the pane and silently diff --git a/test/session-activity.test.ts b/test/session-activity.test.ts index a95337f9..f67f45e3 100644 --- a/test/session-activity.test.ts +++ b/test/session-activity.test.ts @@ -246,3 +246,45 @@ describe('Session interactive idle detection', () => { expect(events).toEqual([]); }); }); + +describe('wire activity stamp across recovery', () => { + // The stamp both home screens sort the quiet group on. Recovery restores the + // previous run's value, and the settle window keeps the boot attach repaint + // (ordinary PTY output, arriving within seconds of construction) from + // restamping every session "now": measured live, a restart left 17 of 17 + // sessions with an identical lastActivityAt, which flattens the ordering to + // tab order after every deploy. + const OLD = 1_700_000_000_000; + const restored = () => + new Session({ workingDir: '/tmp', mode: 'claude', lastActivityAt: OLD } as ConstructorParameters< + typeof Session + >[0]); + + it('restores the previous-run stamp and holds it through attach-repaint output', () => { + const session = restored(); + expect(session.lastActivityAt).toBe(OLD); + (session as unknown as SessionInternals)._handleTerminalOutput('attach repaint bytes'); + expect(session.lastActivityAt).toBe(OLD); + expect(session.toState().lastActivityAt).toBe(OLD); + }); + + it('a real action writes through the settle window', () => { + const session = restored(); + session.assignTask('t1'); + expect(session.lastActivityAt).toBeGreaterThan(OLD); + }); + + it('output after the window moves the stamp normally', () => { + const session = restored(); + (session as unknown as { _wireActivitySettleUntil: number })._wireActivitySettleUntil = Date.now() - 1; + (session as unknown as SessionInternals)._handleTerminalOutput('real output'); + expect(session.lastActivityAt).toBeGreaterThan(OLD); + }); + + it('a fresh session has no window: first output stamps immediately', () => { + const before = Date.now(); + const session = new Session({ workingDir: '/tmp', mode: 'claude' }); + (session as unknown as SessionInternals)._handleTerminalOutput('x'); + expect(session.lastActivityAt).toBeGreaterThanOrEqual(before); + }); +});