Hooks admit the lead's final reply with its turn; the launcher records undelivered prompts and proven exits (#38239)

This commit is contained in:
Jinwoo Hong
2026-11-06 21:46:08 -05:00
committed by GitHub
parent 15d71e2dc0
commit 66cd756f91
32 changed files with 904 additions and 170 deletions
@@ -25,6 +25,14 @@ export function agentHookRuntimeDeps(
? server.getStatusSnapshot()
: server.getProviderSessionSourceRowsForTab(tabId),
getAgentProviderSessionRowsForPane: (paneKey) => server.getStatusSnapshotForPane(paneKey),
getAcceptedAgentReply: (paneKey, leadTurnKey) => server.getAcceptedReply(paneKey, leadTurnKey),
subscribeAgentStatusRowChanges: (listener) =>
server.subscribeStatusRowMutations(({ before, after }) => {
const paneKey = after?.paneKey ?? before?.paneKey
if (paneKey) {
listener(paneKey)
}
}),
subscribeAgentProviderSessionChanges: (listener) =>
server.subscribeProviderSessionChanges(listener),
// Use identity captured at observation; resolving now could attribute cached rows to a new process.
@@ -0,0 +1,143 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { AgentHookServer, _internals } from './server'
import { buildBody, PANE } from './server.test-fixtures'
import { mainAgentDoneAt } from '../../shared/agent-status/lifecycle/main-agent-status'
import type { AutomationRun } from '../../shared/automations-types'
import { createRuntimeAutomationRunTerminalObserver } from '../automations/runtime-terminal-run-observer'
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({
getCohortAtEmit: () => ({ nth_repo_added: 2 })
}))
const CHILD = 'a6324370b7bede0c7'
// Markers past the 8,000-char status preview prove the whole reply survived.
const REPLY = `START ${'a'.repeat(12_000)} MIDDLE-MARKER ${'b'.repeat(12_000)} END-MARKER`
let userDataPath: string
let server: AgentHookServer
beforeEach(async () => {
_internals.resetCachesForTests()
userDataPath = mkdtempSync(join(tmpdir(), 'orca-accepted-reply-'))
server = new AgentHookServer()
await server.start({ env: 'production', userDataPath })
})
afterEach(() => {
server.stop()
rmSync(userDataPath, { recursive: true, force: true })
})
async function post(payload: Record<string, unknown>): Promise<void> {
const env = server.buildPtyEnv()
await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
})
}
/** What a run watcher reads: the reply for the lead turn its selected row reports finished. */
function replyForCurrentRow(): string | undefined {
const row = server.getStatusSnapshotForPane(PANE)[0]
const leadTurnKey = row ? mainAgentDoneAt(row) : undefined
return leadTurnKey === undefined ? undefined : server.getAcceptedReply(PANE, leadTurnKey)
}
describe('accepted lead reply', () => {
it('keeps the whole reply for its turn, out of saved and public status, until a new prompt', async () => {
await post({ hook_event_name: 'UserPromptSubmit', prompt: 'write the report' })
await post({ hook_event_name: 'Stop', last_assistant_message: REPLY })
expect(replyForCurrentRow()).toBe(REPLY)
server.flushStatusPersistSync()
for (const published of [
readFileSync(join(userDataPath, 'agent-hooks', 'last-status.json'), 'utf8'),
JSON.stringify(server.getStatusSnapshot())
]) {
expect(published).not.toContain('MIDDLE-MARKER')
expect(published).not.toContain('acceptedReply')
expect(published).not.toContain('replyCandidate')
}
await post({ hook_event_name: 'UserPromptSubmit', prompt: 'next task' })
await post({ hook_event_name: 'Stop' })
expect(replyForCurrentRow()).toBeUndefined()
})
it.each([
{ name: 'a running child', permission: false },
{ name: "a child's visible permission prompt", permission: true }
])('keeps a reply the lead gave while $name held the turn', async ({ permission }) => {
await post({ hook_event_name: 'UserPromptSubmit', prompt: 'research in background' })
await post({ hook_event_name: 'SubagentStart', agent_id: CHILD, agent_type: 'general-purpose' })
if (permission) {
await post({
hook_event_name: 'PermissionRequest',
agent_id: CHILD,
agent_type: 'general-purpose',
tool_name: 'Bash',
tool_input: { command: 'make' }
})
}
await post({
hook_event_name: 'Stop',
last_assistant_message: REPLY,
background_tasks: [{ id: CHILD, type: 'subagent', status: 'running' }]
})
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: permission ? 'waiting' : 'working',
mainAgent: { state: 'done' }
})
await post({ hook_event_name: 'SubagentStop', agent_id: CHILD, agent_type: 'general-purpose' })
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done' })
expect(replyForCurrentRow()).toBe(REPLY)
})
it("saves the reply of an agent that exits right after it, though exit cleanup clears the pane's status", async () => {
await post({ hook_event_name: 'UserPromptSubmit', prompt: 'write the report' })
const paneKey: string = PANE
const observer = createRuntimeAutomationRunTerminalObserver(
{
getTerminalHandleForPaneKey: () => 'terminal-1',
readTerminal: async () => ({ tail: [] }),
waitForTerminal: async () => {
await post({ hook_event_name: 'Stop', last_assistant_message: REPLY })
server.reconcileEndedProcessForPaneKeys([PANE])
throw Object.assign(new Error('terminal_exited'), {
terminalExitCause: { kind: 'exited', exitCode: 0 }
})
}
},
{
getAgentStatusRowsForPane: (paneKey) => server.getStatusSnapshotForPane(paneKey),
getAcceptedReply: (paneKey, leadTurnKey) => server.getAcceptedReply(paneKey, leadTurnKey),
subscribeStatusChanges: (listener) =>
server.subscribeStatusRowMutations(({ after }) => after && listener(after.paneKey)),
agentCommandsForRun: () => ['claude']
}
)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the observer reads only these run fields.
const run = { terminalPaneKey: paneKey, startedAt: 0, dispatchedAt: 0 } as AutomationRun
const observation = await observer.observeCompletion('terminal-1', {
signal: new AbortController().signal,
run
})
expect(replyForCurrentRow()).toBeUndefined()
expect(observation).toMatchObject({
status: 'completed',
outputSnapshot: { content: REPLY, truncated: false }
})
})
})
@@ -0,0 +1,38 @@
import { mainAgentDoneAt } from '../../../shared/agent-status/lifecycle/main-agent-status'
import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event'
import type { EnrichedAgentHookEventPayload } from './server-types'
/** The reply an event offers its lead's turn; a child's reply never answers it. */
export function leadReplyOf(event: AgentHookEventPayload): string | undefined {
return event.toolAgentId === undefined ? event.replyCandidate : undefined
}
/** Admit a lead event's reply with the lead completion this row commits; otherwise keep the previous
* reply only for the same owner and lead turn. Returns `row` itself when nothing changes. */
export function withAcceptedReply(
row: EnrichedAgentHookEventPayload,
previous: EnrichedAgentHookEventPayload | undefined,
leadReply: string | undefined,
sameOwner: boolean
): EnrichedAgentHookEventPayload {
// Readers name the turn the same way from the public row they selected.
const leadTurnKey = mainAgentDoneAt({
state: row.payload.state,
stateStartedAt: row.stateStartedAt,
mainAgent: row.payload.mainAgent
})
const carried = previous?.acceptedReply
const accepted =
leadTurnKey === undefined
? undefined
: leadReply
? { text: leadReply, leadTurnKey }
: sameOwner && carried?.leadTurnKey === leadTurnKey
? carried
: undefined
if (accepted === row.acceptedReply) {
return row
}
const { acceptedReply: _stale, ...rest } = row
return accepted ? { ...rest, acceptedReply: accepted } : rest
}
@@ -10,6 +10,7 @@ import {
isClaudePermissionSettledByReplay
} from '../../../shared/agent-hook-listener/claude-settled-permission-requests'
import type { EnrichedAgentHookEventPayload } from './server-types'
import { leadReplyOf, withAcceptedReply } from './server-accepted-reply'
import {
claudePermissionNamesToolUse,
type ClaudeToolRequestFields
@@ -28,10 +29,17 @@ export function pairedClaudeNonAgentWork(
: undefined
}
/** Keep main-agent progress behind a child's permission prompt so restart cannot settle live work. */
/** Keep main-agent progress, and the reply the lead finished with, behind a child's permission prompt so restart cannot settle live work. */
export function withHeldChildWaitMainAgent(
previous: EnrichedAgentHookEventPayload,
next: AgentHookEventPayload
): EnrichedAgentHookEventPayload {
return withAcceptedReply(heldChildWaitRow(previous, next), previous, leadReplyOf(next), true)
}
function heldChildWaitRow(
previous: EnrichedAgentHookEventPayload,
next: AgentHookEventPayload
): EnrichedAgentHookEventPayload {
const mainAgent = next.payload.mainAgent
if (!previous.toolAgentId || !mainAgent) {
@@ -265,6 +265,15 @@ export abstract class AgentHookServerListeners extends AgentHookServerState {
return rows
}
/** The lead's full final reply for the finished turn `leadTurnKey` names, when this runtime admitted one. */
getAcceptedReply(paneKey: string, leadTurnKey: number): string | undefined {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; the shared view declares their base event type.
const row = this.state.lastStatusByPaneKey.get(paneKey) as
| EnrichedAgentHookEventPayload
| undefined
return row?.acceptedReply?.leadTurnKey === leadTurnKey ? row.acceptedReply.text : undefined
}
getHydratedAuthorityCommitments(): readonly AgentHookAuthorityEvidence[] {
return this.hydratedAuthorityCommitments
}
@@ -50,6 +50,8 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio
// Terminal handles belong to this runtime and cannot safely rejoin a hydrated row.
terminalHandle: _terminalHandle,
hostTurnRevision: _hostTurnRevision,
// Full replies serve this runtime's run records only.
acceptedReply: _acceptedReply,
launchToken,
...persistedPayload
} = enrichedPayload
@@ -1,4 +1,5 @@
import { deriveCliWriterTopology } from './cli-writer-observation'
import { leadReplyOf, withAcceptedReply } from './server-accepted-reply'
import { createHash } from 'node:crypto'
import { getCohortAtEmit } from '../../telemetry/cohort-classifier'
@@ -159,18 +160,24 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt
turnEndedAt: _turnEndedAt,
turnEndDisposition: _turnEndDisposition,
hostTurn: _hostTurn,
replyCandidate: _replyCandidate,
...event
} = payload
return {
...event,
receivedAt: now,
evidenceObservedAt: observedAt ?? this.resolveEvidenceObservedAt(payload, previous, now),
stateStartedAt,
// Producers cannot declare a main-agent turn start.
turnStartedAt,
...turnEnd,
...(hostTurn ? { hostTurn } : {})
}
return withAcceptedReply(
{
...event,
receivedAt: now,
evidenceObservedAt: observedAt ?? this.resolveEvidenceObservedAt(payload, previous, now),
stateStartedAt,
// Producers cannot declare a main-agent turn start.
turnStartedAt,
...turnEnd,
...(hostTurn ? { hostTurn } : {})
},
previous,
leadReplyOf(payload),
!!previous && this.sameTerminalOwner(previous, payload)
)
}
/** Replay keeps evidence age separate from arrival order; unknown age falls back to now. */
@@ -54,6 +54,8 @@ export type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
programStatus?: ProgramStatusFacet
/** Facet-only publication; state readers must not infer a transition. */
programStatusOnly?: true
/** The lead's full final reply for the turn `leadTurnKey` names; main-process only, never persisted or published. */
acceptedReply?: { text: string; leadTurnKey: number }
}
// Persist non-agent work too: mainAgent cannot describe shell/cron/notification work that affects hydration.
@@ -71,6 +73,8 @@ export type PersistedAgentHookEventPayload = Omit<
| 'observation'
// Terminal handles belong to one runtime.
| 'terminalHandle'
| 'acceptedReply'
| 'replyCandidate'
> & {
launchTokenHash?: string
}
+44 -22
View File
@@ -50,11 +50,24 @@ import {
recoverCreatedWorkspace
} from './agent-launch-created-workspace'
import { opening, type AgentLaunchOpening } from './agent-launch-opening'
export type { AgentLaunchExecution } from './agent-launch-execution'
export async function executeAgentLaunch(
execution: AgentLaunchExecution
): Promise<AgentLaunchResult> {
const settled = await (await openAgentLaunch(execution)).settlement
if (settled.status === 'rejected') {
throw settled.reason
}
return settled.value
}
/** Resolve once the surface is published; prompt delivery continues in `settlement`. */
export async function openAgentLaunch(
execution: AgentLaunchExecution
): Promise<AgentLaunchOpening> {
const { intent, runtime } = execution
const vocabulary = execution.vocabulary ?? DEFAULT_LAUNCH_VOCABULARY
const preflight = execution.decidedMode ?? decideAgentLaunchIntentMode(execution)
@@ -64,7 +77,8 @@ export async function executeAgentLaunch(
if (execution.promptPolicy === 'legacy-host') {
assertLegacyHostTarget(execution)
const placed = await resolveWorkspace(execution, preflight)
return published(execution, legacyHostCreateResult(execution, placed, preflight))
const surface = published(execution, legacyHostCreateResult(execution, placed, preflight))
return opening(surface, async () => surface)
}
// A reused process cannot accept new argv; deliver through its PTY.
@@ -75,15 +89,14 @@ export async function executeAgentLaunch(
receipt: preflight,
...promptReceipt(intent, settledAtCreation(intent, {}))
})
return {
const { handle } = intent.reuseTerminal
return opening(reused, async () => ({
...reused,
...promptReceipt(
intent,
await deliverTerminalLaunchPrompt(execution, intent.reuseTerminal.handle, {
freshLaunch: false
})
await deliverTerminalLaunchPrompt(execution, handle, { freshLaunch: false })
)
}
}))
}
const launch =
@@ -109,18 +122,23 @@ export async function executeAgentLaunch(
})
surfacePublished = true
launch?.release()
return {
...startup,
...promptReceipt(
intent,
placed.promptRodeLaunchCommand
? HANDED_TO_TERMINAL
: await deliverTerminalLaunchPrompt(execution, placed.startupTerminalHandle, {
freshLaunch: true,
terminalCreation: placed.startupTerminalCreation
})
)
}
const { startupTerminalHandle, startupTerminalCreation, promptRodeLaunchCommand } = placed
return opening(
startup,
async () => ({
...startup,
...promptReceipt(
intent,
promptRodeLaunchCommand
? HANDED_TO_TERMINAL
: await deliverTerminalLaunchPrompt(execution, startupTerminalHandle, {
freshLaunch: true,
terminalCreation: startupTerminalCreation
})
)
}),
startupTerminalCreation
)
}
// Recover a created workspace on cancellation before an agent exists.
@@ -175,10 +193,14 @@ export async function executeAgentLaunch(
})
surfacePublished = true
launch?.release()
return {
...surface,
...promptReceipt(intent, await settleLaunchPromptDisposal(execution, created))
}
return opening(
surface,
async () => ({
...surface,
...promptReceipt(intent, await settleLaunchPromptDisposal(execution, created))
}),
created.terminalCreation
)
} catch (error) {
const createdWorktreeId = recoverableWorktreeId ?? launch?.createdWorktreeId()
if (!surfacePublished && intent.target.kind !== 'existing' && createdWorktreeId) {
@@ -0,0 +1,26 @@
import type { AgentLaunchResult } from '../../shared/agent-launch/contracts/agent-launch-intent'
import type { RuntimeTerminalCreate } from '../../shared/runtime-terminal-create-contract'
/** A published surface and, separately, its prompt settlement, which never rejects. */
export type AgentLaunchOpening = {
surface: AgentLaunchResult
/** The terminal the surface opened, as its factory created it. */
terminalCreation?: RuntimeTerminalCreate
settlement: Promise<PromiseSettledResult<AgentLaunchResult>>
}
/** Start delivery now; its outcome is captured so a late rejection is never unhandled. */
export function opening(
surface: AgentLaunchResult,
settle: () => Promise<AgentLaunchResult>,
terminalCreation?: RuntimeTerminalCreate
): AgentLaunchOpening {
return {
surface,
...(terminalCreation ? { terminalCreation } : {}),
settlement: settle().then(
(value) => ({ status: 'fulfilled', value }),
(reason: unknown) => ({ status: 'rejected', reason })
)
}
}
@@ -175,6 +175,13 @@ export async function requestAutomationDispatch(
automation,
run,
target,
// The status check and the store write share one synchronous step, so a settled run stays settled.
transition: (result) => {
const current = readRun()
return current.status === 'dispatching'
? { applied: true, run: ctx.runs.updateRun({ runId: run.id, ...result }) }
: { applied: false, run: returnDurable(current) }
},
dispatcher: (request) => {
if (readRun().status !== 'dispatching') {
throw new AutomationDispatchCancelledError('The run changed before its agent could launch.')
@@ -1,4 +1,10 @@
import type { AutomationRun } from '../../shared/automations-types'
import type { ParsedAgentStatusPayload } from '../../shared/agent-status/contracts/agent-status-types'
import { mainAgentDoneAt } from '../../shared/agent-status/lifecycle/main-agent-status'
import {
describeTerminalExitCause,
type TerminalExitCause
} from '../../shared/terminal/pty/lifecycle/terminal-exit-cause'
import {
automationRunFailure,
type AutomationRunFailure
@@ -11,8 +17,20 @@ import {
export { AGENT_START_GRACE_MS } from '../runtime/agent-state-rules/agent-turn-idle-verdict'
type AutomationRunStatusRow = AgentTurnStatusRow &
Partial<
Pick<
ParsedAgentStatusPayload,
'mainAgent' | 'lastAssistantMessage' | 'lastAssistantMessageIsToolOutput'
>
>
export type AutomationRunAgentEvidence = {
getAgentStatusRowsForPane(paneKey: string): readonly AgentTurnStatusRow[]
getAgentStatusRowsForPane(paneKey: string): readonly AutomationRunStatusRow[]
/** The lead's full final reply for that finished turn; rows carry only a capped preview. */
getAcceptedReply(paneKey: string, leadTurnKey: number): string | undefined
/** Calls back synchronously on every status row change, including the clear when an agent exits. */
subscribeStatusChanges(listener: (paneKey: string) => void): () => void
/** Executable aliases used to distinguish a launch refusal from ordinary output. */
agentCommandsForRun(run: AutomationRun): readonly string[]
}
@@ -75,3 +93,71 @@ export function judgeHeldRunTurn(
? { kind: 'failed', ...automationRunFailure('agentInterrupted') }
: { kind: 'completed' }
}
export type RunAgentReply = { text: string; truncated: boolean }
/** This run's latest finished turn: whether it was cut off, and what the agent answered. */
export type RunAgentTurn = { interrupted: boolean; reply: RunAgentReply | null }
/** A run watched with its agent's status evidence; `turn` is its latest captured finished turn. */
export type JudgedRun = {
evidence: AutomationRunAgentEvidence
run: AutomationRun
turn: () => RunAgentTurn | null
}
/** The run's latest finished turn. Its reply is the full accepted one, else the status preview,
* which the status cap may have cut by an amount its length cannot show. */
export function runAgentTurn(
evidence: AutomationRunAgentEvidence,
run: AutomationRun,
runStartedAt: number
): RunAgentTurn | null {
const paneKey = run.terminalPaneKey
const sentAt = run.dispatchedAt ?? runStartedAt
const row = (paneKey ? evidence.getAgentStatusRowsForPane(paneKey) : [])
.filter((entry) => isTurnDoneSince(entry, sentAt))
.toSorted((left, right) => right.stateStartedAt - left.stateStartedAt)[0]
if (!paneKey || !row) {
return null
}
const interrupted = row.interrupted === true
const leadTurnKey = mainAgentDoneAt(row)
const accepted =
leadTurnKey === undefined ? undefined : evidence.getAcceptedReply(paneKey, leadTurnKey)
if (accepted?.trim()) {
return { interrupted, reply: { text: accepted, truncated: false } }
}
// Tool stdout is not the agent's answer.
const preview = row.lastAssistantMessageIsToolOutput ? undefined : row.lastAssistantMessage
return { interrupted, reply: preview?.trim() ? { text: preview, truncated: true } : null }
}
/** Holds this run's latest finished turn as its status changes, so the clear when the agent exits
* cannot erase it before the run settles. */
export function captureRunAgentTurn(
evidence: AutomationRunAgentEvidence,
run: AutomationRun,
runStartedAt: number
): JudgedRun & { stop: () => void } {
let latest = runAgentTurn(evidence, run, runStartedAt)
const stop = evidence.subscribeStatusChanges((paneKey) => {
if (paneKey === run.terminalPaneKey) {
latest = runAgentTurn(evidence, run, runStartedAt) ?? latest
}
})
return { evidence, run, turn: () => runAgentTurn(evidence, run, runStartedAt) ?? latest, stop }
}
/** A proven agent exit's verdict: exit 0 completes the run unless its latest turn was cut off. */
export function judgeRunExit(
exit: TerminalExitCause,
judged: JudgedRun | null
): Exclude<IdleRunVerdict, { kind: 'wait' }> {
if (judged?.turn()?.interrupted) {
return { kind: 'failed', ...automationRunFailure('agentInterrupted') }
}
return exit.kind === 'exited' && exit.exitCode === 0
? { kind: 'completed' }
: { kind: 'failed', error: `${describeTerminalExitCause(exit)}.` }
}
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from 'vitest'
import { EVERY_AGENT_CHAT } from '../../shared/native-chat/every-agent-chat.test-fixture'
import { launchHeadlessAutomationAgent } from './headless-automation-agent-launch'
function launch(deliverStartupFollowup: () => Promise<boolean>) {
const runtime = {
requireStore: () => ({ getSettings: () => ({ nativeChatProviders: EVERY_AGENT_CHAT }) }),
resolveManagedTerminalWorkspaceTarget: vi.fn(async () => ({
managedWorktree: { displayName: 'repo' },
scope: { folderWorkspace: null }
})),
launchAgentTerminal: vi.fn(async () => ({
handle: 'term-1',
tabId: 'tab-1',
paneKey: 'tab-1:leaf-1',
ptyId: 'pty-1',
worktreeId: 'wt-1',
title: 'Nightly'
})),
getPtyExecutionHostMetadata: vi.fn(() => ({ executionHostId: 'local' })),
deliverStartupFollowup: vi.fn(deliverStartupFollowup)
}
return launchHeadlessAutomationAgent(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the existing-workspace launch reads only these runtime members.
runtime as never,
{
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only these automation fields are read.
automation: {
id: 'auto-1',
agentId: 'aider',
prompt: 'fix the flaky test',
workspaceMode: 'existing',
workspaceId: 'wt-1'
} as never,
run: { id: 'run-1', title: 'Nightly', scheduledFor: 1, trigger: 'scheduled' },
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the repo is read.
target: { ok: true, repo: { id: 'repo-1', path: '/repo', displayName: 'repo' } } as never,
runs: []
}
)
}
describe('headless automation agent launch', () => {
it.each([
['is refused', async () => false, "Aider started, but Orca could not send this run's prompt."],
[
'throws',
async () => {
throw new Error('writer broke')
},
'writer broke'
]
])(
'opens the terminal and reports the prompt undelivered when delivery %s',
async (_, deliver, error) => {
const opened = await launch(deliver)
expect(opened).toMatchObject({
terminalHandle: 'term-1',
terminalSessionId: 'tab-1',
terminalPaneKey: 'tab-1:leaf-1',
terminalPtyId: 'pty-1'
})
await expect(opened.delivery).resolves.toEqual({ error })
}
)
})
@@ -3,16 +3,18 @@ import { parseWorkspaceKey } from '../../shared/workspace-scope'
import { getRepoExecutionHostId } from '../../shared/execution-host'
// Automations observe PTYs, so launch terminal agents regardless of the chat default.
import type { AgentLaunchResult as AgentLaunchPublishedSurface } from '../../shared/agent-launch/contracts/agent-launch-intent'
import type { AgentLaunchSurfaceExecution } from '../agent-launch/agent-launch-execution'
import { executeAgentLaunch } from '../agent-launch/agent-launch-executor'
import { openAgentLaunch } from '../agent-launch/agent-launch-executor'
import { tuiAgentDisplayName } from '../../shared/agent-launch/catalog/tui-agent-display-names'
import type { RuntimeTerminalCreate } from '../../shared/runtime-terminal-create-contract'
import type { Automation, AutomationRun } from '../../shared/automations-types'
import { findReusableAutomationRun } from '../../shared/automation-session-reuse'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { deliverTerminalAgentLaunchPrompt } from '../runtime/rpc/methods/agent-launch/agent-launch-terminal-prompt'
import type { HeadlessAutomationDispatchLaunch } from './headless-dispatch'
import {
AutomationRunLaunchError,
type HeadlessAutomationDispatchLaunch
} from './headless-dispatch'
import { buildHeadlessAutomationWorktreeCreateArgs } from './headless-workspace-create'
import type { AutomationRunTargetResult } from './run-target-resolution'
@@ -67,8 +69,6 @@ export async function launchHeadlessAutomationAgent(
return reused
}
}
let workspace: { id: string; displayName: string | null } | undefined
let terminal: LaunchedTerminal | undefined
const execution: AgentLaunchSurfaceExecution = {
runtime,
intent: {
@@ -100,17 +100,22 @@ export async function launchHeadlessAutomationAgent(
startupAgent,
...(startupPrompt ? { startupPrompt } : {})
})
workspace = {
id: created.worktree.id,
displayName: created.worktree.displayName ?? null
}
const startup = created.startupTerminal
if (!startup?.handle) {
throw new Error(
const message =
created.warning || 'Automation workspace was created, but no agent terminal started.'
)
// A terminal that started before a later startup step failed still belongs to the run.
throw startupTerminalCreation
? new AutomationRunLaunchError(
message,
runTargetOf(
startupTerminalCreation,
created.worktree.id,
created.worktree.displayName ?? null
)
)
: new Error(message)
}
terminal = { ...startup, handle: startup.handle }
return {
worktreeId: created.worktree.id,
startupTerminalHandle: startup.handle,
@@ -148,8 +153,6 @@ export async function launchHeadlessAutomationAgent(
folderWorkspace: selectedTarget?.scope.folderWorkspace ?? undefined
}
)
terminal = launched
workspace = { id: launched.worktreeId, displayName: resolved.displayName ?? null }
return {
handle: launched.handle,
terminalCreation: launched,
@@ -161,13 +164,39 @@ export async function launchHeadlessAutomationAgent(
runtime.deliverStartupFollowup(handle, { agent, prompt: prompt.text }, { terminalCreation })
}
}
await launchUntilPublished(execution)
if (!workspace || !terminal) {
throw new Error('Automation agent launch did not report its terminal.')
const opened = await openAgentLaunch(execution)
const { outcome, worktreeId } = opened.surface
if (outcome.kind !== 'terminal') {
throw new Error(TERMINAL_ONLY_MESSAGE)
}
return {
workspaceId: workspace.id,
workspaceDisplayName: workspace.displayName,
...runTargetOf(
{ paneKey: outcome.paneKey, ...opened.terminalCreation, handle: outcome.handle },
worktreeId,
selectedWorkspace?.displayName ?? null
),
delivery: opened.settlement.then((settled) => ({
error:
settled.status === 'rejected'
? settled.reason instanceof Error
? settled.reason.message
: String(settled.reason)
: settled.value.prompt?.outcome === 'not-delivered'
? `${tuiAgentDisplayName(agent)} started, but Orca could not send this run's prompt.`
: null
}))
}
}
/** A null display name lets the run store resolve it from the workspace's metadata. */
function runTargetOf(
terminal: LaunchedTerminal,
workspaceId: string,
workspaceDisplayName: string | null
): HeadlessAutomationAgentLaunch {
return {
workspaceId,
workspaceDisplayName,
terminalHandle: terminal.handle,
terminalSessionId: terminal.tabId ?? null,
terminalPaneKey: terminal.paneKey ?? null,
@@ -256,28 +285,6 @@ export function runOwnHandle(
: null
}
/** Return on terminal publication; post-start prompt delivery may still be pending. */
function launchUntilPublished(
execution: AgentLaunchSurfaceExecution
): Promise<AgentLaunchPublishedSurface> {
return new Promise((resolve, reject) => {
let published = false
executeAgentLaunch({
...execution,
onSurfacePublished: (surface) => {
published = true
resolve(surface)
}
}).catch((error: unknown) => {
if (!published) {
reject(error)
return
}
console.warn('[automations] the agent launch failed after its terminal was published', error)
})
})
}
function requireWorkspaceId(automation: Automation): string {
if (!automation.workspaceId) {
throw new Error('The target workspace is no longer available.')
@@ -0,0 +1,101 @@
import { closeTestStores, createStore, testState } from '../persistence/persistence-test-harness'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { AutomationService } from './service'
vi.mock('electron', () => ({ app: { getPath: () => testState.dir } }))
const OPENED = {
workspaceId: 'r1::/repo/wt',
terminalSessionId: 'tab-1',
terminalPaneKey: 'tab-1:9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d',
terminalPtyId: 'pty-1'
}
async function heldLaunch(hold: 'launch' | 'delivery') {
const store = await createStore()
store.addRepo({ id: 'r1', path: '/repo', displayName: 'repo', badgeColor: '#fff', addedAt: 1 })
const automation = store.createAutomation({
name: 'Nightly',
prompt: 'Fix the flaky test',
agentId: 'aider',
projectId: 'r1',
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: Date.UTC(2026, 4, 12)
})
let release: (error: string | null) => void = () => {}
const released = new Promise<string | null>((resolve) => (release = resolve))
let reach: () => void = () => {}
const reached = new Promise<void>((resolve) => (reach = resolve))
const service = new AutomationService(store, {
tickMs: 60_000,
allowRemoteHostScheduling: true,
headlessDispatcher: async () => {
reach()
if (hold === 'launch') {
await released
return OPENED
}
return { ...OPENED, delivery: released.then((error) => ({ error })) }
}
})
const run = await service.runNow(automation.id)
const row = () => store.listAutomationRuns(automation.id).find((entry) => entry.id === run.id)
const settle = async (error: string | null) => {
release(error)
await service.runInAutomationLane(automation.id, async () => undefined)
}
return { store, service, run, row, reached, settle }
}
describe('headless dispatch prompt delivery', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-automation-delivery-'))
})
afterEach(async () => {
await closeTestStores()
rmSync(testState.dir, { recursive: true, force: true })
})
it('links the terminal while delivery is held, then fails an undelivered run keeping it', async () => {
const { service, row, settle } = await heldLaunch('delivery')
await vi.waitFor(() =>
expect(row()).toMatchObject({
status: 'dispatching',
terminalPaneKey: 'tab-1:9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d',
terminalPtyId: 'pty-1'
})
)
await settle("Aider started, but Orca could not send this run's prompt.")
expect(row()).toMatchObject({
status: 'dispatch_failed',
terminalPaneKey: 'tab-1:9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d',
terminalPtyId: 'pty-1',
error: "Aider started, but Orca could not send this run's prompt."
})
service.stop()
})
it.each(['launch', 'delivery'] as const)(
'keeps a run settled while its %s was held when that launch later succeeds',
async (hold) => {
const { store, service, run, row, reached, settle } = await heldLaunch(hold)
await reached
if (hold === 'delivery') {
await vi.waitFor(() => expect(row()?.terminalPaneKey).toBe(OPENED.terminalPaneKey))
}
store.updateAutomationRun({ runId: run.id, status: 'dispatch_failed', error: 'Stranded.' })
await settle(null)
expect(row()).toMatchObject({ status: 'dispatch_failed', error: 'Stranded.' })
service.stop()
}
)
})
@@ -14,7 +14,6 @@ import type {
} from './headless-dispatch'
import { AutomationRunLaunchError } from './headless-dispatch'
import type { AutomationRunTargetResult } from './run-target-resolution'
import type { AutomationRunWriter } from './automation-run-writer'
import {
automationRunFailure,
type AutomationRunFailure
@@ -26,7 +25,11 @@ export type HeadlessAutomationDispatchContext = {
run: AutomationRun
target: Extract<AutomationRunTargetResult, { ok: true }>
dispatcher: HeadlessAutomationDispatcher
runs: AutomationRunWriter
/** Write only while this dispatch still owns a dispatching run; throws AutomationDispatchCancelledError once the service or automation is gone. */
transition: (result: Omit<AutomationDispatchResult, 'runId'>) => {
applied: boolean
run: Promise<AutomationRun>
}
runPrecheck: () => Promise<AutomationPrecheckResult | null>
markDispatchResult: (result: AutomationDispatchResult) => Promise<AutomationRun>
watchRun: (run: AutomationRun) => void
@@ -52,17 +55,16 @@ function heldForStoppedWslDistro(error: unknown): AutomationRunFailure | null {
export async function runHeadlessAutomationDispatch(
ctx: HeadlessAutomationDispatchContext
): Promise<AutomationRun> {
const { automation, run, target, runs } = ctx
const { automation, run, target } = ctx
const precheckResult =
run.trigger === 'scheduled' && automation.precheck ? await ctx.runPrecheck() : null
if (precheckResult && !didAutomationPrecheckPass(precheckResult)) {
return runs.updateRun({
runId: run.id,
return ctx.transition({
status: 'skipped_precheck',
workspaceId: automation.workspaceId,
precheckResult,
error: formatAutomationPrecheckFailure(precheckResult)
})
}).run
}
let launch: HeadlessAutomationDispatchLaunch
// The agent can finish before launch returns.
@@ -74,26 +76,30 @@ export async function runHeadlessAutomationDispatch(
error instanceof AutomationDispatchRefusedError
? error.failure
: heldForStoppedWslDistro(error)
return runs.updateRun({
runId: run.id,
return ctx.transition({
status: refusal ? 'skipped_unavailable' : 'dispatch_failed',
workspaceId: automation.workspaceId,
...(error instanceof AutomationRunLaunchError ? error.target : {}),
...(automation.surface === 'chat' ? { dispatchedAt, precheckResult } : {}),
...(refusal ?? { error: describeDispatchError(error) })
}).run
}
const launchRunTarget = launchRunTargetOf(launch)
if (launch.delivery) {
// A startup dialog can hold delivery, so link the terminal while the run is still dispatching.
const linked = ctx.transition({ status: 'dispatching', ...launchRunTarget })
if (!linked.applied) {
return linked.run
}
linked.run.catch((error: unknown) => {
console.warn('[automations] failed to record the opened run terminal:', error)
})
const { error } = await launch.delivery
if (error !== null) {
return ctx.transition({ status: 'dispatch_failed', ...launchRunTarget, error }).run
}
}
const launchRunTarget = {
sessionKind: launch.sessionKind ?? 'terminal',
chatSessionId: launch.chatSessionId ?? null,
workspaceId: launch.workspaceId,
workspaceDisplayName: launch.workspaceDisplayName ?? null,
terminalSessionId: launch.terminalSessionId,
terminalPaneKey: launch.terminalPaneKey ?? null,
terminalPtyId: launch.terminalPtyId ?? null
}
const updated = runs.updateRun({
runId: run.id,
const updated = ctx.transition({
status: 'dispatched',
dispatchedAt,
...launchRunTarget,
@@ -102,6 +108,9 @@ export async function runHeadlessAutomationDispatch(
...(launch.setupSkippedReason ? { setupSkippedReason: launch.setupSkippedReason } : {}),
error: null
})
if (!updated.applied) {
return updated.run
}
// Start observation without waiting for persistence acknowledgement.
if (!launch.completion) {
ctx.watchRun({
@@ -135,5 +144,17 @@ export async function runHeadlessAutomationDispatch(
)
.catch((error) => console.error('[automations] Failed to persist run completion:', error))
}
return updated
return updated.run
}
function launchRunTargetOf(launch: HeadlessAutomationDispatchLaunch) {
return {
sessionKind: launch.sessionKind ?? 'terminal',
chatSessionId: launch.chatSessionId ?? null,
workspaceId: launch.workspaceId,
workspaceDisplayName: launch.workspaceDisplayName ?? null,
terminalSessionId: launch.terminalSessionId,
terminalPaneKey: launch.terminalPaneKey ?? null,
terminalPtyId: launch.terminalPtyId ?? null
}
}
@@ -17,6 +17,8 @@ export type HeadlessAutomationDispatchLaunch = {
terminalSessionId: string | null
terminalPaneKey?: string | null
terminalPtyId?: string | null
/** Settles once a newly opened terminal's prompt is handed over, or with why it was not; never rejects. */
delivery?: Promise<{ error: string | null }>
setupSkippedReason?: AutomationRunSetupSkippedReason
completion?: Promise<
{
@@ -37,6 +37,8 @@ export function createRuntimeAutomationService(input: {
const launchedHandles = new Map<string, string>()
const observer = createRuntimeAutomationRunTerminalObserver(runtime, {
getAgentStatusRowsForPane: (paneKey) => runtime.getAgentStatusRowsForPane(paneKey),
getAcceptedReply: (paneKey, leadTurnKey) => runtime.getAcceptedAgentReply(paneKey, leadTurnKey),
subscribeStatusChanges: (listener) => runtime.subscribeAgentStatusRowChanges(listener),
agentCommandsForRun: (run) =>
automationAgentCommands(
store.listAutomations().find((entry) => entry.id === run.automationId)?.agentId
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentStatusState } from '../../shared/agent-status/contracts/agent-status-types'
import type { AutomationRun } from '../../shared/automations-types'
import type { TerminalExitCause } from '../../shared/terminal/pty/lifecycle/terminal-exit-cause'
import { AGENT_START_GRACE_MS } from './automation-run-agent-evidence'
import {
createRuntimeAutomationRunTerminalObserver,
@@ -17,6 +18,7 @@ type StatusRow = {
stateStartedAt: number
sessionBoundary?: boolean
interrupted?: boolean
lastAssistantMessage?: string
}
function createPane(initial: {
@@ -25,12 +27,14 @@ function createPane(initial: {
idleEdgeOnly?: boolean
/** Model status cleanup landing while the observer reads the screen. */
clearRowsOnRead?: boolean
exitCause?: TerminalExitCause
}) {
let idle = initial.idle
// Model runtimes that satisfy tui-idle only once per idle edge.
let edgeSpent = false
let tail = initial.tail
let rows: StatusRow[] = []
const acceptedReplies = new Map<number, string>()
const waiters = new Set<() => void>()
const runtime: AutomationRunTerminalHost = {
getTerminalHandleForPaneKey: () => HANDLE,
@@ -44,6 +48,11 @@ function createPane(initial: {
if (options?.signal?.aborted) {
return Promise.reject(new Error('request_aborted'))
}
if (initial.exitCause) {
return Promise.reject(
Object.assign(new Error('terminal_exited'), { terminalExitCause: initial.exitCause })
)
}
if (idle && !(initial.idleEdgeOnly && edgeSpent)) {
edgeSpent = true
return Promise.resolve({ satisfied: true })
@@ -69,6 +78,7 @@ function createPane(initial: {
return {
runtime,
agentRows: () => rows,
acceptedReplies,
report: (row: Partial<StatusRow> = {}) =>
(rows = [{ state: 'done', receivedAt: Date.now(), stateStartedAt: Date.now(), ...row }]),
setTail: (next: string[]) => (tail = next),
@@ -95,6 +105,9 @@ function observe(
) {
const observer = createRuntimeAutomationRunTerminalObserver(pane.runtime, {
getAgentStatusRowsForPane: (paneKey) => (paneKey === PANE_KEY ? pane.agentRows() : []),
getAcceptedReply: (paneKey, leadTurnKey) =>
paneKey === PANE_KEY ? pane.acceptedReplies.get(leadTurnKey) : undefined,
subscribeStatusChanges: () => () => {},
agentCommandsForRun: () => ['goose']
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the observer reads only these run fields.
@@ -177,6 +190,59 @@ describe('observing a run with agent evidence', () => {
expect(run.settled).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' }))
})
it("records the agent's accepted full reply as the run output, not its screen", async () => {
const pane = createPane({ idle: false, tail: ['TUI chrome'] })
const run = observe(pane)
await vi.advanceTimersByTimeAsync(1_000)
const doneAt = Date.now()
pane.report({ stateStartedAt: doneAt })
pane.acceptedReplies.set(doneAt, 'the full final reply')
pane.becomeIdle()
await run.promise
expect(run.settled).toHaveBeenCalledWith(
expect.objectContaining({
status: 'completed',
outputSnapshot: expect.objectContaining({
content: 'the full final reply',
truncated: false
})
})
)
})
it('marks a status preview fallback truncated, since the cap can cut it below its own length', async () => {
const pane = createPane({ idle: false, tail: ['TUI chrome'] })
const run = observe(pane)
await vi.advanceTimersByTimeAsync(1_000)
pane.report({ lastAssistantMessage: 'a cut preview' })
pane.becomeIdle()
await run.promise
expect(run.settled).toHaveBeenCalledWith(
expect.objectContaining({
outputSnapshot: expect.objectContaining({ content: 'a cut preview', truncated: true })
})
)
})
it.each([
{ exitCode: 0, interrupted: false, status: 'completed' },
{ exitCode: 0, interrupted: true, status: 'dispatch_failed' },
{ exitCode: 3, interrupted: false, status: 'dispatch_failed' }
])(
'settles a proven agent exit $exitCode after a turn with interrupted $interrupted as $status',
async ({ exitCode, interrupted, status }) => {
const pane = createPane({
idle: false,
tail: ['bye'],
exitCause: { kind: 'exited', exitCode }
})
pane.report({ interrupted })
const run = observe(pane)
await run.promise
expect(run.settled).toHaveBeenCalledWith(expect.objectContaining({ status }))
}
)
it('stops on cancellation without recording any result', async () => {
const pane = createPane({ idle: true, tail: ['$'] })
const run = observe(pane)
@@ -3,7 +3,7 @@ import type {
AutomationRunCompletionObservation,
AutomationRunTerminalObserver
} from './run-completion-watcher'
import type { AutomationRun, AutomationRunOutputSnapshot } from '../../shared/automations-types'
import type { AutomationRunOutputSnapshot } from '../../shared/automations-types'
import {
AUTOMATION_RUN_FAILURE_COPY,
automationRunFailure,
@@ -12,9 +12,13 @@ import {
import {
judgeHeldRunTurn,
judgeIdleRun,
captureRunAgentTurn,
judgeRunExit,
type AutomationRunAgentEvidence,
type JudgedRun,
type IdleRunVerdict
} from './automation-run-agent-evidence'
import { readProvenTerminalExit } from '../runtime/terminal/wait/terminal-wait-results'
const TERMINAL_SNAPSHOT_LIMIT = 2_000
@@ -53,22 +57,36 @@ function snapshotOf(tail: readonly string[]): AutomationRunOutputSnapshot | null
return snapshotBuffer.snapshot()
}
/** A finished run keeps what the agent answered; the screen is the fallback for agents without hooks. */
function completedSnapshot(
tail: readonly string[],
judged: JudgedRun | null
): AutomationRunOutputSnapshot | null {
const reply = judged?.turn()?.reply ?? null
const snapshot = snapshotOf(reply ? [reply.text] : tail)
return snapshot && reply?.truncated ? { ...snapshot, truncated: true } : snapshot
}
async function judgeIdleTail(
tail: readonly string[],
evidence: AutomationRunAgentEvidence,
run: AutomationRun,
judged: JudgedRun,
runStartedAt: number
): Promise<AutomationRunCompletionObservation | null> {
const verdict = judgeIdleRun(evidence, run, tail, runStartedAt, Date.now())
return verdict.kind === 'wait' ? null : observationOf(verdict, tail)
const verdict = judgeIdleRun(judged.evidence, judged.run, tail, runStartedAt, Date.now())
return verdict.kind === 'wait' ? null : observationOf(verdict, tail, judged)
}
function observationOf(
verdict: Exclude<IdleRunVerdict, { kind: 'wait' }>,
tail: readonly string[]
tail: readonly string[],
judged: JudgedRun | null
): AutomationRunCompletionObservation {
if (verdict.kind === 'completed') {
return { status: 'completed', outputSnapshot: snapshotOf(tail), error: null }
return {
status: 'completed',
outputSnapshot: completedSnapshot(tail, judged),
error: null
}
}
const { kind: _kind, ...failure } = verdict
return { status: 'dispatch_failed', outputSnapshot: snapshotOf(tail), ...failure }
@@ -78,7 +96,7 @@ function observationOf(
async function settleIdlePane(
runtime: AutomationRunTerminalHost,
handle: string,
judged: { evidence: AutomationRunAgentEvidence; run: AutomationRun },
judged: JudgedRun,
runStartedAt: number,
signal: AbortSignal,
deadlineAt: number
@@ -89,7 +107,7 @@ async function settleIdlePane(
if (tail.join('\n') !== idleTail) {
return null
}
const observation = await judgeIdleTail(tail, judged.evidence, judged.run, runStartedAt)
const observation = await judgeIdleTail(tail, judged, runStartedAt)
if (observation) {
return observation
}
@@ -218,70 +236,86 @@ export function createRuntimeAutomationRunTerminalObserver(
run.terminalPaneKey ? runtime.getTerminalHandleForPaneKey(run.terminalPaneKey) : null,
observeCompletion: async (handle, { signal, run }) => {
const startedAt = Date.now()
const judged = evidence && run?.terminalPaneKey ? { evidence, run } : null
const runStartedAt = run?.startedAt ?? run?.dispatchedAt ?? startedAt
// Without agent evidence, require a busy edge to exclude a previous run's idle state.
if (!judged && (await isTuiIdleSatisfiedNow(runtime, handle, signal))) {
const started = await waitForAgentStart(
runtime,
handle,
signal,
startedAt + AGENT_START_DEADLINE_MS
)
if (!started) {
return await buildUnobservedObservation(
runtime,
handle,
automationRunFailure('agentNeverStarted')
)
}
const judged =
evidence && run?.terminalPaneKey ? captureRunAgentTurn(evidence, run, runStartedAt) : null
try {
return await observeTerminalRun(runtime, handle, signal, judged, startedAt, runStartedAt)
} finally {
judged?.stop()
}
const deadlineAt = startedAt + OBSERVE_DEADLINE_MS
while (Date.now() < deadlineAt) {
try {
const wait = await runtime.waitForTerminal(handle, {
condition: 'tui-idle',
signal,
...(judged ? { timeoutMs: HELD_DONE_SETTLE_MS } : {})
})
if (!judged || !wait.satisfied) {
return await buildObservation(runtime, handle, wait)
}
const observation = await settleIdlePane(
runtime,
handle,
judged,
runStartedAt,
signal,
deadlineAt
)
if (observation) {
return observation
}
} catch (error) {
// Individual wait timeouts do not prove the agent stopped working.
if (signal.aborted || !isTerminalWaitTimeout(error)) {
throw error
}
const held = judged
? judgeHeldRunTurn(
judged.evidence,
judged.run,
runStartedAt,
Date.now(),
HELD_DONE_SETTLE_MS
)
: null
if (held) {
return observationOf(held, await readTail(runtime, handle))
}
}
}
return await buildUnobservedObservation(
runtime,
handle,
automationRunFailure('watchTimedOut')
)
}
}
}
async function observeTerminalRun(
runtime: AutomationRunTerminalHost,
handle: string,
signal: AbortSignal,
judged: JudgedRun | null,
startedAt: number,
runStartedAt: number
): Promise<AutomationRunCompletionObservation> {
// Without agent evidence, require a busy edge to exclude a previous run's idle state.
if (!judged && (await isTuiIdleSatisfiedNow(runtime, handle, signal))) {
const started = await waitForAgentStart(
runtime,
handle,
signal,
startedAt + AGENT_START_DEADLINE_MS
)
if (!started) {
return await buildUnobservedObservation(
runtime,
handle,
automationRunFailure('agentNeverStarted')
)
}
}
const deadlineAt = startedAt + OBSERVE_DEADLINE_MS
while (Date.now() < deadlineAt) {
try {
const wait = await runtime.waitForTerminal(handle, {
condition: 'tui-idle',
signal,
...(judged ? { timeoutMs: HELD_DONE_SETTLE_MS } : {})
})
if (!judged || !wait.satisfied) {
return await buildObservation(runtime, handle, wait)
}
const observation = await settleIdlePane(
runtime,
handle,
judged,
runStartedAt,
signal,
deadlineAt
)
if (observation) {
return observation
}
} catch (error) {
const exit = signal.aborted ? null : readProvenTerminalExit(error)
if (exit) {
return observationOf(judgeRunExit(exit, judged), await readTail(runtime, handle), judged)
}
// Individual wait timeouts do not prove the agent stopped working.
if (signal.aborted || !isTerminalWaitTimeout(error)) {
throw error
}
const held = judged
? judgeHeldRunTurn(
judged.evidence,
judged.run,
runStartedAt,
Date.now(),
HELD_DONE_SETTLE_MS
)
: null
if (held) {
return observationOf(held, await readTail(runtime, handle), judged)
}
}
}
return await buildUnobservedObservation(runtime, handle, automationRunFailure('watchTimedOut'))
}
@@ -176,7 +176,10 @@ describe('AutomationService prechecks', () => {
run,
target: { ok: true, cwd: '/repo', repo: store.getRepo('r1')! },
dispatcher: headlessDispatcher,
runs: createAutomationRunWriter(store, null),
transition: (result) => ({
applied: true,
run: createAutomationRunWriter(store, null).updateRun({ runId: run.id, ...result })
}),
runPrecheck: () => service.runPrecheck(automation.id, run.id),
markDispatchResult: (result) => service.markDispatchResult(result),
watchRun: () => {}
+2 -1
View File
@@ -28,7 +28,8 @@ function headlessRuntime() {
waitForTerminal: vi.fn(async () => ({ satisfied: true })),
readTerminal: vi.fn(async () => ({ tail: ['done'] })),
getTerminalHandleForPaneKey: vi.fn(() => null),
getAgentStatusRowsForPane: vi.fn(() => [])
getAgentStatusRowsForPane: vi.fn(() => []),
subscribeAgentStatusRowChanges: vi.fn(() => () => {})
}
return { runtime, service: () => bound }
}
@@ -37,6 +37,8 @@ export type RuntimeConstructionDependencies = {
readObservedAgentStatusPaneIdentity?: (paneKey: string) => ObservedAgentStatusPaneIdentity
getAgentProviderSessionSnapshot?: (tabId?: string) => AgentStatusIpcPayload[]
getAgentProviderSessionRowsForPane?: (paneKey: string) => AgentStatusIpcPayload[]
getAcceptedAgentReply?: (paneKey: string, leadTurnKey: number) => string | undefined
subscribeAgentStatusRowChanges?: (listener: (paneKey: string) => void) => () => void
subscribeAgentProviderSessionChanges?: (
listener: (sessions: readonly { paneKey: string; sessionId: string }[]) => void
) => () => void
@@ -121,6 +121,11 @@ export abstract class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCo
| ((paneKey: string) => AgentStatusIpcPayload[])
| null
/** The agent's full final reply for that finished lead turn; status rows carry only a capped preview. */
readonly getAcceptedAgentReply: RuntimeConstructionBindings['getAcceptedAgentReply']
/** Synchronous on every hook status row change, including clears. */
readonly subscribeAgentStatusRowChanges: RuntimeConstructionBindings['subscribeAgentStatusRowChanges']
protected readonly machineName = new RuntimeMachineName(
() => this.store?.getSettings?.().machineName
)
@@ -246,6 +251,8 @@ export abstract class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCo
this.getAgentProviderSessionSnapshotFn =
deps?.getAgentProviderSessionSnapshot ?? deps?.getAgentStatusSnapshot ?? null
this.getAgentProviderSessionRowsForPaneFn = deps?.getAgentProviderSessionRowsForPane ?? null
this.getAcceptedAgentReply = deps?.getAcceptedAgentReply ?? (() => undefined)
this.subscribeAgentStatusRowChanges = deps?.subscribeAgentStatusRowChanges ?? (() => () => {})
this.attestAgentHookCompatibilityAuthorityFn =
deps?.attestAgentHookCompatibilityAuthority ?? null
this.retireAgentHookCompatibilityAuthorityFn =
@@ -16,6 +16,16 @@ export function buildPtyTerminalWaitExitError(pty: ReadonlyTerminalStateRecord):
return Object.assign(new Error('terminal_exited'), { terminalExitCause: pty.lastExitCause })
}
/** The process exit a terminal wait error proves; operator closes and unreported causes prove nothing. */
export function readProvenTerminalExit(error: unknown): TerminalExitCause | null {
const cause =
error instanceof Error && error.message === 'terminal_exited' && 'terminalExitCause' in error
? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: buildPtyTerminalWaitExitError attaches the PTY's recorded cause.
(error.terminalExitCause as TerminalExitCause | null | undefined)
: null
return cause?.kind === 'exited' || cause?.kind === 'signaled' ? cause : null
}
export function getTerminalState(leaf: ReadonlyTerminalStateRecord): RuntimeTerminalState {
if (leaf.connected) {
return 'running'
@@ -76,6 +76,8 @@ export type AgentHookEventPayload = {
structuredHost?: StructuredHostStatus
/** Rejoin moved pane rows by runtime terminal handle; never persist beyond its issuing runtime. */
terminalHandle?: string
/** Full reply text this hook carried; main's ingest consumes it and never stores, relays or persists it. */
replyCandidate?: string
/** Execution-host clock retained for age-preserving projection replay; never sent verbatim. */
hostEvidenceObservedAt?: number
payload: ParsedAgentStatusPayload
@@ -37,6 +37,8 @@ export type HookListenerState = {
warnedEnvs: Set<string>
lastPromptByPaneKey: Map<string, string>
lastToolByPaneKey: Map<string, ToolSnapshot>
/** Reply text the event being normalized carried; normalizeProviderEvent takes and clears it, so it never outlives that event. */
eventReplyCandidate: string | undefined
/** Read-only compatibility view. All writes pass through the isolated legacy adapter. */
lastStatusByPaneKey: ReadonlyMap<string, AgentHookEventPayload>
antigravityCompletedTranscriptByPaneKey: Map<string, string>
@@ -131,6 +133,7 @@ export function createHookListenerState(
warnedEnvs: new Set(),
lastPromptByPaneKey: new Map(),
lastToolByPaneKey: new Map(),
eventReplyCandidate: undefined,
lastStatusByPaneKey: adapter.view,
antigravityCompletedTranscriptByPaneKey: new Map(),
antigravitySubagentParentByPaneKey: new Map(),
@@ -302,6 +302,7 @@ export function normalizeHookPayload(
...(statusAdmissionSession ? { statusAdmissionSession } : {}),
...(providerSessionOnly ? { providerSessionOnly: true } : {}),
...dispatched.hostTurnSignals,
...(dispatched.replyCandidate ? { replyCandidate: dispatched.replyCandidate } : {}),
payload: transportPayload
}
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { createHookListenerState } from '../listener-state'
import { normalizeAndAccept } from './agent-hook-listener-test-harness'
describe('hook reply candidate', () => {
it('offers only the reply text the event itself carried, never the cached one', () => {
const state = createHookListenerState()
normalizeAndAccept(state, 'claude', { hook_event_name: 'UserPromptSubmit', prompt: 'go' })
const stop = normalizeAndAccept(state, 'claude', {
hook_event_name: 'Stop',
last_assistant_message: 'the answer'
})
const next = normalizeAndAccept(state, 'claude', {
hook_event_name: 'PreToolUse',
tool_name: 'Read',
tool_input: { file_path: '/tmp/a' }
})
expect(stop?.replyCandidate).toBe('the answer')
expect(next?.payload.lastAssistantMessage).toBe('the answer')
expect(next?.replyCandidate).toBeUndefined()
})
})
@@ -153,5 +153,10 @@ export function resolveToolState(
: update.lastAssistantMessageIsToolOutput
}
state.lastToolByPaneKey.set(paneKey, merged)
// Only text this event carried can answer its turn; cached and tool-output text never can.
state.eventReplyCandidate =
update.lastAssistantMessageIsToolOutput || update.clearLastAssistantMessage
? undefined
: update.lastAssistantMessage
return merged
}
@@ -43,6 +43,8 @@ export type ProviderDispatchResult = {
/** The event the payload was normalized as, which a provider may restate (Codex ended turns). */
eventName: unknown
hostTurnSignals: Pick<AgentHookEventPayload, 'stopHookActive' | 'nativeTurnKey'>
/** Full reply text this hook carried, outside the cached tool snapshot; refreshes carry none. */
replyCandidate: string | undefined
}
/** Exhaustive provider routing with provider-specific transcript locality and attribution. */
@@ -73,6 +75,7 @@ export function normalizeProviderEvent(input: {
let promptInteractionKey: string | undefined
let hasTranscriptPromptEvidence = false
let payload: ParsedAgentStatusPayload | null
state.eventReplyCandidate = undefined
switch (source) {
case 'codebuddy':
@@ -229,6 +232,8 @@ export function normalizeProviderEvent(input: {
case 'kiro':
payload = normalizeKiroEvent(state, eventName, promptText, paneKey, hookPayload)
}
const replyCandidate = state.eventReplyCandidate
state.eventReplyCandidate = undefined
return {
payload,
@@ -236,6 +241,7 @@ export function normalizeProviderEvent(input: {
promptInteractionKey,
hasTranscriptPromptEvidence,
eventName,
hostTurnSignals: readHostTurnSignals(source, hookPayload, promptInteractionKey)
hostTurnSignals: readHostTurnSignals(source, hookPayload, promptInteractionKey),
replyCandidate
}
}
@@ -22,6 +22,16 @@ export function mainAgentState(row: {
return row.mainAgent?.state ?? row.state
}
/** When the main agent's current done state began, naming the turn it finished; undefined unless done. */
export function mainAgentDoneAt(row: {
state: AgentStatusState
stateStartedAt: number
mainAgent?: AgentMainAgentStatus
}): number | undefined {
const lead = row.mainAgent ?? row
return lead.state === 'done' ? lead.stateStartedAt : undefined
}
export function mainAgentStatusEqual(
a: AgentMainAgentStatus | undefined,
b: AgentMainAgentStatus | undefined