diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index 849286009f0..e1cd456abed 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -149,8 +149,6 @@ export type RuntimePtyTitleTrackerEntry = { commandCodeDetector: { observe: (data: string) => boolean } | null /** The program's OSC 7501 activity; while set it, not the title glyph, is the PTY's status. */ programActivity: AgentStatus | null - /** Wall clock of the last programActivity change. */ - programActivitySince: number | null /** The last observed title, so a program's activity change can re-classify it. */ lastTitle: { raw: string; normalized: string } | null } diff --git a/src/main/runtime/terminal/output/terminal-program-activity.ts b/src/main/runtime/terminal/output/terminal-program-activity.ts index fc2488ef8e3..bbfca83840f 100644 --- a/src/main/runtime/terminal/output/terminal-program-activity.ts +++ b/src/main/runtime/terminal/output/terminal-program-activity.ts @@ -9,9 +9,6 @@ export abstract class OrcaRuntimeWithApplyPtyProgramActivity extends OrcaRuntime if (!entry) { return } - if (entry.programActivity !== activity) { - entry.programActivitySince = Date.now() - } entry.programActivity = activity this.observeClaudeTerminalEvidence(ptyId, { kind: 'activity', status: activity }) const pty = this.ptysById.get(ptyId) diff --git a/src/main/runtime/terminal/output/terminal-title-tracker.ts b/src/main/runtime/terminal/output/terminal-title-tracker.ts index 6b8f049c429..75948b2dc9b 100644 --- a/src/main/runtime/terminal/output/terminal-title-tracker.ts +++ b/src/main/runtime/terminal/output/terminal-title-tracker.ts @@ -198,7 +198,6 @@ export abstract class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends Or ? this.createTerminalSideEffectCommandCodeDetector(ptyId) : null, programActivity: null, - programActivitySince: null, // The persisted title is already normalized, so it stands for both forms. lastTitle: initialTitle !== null ? { raw: initialTitle, normalized: initialTitle } : null } diff --git a/src/main/runtime/terminal/wait/terminal-wait-permission.ts b/src/main/runtime/terminal/wait/terminal-wait-permission.ts index d2ebd656017..5728d1896ea 100644 --- a/src/main/runtime/terminal/wait/terminal-wait-permission.ts +++ b/src/main/runtime/terminal/wait/terminal-wait-permission.ts @@ -190,7 +190,6 @@ export abstract class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission return null } const { handles, paneKeys, hookRows } = join - const tracker = this.ptyTitleTrackersByPtyId.get(ptyId) return readTuiIdleHookTurn({ agent, handles, @@ -201,8 +200,7 @@ export abstract class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission launchToken: pty.launchToken, titleObservedAtEpochMs: pty.lastOscTitleEpochMs, hasExplicitIdleTitle: hasExplicitIdleTitle(pty), - programWorkingSince: - tracker?.programActivity === 'working' ? tracker.programActivitySince : null, + programWorking: this.ptyTitleTrackersByPtyId.get(ptyId)?.programActivity === 'working', respawnedAt: this.agentPromptExplicitStatusFloorByPtyId.get(ptyId), lastInputAt: this.terminalRunFacts.readLastInputAt(ptyId), tailIncludes: (text) => retainedTailIncludes(pty.tailBuffer, pty.tailPartialLine, text), diff --git a/src/main/runtime/tui-idle-hook-lane.test.ts b/src/main/runtime/tui-idle-hook-lane.test.ts index 894a7a07e97..66eb8f2261e 100644 --- a/src/main/runtime/tui-idle-hook-lane.test.ts +++ b/src/main/runtime/tui-idle-hook-lane.test.ts @@ -178,34 +178,33 @@ describe('readTuiIdleHookTurn', () => { ).toEqual({ state: 'permission', blockedReason: null }) }) - it("marks a done newer than the program's working report, and joins Agent Teams to claude's row", () => { - const hookRow = row({ agentType: 'claude' }) - const { receivedAt } = hookRow - const read = (programWorkingSince: number | null) => + it("marks a live turn end under program work, and joins Agent Teams to claude's row", () => { + const transition = (kind: 'transition' | 'snapshot', launchToken = 'launch-1') => + row({ + agentType: 'claude', + launchToken, + observation: { + origin: 'hook', + authorityId: 'local', + incarnation: 1, + revision: 1, + observedAt: 1, + kind + } + }) + const read = (hookRow: AgentStatusIpcPayload, programWorking = true) => readTuiIdleHookTurn({ ...base, agent: 'claude-agent-teams', + launchToken: 'launch-1', hookRows: [hookRow], - programWorkingSince + programWorking }) - expect(read(receivedAt - 1000)).toEqual({ ...DONE, endedUnderProgramWork: true }) - // A child's hook restamps the row, but the lead's turn still ended before the program's report. - const childChurn = row({ - agentType: 'claude', - state: 'working', - mainAgent: { state: 'done', stateStartedAt: receivedAt - 2000 } - }) - expect( - readTuiIdleHookTurn({ - ...base, - agent: 'claude', - hookRows: [childChurn], - programWorkingSince: receivedAt - 1000 - }) - ).toEqual(DONE) - // Work the program reported after the turn end, or no working report at all, is not outlasted. - expect(read(receivedAt + 1)).toEqual(DONE) - expect(read(null)).toEqual(DONE) + expect(read(transition('transition'))).toEqual({ ...DONE, endedUnderProgramWork: true }) + // A replayed done restates an old turn; another launch's done speaks for its predecessor. + expect(read(transition('snapshot'))).toEqual(DONE) + expect(read(transition('transition', 'launch-0'))).toEqual(DONE) + expect(read(transition('transition'), false)).toEqual(DONE) }) it('takes the newest joined row', () => { diff --git a/src/main/runtime/tui-idle-hook-lane.ts b/src/main/runtime/tui-idle-hook-lane.ts index f8e116e00c2..c318e53f3a5 100644 --- a/src/main/runtime/tui-idle-hook-lane.ts +++ b/src/main/runtime/tui-idle-hook-lane.ts @@ -14,8 +14,8 @@ export type TuiIdleHookTurn = { taskWakeupPending?: true /** When a fresh `done`'s turn has had time to paint its last frame. */ finalFrameAt?: number - /** A clear turn end newer than the program's ongoing `working` report, which then covers work - * beyond the lead's turn (e.g. live teammates) rather than the turn itself. */ + /** A live turn end while the program still reports `working`, which then covers work beyond + * the lead's turn (e.g. live teammates) rather than the turn itself. */ endedUnderProgramWork?: true } @@ -30,13 +30,6 @@ function hookReportingAgent(agent: TuiAgent | null | undefined): TuiAgent | null return agent === 'claude-agent-teams' ? 'claude' : agent } -/** When the lead's own turn ended, on this runtime's clock: child events restamp `receivedAt`, and - * a remote host stamps the turn on its own clock. */ -function leadTurnEndedAt(row: AgentStatusIpcPayload): number { - const ended = row.turnEndedAt ?? row.mainAgent?.stateStartedAt ?? row.stateStartedAt - return ended + row.receivedAt - (row.evidenceObservedAt ?? row.receivedAt) -} - /** Scans newest-first so the common hit (this turn's plan) returns early. */ export function retainedTailIncludes( lines: readonly string[], @@ -86,8 +79,8 @@ export type TuiIdleHookTurnRead = { * the turn that input may have started (or the agent it restarted), whose first hook can still * be in flight. */ lastInputAt?: number | null - /** When the program's current OSC 7501 `working` report began; null while it reports none. */ - programWorkingSince?: number | null + /** Whether the program currently reports OSC 7501 `working`. */ + programWorking?: boolean /** Whether the pane's retained output holds `text`; read only while a fresh `done` may precede a menu. */ tailIncludes?: (text: string) => boolean /** The existing permission arbiter, given the turn as the pane's explicit status. */ @@ -110,6 +103,11 @@ export function readTuiIdleHookTurn(read: TuiIdleHookTurnRead): TuiIdleHookTurn return null } const blockedReason = read.resolveBlockedText(state, row) + // Why: an older host or launch's late hook speaks for its predecessor, not this process. + const ownHook = + row.observation?.origin === 'hook' && + terminalHostConnectionMatches(row.connectionId, read.connectionId ?? null, read.wslDistro) && + (!read.launchToken || row.launchToken === read.launchToken) // Why the hook alone blocks: a question or custom modal paints no dialog text the arbiter knows, // and these hooks report its answer. Input since may have answered it before the hook arrived. const taskWakeupPending = @@ -122,23 +120,23 @@ export function readTuiIdleHookTurn(read: TuiIdleHookTurnRead): TuiIdleHookTurn typeof read.titleObservedAtEpochMs === 'number' && read.titleObservedAtEpochMs > (row.turnStartedAt ?? row.receivedAt) ))) && - row.observation?.origin === 'hook' && - row.providerSession?.key === 'session_id' && - terminalHostConnectionMatches(row.connectionId, read.connectionId ?? null, read.wslDistro) && - (!read.launchToken || row.launchToken === read.launchToken) + ownHook && + row.providerSession?.key === 'session_id' const finalFrameAt = row.receivedAt + TURN_END_FINAL_FRAME_MS const finalFramePending = state === 'done' && hookAuthority(read.agent) === 'turn-end' && Date.now() < finalFrameAt && read.tailIncludes?.(CODEX_PROPOSED_PLAN_HEADER) === true + // Why a transition: a replay or snapshot restates an old turn end and cannot prove this one ended. const endedUnderProgramWork = state === 'done' && + read.programWorking === true && + ownHook && + row.observation?.kind === 'transition' && !taskWakeupPending && !finalFramePending && - blockedReason === null && - typeof read.programWorkingSince === 'number' && - leadTurnEndedAt(row) > Math.max(read.programWorkingSince, read.lastInputAt ?? -1) + blockedReason === null return { state, ...(taskWakeupPending ? { taskWakeupPending: true as const } : {}),