Merge orch-fix-killed-worker into integration: report a worker whose agent ends without reporting, from a launch-bound exit receipt

This commit is contained in:
Jinwoo-H
2026-10-29 14:41:59 -04:00
20 changed files with 399 additions and 18 deletions
@@ -1,3 +1,5 @@
import { createHash } from 'node:crypto'
/** Bind configured launches to their environment and PTY with the same token. */
export function agentLaunchTokenFor(
launch: { launchConfig?: unknown; launchToken?: string },
@@ -12,3 +14,13 @@ export function withAgentLaunchToken(
): Record<string, string> {
return { ...env, ...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {}) }
}
/** The commitment a Dispatch persists for its terminal's launch. */
export function agentLaunchTokenHash(launchToken: string): string {
return createHash('sha256').update(launchToken).digest('hex')
}
/** Derived from the persisted commitment so a restarted runtime can still expect it; the token itself never prints. */
export function launchExitReceiptId(launchTokenHash: string): string {
return createHash('sha256').update(`launch-exit:${launchTokenHash}`).digest('hex').slice(0, 32)
}
@@ -42,6 +42,8 @@ export class BackgroundTransientFactRelay {
onBell: () => this.emitFact(sessionId, { kind: 'bell' }),
onCommandFinished: (exitCode) =>
this.emitFact(sessionId, { kind: 'command-finished', exitCode }),
onLaunchExitReceipt: (receiptId) =>
this.emitFact(sessionId, { kind: 'launch-exit-receipt', receiptId }),
// Background toggles reset PR-link dedupe; consumers accept links as association updates.
onPrLink: (link) => this.emitFact(sessionId, { kind: 'pr-link', link }),
onMode2031Subscribe: () => this.emitFact(sessionId, { kind: '2031-subscribe' }),
+2
View File
@@ -57,6 +57,8 @@ export type DataGapEvent = {
export type DaemonTransientFact =
| { kind: 'bell' }
| { kind: 'command-finished'; exitCode: number | null }
/** Older mains ignore unknown kinds; they never appended the receipt. */
| { kind: 'launch-exit-receipt'; receiptId: string }
| { kind: 'pr-link'; link: TerminalGitHubPRLink }
| { kind: '2031-subscribe' }
| { kind: '2031-unsubscribe' }
@@ -242,12 +242,18 @@ const PROVIDER_FACT_KEYS = [
] as const
const CASE_ENV_KEYS = ['A', 'CUSTOM_FLAG']
const LAUNCH_EXIT_RECEIPT_SUFFIX =
/ ?[;&] (printf|Write-Host|powershell\.exe) .*orca-launch-exit;[0-9a-f]{32}.*$/
/** Capture provider identity, requested environment and pre-view hidden state at the spawn boundary. */
export async function providerFacts(provider: Mock): Promise<Record<string, unknown>> {
const options = recordOf(provider.mock.calls.at(-1)?.[0])
const env = recordOf(options.env)
const spawned = recordOf(await provider.mock.results.at(-1)?.value)
if (typeof options.command === 'string') {
// agent-launch-exit-receipt.test.ts covers the shell's exit receipt; parity compares the launch.
options.command = options.command.replace(LAUNCH_EXIT_RECEIPT_SUFFIX, '')
}
return {
...Object.fromEntries(
PROVIDER_FACT_KEYS.filter((k) => k in options).map((k) => [k, options[k]])
+11 -1
View File
@@ -1,4 +1,5 @@
import { withClaudeAgentViewPlugin } from '../../../shared/claude-agent-view-launch'
import { withLaunchExitReceipt } from '../../../shared/agent-launch-exit-receipt'
import { resolveStartupShell } from '../../../shared/tui-agent-startup-shell'
import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell'
import { getAppEnvironment } from '../../../shared/app-environment'
@@ -11,6 +12,7 @@ import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../../pty/legacy-terminal-
import { PI_PROCESS_OWNER_ENV_KEYS } from '../../pty/pi-process-owner-env'
import { planCodexNoDaemonLaunch } from '../../pty/codex-no-daemon-launch-command'
import { applyAgentWorkspaceTrustToSpawn } from '../../agent-workspace-trust-spawn'
import { agentLaunchTokenHash, launchExitReceiptId } from '../../agent-launch/agent-launch-token'
import type { PtySpawnOptions } from '../../providers/pty-provider-contract'
import type { CodexAccountSelectionTarget } from '../../codex-accounts/runtime-selection'
import { getStartupTerminalIngressIntent } from '../terminal-startup-color-query-replies'
@@ -172,8 +174,16 @@ export async function assemblePtySpawnLaunchOptions(input: PtySpawnLaunchOptions
cwd: input.cwd
})
const command = noDaemonLaunch ? await noDaemonLaunch : launchCommand
const launchToken = input.preAdoptedStablePane ? undefined : env?.ORCA_AGENT_LAUNCH_TOKEN
if (command !== undefined) {
spawnOptions.command = command
// The shell reports the agent's return, since a killed agent sends no hook; SSH hosts are POSIX.
spawnOptions.command = launchToken
? withLaunchExitReceipt(
command,
launchExitReceiptId(agentLaunchTokenHash(launchToken)),
connectionId ? 'posix' : shell
)
: command
}
if (args.commandDelivery !== undefined) {
spawnOptions.commandDelivery = args.commandDelivery
@@ -14,6 +14,7 @@ export type PtyDataEvent = {
export type PtyTransientFact =
| { kind: 'bell' }
| { kind: 'command-finished'; exitCode: number | null }
| { kind: 'launch-exit-receipt'; receiptId: string }
| { kind: 'pr-link'; link: TerminalGitHubPRLink }
| { kind: '2031-subscribe' }
| { kind: '2031-unsubscribe' }
@@ -56,6 +56,26 @@ export abstract class OrcaRuntimeWithAgentTransitions extends OrcaRuntimeWithMig
readScreen: (handle) => this.readTerminal(handle, { screen: true, limit: 15 })
})
/** Only the receipt of this PTY's current launch counts: a nested shell has none, a relaunch names a new one. */
protected observeLaunchExitReceipt(ptyId: string, receiptId: string): void {
const pty = this.ptysById.get(ptyId)
if (!pty) {
return
}
const launch = pty.launchExitReceipt
// A PTY reattached after restart has no launch in memory; its Dispatch's commitment names the receipt.
const restoredReceiptId = pty.launchToken === null ? receiptId : undefined
if (!restoredReceiptId) {
if (!launch || launch.id !== receiptId || launch.incarnationId !== pty.incarnationId) {
return
}
pty.launchExitReceipt = null
}
for (const paneKey of this.collectPaneKeysForPty(ptyId)) {
this.terminalWorkerTurnStops.observeAgentExited(paneKey, restoredReceiptId)
}
}
private readonly externalConversations = installExternalConversations(this.store, this)
/** The connector seam while `experimentalConnectors` is on; null refuses every connector call. */
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest'
import { makePaneKey } from '../../../shared/stable-pane-id'
import { agentLaunchTokenHash, launchExitReceiptId } from '../../agent-launch/agent-launch-token'
import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from '../agent-transcript-pane-test-harness'
import { registerBoundPty } from '../bind-first-pane.test-fixture'
import { OrchestrationDb } from './db'
const PANE = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111')
const receiptId = (launchToken: string) => launchExitReceiptId(agentLaunchTokenHash(launchToken))
const receipt = (launchToken: string) => `\x1b]7503;orca-launch-exit;${receiptId(launchToken)}\x07`
async function createWorkerPane(options: { launchAgent?: 'codex'; launchTokenHash?: string }) {
const { runtime, handle } = await createTranscriptPane({
paneTitle: 'Worker',
foregroundProcess: 'codex',
data: '',
...options
})
const db = new OrchestrationDb(':memory:')
runtime.setOrchestrationDb(db)
const run = db.createRun({
objective: 'o',
coordinatorHandle: 'term_coordinator',
coordinatorPaneKey: makePaneKey('tab-coordinator', '22222222-2222-4222-8222-222222222222')
})
const task = db.createTask({ spec: 'work', runId: run.id })
const { dispatch } = db.createStartingWorkerDispatch({
creator: { kind: 'system' },
maxDepth: Number.MAX_SAFE_INTEGER,
taskId: task.id,
startOptions: {}
})
db.prepareStartingWorkerAuthority({
dispatchId: dispatch.id,
handle,
paneKey: PANE,
processIncarnation: runtime.getTerminalProcessIncarnation(handle),
launchTokenHash: options.launchTokenHash,
worktreeId: 'wt-1',
effects: [],
setupState: 'skipped'
})
db.markWorkerDispatchReady(dispatch.id)
const mail = () =>
db.getUnreadRunMailbox(run.id, 100, ['escalation']).map((message) => message.subject)
let seq = 0
const output = (data: string) => runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, data, ++seq)
return { runtime, db, dispatch, mail, output }
}
describe('a terminal worker whose agent died while its shell lives', () => {
it("reports its current launch's exit receipt once; nested, forged and stale receipts do not", async () => {
const { runtime, db, dispatch, mail, output } = await createWorkerPane({ launchAgent: 'codex' })
try {
// A nested shell's completion, another launch's receipt, and the echoed launch line are not this agent's exit.
output('\x1b]133;C\x07ls\r\n\x1b]133;D;0\x07')
output(receipt('another-launch'))
output(`printf '\\033]7503;orca-launch-exit;${receiptId('transcript-launch')}\\007'`)
expect(mail()).toEqual([])
// The old prompt returns and a relaunch binds a new token: the old launch's receipt is stale.
output('\x1b]133;D;0\x07')
registerBoundPty(runtime, TRANSCRIPT_PANE_PTY_ID, 'wt-1', null, {
tabId: 'tab-1',
leafId: '11111111-1111-4111-8111-111111111111',
incarnationId: 'inc-1',
agentLaunchAuthority: { launchToken: 'relaunch', launchAgent: 'codex' }
})
output(receipt('transcript-launch'))
expect(mail()).toEqual([])
output(receipt('relaunch'))
output(receipt('relaunch'))
expect(mail()).toEqual(['Worker exited without reporting'])
expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched')
// The shell's own later exit settles the Dispatch without a second report.
runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0)
expect(mail()).toEqual(['Worker exited without reporting'])
expect(db.getDispatchContextById(dispatch.id)?.status).not.toBe('dispatched')
} finally {
// Let the report's coordinator wake-up run before the database closes.
await new Promise((resolve) => setImmediate(resolve))
db.close()
}
})
it("reports a worker reattached after restart from its Dispatch's launch commitment", async () => {
// A reattached PTY holds no launch token; its Dispatch persisted the commitment before the restart.
const { db, mail, output } = await createWorkerPane({
launchTokenHash: agentLaunchTokenHash('transcript-launch')
})
try {
output(receipt('another-launch'))
expect(mail()).toEqual([])
output(receipt('transcript-launch'))
output(receipt('transcript-launch'))
expect(mail()).toEqual(['Worker exited without reporting'])
} finally {
await new Promise((resolve) => setImmediate(resolve))
db.close()
}
})
})
@@ -1,4 +1,5 @@
import type { AgentPaneChange } from '../../agent-hooks/agent-transition-detector'
import { launchExitReceiptId } from '../../agent-launch/agent-launch-token'
import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict'
import type { AgentStatusIpcPayload } from '../../../shared/agent-status-ipc-payload'
import { hookRowAgentPhase } from '../../../shared/agent-transitions'
@@ -7,6 +8,7 @@ import { TerminalAgentTurnNews, type TerminalAgentTurnNewsItem } from '../termin
import type { OrchestrationDb } from './db'
import { redactWorkerTerminalView } from './worker-terminal-redaction'
import {
reportWorkerExit,
reportWorkerTurnStop,
workerTurnStopDispatchId,
type WorkerTurnStopDispatch,
@@ -47,6 +49,41 @@ export class TerminalWorkerTurnStops {
this.news.observe(change)
}
/** The launched agent returned to its shell; a killed agent sends no stop hook and the PTY stays open. */
observeAgentExited(paneKey: string, restoredReceiptId?: string): void {
const resolved = this.resolve(paneKey)
if (!resolved || (restoredReceiptId && !this.isDispatchReceipt(resolved, restoredReceiptId))) {
return
}
reportWorkerExit(
resolved.db,
resolved.target,
{
from: resolved.handle,
processIncarnation: this.deps.getProcessIncarnation(resolved.handle),
exitCause: 'the agent process ended; its terminal is still open'
},
this.deps
)
}
/** The receipt of the launch this Dispatch committed to, in the same shell process it was sent to. */
private isDispatchReceipt(
{ handle, target }: { handle: string; target: WorkerTurnStopDispatch },
receiptId: string
): boolean {
if (target.kind !== 'local') {
return false
}
const { launch_token_hash, process_incarnation } = target.dispatch
return (
!!launch_token_hash &&
!!process_incarnation &&
launchExitReceiptId(launch_token_hash) === receiptId &&
process_incarnation === this.deps.getProcessIncarnation(handle)
)
}
private async report(stop: PreparedStop): Promise<void> {
const stopped = this.stillStopped(stop)
if (!stopped) {
@@ -69,6 +69,18 @@ function describeStop(stop: WorkerTurnStop, task: string, dispatchId: string): s
].join('\n')
}
export function workerTurnStopMessageId(dispatchId: string, stopKey: string): string {
return `msg_stop_${createHash('sha256')
.update(JSON.stringify([dispatchId, stopKey]))
.digest('hex')
.slice(0, 24)}`
}
/** One key per process, so the agent's return to its shell and the PTY's later exit report it once. */
export function workerExitStopKey(processIncarnation: string | null): string {
return `exit:${processIncarnation}`
}
/** Tell the coordinator once that a worker is at rest with its Dispatch unsettled; never settles it. */
export function reportWorkerTurnStop(
db: OrchestrationDb,
@@ -87,10 +99,7 @@ export function reportWorkerTurnStop(
) {
return
}
const id = `msg_stop_${createHash('sha256')
.update(JSON.stringify([dispatchId, stop.stopKey]))
.digest('hex')
.slice(0, 24)}`
const id = workerTurnStopMessageId(dispatchId, stop.stopKey)
const payload = { dispatchId, handle: stop.from, stopReason: stop.reason }
if (target.kind === 'local') {
const { dispatch } = target
@@ -139,3 +148,23 @@ export function reportWorkerTurnStop(
console.warn('[orchestration] failed to relay a worker stop report', dispatchId, error)
}
}
export function reportWorkerExit(
db: OrchestrationDb,
target: WorkerTurnStopDispatch,
exit: { from: string; processIncarnation: string | null; exitCause: string },
deps: WorkerTurnStopReportDeps
): void {
reportWorkerTurnStop(
db,
target,
{
from: exit.from,
stopKey: workerExitStopKey(exit.processIncarnation),
reason: 'exited',
lastOutput: undefined,
exitCause: exit.exitCause
},
deps
)
}
@@ -81,6 +81,8 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & {
launchConfig: SleepingAgentLaunchConfig | null
launchToken: string | null
launchIncarnationId: PtyIncarnationId | null
/** The launch its shell's exit receipt must name; authority retirement leaves it, so a later receipt still matches. */
launchExitReceipt?: { id: string; incarnationId: PtyIncarnationId | null } | null
launchAgent: TuiAgent | null
/** Identify flagged unmanaged --command panes before foreground reads; null for adopted/restored panes. */
launchCommandAgent: TuiAgent | null
@@ -4,6 +4,7 @@ import type { RuntimePtyController } from '../../runtime-pty-controller-contract
import type { TerminalCreateOptions } from '../../runtime-terminal-contracts'
import type { RuntimePtyWorktreeRecord } from '../../runtime-terminal-state-records'
import { copySleepingAgentLaunchConfig } from '../../runtime-agent-launch-resolution'
import { agentLaunchTokenHash, launchExitReceiptId } from '../../../agent-launch/agent-launch-token'
type TerminalSpawnResult = Awaited<ReturnType<NonNullable<RuntimePtyController['spawn']>>>
@@ -56,6 +57,9 @@ export function recordTerminalLaunchMetadata(
if (pty.launchToken && !pty.launchIncarnationId) {
pty.launchIncarnationId = pty.incarnationId
}
if (pty.launchToken) {
bindLaunchExitReceipt(pty)
}
} else {
pty.launchConfig = launch.launchConfig
? copySleepingAgentLaunchConfig(launch.launchConfig)
@@ -63,9 +67,19 @@ export function recordTerminalLaunchMetadata(
pty.launchToken = launch.launchToken ?? null
pty.launchIncarnationId = launch.launchToken ? pty.incarnationId : null
pty.launchAgent = launch.launchAgent ?? null
bindLaunchExitReceipt(pty)
}
}
export function bindLaunchExitReceipt(pty: RuntimePtyWorktreeRecord): void {
pty.launchExitReceipt = pty.launchToken
? {
id: launchExitReceiptId(agentLaunchTokenHash(pty.launchToken)),
incarnationId: pty.launchIncarnationId
}
: null
}
export function terminalLaunchPublicFacts(
result: TerminalSpawnResult,
adoptedStablePane: boolean,
@@ -22,7 +22,11 @@ import { parseWorkerTerminalHostScope } from '../../../../shared/worker-terminal
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id'
import { escalateWorkerFailure } from '../../orchestration/worker-failure-escalation'
import { dispatchReportMailbox } from '../../orchestration/dispatch-report-mailbox'
import { reportWorkerTurnStop } from '../../orchestration/worker-turn-stop-report'
import {
reportWorkerExit,
workerExitStopKey,
workerTurnStopMessageId
} from '../../orchestration/worker-turn-stop-report'
import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../../../shared/execution-host'
import type { OrchestrationDb } from '../../orchestration/db'
@@ -65,6 +69,8 @@ export abstract class OrcaRuntimeWithSubscribeToTerminalResize extends OrcaRunti
processExited: true,
// Escalate unexpected exits on the next coordinator check even before its circuit breaker trips.
escalate: !isDeliberateTerminalExit(cause),
// An agent exit receipt already sent this process's exit report.
stopKey: workerExitStopKey(this.getTerminalProcessIncarnation(handle)),
describe: (task, outcome) => ({
subject: `Agent exited unexpectedly (${errorContext})`,
body: `Worker ${handle} stopped while running task ${task}. ${errorContext}.${outcome}`
@@ -92,16 +98,10 @@ export abstract class OrcaRuntimeWithSubscribeToTerminalResize extends OrcaRunti
) {
return
}
reportWorkerTurnStop(
reportWorkerExit(
db,
{ kind: 'remote', attachment },
{
from: handle,
stopKey: `exit:${processIncarnation}`,
reason: 'exited',
lastOutput: undefined,
exitCause
},
{ from: handle, processIncarnation, exitCause },
this.workerTurnStopReportDeps
)
}
@@ -151,6 +151,7 @@ export abstract class OrcaRuntimeWithSubscribeToTerminalResize extends OrcaRunti
options: Parameters<OrchestrationDb['failDispatch']>[2]
processExited: boolean
escalate: boolean
stopKey?: string
describe: (task: string, outcome: string) => { subject: string; body: string }
payload: Record<string, unknown>
}
@@ -177,6 +178,7 @@ export abstract class OrcaRuntimeWithSubscribeToTerminalResize extends OrcaRunti
db,
dispatch,
{
...(failure.stopKey ? { id: workerTurnStopMessageId(dispatch.id, failure.stopKey) } : {}),
from: handle,
mailbox: () => dispatchReportMailbox(db, dispatch),
describe: (task) =>
@@ -5,6 +5,7 @@ import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id'
import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import { spawnSurfaceClaimSequence } from '../../pty-recorded-surface-topology'
import { bindLaunchExitReceipt } from '../launch/terminal-launch-result'
import type {
PtyRegistrationClaim,
RuntimePtyWorktreeRecord
@@ -144,6 +145,7 @@ export abstract class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalida
pty.launchToken = agentLaunchAuthority.launchToken
pty.launchIncarnationId = binding.incarnationId
pty.launchAgent = agentLaunchAuthority.launchAgent
bindLaunchExitReceipt(pty)
}
const providerReattachLaunchIdentity = binding?.providerReattachLaunchIdentity
if (
@@ -2,7 +2,7 @@ import type { AgentStatusOrchestrationContext } from '../../../../shared/agent-s
import type { OrchestrationDb } from '../../orchestration/db/orchestration-db'
import { OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller } from '../../orchestration/terminal-authority/verify-orchestration-compatibility-caller'
import type { OrchestrationCompatibilityTerminalAuthority } from '../../runtime-terminal-contracts'
import { createHash } from 'node:crypto'
import { agentLaunchTokenHash } from '../../../agent-launch/agent-launch-token'
import {
isTerminalLeafId,
makePaneKey,
@@ -126,9 +126,7 @@ export abstract class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends O
worktreeId: pty.worktreeId,
processIncarnation: this.getTerminalProcessIncarnation(terminalHandle),
paneKey: this.paneOfPty(pty).paneKey,
launchTokenHash: pty.launchToken
? createHash('sha256').update(pty.launchToken).digest('hex')
: null,
launchTokenHash: pty.launchToken ? agentLaunchTokenHash(pty.launchToken) : null,
hostScope
}
}
@@ -168,6 +168,9 @@ export abstract class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends Or
this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode })
this.openCodeRunLifetime.onCommandFinished(ptyId, exitCode)
},
onLaunchExitReceipt: (receiptId) => {
this.observeLaunchExitReceipt(ptyId, receiptId)
},
onBell: () => {
this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' })
},
@@ -22,6 +22,7 @@ import type { TerminalTitleFactMeta } from '../../../../shared/terminal-output-s
export abstract class OrcaRuntimeWithEmitDaemonPtyTransientFact extends OrcaRuntimeWithScheduleWaitBlockedCheck {
protected abstract retirePtyAgentLaunchAuthority(ptyId: string): void
protected abstract observeLaunchExitReceipt(ptyId: string, receiptId: string): void
/** A transient fact the daemon detected while it held scan authority —
* emitted through the same fact channel as byte-scanned facts. Arrives
@@ -39,6 +40,9 @@ export abstract class OrcaRuntimeWithEmitDaemonPtyTransientFact extends OrcaRunt
})
this.openCodeRunLifetime.onCommandFinished(ptyId, fact.exitCode)
return
case 'launch-exit-receipt':
this.observeLaunchExitReceipt(ptyId, fact.receiptId)
return
case 'pr-link':
this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link: fact.link })
return
@@ -0,0 +1,58 @@
import { execFileSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
import { createLaunchExitReceiptScanner, withLaunchExitReceipt } from './agent-launch-exit-receipt'
import type { AgentStartupShell } from './tui-agent-startup-shell'
const RECEIPT_ID = '0123456789abcdef0123456789abcdef'
function hasCommand(command: string, args: string[]): boolean {
try {
execFileSync(command, args, { stdio: 'ignore' })
return true
} catch {
return false
}
}
// Each case runs a launch that dies by its own hand, then the receipt the shell prints after it.
const SHELLS: { shell: AgentStartupShell; run: string[]; agent: string; available: boolean }[] = [
{
shell: 'posix',
run: ['sh', '-c'],
agent: `sh -c 'kill -9 $$'`,
available: process.platform !== 'win32'
},
{
shell: 'powershell',
run: ['pwsh', '-NoProfile', '-NonInteractive', '-Command'],
agent: 'pwsh -NoProfile -Command exit 9',
available: hasCommand('pwsh', ['-NoProfile', '-Command', 'exit 0'])
},
{
shell: 'cmd',
run: ['cmd.exe', '/d', '/c'],
agent: 'cmd /c exit 9',
available: process.platform === 'win32'
}
]
describe('launch exit receipt', () => {
it.each(SHELLS.filter((each) => each.available))(
'$shell prints the receipt after its launch dies, and the scanner reads it across chunks',
({ shell, run, agent }) => {
const [command, ...args] = run
// The receipt is the line's last command, so the line succeeds whatever the launch did.
const output = execFileSync(
command,
[...args, withLaunchExitReceipt(agent, RECEIPT_ID, shell)],
{ encoding: 'utf8' }
)
const seen: string[] = []
const scanner = createLaunchExitReceiptScanner((id) => seen.push(id))
const split = output.indexOf(RECEIPT_ID)
scanner.scan(output.slice(0, split))
scanner.scan(output.slice(split))
expect(seen).toEqual([RECEIPT_ID])
}
)
})
+66
View File
@@ -0,0 +1,66 @@
/** A launch-bound marker the shell prints once the launched agent returns, however it ended (exit, crash, SIGKILL). */
import { ownRetainedString } from './own-retained-string'
import type { AgentStartupShell } from './tui-agent-startup-shell'
const RECEIPT_PREFIX = '\x1b]7503;orca-launch-exit;'
const RECEIPT_ID = /^[0-9a-f]{32}$/
// oxlint-disable-next-line no-control-regex -- the receipt is an ESC…BEL OSC by definition.
const RECEIPT = /\x1b\]7503;orca-launch-exit;([0-9a-f]{32})\x07/g
const RECEIPT_LENGTH = RECEIPT_PREFIX.length + 32 + 1
/** Terminals ignore this private OSC; the typed line holds only its escaped spelling, so echo cannot match. */
export function withLaunchExitReceipt(
command: string,
receiptId: string,
shell: AgentStartupShell
): string {
if (!RECEIPT_ID.test(receiptId)) {
throw new Error('withLaunchExitReceipt: receipt id must be 32 lowercase hex characters')
}
// Single-quoted printf parses alike in sh, bash, zsh, dash and fish; `$?` would not parse in fish.
if (shell === 'posix') {
return `${command}; printf '\\033]7503;orca-launch-exit;${receiptId}\\007'`
}
const write = `Write-Host -NoNewline ([string][char]27+']7503;orca-launch-exit;${receiptId}'+[char]7)`
if (shell === 'powershell') {
return `${command}; ${write}`
}
// cmd's echo cannot emit ESC, and a typed ESC clears its line.
return `${command} & powershell.exe -NoLogo -NoProfile -NonInteractive -Command ${write}`
}
export type LaunchExitReceiptScanner = {
scan: (data: string) => void
reset: () => void
}
export function createLaunchExitReceiptScanner(
onReceipt: (receiptId: string) => void
): LaunchExitReceiptScanner {
// Only an ESC-led tail shorter than a full receipt can continue into the next chunk.
let carry = ''
return {
scan(data) {
if (!carry && !data.includes('\x1b')) {
return
}
const text = carry + data
let scannedTo = 0
if (text.includes(RECEIPT_PREFIX)) {
for (const match of text.matchAll(RECEIPT)) {
onReceipt(match[1])
scannedTo = match.index + match[0].length
}
}
const escape = text.lastIndexOf('\x1b')
carry =
escape >= scannedTo && text.length - escape < RECEIPT_LENGTH
? ownRetainedString(text.slice(escape))
: ''
},
reset() {
carry = ''
}
}
}
@@ -24,6 +24,7 @@ import {
type TerminalGitHubPRLink
} from './terminal-github-pr-link-detector'
import { createOsc133CommandFinishedScanner } from './terminal-osc133-command-finished'
import { createLaunchExitReceiptScanner } from './agent-launch-exit-receipt'
/** Ms of title-less output after a working title before it is cleared. */
export const STALE_WORKING_TITLE_TIMEOUT_MS = 3000
@@ -55,6 +56,8 @@ export type TerminalTitleTrackerCallbacks = {
onBell?: () => void
/** Complete OSC 133;D with best-effort exit code; fact and byte paths must retire stale agent rows alike. */
onCommandFinished?: (bestEffortExitCode: number | null) => void
/** The shell printed a launch's exit receipt: the agent it launched has returned. */
onLaunchExitReceipt?: (receiptId: string) => void
/** Fired per complete OSC 133;C: the shell exec'd a command, so the pane's foreground changed. */
onCommandStarted?: () => void
/** Fired once per newly observed GitHub PR URL (chunk-boundary-safe, deduplicated per tracker). */
@@ -98,6 +101,7 @@ export function createTerminalTitleTracker(
onProgramActivity,
onBell,
onCommandFinished,
onLaunchExitReceipt,
onCommandStarted,
onPrLink,
onMode2031Subscribe,
@@ -112,6 +116,9 @@ export function createTerminalTitleTracker(
onCommandStarted ? () => onCommandStarted() : undefined
)
: null
const launchExitReceiptScanner = onLaunchExitReceipt
? createLaunchExitReceiptScanner(onLaunchExitReceipt)
: null
let prLinkDetector = onPrLink ? createTerminalGitHubPRLinkDetector() : null
let transientSideEffectScanningEnabled = true
let transientFactScanningSuppressed = false
@@ -229,6 +236,7 @@ export function createTerminalTitleTracker(
}
// Fact order (matches renderer drain): titles → command-finished → pr-link → 2031-subscribe → bell; bell last.
if (!transientFactScanningSuppressed) {
launchExitReceiptScanner?.scan(data)
commandFinishedScanner?.scan(data)
if (prLinkDetector) {
for (const link of prLinkDetector(data)) {
@@ -300,6 +308,7 @@ export function createTerminalTitleTracker(
// Cross-chunk carry predates the gapped span; reset it so stale state can't swallow real bells or mint phantom facts.
bellDetector?.reset()
commandFinishedScanner?.reset()
launchExitReceiptScanner?.reset()
mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE
if (prLinkDetector) {
prLinkDetector = createTerminalGitHubPRLinkDetector()
@@ -321,6 +330,7 @@ export function createTerminalTitleTracker(
agentTracker?.reset()
bellDetector?.reset()
commandFinishedScanner?.reset()
launchExitReceiptScanner?.reset()
mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE
}
}