mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 18:17:36 +00:00
* 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.
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
// Projects OSC 7501 records onto agent-status payloads so they ride the OSC 9999 terminal-status ingress.
|
|
import type { AgentStatusState, ParsedAgentStatusPayload } from '../agent-status-types'
|
|
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,
|
|
programStatusFacetFromRecord,
|
|
programStatusFacetsEqual,
|
|
type ProgramStatusFacet
|
|
} from './facet'
|
|
import { isTurnScopedProgramStatusKind } from './protocol'
|
|
import { ProgramStatusRecordSet, type ProgramStatusRecord } from './records'
|
|
|
|
/** OSC 7501 `app` ids whose Orca agent id differs. Other apps pass through verbatim. */
|
|
const APP_TO_AGENT_TYPE: Readonly<Record<string, string>> = {
|
|
'claude-code': 'claude',
|
|
'grok-build': 'grok'
|
|
}
|
|
|
|
function agentTypeForProgramStatusApp(app: string | undefined): string | undefined {
|
|
if (app === undefined) {
|
|
return undefined
|
|
}
|
|
// Why hasOwn: `app=constructor` must not resolve to an Object.prototype member.
|
|
return Object.hasOwn(APP_TO_AGENT_TYPE, app) ? APP_TO_AGENT_TYPE[app] : app
|
|
}
|
|
|
|
/** A child id within the snapshot cap; long ids keep their leaf and a hash of the whole path. */
|
|
function boundedChildId(id: string): string {
|
|
if (id.length <= AGENT_SUBAGENT_ID_MAX_LENGTH) {
|
|
return id
|
|
}
|
|
let hash = 0x811c9dc5
|
|
for (let index = 0; index < id.length; index += 1) {
|
|
hash = Math.imul(hash ^ id.charCodeAt(index), 0x01000193) >>> 0
|
|
}
|
|
const prefix = `${hash.toString(16).padStart(8, '0')}~`
|
|
return prefix + id.slice(-(AGENT_SUBAGENT_ID_MAX_LENGTH - prefix.length))
|
|
}
|
|
|
|
function blockedState(record: ProgramStatusRecord): 'waiting' | 'blocked' {
|
|
// Why: Orca's hooks report approvals and questions as `waiting`.
|
|
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':
|
|
return 'working'
|
|
case 'blocked':
|
|
return blockedState(record)
|
|
case 'idle':
|
|
return 'idle'
|
|
case 'done':
|
|
case 'error':
|
|
// A finished child is not a live child row.
|
|
return null
|
|
}
|
|
}
|
|
|
|
type RootProjection = {
|
|
state: AgentStatusState
|
|
outcome?: AgentTurnOutcome
|
|
sessionBoundary?: true
|
|
}
|
|
|
|
/** The root as a live program: undefined once the program ended (prompt start, reset), even while
|
|
* its finished record is kept for display. */
|
|
type ProgramStatusRootChange = { offset: number; root: ProgramStatusRecord | undefined }
|
|
|
|
/** `removesRow`: no records remain (clear, reset, ended mid-work), so the program's row goes too. */
|
|
export type ProgramStatusPayload = {
|
|
payload: ParsedAgentStatusPayload
|
|
offset: number
|
|
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. */
|
|
process: (data: string) => {
|
|
payloads: ProgramStatusPayload[]
|
|
rootChanges: ProgramStatusRootChange[]
|
|
facet?: ProgramStatusFacet | null
|
|
}
|
|
}
|
|
|
|
export function createProgramStatusProjector(now: () => number = Date.now): ProgramStatusProjector {
|
|
const scan = createProgramStatusOscScanner()
|
|
const records = new ProgramStatusRecordSet()
|
|
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
|
|
let reportedSinceEnd = false
|
|
const childStartedAt = new Map<string, number>()
|
|
|
|
const projectRoot = (
|
|
root: ProgramStatusRecord | undefined,
|
|
newInvocation: boolean
|
|
): RootProjection | null => {
|
|
if (!root) {
|
|
// Why a boundary, not a verdict: a dropped record proves the program ended, not how.
|
|
return lastRoot !== null && lastRoot.sessionBoundary !== true
|
|
? { state: 'done', sessionBoundary: true }
|
|
: null
|
|
}
|
|
switch (root.state) {
|
|
case 'working':
|
|
return { state: 'working' }
|
|
case 'blocked':
|
|
return { state: blockedState(root) }
|
|
case 'done':
|
|
return { state: 'done', outcome: 'success' }
|
|
case 'error':
|
|
return { state: 'done', outcome: 'failure' }
|
|
case 'idle':
|
|
// Idle may follow startup work (Codex), so it proves rest, not cancellation; interrupt keystrokes own that verdict.
|
|
// Keep unseen done results when the same invocation rests.
|
|
return !newInvocation && lastRoot?.state === 'done' && lastRoot.sessionBoundary !== true
|
|
? lastRoot
|
|
: { state: 'done', sessionBoundary: true }
|
|
}
|
|
}
|
|
|
|
const snapshotChildren = (): AgentSubagentSnapshot[] => {
|
|
const at = now()
|
|
const live = new Set<string>()
|
|
const children: AgentSubagentSnapshot[] = []
|
|
for (const record of records.descendants()) {
|
|
const state = subagentState(record)
|
|
if (record.id === null || state === null) {
|
|
continue
|
|
}
|
|
const id = boundedChildId(record.id)
|
|
live.add(id)
|
|
const startedAt = childStartedAt.get(id) ?? at
|
|
childStartedAt.set(id, startedAt)
|
|
const agentType = agentTypeForProgramStatusApp(record.app)
|
|
const description =
|
|
programStatusDisplayText(record.title) ?? programStatusDisplayText(record.msg)
|
|
children.push({
|
|
id,
|
|
state,
|
|
startedAt,
|
|
...(agentType ? { agentType } : {}),
|
|
...(description ? { description } : {})
|
|
})
|
|
}
|
|
for (const id of childStartedAt.keys()) {
|
|
if (!live.has(id)) {
|
|
childStartedAt.delete(id)
|
|
}
|
|
}
|
|
return children
|
|
}
|
|
|
|
const project = (): ParsedAgentStatusPayload | null => {
|
|
const root = records.root()
|
|
const newInvocation = invocationEnded
|
|
invocationEnded = false
|
|
const projection = projectRoot(root, newInvocation)
|
|
if (projection === null) {
|
|
lastRoot = null
|
|
lastKey = null
|
|
return null
|
|
}
|
|
// Why on a new invocation too: an identical result from the next run is still news.
|
|
if (
|
|
newInvocation ||
|
|
projection.state !== lastRoot?.state ||
|
|
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,
|
|
prompt: '',
|
|
...(agentType ? { agentType } : {}),
|
|
// Live work names what it is doing or waiting on; a finished turn names its result.
|
|
...(msg && projection.state !== 'done' ? { toolInput: msg } : {}),
|
|
...(msg && projection.state === 'done' && projection.sessionBoundary !== true
|
|
? { lastAssistantMessage: msg }
|
|
: {}),
|
|
...(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 } : {}),
|
|
stateStartedAt: rootStateStartedAt
|
|
}
|
|
}
|
|
// Deduplicate redraw reports so only changed payloads reach the store.
|
|
const key = JSON.stringify(payload)
|
|
if (key === lastKey) {
|
|
return null
|
|
}
|
|
lastKey = key
|
|
return payload
|
|
}
|
|
|
|
return {
|
|
process: (data) => {
|
|
const events = scan(data)
|
|
const payloads: ProgramStatusPayload[] = []
|
|
const rootChanges: ProgramStatusRootChange[] = []
|
|
const facetBefore = lastFacet
|
|
for (const event of events) {
|
|
let changed: boolean
|
|
switch (event.type) {
|
|
case 'query':
|
|
// Answered by the terminal emulator that owns replies for this chunk.
|
|
continue
|
|
case 'report':
|
|
changed = records.apply(event.report)
|
|
reportedSinceEnd = true
|
|
break
|
|
case 'prompt-start':
|
|
changed = records.endProgram()
|
|
break
|
|
case 'full-reset':
|
|
changed = records.reset()
|
|
break
|
|
}
|
|
const ended = event.type !== 'report'
|
|
const endsInvocation = ended && reportedSinceEnd
|
|
if (endsInvocation) {
|
|
// The kept done/error record is for display; the program itself no longer speaks.
|
|
reportedSinceEnd = false
|
|
rootChanges.push({ offset: event.offset, root: undefined })
|
|
}
|
|
if (changed) {
|
|
const root = records.root()
|
|
if (!ended) {
|
|
rootChanges.push({ offset: event.offset, root })
|
|
}
|
|
const facet = root ? programStatusFacetFromRecord(root, now()) : undefined
|
|
// Keep the earlier facet object (and its observedAt) when the report repeats it.
|
|
lastFacet = programStatusFacetsEqual(lastFacet, facet) ? lastFacet : facet
|
|
const payload = project()
|
|
// A removal is news even when the last projection was already an at-rest boundary.
|
|
if (root === undefined && records.descendants().length === 0) {
|
|
payloads.push({
|
|
payload: payload ?? { state: 'done', prompt: '', sessionBoundary: true },
|
|
offset: event.offset,
|
|
removesRow: true
|
|
})
|
|
} else if (payload) {
|
|
payloads.push({ payload, offset: event.offset })
|
|
}
|
|
}
|
|
// After the ending event's own projection, so only the next report opens a new invocation.
|
|
if (endsInvocation) {
|
|
invocationEnded = true
|
|
}
|
|
}
|
|
if (lastFacet === facetBefore) {
|
|
return { payloads, rootChanges }
|
|
}
|
|
return { payloads, rootChanges, facet: lastFacet ?? null }
|
|
}
|
|
}
|
|
}
|