diff --git a/src/main/agent-hooks/server/program-status/hook-precedence.test.ts b/src/main/agent-hooks/server/program-status/hook-precedence.test.ts index cb3c34fa9f6..c873db8f785 100644 --- a/src/main/agent-hooks/server/program-status/hook-precedence.test.ts +++ b/src/main/agent-hooks/server/program-status/hook-precedence.test.ts @@ -70,6 +70,16 @@ describe('programStatusHookYieldEndsAt', () => { } }) + it("never lets a program's child roster reopen a turn the hooks settled", () => { + const hook = row({ state: 'done', agentType: 'pi' }) + const heldOpen = report({ + state: 'working', + agentType: 'pi', + mainAgent: { state: 'done', outcome: 'success', stateStartedAt: NOW } + }) + expect(programStatusHookYieldEndsAt(hook, heldOpen, NOW + 1)).toBe(Infinity) + }) + it('keeps a question the hooks raised only from a working report that began before it', () => { for (const state of ['waiting', 'blocked'] as const) { const hook = row({ state, agentType: 'pi' }) diff --git a/src/main/agent-hooks/server/program-status/hook-precedence.ts b/src/main/agent-hooks/server/program-status/hook-precedence.ts index 22bb08bfc66..df80f7f8bbe 100644 --- a/src/main/agent-hooks/server/program-status/hook-precedence.ts +++ b/src/main/agent-hooks/server/program-status/hook-precedence.ts @@ -1,4 +1,5 @@ import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' +import { isAgentStatusHeldOpenByChildWork } from '../../../../shared/agent-lead-status-fold' import type { EnrichedAgentHookEventPayload } from '../server-types' // Long tool calls can be hook-silent; allow sustained disagreement before yielding to OSC 7501. @@ -19,7 +20,11 @@ export function programStatusHookYieldEndsAt( if (!sameAgent && previous.payload.state === 'done') { return undefined } - if (payload.state === previous.payload.state) { + // Why: the hook lane tracks its own children; a program's child roster must not reopen the hook's settled turn. + if ( + payload.state === previous.payload.state || + (previous.payload.state === 'done' && isAgentStatusHeldOpenByChildWork(payload)) + ) { return Infinity } // Settled turns are naturally silent; a later wait must prove intervening program work. diff --git a/src/main/runtime/terminal/output/terminal-derived-agent-facts.ts b/src/main/runtime/terminal/output/terminal-derived-agent-facts.ts index 8060adcd50a..e12cd4f624b 100644 --- a/src/main/runtime/terminal/output/terminal-derived-agent-facts.ts +++ b/src/main/runtime/terminal/output/terminal-derived-agent-facts.ts @@ -13,9 +13,15 @@ import { mapExplicitAgentStateToRuntimeTerminalStatus } from '../../runtime-work import type { RuntimeTerminalAgentStatusEvent } from '../../runtime-terminal-contracts' import { programStatusStateOutlivesProgram } from '../../../../shared/program-status/protocol' import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' +import { isAgentStatusHeldOpenByChildWork } from '../../../../shared/agent-lead-status-fold' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-freshness' +import { lapsedProgramStatusChildHold } from '../../../../shared/program-status/agent-status-projector' // Why: programs report only changes, so a long `working` renews its row inside AGENT_STATUS_STALE_AFTER_MS. const PROGRAM_STATUS_LEASE_RENEWAL_MS = 10 * 60 * 1000 +// Why: the last renewal tick before the row would decay settles an unrefreshed child hold. +const PROGRAM_STATUS_CHILD_HOLD_LAPSE_MS = + AGENT_STATUS_STALE_AFTER_MS - PROGRAM_STATUS_LEASE_RENEWAL_MS type TerminalAgentStatusTarget = Pick< RuntimeTerminalAgentStatusEvent, @@ -26,7 +32,10 @@ export abstract class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector abstract getAgentStatusTerminalHandleForPaneKey(paneKey: string): string | undefined /** Each live OSC 7501 program's latest row payload; renewed while its PTY keeps a live facet. */ - private readonly liveProgramStatusPayloadByPtyId = new Map() + private readonly liveProgramStatusPayloadByPtyId = new Map< + string, + { payload: ParsedAgentStatusPayload; trackedAt: number } + >() private programStatusLeaseTimer: ReturnType | null = null protected createTerminalSideEffectCommandCodeDetector( @@ -178,7 +187,7 @@ export abstract class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector this.liveProgramStatusPayloadByPtyId.delete(ptyId) return } - this.liveProgramStatusPayloadByPtyId.set(ptyId, payload) + this.liveProgramStatusPayloadByPtyId.set(ptyId, { payload, trackedAt: Date.now() }) if (this.programStatusLeaseTimer === null) { this.programStatusLeaseTimer = setInterval( () => this.renewProgramStatusLeases(), @@ -189,9 +198,29 @@ export abstract class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector } private renewProgramStatusLeases(): void { - for (const [ptyId, payload] of this.liveProgramStatusPayloadByPtyId) { + for (const [ptyId, { payload, trackedAt }] of this.liveProgramStatusPayloadByPtyId) { + if (!this.ptysById.has(ptyId)) { + this.liveProgramStatusPayloadByPtyId.delete(ptyId) + continue + } + // A child-held row outlives its done root's facet until children report or the hold lapses. + if (isAgentStatusHeldOpenByChildWork(payload)) { + const lapsed = lapsedProgramStatusChildHold( + payload, + trackedAt, + Date.now(), + PROGRAM_STATUS_CHILD_HOLD_LAPSE_MS + ) + if (lapsed) { + this.emitTerminalAgentStatusEvents(ptyId, { + payloads: [lapsed], + programStatusPayloads: new Set([lapsed]) + }) + } + continue + } // The live facet set is the authority on whether the program still runs. - if (!this.liveProgramStatusFacetPtyIds.has(ptyId) || !this.ptysById.has(ptyId)) { + if (!this.liveProgramStatusFacetPtyIds.has(ptyId)) { this.liveProgramStatusPayloadByPtyId.delete(ptyId) continue } diff --git a/src/shared/program-status/agent-status-projector.test.ts b/src/shared/program-status/agent-status-projector.test.ts index edb5b8d44a8..b043fe3659f 100644 --- a/src/shared/program-status/agent-status-projector.test.ts +++ b/src/shared/program-status/agent-status-projector.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { createProgramStatusProjector } from './agent-status-projector' +import { + createProgramStatusProjector, + lapsedProgramStatusChildHold +} from './agent-status-projector' const b64 = (text: string): string => Buffer.from(text, 'utf8').toString('base64') const osc = (body: string): string => `\x1b]7501;${body}\x1b\\` @@ -120,6 +123,59 @@ describe('createProgramStatusProjector', () => { expect(cleared.subagents).toBeUndefined() }) + it("keeps a finished turn working while a child runs, stamping the turn's own end for both rows", () => { + const { feed, tick } = projector() + feed(osc('state=working:app=pi') + osc('state=working:id=subagents/run1:app=pi-subagents')) + tick(10) + expect(feed(osc('state=done:app=pi'))).toMatchObject([ + { + state: 'working', + turnCompletedAt: 1010, + mainAgent: { state: 'done', outcome: 'success', stateStartedAt: 1010 }, + subagents: [{ id: 'subagents/run1', state: 'working' }] + } + ]) + tick(10) + const [settled] = feed(osc('state=done:id=subagents/run1:app=pi-subagents')) + expect(settled).toMatchObject({ + state: 'done', + turnCompletedAt: 1010, + mainAgent: { state: 'done', outcome: 'success', stateStartedAt: 1010 } + }) + expect(settled.subagents).toBeUndefined() + }) + + it("lapses a hold no child refreshed to the lead's done, keeping its completion stamp", () => { + const { feed } = projector() + feed(osc('state=working:app=pi') + osc('state=working:id=subagents/run1')) + const [held] = feed(osc('state=done:app=pi')) + expect(lapsedProgramStatusChildHold(held, 0, 999, 1000)).toBeNull() + const lapsed = lapsedProgramStatusChildHold(held, 0, 1000, 1000) + expect(lapsed).toMatchObject({ + state: 'done', + turnCompletedAt: held.turnCompletedAt, + mainAgent: held.mainAgent + }) + expect(lapsed?.subagents).toBeUndefined() + const [settled] = feed(osc('state=done:id=subagents/run1')) + expect(lapsedProgramStatusChildHold(settled, 0, 1000, 1000)).toBeNull() + }) + + it('leaves live turns, failed children and an idle root as their root reports them', () => { + const { feed } = projector() + const last = (data: string) => feed(data).at(-1) + expect( + last(osc('state=working:app=claude-code') + osc('state=blocked:kind=question:id=task')) + ).toMatchObject({ state: 'working', subagents: [{ state: 'waiting' }] }) + const failedChild = last(osc('state=blocked:id=task') + osc('state=done:app=claude-code')) + expect(failedChild).toMatchObject({ state: 'done', subagents: [{ state: 'blocked' }] }) + expect(failedChild?.turnCompletedAt).toBeUndefined() + const resumed = projector() + expect( + resumed.feed(osc('state=idle:app=pi') + osc('state=working:id=subagents/run1')).at(-1) + ).toMatchObject({ state: 'done', sessionBoundary: true }) + }) + it('reads a clear during live work as the program leaving, not a cancelled turn', () => { const { feed } = projector() feed(osc('state=working:app=claude-code')) diff --git a/src/shared/program-status/agent-status-projector.ts b/src/shared/program-status/agent-status-projector.ts index 56f4c0a1310..0f810d73748 100644 --- a/src/shared/program-status/agent-status-projector.ts +++ b/src/shared/program-status/agent-status-projector.ts @@ -3,6 +3,8 @@ import type { AgentStatusState, ParsedAgentStatusPayload } from '../agent-status import type { AgentSubagentSnapshot, AgentSubagentState } from '../agent-status-subagent-snapshot' import { AGENT_SUBAGENT_ID_MAX_LENGTH } from '../agent-status-subagent-snapshot' import type { AgentTurnOutcome } from '../agent-turn-outcome' +import { foldMainAgentWithRowChildWork } from '../agent-hook-row-child-work-fold' +import { isAgentStatusHeldOpenByChildWork } from '../agent-lead-status-fold' import { createProgramStatusOscScanner } from './osc-scanner' import { programStatusDisplayText, @@ -45,6 +47,10 @@ function blockedState(record: ProgramStatusRecord): 'waiting' | 'blocked' { return isTurnScopedProgramStatusKind(record.kind) ? 'waiting' : 'blocked' } +function isRowHoldingChild(child: AgentSubagentSnapshot): boolean { + return child.state === 'working' || child.state === 'waiting' +} + function subagentState(record: ProgramStatusRecord): AgentSubagentState | null { switch (record.state) { case 'working': @@ -77,6 +83,24 @@ export type ProgramStatusPayload = { removesRow?: true } +/** Children report only changes, so a hold none refreshed within `lapseAfterMs` settles to the lead's verdict; its stamp keeps that quiet. */ +export function lapsedProgramStatusChildHold( + payload: ParsedAgentStatusPayload, + heldSince: number, + now: number, + lapseAfterMs: number +): ParsedAgentStatusPayload | null { + if ( + !payload.mainAgent || + !isAgentStatusHeldOpenByChildWork(payload) || + now - heldSince < lapseAfterMs + ) { + return null + } + const { subagents: _subagents, ...lead } = payload + return { ...lead, state: payload.mainAgent.state } +} + type ProgramStatusProjector = { /** Return lifecycle payloads/root changes in chunk order (including unchanged store projections); facet is new, null when removed, * or absent when unchanged. */ @@ -93,6 +117,8 @@ export function createProgramStatusProjector(now: () => number = Date.now): Prog let lastRoot: RootProjection | null = null let lastKey: string | null = null let rootStateStartedAt = 0 + // Set once children hold this root's done open; that row and its all-clear share one completion stamp. + let rootDoneHeldOpen = false let lastFacet: ProgramStatusFacet | undefined // Set when the program ends; the next projection starts a new invocation. let invocationEnded = false @@ -176,14 +202,23 @@ export function createProgramStatusProjector(now: () => number = Date.now): Prog projection.outcome !== lastRoot?.outcome ) { rootStateStartedAt = now() + rootDoneHeldOpen = false lastKey = null } lastRoot = projection const agentType = agentTypeForProgramStatusApp(root?.app) const msg = programStatusDisplayText(root?.msg) const subagents = snapshotChildren() + // Why: async children outlive a finished turn (pi-subagents); failed/idle children and boundaries hold nothing. + const state = + projection.state === 'done' && projection.sessionBoundary !== true + ? foldMainAgentWithRowChildWork('done', { + payload: { subagents: subagents.filter(isRowHoldingChild) } + }).stateName + : projection.state + rootDoneHeldOpen ||= projection.state === 'done' && state !== 'done' const payload: ParsedAgentStatusPayload = { - state: projection.state, + state, prompt: '', ...(agentType ? { agentType } : {}), // Live work names what it is doing or waiting on; a finished turn names its result. @@ -193,6 +228,8 @@ export function createProgramStatusProjector(now: () => number = Date.now): Prog : {}), ...(projection.sessionBoundary ? { sessionBoundary: true } : {}), ...(subagents.length > 0 ? { subagents } : {}), + // Hook rows announce a held turn's own done now; the all-clear restates it so it is not announced twice. + ...(rootDoneHeldOpen ? { turnCompletedAt: rootStateStartedAt } : {}), mainAgent: { state: projection.state, ...(projection.outcome ? { outcome: projection.outcome } : {}),