mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
fix(orchestration): let a failed start release the terminal it created
A start that died before prepareStartingWorkerAuthority recorded no ownership row, so worker-release answered no_owned_resource, worker-stop reported already settled, and the PTY it created had no owner that could ever close it. Record the ownership the successful path would have recorded, gated on provable pane and process identity and on no other live resource accounting for that process. worker-release then proceeds through the existing table, worker-list reports the row reclaimable, and the failure receipt names the command. Extract the parseTaskDeps copy the local and federated start paths duplicated.
This commit is contained in:
@@ -116,6 +116,10 @@ closes only the exact terminal owned by that settled Dispatch. Replays may call
|
||||
release again safely. Reused, pre-existing, setup, coordinator, active,
|
||||
user-taken-over, and unproven terminals are retained.
|
||||
|
||||
A `worker-start` that failed before its agent was ready still owns the terminal
|
||||
it created. Its receipt names `worker-release`, and `worker-list` reports that
|
||||
row as `reclaimable`; release it there rather than closing the terminal by hand.
|
||||
|
||||
Never release because of timeout, TUI idle, heartbeat, status, question,
|
||||
escalation, or stale/rejected completion. If the receipt says `release_pending`
|
||||
or `release_unknown`, follow its exact recovery action. Never substitute
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,6 +2,10 @@ import type { WorkerDispatchRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
import { transitionLifecycleWithDb } from '../lifecycle-transition'
|
||||
import {
|
||||
adoptFailedStartTerminal,
|
||||
type FailedStartTerminalAdoption
|
||||
} from '../worker-terminal/failed-start-terminal-adoption'
|
||||
|
||||
export function markWorkerDispatchReady(
|
||||
this: OrchestrationDb,
|
||||
@@ -47,7 +51,11 @@ export function failWorkerStart(
|
||||
// Why (#16095): revocation exists to stop a worker acting on a dispatch that never landed. A
|
||||
// prompt whose turn start went unobserved provably landed, so its worker keeps the authority its
|
||||
// own report needs.
|
||||
options: { retainCapability?: boolean } = {}
|
||||
options: {
|
||||
retainCapability?: boolean
|
||||
/** A start that died before authority attached still owns the terminal it created. */
|
||||
adoptResidualTerminal?: FailedStartTerminalAdoption
|
||||
} = {}
|
||||
): WorkerDispatchRow {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
@@ -96,6 +104,11 @@ export function failWorkerStart(
|
||||
})
|
||||
}
|
||||
this.closeQuestionsForDispatch(dispatchId)
|
||||
adoptFailedStartTerminal(
|
||||
this,
|
||||
this.getWorkerDispatch(dispatchId) as WorkerDispatchRow,
|
||||
options.adoptResidualTerminal
|
||||
)
|
||||
this.db.exec('COMMIT')
|
||||
return this.getWorkerDispatch(dispatchId) as WorkerDispatchRow
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { WorkerDispatchRow } from '../../types'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
/** Identity of a terminal this worker-start created and never handed to an owner. */
|
||||
export type FailedStartTerminalAdoption = {
|
||||
terminalHandle: string
|
||||
worktreeId: string | null
|
||||
paneKey: string
|
||||
processIncarnation: string
|
||||
hostScope?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A start that dies before `prepareStartingWorkerAuthority` leaves the terminal it created with no
|
||||
* owner, so no release path can ever close it and the fleet can only say `inspect`. Record the
|
||||
* ownership the successful path would have recorded, so ordinary `worker-release` owns the cleanup.
|
||||
*
|
||||
* No transaction: composes inside `failWorkerStart`'s.
|
||||
*/
|
||||
export function adoptFailedStartTerminal(
|
||||
db: OrchestrationDb,
|
||||
worker: WorkerDispatchRow,
|
||||
adoption: FailedStartTerminalAdoption | undefined
|
||||
): void {
|
||||
if (!adoption || worker.agent_terminal_handle !== adoption.terminalHandle) {
|
||||
return
|
||||
}
|
||||
if (db.getWorkerTerminalResourceByOwner(worker.dispatch_id)) {
|
||||
return
|
||||
}
|
||||
// A second owner for one process could close it twice, or close a terminal already handed on.
|
||||
const conflict = db.db
|
||||
.prepare(
|
||||
`SELECT 1 FROM worker_terminal_resources
|
||||
WHERE ownership_state <> 'released'
|
||||
AND (terminal_handle = ? OR process_incarnation = ?) LIMIT 1`
|
||||
)
|
||||
.get(adoption.terminalHandle, adoption.processIncarnation)
|
||||
if (conflict) {
|
||||
return
|
||||
}
|
||||
db.createWorkerTerminalResourceStatement({
|
||||
dispatchId: worker.dispatch_id,
|
||||
worktreeId: adoption.worktreeId ?? worker.worktree_id,
|
||||
terminalHandle: adoption.terminalHandle,
|
||||
paneKey: adoption.paneKey,
|
||||
processIncarnation: adoption.processIncarnation,
|
||||
endpointId: worker.runtime_epoch ?? null,
|
||||
endpointIncarnation: adoption.processIncarnation,
|
||||
hostScope: adoption.hostScope ?? null,
|
||||
ownership: 'owned'
|
||||
})
|
||||
// Release re-proves identity through the Dispatch context, which a failed start never filled in.
|
||||
// This records which pane the Dispatch owns; `capability_hash` stays null, so it grants nothing.
|
||||
db.db
|
||||
.prepare(
|
||||
`UPDATE dispatch_contexts
|
||||
SET assignee_handle = ?, assignee_pane_key = ?, process_incarnation = ?, host_scope = ?
|
||||
WHERE id = ? AND status = 'failed' AND capability_hash IS NULL`
|
||||
)
|
||||
.run(
|
||||
adoption.terminalHandle,
|
||||
adoption.paneKey,
|
||||
adoption.processIncarnation,
|
||||
adoption.hostScope ?? null,
|
||||
worker.dispatch_id
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { OrchestrationDb } from './db'
|
||||
|
||||
const HANDLE = 'term_residual'
|
||||
const PANE_KEY = 'tab_residual:leaf_residual'
|
||||
const INCARNATION = 'runtime:pty-residual:1'
|
||||
|
||||
describe('a start that fails before authority still owns the terminal it created', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
|
||||
afterEach(() => {
|
||||
db?.close()
|
||||
})
|
||||
|
||||
/** Replays the shipping order: readiness stage records the handle, then the wait fails. */
|
||||
function failStartAfterCreatingTerminal(
|
||||
adoption?: Parameters<OrchestrationDb['failWorkerStart']>[3]
|
||||
): { db: OrchestrationDb; dispatchId: string } {
|
||||
const d = (db = new OrchestrationDb(':memory:'))
|
||||
const task = d.createTask({ spec: 'residual terminal' })
|
||||
const started = d.createStartingWorkerDispatch({
|
||||
creator: { kind: 'system' },
|
||||
maxDepth: Number.MAX_SAFE_INTEGER,
|
||||
taskId: task.id,
|
||||
startOptions: {}
|
||||
})
|
||||
const effects = [
|
||||
{ kind: 'terminal', role: 'agent', action: 'created', id: HANDLE, surface: 'visible' }
|
||||
]
|
||||
d.recordWorkerStage({
|
||||
dispatchId: started.dispatch.id,
|
||||
stage: 'terminal_readying',
|
||||
worktreeId: 'repo::worktree',
|
||||
terminalHandle: HANDLE,
|
||||
effects,
|
||||
residualResources: effects
|
||||
})
|
||||
d.failWorkerStart(
|
||||
started.dispatch.id,
|
||||
'agent_readiness',
|
||||
'Agent startup blocked: codex-interactive-prompt',
|
||||
adoption
|
||||
)
|
||||
return { db: d, dispatchId: started.dispatch.id }
|
||||
}
|
||||
|
||||
const adoption = {
|
||||
adoptResidualTerminal: {
|
||||
terminalHandle: HANDLE,
|
||||
worktreeId: 'repo::worktree',
|
||||
paneKey: PANE_KEY,
|
||||
processIncarnation: INCARNATION,
|
||||
hostScope: null
|
||||
}
|
||||
}
|
||||
|
||||
it('leaves nothing that can close the terminal when the start is not adopted', () => {
|
||||
const { db: d, dispatchId } = failStartAfterCreatingTerminal()
|
||||
|
||||
expect(d.getWorkerTerminalResourceByOwner(dispatchId)).toBeUndefined()
|
||||
expect(d.requestWorkerTerminalRelease(dispatchId)).toMatchObject({
|
||||
disposition: 'retained',
|
||||
reason: 'no_owned_resource'
|
||||
})
|
||||
})
|
||||
|
||||
it('records the ownership the successful path would have recorded', () => {
|
||||
const { db: d, dispatchId } = failStartAfterCreatingTerminal(adoption)
|
||||
|
||||
expect(d.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({
|
||||
owner_dispatch_id: dispatchId,
|
||||
terminal_handle: HANDLE,
|
||||
pane_key: PANE_KEY,
|
||||
process_incarnation: INCARNATION,
|
||||
ownership_state: 'owned',
|
||||
release_state: 'not_requested'
|
||||
})
|
||||
})
|
||||
|
||||
it('lets worker-release proceed on the failed dispatch', () => {
|
||||
const { db: d, dispatchId } = failStartAfterCreatingTerminal(adoption)
|
||||
|
||||
expect(d.requestWorkerTerminalRelease(dispatchId)).toMatchObject({
|
||||
disposition: 'requested',
|
||||
resource: { release_state: 'requested' }
|
||||
})
|
||||
})
|
||||
|
||||
it('re-proves identity through the dispatch context release reads', () => {
|
||||
const { db: d, dispatchId } = failStartAfterCreatingTerminal(adoption)
|
||||
|
||||
expect(
|
||||
d.isDispatchProcessCurrent({ dispatchId, paneKey: PANE_KEY, processIncarnation: INCARNATION })
|
||||
).toBe(true)
|
||||
// Adoption records which pane the dispatch owns; it never restores authority over it.
|
||||
expect(d.getDispatchContextById(dispatchId)).toMatchObject({
|
||||
status: 'failed',
|
||||
capability_hash: null
|
||||
})
|
||||
expect(d.getDispatchContextById(dispatchId)?.capability_revoked_at).not.toBeNull()
|
||||
})
|
||||
|
||||
it('publishes the terminal as reclaimable so the fleet names release', () => {
|
||||
const { db: d, dispatchId } = failStartAfterCreatingTerminal(adoption)
|
||||
|
||||
expect(d.listWorkerTerminalResources({ dispatchIds: [dispatchId] })[0]).toMatchObject({
|
||||
agentTerminalHandle: HANDLE,
|
||||
terminalState: 'reclaimable'
|
||||
})
|
||||
})
|
||||
|
||||
it('never claims a terminal the durable row does not name', () => {
|
||||
const { db: d, dispatchId } = failStartAfterCreatingTerminal({
|
||||
adoptResidualTerminal: { ...adoption.adoptResidualTerminal, terminalHandle: 'term_other' }
|
||||
})
|
||||
|
||||
expect(d.getWorkerTerminalResourceByOwner(dispatchId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never claims a terminal another live resource already accounts for', () => {
|
||||
const d = (db = new OrchestrationDb(':memory:'))
|
||||
const first = d.createStartingWorkerDispatch({
|
||||
creator: { kind: 'system' },
|
||||
maxDepth: Number.MAX_SAFE_INTEGER,
|
||||
taskId: d.createTask({ spec: 'owner' }).id,
|
||||
startOptions: {}
|
||||
})
|
||||
d.prepareStartingWorkerAuthority({
|
||||
dispatchId: first.dispatch.id,
|
||||
handle: HANDLE,
|
||||
paneKey: PANE_KEY,
|
||||
processIncarnation: INCARNATION,
|
||||
worktreeId: 'repo::worktree',
|
||||
setupState: 'not_applicable',
|
||||
effects: [],
|
||||
terminalOwnership: 'created'
|
||||
})
|
||||
const second = d.createStartingWorkerDispatch({
|
||||
creator: { kind: 'system' },
|
||||
maxDepth: Number.MAX_SAFE_INTEGER,
|
||||
taskId: d.createTask({ spec: 'claimant' }).id,
|
||||
startOptions: {}
|
||||
})
|
||||
d.recordWorkerStage({
|
||||
dispatchId: second.dispatch.id,
|
||||
stage: 'terminal_readying',
|
||||
terminalHandle: HANDLE
|
||||
})
|
||||
|
||||
d.failWorkerStart(second.dispatch.id, 'agent_readiness', 'blocked', adoption)
|
||||
|
||||
expect(d.getWorkerTerminalResourceByOwner(second.dispatch.id)).toBeUndefined()
|
||||
expect(d.getWorkerTerminalResourceByOwner(first.dispatch.id)).toMatchObject({
|
||||
ownership_state: 'owned'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -31,24 +31,7 @@ import {
|
||||
federatedUnknownReceipt,
|
||||
isKnownRemoteStartFailure
|
||||
} from './federated-worker-start-receipts'
|
||||
|
||||
function parseTaskDeps(value: string | undefined): string[] | undefined {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (!Array.isArray(parsed) || !parsed.every((item) => typeof item === 'string')) {
|
||||
throw new Error('not an array of strings')
|
||||
}
|
||||
return parsed
|
||||
} catch {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'Invalid --deps: must be a JSON array of task IDs'
|
||||
)
|
||||
}
|
||||
}
|
||||
import { parseTaskDeps } from '../worker/task-deps-argument'
|
||||
|
||||
export async function startFederatedWorker(args: {
|
||||
params: WorkerStartInput
|
||||
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { OrcaRuntimeService } from '../../../../orca-runtime'
|
||||
import { OrchestrationDb } from '../../../../orchestration/db'
|
||||
import { resolveResidualAgentTerminal } from './failed-start-residual-terminal'
|
||||
import { failWorkerStartWithReceipt } from './worker-start-receipt'
|
||||
import type { WorkerEffect } from './worker-topology'
|
||||
|
||||
const HANDLE = 'term_residual'
|
||||
const PANE_KEY = 'tab_residual:leaf_residual'
|
||||
const INCARNATION = 'pty-residual:1'
|
||||
|
||||
const createdAgentTerminal: WorkerEffect = {
|
||||
kind: 'terminal',
|
||||
role: 'agent',
|
||||
action: 'created',
|
||||
id: HANDLE,
|
||||
surface: 'visible'
|
||||
}
|
||||
|
||||
function createRuntime(overrides: Partial<Record<string, unknown>> = {}): OrcaRuntimeService {
|
||||
return {
|
||||
getOrchestrationDispatchAuthority: () => ({
|
||||
paneKey: PANE_KEY,
|
||||
processIncarnation: INCARNATION,
|
||||
hostScope: { kind: 'local', hostId: 'local' }
|
||||
}),
|
||||
getTerminalPaneKey: () => PANE_KEY,
|
||||
getTerminalProcessIncarnation: () => INCARNATION,
|
||||
...overrides
|
||||
} as unknown as OrcaRuntimeService
|
||||
}
|
||||
|
||||
describe('residual agent terminal left by a failed start', () => {
|
||||
it('resolves identity for a terminal this start created', () => {
|
||||
expect(
|
||||
resolveResidualAgentTerminal({
|
||||
runtime: createRuntime(),
|
||||
effects: [createdAgentTerminal],
|
||||
terminalHandle: HANDLE,
|
||||
worktreeId: 'repo::worktree'
|
||||
})
|
||||
).toEqual({
|
||||
terminalHandle: HANDLE,
|
||||
worktreeId: 'repo::worktree',
|
||||
paneKey: PANE_KEY,
|
||||
processIncarnation: INCARNATION,
|
||||
hostScope: JSON.stringify({ kind: 'local', hostId: 'local' })
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves the agent-first worktree terminal the same way', () => {
|
||||
expect(
|
||||
resolveResidualAgentTerminal({
|
||||
runtime: createRuntime(),
|
||||
effects: [{ ...createdAgentTerminal, action: 'reused_agent_terminal' }],
|
||||
terminalHandle: HANDLE,
|
||||
worktreeId: null
|
||||
})
|
||||
).toMatchObject({ terminalHandle: HANDLE })
|
||||
})
|
||||
|
||||
it('never claims a caller-supplied terminal', () => {
|
||||
expect(
|
||||
resolveResidualAgentTerminal({
|
||||
runtime: createRuntime(),
|
||||
effects: [{ ...createdAgentTerminal, action: 'reused' }],
|
||||
terminalHandle: HANDLE,
|
||||
worktreeId: null
|
||||
})
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never claims a setup terminal', () => {
|
||||
expect(
|
||||
resolveResidualAgentTerminal({
|
||||
runtime: createRuntime(),
|
||||
effects: [{ ...createdAgentTerminal, role: 'setup' }],
|
||||
terminalHandle: HANDLE,
|
||||
worktreeId: null
|
||||
})
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refuses a pane whose process cannot be identified', () => {
|
||||
expect(
|
||||
resolveResidualAgentTerminal({
|
||||
runtime: createRuntime({
|
||||
getOrchestrationDispatchAuthority: () => null,
|
||||
getTerminalProcessIncarnation: () => null
|
||||
}),
|
||||
effects: [createdAgentTerminal],
|
||||
terminalHandle: HANDLE,
|
||||
worktreeId: null
|
||||
})
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refuses when the start never resolved a terminal', () => {
|
||||
expect(
|
||||
resolveResidualAgentTerminal({
|
||||
runtime: createRuntime(),
|
||||
effects: [],
|
||||
terminalHandle: undefined,
|
||||
worktreeId: null
|
||||
})
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stays silent when identity resolution throws', () => {
|
||||
expect(
|
||||
resolveResidualAgentTerminal({
|
||||
runtime: createRuntime({
|
||||
getOrchestrationDispatchAuthority: () => {
|
||||
throw new Error('handle retired')
|
||||
}
|
||||
}),
|
||||
effects: [createdAgentTerminal],
|
||||
terminalHandle: HANDLE,
|
||||
worktreeId: null
|
||||
})
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('failed worker-start receipt for a residual terminal', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
|
||||
afterEach(() => {
|
||||
db?.close()
|
||||
})
|
||||
|
||||
function failStart(residual: boolean): { recovery?: string } {
|
||||
const d = (db = new OrchestrationDb(':memory:'))
|
||||
const task = d.createTask({ spec: 'residual receipt' })
|
||||
const started = d.createStartingWorkerDispatch({
|
||||
creator: { kind: 'system' },
|
||||
maxDepth: Number.MAX_SAFE_INTEGER,
|
||||
taskId: task.id,
|
||||
startOptions: {}
|
||||
})
|
||||
d.recordWorkerStage({
|
||||
dispatchId: started.dispatch.id,
|
||||
stage: 'terminal_readying',
|
||||
terminalHandle: HANDLE,
|
||||
effects: [createdAgentTerminal],
|
||||
residualResources: [createdAgentTerminal]
|
||||
})
|
||||
return failWorkerStartWithReceipt({
|
||||
db: d,
|
||||
runId: 'run_residual',
|
||||
taskId: task.id,
|
||||
dispatchId: started.dispatch.id,
|
||||
failedStage: 'agent_readiness',
|
||||
error: new Error('Agent startup blocked: codex-interactive-prompt'),
|
||||
setup: {
|
||||
requested: 'not_applicable',
|
||||
effective: 'not_applicable',
|
||||
source: 'existing_worktree',
|
||||
hookFound: false,
|
||||
startupPolicy: 'start-immediately',
|
||||
state: 'not_applicable'
|
||||
},
|
||||
launch: { requested: { agent: 'codex' }, effective: { agent: 'codex' } } as never,
|
||||
...(residual
|
||||
? {
|
||||
residualAgentTerminal: {
|
||||
terminalHandle: HANDLE,
|
||||
worktreeId: 'repo::worktree',
|
||||
paneKey: PANE_KEY,
|
||||
processIncarnation: INCARNATION,
|
||||
hostScope: null
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}) as { recovery?: string }
|
||||
}
|
||||
|
||||
it('names worker-release for the terminal it left behind', () => {
|
||||
expect(failStart(true).recovery).toContain('worker-release')
|
||||
})
|
||||
|
||||
it('promises no cleanup when there is no residual terminal', () => {
|
||||
expect(failStart(false).recovery).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { OrcaRuntimeService } from '../../../../orca-runtime'
|
||||
import type { FailedStartTerminalAdoption } from '../../../../orchestration/db/worker-terminal/failed-start-terminal-adoption'
|
||||
import type { WorkerEffect } from './worker-topology'
|
||||
|
||||
/** True only for an agent terminal this worker-start brought into existence. An explicit
|
||||
* `--terminal` reuse records `reused` and is never residual — it is the caller's terminal. */
|
||||
function orchestrationCreatedAgentTerminal(
|
||||
effects: readonly WorkerEffect[],
|
||||
handle: string
|
||||
): boolean {
|
||||
return effects.some(
|
||||
(effect) =>
|
||||
effect.kind === 'terminal' &&
|
||||
effect.role === 'agent' &&
|
||||
effect.id === handle &&
|
||||
(effect.action?.startsWith('created') === true || effect.action === 'reused_agent_terminal')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity for the terminal a failed start leaves behind, so the failed Dispatch can own it and
|
||||
* `worker-release` can close it. Returns nothing unless the pane and process are both provable:
|
||||
* an unprovable identity must never authorize a later close.
|
||||
*/
|
||||
export function resolveResidualAgentTerminal(args: {
|
||||
runtime: OrcaRuntimeService
|
||||
effects: readonly WorkerEffect[]
|
||||
terminalHandle: string | undefined
|
||||
worktreeId: string | null
|
||||
}): FailedStartTerminalAdoption | undefined {
|
||||
const handle = args.terminalHandle
|
||||
if (!handle || !orchestrationCreatedAgentTerminal(args.effects, handle)) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const authority = args.runtime.getOrchestrationDispatchAuthority(handle)
|
||||
const paneKey = authority?.paneKey ?? args.runtime.getTerminalPaneKey(handle)
|
||||
const processIncarnation =
|
||||
authority?.processIncarnation ?? args.runtime.getTerminalProcessIncarnation(handle)
|
||||
if (!paneKey || !processIncarnation) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
terminalHandle: handle,
|
||||
worktreeId: args.worktreeId,
|
||||
paneKey,
|
||||
processIncarnation,
|
||||
hostScope: authority?.hostScope ? JSON.stringify(authority.hostScope) : null
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
persistWorkerSetupWaitOutcome
|
||||
} from './worker-setup-gate'
|
||||
import { failWorkerStartWithReceipt } from './worker-start-receipt'
|
||||
import { resolveResidualAgentTerminal } from './failed-start-residual-terminal'
|
||||
import { parseTaskDeps } from './task-deps-argument'
|
||||
import {
|
||||
createExistingWorktreeWorkerTerminal,
|
||||
createWorkerWorktree,
|
||||
@@ -270,6 +272,12 @@ export async function startLocalWorker(args: {
|
||||
...(terminalRevealWarning ? { warning: terminalRevealWarning } : {})
|
||||
}
|
||||
} catch (error) {
|
||||
const residualAgentTerminal = resolveResidualAgentTerminal({
|
||||
runtime,
|
||||
effects,
|
||||
terminalHandle,
|
||||
worktreeId: resolvedWorktree?.id ?? null
|
||||
})
|
||||
return failWorkerStartWithReceipt({
|
||||
db,
|
||||
runId: run.id,
|
||||
@@ -278,25 +286,8 @@ export async function startLocalWorker(args: {
|
||||
failedStage,
|
||||
error,
|
||||
setup: setupReceipt,
|
||||
launch: launch.receipt
|
||||
launch: launch.receipt,
|
||||
...(residualAgentTerminal ? { residualAgentTerminal } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function parseTaskDeps(value: string | undefined): string[] | undefined {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (!Array.isArray(parsed) || !parsed.every((item) => typeof item === 'string')) {
|
||||
throw new Error('not an array of strings')
|
||||
}
|
||||
return parsed
|
||||
} catch {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'Invalid --deps: must be a JSON array of task IDs'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { OrchestrationError } from '../../../../orchestration/orchestration-error'
|
||||
|
||||
/** Parses the `--deps` JSON argument shared by the local and federated start paths. */
|
||||
export function parseTaskDeps(value: string | undefined): string[] | undefined {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (!Array.isArray(parsed) || !parsed.every((item) => typeof item === 'string')) {
|
||||
throw new Error('not an array of strings')
|
||||
}
|
||||
return parsed
|
||||
} catch {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'Invalid --deps: must be a JSON array of task IDs'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { isAgentPromptStalledError } from '../../../../agent-prompt-submission-v
|
||||
import { isUnknownWorkerStartOutcome, type WorkerSetupReceipt } from './worker-topology'
|
||||
import type { OrchestrationWorkerLaunchReceipt } from './worker-launch-preferences'
|
||||
import { isAgentSessionPtyWriteRefusedError } from '../../../../../../shared/agent-session-pty-write-admission'
|
||||
import type { FailedStartTerminalAdoption } from '../../../../orchestration/db/worker-terminal/failed-start-terminal-adoption'
|
||||
import { structuredChatPtyWriteRefusalCopy } from '../../../../../../shared/agent-session-pty-write-refusal-copy'
|
||||
|
||||
export function failWorkerStartWithReceipt(args: {
|
||||
@@ -14,6 +15,8 @@ export function failWorkerStartWithReceipt(args: {
|
||||
error: unknown
|
||||
setup: WorkerSetupReceipt
|
||||
launch: OrchestrationWorkerLaunchReceipt
|
||||
/** The terminal this start created and never handed to an owner. */
|
||||
residualAgentTerminal?: FailedStartTerminalAdoption
|
||||
}): unknown {
|
||||
const agentSessionRefusal = isAgentSessionPtyWriteRefusedError(args.error)
|
||||
? args.error.refusal
|
||||
@@ -28,8 +31,14 @@ export function failWorkerStartWithReceipt(args: {
|
||||
: args.db.failWorkerStart(args.dispatchId, args.failedStage, reason, {
|
||||
// Why (#16095): the preamble is written before submission is verified, so a stalled
|
||||
// verdict never means the worker lacks its task — keep the authority its report needs.
|
||||
retainCapability: isAgentPromptStalledError(args.error)
|
||||
retainCapability: isAgentPromptStalledError(args.error),
|
||||
...(args.residualAgentTerminal ? { adoptResidualTerminal: args.residualAgentTerminal } : {})
|
||||
})
|
||||
// Only claim cleanup the ownership table actually accepted; the adoption declines a terminal
|
||||
// another resource already accounts for.
|
||||
const adopted =
|
||||
Boolean(args.residualAgentTerminal) &&
|
||||
Boolean(args.db.getWorkerTerminalResourceByOwner(args.dispatchId))
|
||||
return {
|
||||
runId: args.runId,
|
||||
taskId: args.taskId,
|
||||
@@ -43,6 +52,11 @@ export function failWorkerStartWithReceipt(args: {
|
||||
effects: JSON.parse(worker.effects) as unknown[],
|
||||
residualResources: JSON.parse(worker.residual_resources) as unknown[],
|
||||
...(agentSessionRefusal ? { agentSessionRefusal } : {}),
|
||||
...(adopted
|
||||
? {
|
||||
recovery: `This start created a terminal that never ran the Task. Close it with: orca orchestration worker-release --dispatch ${args.dispatchId}`
|
||||
}
|
||||
: {}),
|
||||
...(unknown
|
||||
? {
|
||||
nextCommands: [
|
||||
|
||||
Reference in New Issue
Block a user