diff --git a/src/main/agent-hooks/agent-hook-runtime-deps.ts b/src/main/agent-hooks/agent-hook-runtime-deps.ts index 26bd9671890..638d2fe13e9 100644 --- a/src/main/agent-hooks/agent-hook-runtime-deps.ts +++ b/src/main/agent-hooks/agent-hook-runtime-deps.ts @@ -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. diff --git a/src/main/agent-hooks/server-accepted-reply.test.ts b/src/main/agent-hooks/server-accepted-reply.test.ts new file mode 100644 index 00000000000..4c3cfc0423f --- /dev/null +++ b/src/main/agent-hooks/server-accepted-reply.test.ts @@ -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): Promise { + 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 } + }) + }) +}) diff --git a/src/main/agent-hooks/server/server-accepted-reply.ts b/src/main/agent-hooks/server/server-accepted-reply.ts new file mode 100644 index 00000000000..744989fa22e --- /dev/null +++ b/src/main/agent-hooks/server/server-accepted-reply.ts @@ -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 +} diff --git a/src/main/agent-hooks/server/server-claude-status-rules.ts b/src/main/agent-hooks/server/server-claude-status-rules.ts index 300e270d61f..2c093e091cc 100644 --- a/src/main/agent-hooks/server/server-claude-status-rules.ts +++ b/src/main/agent-hooks/server/server-claude-status-rules.ts @@ -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) { diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts index a7ede15e0c0..7db72a6c592 100644 --- a/src/main/agent-hooks/server/server-listeners.ts +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -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 } diff --git a/src/main/agent-hooks/server/server-persistence.ts b/src/main/agent-hooks/server/server-persistence.ts index a3f38ab2978..c3767e2ffb8 100644 --- a/src/main/agent-hooks/server/server-persistence.ts +++ b/src/main/agent-hooks/server/server-persistence.ts @@ -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 diff --git a/src/main/agent-hooks/server/server-status-application.ts b/src/main/agent-hooks/server/server-status-application.ts index 8095f531cc0..8c179190ab4 100644 --- a/src/main/agent-hooks/server/server-status-application.ts +++ b/src/main/agent-hooks/server/server-status-application.ts @@ -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. */ diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index ca158e29b2d..0e3373e53d0 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -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 } diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index f56a69356c4..f79870c2187 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -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 { + 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 { 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) { diff --git a/src/main/agent-launch/agent-launch-opening.ts b/src/main/agent-launch/agent-launch-opening.ts new file mode 100644 index 00000000000..f1cdbc2cdd5 --- /dev/null +++ b/src/main/agent-launch/agent-launch-opening.ts @@ -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> +} + +/** Start delivery now; its outcome is captured so a late rejection is never unhandled. */ +export function opening( + surface: AgentLaunchResult, + settle: () => Promise, + terminalCreation?: RuntimeTerminalCreate +): AgentLaunchOpening { + return { + surface, + ...(terminalCreation ? { terminalCreation } : {}), + settlement: settle().then( + (value) => ({ status: 'fulfilled', value }), + (reason: unknown) => ({ status: 'rejected', reason }) + ) + } +} diff --git a/src/main/automations/automation-dispatch-request.ts b/src/main/automations/automation-dispatch-request.ts index b5661b1db24..286628e2e32 100644 --- a/src/main/automations/automation-dispatch-request.ts +++ b/src/main/automations/automation-dispatch-request.ts @@ -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.') diff --git a/src/main/automations/automation-run-agent-evidence.ts b/src/main/automations/automation-run-agent-evidence.ts index 793217c031c..2e4a11c2508 100644 --- a/src/main/automations/automation-run-agent-evidence.ts +++ b/src/main/automations/automation-run-agent-evidence.ts @@ -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 { + if (judged?.turn()?.interrupted) { + return { kind: 'failed', ...automationRunFailure('agentInterrupted') } + } + return exit.kind === 'exited' && exit.exitCode === 0 + ? { kind: 'completed' } + : { kind: 'failed', error: `${describeTerminalExitCause(exit)}.` } +} diff --git a/src/main/automations/headless-automation-agent-launch.test.ts b/src/main/automations/headless-automation-agent-launch.test.ts new file mode 100644 index 00000000000..411c2742413 --- /dev/null +++ b/src/main/automations/headless-automation-agent-launch.test.ts @@ -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) { + 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 }) + } + ) +}) diff --git a/src/main/automations/headless-automation-agent-launch.ts b/src/main/automations/headless-automation-agent-launch.ts index eb949ecc8ba..5979677c298 100644 --- a/src/main/automations/headless-automation-agent-launch.ts +++ b/src/main/automations/headless-automation-agent-launch.ts @@ -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 { - 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.') diff --git a/src/main/automations/headless-dispatch-delivery.test.ts b/src/main/automations/headless-dispatch-delivery.test.ts new file mode 100644 index 00000000000..ed197008530 --- /dev/null +++ b/src/main/automations/headless-dispatch-delivery.test.ts @@ -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((resolve) => (release = resolve)) + let reach: () => void = () => {} + const reached = new Promise((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() + } + ) +}) diff --git a/src/main/automations/headless-dispatch-runner.ts b/src/main/automations/headless-dispatch-runner.ts index e85c933fb1e..9823318fe1b 100644 --- a/src/main/automations/headless-dispatch-runner.ts +++ b/src/main/automations/headless-dispatch-runner.ts @@ -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 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) => { + applied: boolean + run: Promise + } runPrecheck: () => Promise markDispatchResult: (result: AutomationDispatchResult) => Promise watchRun: (run: AutomationRun) => void @@ -52,17 +55,16 @@ function heldForStoppedWslDistro(error: unknown): AutomationRunFailure | null { export async function runHeadlessAutomationDispatch( ctx: HeadlessAutomationDispatchContext ): Promise { - 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 + } } diff --git a/src/main/automations/headless-dispatch.ts b/src/main/automations/headless-dispatch.ts index bb1d25431c3..9b54a684c84 100644 --- a/src/main/automations/headless-dispatch.ts +++ b/src/main/automations/headless-dispatch.ts @@ -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< { diff --git a/src/main/automations/runtime-automation-service.ts b/src/main/automations/runtime-automation-service.ts index 6a97d1648be..7717e7c332e 100644 --- a/src/main/automations/runtime-automation-service.ts +++ b/src/main/automations/runtime-automation-service.ts @@ -37,6 +37,8 @@ export function createRuntimeAutomationService(input: { const launchedHandles = new Map() 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 diff --git a/src/main/automations/runtime-terminal-run-observer-evidence.test.ts b/src/main/automations/runtime-terminal-run-observer-evidence.test.ts index 1c6827cfe39..22f1dcde3be 100644 --- a/src/main/automations/runtime-terminal-run-observer-evidence.test.ts +++ b/src/main/automations/runtime-terminal-run-observer-evidence.test.ts @@ -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() 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 = {}) => (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) diff --git a/src/main/automations/runtime-terminal-run-observer.ts b/src/main/automations/runtime-terminal-run-observer.ts index b98e936adeb..5c28fa5ec33 100644 --- a/src/main/automations/runtime-terminal-run-observer.ts +++ b/src/main/automations/runtime-terminal-run-observer.ts @@ -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 { - 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, - 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 { + // 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')) +} diff --git a/src/main/automations/service-precheck.test.ts b/src/main/automations/service-precheck.test.ts index 235307e2890..392c5df8ea5 100644 --- a/src/main/automations/service-precheck.test.ts +++ b/src/main/automations/service-precheck.test.ts @@ -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: () => {} diff --git a/src/main/orcad/orcad-automations.test.ts b/src/main/orcad/orcad-automations.test.ts index 0152cd6705a..1445ef6bb96 100644 --- a/src/main/orcad/orcad-automations.test.ts +++ b/src/main/orcad/orcad-automations.test.ts @@ -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 } } diff --git a/src/main/runtime/construction/runtime-construction-dependencies.ts b/src/main/runtime/construction/runtime-construction-dependencies.ts index 04c21354818..d1b86b20ac2 100644 --- a/src/main/runtime/construction/runtime-construction-dependencies.ts +++ b/src/main/runtime/construction/runtime-construction-dependencies.ts @@ -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 diff --git a/src/main/runtime/construction/runtime-construction.ts b/src/main/runtime/construction/runtime-construction.ts index 5120b26036f..d070dbfda67 100644 --- a/src/main/runtime/construction/runtime-construction.ts +++ b/src/main/runtime/construction/runtime-construction.ts @@ -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 = diff --git a/src/main/runtime/terminal/wait/terminal-wait-results.ts b/src/main/runtime/terminal/wait/terminal-wait-results.ts index dc2996fb0de..968af0dd62a 100644 --- a/src/main/runtime/terminal/wait/terminal-wait-results.ts +++ b/src/main/runtime/terminal/wait/terminal-wait-results.ts @@ -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' diff --git a/src/shared/agent-hook-listener/listener-event.ts b/src/shared/agent-hook-listener/listener-event.ts index 0976a64b72e..a2bae4be6ac 100644 --- a/src/shared/agent-hook-listener/listener-event.ts +++ b/src/shared/agent-hook-listener/listener-event.ts @@ -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 diff --git a/src/shared/agent-hook-listener/listener-state.ts b/src/shared/agent-hook-listener/listener-state.ts index 466f0590306..fe744dde598 100644 --- a/src/shared/agent-hook-listener/listener-state.ts +++ b/src/shared/agent-hook-listener/listener-state.ts @@ -37,6 +37,8 @@ export type HookListenerState = { warnedEnvs: Set lastPromptByPaneKey: Map lastToolByPaneKey: Map + /** 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 antigravityCompletedTranscriptByPaneKey: Map @@ -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(), diff --git a/src/shared/agent-hook-listener/normalization/normalize-hook-payload.ts b/src/shared/agent-hook-listener/normalization/normalize-hook-payload.ts index 8b2c5bc2156..e04dee2d1a5 100644 --- a/src/shared/agent-hook-listener/normalization/normalize-hook-payload.ts +++ b/src/shared/agent-hook-listener/normalization/normalize-hook-payload.ts @@ -302,6 +302,7 @@ export function normalizeHookPayload( ...(statusAdmissionSession ? { statusAdmissionSession } : {}), ...(providerSessionOnly ? { providerSessionOnly: true } : {}), ...dispatched.hostTurnSignals, + ...(dispatched.replyCandidate ? { replyCandidate: dispatched.replyCandidate } : {}), payload: transportPayload } } diff --git a/src/shared/agent-hook-listener/normalization/normalize-hook-reply-candidate.test.ts b/src/shared/agent-hook-listener/normalization/normalize-hook-reply-candidate.test.ts new file mode 100644 index 00000000000..dcd70599f8a --- /dev/null +++ b/src/shared/agent-hook-listener/normalization/normalize-hook-reply-candidate.test.ts @@ -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() + }) +}) diff --git a/src/shared/agent-hook-listener/prompt-fields.ts b/src/shared/agent-hook-listener/prompt-fields.ts index 5d3a37443bc..7ed20090589 100644 --- a/src/shared/agent-hook-listener/prompt-fields.ts +++ b/src/shared/agent-hook-listener/prompt-fields.ts @@ -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 } diff --git a/src/shared/agent-hook-listener/provider-dispatch.ts b/src/shared/agent-hook-listener/provider-dispatch.ts index f1e4a06ec5f..3acd78a755c 100644 --- a/src/shared/agent-hook-listener/provider-dispatch.ts +++ b/src/shared/agent-hook-listener/provider-dispatch.ts @@ -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 + /** 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 } } diff --git a/src/shared/agent-status/lifecycle/main-agent-status.ts b/src/shared/agent-status/lifecycle/main-agent-status.ts index 304eb59d485..83d6ba6eecb 100644 --- a/src/shared/agent-status/lifecycle/main-agent-status.ts +++ b/src/shared/agent-status/lifecycle/main-agent-status.ts @@ -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