mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
fix(orchestration): preserve caller evidence after handle remint
This commit is contained in:
@@ -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) ||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user