mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
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:
@@ -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' }),
|
||||
|
||||
@@ -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]])
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user