mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user