refactor(agent): complete inbox lifecycle migration

This commit is contained in:
_Kerman
2026-08-03 12:25:33 +08:00
parent dc1d542092
commit 49e90695cc
214 changed files with 6019 additions and 4235 deletions

View File

@@ -50,8 +50,11 @@ const WAIT_POLL_INTERVAL_MS = 10
* `waitForTurnStart` waits for an open durable turn, optionally at or beyond a
* specified turn number. `waitForTurnEnd` holds the subprocess open until the
* selected session's latest complete raw-JSONL turn boundary is `turn/end`.
* `waitForGoalPhase` waits for the latest durable goal snapshot to reach one phase.
* `waitForInboxMessage` waits for inserted inbox text containing a scenario marker.
* `waitForTitleAfterTurnEnd` additionally waits for a later durable title.
* `waitForSubagentTurnEnd` applies the same work-turn boundary to one
* background child, whose progress has no ACP update to wait on.
* A standalone `cancel` may also wait for a cwd-relative readiness marker.
* All wait timeouts default to 10s.
*/
@@ -67,8 +70,11 @@ export type InputStep =
text: string
waitForFile?: { path: string; timeoutMs?: number }
}
| { op: 'waitForFile'; path: string; timeoutMs?: number }
| { op: 'waitForTurnStart'; minimumTurn?: number; timeoutMs?: number }
| { op: 'waitForTurnEnd'; timeoutMs?: number }
| { op: 'waitForSubagentTurnEnd'; child?: number; minimumTurn?: number; timeoutMs?: number }
| { op: 'waitForGoalPhase'; phase: 'active' | 'paused' | 'blocked' | 'complete'; timeoutMs?: number }
| { op: 'waitForInboxMessage'; text: string; timeoutMs?: number }
| { op: 'waitForTitleAfterTurnEnd'; timeoutMs?: number }
| { op: 'cancel'; waitForFile?: { path: string; timeoutMs?: number } }
@@ -292,6 +298,8 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
(id) => { sessionId = id },
(id, timeoutMs, minimumTurn) => waitForPersistedTurnStart(sessionsRoot, id, timeoutMs, minimumTurn),
(id, timeoutMs) => waitForPersistedTurnEnd(sessionsRoot, id, timeoutMs),
(child, timeoutMs, minimumTurn) => waitForPersistedChildTurnEnd(sessionsRoot, child, timeoutMs, minimumTurn),
(id, phase, timeoutMs) => waitForPersistedGoalPhase(sessionsRoot, id, phase, timeoutMs),
(id, text, timeoutMs) => waitForPersistedInboxMessage(sessionsRoot, id, text, timeoutMs),
(id, timeoutMs) => waitForPersistedTitleAfterTurnEnd(sessionsRoot, id, timeoutMs),
)
@@ -367,6 +375,8 @@ async function runStep(
setSessionId: (id: string) => void,
waitForTurnStart: (sessionId: string, timeoutMs?: number, minimumTurn?: number) => Promise<void>,
waitForTurnEnd: (sessionId: string, timeoutMs?: number) => Promise<void>,
waitForChildTurnEnd: (child: number, timeoutMs?: number, minimumTurn?: number) => Promise<void>,
waitForGoalPhase: (sessionId: string, phase: string, timeoutMs?: number) => Promise<void>,
waitForInboxMessage: (sessionId: string, text: string, timeoutMs?: number) => Promise<void>,
waitForTitleAfterTurnEnd: (sessionId: string, timeoutMs?: number) => Promise<void>,
): Promise<void> {
@@ -440,12 +450,24 @@ async function runStep(
await promptDone
return
}
case 'waitForFile':
await waitForWorkspaceFile(cwd, step.path, step.timeoutMs)
return
case 'waitForTurnEnd': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: waitForTurnEnd before newSession')
await waitForTurnEnd(sessionId, step.timeoutMs)
return
}
case 'waitForSubagentTurnEnd':
await waitForChildTurnEnd(step.child ?? 1, step.timeoutMs, step.minimumTurn)
return
case 'waitForGoalPhase': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: waitForGoalPhase before newSession')
await waitForGoalPhase(sessionId, step.phase, step.timeoutMs)
return
}
case 'waitForInboxMessage': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: waitForInboxMessage before newSession')
@@ -525,6 +547,59 @@ async function waitForPersistedTurnEnd(
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
}
/**
* Wait until the Nth harvested child Session closes a model work turn.
*
* Harvest order matches `session.1.jsonl`, `session.2.jsonl`, and so on. A
* continuable child appends its descriptor after any inherited history and
* before accepting its first prompt, so only a later request header proves its
* own model work reached a closed turn.
*/
async function waitForPersistedChildTurnEnd(
root: string,
child: number,
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
minimumTurn = 1,
): Promise<void> {
await vi.waitFor(async () => {
const log = (await harvestSessionLogs(root))[child]
if (log === undefined || !latestTurnIsClosed(log.content)
|| !hasRequestHeaderAfterDescriptor(log.content)
|| !hasClosedTurn(log.content, minimumTurn)) {
throw new Error(
`snapshot-harness: subagent child #${child} did not persist closed turn ${minimumTurn} within ${timeoutMs}ms`,
)
}
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
}
/** Whether a raw session log contains the requested closed turn. */
function hasClosedTurn(content: string, turn: number): boolean {
return content.split('\n').filter(Boolean).some((line) => {
const event = JSON.parse(line) as { type?: unknown; data?: { turn?: unknown } }
return event.type === 'turn/end' && event.data?.turn === turn
})
}
/** Wait until the latest durable goal snapshot reaches one phase. */
async function waitForPersistedGoalPhase(
root: string,
sessionId: string,
phase: string,
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
): Promise<void> {
await vi.waitFor(async () => {
const content = (await harvestSessionLogs(root)).find(log => log.id === sessionId)?.content
const matched = content?.split('\n').filter(Boolean).some((line) => {
const event = JSON.parse(line) as { type?: unknown; data?: { goal?: { phase?: unknown } } }
return event.type === 'goal/change' && event.data?.goal?.phase === phase
}) ?? false
if (!matched) {
throw new Error(`snapshot-harness: session "${sessionId}" did not persist goal phase "${phase}" within ${timeoutMs}ms`)
}
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
}
/** Wait until an inserted inbox message contains scenario-owned text. */
async function waitForPersistedInboxMessage(
root: string,
@@ -544,10 +619,23 @@ async function waitForPersistedInboxMessage(
message.content?.some(block => block.type === 'text'
&& typeof block.text === 'string' && block.text.includes(text))) === true
}) ?? false
if (!matched) throw new Error(`snapshot-harness: session "${sessionId}" did not persist expected inbox message within ${timeoutMs}ms`)
if (!matched) {
throw new Error(`snapshot-harness: session "${sessionId}" did not persist expected inbox message within ${timeoutMs}ms`)
}
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
}
/** Whether a child log contains model work after its own descriptor event. */
function hasRequestHeaderAfterDescriptor(content: string): boolean {
const events = content.slice(0, content.lastIndexOf('\n') + 1)
.split('\n')
.filter(line => line.length > 0)
.map(line => JSON.parse(line) as { type?: unknown })
const descriptor = events.findLastIndex(event => event.type === 'subagent/descriptor')
return descriptor >= 0
&& events.slice(descriptor + 1).some(event => event.type === 'request/header')
}
/** Wait until a complete provider or fallback title record follows the latest closed turn. */
async function waitForPersistedTitleAfterTurnEnd(
root: string,