refactor(agent): complete inbox lifecycle migration
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user