fix(orchestration): preserve caller evidence after handle remint

This commit is contained in:
Brennan Benson
2026-08-27 20:06:06 -07:00
parent 4216eaf6cb
commit cc4b36cee2
7 changed files with 158 additions and 6 deletions
+1 -1
View File
@@ -103,7 +103,7 @@ Do not launch a replacement editor merely because the desktop app or runtime was
Orca orchestration is agent-operated. A Run therefore has one current coordinator agent, not a human operator. That coordinator is the sole task-graph writer and consuming mailbox reader for coordinator-side operations; workers retain only their exact Dispatch capabilities for heartbeat, questions, escalation, and `worker_done`. This is per-Run authority, not an operating-system or global Orca privilege.
The single-writer rule prevents two agents from changing Task, Dispatch, worker, or gate state concurrently and prevents two consumers from acknowledging different views of the same FIFO Delivery. The current coordinator may create and update Tasks (`task-create`, `task-update`), start or dispatch workers (`worker-start`, `dispatch`), create and resolve gates (`gate-create`, `gate-resolve`), answer worker questions (`reply`), and consume or acknowledge Run mail (`check`). Explicit `run-show`, `task-list --run`, inbox, and peek calls remain read-only for non-owners and headless callers.
The single-writer rule prevents two agents from changing Task, Dispatch, worker, or gate state concurrently and prevents two consumers from acknowledging different views of the same FIFO Delivery. The current coordinator may create and update Tasks (`task-create`, `task-update`), start or dispatch workers (`worker-start`, `dispatch`), create and resolve gates (`gate-create`, `gate-resolve`), answer worker questions (`reply`), and consume or acknowledge Run mail (`check`). Explicit `run-show`, `task-list --run`, `gate-list --run`, and inbox calls remain read-only for non-owners and headless callers. `check --peek` is non-consuming, but an ordinary current Run still requires its coordinator authority; do not use it as a non-owner inspection path.
`run-use` is an authority claim, not a read-only selection. When a transfer is intended, make one claim from the stable replacement agent terminal with `orca orchestration run-use --id <run_id> --json`; do not guess whether its process is a remint or spoof the owner with `--from`. Orca either preserves same-process authority, grants replacement authority after proving exit, or rejects the claim with `effectsApplied: false`.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { RuntimeClientError } from '../runtime-client'
import { resolveOrchestrationTerminalHandle } from './orchestration/terminal-identity'
const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE
const originalPaneKey = process.env.ORCA_PANE_KEY
afterEach(() => {
restoreEnv('ORCA_TERMINAL_HANDLE', originalTerminalHandle)
restoreEnv('ORCA_PANE_KEY', originalPaneKey)
})
describe('orchestration terminal identity', () => {
it('refreshes caller evidence after resolving a stale handle by pane', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_stale'
process.env.ORCA_PANE_KEY = 'tab_1:leaf_1'
const call = vi
.fn()
.mockRejectedValueOnce(new RuntimeClientError('terminal_handle_stale', 'stale'))
.mockResolvedValueOnce({ result: { terminal: { handle: 'term_reminted' } } })
const refresh = vi.fn()
const handle = await resolveOrchestrationTerminalHandle(
new Map(),
'/tmp/repo',
{
call,
refreshOrchestrationCallerHandleAfterPaneRemint: refresh
} as never,
'from',
{ validateEnvHandle: true }
)
expect(handle).toBe('term_reminted')
expect(refresh).toHaveBeenCalledWith('term_stale', 'tab_1:leaf_1', 'term_reminted')
})
})
function restoreEnv(name: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[name]
} else {
process.env[name] = value
}
}
@@ -77,7 +77,16 @@ async function resolveOrchestrationPaneTerminalHandle(
const response = await client.call<{ terminal: { handle: string } }>('terminal.resolvePane', {
paneKey
})
return response.result.terminal.handle
const remintedHandle = response.result.terminal.handle
const previousHandle = process.env.ORCA_TERMINAL_HANDLE
if (previousHandle) {
client.refreshOrchestrationCallerHandleAfterPaneRemint?.(
previousHandle,
paneKey,
remintedHandle
)
}
return remintedHandle
} catch (err) {
if (
isPaneRemintUnavailableError(err) ||
+64
View File
@@ -55,6 +55,14 @@ function writeMetadata(
)
}
function restoreEnv(name: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[name]
} else {
process.env[name] = value
}
}
function findUnusedPid(seed = 200_000): number {
// Why: the stale-bootstrap test must point metadata at a definitely-dead
// process. Hard-coding a small PID is host-dependent and flakes when that
@@ -139,6 +147,62 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => {
expect(requests[3]?.compatibilityInvocationId).not.toBe(requests[1]?.compatibilityInvocationId)
})
it('refreshes caller evidence after a pane-proven terminal handle remint', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
const endpoint = join(userDataPath, 'runtime.sock')
const requests: Record<string, unknown>[] = []
const server = createServer((socket) => {
sockets.add(socket)
socket.once('close', () => sockets.delete(socket))
socket.once('data', (data) => {
const request = JSON.parse(String(data).trim()) as Record<string, unknown>
requests.push(request)
const result =
request.method === 'status.get'
? { capabilities: [ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY] }
: {}
socket.write(
`${JSON.stringify({
id: request.id,
ok: true,
result,
_meta: { runtimeId: 'runtime-1' }
})}\n`
)
})
})
servers.add(server)
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
writeMetadata(userDataPath, endpoint)
const priorHandle = process.env.ORCA_TERMINAL_HANDLE
const priorPaneKey = process.env.ORCA_PANE_KEY
const priorLaunchToken = process.env.ORCA_AGENT_LAUNCH_TOKEN
process.env.ORCA_TERMINAL_HANDLE = 'term_stale'
process.env.ORCA_PANE_KEY = 'tab_1:leaf_1'
process.env.ORCA_AGENT_LAUNCH_TOKEN = 'launch-secret'
const client = new RuntimeClient(userDataPath, 500)
try {
client.refreshOrchestrationCallerHandleAfterPaneRemint(
'term_stale',
'tab_1:leaf_1',
'term_reminted'
)
await client.call('orchestration.taskList', {})
} finally {
restoreEnv('ORCA_TERMINAL_HANDLE', priorHandle)
restoreEnv('ORCA_PANE_KEY', priorPaneKey)
restoreEnv('ORCA_AGENT_LAUNCH_TOKEN', priorLaunchToken)
}
expect(requests[0]?.method).toBe('orchestration.taskList')
expect(requests[0]?.orchestrationCompatibilityEvidence).toEqual({
terminalHandle: 'term_reminted',
paneKey: 'tab_1:leaf_1',
launchToken: 'launch-secret'
})
})
it('rejects an old local runtime before sending an orchestration mutation', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
const endpoint = join(userDataPath, 'runtime.sock')
+15 -3
View File
@@ -30,6 +30,7 @@ import {
buildOrchestrationRecoveryCommand,
resolveOrchestrationCliExecutable
} from './orchestration-recovery-command'
import { refreshOrchestrationCallerHandleAfterPaneRemint } from './orchestration-caller-handle-remint'
// Why: for long-poll methods the caller's method-level
// `params.timeoutMs` is the inner waiter budget; we extend the client-side
@@ -55,9 +56,7 @@ export class RuntimeClient {
private readonly originalArgs: readonly string[] | undefined
private readonly remoteCompat: RemoteRuntimeCompatGate
private orchestrationContractCheck: Promise<void> | null = null
private readonly orchestrationCompatibility = createOrchestrationCompatibilityEnvelope(
process.env
)
private orchestrationCompatibility = createOrchestrationCompatibilityEnvelope(process.env)
// Why: browser commands trigger first-time session init (agent-browser connect +
// CDP proxy setup) which can take 15-30s. 60s accommodates cold start without
@@ -83,6 +82,19 @@ export class RuntimeClient {
return this.remotePairing !== null
}
refreshOrchestrationCallerHandleAfterPaneRemint(
previousHandle: string,
paneKey: string,
remintedHandle: string
): void {
this.orchestrationCompatibility = refreshOrchestrationCallerHandleAfterPaneRemint(
this.orchestrationCompatibility,
previousHandle,
paneKey,
remintedHandle
)
}
async call<TResult>(
method: string,
params?: unknown,
@@ -0,0 +1,22 @@
import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope'
export function refreshOrchestrationCallerHandleAfterPaneRemint(
envelope: RuntimeOrchestrationEnvelope,
previousHandle: string,
paneKey: string,
remintedHandle: string
): RuntimeOrchestrationEnvelope {
const evidence = envelope.orchestrationCompatibilityEvidence
if (
!evidence ||
evidence.terminalHandle !== previousHandle ||
evidence.paneKey !== paneKey ||
!remintedHandle
) {
return envelope
}
return {
...envelope,
orchestrationCompatibilityEvidence: { ...evidence, terminalHandle: remintedHandle }
}
}