fix(program-status): keep a Pi row working while a pi-subagents child outlives the parent's done (#33738)

* fix(program-status): keep a Pi row working while a pi-subagents child outlives the parent's done

OSC 7501 projected the root record's done straight onto the row even when
live child records remained, so a Pi parent showed Done/IDLE while an async
pi-subagents worker kept running. Fold child liveness with the existing
foldMainAgentWithRowChildWork; mainAgent keeps the lead's own verdict so the
row settles to done once the last child ends.

Fixes #33657

* fix(program-status): announce a held turn's done on time and keep hook verdicts authoritative

- Stamp turnCompletedAt on the child-held row and its all-clear so the lead's
  done is announced once, when it happens, as hook rows do.
- Hold only a finished (non-boundary) turn, only for working/waiting children;
  in-turn rows and failed/idle children keep the root's state.
- A settled hook row is never reopened by a program's child-held working row,
  so the 2-minute silence fallback cannot flip done -> working -> done.

* fix(program-status): lapse an unrefreshed child hold to the lead's done

Children report only changes, so a pi-subagents that dies or hangs inside a
live Pi would hold the row working until decay. The lease timer now settles a
child-held row no report refreshed within AGENT_STATUS_STALE_AFTER_MS minus one
renewal interval (20-30 min) to the lead's done, keeping turnCompletedAt so it
is not announced again.
This commit is contained in:
Neil
2026-10-25 20:24:34 -07:00
committed by GitHub
parent dfde70b519
commit d5ee28d716
5 changed files with 144 additions and 7 deletions
@@ -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' })
@@ -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.
@@ -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<string, ParsedAgentStatusPayload>()
private readonly liveProgramStatusPayloadByPtyId = new Map<
string,
{ payload: ParsedAgentStatusPayload; trackedAt: number }
>()
private programStatusLeaseTimer: ReturnType<typeof setInterval> | 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
}
@@ -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'))
@@ -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 } : {}),