mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix(orchestration): preserve active workers across updates (#11271)
* fix(orchestration): preserve active workers across updates * test(ssh): model absent legacy adoption * test(orchestration): align compatibility contracts * fix(windows): escape updater PowerShell booleans * fix(windows): restore stock uninstall process check * fix(orchestration): keep recovery off renderer startup barrier * fix(orchestration): harden legacy recovery migration * fix(orchestration): close recovery review gaps * fix(orchestration): complete legacy worker cutover recovery * fix(orchestration): preserve legacy workers across updates --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -128,6 +128,7 @@ jobs:
|
||||
node tools/win-update-e2e/run.mjs `
|
||||
--from "$env:FROM_EXE" `
|
||||
--to "$env:TO_EXE" `
|
||||
--require-distinct-artifacts `
|
||||
--expect "$env:EXPECT" `
|
||||
--soak-seconds "$env:SOAK_SECONDS" 2>&1 | Tee-Object -FilePath $log
|
||||
exit $LASTEXITCODE
|
||||
|
||||
@@ -13,6 +13,26 @@
|
||||
; The image name and the LOCALAPPDATA folder name must stay in sync with
|
||||
; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in
|
||||
; src/main/daemon/daemon-host-relocation.ts.
|
||||
|
||||
!include "getProcessInfo.nsh"
|
||||
Var pid
|
||||
|
||||
!macro customCheckAppRunning
|
||||
${if} ${isUpdated}
|
||||
; Why: an A-era packaged CLI may still hold Orca.exe while its exact native launcher waits.
|
||||
nsExec::Exec `"$PowerShellPath" -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "& { $$ErrorActionPreference='Stop'; $$app=[IO.Path]::GetFullPath($$args[0]); $$launcher=[IO.Path]::GetFullPath($$args[1]); $$comparison=[StringComparison]::OrdinalIgnoreCase; $$children=@(Get-CimInstance Win32_Process | Where-Object { $$_.ExecutablePath -and [String]::Equals([IO.Path]::GetFullPath($$_.ExecutablePath), $$app, $$comparison) }); $$launcherProofs=@(); foreach ($$child in $$children) { $$parent=Get-CimInstance Win32_Process -Filter ('ProcessId=' + $$child.ParentProcessId) -ErrorAction SilentlyContinue; if ($$parent -and $$parent.ExecutablePath -and [String]::Equals([IO.Path]::GetFullPath($$parent.ExecutablePath), $$launcher, $$comparison)) { $$launcherProofs += [pscustomobject]@{ ProcessId=[int]$$parent.ProcessId; CreationDate=[string]$$parent.CreationDate } }; Stop-Process -Id $$child.ProcessId -Force -ErrorAction SilentlyContinue }; $$launcherDeadline=[DateTime]::UtcNow.AddSeconds(30); do { $$remaining=@($$launcherProofs | Where-Object { $$proof=$$_; $$current=Get-CimInstance Win32_Process -Filter ('ProcessId=' + $$proof.ProcessId) -ErrorAction SilentlyContinue; $$current -and $$current.ExecutablePath -and [String]::Equals([IO.Path]::GetFullPath($$current.ExecutablePath), $$launcher, $$comparison) -and [String]::Equals([string]$$current.CreationDate, $$proof.CreationDate, [StringComparison]::Ordinal) }); if ($$remaining.Count -eq 0) { break }; Start-Sleep -Milliseconds 100 } while ([DateTime]::UtcNow -lt $$launcherDeadline); if ($$remaining.Count -ne 0) { exit 20 }; $$lockDeadline=[DateTime]::UtcNow.AddSeconds(30); $$released=$$false; do { try { $$stream=[IO.File]::Open($$app, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None); $$stream.Dispose(); $$released=$$true } catch { Start-Sleep -Milliseconds 100 } } while (-not $$released -and [DateTime]::UtcNow -lt $$lockDeadline); if (-not $$released) { exit 21 } }" "$INSTDIR\Orca.exe" "$INSTDIR\resources\bin\orca.exe"`
|
||||
Pop $0
|
||||
${if} $0 != 0
|
||||
DetailPrint "Orca update release proof failed with status $0."
|
||||
SetErrorLevel 2
|
||||
Quit
|
||||
${endIf}
|
||||
${else}
|
||||
!insertmacro IS_POWERSHELL_AVAILABLE
|
||||
!insertmacro _CHECK_APP_RUNNING
|
||||
${endIf}
|
||||
!macroend
|
||||
|
||||
!macro customUnInstall
|
||||
${ifNot} ${isUpdated}
|
||||
nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'
|
||||
|
||||
+101
-19
@@ -5232,7 +5232,7 @@
|
||||
},
|
||||
{
|
||||
"id": "orchestration.worker-terminal-delivery",
|
||||
"title": "Started workers are visible without stealing focus and retain mailbox identity",
|
||||
"title": "Workers remain visible and observable across workspace entry and app restart",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "orchestration",
|
||||
@@ -5242,7 +5242,9 @@
|
||||
"worker-start",
|
||||
"terminal creation",
|
||||
"terminal tab materialization",
|
||||
"workspace re-entry"
|
||||
"workspace re-entry",
|
||||
"app restart with retained daemon PTYs",
|
||||
"legacy update resume fencing"
|
||||
],
|
||||
"platforms": [
|
||||
"macos",
|
||||
@@ -5264,25 +5266,58 @@
|
||||
"daemon",
|
||||
"ssh"
|
||||
],
|
||||
"coverageNotes": "Deterministic units cover local worker presentation, reveal-failure warnings, stable-pane Run/Dispatch routing, and the SSH in-process CLI fallback. An isolated macOS Electron journey launches a fake Codex worker through the real RPC path, tolerates spawn-time handle reminting, asserts the inactive tab in the DOM before navigation, checks Run delivery by pane identity, and proves workspace re-entry keeps one worker tab by both original tab ID and visible title. SSH, WSL, remote-runtime, Linux, and Windows remain live-test gaps; federated workers retain explicit background presentation.",
|
||||
"coverageNotes": "Deterministic units cover authority-aware legacy formatting, exact legacy worker identity planning, local worker presentation, retained-output reads after adoption, reveal-failure warnings, stable-pane Run/Dispatch routing, the SSH in-process CLI fallback, federated non-reveal, and fail-closed distinct Windows package proof. Two isolated macOS Electron journeys launch fake Codex workers through the real RPC path and record append-only spawn/interruption ledgers. They assert immediate inactive presentation, one live agent PID, stable PTY/incarnation/tab/leaf/worktree/Task/Dispatch identity, and no interruption after workspace re-entry; the restart journey additionally removes renderer ownership, marks the Dispatch legacy, retains the daemon process across an app restart, and proves exact background adoption with readable ACK output and no resume replay. Distinct A/B artifacts plus live SSH, WSL, folder, remote-runtime, Linux, and Windows cutover journeys remain explicit gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/11107#discussion_r3663321387"
|
||||
],
|
||||
"invariant": "Starting a worker in the coordinator's current workspace must materialize one inactive terminal tab before worker-start returns, preserve coordinator focus, and remain exactly once after workspace re-entry. An exact existing target workspace must receive a discoverable tab without stealing coordinator focus; if renderer reveal fails, worker-start must expose that the live worker remains background-only. Run and Dispatch checks must resolve through the caller's stable pane identity when a terminal handle is reminted, while a live handle outranks mismatched pane metadata, explicit legacy terminal inspection remains handle-scoped, and remote or headless worker presentation remains background-only.",
|
||||
"oracle": "Drive Run create, Task create, and worker-start through a production Electron runtime with a deterministic Codex fixture. Require the worker tab to be visible in the DOM with data-active=false while the coordinator tab stays active, send ACK to the Run, read it with a deliberately stale coordinator handle plus its stable pane key, switch workspaces away and back, and require exactly one worker tab by original ID and visible title. Unit tests separately assert local worker-start omits background presentation, reveal failures return an actionable warning without discarding the worker, reminted coordinators and workers retain mailbox routing, live handles outrank mismatched pane metadata, and explicit legacy terminal checks do not inherit the caller pane key locally or through the SSH fallback.",
|
||||
"invariant": "Starting a worker in the coordinator's current workspace must materialize one inactive terminal tab before worker-start returns, preserve coordinator focus, and remain exactly once after workspace re-entry. After an app update or restart, an exact live legacy worker must fence automatic provider resume, adopt its original PTY into its original background pane, retain readable output, and clear the resume record without spawning, writing, signalling, interrupting, replacing, or focusing the worker. An exact existing target workspace must receive a discoverable tab without stealing coordinator focus; if renderer reveal fails, worker-start must expose that the live worker remains background-only. Run and Dispatch checks must resolve through the caller's stable pane identity when a terminal handle is reminted, while a live handle outranks mismatched pane metadata, explicit legacy terminal inspection remains handle-scoped, and remote or headless worker presentation remains background-only.",
|
||||
"oracle": "Drive Run create, Task create, and worker-start through production Electron runtimes with a deterministic Codex fixture. Require append-only ledgers with one still-live PID and no interruption, a visible inactive worker tab while the coordinator stays active, Run delivery through stable pane identity, and stable PTY/incarnation, tab, leaf, worktree, Task, and Dispatch across workspace re-entry. In a restart journey, retain the original daemon PTY and PID, remove renderer ownership, retain sleeping-session evidence, mark the Dispatch legacy, relaunch, and require exact inactive tab adoption, readable ACK output, cleared resume state, one spawn, and no resume argv or Conversation interrupted text after another workspace round trip. Unit tests separately assert authority-specific legacy affordances, exact identity and owner matching, retained-output fallback, pane-stable routing, federated non-activation, SSH fallback parity, and distinct packaged installer paths, versions, and hashes.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/ssh/ssh-remote-orca-cli.test.ts",
|
||||
"pnpm run test:e2e -- tests/e2e/orchestration-worker-terminal-visibility.spec.ts --workers=1"
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/formatter.test.ts src/main/runtime/rpc/methods/orchestration-federation.test.ts tools/win-update-e2e/installer-pair-proof.test.mjs",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts",
|
||||
"pnpm run test:e2e -- tests/e2e/orchestration-worker-terminal-visibility.spec.ts --workers=1",
|
||||
"pnpm run test:e2e -- tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/runtime/orchestration/formatter.test.ts",
|
||||
"src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts",
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/cli/handlers/orchestration.test.ts",
|
||||
"src/cli/handlers/orchestration-check-identity.test.ts",
|
||||
"src/cli/handlers/orchestration-worker-cli.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-federation.test.ts",
|
||||
"src/main/ssh/ssh-remote-orca-cli.test.ts",
|
||||
"tests/e2e/orchestration-worker-terminal-visibility.spec.ts"
|
||||
"tests/e2e/orchestration-worker-terminal-visibility.spec.ts",
|
||||
"tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts",
|
||||
"tools/win-update-e2e/installer-pair-proof.test.mjs"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts",
|
||||
"assertions": [
|
||||
"only exact unique terminal, pane, process-incarnation, and worktree evidence becomes recoverable",
|
||||
"ambiguous, incomplete, or mismatched legacy identities remain fenced and deferred"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orca-runtime.test.ts",
|
||||
"assertions": [
|
||||
"one exact live legacy worker is adopted into its original background pane without input or signals",
|
||||
"automatic provider resume stays fenced until exact adoption is persisted",
|
||||
"retained renderer output remains readable through the recovered terminal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orchestration/formatter.test.ts",
|
||||
"assertions": [
|
||||
"legacy compatibility and recovery replay show only runtime-supplied supported actions",
|
||||
"legacy provenance without live authority stays read-only",
|
||||
"current formatting remains unchanged"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/cli/handlers/orchestration-check-identity.test.ts",
|
||||
"assertions": [
|
||||
@@ -5306,6 +5341,12 @@
|
||||
"a live handle cannot be retargeted by mismatched pane metadata"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/methods/orchestration-federation.test.ts",
|
||||
"assertions": [
|
||||
"federated worker placement explicitly sets activate=false"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ssh/ssh-remote-orca-cli.test.ts",
|
||||
"assertions": [
|
||||
@@ -5319,7 +5360,25 @@
|
||||
"worker-start exposes one inactive worker tab before workspace navigation",
|
||||
"the coordinator tab remains active",
|
||||
"ACK delivery reaches a stable coordinator pane through a stale handle",
|
||||
"workspace re-entry does not duplicate the worker tab under the same or a new tab ID"
|
||||
"one spawn remains live with no interruption event",
|
||||
"PTY/incarnation, tab, leaf, worktree, Task, and Dispatch identities remain stable",
|
||||
"workspace re-entry does not duplicate the worker tab or print Conversation interrupted"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts",
|
||||
"assertions": [
|
||||
"the original daemon PTY, process incarnation, PID, pane, Task, and Dispatch survive app restart",
|
||||
"the legacy worker tab is restored once in the background with retained ACK output",
|
||||
"sleeping-session, resume-claim, and pending-startup state are cleared after adoption",
|
||||
"no second spawn, resume argv, input, signal, interruption, or duplicate tab occurs after workspace re-entry"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tools/win-update-e2e/installer-pair-proof.test.mjs",
|
||||
"assertions": [
|
||||
"distinct package proof records different paths, versions, and SHA-256 hashes",
|
||||
"identical paths, bytes, versions, or unreadable versions fail closed"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -5333,43 +5392,66 @@
|
||||
"durationSeconds": 5.27,
|
||||
"summary": "Five focused files passed with 216 tests, covering visible inactive local worker creation, reveal-failure warnings, stable-pane mailbox routing, live-handle precedence, and SSH fallback parity."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-28",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/formatter.test.ts src/main/runtime/rpc/methods/orchestration-federation.test.ts tools/win-update-e2e/installer-pair-proof.test.mjs",
|
||||
"result": "passed",
|
||||
"durationSeconds": 2.72,
|
||||
"summary": "Three focused files passed with 39 tests, covering authority-aware legacy affordances, federated non-reveal, and fail-closed distinct packaged installer proof."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-28",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm run test:e2e -- tests/e2e/orchestration-worker-terminal-visibility.spec.ts --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 11.5,
|
||||
"summary": "The isolated Electron journey passed with a real Run and Task, deterministic Codex PTY, immediate inactive DOM tab, ACK delivery through a stale coordinator handle, and exactly one worker tab by original ID and management title after workspace re-entry."
|
||||
"durationSeconds": 8.3,
|
||||
"summary": "The isolated Electron journey passed with one live fake-agent spawn, no interruption events, stable PTY/incarnation/tab/leaf/worktree/Task/Dispatch identity, immediate inactive presentation, pane-stable ACK delivery, and exactly one tab after workspace re-entry."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-28",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm run test:e2e -- tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 12.6,
|
||||
"summary": "The restart journey retained one daemon PTY and PID, adopted its exact original background pane, preserved readable ACK output and Task/Dispatch authority, cleared all resume state, and recorded no second spawn, resume argv, input, signal, interruption, or duplicate after workspace re-entry."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 45,
|
||||
"scope": "focused CLI/runtime units plus one isolated Electron worker-start journey"
|
||||
"p95Seconds": 75,
|
||||
"scope": "focused CLI/runtime units plus isolated worker-start and app-restart Electron journeys"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "The deterministic units and isolated Electron journey pass locally; CI and soak history are not yet available."
|
||||
"evidence": "The deterministic units and two isolated Electron journeys pass locally; CI and soak history are not yet available."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The focused presentation and stale-handle tests failed against the pre-fix implementation, and the live incident plus Electron topology showed the worker PTY existed without an immediate tab. Saved intentional-break and CI artifacts are still needed."
|
||||
"evidence": "The focused presentation and stale-handle tests failed against the pre-fix implementation. The restart journey additionally failed first on empty retained output while the original PTY/PID remained live, then passed after the scoped recovered-worker snapshot fallback. The live incident and pre-fix Electron topology showed workspace re-entry replaying provider resume against a worker whose tab binding was missing. Saved intentional-break and CI artifacts are still needed."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Worker-start reuses the existing one-shot renderer reveal and adds no polling, provider listing, or output work. Check adds one optional pane-key field and reuses the existing Run scan or bounded active-Dispatch lookup; it adds no extra RPC, subprocess, timer, or renderer update. Federated and explicitly background terminals are unchanged."
|
||||
"evidence": "Worker-start reuses the existing one-shot renderer reveal and adds no polling, provider listing, or output work. Startup recovery performs one bounded controller inventory per legacy candidate, exact owner/identity checks, one background reveal, and a provider/renderer snapshot only when an adopted worker's in-memory tail is empty and terminal.read is explicitly requested. Check adds one optional pane-key field and reuses the existing Run scan or bounded active-Dispatch lookup. Federated and explicitly background terminals are unchanged."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 consecutive passes or 14 days of stable CI history on macOS, Linux, and Windows.",
|
||||
"Add live SSH and WSL exact-workspace worker-start evidence and a paired remote-runtime control.",
|
||||
"Run distinct installed A/B artifacts through separate headed and headless paired-runtime cutover journeys, plus a git-independent folder workspace parameter.",
|
||||
"Add Docker SSH restart/reconnect proof and physical Windows WSL correct-distro/wrong-distro proof.",
|
||||
"Gate a packaged Windows real-updater journey with a blocked packaged CLI ask, launcher status 75, exact native/WSL resume commands, and one question/reply.",
|
||||
"Attach saved intentional-break artifacts for hidden local presentation and dropped stable-pane delivery."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No live Linux, Windows, SSH, WSL, or paired remote-runtime run is attached.",
|
||||
"No distinct installed A/B headed or headless paired-runtime cutover is attached; the restart journey relaunches the same build while preserving the daemon and agent.",
|
||||
"No Docker SSH restart/reconnect, physical Windows WSL distro-authority, or git-independent folder-workspace cutover journey is attached.",
|
||||
"The Windows workflow now rejects non-distinct package inputs, but it does not yet drive the packaged updater with a blocked packaged CLI ask or prove status 75 and native/WSL resume.",
|
||||
"The Electron journey uses a deterministic fake Codex CLI rather than a real account.",
|
||||
"The gate does not cover terminal output recovery after app restart; it covers identity, discoverability, focus, and mailbox routing."
|
||||
"The local presentation journey tolerates its existing terminal-handle remint and proves continuity by PTY/incarnation/tab/leaf; byte-stable handle proof across a real A/B cutover awaits the runtime-authority/RPC implementation.",
|
||||
"The local Electron restart proof covers retained visible output from the same-build daemon checkpoint, not transcript recovery after a transport cut or distinct A/B runtime replacement."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote if the Electron journey flakes without a product or harness defect, if local worker-start can return before tab materialization without an explicit reveal warning, if focus moves to the worker, if workspace re-entry duplicates the tab, or if pane-stable delivery reads the wrong mailbox."
|
||||
"demotionRule": "Keep experimental or demote if either Electron journey flakes without a product or harness defect, if local worker-start can return before tab materialization without an explicit reveal warning, if focus moves to the worker, if restart or workspace re-entry spawns/resumes/duplicates/interferes with the worker, if retained output becomes unreadable, or if pane-stable delivery reads the wrong mailbox."
|
||||
},
|
||||
{
|
||||
"id": "terminal-render.windows-cjk-repaint",
|
||||
|
||||
@@ -51,30 +51,39 @@ describe('orchestration skill guidance', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('teaches the hard cutover without reviving a legacy executor', () => {
|
||||
it('teaches attested adoption without reviving the retired scheduler', () => {
|
||||
const skill = readSkill()
|
||||
const migration = getSection(skill, 'Contract Migration')
|
||||
|
||||
expect(migration).toContain('hard cutover')
|
||||
expect(migration).toContain('effectsApplied')
|
||||
expect(migration).toContain('skills get orchestration --full')
|
||||
expect(migration).toContain('Do not retry the rejected command unchanged')
|
||||
expect(migration).toContain('no longer supervised')
|
||||
expect(migration).toContain('task-list --run run_legacy_local')
|
||||
expect(migration).toContain('Read-only inspection never consumes legacy mail')
|
||||
expect(migration).toContain('does not run a legacy scheduler, translate old writes, or drain')
|
||||
expect(migration).toContain('does not cancel the prior assignment')
|
||||
expect(migration).toContain('invalidate its worktree')
|
||||
expect(migration).toContain('discard filesystem changes')
|
||||
expect(migration).toContain('leave it as the only editor in that worktree')
|
||||
expect(migration).toContain('observe it manually, read-only')
|
||||
expect(migration).toContain('until it reaches a stable handoff point')
|
||||
expect(migration).toContain('visible activity is a reason to keep observing')
|
||||
expect(migration).toContain(
|
||||
'Never launch a replacement editor in the same worktree while the legacy worker may still write there.'
|
||||
'adopts a live pre-update orchestration assignment into an ordinary Run'
|
||||
)
|
||||
expect(migration).toContain('if remaining work needs new lifecycle supervision')
|
||||
expect(migration).not.toContain('restart the work using Run -> Task -> `worker-start`')
|
||||
expect(migration).toContain(
|
||||
'preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch'
|
||||
)
|
||||
expect(migration).toContain('never restarts or replaces the worker')
|
||||
expect(migration).toContain('The retired scheduler is not revived')
|
||||
expect(migration).toContain('[LEGACY COMPATIBILITY]')
|
||||
expect(migration).toContain('[LEGACY READ-ONLY]')
|
||||
expect(migration).toContain(
|
||||
'Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.'
|
||||
)
|
||||
expect(migration).toContain(
|
||||
'It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal.'
|
||||
)
|
||||
expect(migration).not.toContain('task-list --run run_legacy_local')
|
||||
expect(migration).toContain('run_legacy_local is an empty audit tombstone')
|
||||
expect(migration).toContain('Recovered orchestration work from a contract update')
|
||||
expect(migration).toContain('run-show --id <adopted_run_id>')
|
||||
expect(migration).toContain('task-list --run <adopted_run_id>')
|
||||
expect(migration).toContain('Legacy inspection remains available without consuming mail')
|
||||
expect(migration).toContain('run-use --id <adopted_run_id> --takeover-legacy')
|
||||
expect(migration).toContain('Takeover fences only the old coordinator')
|
||||
expect(migration).toContain('Live legacy workers keep their original Tasks, Dispatches')
|
||||
expect(migration).toContain(
|
||||
'keep the original worker as the only editor until it reaches a stable handoff point'
|
||||
)
|
||||
expect(migration).toContain('a conflict-free placement for any remaining work')
|
||||
})
|
||||
|
||||
it('treats long-running worker waits as liveness checkpoints, not failures', () => {
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
# Preserve active orchestration work across contract updates
|
||||
|
||||
Status: implementation validation
|
||||
|
||||
Date: 2026-07-28
|
||||
|
||||
## Decision
|
||||
|
||||
Do not block updates, restart active workers, or revive the retired scheduler.
|
||||
|
||||
On first launch after the contract update, atomically adopt the durable
|
||||
pre-Run graph into one ordinary lightweight Run. Preserve every Task,
|
||||
Dispatch, message, gate, terminal, PTY, process, worktree, and prompt
|
||||
identifier. Mark only the pre-update Dispatch attempts as legacy-contract
|
||||
attempts and accept the exact command shapes already pasted into those
|
||||
workers through a narrow adapter.
|
||||
|
||||
This is state migration plus a per-Dispatch protocol adapter. It is not a
|
||||
second scheduler:
|
||||
|
||||
- the migrated Run owns the preserved graph and coordinator inbox;
|
||||
- legacy attempts keep their original protocol until they settle;
|
||||
- every new attempt uses the current protocol, even on a preserved Task; and
|
||||
- no placement, polling, concurrency, or deadline loop is resurrected.
|
||||
|
||||
## User-visible outcome
|
||||
|
||||
- The update installs immediately.
|
||||
- Existing coordinator and worker terminals keep the same process, PTY,
|
||||
worktree, prompt, Task ID, Dispatch ID, and history.
|
||||
- Orca does not inject text, focus a tab, or start a replacement editor.
|
||||
- The legacy graph appears in an automatically adopted, normal Run instead of
|
||||
being stranded in `run_legacy_local`.
|
||||
- An original live worker with retained launch proof can keep using the
|
||||
`heartbeat`, `worker_done`, `escalation`, `ask`, and `check` commands in its
|
||||
existing prompt.
|
||||
- An original live coordinator can keep listing and settling preserved Tasks,
|
||||
dispatch remaining work with current commands, check direct legacy mail, and
|
||||
use the pre-cutover `reply` CLI guidance.
|
||||
- Unprovable, stale, ambiguous, or already-settled rows remain inspectable and
|
||||
unmistakably read-only. They never advertise Reply or Ack actions.
|
||||
- A pre-update WSL process whose older launcher did not propagate the hidden
|
||||
token remains lifecycle read-only. Its assignment, process, terminal, and
|
||||
filesystem work remain valid and inspectable.
|
||||
|
||||
The retired `orchestration run`, `coordinator-start`, and `coordinator-stop`
|
||||
scheduler commands remain retired. Their in-memory loop cannot be reconstructed
|
||||
truthfully. The coordinator agent and all durable work remain accessible; it
|
||||
explicitly dispatches any ready work that was never assigned before the update.
|
||||
|
||||
## Evidence and constraints
|
||||
|
||||
- Schema v7 preserved old `messages`, `tasks`, `dispatch_contexts`, and
|
||||
`decision_gates` by assigning them to `run_legacy_local`.
|
||||
- The exact prompt at `8b154d686` used old `send`, `ask`, and consuming
|
||||
`check` shapes. It omitted `worker_done --outcome`; a subject equal to
|
||||
`Failed` or beginning `Failed:` represented failure. Its escalation carried
|
||||
`taskId` but no `dispatchId`.
|
||||
- `reply` existed in the old CLI and message formatter guidance, not in that
|
||||
pinned worker prompt.
|
||||
- Restored `OrcaRuntime` PTY records currently set `launchToken: null`, and
|
||||
provider `listProcesses()` does not expose the launch token. Handles and
|
||||
pane IDs alone therefore cannot authorize compatibility.
|
||||
- Managed hooks source the owner-only endpoint file on every invocation, so an
|
||||
old process reaches the new hook server after restart. The hook cache retains
|
||||
the prior authenticated pane/launch-token association and distinguishes
|
||||
hydrated rows from events observed by the current runtime.
|
||||
- Older builds did not propagate `ORCA_AGENT_LAUNCH_TOKEN` through `WSLENV`, and
|
||||
a running process cannot acquire it retroactively.
|
||||
- The CLI transport mutation receipt is keyed by rotating runtime
|
||||
authentication. It cannot deduplicate a legacy mutation across restart.
|
||||
- Current invalid-capability lifecycle sends deliberately remain as auditable
|
||||
rejected messages. Compatibility must not silently change that behavior.
|
||||
- PR #11142 fixes background-tab materialization for newly dispatched workers.
|
||||
It does not recover a pre-fix worker whose PTY survived an update after its
|
||||
renderer tab binding was lost; workspace activation can otherwise mistake
|
||||
that worker for a sleeping provider session and launch `codex resume`.
|
||||
- The packaged Windows launcher keeps its Electron-as-Node child alive for the
|
||||
lifetime of a blocking CLI command. A seamless design cannot assume that a
|
||||
bounded RPC wait releases the installed executable.
|
||||
|
||||
## Non-negotiable invariants
|
||||
|
||||
1. Adoption never spawns, writes, signals, stops, focuses, or injects a
|
||||
terminal and never mutates the assigned filesystem. It may restore one
|
||||
persisted background tab/leaf binding for the exact already-live PTY.
|
||||
2. Every durable row and identifier survives adoption; only its owning Run and
|
||||
explicit protocol metadata change.
|
||||
3. Compatibility is write-once per Dispatch. Migration never classifies a
|
||||
current retry as legacy.
|
||||
4. A legacy mutation requires one uniquely resolved legacy attempt or
|
||||
coordinator principal plus current live proof of its original process tree.
|
||||
5. Caller-supplied handles, pane keys, Task IDs, Dispatch IDs, or remote
|
||||
attachments never create authority.
|
||||
6. Current Dispatch behavior remains unchanged, including auditable rejected
|
||||
lifecycle messages, capabilities, process incarnation, Run Delivery, and
|
||||
question semantics.
|
||||
7. Legacy direct mail and current Run Delivery are disjoint, even when their
|
||||
Dispatches share one migrated Task and Run.
|
||||
8. A B-era disconnect may replay accepted work; it may not lose mail or create
|
||||
a second question, reply, completion, or settlement. A cut after an A-era
|
||||
ask was already answered is explicitly ambiguous because A persisted no
|
||||
invocation identity; recovery replays its answer through legacy check before
|
||||
a same-text question is asked again.
|
||||
9. Different reply bodies for the same source and principal conflict instead
|
||||
of creating multiple replies.
|
||||
10. Folder workspaces, SSH, WSL, remote runtimes, macOS, Linux, and Windows
|
||||
follow the same proof rules. Missing proof degrades to inspection without
|
||||
invalidating the retained assignment or filesystem work.
|
||||
|
||||
## Storage and atomic adoption
|
||||
|
||||
Raise the schema version and add:
|
||||
|
||||
```text
|
||||
dispatch_contexts.contract_version INTEGER NOT NULL
|
||||
dispatch_contexts.launch_token_hash TEXT
|
||||
messages.delivery_contract TEXT NOT NULL
|
||||
CHECK(delivery_contract IN ('legacy_direct', 'current_delivery', 'audit_only'))
|
||||
legacy_adoptions(
|
||||
source_run_id PRIMARY KEY,
|
||||
adopted_run_id UNIQUE NOT NULL,
|
||||
scheduler_state_lost INTEGER NOT NULL,
|
||||
adopted_at
|
||||
)
|
||||
legacy_compatibility_principals(
|
||||
id PRIMARY KEY,
|
||||
run_id NOT NULL,
|
||||
dispatch_id,
|
||||
role CHECK(role IN ('worker', 'coordinator')),
|
||||
host_scope NOT NULL,
|
||||
terminal_handle NOT NULL,
|
||||
pane_key NOT NULL,
|
||||
launch_token_hash NOT NULL,
|
||||
process_incarnation,
|
||||
status CHECK(status IN ('committed', 'settled', 'revoked')),
|
||||
UNIQUE(role, run_id, dispatch_id)
|
||||
)
|
||||
legacy_operation_receipts(
|
||||
principal_id,
|
||||
operation_key,
|
||||
method,
|
||||
payload_hash,
|
||||
effect_id,
|
||||
response_json,
|
||||
completed_at,
|
||||
PRIMARY KEY(principal_id, operation_key)
|
||||
)
|
||||
legacy_mail_receipts(
|
||||
principal_id,
|
||||
message_id,
|
||||
acknowledged_at,
|
||||
PRIMARY KEY(principal_id, message_id)
|
||||
)
|
||||
```
|
||||
|
||||
The schema migration runs in one `BEGIN IMMEDIATE` transaction:
|
||||
|
||||
1. create a persisted random Run ID and `legacy_adoptions` marker if one does
|
||||
not already exist;
|
||||
2. create an ordinary `legacy = 0` Run with a recovery objective;
|
||||
3. move all `run_legacy_local` Tasks, Dispatches, decision gates, messages,
|
||||
and question threads to it without changing their IDs, sequence, status, or
|
||||
content;
|
||||
4. mark pre-existing direct mail `legacy_direct` and hard-cutover rejection
|
||||
rows `audit_only`;
|
||||
5. mark a Dispatch legacy only when it predates the contract column and lacks
|
||||
a current dispatch capability; and
|
||||
6. fence any impossible outstanding legacy Delivery, move retained Delivery
|
||||
history to the adopted Run, assert every Task/Dispatch/gate/message/question/
|
||||
Delivery Run ID agrees, and leave `run_legacy_local` as an empty read-only
|
||||
audit tombstone.
|
||||
|
||||
Every new Dispatch and message writes the current contract explicitly.
|
||||
Repeated startup, a partially initialized fixture, and WAL recovery reuse the
|
||||
persisted adoption marker. Reset and fresh-database paths cannot synthesize
|
||||
legacy authority.
|
||||
|
||||
## Durable principals and live authority
|
||||
|
||||
After the hook server and PTY inventory are ready, a one-shot adoption service
|
||||
may commit compatibility principals. It does not mutate terminal processes or
|
||||
input.
|
||||
|
||||
Before renderer hydration, legacy Dispatch evidence also fences automatic
|
||||
provider resume for the exact sleeping pane. Once the relevant local daemon or
|
||||
SSH relay has authoritative inventory, Orca intersects the durable handle,
|
||||
pane, PTY ID, PTY incarnation, workspace, and host owner. A unique live match
|
||||
is adopted into its original tab/leaf as inactive background state while the
|
||||
coordinator tab remains active. Missing, stale, reused, cross-host, or
|
||||
ambiguous evidence stays fenced and inspectable. Ordinary provider resume is
|
||||
unblocked only when authoritative inventory proves that exact PTY exited.
|
||||
|
||||
At hook-server startup, copy the validated hydrated status rows into an
|
||||
immutable main-process-only startup snapshot before the server listens or
|
||||
binds the new listener. Later live events update a separate current-runtime
|
||||
observation map and cannot overwrite the pre-update commitment source. A
|
||||
current event is preferred but is not required before the worker's first
|
||||
post-restart lifecycle command when controller inventory proves the exact
|
||||
original PTY and process incarnation are live.
|
||||
|
||||
For each candidate it resolves the union of:
|
||||
|
||||
- durable Dispatch assignee and pane;
|
||||
- equivalent-pane aliases;
|
||||
- remote attachment and host ownership;
|
||||
- coordinator candidates from active legacy coordinator rows, Task creator
|
||||
handles, Dispatch coordinator/parent provenance, and direct-message
|
||||
sender/recipient history;
|
||||
- the restored terminal inventory and process incarnation; and
|
||||
- the hydrated, previously authenticated hook pane/launch-token association.
|
||||
|
||||
The candidate commits only when that union identifies exactly one role and
|
||||
row. The orchestration database stores a SHA-256 token commitment, never the
|
||||
raw token. The existing owner-only hook status cache is an input to the
|
||||
one-time commitment, but a hydrated status is not enough to mutate.
|
||||
|
||||
Every compatibility call then requires:
|
||||
|
||||
- a hidden launch token and pane inherited by the calling process;
|
||||
- the immutable pre-update hook commitment, plus either a matching
|
||||
current-runtime hook event or exact current controller proof of the original
|
||||
PTY and process incarnation;
|
||||
- a live PTY with the committed handle, host scope, and process incarnation;
|
||||
- the committed principal and exact active legacy Dispatch or adopted Run; and
|
||||
- an operation-specific unique match.
|
||||
|
||||
The current hook event or exact controller inventory proves that the process
|
||||
survived the restart; equality with the pre-update commitment proves it is the
|
||||
same launch. If the hook cache was absent, the provider cannot report the
|
||||
proof, the token changed, or any candidate is ambiguous, the row remains
|
||||
read-only.
|
||||
|
||||
For new Dispatches, persist the launch-token hash at dispatch time so later
|
||||
contract updates do not depend on hook-cache recovery. Never return or log raw
|
||||
tokens. Add explicit RPC-envelope, structured-log, error, and CLI JSON
|
||||
redaction tests.
|
||||
|
||||
Host scope is runtime-owned:
|
||||
|
||||
- local: stable local execution-host identity;
|
||||
- WSL: stable native execution-host identity plus distro;
|
||||
- SSH: saved-host authority plus remote pane/process identity; and
|
||||
- federated runtime: authenticated peer fingerprint plus remote attachment.
|
||||
|
||||
`pickRemoteCliEnv`, SSH relay forwarding, WSLENV, and the legacy in-process SSH
|
||||
fallback must carry the hidden evidence without exposing it in command text or
|
||||
logs. A local runtime never accepts a raw remote token as local authority.
|
||||
Pre-update WSL processes without the token cannot use the lifecycle adapter.
|
||||
|
||||
For SSH, the host passthrough and in-process fallback stamp the saved target,
|
||||
`SshRelaySession` connection incarnation, and attachment provenance from the
|
||||
authenticated runtime channel; caller environment cannot choose them. WSL
|
||||
stamps the runtime-owned native host and distro in both the full bridge and
|
||||
fallback bridge. The verifier intersects those stamps with the committed host
|
||||
scope before considering token or pane evidence.
|
||||
|
||||
## Mixed-contract Run and mail routing
|
||||
|
||||
The adopted Run is a normal current Run. Tasks belong to it, so a current retry
|
||||
of a preserved Task naturally routes to the same Run without a cross-Run Task
|
||||
reference.
|
||||
|
||||
Message delivery is selected by `messages.delivery_contract`, not by Run ID:
|
||||
|
||||
- old and adapter-created direct messages are `legacy_direct`;
|
||||
- current sends are `current_delivery`; and
|
||||
- migration rejections and non-actionable history are `audit_only`.
|
||||
|
||||
Current Run Delivery creation, filtering, waiting, and acknowledgment select
|
||||
only `current_delivery`. Legacy `check` selects only the exact principal's
|
||||
`legacy_direct` address. History and inbox may show all three with explicit
|
||||
labels but never attach actions to `audit_only`. Mixed legacy/current attempts
|
||||
under one Task are covered across send, ask, check, reply, and settlement.
|
||||
|
||||
The original coordinator is automatically associated with the adopted Run
|
||||
after its principal proves live. `run-use` may bind that same principal without
|
||||
converting legacy Dispatches. A different coordinator cannot silently take
|
||||
over while a legacy attempt is active; an explicit recovery takeover is a
|
||||
separate current-contract action and revokes legacy coordinator mutation
|
||||
authority before rebinding.
|
||||
|
||||
When that coordinator uses old direct `check`, the adapter also performs a
|
||||
non-consuming existence check for `current_delivery` mail. It never mixes the
|
||||
messages into legacy output; it prints the exact current Run check command and,
|
||||
after that command creates a Delivery, the exact acknowledgment form. This
|
||||
keeps an unchanged coordinator informed about current retries without
|
||||
weakening current Delivery semantics.
|
||||
|
||||
## Exact legacy adapter
|
||||
|
||||
The CLI keeps accepting the old flags. It sends a hidden compatibility
|
||||
envelope; the runtime, not the CLI, decides whether legacy behavior applies.
|
||||
|
||||
Version B generates a random hidden `compatibilityInvocationId` once per CLI
|
||||
process and reuses it for every transport reconnect by that invocation.
|
||||
`legacy_operation_receipts.operation_key` is this invocation ID for B calls.
|
||||
The method and canonical payload hash must also match.
|
||||
|
||||
Attested compatibility calls bypass the rotating-auth generic mutation ledger
|
||||
after the contract fence routes them to the adapter. Their only dedupe boundary
|
||||
is the compatibility-principal receipt transaction described here; current
|
||||
calls continue through the existing generic ledger unchanged.
|
||||
|
||||
Version A had no invocation ID. Cross-cutover reconstruction is therefore
|
||||
limited to effects with a durable semantic identity: final settlement per
|
||||
Dispatch, still-pending ask per normalized question/options/recipient, reply
|
||||
per source/principal/body, and read recovery per message. Heartbeat and
|
||||
escalation remain at-least-once across an A-to-B manual retry because
|
||||
collapsing identical commands could suppress a legitimate later event. Tests
|
||||
and guidance do not promise cross-cutover exactly-once behavior for those
|
||||
repeatable signals.
|
||||
|
||||
An A-era ask that was already answered before its output was lost is also
|
||||
ambiguous. B does not guess whether the same text means retry or a new
|
||||
question. Its answer is in the durable recovery cohort and the error tells the
|
||||
agent to run the exact legacy check command first. After that answer is printed
|
||||
and acknowledged, a new B invocation with identical text creates a new
|
||||
question. If multiple identical A threads exist, ask fails ambiguous and
|
||||
recovery check drains them by message sequence; no arbitrary thread is chosen.
|
||||
|
||||
### Lifecycle `send`
|
||||
|
||||
- `heartbeat` and `worker_done`: resolve Task + Dispatch from the payload, then
|
||||
intersect them with the verified worker principal.
|
||||
- `escalation`: resolve Task + verified worker principal to exactly one active
|
||||
legacy Dispatch because the old prompt supplied no Dispatch ID.
|
||||
- Preserve the old direct coordinator recipient after verifying it belongs to
|
||||
the adopted Run principal.
|
||||
- Infer missing `worker_done` outcome as `failed` only when the trimmed subject
|
||||
is `Failed` or begins `Failed:`; otherwise infer `succeeded`.
|
||||
- Normalize to the current internal settlement model.
|
||||
|
||||
Compatibility validation happens before message insertion. Current lifecycle
|
||||
handling is not refactored through this prevalidation: its existing
|
||||
invalid-capability audit message behavior remains locked by regression tests.
|
||||
|
||||
The DB commits the direct message, lifecycle effect, and compatibility receipt
|
||||
in one transaction. If a pre-update `worker_done` already settled the Dispatch,
|
||||
the adapter reconstructs the matching semantic receipt from the existing
|
||||
message and settlement. The persisted A settlement is authoritative: the
|
||||
pinned A reconciler recorded even a `Failed:` subject as completed, so B
|
||||
returns that original completed result rather than retroactively changing Task
|
||||
or dependency state. Only a not-yet-accepted B call applies the corrected
|
||||
failure inference. A conflicting retry fails.
|
||||
|
||||
### `check`
|
||||
|
||||
Legacy check is at-least-once:
|
||||
|
||||
1. a read RPC returns the exact unacknowledged `legacy_direct` message IDs
|
||||
without changing them;
|
||||
2. the CLI prints the complete response; and
|
||||
3. a hidden ack atomically records receipts and marks those exact rows read.
|
||||
|
||||
A cut after read and before ack replays the same IDs. `--peek` and `--all`
|
||||
remain read-only.
|
||||
|
||||
Version A could mark a message read before losing its response. On adoption,
|
||||
every eligible read direct message connected to a still-active legacy attempt
|
||||
and lacking a post-adoption receipt is inserted into a durable recovery cohort
|
||||
using `legacy_mail_receipts` with `acknowledged_at = NULL`. Legacy check drains
|
||||
that stable cohort in bounded sequence-ordered pages before ordinary unread
|
||||
mail. Each page is labeled
|
||||
`[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]`, replays until its post-print
|
||||
ack, and then advances. The cohort never grows after adoption, so every
|
||||
eligible row is covered without an unbounded response. Settled-at-cutover and
|
||||
unrelated historical mail do not enter it.
|
||||
|
||||
### `ask`
|
||||
|
||||
Legacy ask is an idempotent mutation followed by a resumable read:
|
||||
|
||||
1. create or find the direct legacy gate/question by verified Dispatch,
|
||||
normalized question, options, and recipient;
|
||||
2. atomically commit its message/thread plus operation receipt and return the
|
||||
stable question ID;
|
||||
3. wait in bounded read-only slices using that ID; and
|
||||
4. after printing the answer, hidden-ack that exact answer.
|
||||
|
||||
On first adoption, existing pending decision-gate threads are indexed into the
|
||||
same semantic identity. A retry resumes them instead of creating another gate.
|
||||
|
||||
### `reply`
|
||||
|
||||
Legacy reply requires the source thread, the verified coordinator principal,
|
||||
and a unique question owned by the adopted Run. One transaction inserts the
|
||||
reply, resolves the exact gate, marks the exact source, and completes the
|
||||
receipt.
|
||||
|
||||
An existing pre-update reply with the same source, principal, and body is
|
||||
reconstructed as success. A second body for the same source/principal is a
|
||||
conflict, not another reply.
|
||||
|
||||
### Coordinator and Task commands
|
||||
|
||||
After automatic binding, unchanged Task listing, Task settlement, dispatch,
|
||||
and gate inspection use the adopted Run. A new attempt receives a current
|
||||
preamble, capability, launch-token commitment, and current message contract.
|
||||
No unrelated Task can be created until the migrated coordinator binding is
|
||||
proven or explicitly taken over.
|
||||
|
||||
## Windows update behavior
|
||||
|
||||
No compatibility mutation holds a DB transaction while waiting. Windows
|
||||
legacy `ask` commits the question, prints its stable ID and exact resume
|
||||
command, then exits before a long wait whenever the installed launcher cannot
|
||||
prove that the executable is replaceable.
|
||||
|
||||
The native packaged launcher sets an internal
|
||||
`ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER=1` marker for its Electron-as-Node child.
|
||||
After the DB commit, legacy `ask` writes and flushes the stable question ID and
|
||||
the exact resume command, then exits with a documented `75` resume-required
|
||||
status. The command name comes only from the validated inherited
|
||||
`ORCA_CLI_COMMAND` packaged enum: `orca` for native packaged terminals or
|
||||
`orca-ide` for packaged WSL. Arbitrary environment text is rejected rather
|
||||
than rendered. The launcher propagates status 75. No long wait starts on this
|
||||
path.
|
||||
|
||||
The candidate installer owns the backward edge: after the desktop has shut
|
||||
down, it may terminate only remaining processes whose resolved executable path
|
||||
is the exact installed Orca executable before replacement. This releases an
|
||||
already-blocked A CLI that cannot know the new marker. Its accepted question
|
||||
remains durable; retrying the exact old ask under B reconstructs the same
|
||||
pending question and prints the resume command. If launcher restart
|
||||
supervision can be proved, it may transparently reconnect instead, but
|
||||
seamless install cannot depend on that optimization.
|
||||
|
||||
After terminating the exact installed child, the installer waits boundedly for
|
||||
its exact descendant native launcher to exit and for the installed executable
|
||||
lock to clear. Replacement fails explicitly if either release cannot be
|
||||
proved; it never proceeds from a broad process-name match or an assumed delay.
|
||||
|
||||
A non-skipped packaged Windows A-to-B test uses the real launcher and updater,
|
||||
cuts a blocked ask, verifies post-commit output flush and status 75 propagation
|
||||
on native and packaged WSL marker-aware paths, including an executable
|
||||
`orca-ide` WSL resume command. It separately proves the candidate installer
|
||||
terminates an exact-path marker-unaware A child and observes the exact launcher
|
||||
exit plus lock release. The test fails if either release is unknown. It then
|
||||
installs B, resumes the same question, and records one question/reply. This
|
||||
fallback is product behavior, not test-only guidance.
|
||||
|
||||
## Rendering and authoritative guidance
|
||||
|
||||
Formatting is authority-aware:
|
||||
|
||||
- `[LEGACY COMPATIBILITY]`: live and attested; show only supported commands;
|
||||
- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]`: one bounded cutover replay;
|
||||
- `[LEGACY READ-ONLY]`: retained but unactionable; no Reply or Ack hint; and
|
||||
- current messages: unchanged.
|
||||
|
||||
Update `skill-guides/orchestration.md` and regenerate its bundled source. The
|
||||
guide explains automatic adoption, exact continuity, the retired scheduler
|
||||
boundary, proof failures, current retries, and the Windows ask resume fallback.
|
||||
It must not tell agents to restart a worker whose Dispatch was adopted.
|
||||
|
||||
Formatting cannot use database provenance alone; current liveness and
|
||||
attestation are runtime facts. Recovery may reveal the exact already-live PTY
|
||||
once as an inactive background tab. It never creates a PTY or process, writes
|
||||
input, switches workspaces, focuses, splits, or injects a terminal.
|
||||
|
||||
## Deterministic verification
|
||||
|
||||
### Schema and authority
|
||||
|
||||
- Migrate exact pre-v7, hard-cutover, current, partial, WAL, reset, and fresh
|
||||
fixtures; preserve IDs and row counts and reuse one adopted Run.
|
||||
- Assert Task, Dispatch, decision-gate, message, question-thread, and retained
|
||||
Delivery Run ownership agrees; outstanding legacy Deliveries are fenced.
|
||||
- Prove current retries in the adopted Run remain current across repeated
|
||||
migrations.
|
||||
- Test unique worker/coordinator import from prior hook commitment plus current
|
||||
live hook, and every forged, stale, wrong-host, wrong-pane, wrong-process,
|
||||
duplicate-token, duplicate-role, equivalent-pane, and remote-attachment
|
||||
ambiguity.
|
||||
- Race a current-runtime hook before adoption and prove the immutable startup
|
||||
commitment is neither overwritten nor confused with live proof.
|
||||
- Cover manual coordinators with no `coordinator_runs` row and reject
|
||||
conflicting candidates from Task, Dispatch, and message provenance.
|
||||
- Assert all legacy authority failures occur before message/receipt/gate/state
|
||||
effects while current invalid-capability rejection remains unchanged.
|
||||
- Assert tokens are absent from RPC logs, errors, CLI output, and orchestration
|
||||
storage.
|
||||
|
||||
### Exact old behavior and replay
|
||||
|
||||
- Replay the pinned prompt's heartbeat, successful and `Failed:`
|
||||
`worker_done`, task-only escalation, ask, and consuming check.
|
||||
- Replay the old formatter's reply command separately.
|
||||
- Cover every operation once after B and once accepted by A immediately before
|
||||
the cutover where a durable semantic identity exists, including the bounded
|
||||
recovery replay for an A check and preservation of A's persisted settlement.
|
||||
- Prove one B invocation reuses its invocation ID across reconnect, while
|
||||
separate identical heartbeat/escalation invocations remain distinct.
|
||||
- Inject real transport cuts after check read, ask commit, reply commit, and
|
||||
completion commit; prove stable IDs and one receipt/effect.
|
||||
- Page a recovery cohort larger than one response limit across repeated
|
||||
disconnects and prove every eligible row appears until ack, then never again.
|
||||
- Test mixed legacy/current attempts for every send/check/ask/reply route and
|
||||
prove current Delivery cannot consume legacy direct mail and old coordinator
|
||||
check only advertises the exact non-consuming current Delivery command.
|
||||
|
||||
### Live two-launch E2E
|
||||
|
||||
The focused renderer/daemon restart regression reconstructs the rc.3-era
|
||||
orphan artifact in a shared candidate profile: a durable legacy Dispatch,
|
||||
sleeping provider record, and live daemon PTY with its visual binding removed.
|
||||
It proves startup restores the exact PID, PTY/incarnation, handle, tab/leaf,
|
||||
Task, and Dispatch; emits no provider-resume spawn or interruption; preserves
|
||||
coordinator focus; and remains inert after switching away and back. This
|
||||
candidate/candidate fixture covers the renderer regression deterministically
|
||||
but does not replace the distinct installed-A/candidate-B packaged cutover
|
||||
journey below.
|
||||
|
||||
Use distinct installed A and B artifacts plus an external append-only fake
|
||||
agent spawn ledger:
|
||||
|
||||
1. launch A with coordinator and worker terminals and record the replaceable A
|
||||
desktop/main PID, runtime ID, version, and artifact hash separately from the
|
||||
durable daemon, coordinator, and worker identities;
|
||||
2. accept one command with durable semantic identity on A—pending ask, reply,
|
||||
completion, or check—cut its response, close A without stopping the daemon
|
||||
or fake agent, install B, and prove B owns the relaunched runtime;
|
||||
3. assert A's desktop/main process and runtime are gone, B has a different
|
||||
desktop/main PID, runtime ID, version, and artifact hash, while the daemon
|
||||
plus coordinator/worker PID and start time, PTY/session, terminal handle,
|
||||
tab/leaf/pane, worktree, Task, and Dispatch remain byte-identical;
|
||||
4. prove that exact pending ask, reply, completion, or check resumes or
|
||||
deduplicates on B; do not use heartbeat, escalation, or an already-answered
|
||||
A ask as an exactly-once oracle;
|
||||
5. run the remaining exact old lifecycle commands and settle the same
|
||||
Dispatch;
|
||||
6. switch away and back and assert no second spawn/tab and no
|
||||
`Conversation interrupted`; and
|
||||
7. create a current attempt on preserved state and prove current grammar.
|
||||
|
||||
The append-only spawn ledger and an append-only interruption ledger, daemon
|
||||
inventory, runtime terminal inventory, and every viewer DOM independently
|
||||
prove one launch, process, handle, tab, and no `Conversation interrupted`.
|
||||
Transport fault injection cuts the real CLI connection after check output but
|
||||
before ack, and after ask, reply, and completion commit. Re-run the byte-exact A
|
||||
argv under B and assert stable IDs plus exactly one message, gate, reply,
|
||||
settlement, and receipt.
|
||||
|
||||
Retain #11142's local visible-inactive test and extend its oracle beyond DOM
|
||||
counting with the ledgers and host inventory.
|
||||
|
||||
Run separate remote journeys:
|
||||
|
||||
- headed paired server A-to-B with a surviving external client;
|
||||
- headless `orca serve` A-to-B with the same surviving-client oracle;
|
||||
- both prove the remote PID/PTy continuity, both viewer states, zero
|
||||
client-local handles/tabs, and wrong-host evidence with zero effects;
|
||||
- Docker SSH restart/reconnect with a remote proof file binding the process,
|
||||
pane, and token commitment;
|
||||
- physical Windows WSL coverage proving the correct distro and rejecting a
|
||||
wrong-distro token; and
|
||||
- a git-independent folder workspace parameter through the same adoption
|
||||
journey.
|
||||
|
||||
The Windows test is a non-skipped PR/release gate. It installs distinct A and
|
||||
candidate-B packages, drives the real packaged updater while a packaged
|
||||
`orca.exe` ask is blocked, proves the launcher and child release, verifies A
|
||||
and B hashes/versions differ, resumes the same question on B, and records
|
||||
exactly one question and reply.
|
||||
|
||||
## Implementation slices
|
||||
|
||||
1. Schema, atomic Run adoption, per-row contract provenance, and mixed mailbox.
|
||||
2. Hook-commitment import, live principal verifier, host scopes, and redaction.
|
||||
3. Atomic compatibility receipts plus lifecycle send/escalation.
|
||||
4. Check read/ack and cutover recovery replay.
|
||||
5. Ask create/resume, reply conflict semantics, and coordinator binding.
|
||||
6. CLI hidden envelope, SSH/WSL forwarding, Windows resume fallback.
|
||||
7. Authority-aware formatting and the version-matched orchestration guide.
|
||||
8. Two-launch, remote, platform, and #11142 regression coverage.
|
||||
9. Focused unit/type/lint/E2E checks and final adversarial review.
|
||||
|
||||
## Definition of done
|
||||
|
||||
- Updates are never blocked.
|
||||
- Every durable legacy Task is owned by an ordinary visible Run.
|
||||
- Every provably original live legacy Dispatch can finish through its existing
|
||||
prompt without changing process or worktree.
|
||||
- New attempts use the current contract and mailbox in that same Run.
|
||||
- No authority comes from IDs or handles alone.
|
||||
- No adoption step starts, stops, writes, signals, focuses, splits, or injects
|
||||
a terminal. Exact live-PTY recovery may restore one inactive background
|
||||
renderer tab/leaf binding.
|
||||
- B-era disconnects replay or deduplicate instead of losing or duplicating
|
||||
work; A-era repeatable signals and already-answered asks follow the explicit
|
||||
ambiguity recovery rules.
|
||||
- Lost scheduler memory is reported precisely without stranding durable state.
|
||||
- Local/background, remote, SSH, WSL, folder, and Windows guarantees have
|
||||
deterministic proof.
|
||||
- The PR contains this plan, focused implementation, and no unrelated changes.
|
||||
@@ -50,6 +50,11 @@ internal static class OrcaCliLauncher
|
||||
"ORCA_NODE_REPL_EXTERNAL_MODULE"
|
||||
);
|
||||
startInfo.EnvironmentVariables["ELECTRON_RUN_AS_NODE"] = "1";
|
||||
startInfo.EnvironmentVariables["ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER"] = "1";
|
||||
startInfo.EnvironmentVariables["ORCA_CLI_COMMAND"] =
|
||||
Environment.GetEnvironmentVariable("ORCA_CLI_COMMAND") == "orca-ide"
|
||||
? "orca-ide"
|
||||
: "orca";
|
||||
|
||||
using (Process child = Process.Start(startInfo))
|
||||
{
|
||||
|
||||
@@ -53,36 +53,49 @@ Do not use orchestration merely because the user says "hand off", "handoff", "ha
|
||||
|
||||
## Contract Migration
|
||||
|
||||
Orca uses a hard cutover for orchestration mutations. It does not run a legacy scheduler, translate old writes, or drain pre-upgrade orchestration work.
|
||||
Orca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.
|
||||
|
||||
If a command returns `orchestration_migration_required`, `run_required`, or a lifecycle validation error with `nextCommandArgs`:
|
||||
Treat the authority label on injected or formatted messages as definitive:
|
||||
|
||||
1. Confirm `effectsApplied` is `false`.
|
||||
2. Using the same CLI executable that returned the error, run the returned arguments: `skills get orchestration --full`.
|
||||
3. Read the guide completely. Do not retry the rejected command unchanged.
|
||||
4. Inspect the pre-upgrade Run, terminal, and assigned worktree before deciding whether any work needs replacement.
|
||||
5. If the legacy worker is still making valid progress, leave it as the only editor in that worktree and observe it manually, read-only, until it reaches a stable handoff point.
|
||||
6. Only then, if remaining work needs new lifecycle supervision, create or bind a lightweight Run, create a Task for the remaining work, and use `worker-start` in a conflict-free placement.
|
||||
- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.
|
||||
- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.
|
||||
- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.
|
||||
- An unlabeled current message uses the current guide and current grammar.
|
||||
|
||||
The arguments intentionally omit an executable name so this works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command.
|
||||
Database provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.
|
||||
|
||||
The cutover removes lifecycle authority; it does not cancel the prior assignment, invalidate its worktree, discard filesystem changes, or stop the worker process. Pre-upgrade terminals and agents can continue their valid assigned work, but they are no longer supervised by Orca: old heartbeat, question, completion, scheduler, reply, acknowledgment, and mutation calls are rejected before effects.
|
||||
Compatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.
|
||||
|
||||
Legacy database rows and terminal output remain available for explicit read-only inspection:
|
||||
When a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.
|
||||
|
||||
On packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume <message_id>` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.
|
||||
|
||||
Legacy inspection remains available without consuming mail:
|
||||
|
||||
```bash
|
||||
orca orchestration run-list --json
|
||||
# run_legacy_local is an empty audit tombstone after adoption.
|
||||
orca orchestration run-show --id run_legacy_local --json
|
||||
orca orchestration task-list --run run_legacy_local --json
|
||||
# In run-list, find the ordinary Run whose objective is:
|
||||
# "Recovered orchestration work from a contract update"
|
||||
orca orchestration run-show --id <adopted_run_id> --json
|
||||
orca orchestration task-list --run <adopted_run_id> --json
|
||||
orca orchestration inbox --full --json
|
||||
orca orchestration check --terminal <legacy_handle> --peek --format --json
|
||||
orca terminal read --terminal <legacy_handle> --json
|
||||
orca terminal wait --terminal <legacy_handle> --for tui-idle --timeout-ms 60000 --json
|
||||
```
|
||||
|
||||
Read-only inspection never consumes legacy mail. A stable handoff point means the worker has become idle, stopped, or completed a coherent edit/test/commit checkpoint; visible activity is a reason to keep observing, not to replace it. Do not prompt the worker to use old lifecycle commands.
|
||||
If the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:
|
||||
|
||||
Never launch a replacement editor in the same worktree while the legacy worker may still write there. Wait for a stable handoff and preserve its filesystem work; if overlap is truly required, use a separate conflict-free worktree with an explicit plan for transferring existing dirty changes. Do not use actionable `check`, acknowledgment, reply, send, retry, or task updates against the legacy Run.
|
||||
```bash
|
||||
orca orchestration run-use --id <adopted_run_id> --takeover-legacy --json
|
||||
orca orchestration check --run <adopted_run_id> --json
|
||||
```
|
||||
|
||||
Takeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.
|
||||
|
||||
Do not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.
|
||||
|
||||
## Ownership
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -71,4 +71,24 @@ describe('orchestration check identity', () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves the pinned legacy --inject check signature', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_legacy_worker'
|
||||
|
||||
await invokeCheck(
|
||||
new Map<string, string | boolean>([
|
||||
['unread', true],
|
||||
['inject', true]
|
||||
])
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.check',
|
||||
expect.objectContaining({
|
||||
terminal: 'term_legacy_worker',
|
||||
unread: true,
|
||||
inject: true
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -179,6 +179,7 @@ describe('legacy orchestration CLI inspection', () => {
|
||||
id: 'msg_current',
|
||||
run_id: 'run_current',
|
||||
from_handle: 'term_current',
|
||||
to_handle: 'run:run_current',
|
||||
subject: 'current question',
|
||||
type: 'question',
|
||||
body: 'May I continue?',
|
||||
@@ -217,7 +218,8 @@ describe('legacy orchestration CLI inspection', () => {
|
||||
expect(response.result.formatted).not.toContain('RUNTIME_SENTINEL')
|
||||
expect(
|
||||
response.result.formatted.split('\n').filter((line) => line.startsWith('[Reply:'))
|
||||
).toEqual(['[Reply: orca orchestration reply --id msg_current --from term_coord --body "..."]'])
|
||||
).toEqual(['[Reply: orca orchestration reply --id msg_current --body "..."]'])
|
||||
expect(response.result.formatted).not.toContain('--from run:run_current')
|
||||
})
|
||||
|
||||
it('preserves runtime formatting when every message belongs to a current Run', async () => {
|
||||
|
||||
@@ -1,28 +1,44 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
|
||||
describe('orchestration CLI migration recovery', () => {
|
||||
it('redirects worker_done without an outcome before resolving or calling the runtime', async () => {
|
||||
const call = vi.fn()
|
||||
const originalPaneKey = process.env.ORCA_PANE_KEY
|
||||
|
||||
await expect(
|
||||
ORCHESTRATION_HANDLERS['orchestration send']({
|
||||
flags: new Map<string, string | boolean>([
|
||||
['from', 'term_worker'],
|
||||
['subject', 'Done'],
|
||||
['type', 'worker_done']
|
||||
]),
|
||||
client: { call },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
data: {
|
||||
effectsApplied: false,
|
||||
nextCommandArgs: ['skills', 'get', 'orchestration', '--full']
|
||||
}
|
||||
afterEach(() => {
|
||||
if (originalPaneKey === undefined) {
|
||||
delete process.env.ORCA_PANE_KEY
|
||||
} else {
|
||||
process.env.ORCA_PANE_KEY = originalPaneKey
|
||||
}
|
||||
})
|
||||
|
||||
describe('orchestration CLI migration recovery', () => {
|
||||
it('forwards legacy worker_done without inventing a completion outcome', async () => {
|
||||
process.env.ORCA_PANE_KEY = 'tab-worker:leaf-worker'
|
||||
const call = vi.fn().mockResolvedValue({ result: { message: { id: 'msg_done' } } })
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration send']({
|
||||
flags: new Map<string, string | boolean>([
|
||||
['from', 'term_worker'],
|
||||
['subject', 'Done'],
|
||||
['type', 'worker_done']
|
||||
]),
|
||||
client: { call },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
expect(call).toHaveBeenCalledWith('orchestration.send', {
|
||||
from: 'term_worker',
|
||||
to: undefined,
|
||||
run: undefined,
|
||||
subject: 'Done',
|
||||
body: undefined,
|
||||
type: 'worker_done',
|
||||
priority: undefined,
|
||||
threadId: undefined,
|
||||
payload: undefined,
|
||||
senderPaneKey: 'tab-worker:leaf-worker',
|
||||
devMode: false
|
||||
})
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,6 +62,28 @@ describe('lightweight Run CLI handlers', () => {
|
||||
from: 'term_coord'
|
||||
})
|
||||
})
|
||||
|
||||
it('passes explicit legacy takeover only when requested', async () => {
|
||||
callMock.mockResolvedValue({
|
||||
result: { run: { id: 'run_adopted', objective: 'Recovered work' } }
|
||||
})
|
||||
await ORCHESTRATION_HANDLERS['orchestration run-use']({
|
||||
flags: new Map<string, string | boolean>([
|
||||
['id', 'run_adopted'],
|
||||
['from', 'term_current'],
|
||||
['takeover-legacy', true]
|
||||
]),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.runUse', {
|
||||
id: 'run_adopted',
|
||||
from: 'term_current',
|
||||
takeoverLegacy: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('orchestration reset CLI handler', () => {
|
||||
|
||||
@@ -64,11 +64,13 @@ describe('orchestration timeout flag validation', () => {
|
||||
// Why: unread:false makes pre-peek runtimes fall back to non-consuming all mode.
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.check', {
|
||||
terminal: 'term_worker',
|
||||
terminalPaneKey: undefined,
|
||||
unread: false,
|
||||
peek: true,
|
||||
all: undefined,
|
||||
types: undefined,
|
||||
format: undefined,
|
||||
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
|
||||
run: undefined,
|
||||
ack: undefined,
|
||||
wait: true,
|
||||
@@ -182,7 +184,9 @@ describe('orchestration timeout flag validation', () => {
|
||||
resume: undefined,
|
||||
options: undefined,
|
||||
timeoutMs: 123,
|
||||
from: 'term_worker'
|
||||
from: 'term_worker',
|
||||
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
|
||||
compatibilityWindowsCommand: undefined
|
||||
},
|
||||
{ timeoutMs: 5_123, orchestrationCapability: undefined }
|
||||
)
|
||||
@@ -209,7 +213,9 @@ describe('orchestration timeout flag validation', () => {
|
||||
resume: 'msg_question',
|
||||
options: undefined,
|
||||
timeoutMs: undefined,
|
||||
from: 'term_worker'
|
||||
from: 'term_worker',
|
||||
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
|
||||
compatibilityWindowsCommand: undefined
|
||||
},
|
||||
{ timeoutMs: 605_000, orchestrationCapability: undefined }
|
||||
)
|
||||
|
||||
@@ -86,11 +86,15 @@ describe('orchestration timeout flag validation', () => {
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.check', {
|
||||
terminal: 'term_worker',
|
||||
terminalPaneKey: undefined,
|
||||
unread: false,
|
||||
peek: true,
|
||||
all: undefined,
|
||||
types: undefined,
|
||||
inject: undefined,
|
||||
format: undefined,
|
||||
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
|
||||
run: undefined,
|
||||
ack: undefined,
|
||||
wait: true,
|
||||
timeoutMs: 250
|
||||
})
|
||||
@@ -273,12 +277,16 @@ describe('orchestration timeout flag validation', () => {
|
||||
'orchestration.ask',
|
||||
{
|
||||
to: 'term_coord',
|
||||
run: undefined,
|
||||
question: 'Proceed?',
|
||||
resume: undefined,
|
||||
options: undefined,
|
||||
timeoutMs: 123,
|
||||
from: 'term_worker'
|
||||
from: 'term_worker',
|
||||
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
|
||||
compatibilityWindowsCommand: undefined
|
||||
},
|
||||
{ timeoutMs: 5_123 }
|
||||
{ timeoutMs: 5_123, orchestrationCapability: undefined }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
const originalCliCommand = process.env.ORCA_CLI_COMMAND
|
||||
const originalPackagedLauncher = process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER
|
||||
const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE
|
||||
const originalExitCode = process.exitCode
|
||||
|
||||
vi.mock('../format', () => ({ printResult: vi.fn() }))
|
||||
vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() }))
|
||||
|
||||
import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
|
||||
describe('packaged Windows legacy ask protocol', () => {
|
||||
beforeEach(() => {
|
||||
callMock.mockReset()
|
||||
process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER = '1'
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
process.exitCode = undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
process.exitCode = originalExitCode
|
||||
restoreEnv('ORCA_CLI_COMMAND', originalCliCommand)
|
||||
restoreEnv('ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER', originalPackagedLauncher)
|
||||
restoreEnv('ORCA_TERMINAL_HANDLE', originalTerminalHandle)
|
||||
})
|
||||
|
||||
it.each(['orca', 'orca-ide'] as const)(
|
||||
'commits with the %s launcher and exits 75 before resume',
|
||||
async (command) => {
|
||||
process.env.ORCA_CLI_COMMAND = command
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
answer: null,
|
||||
messageId: 'msg_question',
|
||||
threadId: 'msg_question',
|
||||
timedOut: false,
|
||||
legacyCompatibility: {
|
||||
resumeRequired: true,
|
||||
resumeCommand: `${command} orchestration ask --resume msg_question`
|
||||
}
|
||||
}
|
||||
})
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await invokeAsk(new Map([['question', 'Proceed?']]))
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.ask',
|
||||
expect.objectContaining({
|
||||
question: 'Proceed?',
|
||||
resume: undefined,
|
||||
compatibilityWindowsCommand: command
|
||||
}),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(log.mock.calls.map(([line]) => line)).toEqual([
|
||||
'Question msg_question committed.',
|
||||
`Resume with: ${command} orchestration ask --resume msg_question`
|
||||
])
|
||||
expect(process.exitCode).toBe(75)
|
||||
}
|
||||
)
|
||||
|
||||
it('resumes the committed question without another exit-75 handoff', async () => {
|
||||
process.env.ORCA_CLI_COMMAND = 'orca'
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
answer: 'yes',
|
||||
messageId: 'msg_question',
|
||||
threadId: 'msg_question',
|
||||
timedOut: false
|
||||
}
|
||||
})
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await invokeAsk(new Map([['resume', 'msg_question']]))
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.ask',
|
||||
expect.objectContaining({
|
||||
question: undefined,
|
||||
resume: 'msg_question',
|
||||
compatibilityWindowsCommand: 'orca'
|
||||
}),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(log).toHaveBeenCalledWith('yes')
|
||||
expect(process.exitCode).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
function invokeAsk(flags: Map<string, string | boolean>): Promise<void> {
|
||||
return ORCHESTRATION_HANDLERS['orchestration ask']({
|
||||
flags,
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: false
|
||||
} as never)
|
||||
}
|
||||
|
||||
function restoreEnv(name: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[name]
|
||||
} else {
|
||||
process.env[name] = value
|
||||
}
|
||||
}
|
||||
@@ -692,6 +692,7 @@ describe('orchestration timeout flag validation', () => {
|
||||
all: undefined,
|
||||
types: undefined,
|
||||
format: undefined,
|
||||
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
|
||||
run: undefined,
|
||||
ack: undefined,
|
||||
wait: true,
|
||||
@@ -819,7 +820,9 @@ describe('orchestration timeout flag validation', () => {
|
||||
resume: undefined,
|
||||
options: undefined,
|
||||
timeoutMs: 123,
|
||||
from: 'term_worker'
|
||||
from: 'term_worker',
|
||||
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
|
||||
compatibilityWindowsCommand: undefined
|
||||
},
|
||||
{ timeoutMs: 5_123, orchestrationCapability: undefined }
|
||||
)
|
||||
@@ -848,7 +851,9 @@ describe('orchestration timeout flag validation', () => {
|
||||
resume: 'msg_question',
|
||||
options: undefined,
|
||||
timeoutMs: undefined,
|
||||
from: 'term_worker'
|
||||
from: 'term_worker',
|
||||
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
|
||||
compatibilityWindowsCommand: undefined
|
||||
},
|
||||
{ timeoutMs: 605_000, orchestrationCapability: undefined }
|
||||
)
|
||||
|
||||
@@ -21,11 +21,14 @@ import type {
|
||||
} from '../../shared/orchestration-worker-output'
|
||||
import type { NativeChatMessage } from '../../shared/native-chat-types'
|
||||
import type { RuntimeTerminalRead } from '../../shared/runtime-types'
|
||||
import { orchestrationMigrationData } from '../../shared/orchestration-rpc-contract'
|
||||
import {
|
||||
ORCHESTRATION_LEGACY_RUN_ID,
|
||||
orchestrationMigrationData,
|
||||
orchestrationSkillRecoveryData
|
||||
} from '../../shared/orchestration-rpc-contract'
|
||||
formatMessageReadOnlyTag,
|
||||
formatOrchestrationCheckText,
|
||||
prepareOrchestrationCheckOutput,
|
||||
type LegacyCompatibilityResult,
|
||||
type OrchestrationMessageSummary as MessageSummary
|
||||
} from '../../shared/orchestration-check-output'
|
||||
|
||||
// Why: 15 s is well under Claude Code's ~2 min Bash-tool silence budget while keeping log volume low. See design doc §3.4.
|
||||
const DEFAULT_KEEPALIVE_INTERVAL_MS = 15_000
|
||||
@@ -75,81 +78,6 @@ const TASK_STATUS_VALUES = [
|
||||
'blocked'
|
||||
] as const
|
||||
|
||||
type MessageSummary = {
|
||||
id: string
|
||||
run_id?: string
|
||||
from_handle: string
|
||||
to_handle?: string
|
||||
subject?: string
|
||||
type?: string
|
||||
body?: string
|
||||
payload?: string | null
|
||||
priority?: string
|
||||
read?: number
|
||||
}
|
||||
|
||||
function formatMessageReadOnlyTag(message: MessageSummary): string {
|
||||
return message.run_id === ORCHESTRATION_LEGACY_RUN_ID ? ' [legacy, read-only]' : ''
|
||||
}
|
||||
|
||||
function isLegacyReadOnlyMessage(message: MessageSummary): boolean {
|
||||
return message.run_id === ORCHESTRATION_LEGACY_RUN_ID
|
||||
}
|
||||
|
||||
function formatMessagePriorityTag(message: MessageSummary): string {
|
||||
return message.priority === 'urgent' ? ' [URGENT]' : message.priority === 'high' ? ' [HIGH]' : ''
|
||||
}
|
||||
|
||||
function escapeTerminalControlCharacters(value: string): string {
|
||||
return [...value]
|
||||
.map((character) => {
|
||||
const code = character.charCodeAt(0)
|
||||
if (character === '\n' || (code >= 0x20 && code < 0x7f) || code > 0x9f) {
|
||||
return character
|
||||
}
|
||||
return `\\x${code.toString(16).padStart(2, '0')}`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
function formatQuotedMessageField(label: string, value?: string): string {
|
||||
return `[${label}]\n${escapeTerminalControlCharacters(value ?? '')
|
||||
.split('\n')
|
||||
.map((line) => ` ${line}`)
|
||||
.join('\n')}`
|
||||
}
|
||||
|
||||
function formatLegacyAwareCheckMessages(
|
||||
messages: MessageSummary[],
|
||||
checkedTerminal: string
|
||||
): string {
|
||||
return messages
|
||||
.map((message) => {
|
||||
const legacyReadOnly = isLegacyReadOnlyMessage(message)
|
||||
const lines = [
|
||||
`${message.id}${formatMessageReadOnlyTag(message)}${formatMessagePriorityTag(message)} [${message.type ?? 'status'}] from=${message.from_handle}`,
|
||||
formatQuotedMessageField('subject', message.subject)
|
||||
]
|
||||
if (legacyReadOnly) {
|
||||
lines.push('[Inspection only: reply and acknowledgment are unavailable.]')
|
||||
}
|
||||
if (message.body) {
|
||||
lines.push(formatQuotedMessageField('body', message.body))
|
||||
}
|
||||
if (message.payload) {
|
||||
lines.push(formatQuotedMessageField('payload', message.payload))
|
||||
}
|
||||
if (!legacyReadOnly) {
|
||||
const replyFrom = message.to_handle ?? checkedTerminal
|
||||
lines.push(
|
||||
`[Reply: orca orchestration reply --id ${message.id} --from ${replyFrom} --body "..."]`
|
||||
)
|
||||
}
|
||||
return lines.join('\n')
|
||||
})
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
type LifecycleSendRejection = {
|
||||
action: 'rejected'
|
||||
code: string
|
||||
@@ -170,6 +98,40 @@ type OrchestrationSendResult =
|
||||
lifecycle?: { action: 'completed' | 'failed' }
|
||||
}
|
||||
|
||||
function resolveCompatibilityCliCommand(): 'orca' | 'orca-ide' | 'orca-dev' {
|
||||
const configured = process.env.ORCA_CLI_COMMAND
|
||||
if (configured === 'orca' || configured === 'orca-ide' || configured === 'orca-dev') {
|
||||
return configured
|
||||
}
|
||||
return process.platform === 'linux' ? 'orca-ide' : 'orca'
|
||||
}
|
||||
|
||||
function resolvePackagedWindowsCompatibilityCommand(): 'orca' | 'orca-ide' | undefined {
|
||||
if (process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER !== '1') {
|
||||
return undefined
|
||||
}
|
||||
const command = process.env.ORCA_CLI_COMMAND
|
||||
if (command === 'orca' || command === 'orca-ide') {
|
||||
return command
|
||||
}
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'The packaged Orca launcher did not provide a valid resume command. No question was created.'
|
||||
)
|
||||
}
|
||||
|
||||
async function flushStdout(): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
process.stdout.write('', (error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function getOptionalStructuredMessagePayload(
|
||||
flags: Map<string, string | boolean>
|
||||
): string | undefined {
|
||||
@@ -485,7 +447,8 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
run: { id: string; objective: string; consumer_generation: number }
|
||||
}>(client, flags, 'orchestration.runUse', {
|
||||
id: getRequiredStringFlag(flags, 'id'),
|
||||
from
|
||||
from,
|
||||
...(flags.has('takeover-legacy') ? { takeoverLegacy: true } : {})
|
||||
})
|
||||
printResult(result, json, (r) => `Using Run ${r.run.id}: ${r.run.objective}`)
|
||||
},
|
||||
@@ -541,13 +504,6 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
rejectLifecycleGroupRecipient(type, to)
|
||||
}
|
||||
const outcome = getOptionalStringFlag(flags, 'outcome')
|
||||
if (type === 'worker_done' && outcome === undefined && !flags.has('payload')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'worker_done requires --outcome succeeded or --outcome failed. No effects were applied.',
|
||||
orchestrationSkillRecoveryData()
|
||||
)
|
||||
}
|
||||
if (type !== 'worker_done' && outcome !== undefined) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
@@ -634,6 +590,7 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
timedOut?: boolean
|
||||
cancelled?: boolean
|
||||
connectionLost?: boolean
|
||||
legacyCompatibility?: LegacyCompatibilityResult
|
||||
}
|
||||
let result: Awaited<ReturnType<typeof client.call<CheckResult>>>
|
||||
try {
|
||||
@@ -646,6 +603,8 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
all: flags.has('all') ? true : undefined,
|
||||
types: getOptionalStringFlag(flags, 'types'),
|
||||
format: flags.has('format') ? true : undefined,
|
||||
inject: flags.has('inject') ? true : undefined,
|
||||
compatibilityCliCommand: resolveCompatibilityCliCommand(),
|
||||
run: getOptionalStringFlag(flags, 'run'),
|
||||
ack: getOptionalStringFlag(flags, 'ack'),
|
||||
wait: wait ? true : undefined,
|
||||
@@ -682,39 +641,25 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (flags.has('format') && result.result.messages.some(isLegacyReadOnlyMessage)) {
|
||||
// Why: formatted is one opaque batch with untrusted bodies, so selective banner parsing cannot safely remove legacy actions.
|
||||
result = {
|
||||
...result,
|
||||
result: {
|
||||
...result.result,
|
||||
formatted: formatLegacyAwareCheckMessages(result.result.messages, terminal)
|
||||
}
|
||||
}
|
||||
result = {
|
||||
...result,
|
||||
result: prepareOrchestrationCheckOutput(result.result, terminal, flags.has('format'))
|
||||
}
|
||||
printResult(result, json, (r) => formatOrchestrationCheckText(r, terminal))
|
||||
const compatibilityAck = result.result.legacyCompatibility?.ackMessageIds
|
||||
if (compatibilityAck && compatibilityAck.length > 0) {
|
||||
await flushStdout()
|
||||
await client.call('orchestration.check', {
|
||||
terminal,
|
||||
compatibilityAck: JSON.stringify({
|
||||
messageIds: compatibilityAck,
|
||||
types: getOptionalStringFlag(flags, 'types')
|
||||
?.split(',')
|
||||
.map((type) => type.trim())
|
||||
.filter(Boolean)
|
||||
})
|
||||
})
|
||||
}
|
||||
printResult(result, json, (r) => {
|
||||
if (r.formatted) {
|
||||
return r.formatted
|
||||
}
|
||||
if (r.count === 0) {
|
||||
if (r.timedOut) {
|
||||
return 'Wait timed out; no messages were consumed.'
|
||||
}
|
||||
if (r.cancelled) {
|
||||
return r.connectionLost
|
||||
? 'Wait cancelled because the connection closed; no messages were consumed.'
|
||||
: 'Wait cancelled; no messages were consumed.'
|
||||
}
|
||||
return 'No messages.'
|
||||
}
|
||||
const rendered = r.messages
|
||||
.map(
|
||||
(m) =>
|
||||
`${m.id}${formatMessageReadOnlyTag(m)} [${m.type ?? 'status'}] from=${m.from_handle} "${m.subject}"`
|
||||
)
|
||||
.join('\n')
|
||||
return r.deliveryId ? `Delivery ${r.deliveryId}\n${rendered}` : rendered
|
||||
})
|
||||
},
|
||||
|
||||
'orchestration reply': async ({ flags, client, cwd, json }) => {
|
||||
@@ -1034,6 +979,8 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
timeoutMs?: number
|
||||
cancelled?: boolean
|
||||
connectionLost?: boolean
|
||||
answerMessageId?: string | null
|
||||
legacyCompatibility?: LegacyCompatibilityResult
|
||||
}>(
|
||||
client,
|
||||
flags,
|
||||
@@ -1045,7 +992,9 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
resume,
|
||||
options: getOptionalStringFlag(flags, 'options'),
|
||||
timeoutMs: parsedTimeoutMs === undefined ? undefined : timeoutMs,
|
||||
from
|
||||
from,
|
||||
compatibilityCliCommand: resolveCompatibilityCliCommand(),
|
||||
compatibilityWindowsCommand: resolvePackagedWindowsCompatibilityCommand()
|
||||
},
|
||||
// Why: extend past timeoutMs so the RPC transport's 60s default doesn't abort before the runtime's own timeout resolves.
|
||||
{
|
||||
@@ -1056,9 +1005,25 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
// Why: bypass printResult so --json emits a bare JSON object (no envelope) pipeable via `jq -r .answer`, unlike other verbs.
|
||||
if (json) {
|
||||
console.log(JSON.stringify(result.result))
|
||||
} else if (result.result.legacyCompatibility?.resumeRequired) {
|
||||
console.log(`Question ${result.result.messageId} committed.`)
|
||||
console.log(`Resume with: ${result.result.legacyCompatibility.resumeCommand}`)
|
||||
} else if (result.result.answer !== null) {
|
||||
console.log(result.result.answer)
|
||||
}
|
||||
if (result.result.legacyCompatibility?.resumeRequired) {
|
||||
await flushStdout()
|
||||
process.exitCode = 75
|
||||
return
|
||||
}
|
||||
const answerAck = result.result.legacyCompatibility?.answerAcknowledgement
|
||||
if (answerAck && result.result.answer !== null) {
|
||||
await flushStdout()
|
||||
await client.call('orchestration.check', {
|
||||
terminal: from,
|
||||
compatibilityQuestionAck: JSON.stringify(answerAck)
|
||||
})
|
||||
}
|
||||
if (result.result.timedOut) {
|
||||
if (!json) {
|
||||
// Why: report the server's effective budget — it clamps large values, so the requested one would overstate the wait.
|
||||
|
||||
@@ -103,20 +103,40 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => {
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeMetadata(userDataPath, endpoint)
|
||||
|
||||
const priorLaunchToken = process.env.ORCA_AGENT_LAUNCH_TOKEN
|
||||
process.env.ORCA_AGENT_LAUNCH_TOKEN = 'launch-secret'
|
||||
const client = new RuntimeClient(userDataPath, 500)
|
||||
await client.call(
|
||||
'orchestration.send',
|
||||
{ subject: 'hello' },
|
||||
{
|
||||
orchestrationRequestId: 'mutation_explicit'
|
||||
try {
|
||||
await client.call(
|
||||
'orchestration.send',
|
||||
{ subject: 'hello' },
|
||||
{
|
||||
orchestrationRequestId: 'mutation_explicit'
|
||||
}
|
||||
)
|
||||
await client.call('orchestration.taskList', {})
|
||||
const secondClient = new RuntimeClient(userDataPath, 500)
|
||||
await secondClient.call('orchestration.taskList', {})
|
||||
} finally {
|
||||
if (priorLaunchToken === undefined) {
|
||||
delete process.env.ORCA_AGENT_LAUNCH_TOKEN
|
||||
} else {
|
||||
process.env.ORCA_AGENT_LAUNCH_TOKEN = priorLaunchToken
|
||||
}
|
||||
)
|
||||
await client.call('orchestration.taskList', {})
|
||||
}
|
||||
|
||||
expect(requests[0]?.method).toBe('status.get')
|
||||
expect(requests[0]?.compatibilityInvocationId).toBeUndefined()
|
||||
expect(requests[1]?.orchestrationRequestId).toBe('mutation_explicit')
|
||||
expect(requests[1]?.orchestrationContractVersion).toBe(1)
|
||||
expect(requests[1]?.compatibilityInvocationId).toBe('mutation_explicit')
|
||||
expect(requests[1]?.orchestrationCompatibilityEvidence).toMatchObject({
|
||||
launchToken: 'launch-secret'
|
||||
})
|
||||
expect(requests[2]?.orchestrationRequestId).toBeUndefined()
|
||||
expect(requests[2]?.compatibilityInvocationId).not.toBe(requests[1]?.compatibilityInvocationId)
|
||||
expect(requests[3]?.method).toBe('orchestration.taskList')
|
||||
expect(requests[3]?.compatibilityInvocationId).not.toBe(requests[1]?.compatibilityInvocationId)
|
||||
})
|
||||
|
||||
it('rejects an old local runtime before sending an orchestration mutation', async () => {
|
||||
|
||||
+14
-17
@@ -20,6 +20,8 @@ import {
|
||||
ORCHESTRATION_CONTRACT_VERSION,
|
||||
RUNTIME_PROTOCOL_VERSION
|
||||
} from '../../shared/protocol-version'
|
||||
import { createOrchestrationCompatibilityEnvelope } from './orchestration-compatibility-envelope'
|
||||
import { getTimeoutMsParam, isWaitingCheck } from './runtime-request-timeout'
|
||||
|
||||
// Why: for long-poll methods the caller's method-level
|
||||
// `params.timeoutMs` is the inner waiter budget; we extend the client-side
|
||||
@@ -43,6 +45,9 @@ export class RuntimeClient {
|
||||
private readonly environmentSelector: string | null
|
||||
private remoteCompatChecked = false
|
||||
private orchestrationContractCheck: Promise<void> | null = null
|
||||
private readonly 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
|
||||
@@ -76,12 +81,20 @@ export class RuntimeClient {
|
||||
const orchestrationRequestId = orchestrationMutation
|
||||
? (options?.orchestrationRequestId ?? randomUUID())
|
||||
: undefined
|
||||
const compatibilityEnvelope = method.startsWith('orchestration.')
|
||||
? {
|
||||
...this.orchestrationCompatibility,
|
||||
compatibilityInvocationId:
|
||||
orchestrationRequestId ?? this.orchestrationCompatibility.compatibilityInvocationId
|
||||
}
|
||||
: {}
|
||||
const envelope = {
|
||||
orchestrationCapability: options?.orchestrationCapability,
|
||||
orchestrationContractVersion: method.startsWith('orchestration.')
|
||||
? ORCHESTRATION_CONTRACT_VERSION
|
||||
: undefined,
|
||||
orchestrationRequestId
|
||||
orchestrationRequestId,
|
||||
...compatibilityEnvelope
|
||||
}
|
||||
if (this.remotePairing) {
|
||||
if (method !== 'status.get') {
|
||||
@@ -327,19 +340,3 @@ function resolveRemotePairing(
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function isWaitingCheck(params: unknown): boolean {
|
||||
return (
|
||||
typeof params === 'object' &&
|
||||
params !== null &&
|
||||
'wait' in params &&
|
||||
(params as { wait: unknown }).wait === true
|
||||
)
|
||||
}
|
||||
|
||||
function getTimeoutMsParam(params: unknown): unknown {
|
||||
if (typeof params !== 'object' || params === null || !('timeoutMs' in params)) {
|
||||
return undefined
|
||||
}
|
||||
return (params as { timeoutMs?: unknown }).timeoutMs
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { readOrchestrationCompatibilityEvidence } from '../../shared/orchestration-compatibility-evidence'
|
||||
import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope'
|
||||
|
||||
export function createOrchestrationCompatibilityEnvelope(
|
||||
env: NodeJS.ProcessEnv
|
||||
): RuntimeOrchestrationEnvelope {
|
||||
return {
|
||||
compatibilityInvocationId: randomUUID(),
|
||||
orchestrationCompatibilityEvidence: readOrchestrationCompatibilityEvidence(env)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export function isWaitingCheck(params: unknown): boolean {
|
||||
return (
|
||||
typeof params === 'object' &&
|
||||
params !== null &&
|
||||
'wait' in params &&
|
||||
(params as { wait: unknown }).wait === true
|
||||
)
|
||||
}
|
||||
|
||||
export function getTimeoutMsParam(params: unknown): unknown {
|
||||
if (typeof params !== 'object' || params === null || !('timeoutMs' in params)) {
|
||||
return undefined
|
||||
}
|
||||
return (params as { timeoutMs?: unknown }).timeoutMs
|
||||
}
|
||||
@@ -188,7 +188,9 @@ export async function sendRequest<TResult>(
|
||||
params,
|
||||
orchestrationCapability: envelope?.orchestrationCapability,
|
||||
orchestrationContractVersion: envelope?.orchestrationContractVersion,
|
||||
orchestrationRequestId: envelope?.orchestrationRequestId
|
||||
orchestrationRequestId: envelope?.orchestrationRequestId,
|
||||
compatibilityInvocationId: envelope?.compatibilityInvocationId,
|
||||
orchestrationCompatibilityEvidence: envelope?.orchestrationCompatibilityEvidence
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RuntimeRpcFailureError } from './types'
|
||||
|
||||
describe('RuntimeRpcFailureError compatibility redaction', () => {
|
||||
it('removes compatibility evidence from CLI JSON errors', () => {
|
||||
const error = new RuntimeRpcFailureError({
|
||||
id: 'request-1',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'compatibility_rejected',
|
||||
message: 'Rejected',
|
||||
data: {
|
||||
orchestrationCompatibilityEvidence: {
|
||||
launchToken: 'launch-secret'
|
||||
}
|
||||
}
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
|
||||
expect(error.data).toEqual({
|
||||
orchestrationCompatibilityEvidence: '[redacted]'
|
||||
})
|
||||
expect(JSON.stringify(error.response)).not.toContain('launch-secret')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RuntimeRpcFailure } from '../../shared/runtime-rpc-envelope'
|
||||
import { redactOrchestrationCompatibilitySecrets } from '../../shared/orchestration-compatibility-evidence'
|
||||
|
||||
export type {
|
||||
RuntimeRpcFailure,
|
||||
@@ -15,7 +16,7 @@ export class RuntimeClientError extends Error {
|
||||
constructor(code: string, message: string, data?: unknown) {
|
||||
super(message)
|
||||
this.code = code
|
||||
this.data = data
|
||||
this.data = redactOrchestrationCompatibilitySecrets(data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +26,12 @@ export class RuntimeRpcFailureError extends RuntimeClientError {
|
||||
constructor(response: RuntimeRpcFailure) {
|
||||
// Why: all client errors expose recovery through the same inherited channel.
|
||||
super(response.error.code, response.error.message, response.error.data)
|
||||
this.response = response
|
||||
this.response = {
|
||||
...response,
|
||||
error: {
|
||||
...response.error,
|
||||
...(response.error.data === undefined ? {} : { data: this.data })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,12 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
{
|
||||
path: ['orchestration', 'run-use'],
|
||||
summary: 'Bind this coordinator terminal to an existing Run',
|
||||
usage: 'orca orchestration run-use --id <run_id> [--from <handle>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'id', 'from', 'retry-request']
|
||||
usage:
|
||||
'orca orchestration run-use --id <run_id> [--from <handle>] [--takeover-legacy] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'id', 'from', 'takeover-legacy', 'retry-request'],
|
||||
notes: [
|
||||
'--takeover-legacy must run in the live coordinator agent terminal it binds; it preserves existing worker assignments.'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'run-current'],
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { AgentHookEventPayload } from '../../shared/agent-hook-listener'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { AgentHookServer } from './server'
|
||||
|
||||
const PANE_KEY = makePaneKey('tab-authority', '11111111-1111-4111-8111-111111111111')
|
||||
const SECOND_PANE_KEY = makePaneKey('tab-authority-2', '22222222-2222-4222-8222-222222222222')
|
||||
|
||||
describe('AgentHookServer authority evidence', () => {
|
||||
const servers: AgentHookServer[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const server of servers) {
|
||||
server.stop()
|
||||
}
|
||||
servers.length = 0
|
||||
})
|
||||
|
||||
it('freezes pre-listen commitments separately from current-runtime observations', async () => {
|
||||
const server = new AgentHookServer()
|
||||
servers.push(server)
|
||||
const hydrated = {
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: 'launch-before-restart',
|
||||
tabId: 'tab-authority',
|
||||
worktreeId: 'repo::before',
|
||||
connectionId: 'ssh-target',
|
||||
payload: { state: 'working', prompt: 'before', agentType: 'codex' },
|
||||
receivedAt: 100,
|
||||
stateStartedAt: 100
|
||||
} satisfies AgentHookEventPayload & { receivedAt: number; stateStartedAt: number }
|
||||
server._getStateForTests().lastStatusByPaneKey.set(PANE_KEY, hydrated)
|
||||
|
||||
await server.start()
|
||||
const commitments = server.getHydratedAuthorityCommitments()
|
||||
|
||||
expect(commitments).toEqual([
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
tabId: 'tab-authority',
|
||||
worktreeId: 'repo::before',
|
||||
connectionId: 'ssh-target',
|
||||
observedAt: 100
|
||||
}
|
||||
])
|
||||
expect(Object.isFrozen(commitments)).toBe(true)
|
||||
expect(Object.isFrozen(commitments[0])).toBe(true)
|
||||
expect(server.getCurrentAuthorityObservations()).toEqual([])
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toEqual({ paneKey: PANE_KEY, source: 'hydrated_commitment' })
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: 'launch-after-restart',
|
||||
tabId: 'tab-authority',
|
||||
worktreeId: 'repo::after',
|
||||
payload: { state: 'working', prompt: 'after', agentType: 'codex' }
|
||||
},
|
||||
'ssh-target'
|
||||
)
|
||||
|
||||
expect(server.getHydratedAuthorityCommitments()).toBe(commitments)
|
||||
expect(JSON.stringify(commitments)).not.toContain('launch-before-restart')
|
||||
expect(server.getCurrentAuthorityObservations()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
connectionId: 'ssh-target',
|
||||
worktreeId: 'repo::after'
|
||||
})
|
||||
])
|
||||
expect(JSON.stringify(server.getCurrentAuthorityObservations())).not.toContain(
|
||||
'launch-after-restart'
|
||||
)
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'current_runtime'
|
||||
})
|
||||
).toBeNull()
|
||||
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-after-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-after-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'current_runtime'
|
||||
})
|
||||
).toEqual({ paneKey: PANE_KEY, source: 'current_hook' })
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: 'launch-before-restart',
|
||||
tabId: 'tab-authority',
|
||||
worktreeId: 'repo::current',
|
||||
payload: { state: 'working', prompt: 'current', agentType: 'codex' }
|
||||
},
|
||||
'ssh-target'
|
||||
)
|
||||
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toEqual({ paneKey: PANE_KEY, source: 'current_hook' })
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: SECOND_PANE_KEY,
|
||||
launchToken: 'launch-before-restart',
|
||||
tabId: 'tab-authority-2',
|
||||
payload: { state: 'working', prompt: 'duplicate', agentType: 'codex' }
|
||||
},
|
||||
'ssh-target'
|
||||
)
|
||||
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toBeNull()
|
||||
|
||||
server.clearStatusEntriesForConnection('ssh-target')
|
||||
|
||||
expect(server.getHydratedAuthorityCommitments()).toBe(commitments)
|
||||
expect(server.getCurrentAuthorityObservations()).toEqual([])
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toEqual({ paneKey: PANE_KEY, source: 'hydrated_commitment' })
|
||||
|
||||
server.clearPaneState(PANE_KEY)
|
||||
|
||||
expect(server.getHydratedAuthorityCommitments()).toBe(commitments)
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves hydrated authority when stale alias cleanup does not own the stable pane', async () => {
|
||||
const server = new AgentHookServer()
|
||||
servers.push(server)
|
||||
const launchToken = 'launch-before-restart'
|
||||
const launchTokenHash = createHash('sha256').update(launchToken).digest('hex')
|
||||
const hydrated = {
|
||||
paneKey: PANE_KEY,
|
||||
launchToken,
|
||||
tabId: 'tab-authority',
|
||||
worktreeId: 'repo::before',
|
||||
connectionId: 'ssh-target',
|
||||
payload: { state: 'working', prompt: 'before', agentType: 'codex' },
|
||||
receivedAt: 100,
|
||||
stateStartedAt: 100
|
||||
} satisfies AgentHookEventPayload & { receivedAt: number; stateStartedAt: number }
|
||||
server._getStateForTests().lastStatusByPaneKey.set(PANE_KEY, hydrated)
|
||||
server.registerPaneKeyAlias('tab-authority:0', PANE_KEY, 'old-pty')
|
||||
await server.start()
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
launchToken,
|
||||
tabId: 'tab-authority',
|
||||
worktreeId: 'repo::current',
|
||||
payload: { state: 'working', prompt: 'current', agentType: 'codex' }
|
||||
},
|
||||
'ssh-target'
|
||||
)
|
||||
|
||||
server.clearPaneKeyAliasesForPty('old-pty', { shouldClearStablePaneKey: () => false })
|
||||
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash,
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toEqual({ paneKey: PANE_KEY, source: 'current_hook' })
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable max-lines -- Why: this suite exercises the full hook HTTP surface (Claude/Codex/Gemini parsing, transcript chunked scan, paneKey dispatch) and keeping the scenarios co-located avoids fixture drift across files. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
@@ -57,6 +58,7 @@ const TAB_A_PANE = makePaneKey('tab-A', LEAF_5)
|
||||
|
||||
type Body = {
|
||||
paneKey: string
|
||||
launchToken?: string
|
||||
tabId?: string
|
||||
worktreeId?: string
|
||||
env?: string
|
||||
@@ -6254,7 +6256,10 @@ describe('Last-status persistence', () => {
|
||||
try {
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'persist me' })
|
||||
buildBody(
|
||||
{ hook_event_name: 'UserPromptSubmit', prompt: 'persist me' },
|
||||
{ launchToken: 'launch-bearer-must-not-persist' }
|
||||
)
|
||||
)
|
||||
// Synchronous flush via stop() captures the trailing-debounced write.
|
||||
server.flushStatusPersistSync()
|
||||
@@ -6269,11 +6274,148 @@ describe('Last-status persistence', () => {
|
||||
stateStartedAt: expect.any(Number),
|
||||
payload: expect.objectContaining({ state: 'working', prompt: 'persist me' })
|
||||
})
|
||||
expect(file.entries[PANE].launchToken).toBeUndefined()
|
||||
expect(file.entries[PANE].launchTokenHash).toBe(
|
||||
createHash('sha256').update('launch-bearer-must-not-persist').digest('hex')
|
||||
)
|
||||
expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain('launch-bearer-must-not-persist')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('scrubs a legacy persisted launch bearer while retaining its authority commitment', async () => {
|
||||
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })
|
||||
const receivedAt = recentTs()
|
||||
const launchToken = 'legacy-launch-bearer'
|
||||
writeFileSync(
|
||||
lastStatusPath(),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
entries: {
|
||||
[PANE]: {
|
||||
paneKey: PANE,
|
||||
launchToken,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
connectionId: null,
|
||||
receivedAt,
|
||||
stateStartedAt: receivedAt,
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'legacy worker',
|
||||
agentType: 'codex'
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
const launchTokenHash = createHash('sha256').update(launchToken).digest('hex')
|
||||
expect(server.getHydratedAuthorityCommitments()).toEqual([
|
||||
expect.objectContaining({ paneKey: PANE, launchTokenHash })
|
||||
])
|
||||
expect(server.getStatusSnapshotForPane(PANE)[0]?.launchToken).toBeUndefined()
|
||||
|
||||
const persisted = readFileSync(lastStatusPath(), 'utf8')
|
||||
expect(persisted).not.toContain(launchToken)
|
||||
expect(JSON.parse(persisted).entries[PANE]).toMatchObject({ launchTokenHash })
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
|
||||
const restartedServer = new AgentHookServer()
|
||||
await restartedServer.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
expect(restartedServer.getHydratedAuthorityCommitments()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
launchTokenHash: createHash('sha256').update(launchToken).digest('hex')
|
||||
})
|
||||
])
|
||||
expect(restartedServer.getStatusSnapshotForPane(PANE)[0]?.launchToken).toBeUndefined()
|
||||
} finally {
|
||||
restartedServer.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('persists SSH authority through transient clears until explicit retirement', async () => {
|
||||
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })
|
||||
const receivedAt = recentTs()
|
||||
const launchToken = 'retained-ssh-launch-bearer'
|
||||
const launchTokenHash = createHash('sha256').update(launchToken).digest('hex')
|
||||
writeFileSync(
|
||||
lastStatusPath(),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
entries: {
|
||||
[PANE]: {
|
||||
paneKey: PANE,
|
||||
launchTokenHash,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
connectionId: 'ssh-target',
|
||||
receivedAt,
|
||||
stateStartedAt: receivedAt,
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'retained SSH worker',
|
||||
agentType: 'codex'
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const first = new AgentHookServer()
|
||||
await first.start({ env: 'production', userDataPath })
|
||||
first.clearStatusEntriesForConnection('ssh-target')
|
||||
first.flushStatusPersistSync()
|
||||
first.stop()
|
||||
|
||||
const afterClear = JSON.parse(readFileSync(lastStatusPath(), 'utf8'))
|
||||
expect(afterClear.entries).toEqual({})
|
||||
expect(afterClear.authorityCommitments[PANE]).toMatchObject({
|
||||
paneKey: PANE,
|
||||
launchTokenHash,
|
||||
connectionId: 'ssh-target'
|
||||
})
|
||||
|
||||
const restored = new AgentHookServer()
|
||||
await restored.start({ env: 'production', userDataPath })
|
||||
expect(
|
||||
restored.attestCompatibilityAuthority({
|
||||
paneKey: PANE,
|
||||
launchTokenHash,
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toEqual({ paneKey: PANE, source: 'hydrated_commitment' })
|
||||
restored.retirePaneAuthority(PANE)
|
||||
restored.flushStatusPersistSync()
|
||||
restored.stop()
|
||||
|
||||
const retired = new AgentHookServer()
|
||||
await retired.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
expect(
|
||||
retired.attestCompatibilityAuthority({
|
||||
paneKey: PANE,
|
||||
launchTokenHash,
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toBeNull()
|
||||
} finally {
|
||||
retired.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('persists and hydrates Pi session identity without creating status telemetry', async () => {
|
||||
const firstServer = new AgentHookServer()
|
||||
const firstRendererListener = vi.fn()
|
||||
|
||||
+364
-18
@@ -79,6 +79,22 @@ type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
|
||||
stateStartedAt: number
|
||||
}
|
||||
|
||||
type PersistedAgentHookEventPayload = Omit<
|
||||
EnrichedAgentHookEventPayload,
|
||||
'launchToken' | 'promptInteractionKey'
|
||||
> & {
|
||||
launchTokenHash?: string
|
||||
}
|
||||
|
||||
type PersistedAgentHookAuthorityCommitment = {
|
||||
paneKey: string
|
||||
launchTokenHash: string
|
||||
connectionId: string | null
|
||||
tabId?: string
|
||||
worktreeId?: string
|
||||
observedAt: number
|
||||
}
|
||||
|
||||
export type AgentHookStatusChangeEntry = {
|
||||
state: AgentStatusState
|
||||
receivedAt: number
|
||||
@@ -92,6 +108,20 @@ export type AgentHookProviderSessionIdentity = {
|
||||
worktreeId?: string
|
||||
}
|
||||
|
||||
export type AgentHookAuthorityEvidence = Readonly<{
|
||||
paneKey: string
|
||||
launchTokenHash: string
|
||||
connectionId: string | null
|
||||
tabId?: string
|
||||
worktreeId?: string
|
||||
observedAt: number
|
||||
}>
|
||||
|
||||
export type AgentHookAuthorityAttestation = Readonly<{
|
||||
paneKey: string
|
||||
source: 'current_hook' | 'hydrated_commitment'
|
||||
}>
|
||||
|
||||
type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void
|
||||
type ProviderSessionChangeListener = (providerSessions: AgentHookProviderSessionIdentity[]) => void
|
||||
type PaneStatusClearListener = (clear: AgentStatusClearIpcPayload) => void
|
||||
@@ -128,7 +158,8 @@ export const PANE_KEY_ALIASES_MAX = 1024
|
||||
|
||||
type LastStatusFile = {
|
||||
version: number
|
||||
entries: Record<string, EnrichedAgentHookEventPayload>
|
||||
entries: Record<string, PersistedAgentHookEventPayload>
|
||||
authorityCommitments?: Record<string, PersistedAgentHookAuthorityCommitment>
|
||||
}
|
||||
|
||||
type AgentPromptSentDedupeEntry = {
|
||||
@@ -251,7 +282,6 @@ function sanitizeHydratedEntry(
|
||||
}
|
||||
return {
|
||||
paneKey,
|
||||
launchToken: typeof record.launchToken === 'string' ? record.launchToken : undefined,
|
||||
tabId: typeof tabId === 'string' ? tabId : undefined,
|
||||
worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined,
|
||||
connectionId,
|
||||
@@ -268,6 +298,63 @@ function sanitizeHydratedEntry(
|
||||
}
|
||||
}
|
||||
|
||||
function readPersistedLaunchTokenHash(rawEntry: unknown): string | null {
|
||||
if (typeof rawEntry !== 'object' || rawEntry === null) {
|
||||
return null
|
||||
}
|
||||
const record = rawEntry as Record<string, unknown>
|
||||
const launchTokenHash =
|
||||
typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : ''
|
||||
if (/^[a-f0-9]{64}$/.test(launchTokenHash)) {
|
||||
return launchTokenHash
|
||||
}
|
||||
const legacyLaunchToken = typeof record.launchToken === 'string' ? record.launchToken.trim() : ''
|
||||
return legacyLaunchToken ? createHash('sha256').update(legacyLaunchToken).digest('hex') : null
|
||||
}
|
||||
|
||||
function sanitizePersistedAuthorityCommitment(
|
||||
paneKey: string,
|
||||
value: unknown
|
||||
): AgentHookAuthorityEvidence | null {
|
||||
if (!isValidPaneKey(paneKey) || typeof value !== 'object' || value === null) {
|
||||
return null
|
||||
}
|
||||
const record = value as Record<string, unknown>
|
||||
const launchTokenHash =
|
||||
typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : ''
|
||||
const connectionId = record.connectionId
|
||||
const observedAt = record.observedAt
|
||||
if (
|
||||
!/^[a-f0-9]{64}$/.test(launchTokenHash) ||
|
||||
(connectionId !== null && typeof connectionId !== 'string') ||
|
||||
typeof observedAt !== 'number' ||
|
||||
!Number.isFinite(observedAt)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return Object.freeze({
|
||||
paneKey,
|
||||
launchTokenHash,
|
||||
connectionId,
|
||||
...(typeof record.tabId === 'string' ? { tabId: record.tabId } : {}),
|
||||
...(typeof record.worktreeId === 'string' ? { worktreeId: record.worktreeId } : {}),
|
||||
observedAt
|
||||
})
|
||||
}
|
||||
|
||||
function authorityCommitmentsMatch(
|
||||
left: AgentHookAuthorityEvidence,
|
||||
right: AgentHookAuthorityEvidence
|
||||
): boolean {
|
||||
return (
|
||||
left.paneKey === right.paneKey &&
|
||||
left.launchTokenHash === right.launchTokenHash &&
|
||||
left.connectionId === right.connectionId &&
|
||||
left.tabId === right.tabId &&
|
||||
left.worktreeId === right.worktreeId
|
||||
)
|
||||
}
|
||||
|
||||
function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentStatusIpcPayload {
|
||||
return {
|
||||
paneKey: entry.paneKey,
|
||||
@@ -479,6 +566,11 @@ export class AgentHookServer {
|
||||
private state: HookListenerState = createHookListenerState()
|
||||
// Why: hydrated rows give UI continuity but aren't evidence of live agent work in this runtime.
|
||||
private runtimeObservedStatusPaneKeys = new Set<string>()
|
||||
private hydratedAuthorityCommitments: readonly AgentHookAuthorityEvidence[] = Object.freeze([])
|
||||
private hydratedLaunchTokenHashByPaneKey = new Map<string, string>()
|
||||
private persistedAuthorityCommitmentsByPaneKey = new Map<string, AgentHookAuthorityEvidence>()
|
||||
private revokedHydratedAuthorityCommitments = new WeakSet<AgentHookAuthorityEvidence>()
|
||||
private currentAuthorityObservations = new Map<string, AgentHookAuthorityEvidence>()
|
||||
private legacyPaneKeyAliases = new Map<string, PaneKeyAliasEntry>()
|
||||
private paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null
|
||||
// Why: on-disk last-status cache path; null without a userDataPath (tests), where persistence is a no-op and only in-memory replay applies.
|
||||
@@ -562,6 +654,53 @@ export class AgentHookServer {
|
||||
return entry ? [toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)] : []
|
||||
}
|
||||
|
||||
getHydratedAuthorityCommitments(): readonly AgentHookAuthorityEvidence[] {
|
||||
return this.hydratedAuthorityCommitments
|
||||
}
|
||||
|
||||
getCurrentAuthorityObservations(): readonly AgentHookAuthorityEvidence[] {
|
||||
return Object.freeze(
|
||||
Array.from(this.currentAuthorityObservations.values(), (entry) => Object.freeze({ ...entry }))
|
||||
)
|
||||
}
|
||||
|
||||
attestCompatibilityAuthority(candidate: {
|
||||
paneKey: string
|
||||
launchTokenHash: string
|
||||
connectionId: string | null
|
||||
terminalProvenance: 'current_runtime' | 'restored'
|
||||
}): AgentHookAuthorityAttestation | null {
|
||||
const paneKey = this.resolvePaneKeyAlias(candidate.paneKey)
|
||||
const matchesCandidate = (entry: AgentHookAuthorityEvidence): boolean =>
|
||||
entry.launchTokenHash === candidate.launchTokenHash &&
|
||||
entry.connectionId === candidate.connectionId
|
||||
const commitments = this.hydratedAuthorityCommitments.filter(
|
||||
(entry) => matchesCandidate(entry) && !this.revokedHydratedAuthorityCommitments.has(entry)
|
||||
)
|
||||
const current = Array.from(this.currentAuthorityObservations.values())
|
||||
const observations = current.filter(matchesCandidate)
|
||||
const paneObservations = current.filter(
|
||||
(entry) => this.resolvePaneKeyAlias(entry.paneKey) === paneKey
|
||||
)
|
||||
const hasUniqueCurrentObservation =
|
||||
observations.length === 1 &&
|
||||
paneObservations.length === 1 &&
|
||||
this.resolvePaneKeyAlias(observations[0]!.paneKey) === paneKey
|
||||
if (candidate.terminalProvenance === 'current_runtime') {
|
||||
return hasUniqueCurrentObservation ? Object.freeze({ paneKey, source: 'current_hook' }) : null
|
||||
}
|
||||
if (commitments.length !== 1 || this.resolvePaneKeyAlias(commitments[0]!.paneKey) !== paneKey) {
|
||||
return null
|
||||
}
|
||||
if (observations.length === 0 && paneObservations.length === 0) {
|
||||
return Object.freeze({ paneKey, source: 'hydrated_commitment' })
|
||||
}
|
||||
if (!hasUniqueCurrentObservation) {
|
||||
return null
|
||||
}
|
||||
return Object.freeze({ paneKey, source: 'current_hook' })
|
||||
}
|
||||
|
||||
inferInterrupt(request: AgentInterruptInferenceRequest): boolean {
|
||||
if (!isValidPaneKey(request.paneKey)) {
|
||||
return false
|
||||
@@ -1301,9 +1440,40 @@ export class AgentHookServer {
|
||||
tabId: owner?.tabId
|
||||
})
|
||||
}
|
||||
const hydratedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(previousOwnerPaneKey)
|
||||
if (hydratedLaunchTokenHash) {
|
||||
this.hydratedLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey)
|
||||
this.hydratedLaunchTokenHashByPaneKey.set(toPaneKey, hydratedLaunchTokenHash)
|
||||
}
|
||||
const persistedAuthority = this.persistedAuthorityCommitmentsByPaneKey.get(previousOwnerPaneKey)
|
||||
if (persistedAuthority) {
|
||||
const owner = parsePaneKey(toPaneKey)
|
||||
this.persistedAuthorityCommitmentsByPaneKey.delete(previousOwnerPaneKey)
|
||||
this.persistedAuthorityCommitmentsByPaneKey.set(
|
||||
toPaneKey,
|
||||
Object.freeze({
|
||||
...persistedAuthority,
|
||||
paneKey: toPaneKey,
|
||||
...(owner?.tabId ? { tabId: owner.tabId } : {})
|
||||
})
|
||||
)
|
||||
}
|
||||
if (this.runtimeObservedStatusPaneKeys.delete(previousOwnerPaneKey)) {
|
||||
this.runtimeObservedStatusPaneKeys.add(toPaneKey)
|
||||
}
|
||||
const authorityObservation = this.currentAuthorityObservations.get(previousOwnerPaneKey)
|
||||
if (authorityObservation) {
|
||||
const owner = parsePaneKey(toPaneKey)
|
||||
this.currentAuthorityObservations.delete(previousOwnerPaneKey)
|
||||
this.currentAuthorityObservations.set(
|
||||
toPaneKey,
|
||||
Object.freeze({
|
||||
...authorityObservation,
|
||||
paneKey: toPaneKey,
|
||||
tabId: owner?.tabId
|
||||
})
|
||||
)
|
||||
}
|
||||
const promptDedupe = this.promptSentDedupeByPaneKey.get(previousOwnerPaneKey)
|
||||
if (promptDedupe !== undefined) {
|
||||
this.promptSentDedupeByPaneKey.delete(previousOwnerPaneKey)
|
||||
@@ -1321,7 +1491,7 @@ export class AgentHookServer {
|
||||
this.boundPaneKeyAliases()
|
||||
this.closedAgentStatusPaneKeys.delete(toPaneKey)
|
||||
this.notifyPaneKeyAliasPersistenceListener()
|
||||
if (hadStatus) {
|
||||
if (hadStatus || persistedAuthority) {
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
}
|
||||
@@ -1339,6 +1509,7 @@ export class AgentHookServer {
|
||||
aliasChanged = true
|
||||
}
|
||||
}
|
||||
const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys)
|
||||
const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key))
|
||||
for (const key of paneKeys) {
|
||||
this.markPaneClosedForAgentStatus(key)
|
||||
@@ -1346,12 +1517,13 @@ export class AgentHookServer {
|
||||
this.clearCodexSubagentPoll(key)
|
||||
clearPaneCacheState(this.state, key)
|
||||
this.runtimeObservedStatusPaneKeys.delete(key)
|
||||
this.currentAuthorityObservations.delete(key)
|
||||
this.promptSentDedupeByPaneKey.delete(key)
|
||||
}
|
||||
if (aliasChanged) {
|
||||
this.notifyPaneKeyAliasPersistenceListener()
|
||||
}
|
||||
if (hadStatus) {
|
||||
if (hadStatus || authorityChanged) {
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
}
|
||||
@@ -1366,11 +1538,19 @@ export class AgentHookServer {
|
||||
const clearedStatusPaneKeys = new Set<string>()
|
||||
for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) {
|
||||
if (entry.ptyId === ptyId) {
|
||||
this.legacyPaneKeyAliases.delete(legacyPaneKey)
|
||||
clearPaneCacheState(this.state, legacyPaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
|
||||
const shouldClearStablePaneKey =
|
||||
options?.shouldClearStablePaneKey?.(entry.stablePaneKey) ?? true
|
||||
const revokedPaneKeys = new Set([legacyPaneKey])
|
||||
if (shouldClearStablePaneKey) {
|
||||
revokedPaneKeys.add(entry.stablePaneKey)
|
||||
}
|
||||
if (this.revokeHydratedAuthorityForPaneKeys(revokedPaneKeys)) {
|
||||
statusChanged = true
|
||||
}
|
||||
this.legacyPaneKeyAliases.delete(legacyPaneKey)
|
||||
clearPaneCacheState(this.state, legacyPaneKey)
|
||||
this.currentAuthorityObservations.delete(legacyPaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
|
||||
if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) {
|
||||
statusChanged = true
|
||||
clearedStatusPaneKeys.add(entry.stablePaneKey)
|
||||
@@ -1379,6 +1559,7 @@ export class AgentHookServer {
|
||||
// Why: hydrated rows live under the stable key; if this PTY dies before ptyPaneKey rebuilds, alias cleanup is the only evictor.
|
||||
clearPaneCacheState(this.state, entry.stablePaneKey)
|
||||
this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey)
|
||||
this.currentAuthorityObservations.delete(entry.stablePaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(entry.stablePaneKey)
|
||||
}
|
||||
aliasChanged = true
|
||||
@@ -1400,6 +1581,27 @@ export class AgentHookServer {
|
||||
return this.legacyPaneKeyAliases.get(paneKey)?.stablePaneKey ?? paneKey
|
||||
}
|
||||
|
||||
private revokeHydratedAuthorityForPaneKeys(paneKeys: ReadonlySet<string>): boolean {
|
||||
let changed = false
|
||||
for (const commitment of this.hydratedAuthorityCommitments) {
|
||||
if (
|
||||
paneKeys.has(commitment.paneKey) ||
|
||||
paneKeys.has(this.resolvePaneKeyAlias(commitment.paneKey))
|
||||
) {
|
||||
this.revokedHydratedAuthorityCommitments.add(commitment)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
for (const paneKey of paneKeys) {
|
||||
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
changed = this.hydratedLaunchTokenHashByPaneKey.delete(paneKey) || changed
|
||||
changed = this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) || changed
|
||||
changed = this.persistedAuthorityCommitmentsByPaneKey.delete(paneKey) || changed
|
||||
changed = this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) || changed
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
private normalizeHookBodyPaneKeyAlias(body: unknown): unknown {
|
||||
if (typeof body !== 'object' || body === null) {
|
||||
return body
|
||||
@@ -1602,6 +1804,7 @@ export class AgentHookServer {
|
||||
isReplay: envelope.isReplay === true ? true : undefined,
|
||||
payload: normalizedPayload
|
||||
}
|
||||
this.recordCurrentAuthorityObservation(event)
|
||||
this.applyNormalizedStatus(event)
|
||||
}
|
||||
|
||||
@@ -1632,6 +1835,7 @@ export class AgentHookServer {
|
||||
if (this.lastStatusFilePath) {
|
||||
this.hydrateLastStatusFromDisk()
|
||||
}
|
||||
this.captureHydratedAuthorityCommitments()
|
||||
const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(404)
|
||||
@@ -1673,6 +1877,7 @@ export class AgentHookServer {
|
||||
const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body)
|
||||
const normalized = normalizeHookPayload(this.state, source, aliasedBody, this.env)
|
||||
if (normalized && !this.shouldSuppressClosedTabStatus(normalized.paneKey)) {
|
||||
this.recordCurrentAuthorityObservation(normalized)
|
||||
const enriched = this.applyNormalizedStatus(normalized)
|
||||
this.scheduleAssistantMessageRetry(source, aliasedBody, enriched)
|
||||
this.scheduleCodexSubagentPoll(source, aliasedBody, enriched)
|
||||
@@ -1739,6 +1944,11 @@ export class AgentHookServer {
|
||||
this.lastStatusFilePath = null
|
||||
this.lastWrittenJson = null
|
||||
this.runtimeObservedStatusPaneKeys.clear()
|
||||
this.hydratedAuthorityCommitments = Object.freeze([])
|
||||
this.hydratedLaunchTokenHashByPaneKey.clear()
|
||||
this.persistedAuthorityCommitmentsByPaneKey.clear()
|
||||
this.revokedHydratedAuthorityCommitments = new WeakSet()
|
||||
this.currentAuthorityObservations.clear()
|
||||
this.promptSentDedupeByPaneKey.clear()
|
||||
this.closedAgentStatusTabIds.clear()
|
||||
this.closedAgentStatusPaneKeys.clear()
|
||||
@@ -1750,7 +1960,7 @@ export class AgentHookServer {
|
||||
|
||||
/** Drop only the status row (user dismissal); do NOT wipe prompt/tool caches since the pane's agent may still be alive. Use clearPaneState for PTY-teardown. */
|
||||
dropStatusEntry(paneKey: string): void {
|
||||
if (!this.deleteStatusEntry(paneKey)) {
|
||||
if (!this.deleteStatusEntry(paneKey, { preserveAuthority: true })) {
|
||||
return
|
||||
}
|
||||
this.scheduleStatusPersist()
|
||||
@@ -1775,7 +1985,7 @@ export class AgentHookServer {
|
||||
if (entry.connectionId !== normalizedConnectionId) {
|
||||
continue
|
||||
}
|
||||
const deleted = this.deleteStatusEntry(paneKey)
|
||||
const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true })
|
||||
if (deleted) {
|
||||
statusChanged = true
|
||||
if (deleted.payload.agentType === 'codex') {
|
||||
@@ -1785,6 +1995,11 @@ export class AgentHookServer {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [paneKey, evidence] of this.currentAuthorityObservations) {
|
||||
if (evidence.connectionId === normalizedConnectionId) {
|
||||
this.currentAuthorityObservations.delete(paneKey)
|
||||
}
|
||||
}
|
||||
if (statusChanged) {
|
||||
// Why: persist/notify once — one disconnect can own many panes.
|
||||
this.scheduleStatusPersist()
|
||||
@@ -1798,7 +2013,10 @@ export class AgentHookServer {
|
||||
})
|
||||
}
|
||||
|
||||
private deleteStatusEntry(paneKey: string): EnrichedAgentHookEventPayload | null {
|
||||
private deleteStatusEntry(
|
||||
paneKey: string,
|
||||
options?: { preserveAuthority?: boolean }
|
||||
): EnrichedAgentHookEventPayload | null {
|
||||
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as
|
||||
| EnrichedAgentHookEventPayload
|
||||
@@ -1807,9 +2025,14 @@ export class AgentHookServer {
|
||||
return null
|
||||
}
|
||||
this.state.lastStatusByPaneKey.delete(resolvedPaneKey)
|
||||
if (!options?.preserveAuthority) {
|
||||
this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey)
|
||||
this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey)
|
||||
}
|
||||
this.clearAssistantMessageRetry(resolvedPaneKey)
|
||||
this.clearCodexSubagentPoll(resolvedPaneKey)
|
||||
this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey)
|
||||
this.currentAuthorityObservations.delete(resolvedPaneKey)
|
||||
if (existing.payload.state === 'done') {
|
||||
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
|
||||
}
|
||||
@@ -1854,6 +2077,11 @@ export class AgentHookServer {
|
||||
paneKeysToClear.add(paneKey)
|
||||
}
|
||||
}
|
||||
for (const commitment of this.hydratedAuthorityCommitments) {
|
||||
if (paneCacheKeyMatchesTab(commitment.paneKey, tabId)) {
|
||||
paneKeysToClear.add(commitment.paneKey)
|
||||
}
|
||||
}
|
||||
|
||||
let aliasChanged = false
|
||||
for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) {
|
||||
@@ -1867,6 +2095,7 @@ export class AgentHookServer {
|
||||
aliasChanged = true
|
||||
}
|
||||
}
|
||||
const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeysToClear)
|
||||
|
||||
let statusChanged = false
|
||||
for (const paneKey of paneKeysToClear) {
|
||||
@@ -1877,12 +2106,13 @@ export class AgentHookServer {
|
||||
this.clearCodexSubagentPoll(paneKey)
|
||||
clearPaneCacheState(this.state, paneKey)
|
||||
this.runtimeObservedStatusPaneKeys.delete(paneKey)
|
||||
this.currentAuthorityObservations.delete(paneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(paneKey)
|
||||
}
|
||||
if (aliasChanged) {
|
||||
this.notifyPaneKeyAliasPersistenceListener()
|
||||
}
|
||||
if (statusChanged) {
|
||||
if (statusChanged || authorityChanged) {
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
}
|
||||
@@ -1890,25 +2120,31 @@ export class AgentHookServer {
|
||||
|
||||
clearPaneState(paneKey: string): void {
|
||||
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
const paneKeys = new Set([paneKey, resolvedPaneKey])
|
||||
// Why: only persist when a status entry was actually evicted; dropping prompt/tool caches doesn't change the file.
|
||||
const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey)
|
||||
this.clearAssistantMessageRetry(resolvedPaneKey)
|
||||
this.clearCodexSubagentPoll(resolvedPaneKey)
|
||||
clearPaneCacheState(this.state, resolvedPaneKey)
|
||||
this.currentAuthorityObservations.delete(resolvedPaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
|
||||
let clearedAlias = false
|
||||
for (const [legacyPaneKey, stablePaneKey] of this.legacyPaneKeyAliases) {
|
||||
if (stablePaneKey.stablePaneKey === resolvedPaneKey) {
|
||||
this.legacyPaneKeyAliases.delete(legacyPaneKey)
|
||||
paneKeys.add(legacyPaneKey)
|
||||
paneKeys.add(stablePaneKey.stablePaneKey)
|
||||
clearPaneCacheState(this.state, legacyPaneKey)
|
||||
this.currentAuthorityObservations.delete(legacyPaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
|
||||
clearedAlias = true
|
||||
}
|
||||
}
|
||||
const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys)
|
||||
if (clearedAlias) {
|
||||
this.notifyPaneKeyAliasPersistenceListener()
|
||||
}
|
||||
if (hadStatus) {
|
||||
if (hadStatus || authorityChanged) {
|
||||
this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey)
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
@@ -1963,6 +2199,8 @@ export class AgentHookServer {
|
||||
}
|
||||
// Why: keep hydrate idempotent so a future re-start path can't merge prior-session state.
|
||||
this.state.lastStatusByPaneKey.clear()
|
||||
this.hydratedLaunchTokenHashByPaneKey.clear()
|
||||
this.persistedAuthorityCommitmentsByPaneKey.clear()
|
||||
let raw: string
|
||||
try {
|
||||
raw = readFileSync(this.lastStatusFilePath, 'utf8')
|
||||
@@ -2001,6 +2239,7 @@ export class AgentHookServer {
|
||||
let hydrated = 0
|
||||
let dropped = 0
|
||||
let prunedLegacyClaudeSubagents = 0
|
||||
let scrubbedLegacyLaunchTokens = 0
|
||||
// Why: drop entries older than HYDRATE_MAX_AGE_MS to bound disk growth (one Date.now() for a consistent cutoff).
|
||||
const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS
|
||||
for (const [paneKey, rawEntry] of Object.entries(entries)) {
|
||||
@@ -2011,6 +2250,21 @@ export class AgentHookServer {
|
||||
: { ...(rawEntry as Record<string, unknown>), paneKey: resolvedPaneKey }
|
||||
const entry = sanitizeHydratedEntry(resolvedPaneKey, rawResolvedEntry)
|
||||
if (entry && entry.receivedAt >= ttlCutoff) {
|
||||
const launchTokenHash = readPersistedLaunchTokenHash(rawResolvedEntry)
|
||||
if (launchTokenHash) {
|
||||
this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, launchTokenHash)
|
||||
const evidence = this.toAuthorityEvidence(entry, launchTokenHash)
|
||||
if (evidence) {
|
||||
this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, evidence)
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof rawResolvedEntry === 'object' &&
|
||||
rawResolvedEntry !== null &&
|
||||
typeof (rawResolvedEntry as Record<string, unknown>).launchToken === 'string'
|
||||
) {
|
||||
scrubbedLegacyLaunchTokens += 1
|
||||
}
|
||||
const hydratedPayload = dropHydratedIdleClaudeSubagents(entry.payload)
|
||||
if (hydratedPayload !== entry.payload) {
|
||||
prunedLegacyClaudeSubagents +=
|
||||
@@ -2041,13 +2295,30 @@ export class AgentHookServer {
|
||||
dropped += 1
|
||||
}
|
||||
}
|
||||
for (const [paneKey, rawCommitment] of Object.entries(file.authorityCommitments ?? {})) {
|
||||
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
const commitment = sanitizePersistedAuthorityCommitment(resolvedPaneKey, rawCommitment)
|
||||
if (!commitment || commitment.observedAt < ttlCutoff) {
|
||||
dropped += 1
|
||||
continue
|
||||
}
|
||||
const existing = this.persistedAuthorityCommitmentsByPaneKey.get(resolvedPaneKey)
|
||||
if (existing && !authorityCommitmentsMatch(existing, commitment)) {
|
||||
this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey)
|
||||
this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey)
|
||||
dropped += 1
|
||||
continue
|
||||
}
|
||||
this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, commitment)
|
||||
this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, commitment.launchTokenHash)
|
||||
}
|
||||
if (dropped > 0) {
|
||||
console.warn(
|
||||
`[agent-hooks] last-status hydrate dropped ${dropped} entries (kept ${hydrated})`
|
||||
)
|
||||
}
|
||||
if (dropped > 0 || prunedLegacyClaudeSubagents > 0) {
|
||||
// Why: persist load-time pruning once so legacy idle rows aren't re-parsed every launch.
|
||||
if (dropped > 0 || prunedLegacyClaudeSubagents > 0 || scrubbedLegacyLaunchTokens > 0) {
|
||||
// Why: persist load-time pruning and bearer scrubbing once.
|
||||
this.runStatusPersist()
|
||||
} else if (hydrated > 0) {
|
||||
// Why: prime dedup from raw bytes (not re-serialized) only when hydration was lossless.
|
||||
@@ -2055,17 +2326,92 @@ export class AgentHookServer {
|
||||
}
|
||||
}
|
||||
|
||||
private captureHydratedAuthorityCommitments(): void {
|
||||
this.revokedHydratedAuthorityCommitments = new WeakSet()
|
||||
for (const entry of this.state.lastStatusByPaneKey.values()) {
|
||||
const evidence = this.toAuthorityEvidence(
|
||||
entry as EnrichedAgentHookEventPayload,
|
||||
this.hydratedLaunchTokenHashByPaneKey.get(entry.paneKey)
|
||||
)
|
||||
if (evidence && !this.persistedAuthorityCommitmentsByPaneKey.has(entry.paneKey)) {
|
||||
this.persistedAuthorityCommitmentsByPaneKey.set(entry.paneKey, evidence)
|
||||
}
|
||||
}
|
||||
this.hydratedAuthorityCommitments = Object.freeze(
|
||||
Array.from(this.persistedAuthorityCommitmentsByPaneKey.values())
|
||||
)
|
||||
}
|
||||
|
||||
private recordCurrentAuthorityObservation(payload: AgentHookEventPayload): void {
|
||||
const evidence = this.toAuthorityEvidence(payload)
|
||||
if (evidence) {
|
||||
this.currentAuthorityObservations.set(evidence.paneKey, evidence)
|
||||
this.persistedAuthorityCommitmentsByPaneKey.set(evidence.paneKey, evidence)
|
||||
this.hydratedLaunchTokenHashByPaneKey.set(evidence.paneKey, evidence.launchTokenHash)
|
||||
}
|
||||
}
|
||||
|
||||
private toAuthorityEvidence(
|
||||
payload: AgentHookEventPayload | EnrichedAgentHookEventPayload,
|
||||
launchTokenHashOverride?: string
|
||||
): AgentHookAuthorityEvidence | null {
|
||||
const launchToken = payload.launchToken?.trim()
|
||||
const launchTokenHash =
|
||||
launchTokenHashOverride ??
|
||||
(launchToken ? createHash('sha256').update(launchToken).digest('hex') : null)
|
||||
if (!launchTokenHash) {
|
||||
return null
|
||||
}
|
||||
return Object.freeze({
|
||||
paneKey: payload.paneKey,
|
||||
launchTokenHash,
|
||||
connectionId: payload.connectionId,
|
||||
...(payload.tabId ? { tabId: payload.tabId } : {}),
|
||||
...(payload.worktreeId ? { worktreeId: payload.worktreeId } : {}),
|
||||
observedAt: 'receivedAt' in payload ? payload.receivedAt : Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
private serializeStatusFile(): string {
|
||||
const entries: Record<string, EnrichedAgentHookEventPayload> = {}
|
||||
const entries: Record<string, PersistedAgentHookEventPayload> = {}
|
||||
const authorityCommitments: Record<string, PersistedAgentHookAuthorityCommitment> = {}
|
||||
const conflictedCommitments = new Set<string>()
|
||||
for (const [paneKey, commitment] of this.persistedAuthorityCommitmentsByPaneKey) {
|
||||
authorityCommitments[paneKey] = { ...commitment }
|
||||
}
|
||||
for (const [paneKey, payload] of this.state.lastStatusByPaneKey) {
|
||||
// Why: never persist invalid keys (matches the hydrate-path invariant).
|
||||
if (!isValidPaneKey(paneKey)) {
|
||||
continue
|
||||
}
|
||||
const { promptInteractionKey: _promptInteractionKey, ...persistedPayload } = payload
|
||||
entries[paneKey] = persistedPayload as EnrichedAgentHookEventPayload
|
||||
const {
|
||||
promptInteractionKey: _promptInteractionKey,
|
||||
launchToken,
|
||||
...persistedPayload
|
||||
} = payload as EnrichedAgentHookEventPayload
|
||||
const launchTokenHash = launchToken?.trim()
|
||||
? createHash('sha256').update(launchToken.trim()).digest('hex')
|
||||
: this.hydratedLaunchTokenHashByPaneKey.get(paneKey)
|
||||
entries[paneKey] = {
|
||||
...persistedPayload,
|
||||
...(launchTokenHash ? { launchTokenHash } : {})
|
||||
}
|
||||
const commitment = this.toAuthorityEvidence(payload, launchTokenHash)
|
||||
if (commitment && !conflictedCommitments.has(paneKey)) {
|
||||
const existing = authorityCommitments[paneKey]
|
||||
if (existing && !authorityCommitmentsMatch(existing, commitment)) {
|
||||
delete authorityCommitments[paneKey]
|
||||
conflictedCommitments.add(paneKey)
|
||||
} else {
|
||||
authorityCommitments[paneKey] = { ...commitment }
|
||||
}
|
||||
}
|
||||
}
|
||||
const file: LastStatusFile = {
|
||||
version: LAST_STATUS_FILE_VERSION,
|
||||
entries,
|
||||
authorityCommitments
|
||||
}
|
||||
const file: LastStatusFile = { version: LAST_STATUS_FILE_VERSION, entries }
|
||||
return JSON.stringify(file)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,4 +11,41 @@ describe('packaged Windows CLI launcher asset', () => {
|
||||
expect(launcher).toContain('orca.cmd cannot safely forward orchestration message bodies')
|
||||
expect(launcher).not.toContain('"%ELECTRON%" "%CLI%" %*')
|
||||
})
|
||||
|
||||
it('marks the packaged child and propagates its exact exit status', () => {
|
||||
const sourcePath = join(process.cwd(), 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
|
||||
const source = readFileSync(sourcePath, 'utf8')
|
||||
|
||||
expect(source).toContain(
|
||||
'startInfo.EnvironmentVariables["ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER"] = "1";'
|
||||
)
|
||||
expect(source).toContain('Environment.GetEnvironmentVariable("ORCA_CLI_COMMAND") == "orca-ide"')
|
||||
expect(source).toContain('? "orca-ide"')
|
||||
expect(source).toContain(': "orca";')
|
||||
expect(source).toContain('child.WaitForExit();')
|
||||
expect(source).toContain('return child.ExitCode;')
|
||||
})
|
||||
|
||||
it('requires exact updater process and executable release proof', () => {
|
||||
const includePath = join(process.cwd(), 'config', 'nsis', 'daemon-host-uninstall.nsh')
|
||||
const source = readFileSync(includePath, 'utf8')
|
||||
|
||||
expect(source).toContain('!macro customCheckAppRunning')
|
||||
expect(source).toContain('!include "getProcessInfo.nsh"')
|
||||
expect(source).toContain('Var pid')
|
||||
expect(source).toContain('[String]::Equals')
|
||||
expect(source).toContain('"$INSTDIR\\Orca.exe" "$INSTDIR\\resources\\bin\\orca.exe"')
|
||||
expect(source).toContain('CreationDate=[string]$$parent.CreationDate')
|
||||
expect(source).toContain("'ProcessId=' + $$proof.ProcessId")
|
||||
expect(source).toContain('[IO.FileShare]::None')
|
||||
expect(source).toContain('$$released=$$false')
|
||||
expect(source).toContain('$$released=$$true')
|
||||
expect(source).not.toMatch(/(?<!\$)(?:\$\$)*\$(?:false|true)\b/)
|
||||
expect(source).toContain(
|
||||
' ${else}\n !insertmacro IS_POWERSHELL_AVAILABLE\n !insertmacro _CHECK_APP_RUNNING\n ${endIf}'
|
||||
)
|
||||
expect(source).toContain('SetErrorLevel 2')
|
||||
expect(source).toContain('Quit')
|
||||
expect(source).not.toContain('taskkill /IM Orca.exe')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -299,6 +299,16 @@ export class HeadlessEmulator {
|
||||
return lines
|
||||
}
|
||||
|
||||
getBufferTailLines(limit: number): string[] {
|
||||
const buffer = this.terminal.buffer.active
|
||||
const start = Math.max(0, buffer.length - Math.max(0, Math.floor(limit)))
|
||||
const lines: string[] = []
|
||||
for (let row = start; row < buffer.length; row += 1) {
|
||||
lines.push(buffer.getLine(row)?.translateToString(true) ?? '')
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
getCwd(): string | null {
|
||||
return this.oscText.cwd
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as WslModule from '../wsl'
|
||||
|
||||
vi.mock('../wsl', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WslModule>()),
|
||||
getDefaultWslDistro: () => 'Ubuntu'
|
||||
}))
|
||||
|
||||
import { TerminalHost } from './terminal-host'
|
||||
import type { SubprocessHandle } from './session'
|
||||
import { resolveWslSessionContext } from './wsl-session-context'
|
||||
@@ -60,6 +67,40 @@ describe('TerminalHost WSL context', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('persists the resolved default distro across daemon attaches', async () => {
|
||||
const spawnSubprocess = vi.fn(() => createSubprocess())
|
||||
host = new TerminalHost({ spawnSubprocess })
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
try {
|
||||
const created = await host.createOrAttach({
|
||||
sessionId: 'session-default-wsl',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: 'C:\\Users\\jin\\repo',
|
||||
shellOverride: 'wsl.exe',
|
||||
terminalWindowsWslDistro: null,
|
||||
streamClient: { onData: vi.fn(), onExit: vi.fn() }
|
||||
})
|
||||
const attached = await host.createOrAttach({
|
||||
sessionId: 'session-default-wsl',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shellOverride: 'wsl.exe',
|
||||
terminalWindowsWslDistro: 'Debian',
|
||||
streamClient: { onData: vi.fn(), onExit: vi.fn() }
|
||||
})
|
||||
|
||||
expect(created.wslDistro).toBe('Ubuntu')
|
||||
expect(attached.wslDistro).toBe('Ubuntu')
|
||||
expect(spawnSubprocess).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('returns authoritative null when a native session is attached with a WSL preference', async () => {
|
||||
const spawnSubprocess = vi.fn(() => createSubprocess())
|
||||
host = new TerminalHost({ spawnSubprocess })
|
||||
@@ -118,6 +159,13 @@ describe('TerminalHost WSL context', () => {
|
||||
terminalWindowsWslDistro: ' Ubuntu '
|
||||
})
|
||||
).toEqual({ distro: 'Ubuntu', treatPosixCwdAsWsl: true })
|
||||
expect(
|
||||
resolveWslSessionContext({
|
||||
cwd: 'C:\\Users\\jin\\repo',
|
||||
shellOverride: 'wsl.exe',
|
||||
terminalWindowsWslDistro: null
|
||||
})
|
||||
).toEqual({ distro: 'Ubuntu', treatPosixCwdAsWsl: true })
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
|
||||
import { isWslShellName } from '../../shared/local-windows-terminal-runtime'
|
||||
import { parseWslPath } from '../wsl'
|
||||
import { getDefaultWslDistro, parseWslPath } from '../wsl'
|
||||
import { parsePtySessionId } from './pty-session-id'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
|
||||
@@ -41,7 +41,8 @@ export function resolveWslSessionContext(args: {
|
||||
return (
|
||||
(args.sessionId ? getWslContextFromSessionId(args.sessionId) : undefined) ??
|
||||
(isWslShellName(args.shellOverride)
|
||||
? getWslContextFromPreferredDistro(args.terminalWindowsWslDistro)
|
||||
? (getWslContextFromPreferredDistro(args.terminalWindowsWslDistro) ??
|
||||
getWslContextFromPreferredDistro(getDefaultWslDistro()))
|
||||
: undefined)
|
||||
)
|
||||
}
|
||||
|
||||
+30
-2
@@ -30,7 +30,12 @@ import {
|
||||
getSshPtyProvider,
|
||||
registerHeadlessPtyRuntime
|
||||
} from './ipc/pty'
|
||||
import { initDaemonPtyProvider, disconnectDaemon, shutdownDaemon } from './daemon/daemon-init'
|
||||
import {
|
||||
initDaemonPtyProvider,
|
||||
disconnectDaemon,
|
||||
getDaemonProvider,
|
||||
shutdownDaemon
|
||||
} from './daemon/daemon-init'
|
||||
import { closeAllWatchers } from './ipc/filesystem-watcher'
|
||||
import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher'
|
||||
import { registerCoreHandlers } from './ipc/register-core-handlers'
|
||||
@@ -132,6 +137,7 @@ import {
|
||||
import { maybeRedirectAppImageCliLaunch } from './startup/appimage-cli-redirect'
|
||||
import { maybeRedirectPackagedCliEntryLaunch } from './startup/packaged-cli-entry-redirect'
|
||||
import { startFirstWindowStartupServices } from './startup/first-window-startup-services'
|
||||
import { recoverLegacyWorkerTerminalsForRendererStartup } from './startup/legacy-worker-renderer-recovery'
|
||||
import { createWslCliReconciliationStartupBarrier } from './startup/wsl-cli-reconciliation-startup-barrier'
|
||||
import { getDevInstanceIdentity } from './startup/dev-instance-identity'
|
||||
import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path'
|
||||
@@ -724,10 +730,24 @@ if (hasSingleInstanceLock) {
|
||||
}
|
||||
|
||||
ipcMain.handle('app:awaitFirstWindowStartupServices', async () => {
|
||||
// Why: restored WSL terminals get a bounded chance to receive launcher repairs before window rendering proceeds.
|
||||
await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady])
|
||||
})
|
||||
|
||||
ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup', () =>
|
||||
recoverLegacyWorkerTerminalsForRendererStartup({
|
||||
firstWindowStartupServicesReady,
|
||||
managedWslCliStartupBarrierReady,
|
||||
localPtyProviderStartupReady,
|
||||
reconcile: async () => {
|
||||
await runtime?.refreshRestoredOrchestrationAuthority()
|
||||
return runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true })
|
||||
},
|
||||
onDeferredRecoveryError: (error) => {
|
||||
console.warn('[orchestration] legacy worker provider-ready recovery failed', error)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Why: the renderer pulls this once its ui:openSettings listener attaches, so a Settings request queued before mount isn't lost.
|
||||
ipcMain.handle('ui:consumePendingOpenSettings', (event) =>
|
||||
pendingOpenSettings.matches(event.sender.id, { consume: true })
|
||||
@@ -2207,6 +2227,11 @@ void app.whenReady().then(async () => {
|
||||
getAgentProviderSessionSnapshot: () => agentHookServer.getStatusSnapshot(),
|
||||
getAgentProviderSessionRowsForPane: (paneKey) =>
|
||||
agentHookServer.getStatusSnapshotForPane(paneKey),
|
||||
attestAgentHookCompatibilityAuthority: (candidate) =>
|
||||
agentHookServer.attestCompatibilityAuthority(candidate),
|
||||
retireAgentHookCompatibilityAuthority: (paneKey) =>
|
||||
agentHookServer.retirePaneAuthority(paneKey),
|
||||
canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null,
|
||||
// Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only.
|
||||
getAdditionalAiVaultCodexHomePaths: () =>
|
||||
codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [],
|
||||
@@ -2222,6 +2247,7 @@ void app.whenReady().then(async () => {
|
||||
orchestrationEnvironmentTransport
|
||||
})
|
||||
runtime = runtimeService
|
||||
runtimeService.prepareLegacyWorkerTerminalRecovery()
|
||||
publishProviderSessionChanges(agentHookServer.getProviderSessionIdentities())
|
||||
browserManager.setBrowserGuestStateChangedListener((worktreeId) => {
|
||||
runtimeService.notifyMobileSessionTabsChanged(worktreeId)
|
||||
@@ -2664,6 +2690,8 @@ void app.whenReady().then(async () => {
|
||||
store,
|
||||
prepareCodexSessionResumeForLaunch
|
||||
)
|
||||
await runtime.refreshRestoredOrchestrationAuthority()
|
||||
await runtime.reconcileLegacyWorkerTerminals()
|
||||
// Why: headless servers can't mount <webview> panes; use offscreen WebContents, gated on a real display so browser.headless.v1 stays honest.
|
||||
if (headlessBrowserDisplayAvailable) {
|
||||
runtime.setOffscreenBrowserBackend(new OffscreenBrowserBackend(browserManager))
|
||||
|
||||
@@ -3599,6 +3599,64 @@ describe('registerPtyHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves default WSL authority before daemon host env and spawn metadata', async () => {
|
||||
await withWin32Platform(async () => {
|
||||
_setWslCachesForTests({ available: true, distros: ['Ubuntu'] })
|
||||
const daemonSpawn = setupDaemonAdapter()
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
registerPty: vi.fn(),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn(),
|
||||
preparePtyExecutionContext: vi.fn().mockReturnValue(true),
|
||||
getOrchestrationCompatibilityHostId: vi.fn(() => 'compat-host')
|
||||
}
|
||||
const settings = {
|
||||
terminalWindowsShell: 'wsl.exe',
|
||||
terminalWindowsWslDistro: null,
|
||||
terminalWindowsPowerShellImplementation: 'auto'
|
||||
}
|
||||
handlers.clear()
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
runtime as never,
|
||||
undefined,
|
||||
(() => settings) as never
|
||||
)
|
||||
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
|
||||
spawn(args: {
|
||||
cols: number
|
||||
rows: number
|
||||
cwd?: string
|
||||
worktreeId?: string
|
||||
env?: Record<string, string>
|
||||
}): Promise<{ id: string }>
|
||||
}
|
||||
|
||||
await controller.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: 'C:\\repo',
|
||||
worktreeId: 'repo-1::C:\\repo',
|
||||
env: {}
|
||||
})
|
||||
|
||||
const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall
|
||||
expect(spawnOptions.terminalWindowsWslDistro).toBe('Ubuntu')
|
||||
expect(spawnOptions.env).toMatchObject({
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'compat-host',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu'
|
||||
})
|
||||
expect(runtime.preparePtyExecutionContext).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'Ubuntu',
|
||||
expect.objectContaining({ resetIncarnation: true })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('distinguishes an attached native context from an older daemon fallback', async () => {
|
||||
await withWin32Platform(async () => {
|
||||
_setWslCachesForTests({ available: true, distros: ['Ubuntu'] })
|
||||
@@ -5374,6 +5432,37 @@ describe('registerPtyHandlers', () => {
|
||||
expect(listProcesses).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('scopes runtime inventories to the requested provider', async () => {
|
||||
const localList = vi
|
||||
.spyOn(getLocalPtyProvider(), 'listProcesses')
|
||||
.mockResolvedValue([{ id: 'local-pty', title: 'Local', cwd: '/local' }])
|
||||
const sshAList = vi.fn(async () => [{ id: 'ssh-a-pty' }])
|
||||
const sshBList = vi.fn(async () => {
|
||||
throw new Error('ssh-b unavailable')
|
||||
})
|
||||
registerSshPtyProvider('ssh-a', { listProcesses: sshAList } as never)
|
||||
registerSshPtyProvider('ssh-b', { listProcesses: sshBList } as never)
|
||||
const runtime = { setPtyController: vi.fn() }
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
|
||||
listProcesses(connectionId?: string | null): Promise<{ id: string }[]>
|
||||
}
|
||||
|
||||
await expect(controller.listProcesses(null)).resolves.toEqual([
|
||||
{ id: 'local-pty', title: 'Local', cwd: '/local' }
|
||||
])
|
||||
expect(localList).toHaveBeenCalledOnce()
|
||||
expect(sshAList).not.toHaveBeenCalled()
|
||||
expect(sshBList).not.toHaveBeenCalled()
|
||||
|
||||
await expect(controller.listProcesses('ssh-a')).resolves.toEqual([{ id: 'ssh-a-pty' }])
|
||||
expect(sshAList).toHaveBeenCalledOnce()
|
||||
expect(sshBList).not.toHaveBeenCalled()
|
||||
|
||||
await expect(controller.listProcesses()).rejects.toThrow('ssh-b unavailable')
|
||||
})
|
||||
|
||||
it('returns unavailable runtime confirmation for unsupported or missing providers', async () => {
|
||||
registerSshPtyProvider('ssh-1', {} as never)
|
||||
setPtyOwnership('unsupported-pty', 'ssh-1')
|
||||
|
||||
+72
-54
@@ -140,7 +140,7 @@ import {
|
||||
} from '../agent-hooks/migration-unsupported-pty-state'
|
||||
import { parseWslPath } from '../wsl'
|
||||
import { mergePersistedWindowsPath } from '../pty/windows-environment-path'
|
||||
import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env'
|
||||
import { addOrcaWslInteropEnv, stampWslOrchestrationCompatibilityHost } from '../pty/wsl-orca-env'
|
||||
import { PtyProducerFlowController } from './pty-producer-flow-control'
|
||||
import { beginTerminalInstall } from './watcher-removal-gate'
|
||||
import {
|
||||
@@ -1787,6 +1787,11 @@ export function registerPtyHandlers(
|
||||
if (preAllocatedHandle) {
|
||||
env.ORCA_TERMINAL_HANDLE = preAllocatedHandle
|
||||
}
|
||||
stampWslOrchestrationCompatibilityHost(
|
||||
env,
|
||||
runtime?.getOrchestrationCompatibilityHostId?.(),
|
||||
ctx?.isWsl === true ? ctx.wslDistro : null
|
||||
)
|
||||
if (ctx?.isWsl === true) {
|
||||
addOrcaWslInteropEnv(env)
|
||||
}
|
||||
@@ -3362,10 +3367,35 @@ export function registerPtyHandlers(
|
||||
})
|
||||
: { shellOverride: undefined, terminalWindowsWslDistro: null }
|
||||
const daemonShellOverride = terminalRuntimeOptions.shellOverride
|
||||
const isDaemonHostSpawn =
|
||||
!args.connectionId &&
|
||||
!(provider instanceof LocalPtyProvider) &&
|
||||
!routesFreshSpawnsToLocalProvider(provider)
|
||||
const callerRequestedSessionId = args.sessionId?.trim()
|
||||
const requestedSessionId =
|
||||
callerRequestedSessionId ??
|
||||
(isDaemonHostSpawn && args.agentSessionCreateOperationId
|
||||
? ptySessionIdForAgentCreateOperation(args.worktreeId, args.agentSessionCreateOperationId)
|
||||
: undefined)
|
||||
const sessionId =
|
||||
requestedSessionId ?? (isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined)
|
||||
const effectiveSessionRelayId =
|
||||
sessionId !== undefined ? getRelayPtyId(args.connectionId, sessionId) : undefined
|
||||
const effectiveSessionAppId =
|
||||
sessionId !== undefined ? getAppPtyId(args.connectionId, sessionId) : undefined
|
||||
const isMintedSessionId = callerRequestedSessionId === undefined && isDaemonHostSpawn
|
||||
const expectedWslDistro = !args.connectionId
|
||||
? (resolveWslSessionContext({
|
||||
cwd,
|
||||
sessionId,
|
||||
shellOverride: terminalRuntimeOptions.shellOverride,
|
||||
terminalWindowsWslDistro: terminalRuntimeOptions.terminalWindowsWslDistro
|
||||
})?.distro ?? null)
|
||||
: null
|
||||
const codexSelectionTarget = getCodexSelectionTargetForPty(
|
||||
daemonShellOverride,
|
||||
cwd,
|
||||
terminalRuntimeOptions.terminalWindowsWslDistro ?? null
|
||||
expectedWslDistro
|
||||
)
|
||||
const codexResumePreparation = prepareCodexResumeHome({
|
||||
connectionId: args.connectionId,
|
||||
@@ -3393,31 +3423,6 @@ export function registerPtyHandlers(
|
||||
)
|
||||
}
|
||||
|
||||
const isDaemonHostSpawn =
|
||||
!args.connectionId &&
|
||||
!(provider instanceof LocalPtyProvider) &&
|
||||
!routesFreshSpawnsToLocalProvider(provider)
|
||||
const callerRequestedSessionId = args.sessionId?.trim()
|
||||
const requestedSessionId =
|
||||
callerRequestedSessionId ??
|
||||
(isDaemonHostSpawn && args.agentSessionCreateOperationId
|
||||
? ptySessionIdForAgentCreateOperation(args.worktreeId, args.agentSessionCreateOperationId)
|
||||
: undefined)
|
||||
const sessionId =
|
||||
requestedSessionId ?? (isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined)
|
||||
const effectiveSessionRelayId =
|
||||
sessionId !== undefined ? getRelayPtyId(args.connectionId, sessionId) : undefined
|
||||
const effectiveSessionAppId =
|
||||
sessionId !== undefined ? getAppPtyId(args.connectionId, sessionId) : undefined
|
||||
const isMintedSessionId = callerRequestedSessionId === undefined && isDaemonHostSpawn
|
||||
const expectedWslDistro = !args.connectionId
|
||||
? (resolveWslSessionContext({
|
||||
cwd,
|
||||
sessionId,
|
||||
shellOverride: terminalRuntimeOptions.shellOverride,
|
||||
terminalWindowsWslDistro: terminalRuntimeOptions.terminalWindowsWslDistro
|
||||
})?.distro ?? null)
|
||||
: null
|
||||
const shouldPersistHostSessionBinding = args.persistHostSessionBinding === true
|
||||
let hostSessionBinding: {
|
||||
store: NonNullable<typeof store>
|
||||
@@ -3492,11 +3497,16 @@ export function registerPtyHandlers(
|
||||
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
|
||||
shellPath: daemonShellOverride ?? process.env.COMSPEC,
|
||||
isWsl: shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd),
|
||||
wslDistro: codexSelectionTarget.runtime === 'wsl' ? codexSelectionTarget.wslDistro : null,
|
||||
wslDistro: codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null,
|
||||
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
|
||||
networkProxySettings: getSettings?.(),
|
||||
deferGitConfigGuardToDaemon: provider.supportsGitCredentialGuardHost?.(sessionId) === true
|
||||
})
|
||||
stampWslOrchestrationCompatibilityHost(
|
||||
env,
|
||||
runtime?.getOrchestrationCompatibilityHostId?.(),
|
||||
codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null
|
||||
)
|
||||
promoteAgentTeamsShimPath(env, requestedAgentTeamsPath)
|
||||
}
|
||||
|
||||
@@ -3596,8 +3606,7 @@ export function registerPtyHandlers(
|
||||
}
|
||||
if (process.platform === 'win32' && !args.connectionId) {
|
||||
spawnOptions.shellOverride = terminalRuntimeOptions.shellOverride
|
||||
spawnOptions.terminalWindowsWslDistro =
|
||||
terminalRuntimeOptions.terminalWindowsWslDistro ?? null
|
||||
spawnOptions.terminalWindowsWslDistro = expectedWslDistro
|
||||
spawnOptions.terminalWindowsPowerShellImplementation = getSettings
|
||||
? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto')
|
||||
: undefined
|
||||
@@ -4243,7 +4252,13 @@ export function registerPtyHandlers(
|
||||
return null
|
||||
}
|
||||
},
|
||||
listProcesses: async () => {
|
||||
listProcesses: async (connectionId) => {
|
||||
if (connectionId === null) {
|
||||
return localProvider.listProcesses()
|
||||
}
|
||||
if (connectionId !== undefined) {
|
||||
return getProvider(connectionId).listProcesses()
|
||||
}
|
||||
const providerSessions = await Promise.all([
|
||||
localProvider.listProcesses(),
|
||||
...Array.from(sshProviders.values(), (provider) => provider.listProcesses())
|
||||
@@ -4450,24 +4465,6 @@ export function registerPtyHandlers(
|
||||
})
|
||||
: { shellOverride: args.shellOverride, terminalWindowsWslDistro: null }
|
||||
const initialShellOverride = terminalRuntimeOptions.shellOverride
|
||||
const initialSelectionTarget = getCodexSelectionTargetForPty(
|
||||
initialShellOverride,
|
||||
cwd,
|
||||
terminalRuntimeOptions.terminalWindowsWslDistro ?? null
|
||||
)
|
||||
const claudeAuth =
|
||||
isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(initialSelectionTarget) : null
|
||||
spawnTiming.mark('auth')
|
||||
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
|
||||
throw new Error('A Claude account switch is in progress. Try again after it finishes.')
|
||||
}
|
||||
if (claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) {
|
||||
throw new Error(
|
||||
'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.'
|
||||
)
|
||||
}
|
||||
// Why: the daemon-backed provider skips LocalPtyProvider's buildSpawnEnv, so assemble the same host-local env here for parity.
|
||||
// Safety: skip entirely for SSH — every injection is a loopback secret or a local path that leaks or misleads on the remote host.
|
||||
const isDaemonHostSpawn =
|
||||
!args.connectionId &&
|
||||
!(provider instanceof LocalPtyProvider) &&
|
||||
@@ -4494,6 +4491,24 @@ export function registerPtyHandlers(
|
||||
terminalWindowsWslDistro: terminalRuntimeOptions.terminalWindowsWslDistro
|
||||
})?.distro ?? null)
|
||||
: null
|
||||
const initialSelectionTarget = getCodexSelectionTargetForPty(
|
||||
initialShellOverride,
|
||||
cwd,
|
||||
expectedWslDistro
|
||||
)
|
||||
const claudeAuth =
|
||||
isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(initialSelectionTarget) : null
|
||||
spawnTiming.mark('auth')
|
||||
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
|
||||
throw new Error('A Claude account switch is in progress. Try again after it finishes.')
|
||||
}
|
||||
if (claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) {
|
||||
throw new Error(
|
||||
'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.'
|
||||
)
|
||||
}
|
||||
// Why: the daemon-backed provider skips LocalPtyProvider's buildSpawnEnv, so assemble the same host-local env here for parity.
|
||||
// Safety: skip entirely for SSH — every injection is a loopback secret or a local path that leaks or misleads on the remote host.
|
||||
const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args)
|
||||
// Why: forward pane env to SSH only when the relay hook path is enabled, or a newer relay could emit statuses this build can't route.
|
||||
const sshSourceEnv = stripRemotePaneEnvWhenHooksDisabled(args.connectionId, args.env)
|
||||
@@ -4603,7 +4618,7 @@ export function registerPtyHandlers(
|
||||
const codexSelectionTarget = getCodexSelectionTargetForPty(
|
||||
effectiveShellOverride,
|
||||
cwd,
|
||||
terminalRuntimeOptions.terminalWindowsWslDistro ?? null
|
||||
expectedWslDistro
|
||||
)
|
||||
const codexResumePreparation = prepareCodexResumeHome({
|
||||
connectionId: args.connectionId,
|
||||
@@ -4669,13 +4684,17 @@ export function registerPtyHandlers(
|
||||
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
|
||||
shellPath: effectiveShellOverride ?? process.env.COMSPEC,
|
||||
isWsl: shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd),
|
||||
wslDistro:
|
||||
codexSelectionTarget.runtime === 'wsl' ? codexSelectionTarget.wslDistro : null,
|
||||
wslDistro: codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null,
|
||||
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
|
||||
networkProxySettings: getSettings?.(),
|
||||
deferGitConfigGuardToDaemon:
|
||||
provider.supportsGitCredentialGuardHost?.(effectiveSessionId) === true
|
||||
})
|
||||
stampWslOrchestrationCompatibilityHost(
|
||||
env,
|
||||
runtime?.getOrchestrationCompatibilityHostId?.(),
|
||||
codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null
|
||||
)
|
||||
promoteAgentTeamsShimPath(env, requestedAgentTeamsPath)
|
||||
} catch (err) {
|
||||
// Why: buildPtyHostEnv has fs side-effects (Pi/OMP install); clear per-PTY state on throw, but only minted ids — caller ids may name existing PTYs.
|
||||
@@ -4762,8 +4781,7 @@ export function registerPtyHandlers(
|
||||
}
|
||||
if (process.platform === 'win32' && !args.connectionId) {
|
||||
// Why: the renderer models PowerShell as one shell family; thread the implementation choice so both PTY paths resolve the same executable.
|
||||
spawnOptions.terminalWindowsWslDistro =
|
||||
terminalRuntimeOptions.terminalWindowsWslDistro ?? null
|
||||
spawnOptions.terminalWindowsWslDistro = expectedWslDistro
|
||||
spawnOptions.terminalWindowsPowerShellImplementation = getSettings
|
||||
? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto')
|
||||
: undefined
|
||||
|
||||
@@ -3691,6 +3691,21 @@ describe('Store', () => {
|
||||
expect(hostSession.lastVisitedAtByWorktreeId?.['r1::/path/wt1']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lists only persisted workspace-session host partitions', async () => {
|
||||
const store = await createStore()
|
||||
expect(store.getWorkspaceSessionHostIds()).toEqual(['local'])
|
||||
|
||||
store.getWorkspaceSession('ssh:not-persisted')
|
||||
store.setWorkspaceSession(getDefaultWorkspaceSession(), 'ssh:ssh-a')
|
||||
store.setWorkspaceSession(getDefaultWorkspaceSession(), 'runtime:environment-a')
|
||||
|
||||
expect(store.getWorkspaceSessionHostIds()).toEqual([
|
||||
'local',
|
||||
'ssh:ssh-a',
|
||||
'runtime:environment-a'
|
||||
])
|
||||
})
|
||||
|
||||
it('removeProject removes the derived project host setup compatibility record', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo({ id: 'r1' }))
|
||||
|
||||
@@ -5769,6 +5769,17 @@ export class Store {
|
||||
return this.state.workspaceSessionsByHostId?.[resolved] ?? getDefaultWorkspaceSession()
|
||||
}
|
||||
|
||||
getWorkspaceSessionHostIds(): ExecutionHostId[] {
|
||||
const hostIds = new Set<ExecutionHostId>([LOCAL_EXECUTION_HOST_ID])
|
||||
for (const key of Object.keys(this.state.workspaceSessionsByHostId ?? {})) {
|
||||
const hostId = normalizeExecutionHostId(key)
|
||||
if (hostId) {
|
||||
hostIds.add(hostId)
|
||||
}
|
||||
}
|
||||
return [...hostIds]
|
||||
}
|
||||
|
||||
readTerminalScrollbackSnapshot(ref: string): string | null {
|
||||
return readTerminalScrollbackSnapshotSync(ref, this.terminalScrollbackSnapshotStorage)
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ vi.mock('../wsl', () => ({
|
||||
toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'),
|
||||
toWindowsWslPath: (path: string, distro: string) =>
|
||||
`\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}`,
|
||||
getDefaultWslDistro: () => 'Ubuntu',
|
||||
isWslAvailable: () => true,
|
||||
// Why: WSL worktree validation now asks the distro; these tests use WSL UNC
|
||||
// cwds that are meant to exist, so report them present without spawning wsl.exe.
|
||||
@@ -880,6 +881,45 @@ describe('LocalPtyProvider', () => {
|
||||
expect(spawnCall[2].env.HISTFILE).toContain('terminal-history-wsl/Debian')
|
||||
})
|
||||
|
||||
it('resolves and persists the default distro for Windows cwd WSL terminals', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const buildSpawnEnv = vi.fn(
|
||||
(
|
||||
_id: string,
|
||||
env: Record<string, string>,
|
||||
_ctx?: { isWsl?: boolean; wslDistro?: string | null }
|
||||
) => env
|
||||
)
|
||||
provider.configure({ buildSpawnEnv })
|
||||
|
||||
const result = await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
worktreeId: 'repo-1::C:\\Users\\jin\\repo',
|
||||
cwd: 'C:\\Users\\jin\\repo',
|
||||
shellOverride: 'wsl.exe',
|
||||
terminalWindowsWslDistro: null
|
||||
})
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[1]).toEqual([
|
||||
'-d',
|
||||
'Ubuntu',
|
||||
'--',
|
||||
'sh',
|
||||
'-c',
|
||||
expect.stringContaining("cd '/mnt/c/Users/jin/repo'")
|
||||
])
|
||||
expect(buildSpawnEnv.mock.calls[0]?.[2]).toMatchObject({
|
||||
isWsl: true,
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
expect(result.wslDistro).toBe('Ubuntu')
|
||||
expect(
|
||||
(await provider.listProcesses()).find((entry) => entry.id === result.id)?.wslDistro
|
||||
).toBe('Ubuntu')
|
||||
})
|
||||
|
||||
it('repro: keeps explicit PowerShell 7 selection when the pwsh probe is cold-false', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const pwshAvailable = vi.fn(() => false)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { buildWindowsPowerShellSpawnAttempts } from './windows-shell-fallback-ch
|
||||
import { resolveProcessCwd } from './process-cwd'
|
||||
import { existsSync } from 'node:fs'
|
||||
import * as pty from 'node-pty'
|
||||
import { parseWslPath, isWslAvailable } from '../wsl'
|
||||
import { getDefaultWslDistro, parseWslPath, isWslAvailable } from '../wsl'
|
||||
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
|
||||
import {
|
||||
injectHistoryEnv,
|
||||
@@ -547,6 +547,10 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
process.platform === 'win32'
|
||||
? getWslContextFromPreferredDistro(args.terminalWindowsWslDistro)
|
||||
: undefined
|
||||
let launchWslContext =
|
||||
wslInfo !== null
|
||||
? getWslContextFromPreferredDistro(wslInfo.distro)
|
||||
: (worktreeWslContext ?? preferredWslContext)
|
||||
|
||||
let shellPath: string
|
||||
let shellArgs: string[]
|
||||
@@ -572,6 +576,9 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
process.env.COMSPEC ||
|
||||
'powershell.exe'
|
||||
const shellFamily = worktreeWslContext ? 'wsl.exe' : requestedShellFamily
|
||||
if (!launchWslContext && pathWin32.basename(shellFamily).toLowerCase() === 'wsl.exe') {
|
||||
launchWslContext = getWslContextFromPreferredDistro(getDefaultWslDistro())
|
||||
}
|
||||
const normalizedShellFamily = pathWin32.basename(shellFamily).toLowerCase()
|
||||
const resolvedGitBashPath = resolveWindowsGitBashShellPath(shellFamily)
|
||||
// Why: normalize setting-value and path forms to the PowerShell family so the resolver can fall back to inbox powershell.exe.
|
||||
@@ -606,7 +613,7 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
shellPath,
|
||||
cwd,
|
||||
defaultCwd,
|
||||
wslContext: worktreeWslContext ?? preferredWslContext,
|
||||
wslContext: launchWslContext,
|
||||
startupCommand: args.command
|
||||
})
|
||||
const primaryAttempt = windowsFallbackAttempts[0]
|
||||
@@ -621,7 +628,7 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
shellPath,
|
||||
cwd,
|
||||
defaultCwd,
|
||||
worktreeWslContext ?? preferredWslContext,
|
||||
launchWslContext,
|
||||
args.command
|
||||
)
|
||||
shellArgs = resolved.shellArgs
|
||||
@@ -672,8 +679,7 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
}
|
||||
|
||||
const isWslShell = Boolean(wslInfo) || pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe'
|
||||
const launchWslDistro =
|
||||
wslInfo?.distro ?? worktreeWslContext?.distro ?? preferredWslContext?.distro ?? null
|
||||
const launchWslDistro = isWslShell ? (launchWslContext?.distro ?? null) : null
|
||||
const finalEnv = this.opts.buildSpawnEnv
|
||||
? this.opts.buildSpawnEnv(id, spawnEnv, {
|
||||
command: args.command,
|
||||
@@ -796,7 +802,7 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
let historyResult: ReturnType<typeof injectHistoryEnv> | null = null
|
||||
if (historyEnabled) {
|
||||
historyResult = injectHistoryEnv(finalEnv, worktreeId, effectiveShellPath, cwd, {
|
||||
wslDistro: preferredWslContext?.distro ?? worktreeWslContext?.distro ?? null
|
||||
wslDistro: launchWslDistro
|
||||
})
|
||||
logHistoryInjection(worktreeId, historyResult)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { addOrcaWslInteropEnv, addWorktreeSetupWslInteropEnv } from './wsl-orca-env'
|
||||
import {
|
||||
addOrcaWslInteropEnv,
|
||||
addWorktreeSetupWslInteropEnv,
|
||||
stampWslOrchestrationCompatibilityHost
|
||||
} from './wsl-orca-env'
|
||||
|
||||
describe('addOrcaWslInteropEnv', () => {
|
||||
it('marks the Orca terminal handle for Windows to WSL env import', () => {
|
||||
@@ -29,10 +33,14 @@ describe('addOrcaWslInteropEnv', () => {
|
||||
ORCA_PANE_KEY: 'tab-1:leaf-1',
|
||||
ORCA_TAB_ID: 'tab-1',
|
||||
ORCA_WORKTREE_ID: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo',
|
||||
ORCA_AGENT_LAUNCH_TOKEN: 'launch-secret',
|
||||
ORCA_AGENT_HOOK_PORT: '4567',
|
||||
ORCA_AGENT_HOOK_TOKEN: 'token',
|
||||
ORCA_AGENT_HOOK_ENV: 'dev',
|
||||
ORCA_AGENT_HOOK_VERSION: '1'
|
||||
ORCA_AGENT_HOOK_VERSION: '1',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu'
|
||||
}
|
||||
|
||||
addOrcaWslInteropEnv(env)
|
||||
@@ -44,10 +52,44 @@ describe('addOrcaWslInteropEnv', () => {
|
||||
expect(env.WSLENV).toContain('ORCA_PANE_KEY/u')
|
||||
expect(env.WSLENV).toContain('ORCA_TAB_ID/u')
|
||||
expect(env.WSLENV).toContain('ORCA_WORKTREE_ID/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_LAUNCH_TOKEN/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_PORT/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_TOKEN/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_ENV/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_VERSION/u')
|
||||
expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u')
|
||||
expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID/u')
|
||||
expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION/u')
|
||||
})
|
||||
|
||||
it('overwrites caller host evidence with native runtime WSL authority', () => {
|
||||
const env = {
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'ssh',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'caller-host',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'caller-incarnation',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_ATTACHMENT: 'caller-attachment'
|
||||
}
|
||||
|
||||
stampWslOrchestrationCompatibilityHost(env, 'local', 'Ubuntu')
|
||||
|
||||
expect(env).toEqual({
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu'
|
||||
})
|
||||
})
|
||||
|
||||
it('clears inherited host evidence outside a runtime-owned WSL scope', () => {
|
||||
const env = {
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'ssh',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'caller-host',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'caller-incarnation',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_ATTACHMENT: 'caller-attachment'
|
||||
}
|
||||
|
||||
stampWslOrchestrationCompatibilityHost(env, 'local', null)
|
||||
|
||||
expect(env).toEqual({})
|
||||
})
|
||||
|
||||
it('path-translates a Windows hook endpoint but passes a guest-side one untouched', () => {
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV,
|
||||
ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV,
|
||||
ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV,
|
||||
ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV
|
||||
} from '../../shared/orchestration-compatibility-evidence'
|
||||
|
||||
const WSLENV_ENTRY_SEPARATOR = ':'
|
||||
|
||||
function parseWslenvEntries(value: string | undefined): string[] {
|
||||
@@ -70,6 +77,9 @@ export function addOrcaWslInteropEnv(env: Record<string, string>): void {
|
||||
'ORCA_TAB_ID/u',
|
||||
'ORCA_WORKTREE_ID/u',
|
||||
'ORCA_AGENT_LAUNCH_TOKEN/u',
|
||||
'ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u',
|
||||
'ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID/u',
|
||||
'ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION/u',
|
||||
'ORCA_AGENT_HOOK_PORT/u',
|
||||
'ORCA_AGENT_HOOK_TOKEN/u',
|
||||
'ORCA_AGENT_HOOK_ENV/u',
|
||||
@@ -84,3 +94,22 @@ export function addOrcaWslInteropEnv(env: Record<string, string>): void {
|
||||
]
|
||||
applyWslenvPassthrough(env, passthroughEntries)
|
||||
}
|
||||
|
||||
export function stampWslOrchestrationCompatibilityHost(
|
||||
env: Record<string, string>,
|
||||
hostId: string | null | undefined,
|
||||
distro: string | null | undefined
|
||||
): void {
|
||||
delete env[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV]
|
||||
delete env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV]
|
||||
delete env[ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV]
|
||||
delete env[ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV]
|
||||
const normalizedHostId = hostId?.trim()
|
||||
const normalizedDistro = distro?.trim()
|
||||
if (!normalizedHostId || !normalizedDistro) {
|
||||
return
|
||||
}
|
||||
env[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV] = 'wsl'
|
||||
env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV] = normalizedHostId
|
||||
env[ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV] = normalizedDistro
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1393
-101
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,350 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AgentHookServer } from '../agent-hooks/server'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
const PANE_KEY = '11111111-1111-4111-8111-111111111111:22222222-2222-4222-8222-222222222222'
|
||||
const TOKEN = 'launch-secret'
|
||||
const TOKEN_HASH = createHash('sha256').update(TOKEN).digest('hex')
|
||||
|
||||
type TerminalAuthorityResolver = {
|
||||
getOrchestrationDispatchAuthority: (terminalHandle: string) => unknown
|
||||
restoredOrchestrationAuthorityByPtyId: Map<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
function createRuntime(
|
||||
hostScope:
|
||||
| { kind: 'local'; hostId: 'local' }
|
||||
| { kind: 'wsl'; hostId: 'local'; distro: string }
|
||||
| { kind: 'ssh'; targetId: string },
|
||||
launchTokenHash: string | null = TOKEN_HASH
|
||||
) {
|
||||
const runtime = new OrcaRuntimeService(null, undefined, {
|
||||
attestAgentHookCompatibilityAuthority: ({ paneKey, launchTokenHash, connectionId }) =>
|
||||
paneKey === PANE_KEY &&
|
||||
launchTokenHash === TOKEN_HASH &&
|
||||
connectionId === (hostScope.kind === 'ssh' ? hostScope.targetId : null)
|
||||
? { paneKey, source: 'hydrated_commitment' }
|
||||
: null
|
||||
})
|
||||
const resolveTerminal = vi.fn(() => ({
|
||||
runtimeId: 'runtime-1',
|
||||
terminalHandle: 'term-1',
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: 'repo-1::/worktree',
|
||||
processIncarnation: 'incarnation-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash,
|
||||
hostScope
|
||||
}))
|
||||
;(runtime as unknown as TerminalAuthorityResolver).getOrchestrationDispatchAuthority =
|
||||
resolveTerminal
|
||||
if (launchTokenHash === null) {
|
||||
;(runtime as unknown as TerminalAuthorityResolver).restoredOrchestrationAuthorityByPtyId.set(
|
||||
'pty-1',
|
||||
{
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: 'repo-1::/worktree',
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
processIncarnation: 'incarnation-1',
|
||||
hostScope
|
||||
}
|
||||
)
|
||||
}
|
||||
return runtime
|
||||
}
|
||||
|
||||
describe('orchestration compatibility runtime authority', () => {
|
||||
it('returns only attested local identity and its token hash', () => {
|
||||
const runtime = createRuntime({ kind: 'local', hostId: 'local' })
|
||||
|
||||
const authority = runtime.verifyOrchestrationCompatibilityCaller({
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: TOKEN
|
||||
})
|
||||
|
||||
expect(authority).toEqual({
|
||||
hostScope: { kind: 'local', hostId: 'local' },
|
||||
paneKey: PANE_KEY,
|
||||
terminalHandle: 'term-1',
|
||||
processIncarnation: 'incarnation-1',
|
||||
launchTokenHash: TOKEN_HASH
|
||||
})
|
||||
expect(JSON.stringify(authority)).not.toContain(TOKEN)
|
||||
expect(
|
||||
runtime.verifyOrchestrationCompatibilityCaller({
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: 'wrong'
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
runtime.verifyOrchestrationCompatibilityCaller({
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: TOKEN_HASH
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps local and WSL authority stable across app runtime generations', () => {
|
||||
const firstLocal = createRuntime({ kind: 'local', hostId: 'local' })
|
||||
const secondLocal = createRuntime({ kind: 'local', hostId: 'local' })
|
||||
const wsl = createRuntime({ kind: 'wsl', hostId: 'local', distro: 'Ubuntu' })
|
||||
const localEvidence = {
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: TOKEN
|
||||
}
|
||||
const wslEvidence = {
|
||||
...localEvidence,
|
||||
host: { kind: 'wsl', hostId: 'local', distro: 'Ubuntu' }
|
||||
} as const
|
||||
|
||||
expect(firstLocal.verifyOrchestrationCompatibilityCaller(localEvidence)?.hostScope).toEqual(
|
||||
secondLocal.verifyOrchestrationCompatibilityCaller(localEvidence)?.hostScope
|
||||
)
|
||||
expect(wsl.verifyOrchestrationCompatibilityCaller(wslEvidence)?.hostScope).toEqual({
|
||||
kind: 'wsl',
|
||||
hostId: 'local',
|
||||
distro: 'Ubuntu'
|
||||
})
|
||||
expect(wsl.verifyOrchestrationCompatibilityCaller(localEvidence)).toBeNull()
|
||||
expect(
|
||||
wsl.verifyOrchestrationCompatibilityCaller({
|
||||
...wslEvidence,
|
||||
host: { kind: 'wsl', hostId: 'runtime-before-restart', distro: 'Ubuntu' }
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('requires a live exact terminal even when the hook proof is hydrated', () => {
|
||||
const runtime = createRuntime({ kind: 'local', hostId: 'local' })
|
||||
;(runtime as unknown as TerminalAuthorityResolver).getOrchestrationDispatchAuthority = () =>
|
||||
null
|
||||
|
||||
expect(
|
||||
runtime.verifyOrchestrationCompatibilityCaller({
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: TOKEN
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('uses the hydrated hook commitment for a restored exact PTY', () => {
|
||||
const restored = createRuntime({ kind: 'local', hostId: 'local' }, null)
|
||||
const uncommitted = new OrcaRuntimeService()
|
||||
;(uncommitted as unknown as TerminalAuthorityResolver).getOrchestrationDispatchAuthority =
|
||||
() => ({
|
||||
runtimeId: 'runtime-1',
|
||||
terminalHandle: 'term-1',
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: 'repo-1::/worktree',
|
||||
processIncarnation: 'incarnation-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: null,
|
||||
hostScope: { kind: 'local', hostId: 'local' }
|
||||
})
|
||||
const evidence = {
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: TOKEN
|
||||
}
|
||||
|
||||
expect(restored.verifyOrchestrationCompatibilityCaller(evidence)).toMatchObject({
|
||||
processIncarnation: 'incarnation-1',
|
||||
launchTokenHash: TOKEN_HASH
|
||||
})
|
||||
expect(uncommitted.verifyOrchestrationCompatibilityCaller(evidence)).toBeNull()
|
||||
expect(
|
||||
restored.verifyOrchestrationCompatibilityCaller({
|
||||
...evidence,
|
||||
launchToken: 'wrong'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['PTY', { ptyId: 'pty-other' }],
|
||||
['worktree', { worktreeId: 'repo-1::/other' }],
|
||||
['terminal handle', { terminalHandle: 'term-other' }],
|
||||
[
|
||||
'pane',
|
||||
{ paneKey: '33333333-3333-4333-8333-333333333333:44444444-4444-4444-8444-444444444444' }
|
||||
],
|
||||
['process incarnation', { processIncarnation: 'incarnation-other' }]
|
||||
])('rejects a restored receipt with mismatched %s identity', (_field, mismatch) => {
|
||||
const runtime = createRuntime({ kind: 'local', hostId: 'local' }, null)
|
||||
const internals = runtime as unknown as TerminalAuthorityResolver
|
||||
const receipt = internals.restoredOrchestrationAuthorityByPtyId.get('pty-1')!
|
||||
internals.restoredOrchestrationAuthorityByPtyId.set('pty-1', { ...receipt, ...mismatch })
|
||||
|
||||
expect(
|
||||
runtime.verifyOrchestrationCompatibilityCaller({
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: TOKEN
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects restored receipts from a different WSL distro or SSH target', () => {
|
||||
const wsl = createRuntime({ kind: 'wsl', hostId: 'local', distro: 'Ubuntu' }, null)
|
||||
const wslInternals = wsl as unknown as TerminalAuthorityResolver
|
||||
const wslReceipt = wslInternals.restoredOrchestrationAuthorityByPtyId.get('pty-1')!
|
||||
wslInternals.restoredOrchestrationAuthorityByPtyId.set('pty-1', {
|
||||
...wslReceipt,
|
||||
hostScope: { kind: 'wsl', hostId: 'local', distro: 'Debian' }
|
||||
})
|
||||
expect(
|
||||
wsl.verifyOrchestrationCompatibilityCaller({
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: TOKEN,
|
||||
host: { kind: 'wsl', hostId: 'local', distro: 'Ubuntu' }
|
||||
})
|
||||
).toBeNull()
|
||||
|
||||
const ssh = createRuntime({ kind: 'ssh', targetId: 'saved-target' }, null)
|
||||
const sshInternals = ssh as unknown as TerminalAuthorityResolver
|
||||
const sshReceipt = sshInternals.restoredOrchestrationAuthorityByPtyId.get('pty-1')!
|
||||
sshInternals.restoredOrchestrationAuthorityByPtyId.set('pty-1', {
|
||||
...sshReceipt,
|
||||
hostScope: { kind: 'ssh', targetId: 'other-target' }
|
||||
})
|
||||
const host = ssh.registerOrchestrationCompatibilitySshAttachment('saved-target', 'connection-1')
|
||||
expect(
|
||||
ssh.verifyOrchestrationCompatibilityCaller({
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: TOKEN,
|
||||
host
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not fall back to a restored receipt when a fresh launch token mismatches', () => {
|
||||
const runtime = createRuntime({ kind: 'local', hostId: 'local' })
|
||||
;(runtime as unknown as TerminalAuthorityResolver).restoredOrchestrationAuthorityByPtyId.set(
|
||||
'pty-1',
|
||||
{
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: 'repo-1::/worktree',
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
processIncarnation: 'incarnation-1',
|
||||
hostScope: { kind: 'local', hostId: 'local' }
|
||||
}
|
||||
)
|
||||
|
||||
expect(
|
||||
runtime.verifyOrchestrationCompatibilityCaller({
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: 'wrong'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('attests current coordinators immediately without upgrading restored legacy callers', () => {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: TOKEN,
|
||||
payload: { state: 'working', prompt: 'coordinate', agentType: 'codex' }
|
||||
},
|
||||
'saved-target'
|
||||
)
|
||||
const createIntegratedRuntime = (launchTokenHash: string | null): OrcaRuntimeService => {
|
||||
const runtime = new OrcaRuntimeService(null, undefined, {
|
||||
attestAgentHookCompatibilityAuthority: (candidate) =>
|
||||
server.attestCompatibilityAuthority(candidate)
|
||||
})
|
||||
;(runtime as unknown as TerminalAuthorityResolver).getOrchestrationDispatchAuthority = vi.fn(
|
||||
() => ({
|
||||
runtimeId: 'runtime-1',
|
||||
terminalHandle: 'term-1',
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: 'repo-1::/worktree',
|
||||
processIncarnation: 'incarnation-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash,
|
||||
hostScope: { kind: 'ssh', targetId: 'saved-target' }
|
||||
})
|
||||
)
|
||||
if (launchTokenHash === null) {
|
||||
;(
|
||||
runtime as unknown as TerminalAuthorityResolver
|
||||
).restoredOrchestrationAuthorityByPtyId.set('pty-1', {
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: 'repo-1::/worktree',
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
processIncarnation: 'incarnation-1',
|
||||
hostScope: { kind: 'ssh', targetId: 'saved-target' }
|
||||
})
|
||||
}
|
||||
return runtime
|
||||
}
|
||||
const evidenceFor = (runtime: OrcaRuntimeService, launchToken = TOKEN) => ({
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchToken,
|
||||
host: runtime.registerOrchestrationCompatibilitySshAttachment('saved-target', 'connection-1')
|
||||
})
|
||||
|
||||
const current = createIntegratedRuntime(TOKEN_HASH)
|
||||
expect(current.verifyOrchestrationCompatibilityCaller(evidenceFor(current))).not.toBeNull()
|
||||
|
||||
const restored = createIntegratedRuntime(null)
|
||||
expect(restored.verifyOrchestrationCompatibilityCaller(evidenceFor(restored))).toBeNull()
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: '33333333-3333-4333-8333-333333333333:44444444-4444-4444-8444-444444444444',
|
||||
launchToken: TOKEN,
|
||||
payload: { state: 'working', prompt: 'duplicate', agentType: 'codex' }
|
||||
},
|
||||
'saved-target'
|
||||
)
|
||||
expect(current.verifyOrchestrationCompatibilityCaller(evidenceFor(current))).toBeNull()
|
||||
|
||||
const mismatchedToken = 'different-launch-secret'
|
||||
const mismatched = createIntegratedRuntime(
|
||||
createHash('sha256').update(mismatchedToken).digest('hex')
|
||||
)
|
||||
expect(
|
||||
mismatched.verifyOrchestrationCompatibilityCaller(evidenceFor(mismatched, mismatchedToken))
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts only a live runtime-issued SSH attachment', () => {
|
||||
const runtime = createRuntime({ kind: 'ssh', targetId: 'saved-target' })
|
||||
const host = runtime.registerOrchestrationCompatibilitySshAttachment(
|
||||
'saved-target',
|
||||
'connection-1'
|
||||
)
|
||||
const evidence = {
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: TOKEN,
|
||||
host
|
||||
} as const
|
||||
|
||||
expect(runtime.verifyOrchestrationCompatibilityCaller(evidence)).not.toBeNull()
|
||||
expect(
|
||||
runtime.verifyOrchestrationCompatibilityCaller({
|
||||
...evidence,
|
||||
host: { ...host, attachmentId: 'caller-chosen' }
|
||||
})
|
||||
).toBeNull()
|
||||
|
||||
runtime.releaseOrchestrationCompatibilitySshAttachment(host.attachmentId)
|
||||
|
||||
expect(runtime.verifyOrchestrationCompatibilityCaller(evidence)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -942,7 +942,7 @@ describe('OrchestrationDb', () => {
|
||||
|
||||
// v1 data preserved
|
||||
expect(d.getMessageById('msg_v1')?.subject).toBe('pre-migration')
|
||||
expect(d.getMessageById('msg_v1')?.run_id).toBe(LEGACY_RUN_ID)
|
||||
expect(d.getMessageById('msg_v1')?.run_id).toBe(d.getLegacyAdoption()?.adopted_run_id)
|
||||
expect(d.getRun(LEGACY_RUN_ID)).toMatchObject({ legacy: 1 })
|
||||
})
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -77,6 +77,14 @@ describe('formatMessageBanner', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('lets the CLI resolve the live sender for Run and Dispatch addresses', () => {
|
||||
for (const to_handle of ['run:run_test', 'dispatch:dispatch_test']) {
|
||||
const banner = formatMessageBanner(makeMessage({ to_handle }))
|
||||
expect(banner).toContain('[Reply: orca orchestration reply --id msg_test1 --body "..."]')
|
||||
expect(banner).not.toContain(`--from ${to_handle}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('marks legacy messages read-only without reply or acknowledgment affordances', () => {
|
||||
const banner = formatMessageBanner(
|
||||
makeMessage({ id: 'msg_legacy', run_id: 'run_legacy_local' })
|
||||
@@ -88,6 +96,63 @@ describe('formatMessageBanner', () => {
|
||||
expect(banner).not.toContain('orchestration reply')
|
||||
})
|
||||
|
||||
it('renders only attested actions for legacy compatibility authority', () => {
|
||||
const banner = formatMessageBanner(makeMessage({ id: 'msg_legacy' }), {
|
||||
authority: 'legacy_compatibility',
|
||||
supportedActionHints: [
|
||||
'orca orchestration reply --id msg_legacy --from term_coord --body "..."'
|
||||
]
|
||||
})
|
||||
|
||||
expect(banner).toContain('[LEGACY COMPATIBILITY]')
|
||||
expect(banner).toContain(
|
||||
'[Supported action: orca orchestration reply --id msg_legacy --from term_coord --body "..."]'
|
||||
)
|
||||
expect(banner).not.toContain('[Reply:')
|
||||
expect(banner).not.toContain('acknowledgment')
|
||||
})
|
||||
|
||||
it('warns that a bounded legacy recovery replay may already have been seen', () => {
|
||||
const banner = formatMessageBanner(makeMessage(), {
|
||||
authority: 'legacy_recovery_replay',
|
||||
supportedActionHints: ['orca orchestration check --ack delivery_legacy']
|
||||
})
|
||||
|
||||
expect(banner).toContain('[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]')
|
||||
expect(banner).toContain('bounded recovery replay may already have been seen')
|
||||
expect(banner).toContain('[Supported action: orca orchestration check --ack delivery_legacy]')
|
||||
expect(banner).not.toContain('[Reply:')
|
||||
})
|
||||
|
||||
it('does not infer live compatibility from legacy database provenance', () => {
|
||||
const banner = formatMessageBanner(makeMessage({ run_id: 'run_legacy_local' }), {
|
||||
supportedActionHints: ['orca orchestration check --ack delivery_legacy']
|
||||
})
|
||||
|
||||
expect(banner).toContain('[LEGACY READ-ONLY]')
|
||||
expect(banner).not.toContain('Supported action:')
|
||||
expect(banner).not.toContain('check --ack')
|
||||
})
|
||||
|
||||
it('keeps adopted legacy and audit messages read-only without runtime authority', () => {
|
||||
for (const deliveryContract of ['legacy_direct', 'audit_only'] as const) {
|
||||
const banner = formatMessageBanner(
|
||||
makeMessage({ run_id: 'run_adopted', delivery_contract: deliveryContract })
|
||||
)
|
||||
|
||||
expect(banner).toContain('[LEGACY READ-ONLY]')
|
||||
expect(banner).not.toContain('[Reply:')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps current formatting unchanged when authority is explicit', () => {
|
||||
const message = makeMessage({ id: 'msg_current' })
|
||||
|
||||
expect(formatMessageBanner(message, { authority: 'current' })).toBe(
|
||||
formatMessageBanner(message)
|
||||
)
|
||||
})
|
||||
|
||||
it('ends with a separator line', () => {
|
||||
const banner = formatMessageBanner(makeMessage())
|
||||
const lines = banner.split('\n')
|
||||
|
||||
@@ -4,19 +4,77 @@ import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../shared/orchestration-rpc-c
|
||||
const BANNER_WIDTH = 60
|
||||
const SEPARATOR = '─'.repeat(BANNER_WIDTH)
|
||||
|
||||
export function formatMessageBanner(msg: MessageRow): string {
|
||||
export type MessageFormattingAuthority =
|
||||
| 'current'
|
||||
| 'legacy_compatibility'
|
||||
| 'legacy_recovery_replay'
|
||||
| 'legacy_read_only'
|
||||
|
||||
export type MessageFormattingOptions = {
|
||||
authority?: MessageFormattingAuthority
|
||||
supportedActionHints?: readonly string[]
|
||||
}
|
||||
|
||||
function resolveAuthority(
|
||||
msg: MessageRow,
|
||||
authority: MessageFormattingAuthority | undefined
|
||||
): MessageFormattingAuthority {
|
||||
if (authority) {
|
||||
return authority
|
||||
}
|
||||
return msg.run_id === ORCHESTRATION_LEGACY_RUN_ID ||
|
||||
msg.delivery_contract === 'legacy_direct' ||
|
||||
msg.delivery_contract === 'audit_only'
|
||||
? 'legacy_read_only'
|
||||
: 'current'
|
||||
}
|
||||
|
||||
function appendLegacyGuidance(
|
||||
lines: string[],
|
||||
authority: MessageFormattingAuthority,
|
||||
supportedActionHints: readonly string[]
|
||||
): void {
|
||||
if (authority === 'legacy_read_only') {
|
||||
lines.push('[Inspection only: reply and acknowledgment are unavailable.]')
|
||||
return
|
||||
}
|
||||
if (authority === 'legacy_compatibility') {
|
||||
lines.push('[Use only the supported legacy action shown below.]')
|
||||
} else if (authority === 'legacy_recovery_replay') {
|
||||
lines.push(
|
||||
'[This bounded recovery replay may already have been seen. Use only the action shown below.]'
|
||||
)
|
||||
}
|
||||
for (const hint of supportedActionHints) {
|
||||
lines.push(`[Supported action: ${hint}]`)
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMessageBanner(msg: MessageRow, options: MessageFormattingOptions): string
|
||||
export function formatMessageBanner(msg: MessageRow): string
|
||||
export function formatMessageBanner(
|
||||
msg: MessageRow,
|
||||
options: MessageFormattingOptions = {}
|
||||
): string {
|
||||
const priorityTag =
|
||||
msg.priority === 'urgent' ? ' [URGENT]' : msg.priority === 'high' ? ' [HIGH]' : ''
|
||||
const legacyReadOnly = msg.run_id === ORCHESTRATION_LEGACY_RUN_ID
|
||||
const authorityTag = legacyReadOnly ? ' [LEGACY READ-ONLY]' : ''
|
||||
const authority = resolveAuthority(msg, options.authority)
|
||||
const authorityTag =
|
||||
authority === 'legacy_compatibility'
|
||||
? ' [LEGACY COMPATIBILITY]'
|
||||
: authority === 'legacy_recovery_replay'
|
||||
? ' [LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]'
|
||||
: authority === 'legacy_read_only'
|
||||
? ' [LEGACY READ-ONLY]'
|
||||
: ''
|
||||
const senderName = msg.from_handle.toUpperCase()
|
||||
|
||||
const header = `──── From: ${senderName} (${msg.from_handle})${priorityTag}${authorityTag} (${msg.type}) ────`
|
||||
|
||||
const lines: string[] = [header]
|
||||
lines.push(`Subject: ${msg.subject}`)
|
||||
if (legacyReadOnly) {
|
||||
lines.push('[Inspection only: reply and acknowledgment are unavailable.]')
|
||||
if (authority !== 'current') {
|
||||
appendLegacyGuidance(lines, authority, options.supportedActionHints ?? [])
|
||||
}
|
||||
|
||||
if (msg.body) {
|
||||
@@ -27,11 +85,12 @@ export function formatMessageBanner(msg: MessageRow): string {
|
||||
lines.push(`[Payload: ${msg.payload}]`)
|
||||
}
|
||||
|
||||
if (!legacyReadOnly) {
|
||||
// Why: older shells can lack Orca's terminal identity environment.
|
||||
lines.push(
|
||||
`[Reply: orca orchestration reply --id ${msg.id} --from ${msg.to_handle} --body "..."]`
|
||||
)
|
||||
if (authority === 'current') {
|
||||
const explicitFrom =
|
||||
msg.to_handle.startsWith('run:') || msg.to_handle.startsWith('dispatch:')
|
||||
? ''
|
||||
: ` --from ${msg.to_handle}`
|
||||
lines.push(`[Reply: orca orchestration reply --id ${msg.id}${explicitFrom} --body "..."]`)
|
||||
}
|
||||
lines.push(SEPARATOR)
|
||||
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
import { rmSync } from 'node:fs'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { LEGACY_RUN_ID, OrchestrationDb } from './db'
|
||||
import {
|
||||
createLegacyStorageCutoverFixture,
|
||||
type LegacyStorageCutoverFixture
|
||||
} from './orchestration-legacy-storage-test-fixture'
|
||||
|
||||
describe('OrchestrationDb legacy coordinator authority', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
let tempDir: string | undefined
|
||||
|
||||
afterEach(() => {
|
||||
db?.close()
|
||||
db = undefined
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
tempDir = undefined
|
||||
}
|
||||
})
|
||||
|
||||
function createCutoverFixture(): LegacyStorageCutoverFixture {
|
||||
const created = createLegacyStorageCutoverFixture()
|
||||
tempDir = created.tempDir
|
||||
return created.fixture
|
||||
}
|
||||
|
||||
function openAdoptedFixture(): {
|
||||
fixture: LegacyStorageCutoverFixture
|
||||
adoptedRunId: string
|
||||
workerPrincipalId: string
|
||||
} {
|
||||
const fixture = createCutoverFixture()
|
||||
db = new OrchestrationDb(fixture.dbPath)
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
|
||||
const worker = db.commitLegacyCompatibilityPrincipal({
|
||||
runId: adoptedRunId,
|
||||
dispatchId: fixture.legacyDispatchId,
|
||||
role: 'worker',
|
||||
hostScope: 'local:runtime_1',
|
||||
terminalHandle: 'term_legacy_worker',
|
||||
paneKey: 'tab_legacy:33333333-3333-4333-8333-333333333333',
|
||||
launchTokenHash: 'legacy_launch_hash',
|
||||
processIncarnation: 'process_1'
|
||||
})
|
||||
return {
|
||||
fixture,
|
||||
adoptedRunId,
|
||||
workerPrincipalId: worker.principal.id
|
||||
}
|
||||
}
|
||||
|
||||
it('records exact scheduler loss without admitting naturally settled coordinators', () => {
|
||||
const fixture = createCutoverFixture()
|
||||
const raw = new Database(fixture.dbPath)
|
||||
raw.exec(`
|
||||
UPDATE tasks
|
||||
SET created_by_terminal_handle = 'term_manual_coord'
|
||||
WHERE run_id = '${LEGACY_RUN_ID}';
|
||||
UPDATE messages
|
||||
SET from_handle = CASE
|
||||
WHEN from_handle = 'term_legacy_coord' THEN 'term_manual_coord'
|
||||
ELSE from_handle
|
||||
END,
|
||||
to_handle = CASE
|
||||
WHEN to_handle = 'term_legacy_coord' THEN 'term_manual_coord'
|
||||
ELSE to_handle
|
||||
END
|
||||
WHERE run_id = '${LEGACY_RUN_ID}';
|
||||
INSERT INTO coordinator_runs (
|
||||
id, spec, status, coordinator_handle, created_at
|
||||
) VALUES (
|
||||
'coordinator_manual', 'manual legacy coordinator', 'running',
|
||||
'term_manual_coord', datetime('now', '-1 minute')
|
||||
);
|
||||
INSERT INTO coordinator_runs (
|
||||
id, spec, status, coordinator_handle, created_at, completed_at
|
||||
) VALUES (
|
||||
'coordinator_natural', 'naturally completed coordinator', 'completed',
|
||||
'term_natural_coord', datetime('now', '-2 minutes'),
|
||||
'2099-01-01T00:00:00.000Z'
|
||||
);
|
||||
`)
|
||||
raw.close()
|
||||
|
||||
db = new OrchestrationDb(fixture.dbPath)
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
|
||||
expect(db.getActiveCoordinatorRun()).toBeUndefined()
|
||||
const adoption = db.getLegacyAdoption()!
|
||||
const migratedCoordinator = db.getCoordinatorRun('coordinator_manual')!
|
||||
expect(migratedCoordinator).toMatchObject({
|
||||
status: 'failed',
|
||||
completed_at: adoption.adopted_at,
|
||||
scheduler_lost_at: adoption.adopted_at
|
||||
})
|
||||
expect(db.getCoordinatorRun('coordinator_natural')).toMatchObject({
|
||||
status: 'completed',
|
||||
completed_at: '2099-01-01T00:00:00.000Z',
|
||||
scheduler_lost_at: null
|
||||
})
|
||||
expect(
|
||||
db.resolveLegacyCoordinatorCandidate({
|
||||
runId: adoptedRunId,
|
||||
terminalHandle: 'term_manual_coord',
|
||||
paneKey: 'tab_manual:44444444-4444-4444-8444-444444444444'
|
||||
})
|
||||
).toMatchObject({ terminalHandle: 'term_manual_coord' })
|
||||
expect(db.isLegacyCoordinatorHandle(adoptedRunId, 'term_natural_coord')).toBe(false)
|
||||
|
||||
db.close()
|
||||
db = new OrchestrationDb(fixture.dbPath)
|
||||
expect(db.getLegacyAdoption()).toEqual(adoption)
|
||||
expect(db.getCoordinatorRun('coordinator_manual')).toEqual(migratedCoordinator)
|
||||
expect(
|
||||
db.resolveLegacyCoordinatorCandidate({
|
||||
runId: adoptedRunId,
|
||||
terminalHandle: 'term_manual_coord',
|
||||
paneKey: 'tab_manual:44444444-4444-4444-8444-444444444444'
|
||||
})
|
||||
).toMatchObject({ terminalHandle: 'term_manual_coord' })
|
||||
})
|
||||
|
||||
it('fails closed when multiple pre-cutover schedulers lose authority', () => {
|
||||
const fixture = createCutoverFixture()
|
||||
const raw = new Database(fixture.dbPath)
|
||||
raw.exec(`
|
||||
INSERT INTO coordinator_runs (
|
||||
id, spec, status, coordinator_handle, created_at
|
||||
) VALUES
|
||||
('coordinator_first', 'first', 'running', 'term_first_coord', datetime('now', '-2 minutes')),
|
||||
('coordinator_second', 'second', 'running', 'term_second_coord', datetime('now', '-1 minute'));
|
||||
`)
|
||||
raw.close()
|
||||
|
||||
db = new OrchestrationDb(fixture.dbPath)
|
||||
const adoption = db.getLegacyAdoption()!
|
||||
const adoptedRunId = adoption.adopted_run_id
|
||||
expect(db.getActiveCoordinatorRun()).toBeUndefined()
|
||||
expect(db.getCoordinatorRun('coordinator_first')).toMatchObject({
|
||||
status: 'failed',
|
||||
scheduler_lost_at: adoption.adopted_at
|
||||
})
|
||||
expect(db.getCoordinatorRun('coordinator_second')).toMatchObject({
|
||||
status: 'failed',
|
||||
scheduler_lost_at: adoption.adopted_at
|
||||
})
|
||||
expect(
|
||||
db.resolveLegacyCoordinatorCandidate({
|
||||
runId: adoptedRunId,
|
||||
terminalHandle: 'term_legacy_coord',
|
||||
paneKey: 'tab_manual:44444444-4444-4444-8444-444444444444'
|
||||
})
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not infer a coordinator from worker-to-worker legacy mail', () => {
|
||||
const fixture = createCutoverFixture()
|
||||
const raw = new Database(fixture.dbPath)
|
||||
raw.exec(`
|
||||
INSERT INTO tasks (
|
||||
id, run_id, spec, status, created_by_terminal_handle
|
||||
) VALUES (
|
||||
'task_second_worker', '${LEGACY_RUN_ID}', 'second worker',
|
||||
'dispatched', 'term_legacy_coord'
|
||||
);
|
||||
INSERT INTO dispatch_contexts (
|
||||
id, run_id, task_id, contract_version, assignee_handle,
|
||||
assignee_pane_key, status, dispatched_at
|
||||
) VALUES (
|
||||
'dispatch_second_worker', '${LEGACY_RUN_ID}', 'task_second_worker', 0,
|
||||
'term_second_worker', 'tab_second:55555555-5555-4555-8555-555555555555',
|
||||
'dispatched', datetime('now')
|
||||
);
|
||||
INSERT INTO messages (
|
||||
id, run_id, from_handle, to_handle, subject
|
||||
) VALUES (
|
||||
'message_worker_to_worker', '${LEGACY_RUN_ID}', 'term_legacy_worker',
|
||||
'term_second_worker', 'peer coordination'
|
||||
);
|
||||
`)
|
||||
raw.close()
|
||||
|
||||
db = new OrchestrationDb(fixture.dbPath)
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
|
||||
expect(db.isLegacyCoordinatorHandle(adoptedRunId, 'term_second_worker')).toBe(false)
|
||||
expect(db.isLegacyCoordinatorHandle(adoptedRunId, 'term_legacy_worker')).toBe(false)
|
||||
expect(db.isLegacyCoordinatorHandle(adoptedRunId, 'term_legacy_coord')).toBe(true)
|
||||
})
|
||||
|
||||
it('promotes only the unacknowledged coordinator recovery cohort during takeover', () => {
|
||||
const fixture = createCutoverFixture()
|
||||
db = new OrchestrationDb(fixture.dbPath)
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
|
||||
const unacknowledged = db.insertMessage({
|
||||
runId: adoptedRunId,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'second recovered coordinator outcome'
|
||||
})
|
||||
db.markAsRead([unacknowledged.id])
|
||||
const coordinator = db.commitLegacyCompatibilityPrincipal({
|
||||
runId: adoptedRunId,
|
||||
role: 'coordinator',
|
||||
hostScope: 'local:runtime_1',
|
||||
terminalHandle: 'term_legacy_coord',
|
||||
paneKey: 'tab_coord:44444444-4444-4444-8444-444444444444',
|
||||
launchTokenHash: 'coord_launch_hash',
|
||||
processIncarnation: 'process_coord'
|
||||
}).principal
|
||||
const firstPage = db.getLegacyMailPage({ principalId: coordinator.id, limit: 1 })
|
||||
expect(firstPage.recovery).toBe(true)
|
||||
db.acknowledgeLegacyMail({
|
||||
principalId: coordinator.id,
|
||||
messageIds: firstPage.messages.map((message) => message.id)
|
||||
})
|
||||
const acknowledgedId = firstPage.messages[0].id
|
||||
|
||||
db.bindRun({
|
||||
runId: adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:11111111-1111-4111-8111-111111111111',
|
||||
takeoverLegacy: true
|
||||
})
|
||||
|
||||
expect(db.getMessageById(acknowledgedId)).toMatchObject({
|
||||
to_handle: 'term_legacy_coord',
|
||||
delivery_contract: 'legacy_direct',
|
||||
read: 1
|
||||
})
|
||||
expect(db.getMessageById(unacknowledged.id)).toMatchObject({
|
||||
to_handle: `run:${adoptedRunId}`,
|
||||
delivery_contract: 'current_delivery',
|
||||
read: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('promotes a pre-cutover read recovery message when takeover precedes attestation', () => {
|
||||
const fixture = createCutoverFixture()
|
||||
db = new OrchestrationDb(fixture.dbPath)
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
|
||||
const recoveryMessageId = fixture.legacyMessageIds[1]
|
||||
expect(db.getLegacyCoordinatorPrincipal(adoptedRunId)).toBeUndefined()
|
||||
expect(db.getMessageById(recoveryMessageId)).toMatchObject({
|
||||
to_handle: 'term_legacy_coord',
|
||||
delivery_contract: 'legacy_direct',
|
||||
read: 1
|
||||
})
|
||||
|
||||
const run = db.bindRun({
|
||||
runId: adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:11111111-1111-4111-8111-111111111111',
|
||||
takeoverLegacy: true
|
||||
})!
|
||||
expect(db.getMessageById(recoveryMessageId)).toMatchObject({
|
||||
to_handle: `run:${adoptedRunId}`,
|
||||
delivery_contract: 'current_delivery',
|
||||
read: 0
|
||||
})
|
||||
|
||||
const first = db.getOrCreateRunDelivery({
|
||||
runId: adoptedRunId,
|
||||
consumerGeneration: run.consumer_generation
|
||||
})!
|
||||
const replay = db.getOrCreateRunDelivery({
|
||||
runId: adoptedRunId,
|
||||
consumerGeneration: run.consumer_generation
|
||||
})!
|
||||
expect(first.messages.map((message) => message.id)).toContain(recoveryMessageId)
|
||||
expect(replay).toMatchObject({ replayed: true, delivery: { id: first.delivery.id } })
|
||||
|
||||
db.acknowledgeRunDelivery({
|
||||
runId: adoptedRunId,
|
||||
consumerGeneration: run.consumer_generation,
|
||||
deliveryId: first.delivery.id
|
||||
})
|
||||
expect(
|
||||
db
|
||||
.getOrCreateRunDelivery({
|
||||
runId: adoptedRunId,
|
||||
consumerGeneration: run.consumer_generation
|
||||
})
|
||||
?.messages.map((message) => message.id) ?? []
|
||||
).not.toContain(recoveryMessageId)
|
||||
})
|
||||
|
||||
it('promotes retained mail on an ordinary post-settlement coordinator replacement', () => {
|
||||
const state = openAdoptedFixture()
|
||||
const coordinator = db!.commitLegacyCompatibilityPrincipal({
|
||||
runId: state.adoptedRunId,
|
||||
role: 'coordinator',
|
||||
hostScope: 'local:runtime_1',
|
||||
terminalHandle: 'term_legacy_coord',
|
||||
paneKey: 'tab_coord:44444444-4444-4444-8444-444444444444',
|
||||
launchTokenHash: 'coord_launch_hash',
|
||||
processIncarnation: 'process_coord'
|
||||
}).principal
|
||||
const completion = db!.insertMessage({
|
||||
runId: state.adoptedRunId,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Completed before ordinary run-use',
|
||||
type: 'worker_done'
|
||||
})
|
||||
db!.settleWorkerReport({
|
||||
taskId: state.fixture.legacyTaskId,
|
||||
dispatchId: state.fixture.legacyDispatchId,
|
||||
outcome: 'succeeded',
|
||||
result: 'done'
|
||||
})
|
||||
|
||||
const run = db!.bindRun({
|
||||
runId: state.adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:11111111-1111-4111-8111-111111111111'
|
||||
})!
|
||||
|
||||
expect(db!.getLegacyCompatibilityPrincipal(coordinator.id)?.status).toBe('revoked')
|
||||
expect(db!.getMessageById(completion.id)).toMatchObject({
|
||||
to_handle: `run:${state.adoptedRunId}`,
|
||||
delivery_contract: 'current_delivery',
|
||||
read: 0
|
||||
})
|
||||
expect(
|
||||
db!
|
||||
.getOrCreateRunDelivery({
|
||||
runId: state.adoptedRunId,
|
||||
consumerGeneration: run.consumer_generation
|
||||
})
|
||||
?.messages.map((message) => message.id)
|
||||
).toContain(completion.id)
|
||||
})
|
||||
|
||||
it('acknowledges the exact current Run answer to a legacy ask after takeover', () => {
|
||||
const state = openAdoptedFixture()
|
||||
const ask = db!.commitLegacyAskOperation({
|
||||
principalId: state.workerPrincipalId,
|
||||
operationKey: 'ask_before_takeover',
|
||||
method: 'orchestration.ask',
|
||||
payloadHash: 'ask_before_takeover_payload',
|
||||
question: 'Continue after takeover?',
|
||||
recipientHandle: 'term_legacy_coord'
|
||||
})
|
||||
const run = db!.bindRun({
|
||||
runId: state.adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:11111111-1111-4111-8111-111111111111',
|
||||
takeoverLegacy: true
|
||||
})!
|
||||
const answered = db!.answerQuestion({
|
||||
messageId: ask.question.message_id,
|
||||
runId: state.adoptedRunId,
|
||||
consumerGeneration: run.consumer_generation,
|
||||
body: 'continue'
|
||||
})
|
||||
expect(answered.message).toMatchObject({
|
||||
from_handle: `run:${state.adoptedRunId}`,
|
||||
to_handle: `dispatch:${state.fixture.legacyDispatchId}`,
|
||||
delivery_contract: 'current_delivery'
|
||||
})
|
||||
|
||||
const acknowledged = db!.acknowledgeLegacyQuestionAnswer({
|
||||
principalId: state.workerPrincipalId,
|
||||
questionId: ask.question.message_id,
|
||||
answerMessageId: answered.message.id
|
||||
})
|
||||
|
||||
expect(acknowledged.duplicate).toBe(false)
|
||||
expect(db!.getMessageById(answered.message.id)?.read).toBe(1)
|
||||
expect(
|
||||
db!.acknowledgeLegacyQuestionAnswer({
|
||||
principalId: state.workerPrincipalId,
|
||||
questionId: ask.question.message_id,
|
||||
answerMessageId: answered.message.id
|
||||
}).duplicate
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,255 @@
|
||||
import { rmSync } from 'node:fs'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { LEGACY_RUN_ID, OrchestrationDb } from './db'
|
||||
import { createLegacyStorageCutoverFixture } from './orchestration-legacy-storage-test-fixture'
|
||||
|
||||
describe('OrchestrationDb legacy question migration', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
let tempDir: string | undefined
|
||||
|
||||
afterEach(() => {
|
||||
db?.close()
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function prepareTakenOverSchema19Question(readAndAcknowledge: boolean): {
|
||||
dbPath: string
|
||||
adoptedRunId: string
|
||||
questionId: string
|
||||
} {
|
||||
const created = createLegacyStorageCutoverFixture()
|
||||
tempDir = created.tempDir
|
||||
db = new OrchestrationDb(created.fixture.dbPath)
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
|
||||
const questionId = readAndAcknowledge ? 'msg_schema19_acknowledged' : 'msg_schema19_unread'
|
||||
db.insertMessage({
|
||||
id: questionId,
|
||||
runId: adoptedRunId,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Question',
|
||||
body: 'Continue after takeover?',
|
||||
type: 'decision_gate',
|
||||
threadId: questionId,
|
||||
payload: JSON.stringify({
|
||||
taskId: created.fixture.legacyTaskId,
|
||||
dispatchId: created.fixture.legacyDispatchId
|
||||
})
|
||||
})
|
||||
if (readAndAcknowledge) {
|
||||
db.markAsRead([questionId])
|
||||
}
|
||||
const coordinator = db.commitLegacyCompatibilityPrincipal({
|
||||
runId: adoptedRunId,
|
||||
role: 'coordinator',
|
||||
hostScope: 'local:runtime_1',
|
||||
terminalHandle: 'term_legacy_coord',
|
||||
paneKey: 'tab_coord:44444444-4444-4444-8444-444444444444',
|
||||
launchTokenHash: 'coord_launch_hash',
|
||||
processIncarnation: 'process_coord'
|
||||
}).principal
|
||||
if (readAndAcknowledge) {
|
||||
const recovery = db.getLegacyMailPage({ principalId: coordinator.id })
|
||||
expect(recovery.messages.map((message) => message.id)).toContain(questionId)
|
||||
db.acknowledgeLegacyMail({
|
||||
principalId: coordinator.id,
|
||||
messageIds: recovery.messages.map((message) => message.id)
|
||||
})
|
||||
}
|
||||
db.bindRun({
|
||||
runId: adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:11111111-1111-4111-8111-111111111111',
|
||||
takeoverLegacy: true
|
||||
})
|
||||
expect(db.getMessageById(questionId)).toMatchObject(
|
||||
readAndAcknowledge
|
||||
? { delivery_contract: 'legacy_direct', read: 1 }
|
||||
: { delivery_contract: 'current_delivery', read: 0 }
|
||||
)
|
||||
db.close()
|
||||
db = undefined
|
||||
|
||||
const raw = new Database(created.fixture.dbPath)
|
||||
raw.exec('DROP TABLE question_threads')
|
||||
raw.pragma('user_version = 19')
|
||||
raw.close()
|
||||
return { dbPath: created.fixture.dbPath, adoptedRunId, questionId }
|
||||
}
|
||||
|
||||
it('backfills a pre-question-thread ask and its accepted answer', () => {
|
||||
const created = createLegacyStorageCutoverFixture()
|
||||
tempDir = created.tempDir
|
||||
db = new OrchestrationDb(created.fixture.dbPath)
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
|
||||
db.close()
|
||||
db = undefined
|
||||
|
||||
const raw = new Database(created.fixture.dbPath)
|
||||
raw.exec(`
|
||||
DROP TABLE question_threads;
|
||||
INSERT INTO messages (
|
||||
id, run_id, delivery_contract, from_handle, to_handle,
|
||||
subject, body, type, thread_id, payload
|
||||
) VALUES (
|
||||
'msg_cutover', '${adoptedRunId}', 'legacy_direct',
|
||||
'term_legacy_worker', 'term_legacy_coord', 'Question',
|
||||
'Continue?', 'decision_gate', 'msg_cutover',
|
||||
'{"taskId":"${created.fixture.legacyTaskId}","dispatchId":"${created.fixture.legacyDispatchId}"}'
|
||||
);
|
||||
INSERT INTO messages (
|
||||
id, run_id, delivery_contract, from_handle, to_handle,
|
||||
subject, body, thread_id
|
||||
) VALUES
|
||||
(
|
||||
'msg_cross_run_decoy', 'run_decoy', 'legacy_direct',
|
||||
'term_legacy_coord', 'term_legacy_worker',
|
||||
'Re: Question', 'cross-run decoy', 'msg_cutover'
|
||||
),
|
||||
(
|
||||
'msg_self_sent_decoy', '${adoptedRunId}', 'legacy_direct',
|
||||
'term_legacy_worker', 'term_legacy_worker',
|
||||
'Re: Question', 'self-sent decoy', 'msg_cutover'
|
||||
),
|
||||
(
|
||||
'msg_cutover_answer', '${adoptedRunId}', 'legacy_direct',
|
||||
'term_legacy_coord', 'term_legacy_worker',
|
||||
'Re: Question', 'yes', 'msg_cutover'
|
||||
);
|
||||
`)
|
||||
raw.pragma('user_version = 19')
|
||||
raw.close()
|
||||
|
||||
db = new OrchestrationDb(created.fixture.dbPath)
|
||||
expect(db.getQuestion('msg_cutover')).toMatchObject({
|
||||
run_id: adoptedRunId,
|
||||
dispatch_id: created.fixture.legacyDispatchId,
|
||||
asker_handle: 'term_legacy_worker',
|
||||
status: 'answered',
|
||||
answer_message_id: 'msg_cutover_answer',
|
||||
answer_body: 'yes'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves a question pending when only cross-run and self-sent decoys exist', () => {
|
||||
const created = createLegacyStorageCutoverFixture()
|
||||
tempDir = created.tempDir
|
||||
db = new OrchestrationDb(created.fixture.dbPath)
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
|
||||
db.close()
|
||||
db = undefined
|
||||
|
||||
const raw = new Database(created.fixture.dbPath)
|
||||
raw.exec(`
|
||||
DROP TABLE question_threads;
|
||||
INSERT INTO messages (
|
||||
id, run_id, delivery_contract, from_handle, to_handle,
|
||||
subject, body, type, thread_id, payload
|
||||
) VALUES (
|
||||
'msg_pending_cutover', '${adoptedRunId}', 'legacy_direct',
|
||||
'term_legacy_worker', 'term_legacy_coord', 'Question',
|
||||
'Continue?', 'decision_gate', 'msg_pending_cutover',
|
||||
'{"taskId":"${created.fixture.legacyTaskId}","dispatchId":"${created.fixture.legacyDispatchId}"}'
|
||||
);
|
||||
INSERT INTO messages (
|
||||
id, run_id, delivery_contract, from_handle, to_handle,
|
||||
subject, body, thread_id
|
||||
) VALUES
|
||||
(
|
||||
'msg_pending_cross_run_decoy', 'run_decoy', 'legacy_direct',
|
||||
'term_legacy_coord', 'term_legacy_worker',
|
||||
'Re: Question', 'cross-run decoy', 'msg_pending_cutover'
|
||||
),
|
||||
(
|
||||
'msg_pending_self_sent_decoy', '${adoptedRunId}', 'legacy_direct',
|
||||
'term_legacy_worker', 'term_legacy_worker',
|
||||
'Re: Question', 'self-sent decoy', 'msg_pending_cutover'
|
||||
);
|
||||
`)
|
||||
raw.pragma('user_version = 19')
|
||||
raw.close()
|
||||
|
||||
db = new OrchestrationDb(created.fixture.dbPath)
|
||||
expect(db.getQuestion('msg_pending_cutover')).toMatchObject({
|
||||
run_id: adoptedRunId,
|
||||
dispatch_id: created.fixture.legacyDispatchId,
|
||||
asker_handle: 'term_legacy_worker',
|
||||
status: 'pending',
|
||||
answer_message_id: null,
|
||||
answer_body: null
|
||||
})
|
||||
})
|
||||
|
||||
it('uses task provenance to disambiguate same-handle same-second asks', () => {
|
||||
const created = createLegacyStorageCutoverFixture()
|
||||
tempDir = created.tempDir
|
||||
const raw = new Database(created.fixture.dbPath)
|
||||
raw.exec(`
|
||||
INSERT INTO tasks (
|
||||
id, run_id, spec, status, created_by_terminal_handle, created_at
|
||||
) VALUES
|
||||
('task_question_target', '${LEGACY_RUN_ID}', 'target', 'dispatched',
|
||||
'term_legacy_coord', '2026-01-01 00:00:00'),
|
||||
('task_question_other', '${LEGACY_RUN_ID}', 'other', 'dispatched',
|
||||
'term_legacy_coord', '2026-01-01 00:00:00');
|
||||
INSERT INTO dispatch_contexts (
|
||||
id, run_id, task_id, contract_version, assignee_handle,
|
||||
assignee_pane_key, status, dispatched_at, created_at
|
||||
) VALUES
|
||||
('dispatch_question_target', '${LEGACY_RUN_ID}', 'task_question_target', 0,
|
||||
'term_legacy_worker', 'tab_target:11111111-1111-4111-8111-111111111111',
|
||||
'dispatched', '2026-01-01 00:00:00', '2026-01-01 00:00:00'),
|
||||
('dispatch_question_other', '${LEGACY_RUN_ID}', 'task_question_other', 0,
|
||||
'term_legacy_worker', 'tab_other:22222222-2222-4222-8222-222222222222',
|
||||
'dispatched', '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||
INSERT INTO messages (
|
||||
id, run_id, delivery_contract, from_handle, to_handle,
|
||||
subject, body, type, payload, created_at
|
||||
) VALUES (
|
||||
'msg_question_task_only', '${LEGACY_RUN_ID}', 'legacy_direct',
|
||||
'term_legacy_worker', 'term_legacy_coord', 'Question', 'Continue?',
|
||||
'decision_gate', '{"taskId":"task_question_target","question":"Continue?","options":[]}',
|
||||
'2026-01-01 00:00:00'
|
||||
);
|
||||
`)
|
||||
raw.close()
|
||||
|
||||
db = new OrchestrationDb(created.fixture.dbPath)
|
||||
expect(db.getQuestion('msg_question_task_only')).toMatchObject({
|
||||
dispatch_id: 'dispatch_question_target',
|
||||
asker_handle: 'term_legacy_worker',
|
||||
status: 'pending'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['promoted unread', false],
|
||||
['acknowledged recovery', true]
|
||||
] as const)('backfills and routes a %s v19 question after takeover', (_label, acknowledged) => {
|
||||
const state = prepareTakenOverSchema19Question(acknowledged)
|
||||
|
||||
db = new OrchestrationDb(state.dbPath)
|
||||
expect(db.getQuestion(state.questionId)).toMatchObject({
|
||||
run_id: state.adoptedRunId,
|
||||
status: 'pending'
|
||||
})
|
||||
expect(db.getMessageById(state.questionId)).toMatchObject({
|
||||
to_handle: `run:${state.adoptedRunId}`,
|
||||
delivery_contract: 'current_delivery',
|
||||
read: 0
|
||||
})
|
||||
const run = db.getRun(state.adoptedRunId)!
|
||||
expect(
|
||||
db
|
||||
.getOrCreateRunDelivery({
|
||||
runId: state.adoptedRunId,
|
||||
consumerGeneration: run.consumer_generation
|
||||
})
|
||||
?.messages.map((message) => message.id)
|
||||
).toContain(state.questionId)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,751 @@
|
||||
import { rmSync } from 'node:fs'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import {
|
||||
CURRENT_CONTRACT_VERSION,
|
||||
LEGACY_CONTRACT_VERSION,
|
||||
LEGACY_RUN_ID,
|
||||
OrchestrationDb
|
||||
} from './db'
|
||||
import {
|
||||
createLegacyStorageCutoverFixture,
|
||||
type LegacyStorageCutoverFixture
|
||||
} from './orchestration-legacy-storage-test-fixture'
|
||||
|
||||
describe('OrchestrationDb legacy contract storage', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
let tempDir: string | undefined
|
||||
|
||||
afterEach(() => {
|
||||
db?.close()
|
||||
db = undefined
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
tempDir = undefined
|
||||
}
|
||||
})
|
||||
|
||||
function createCutoverFixture(): LegacyStorageCutoverFixture {
|
||||
const created = createLegacyStorageCutoverFixture()
|
||||
tempDir = created.tempDir
|
||||
return created.fixture
|
||||
}
|
||||
|
||||
function openAdoptedFixture(): {
|
||||
fixture: LegacyStorageCutoverFixture
|
||||
adoptedRunId: string
|
||||
workerPrincipalId: string
|
||||
coordinatorPrincipalId: string
|
||||
} {
|
||||
const fixture = createCutoverFixture()
|
||||
db = new OrchestrationDb(fixture.dbPath)
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
|
||||
const worker = db.commitLegacyCompatibilityPrincipal({
|
||||
runId: adoptedRunId,
|
||||
dispatchId: fixture.legacyDispatchId,
|
||||
role: 'worker',
|
||||
hostScope: 'local:runtime_1',
|
||||
terminalHandle: 'term_legacy_worker',
|
||||
paneKey: 'tab_legacy:33333333-3333-4333-8333-333333333333',
|
||||
launchTokenHash: 'legacy_launch_hash',
|
||||
processIncarnation: 'process_1'
|
||||
})
|
||||
const coordinator = db.commitLegacyCompatibilityPrincipal({
|
||||
runId: adoptedRunId,
|
||||
role: 'coordinator',
|
||||
hostScope: 'local:runtime_1',
|
||||
terminalHandle: 'term_legacy_coord',
|
||||
paneKey: 'tab_coord:44444444-4444-4444-8444-444444444444',
|
||||
launchTokenHash: 'coord_launch_hash',
|
||||
processIncarnation: 'process_coord'
|
||||
})
|
||||
return {
|
||||
fixture,
|
||||
adoptedRunId,
|
||||
workerPrincipalId: worker.principal.id,
|
||||
coordinatorPrincipalId: coordinator.principal.id
|
||||
}
|
||||
}
|
||||
|
||||
it('atomically rehomes the full graph, fences legacy Delivery, and preserves current rows', () => {
|
||||
const fixture = createCutoverFixture()
|
||||
db = new OrchestrationDb(fixture.dbPath)
|
||||
const adoption = db.getLegacyAdoption()
|
||||
const adoptedRunId = adoption?.adopted_run_id as string
|
||||
const sqlite = (db as unknown as { db: Database.Database }).db
|
||||
|
||||
expect(adoption).toMatchObject({
|
||||
source_run_id: LEGACY_RUN_ID,
|
||||
scheduler_state_lost: 1
|
||||
})
|
||||
expect(db.getRun(adoptedRunId)).toMatchObject({ legacy: 0, consumer_generation: 0 })
|
||||
expect(db.listTasks({ runId: LEGACY_RUN_ID })).toEqual([])
|
||||
expect(db.getDispatchContextById(fixture.legacyDispatchId)).toMatchObject({
|
||||
run_id: adoptedRunId,
|
||||
contract_version: LEGACY_CONTRACT_VERSION,
|
||||
launch_token_hash: null
|
||||
})
|
||||
expect(db.getGate(fixture.legacyGateId)).toMatchObject({ run_id: adoptedRunId })
|
||||
expect(db.getQuestion(fixture.legacyQuestionId)).toMatchObject({ run_id: adoptedRunId })
|
||||
expect(db.getMessageById(fixture.legacyMessageIds[0])).toMatchObject({
|
||||
run_id: adoptedRunId,
|
||||
delivery_contract: 'legacy_direct'
|
||||
})
|
||||
expect(db.getMessageById(fixture.rejectionMessageId)).toMatchObject({
|
||||
run_id: adoptedRunId,
|
||||
delivery_contract: 'audit_only'
|
||||
})
|
||||
expect(db.getMessageById(fixture.lookalikeMessageId)).toMatchObject({
|
||||
run_id: adoptedRunId,
|
||||
delivery_contract: 'legacy_direct'
|
||||
})
|
||||
for (const messageId of fixture.malformedRejectionMessageIds) {
|
||||
expect(db.getMessageById(messageId)).toMatchObject({
|
||||
run_id: adoptedRunId,
|
||||
delivery_contract: 'legacy_direct'
|
||||
})
|
||||
}
|
||||
expect(
|
||||
sqlite.prepare('SELECT * FROM deliveries WHERE id = ?').get(fixture.legacyDeliveryId)
|
||||
).toMatchObject({ run_id: adoptedRunId, status: 'fenced' })
|
||||
expect(db.getDispatchContextById(fixture.currentDispatchId)).toMatchObject({
|
||||
run_id: fixture.currentRunId,
|
||||
contract_version: CURRENT_CONTRACT_VERSION,
|
||||
launch_token_hash: 'current_launch_hash'
|
||||
})
|
||||
|
||||
db.close()
|
||||
const partial = new Database(fixture.dbPath)
|
||||
partial
|
||||
.prepare('UPDATE tasks SET run_id = ? WHERE id = ?')
|
||||
.run(LEGACY_RUN_ID, fixture.legacyTaskId)
|
||||
partial.pragma('user_version = 19')
|
||||
partial.close()
|
||||
db = new OrchestrationDb(fixture.dbPath)
|
||||
expect(db.getLegacyAdoption()?.adopted_run_id).toBe(adoptedRunId)
|
||||
expect(db.getTask(fixture.legacyTaskId)?.run_id).toBe(adoptedRunId)
|
||||
expect(db.getDispatchContextById(fixture.currentDispatchId)?.contract_version).toBe(
|
||||
CURRENT_CONTRACT_VERSION
|
||||
)
|
||||
expect(db.listTasks({ runId: LEGACY_RUN_ID })).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores unrelated cross-Run anomalies while validating adopted rows', () => {
|
||||
const fixture = createCutoverFixture()
|
||||
const raw = new Database(fixture.dbPath)
|
||||
raw
|
||||
.prepare('UPDATE dispatch_contexts SET run_id = ? WHERE id = ?')
|
||||
.run(fixture.unrelatedRunId, fixture.currentDispatchId)
|
||||
raw.close()
|
||||
|
||||
db = new OrchestrationDb(fixture.dbPath)
|
||||
|
||||
expect(db.getLegacyAdoption()).toBeDefined()
|
||||
expect(db.getDispatchContextById(fixture.currentDispatchId)?.run_id).toBe(
|
||||
fixture.unrelatedRunId
|
||||
)
|
||||
})
|
||||
|
||||
it('does not synthesize an adopted Run or compatibility authority for a fresh database', () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
|
||||
expect(db.getLegacyAdoption()).toBeUndefined()
|
||||
expect(db.listLegacyCompatibilityPrincipals(LEGACY_RUN_ID)).toEqual([])
|
||||
expect(db.listRuns()).toEqual([expect.objectContaining({ id: LEGACY_RUN_ID, legacy: 1 })])
|
||||
})
|
||||
|
||||
it('keeps current Delivery disjoint from adopted direct and audit-only mail', () => {
|
||||
const state = openAdoptedFixture()
|
||||
const run = db!.getRun(state.adoptedRunId) as NonNullable<ReturnType<OrchestrationDb['getRun']>>
|
||||
db!.insertMessage({
|
||||
runId: state.adoptedRunId,
|
||||
from: 'current_worker',
|
||||
to: `run:${state.adoptedRunId}`,
|
||||
subject: 'current retry mail'
|
||||
})
|
||||
|
||||
const delivery = db!.getOrCreateRunDelivery({
|
||||
runId: state.adoptedRunId,
|
||||
consumerGeneration: run.consumer_generation
|
||||
})
|
||||
|
||||
expect(delivery?.messages.map((message) => message.subject)).toEqual(['current retry mail'])
|
||||
})
|
||||
|
||||
it('drains a durable recovery cohort in bounded replaying pages before unread mail', () => {
|
||||
const state = openAdoptedFixture()
|
||||
const first = db!.getLegacyMailPage({ principalId: state.workerPrincipalId, limit: 1 })
|
||||
const replay = db!.getLegacyMailPage({ principalId: state.workerPrincipalId, limit: 1 })
|
||||
|
||||
expect(first.recovery).toBe(true)
|
||||
expect(replay.messages.map((message) => message.id)).toEqual(
|
||||
first.messages.map((message) => message.id)
|
||||
)
|
||||
expect(() =>
|
||||
db!.acknowledgeLegacyMail({
|
||||
principalId: state.workerPrincipalId,
|
||||
messageIds: [state.fixture.legacyMessageIds[2]]
|
||||
})
|
||||
).toThrow(/current replay page/)
|
||||
db!.acknowledgeLegacyMail({
|
||||
principalId: state.workerPrincipalId,
|
||||
messageIds: first.messages.map((message) => message.id)
|
||||
})
|
||||
const second = db!.getLegacyMailPage({ principalId: state.workerPrincipalId, limit: 1 })
|
||||
expect(second.recovery).toBe(true)
|
||||
expect(second.messages[0].id).not.toBe(first.messages[0].id)
|
||||
db!.acknowledgeLegacyMail({
|
||||
principalId: state.workerPrincipalId,
|
||||
messageIds: second.messages.map((message) => message.id)
|
||||
})
|
||||
expect(db!.getLegacyMailPage({ principalId: state.workerPrincipalId, limit: 1 }).recovery).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('returns complete addressed legacy history without changing read state', () => {
|
||||
const state = openAdoptedFixture()
|
||||
const unread = db!.insertMessage({
|
||||
runId: state.adoptedRunId,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: 'term_legacy_coord',
|
||||
to: 'term_legacy_worker',
|
||||
subject: 'unread history'
|
||||
})
|
||||
db!.insertMessage({
|
||||
runId: state.adoptedRunId,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: 'other',
|
||||
to: 'other',
|
||||
subject: 'not addressed'
|
||||
})
|
||||
|
||||
const history = db!.getLegacyMailHistory({ principalId: state.workerPrincipalId })
|
||||
|
||||
expect(history.recovery).toBe(false)
|
||||
expect(history.messages.map((message) => message.subject)).toEqual([
|
||||
'read worker mail',
|
||||
'second worker page',
|
||||
'unread history'
|
||||
])
|
||||
expect(db!.getMessageById(unread.id)?.read).toBe(0)
|
||||
})
|
||||
|
||||
it('resolves legacy principals and completion evidence only within exact assignments', () => {
|
||||
const state = openAdoptedFixture()
|
||||
const taskId = db!.getDispatchContextById(state.fixture.legacyDispatchId)!.task_id
|
||||
const payload = JSON.stringify({ taskId, dispatchId: state.fixture.legacyDispatchId })
|
||||
const completion = db!.insertMessage({
|
||||
runId: state.adoptedRunId,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Completed',
|
||||
body: 'done',
|
||||
type: 'worker_done',
|
||||
payload
|
||||
})
|
||||
|
||||
expect(
|
||||
db!.resolveLegacyWorkerCandidate({
|
||||
runId: state.adoptedRunId,
|
||||
terminalHandle: 'term_legacy_worker',
|
||||
paneKey: 'tab_reminted:33333333-3333-4333-8333-333333333333',
|
||||
taskId
|
||||
})
|
||||
).toMatchObject({ dispatch: { id: state.fixture.legacyDispatchId } })
|
||||
expect(
|
||||
db!.resolveLegacyWorkerCandidate({
|
||||
runId: state.adoptedRunId,
|
||||
terminalHandle: 'term_legacy_worker',
|
||||
paneKey: 'tab_wrong:99999999-9999-4999-8999-999999999999',
|
||||
taskId
|
||||
})
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
db!.resolveLegacyCoordinatorCandidate({
|
||||
runId: state.adoptedRunId,
|
||||
terminalHandle: 'term_legacy_coord',
|
||||
paneKey: 'tab_coord:44444444-4444-4444-8444-444444444444'
|
||||
})
|
||||
).toMatchObject({ terminalHandle: 'term_legacy_coord' })
|
||||
expect(
|
||||
db!.findLegacyWorkerCompletion({
|
||||
principalId: state.workerPrincipalId,
|
||||
taskId,
|
||||
recipientHandle: 'term_legacy_coord',
|
||||
subject: 'Completed',
|
||||
body: 'done',
|
||||
payload
|
||||
})
|
||||
).toMatchObject({ id: completion.id })
|
||||
})
|
||||
|
||||
it('prevents an unproven coordinator from taking over an active adopted Run', () => {
|
||||
const state = openAdoptedFixture()
|
||||
expect(() =>
|
||||
db!.bindRun({
|
||||
runId: state.adoptedRunId,
|
||||
coordinatorHandle: 'term_other',
|
||||
coordinatorPaneKey: 'tab_other:55555555-5555-4555-8555-555555555555'
|
||||
})
|
||||
).toThrow(/attested coordinator may rebind/)
|
||||
|
||||
const bound = db!.bindRun({
|
||||
runId: state.adoptedRunId,
|
||||
coordinatorHandle: 'term_legacy_coord',
|
||||
coordinatorPaneKey: 'tab_coord:44444444-4444-4444-8444-444444444444',
|
||||
legacyCoordinatorAuthority: {
|
||||
runId: state.adoptedRunId,
|
||||
principalId: state.coordinatorPrincipalId,
|
||||
terminalHandle: 'term_legacy_coord',
|
||||
paneKey: 'tab_coord:44444444-4444-4444-8444-444444444444',
|
||||
consumerGeneration: 0
|
||||
}
|
||||
})
|
||||
expect(bound).toMatchObject({ coordinator_handle: 'term_legacy_coord' })
|
||||
expect(
|
||||
db!.bindRun({
|
||||
runId: state.adoptedRunId,
|
||||
coordinatorHandle: 'term_legacy_coord',
|
||||
coordinatorPaneKey: 'tab_coord:44444444-4444-4444-8444-444444444444'
|
||||
})
|
||||
).toMatchObject({ coordinator_handle: 'term_legacy_coord' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['generation', { consumerGeneration: 1 }],
|
||||
['principal', { principalId: 'legacy_principal_wrong' }],
|
||||
['handle', { terminalHandle: 'term_wrong' }],
|
||||
['pane', { paneKey: 'tab_wrong:99999999-9999-4999-8999-999999999999' }]
|
||||
] as const)('rejects stale legacy coordinator %s proof inside Run binding', (_label, patch) => {
|
||||
const state = openAdoptedFixture()
|
||||
const before = db!.getRun(state.adoptedRunId)
|
||||
|
||||
expect(() =>
|
||||
db!.bindRun({
|
||||
runId: state.adoptedRunId,
|
||||
coordinatorHandle: 'term_legacy_coord',
|
||||
coordinatorPaneKey: 'tab_coord:44444444-4444-4444-8444-444444444444',
|
||||
legacyCoordinatorAuthority: {
|
||||
runId: state.adoptedRunId,
|
||||
principalId: state.coordinatorPrincipalId,
|
||||
terminalHandle: 'term_legacy_coord',
|
||||
paneKey: 'tab_coord:44444444-4444-4444-8444-444444444444',
|
||||
consumerGeneration: 0,
|
||||
...patch
|
||||
}
|
||||
})
|
||||
).toThrow(/no longer has lifecycle authority/)
|
||||
expect(db!.getRun(state.adoptedRunId)).toEqual(before)
|
||||
})
|
||||
|
||||
it('revokes the legacy coordinator when a current coordinator takes over after settlement', () => {
|
||||
const state = openAdoptedFixture()
|
||||
db!.settleWorkerReport({
|
||||
taskId: state.fixture.legacyTaskId,
|
||||
dispatchId: state.fixture.legacyDispatchId,
|
||||
outcome: 'succeeded',
|
||||
result: 'done'
|
||||
})
|
||||
|
||||
expect(
|
||||
db!.bindRun({
|
||||
runId: state.adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:11111111-1111-4111-8111-111111111111'
|
||||
})
|
||||
).toMatchObject({ coordinator_handle: 'term_current_coord' })
|
||||
expect(db!.getLegacyCompatibilityPrincipal(state.coordinatorPrincipalId)?.status).toBe(
|
||||
'revoked'
|
||||
)
|
||||
expect(() =>
|
||||
db!.commitLegacyCompatibilityPrincipal({
|
||||
runId: state.adoptedRunId,
|
||||
role: 'coordinator',
|
||||
hostScope: 'local:runtime_1',
|
||||
terminalHandle: 'term_legacy_coord',
|
||||
paneKey: 'tab_coord:44444444-4444-4444-8444-444444444444',
|
||||
launchTokenHash: 'coord_launch_hash',
|
||||
processIncarnation: 'process_coord'
|
||||
})
|
||||
).toThrow(/revoked/)
|
||||
})
|
||||
|
||||
it('promotes only mail addressed to the replaced legacy coordinator', () => {
|
||||
const state = openAdoptedFixture()
|
||||
const coordinatorMail = db!.insertMessage({
|
||||
runId: state.adoptedRunId,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'coordinator outcome'
|
||||
})
|
||||
const workerMail = db!.insertMessage({
|
||||
runId: state.adoptedRunId,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_other_worker',
|
||||
subject: 'worker-only guidance'
|
||||
})
|
||||
|
||||
db!.bindRun({
|
||||
runId: state.adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:11111111-1111-4111-8111-111111111111',
|
||||
takeoverLegacy: true
|
||||
})
|
||||
|
||||
expect(db!.getMessageById(coordinatorMail.id)).toMatchObject({
|
||||
to_handle: `run:${state.adoptedRunId}`,
|
||||
delivery_contract: 'current_delivery'
|
||||
})
|
||||
expect(db!.getMessageById(workerMail.id)).toMatchObject({
|
||||
to_handle: 'term_other_worker',
|
||||
delivery_contract: 'legacy_direct'
|
||||
})
|
||||
})
|
||||
|
||||
it('commits legacy messages, lifecycle effects, and invocation receipts exactly once', () => {
|
||||
const state = openAdoptedFixture()
|
||||
const params = {
|
||||
principalId: state.workerPrincipalId,
|
||||
operationKey: 'invocation_1',
|
||||
method: 'orchestration.send',
|
||||
payloadHash: 'payload_1',
|
||||
message: {
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'alive',
|
||||
type: 'heartbeat' as const
|
||||
},
|
||||
lifecycle: { kind: 'heartbeat' as const, at: '2026-07-28T12:00:00.000Z' }
|
||||
}
|
||||
|
||||
const first = db!.commitLegacyLifecycleOperation(params)
|
||||
const replay = db!.commitLegacyLifecycleOperation(params)
|
||||
|
||||
expect(replay).toMatchObject({
|
||||
duplicate: true,
|
||||
message: { id: first.message.id },
|
||||
receipt: { effect_id: first.message.id }
|
||||
})
|
||||
expect(db!.getDispatchContextById(state.fixture.legacyDispatchId)?.last_heartbeat_at).toBe(
|
||||
'2026-07-28T12:00:00.000Z'
|
||||
)
|
||||
expect(db!.getUnreadMessages('term_legacy_coord')).toEqual([])
|
||||
expect(db!.getUndeliveredUnreadMessages('term_legacy_coord')).toEqual([])
|
||||
expect(() =>
|
||||
db!.commitLegacyLifecycleOperation({ ...params, payloadHash: 'different' })
|
||||
).toThrow(/different input/)
|
||||
})
|
||||
|
||||
it('reconstructs a matching pre-receipt settlement without changing its persisted outcome', () => {
|
||||
const state = openAdoptedFixture()
|
||||
const accepted = db!.insertMessage({
|
||||
runId: state.adoptedRunId,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Completed',
|
||||
type: 'worker_done'
|
||||
})
|
||||
expect(
|
||||
db!.settleWorkerReport({
|
||||
taskId: db!.getDispatchContextById(state.fixture.legacyDispatchId)!.task_id,
|
||||
dispatchId: state.fixture.legacyDispatchId,
|
||||
outcome: 'succeeded',
|
||||
result: 'accepted by A'
|
||||
})
|
||||
).toMatchObject({ action: 'settled', duplicate: false })
|
||||
|
||||
const reconstructed = db!.commitLegacyLifecycleOperation({
|
||||
principalId: state.workerPrincipalId,
|
||||
operationKey: `settlement:${state.fixture.legacyDispatchId}`,
|
||||
method: 'orchestration.send',
|
||||
payloadHash: 'settlement_payload',
|
||||
message: {
|
||||
existingId: accepted.id,
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Completed',
|
||||
type: 'worker_done'
|
||||
},
|
||||
lifecycle: {
|
||||
kind: 'worker_report',
|
||||
taskId: db!.getDispatchContextById(state.fixture.legacyDispatchId)!.task_id,
|
||||
outcome: 'succeeded',
|
||||
result: 'accepted by A'
|
||||
}
|
||||
})
|
||||
|
||||
expect(reconstructed).toMatchObject({
|
||||
duplicate: false,
|
||||
message: { id: accepted.id },
|
||||
settlement: { action: 'settled', outcome: 'succeeded', duplicate: true }
|
||||
})
|
||||
expect(db!.getLegacyCompatibilityPrincipal(state.workerPrincipalId)?.status).toBe('settled')
|
||||
})
|
||||
|
||||
it('reconstructs a read pre-takeover completion through its original legacy route', () => {
|
||||
const state = openAdoptedFixture()
|
||||
const taskId = db!.getDispatchContextById(state.fixture.legacyDispatchId)!.task_id
|
||||
const accepted = db!.insertMessage({
|
||||
runId: state.adoptedRunId,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Completed',
|
||||
body: 'accepted before takeover',
|
||||
type: 'worker_done'
|
||||
})
|
||||
db!.markAsRead([accepted.id])
|
||||
db!.settleWorkerReport({
|
||||
taskId,
|
||||
dispatchId: state.fixture.legacyDispatchId,
|
||||
outcome: 'succeeded',
|
||||
result: 'accepted before takeover'
|
||||
})
|
||||
db!.bindRun({
|
||||
runId: state.adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:11111111-1111-4111-8111-111111111111',
|
||||
takeoverLegacy: true
|
||||
})
|
||||
const beforeIds = db!.getInbox(100).map((message) => message.id)
|
||||
|
||||
const reconstructed = db!.commitLegacyLifecycleOperation({
|
||||
principalId: state.workerPrincipalId,
|
||||
operationKey: 'read_completion_after_takeover',
|
||||
method: 'orchestration.send',
|
||||
payloadHash: 'read_completion_after_takeover_payload',
|
||||
message: {
|
||||
existingId: accepted.id,
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Completed',
|
||||
body: 'accepted before takeover',
|
||||
type: 'worker_done'
|
||||
},
|
||||
lifecycle: {
|
||||
kind: 'worker_report',
|
||||
taskId,
|
||||
outcome: 'succeeded',
|
||||
result: 'accepted before takeover'
|
||||
}
|
||||
})
|
||||
|
||||
expect(reconstructed).toMatchObject({
|
||||
duplicate: false,
|
||||
message: {
|
||||
id: accepted.id,
|
||||
to_handle: 'term_legacy_coord',
|
||||
delivery_contract: 'legacy_direct'
|
||||
},
|
||||
settlement: { action: 'settled', outcome: 'succeeded', duplicate: true }
|
||||
})
|
||||
expect(db!.getInbox(100).map((message) => message.id)).toEqual(beforeIds)
|
||||
})
|
||||
|
||||
it('rejects cross-cutover completion reconstruction for another recipient', () => {
|
||||
const state = openAdoptedFixture()
|
||||
const taskId = db!.getDispatchContextById(state.fixture.legacyDispatchId)!.task_id
|
||||
const payload = JSON.stringify({ taskId, dispatchId: state.fixture.legacyDispatchId })
|
||||
const foreign = db!.insertMessage({
|
||||
runId: state.adoptedRunId,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_other_coord',
|
||||
subject: 'Completed',
|
||||
body: 'accepted elsewhere',
|
||||
type: 'worker_done',
|
||||
payload
|
||||
})
|
||||
db!.settleWorkerReport({
|
||||
taskId,
|
||||
dispatchId: state.fixture.legacyDispatchId,
|
||||
outcome: 'succeeded',
|
||||
result: 'accepted elsewhere'
|
||||
})
|
||||
const beforeIds = db!.getInbox(100).map((message) => message.id)
|
||||
|
||||
expect(
|
||||
db!.findLegacyWorkerCompletion({
|
||||
principalId: state.workerPrincipalId,
|
||||
taskId,
|
||||
recipientHandle: 'term_legacy_coord',
|
||||
subject: 'Completed',
|
||||
body: 'accepted elsewhere',
|
||||
payload
|
||||
})
|
||||
).toBeUndefined()
|
||||
expect(() =>
|
||||
db!.commitLegacyLifecycleOperation({
|
||||
principalId: state.workerPrincipalId,
|
||||
operationKey: 'wrong_recipient_retry',
|
||||
method: 'orchestration.send',
|
||||
payloadHash: 'wrong_recipient_retry_payload',
|
||||
message: {
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Completed',
|
||||
body: 'accepted elsewhere',
|
||||
type: 'worker_done',
|
||||
payload
|
||||
},
|
||||
lifecycle: {
|
||||
kind: 'worker_report',
|
||||
taskId,
|
||||
outcome: 'succeeded',
|
||||
result: 'accepted elsewhere'
|
||||
}
|
||||
})
|
||||
).toThrow(/settled/)
|
||||
expect(() =>
|
||||
db!.commitLegacyLifecycleOperation({
|
||||
principalId: state.workerPrincipalId,
|
||||
operationKey: 'wrong_recipient_reconstruction',
|
||||
method: 'orchestration.send',
|
||||
payloadHash: 'wrong_recipient_payload',
|
||||
message: {
|
||||
existingId: foreign.id,
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Completed',
|
||||
body: 'accepted elsewhere',
|
||||
type: 'worker_done',
|
||||
payload
|
||||
},
|
||||
lifecycle: {
|
||||
kind: 'worker_report',
|
||||
taskId,
|
||||
outcome: 'succeeded',
|
||||
result: 'accepted elsewhere'
|
||||
}
|
||||
})
|
||||
).toThrow(/does not match this principal/)
|
||||
expect(db!.getInbox(100).map((message) => message.id)).toEqual(beforeIds)
|
||||
expect(
|
||||
db!.getLegacyOperationReceipt(state.workerPrincipalId, 'wrong_recipient_reconstruction')
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
db!.getLegacyOperationReceipt(state.workerPrincipalId, 'wrong_recipient_retry')
|
||||
).toBeUndefined()
|
||||
expect(db!.getTask(taskId)).toMatchObject({
|
||||
status: 'completed',
|
||||
result: 'accepted elsewhere'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses invocation identity for repeated asks and atomically conflicts divergent replies', () => {
|
||||
const state = openAdoptedFixture()
|
||||
const ask = {
|
||||
principalId: state.workerPrincipalId,
|
||||
operationKey: 'ask_invocation_1',
|
||||
method: 'orchestration.ask',
|
||||
payloadHash: 'ask_payload',
|
||||
question: 'Same text?',
|
||||
options: ['yes', 'no'],
|
||||
recipientHandle: 'term_legacy_coord'
|
||||
}
|
||||
const first = db!.commitLegacyAskOperation(ask)
|
||||
const replay = db!.commitLegacyAskOperation(ask)
|
||||
const repeated = db!.commitLegacyAskOperation({
|
||||
...ask,
|
||||
operationKey: 'ask_invocation_2'
|
||||
})
|
||||
|
||||
expect(replay).toMatchObject({ duplicate: true, question: { message_id: first.message.id } })
|
||||
expect(repeated.message.id).not.toBe(first.message.id)
|
||||
expect(db!.findPendingLegacyQuestions(ask)).toHaveLength(2)
|
||||
expect(
|
||||
db!.findPendingLegacyQuestions({
|
||||
...ask,
|
||||
question: ' Same text?\r\n',
|
||||
options: ['yes ', ' no']
|
||||
})
|
||||
).toHaveLength(2)
|
||||
|
||||
const inherited = db!.createQuestion({
|
||||
runId: state.adoptedRunId,
|
||||
dispatchId: state.fixture.legacyDispatchId,
|
||||
askerHandle: 'term_legacy_worker',
|
||||
question: 'Inherited?'
|
||||
})
|
||||
const sqlite = (db as unknown as { db: Database.Database }).db
|
||||
sqlite
|
||||
.prepare(
|
||||
`UPDATE messages
|
||||
SET from_handle = 'term_legacy_worker', to_handle = 'term_legacy_coord',
|
||||
delivery_contract = 'legacy_direct'
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(inherited.message.id)
|
||||
const inheritedAsk = {
|
||||
...ask,
|
||||
operationKey: 'ask_inherited_1',
|
||||
payloadHash: 'ask_inherited_payload_1',
|
||||
question: 'Inherited?',
|
||||
options: [],
|
||||
existingQuestionId: inherited.message.id
|
||||
}
|
||||
const adopted = db!.commitLegacyAskOperation(inheritedAsk)
|
||||
const distinct = db!.commitLegacyAskOperation({
|
||||
...inheritedAsk,
|
||||
operationKey: 'ask_inherited_2',
|
||||
payloadHash: 'ask_inherited_payload_2'
|
||||
})
|
||||
expect(adopted.message.id).toBe(inherited.message.id)
|
||||
expect(distinct.message.id).not.toBe(inherited.message.id)
|
||||
|
||||
const reply = {
|
||||
principalId: state.coordinatorPrincipalId,
|
||||
operationKey: 'reply_invocation_1',
|
||||
method: 'orchestration.reply',
|
||||
payloadHash: 'reply_payload',
|
||||
questionId: first.message.id,
|
||||
body: 'yes'
|
||||
}
|
||||
const answered = db!.commitLegacyReplyOperation(reply)
|
||||
const answerReplay = db!.commitLegacyReplyOperation(reply)
|
||||
expect(answerReplay).toMatchObject({
|
||||
duplicate: true,
|
||||
message: { id: answered.message.id }
|
||||
})
|
||||
expect(
|
||||
db!
|
||||
.findLegacyQuestionsBySemanticIdentity(ask)
|
||||
.find((row) => row.question.message_id === first.message.id)
|
||||
).toMatchObject({ question: { status: 'answered' }, answerAcknowledged: false })
|
||||
db!.acknowledgeLegacyQuestionAnswer({
|
||||
principalId: state.workerPrincipalId,
|
||||
questionId: first.message.id,
|
||||
answerMessageId: answered.message.id
|
||||
})
|
||||
expect(
|
||||
db!
|
||||
.findLegacyQuestionsBySemanticIdentity(ask)
|
||||
.find((row) => row.question.message_id === first.message.id)
|
||||
).toMatchObject({ answerAcknowledged: true })
|
||||
expect(() =>
|
||||
db!.commitLegacyReplyOperation({
|
||||
...reply,
|
||||
operationKey: 'reply_invocation_2',
|
||||
payloadHash: 'different_reply',
|
||||
body: 'no'
|
||||
})
|
||||
).toThrow(/different answer/)
|
||||
|
||||
const currentTask = db!.createTask({ runId: state.adoptedRunId, spec: 'current retry' })
|
||||
const currentDispatch = db!.createDispatchContext(currentTask.id, 'term_current_retry')
|
||||
const currentQuestion = db!.createQuestion({
|
||||
runId: state.adoptedRunId,
|
||||
dispatchId: currentDispatch.id,
|
||||
askerHandle: 'term_current_retry',
|
||||
question: 'Current question?'
|
||||
})
|
||||
expect(() =>
|
||||
db!.commitLegacyReplyOperation({
|
||||
...reply,
|
||||
operationKey: 'reply_current_question',
|
||||
payloadHash: 'current_question',
|
||||
questionId: currentQuestion.message.id
|
||||
})
|
||||
).toThrow(/not actionable/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,206 @@
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { LEGACY_RUN_ID, OrchestrationDb } from './db'
|
||||
|
||||
export type LegacyStorageCutoverFixture = {
|
||||
dbPath: string
|
||||
currentRunId: string
|
||||
unrelatedRunId: string
|
||||
currentDispatchId: string
|
||||
legacyTaskId: string
|
||||
legacyDispatchId: string
|
||||
legacyGateId: string
|
||||
legacyMessageIds: string[]
|
||||
legacyQuestionId: string
|
||||
legacyDeliveryId: string
|
||||
rejectionMessageId: string
|
||||
lookalikeMessageId: string
|
||||
malformedRejectionMessageIds: string[]
|
||||
}
|
||||
|
||||
export function createLegacyStorageCutoverFixture(): {
|
||||
fixture: LegacyStorageCutoverFixture
|
||||
tempDir: string
|
||||
} {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'orca-legacy-storage-'))
|
||||
const dbPath = join(tempDir, 'orchestration.db')
|
||||
const first = new OrchestrationDb(dbPath)
|
||||
const currentRun = first.createRun({
|
||||
objective: 'Current work',
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:11111111-1111-4111-8111-111111111111'
|
||||
})
|
||||
const currentTask = first.createTask({ spec: 'current', runId: currentRun.id })
|
||||
const unrelatedRun = first.createRun({
|
||||
objective: 'Unrelated current work',
|
||||
coordinatorHandle: 'term_unrelated_coord',
|
||||
coordinatorPaneKey: 'tab_unrelated:55555555-5555-4555-8555-555555555555'
|
||||
})
|
||||
const currentDispatch = first.createDispatchContext(
|
||||
currentTask.id,
|
||||
'term_current_worker',
|
||||
'tab_current:22222222-2222-4222-9222-222222222222',
|
||||
'current_launch_hash'
|
||||
)
|
||||
first.insertMessage({
|
||||
runId: currentRun.id,
|
||||
from: 'term_current_worker',
|
||||
to: `run:${currentRun.id}`,
|
||||
subject: 'current mail'
|
||||
})
|
||||
|
||||
const legacyTask = first.createTask({
|
||||
spec: 'legacy',
|
||||
createdByTerminalHandle: 'term_legacy_coord'
|
||||
})
|
||||
first.createDispatchContext(
|
||||
legacyTask.id,
|
||||
'term_legacy_worker',
|
||||
'tab_legacy:33333333-3333-4333-8333-333333333333'
|
||||
)
|
||||
const legacyGate = first.createGate({
|
||||
taskId: legacyTask.id,
|
||||
question: 'Retained gate?'
|
||||
})
|
||||
first.resolveGate(legacyGate.id, 'continue')
|
||||
const retryDispatch = first.createDispatchContext(
|
||||
legacyTask.id,
|
||||
'term_legacy_worker',
|
||||
'tab_legacy:33333333-3333-4333-8333-333333333333'
|
||||
)
|
||||
const legacyMessages = [
|
||||
first.insertMessage({
|
||||
from: 'term_legacy_coord',
|
||||
to: 'term_legacy_worker',
|
||||
subject: 'read worker mail'
|
||||
}),
|
||||
first.insertMessage({
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'read coordinator mail'
|
||||
}),
|
||||
first.insertMessage({
|
||||
from: 'term_legacy_coord',
|
||||
to: 'term_legacy_worker',
|
||||
subject: 'second worker page'
|
||||
})
|
||||
]
|
||||
first.markAsRead(legacyMessages.map((message) => message.id))
|
||||
const question = first.createQuestion({
|
||||
runId: LEGACY_RUN_ID,
|
||||
dispatchId: retryDispatch.id,
|
||||
askerHandle: 'term_legacy_worker',
|
||||
question: 'Retained question?'
|
||||
})
|
||||
const rejection = first.insertMessage({
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Rejected heartbeat',
|
||||
type: 'heartbeat',
|
||||
payload: JSON.stringify({ _orcaLifecycleRejection: { code: 'migration', reason: 'cutover' } })
|
||||
})
|
||||
const lookalike = first.insertMessage({
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Ordinary legacy mail',
|
||||
payload: JSON.stringify({
|
||||
userData: { _orcaLifecycleRejection: { code: 'not-a-top-level-audit-marker' } }
|
||||
})
|
||||
})
|
||||
const malformedRejections = [
|
||||
first.insertMessage({
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Invalid JSON marker',
|
||||
payload: '{"_orcaLifecycleRejection":'
|
||||
}),
|
||||
first.insertMessage({
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Array marker',
|
||||
payload: JSON.stringify({ _orcaLifecycleRejection: [] })
|
||||
}),
|
||||
first.insertMessage({
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'String marker',
|
||||
payload: JSON.stringify({ _orcaLifecycleRejection: 'migration' })
|
||||
}),
|
||||
first.insertMessage({
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Incomplete marker',
|
||||
payload: JSON.stringify({ _orcaLifecycleRejection: { code: 'migration' } })
|
||||
}),
|
||||
first.insertMessage({
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Non-string marker fields',
|
||||
payload: JSON.stringify({ _orcaLifecycleRejection: { code: 19, reason: false } })
|
||||
}),
|
||||
first.insertMessage({
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Array root',
|
||||
payload: JSON.stringify([
|
||||
{ _orcaLifecycleRejection: { code: 'migration', reason: 'nested' } }
|
||||
])
|
||||
}),
|
||||
first.insertMessage({
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'String root',
|
||||
payload: JSON.stringify('_orcaLifecycleRejection')
|
||||
})
|
||||
]
|
||||
first.close()
|
||||
|
||||
const raw = new Database(dbPath)
|
||||
const legacyDeliveryId = 'delivery_legacy_outstanding'
|
||||
raw
|
||||
.prepare(
|
||||
`INSERT INTO deliveries (
|
||||
id, run_id, consumer_generation, message_ids, status
|
||||
) VALUES (?, ?, 0, ?, 'outstanding')`
|
||||
)
|
||||
.run(legacyDeliveryId, LEGACY_RUN_ID, JSON.stringify([legacyMessages[0].id]))
|
||||
raw
|
||||
.prepare("UPDATE messages SET delivery_contract = 'legacy_direct' WHERE id = ?")
|
||||
.run(rejection.id)
|
||||
const seedAuditOnly = raw.prepare(
|
||||
"UPDATE messages SET delivery_contract = 'audit_only' WHERE id = ?"
|
||||
)
|
||||
for (const message of [lookalike, ...malformedRejections]) {
|
||||
seedAuditOnly.run(message.id)
|
||||
}
|
||||
raw.exec(`
|
||||
DROP INDEX IF EXISTS idx_messages_delivery_contract;
|
||||
DROP TABLE legacy_mail_receipts;
|
||||
DROP TABLE legacy_operation_receipts;
|
||||
DROP TABLE legacy_compatibility_principals;
|
||||
DROP TABLE legacy_adoptions;
|
||||
`)
|
||||
raw.pragma('user_version = 18')
|
||||
raw.close()
|
||||
|
||||
return {
|
||||
tempDir,
|
||||
fixture: {
|
||||
dbPath,
|
||||
currentRunId: currentRun.id,
|
||||
unrelatedRunId: unrelatedRun.id,
|
||||
currentDispatchId: currentDispatch.id,
|
||||
legacyTaskId: legacyTask.id,
|
||||
legacyDispatchId: retryDispatch.id,
|
||||
legacyGateId: legacyGate.id,
|
||||
legacyMessageIds: legacyMessages.map((message) => message.id),
|
||||
legacyQuestionId: question.message.id,
|
||||
legacyDeliveryId,
|
||||
rejectionMessageId: rejection.id,
|
||||
lookalikeMessageId: lookalike.id,
|
||||
malformedRejectionMessageIds: malformedRejections.map((message) => message.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { LegacyWorkerTerminalRecoveryRow } from './types'
|
||||
import { planLegacyWorkerTerminalRecovery } from './orchestration-legacy-worker-terminal-recovery'
|
||||
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const INCARNATION_ID = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function recoveryRow(
|
||||
overrides: Partial<LegacyWorkerTerminalRecoveryRow> = {}
|
||||
): LegacyWorkerTerminalRecoveryRow {
|
||||
return {
|
||||
dispatch_id: 'dispatch-1',
|
||||
task_id: 'task-1',
|
||||
dispatch_status: 'completed',
|
||||
contract_version: 0,
|
||||
assignee_handle: 'term-worker',
|
||||
assignee_pane_key: `tab-worker:${LEAF_ID}`,
|
||||
process_incarnation: `ssh:ssh-1@@pty-worker:${INCARNATION_ID}`,
|
||||
worker_state: 'ready',
|
||||
worktree_id: 'repo::/workspace',
|
||||
agent_terminal_handle: 'term-worker',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('legacy worker terminal recovery planning', () => {
|
||||
it('retains completed Dispatches when the worker process row is still live', () => {
|
||||
expect(planLegacyWorkerTerminalRecovery([recoveryRow()])).toEqual({
|
||||
blockedPanes: [
|
||||
{
|
||||
worktreeId: 'repo::/workspace',
|
||||
paneKey: `tab-worker:${LEAF_ID}`,
|
||||
contractVersion: 0
|
||||
}
|
||||
],
|
||||
candidates: [
|
||||
expect.objectContaining({
|
||||
dispatchId: 'dispatch-1',
|
||||
ptyId: 'ssh:ssh-1@@pty-worker',
|
||||
incarnationId: INCARNATION_ID
|
||||
})
|
||||
],
|
||||
ambiguousDispatchIds: []
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks resume but refuses recovery when durable handles disagree', () => {
|
||||
expect(
|
||||
planLegacyWorkerTerminalRecovery([recoveryRow({ agent_terminal_handle: 'term-replacement' })])
|
||||
).toEqual({
|
||||
blockedPanes: [
|
||||
{
|
||||
worktreeId: 'repo::/workspace',
|
||||
paneKey: `tab-worker:${LEAF_ID}`,
|
||||
contractVersion: 0
|
||||
}
|
||||
],
|
||||
candidates: [],
|
||||
ambiguousDispatchIds: []
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when two Dispatches claim one terminal identity', () => {
|
||||
const plan = planLegacyWorkerTerminalRecovery([
|
||||
recoveryRow(),
|
||||
recoveryRow({ dispatch_id: 'dispatch-2', task_id: 'task-2' })
|
||||
])
|
||||
|
||||
expect(plan.candidates).toEqual([])
|
||||
expect(plan.ambiguousDispatchIds).toEqual(['dispatch-1', 'dispatch-2'])
|
||||
expect(plan.blockedPanes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not trust malformed pane or process identities', () => {
|
||||
const plan = planLegacyWorkerTerminalRecovery([
|
||||
recoveryRow({
|
||||
assignee_pane_key: 'tab-worker:1',
|
||||
process_incarnation: 'runtime:pty:generation'
|
||||
})
|
||||
])
|
||||
|
||||
expect(plan).toEqual({
|
||||
blockedPanes: [],
|
||||
candidates: [],
|
||||
ambiguousDispatchIds: []
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { isPtyIncarnationId, type PtyIncarnationId } from '../../../shared/pty-incarnation'
|
||||
import { parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import type { LegacyWorkerTerminalRecoveryRow } from './types'
|
||||
|
||||
export type LegacyWorkerTerminalRecoveryCandidate = {
|
||||
dispatchId: string
|
||||
dispatchStatus: LegacyWorkerTerminalRecoveryRow['dispatch_status']
|
||||
contractVersion: number
|
||||
taskId: string
|
||||
worktreeId: string
|
||||
terminalHandle: string
|
||||
paneKey: string
|
||||
tabId: string
|
||||
leafId: string
|
||||
processIncarnation: string
|
||||
ptyId: string
|
||||
incarnationId: PtyIncarnationId
|
||||
}
|
||||
|
||||
export type LegacyWorkerTerminalRecoveryPlan = {
|
||||
blockedPanes: { worktreeId: string; paneKey: string; contractVersion: number }[]
|
||||
candidates: LegacyWorkerTerminalRecoveryCandidate[]
|
||||
ambiguousDispatchIds: string[]
|
||||
}
|
||||
|
||||
function parseProcessIncarnation(
|
||||
value: string
|
||||
): { ptyId: string; incarnationId: PtyIncarnationId } | null {
|
||||
const separator = value.lastIndexOf(':')
|
||||
if (separator <= 0) {
|
||||
return null
|
||||
}
|
||||
const ptyId = value.slice(0, separator)
|
||||
const incarnationId = value.slice(separator + 1)
|
||||
return ptyId && isPtyIncarnationId(incarnationId) ? { ptyId, incarnationId } : null
|
||||
}
|
||||
|
||||
function countCandidateKeys(
|
||||
candidates: readonly LegacyWorkerTerminalRecoveryCandidate[],
|
||||
select: (candidate: LegacyWorkerTerminalRecoveryCandidate) => string
|
||||
): Map<string, number> {
|
||||
const counts = new Map<string, number>()
|
||||
for (const candidate of candidates) {
|
||||
const key = select(candidate)
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1)
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
export function planLegacyWorkerTerminalRecovery(
|
||||
rows: readonly LegacyWorkerTerminalRecoveryRow[]
|
||||
): LegacyWorkerTerminalRecoveryPlan {
|
||||
const blockedPanes = new Map<
|
||||
string,
|
||||
{ worktreeId: string; paneKey: string; contractVersion: number }
|
||||
>()
|
||||
const parsedCandidates: LegacyWorkerTerminalRecoveryCandidate[] = []
|
||||
for (const row of rows) {
|
||||
const worktreeId = row.worktree_id?.trim()
|
||||
const paneKey = row.assignee_pane_key?.trim()
|
||||
const pane = paneKey ? parsePaneKey(paneKey) : null
|
||||
if (worktreeId && paneKey && pane) {
|
||||
blockedPanes.set(`${worktreeId}\0${paneKey}`, {
|
||||
worktreeId,
|
||||
paneKey,
|
||||
contractVersion: row.contract_version
|
||||
})
|
||||
}
|
||||
const terminalHandle = row.assignee_handle?.trim()
|
||||
const workerHandle = row.agent_terminal_handle?.trim()
|
||||
const processIncarnation = row.process_incarnation?.trim()
|
||||
const process = processIncarnation ? parseProcessIncarnation(processIncarnation) : null
|
||||
if (
|
||||
!worktreeId ||
|
||||
!paneKey ||
|
||||
!pane ||
|
||||
!terminalHandle ||
|
||||
terminalHandle !== workerHandle ||
|
||||
!processIncarnation ||
|
||||
!process
|
||||
) {
|
||||
continue
|
||||
}
|
||||
parsedCandidates.push({
|
||||
dispatchId: row.dispatch_id,
|
||||
dispatchStatus: row.dispatch_status,
|
||||
contractVersion: row.contract_version,
|
||||
taskId: row.task_id,
|
||||
worktreeId,
|
||||
terminalHandle,
|
||||
paneKey,
|
||||
tabId: pane.tabId,
|
||||
leafId: pane.leafId,
|
||||
processIncarnation,
|
||||
ptyId: process.ptyId,
|
||||
incarnationId: process.incarnationId
|
||||
})
|
||||
}
|
||||
|
||||
const identityCounts = [
|
||||
countCandidateKeys(parsedCandidates, (candidate) => candidate.terminalHandle),
|
||||
countCandidateKeys(parsedCandidates, (candidate) => candidate.paneKey),
|
||||
countCandidateKeys(parsedCandidates, (candidate) => candidate.processIncarnation)
|
||||
]
|
||||
const ambiguousDispatchIds = new Set<string>()
|
||||
const candidates = parsedCandidates.filter((candidate) => {
|
||||
const keys = [candidate.terminalHandle, candidate.paneKey, candidate.processIncarnation]
|
||||
const ambiguous = keys.some((key, index) => (identityCounts[index]?.get(key) ?? 0) !== 1)
|
||||
if (ambiguous) {
|
||||
ambiguousDispatchIds.add(candidate.dispatchId)
|
||||
}
|
||||
return !ambiguous
|
||||
})
|
||||
return {
|
||||
blockedPanes: [...blockedPanes.values()],
|
||||
candidates,
|
||||
ambiguousDispatchIds: [...ambiguousDispatchIds]
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,12 @@ import type Database from '../../sqlite/sync-database'
|
||||
|
||||
const POST_V6_COLUMNS = [
|
||||
['messages', 'run_id'],
|
||||
['messages', 'delivery_contract'],
|
||||
['coordinator_runs', 'scheduler_lost_at'],
|
||||
['tasks', 'run_id'],
|
||||
['dispatch_contexts', 'run_id'],
|
||||
['dispatch_contexts', 'contract_version'],
|
||||
['dispatch_contexts', 'launch_token_hash'],
|
||||
['dispatch_contexts', 'capability_hash'],
|
||||
['dispatch_contexts', 'process_incarnation'],
|
||||
['dispatch_contexts', 'capability_revoked_at'],
|
||||
@@ -14,11 +18,16 @@ const POST_V6_COLUMNS = [
|
||||
['remote_dispatch_attachments', 'to_worker_imported_sequence'],
|
||||
['remote_dispatch_attachments', 'protocol_version'],
|
||||
['federation_relay_items', 'dispatch_id'],
|
||||
['remote_questions', 'message_id']
|
||||
['remote_questions', 'message_id'],
|
||||
['legacy_adoptions', 'source_run_id'],
|
||||
['legacy_compatibility_principals', 'id'],
|
||||
['legacy_operation_receipts', 'principal_id'],
|
||||
['legacy_mail_receipts', 'principal_id']
|
||||
] as const
|
||||
|
||||
const POST_V6_INDEXES = [
|
||||
'idx_messages_run_sequence',
|
||||
'idx_messages_delivery_contract',
|
||||
'idx_tasks_run_status',
|
||||
'idx_dispatch_run_status',
|
||||
'idx_gates_run_status',
|
||||
@@ -46,11 +55,39 @@ function messagesAllowQuestions(db: Database.Database): boolean {
|
||||
return !!row && row.sql.includes("'question'")
|
||||
}
|
||||
|
||||
function hasConsistentLegacyAdoption(db: Database.Database): boolean {
|
||||
const sourceRunId = 'run_legacy_local'
|
||||
const sourceGraph = db
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
WHERE EXISTS(SELECT 1 FROM tasks WHERE run_id = ?)
|
||||
OR EXISTS(SELECT 1 FROM dispatch_contexts WHERE run_id = ?)
|
||||
OR EXISTS(SELECT 1 FROM decision_gates WHERE run_id = ?)
|
||||
OR EXISTS(SELECT 1 FROM messages WHERE run_id = ?)
|
||||
OR EXISTS(SELECT 1 FROM question_threads WHERE run_id = ?)
|
||||
OR EXISTS(SELECT 1 FROM deliveries WHERE run_id = ?)`
|
||||
)
|
||||
.get(sourceRunId, sourceRunId, sourceRunId, sourceRunId, sourceRunId, sourceRunId)
|
||||
const adoption = db
|
||||
.prepare('SELECT adopted_run_id FROM legacy_adoptions WHERE source_run_id = ?')
|
||||
.get(sourceRunId) as { adopted_run_id: string } | undefined
|
||||
if (sourceGraph) {
|
||||
return false
|
||||
}
|
||||
if (adoption) {
|
||||
return Boolean(
|
||||
db.prepare('SELECT 1 FROM runs WHERE id = ? AND legacy = 0').get(adoption.adopted_run_id)
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function hasCompletePostV6Schema(db: Database.Database): boolean {
|
||||
return (
|
||||
POST_V6_COLUMNS.every(([table, column]) => hasOrchestrationColumn(db, table, column)) &&
|
||||
POST_V6_INDEXES.every((index) => hasOrchestrationIndex(db, index)) &&
|
||||
messagesAllowQuestions(db)
|
||||
messagesAllowQuestions(db) &&
|
||||
hasConsistentLegacyAdoption(db)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,9 +96,12 @@ export function resolveOrchestrationMigrationStartVersion(
|
||||
storedVersion: number,
|
||||
schemaVersion: number
|
||||
): number {
|
||||
if (storedVersion >= schemaVersion || hasCompletePostV6Schema(db)) {
|
||||
if (storedVersion > schemaVersion) {
|
||||
return storedVersion
|
||||
}
|
||||
if (hasCompletePostV6Schema(db)) {
|
||||
return storedVersion
|
||||
}
|
||||
// Why: version-skewed pre-Run databases can claim the post-v6 range while retaining v6 tables.
|
||||
return Math.min(storedVersion, 6)
|
||||
return Math.min(storedVersion, schemaVersion, 6)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { LEGACY_RUN_ID, OrchestrationDb } from './db'
|
||||
import { LEGACY_CONTRACT_VERSION, LEGACY_RUN_ID, OrchestrationDb } from './db'
|
||||
import { resolveOrchestrationMigrationStartVersion } from './orchestration-schema-version-skew'
|
||||
|
||||
describe('OrchestrationDb version-skew migration', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
@@ -11,12 +12,14 @@ describe('OrchestrationDb version-skew migration', () => {
|
||||
|
||||
afterEach(() => {
|
||||
db?.close()
|
||||
db = undefined
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
tempDir = undefined
|
||||
}
|
||||
})
|
||||
|
||||
function createLegacySchemaClaimingVersion17(): string {
|
||||
function createLegacySchemaClaimingVersion(claimedVersion = 17): string {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'orca-db-version-skew-'))
|
||||
const dbPath = join(tempDir, 'orchestration.db')
|
||||
const raw = new Database(dbPath)
|
||||
@@ -126,20 +129,28 @@ describe('OrchestrationDb version-skew migration', () => {
|
||||
'gate_legacy', 'task_legacy', 'retained gate'
|
||||
);
|
||||
`)
|
||||
raw.pragma('user_version = 17')
|
||||
raw.pragma(`user_version = ${claimedVersion}`)
|
||||
raw.close()
|
||||
return dbPath
|
||||
}
|
||||
|
||||
it('repairs retained v6 rows when the database already claims v17', () => {
|
||||
const dbPath = createLegacySchemaClaimingVersion17()
|
||||
const dbPath = createLegacySchemaClaimingVersion()
|
||||
db = new OrchestrationDb(dbPath)
|
||||
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id
|
||||
expect(adoptedRunId).toBeTruthy()
|
||||
expect(db.getRun(LEGACY_RUN_ID)).toMatchObject({ legacy: 1 })
|
||||
expect(db.getMessageById('msg_legacy')).toMatchObject({ run_id: LEGACY_RUN_ID })
|
||||
expect(db.getTask('task_legacy')).toMatchObject({ run_id: LEGACY_RUN_ID })
|
||||
expect(db.getDispatchContextById('ctx_legacy')).toMatchObject({ run_id: LEGACY_RUN_ID })
|
||||
expect(db.getGate('gate_legacy')).toMatchObject({ run_id: LEGACY_RUN_ID })
|
||||
expect(db.getMessageById('msg_legacy')).toMatchObject({
|
||||
run_id: adoptedRunId,
|
||||
delivery_contract: 'legacy_direct'
|
||||
})
|
||||
expect(db.getTask('task_legacy')).toMatchObject({ run_id: adoptedRunId })
|
||||
expect(db.getDispatchContextById('ctx_legacy')).toMatchObject({
|
||||
run_id: adoptedRunId,
|
||||
contract_version: LEGACY_CONTRACT_VERSION
|
||||
})
|
||||
expect(db.getGate('gate_legacy')).toMatchObject({ run_id: adoptedRunId })
|
||||
|
||||
const run = db.createRun({
|
||||
objective: 'verify repaired orchestration',
|
||||
@@ -164,8 +175,18 @@ describe('OrchestrationDb version-skew migration', () => {
|
||||
db.close()
|
||||
db = undefined
|
||||
db = new OrchestrationDb(dbPath)
|
||||
expect(db.listTasks({ runId: LEGACY_RUN_ID }).map((row) => row.id)).toEqual(['task_legacy'])
|
||||
expect(db.listTasks({ runId: adoptedRunId }).map((row) => row.id)).toEqual(['task_legacy'])
|
||||
expect(db.getRun(run.id)).toBeDefined()
|
||||
expect(db.getQuestion(question.message.id)).toMatchObject({ status: 'pending' })
|
||||
})
|
||||
|
||||
it('does not repair an incomplete schema written by a future binary', () => {
|
||||
const dbPath = createLegacySchemaClaimingVersion(20)
|
||||
const raw = new Database(dbPath)
|
||||
|
||||
expect(resolveOrchestrationMigrationStartVersion(raw, 20, 19)).toBe(20)
|
||||
expect(raw.pragma('user_version', { simple: true })).toBe(20)
|
||||
|
||||
raw.close()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { OrchestrationDb } from './db'
|
||||
import { CURRENT_CONTRACT_VERSION, OrchestrationDb } from './db'
|
||||
|
||||
describe('OrchestrationDb worker Dispatch state', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
@@ -44,6 +44,51 @@ describe('OrchestrationDb worker Dispatch state', () => {
|
||||
status: 'dispatched',
|
||||
assignee_handle: 'term_worker'
|
||||
})
|
||||
expect(d.listLegacyWorkerTerminalRecoveryRows()).toEqual([
|
||||
expect.objectContaining({
|
||||
dispatch_id: started.dispatch.id,
|
||||
contract_version: CURRENT_CONTRACT_VERSION,
|
||||
worker_state: 'ready',
|
||||
agent_terminal_handle: 'term_worker'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('requeues an active Task before settling a worker whose terminal is missing', () => {
|
||||
const d = createDb()
|
||||
const task = d.createTask({ spec: 'recover missing worker' })
|
||||
const started = d.createStartingWorkerDispatch({
|
||||
taskId: task.id,
|
||||
startOptions: { topology: 'current', agent: 'codex' }
|
||||
})
|
||||
d.prepareStartingWorkerAuthority({
|
||||
dispatchId: started.dispatch.id,
|
||||
handle: 'term_missing',
|
||||
paneKey: 'tab_missing:11111111-1111-4111-8111-111111111111',
|
||||
processIncarnation: 'pty-missing:22222222-2222-4222-8222-222222222222',
|
||||
worktreeId: 'repo::worktree',
|
||||
setupState: 'not_applicable',
|
||||
effects: []
|
||||
})
|
||||
d.markWorkerDispatchReady(started.dispatch.id)
|
||||
|
||||
expect(
|
||||
d.reconcileMissingWorkerTerminal(started.dispatch.id, 'worker terminal is no longer live')
|
||||
).toMatchObject({
|
||||
state: 'abandoned',
|
||||
stage: 'terminal_missing',
|
||||
last_error: 'worker terminal is no longer live'
|
||||
})
|
||||
expect(d.getDispatchContextById(started.dispatch.id)).toMatchObject({
|
||||
status: 'failed',
|
||||
failure_count: 1,
|
||||
last_failure: 'worker terminal is no longer live'
|
||||
})
|
||||
expect(d.getTask(task.id)?.status).toBe('ready')
|
||||
|
||||
d.reconcileMissingWorkerTerminal(started.dispatch.id, 'duplicate recovery')
|
||||
expect(d.getDispatchContextById(started.dispatch.id)?.failure_count).toBe(1)
|
||||
expect(d.getTask(task.id)?.status).toBe('ready')
|
||||
})
|
||||
|
||||
it('commits worker-start mutation acceptance with the starting Dispatch', () => {
|
||||
|
||||
@@ -14,6 +14,8 @@ export type MessageType = (typeof MESSAGE_TYPES)[number]
|
||||
|
||||
export type MessagePriority = 'normal' | 'high' | 'urgent'
|
||||
|
||||
export type MessageDeliveryContract = 'legacy_direct' | 'current_delivery' | 'audit_only'
|
||||
|
||||
export type TaskStatus = 'pending' | 'ready' | 'dispatched' | 'completed' | 'failed' | 'blocked'
|
||||
|
||||
export type DispatchStatus = 'pending' | 'dispatched' | 'completed' | 'failed' | 'circuit_broken'
|
||||
@@ -61,6 +63,46 @@ export type DeliveryRow = {
|
||||
acknowledged_at: string | null
|
||||
}
|
||||
|
||||
export type LegacyAdoptionRow = {
|
||||
source_run_id: string
|
||||
adopted_run_id: string
|
||||
scheduler_state_lost: number
|
||||
adopted_at: string
|
||||
}
|
||||
|
||||
export type LegacyPrincipalRole = 'worker' | 'coordinator'
|
||||
|
||||
export type LegacyPrincipalStatus = 'committed' | 'settled' | 'revoked'
|
||||
|
||||
export type LegacyCompatibilityPrincipalRow = {
|
||||
id: string
|
||||
run_id: string
|
||||
dispatch_id: string | null
|
||||
role: LegacyPrincipalRole
|
||||
host_scope: string
|
||||
terminal_handle: string
|
||||
pane_key: string
|
||||
launch_token_hash: string
|
||||
process_incarnation: string | null
|
||||
status: LegacyPrincipalStatus
|
||||
}
|
||||
|
||||
export type LegacyOperationReceiptRow = {
|
||||
principal_id: string
|
||||
operation_key: string
|
||||
method: string
|
||||
payload_hash: string
|
||||
effect_id: string
|
||||
response_json: string
|
||||
completed_at: string
|
||||
}
|
||||
|
||||
export type LegacyMailReceiptRow = {
|
||||
principal_id: string
|
||||
message_id: string
|
||||
acknowledged_at: string | null
|
||||
}
|
||||
|
||||
export type QuestionStatus = 'pending' | 'answered' | 'closed'
|
||||
|
||||
export type QuestionRow = {
|
||||
@@ -117,6 +159,19 @@ export type WorkerDispatchRow = {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type LegacyWorkerTerminalRecoveryRow = {
|
||||
dispatch_id: string
|
||||
task_id: string
|
||||
dispatch_status: DispatchStatus
|
||||
contract_version: number
|
||||
assignee_handle: string | null
|
||||
assignee_pane_key: string | null
|
||||
process_incarnation: string | null
|
||||
worker_state: WorkerDispatchState
|
||||
worktree_id: string | null
|
||||
agent_terminal_handle: string | null
|
||||
}
|
||||
|
||||
export type FederatedDispatchRow = {
|
||||
dispatch_id: string
|
||||
environment_id: string
|
||||
@@ -170,6 +225,7 @@ export type FederationRelayItemRow = {
|
||||
export type MessageRow = {
|
||||
id: string
|
||||
run_id: string
|
||||
delivery_contract?: MessageDeliveryContract
|
||||
from_handle: string
|
||||
to_handle: string
|
||||
subject: string
|
||||
@@ -204,6 +260,8 @@ export type DispatchContextRow = {
|
||||
id: string
|
||||
run_id: string
|
||||
task_id: string
|
||||
contract_version: number
|
||||
launch_token_hash: string | null
|
||||
assignee_handle: string | null
|
||||
assignee_pane_key: string | null
|
||||
capability_hash: string | null
|
||||
@@ -238,4 +296,5 @@ export type CoordinatorRun = {
|
||||
poll_interval_ms: number
|
||||
created_at: string
|
||||
completed_at: string | null
|
||||
scheduler_lost_at: string | null
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Why: single boundary between raw RPC frames and OrcaRuntimeService; keeps schema, handler, and result type on one object.
|
||||
import { ZodError, type ZodType } from 'zod'
|
||||
import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
import type { OrcaRuntimeService, OrchestrationCompatibilityCallerAuthority } from '../orca-runtime'
|
||||
import type {
|
||||
DeviceCredentialInstalled,
|
||||
PairingGetEndpointsParams,
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
PairingProvisionRelayParams
|
||||
} from '../../../shared/mobile-relay-credential-contract'
|
||||
import type { RuntimeCapability } from '../../../shared/protocol-version'
|
||||
import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence'
|
||||
|
||||
export type PairingRpcContext = {
|
||||
getEndpoints(params: PairingGetEndpointsParams): Promise<PairingGetEndpointsResult>
|
||||
@@ -48,8 +49,18 @@ export type RpcRequest = {
|
||||
orchestrationCapability?: string
|
||||
orchestrationContractVersion?: number
|
||||
orchestrationRequestId?: string
|
||||
compatibilityInvocationId?: string
|
||||
orchestrationCompatibilityEvidence?: OrchestrationCompatibilityEvidence
|
||||
}
|
||||
|
||||
export type LegacyCoordinatorAuthorityProof = Readonly<{
|
||||
runId: string
|
||||
principalId: string | null
|
||||
terminalHandle: string
|
||||
paneKey: string
|
||||
consumerGeneration: number
|
||||
}>
|
||||
|
||||
export type RpcContext = {
|
||||
runtime: OrcaRuntimeService
|
||||
// Why: lets long-poll handlers release immediately on client disconnect instead of running down timeoutMs. See design doc §3.1.
|
||||
@@ -77,6 +88,11 @@ export type RpcContext = {
|
||||
method: string
|
||||
payloadHash: string
|
||||
}
|
||||
// Why: only the compatibility authority router can set this trusted scope; user params cannot bypass Run consumer binding.
|
||||
legacyCoordinatorRunId?: string
|
||||
legacyCoordinatorAuthority?: LegacyCoordinatorAuthorityProof
|
||||
revalidateLegacyCoordinator?: () => string
|
||||
orchestrationCompatibilityCallerAuthority?: OrchestrationCompatibilityCallerAuthority
|
||||
// Why: federation pins the authenticated saved-environment caller without exposing its token to handlers or storage.
|
||||
authenticatedCallerFingerprint?: string
|
||||
pairing?: PairingRpcContext
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { InvalidArgumentError, ZodError, formatZodError } from './core'
|
||||
import type { RpcEnvelopeMeta, RpcRequest, RpcResponse } from './core'
|
||||
import {
|
||||
computerErrorData,
|
||||
errorResponse,
|
||||
mapBrowserError,
|
||||
mapEmulatorError,
|
||||
mapRuntimeError
|
||||
} from './errors'
|
||||
|
||||
export function invalidArgumentResponse(
|
||||
request: RpcRequest,
|
||||
meta: RpcEnvelopeMeta,
|
||||
message: string
|
||||
): RpcResponse {
|
||||
return errorResponse(
|
||||
request.id,
|
||||
meta,
|
||||
'invalid_argument',
|
||||
message,
|
||||
request.method.startsWith('computer.') ? computerErrorData('invalid_argument') : undefined
|
||||
)
|
||||
}
|
||||
|
||||
export function mapDispatcherError(
|
||||
request: RpcRequest,
|
||||
meta: RpcEnvelopeMeta,
|
||||
error: unknown
|
||||
): RpcResponse {
|
||||
if (error instanceof ZodError) {
|
||||
return invalidArgumentResponse(request, meta, formatZodError(error))
|
||||
}
|
||||
if (error instanceof InvalidArgumentError) {
|
||||
return invalidArgumentResponse(request, meta, error.message)
|
||||
}
|
||||
if (request.method.startsWith('browser.')) {
|
||||
return mapBrowserError(request.id, meta, error)
|
||||
}
|
||||
if (request.method.startsWith('emulator.')) {
|
||||
return mapEmulatorError(request.id, meta, error)
|
||||
}
|
||||
return mapRuntimeError(request.id, meta, error)
|
||||
}
|
||||
@@ -46,6 +46,10 @@ function makeRuntime(ui: PersistedUIState = getDefaultUIState()): OrcaRuntimeSer
|
||||
updateUIState: vi.fn((updates: Partial<PersistedUIState>) => {
|
||||
currentUI = { ...currentUI, ...updates }
|
||||
return currentUI
|
||||
}),
|
||||
getOrchestrationDb: () => ({
|
||||
getLegacyAdoption: () => undefined,
|
||||
resolveLegacyWorkerCandidate: () => undefined
|
||||
})
|
||||
} as unknown as OrcaRuntimeService
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { RuntimeCapability } from '../../../shared/protocol-version'
|
||||
import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol'
|
||||
import type { PairingRpcContext } from './core'
|
||||
|
||||
export type RpcDispatchStreamingOptions = {
|
||||
connectionId?: string
|
||||
signal?: AbortSignal
|
||||
clientId?: string
|
||||
pairedDeviceId?: string
|
||||
clientKind?: 'mobile' | 'runtime'
|
||||
clientCapabilities?: readonly RuntimeCapability[]
|
||||
pairing?: PairingRpcContext
|
||||
sendBinary?: (bytes: Uint8Array<ArrayBufferLike>) => boolean | void
|
||||
registerBinaryStreamHandler?: (
|
||||
streamId: number,
|
||||
handler: (frame: TerminalStreamFrame) => void
|
||||
) => () => void
|
||||
}
|
||||
+100
-118
@@ -1,57 +1,44 @@
|
||||
// Why: the dispatcher is the one place that knows how to turn a validated
|
||||
// RPC request into a response envelope. Splitting it from the transport
|
||||
// makes it unit-testable without spinning up a socket, and keeps
|
||||
// runtime-rpc.ts focused on framing/auth/connection bookkeeping.
|
||||
import {
|
||||
ZodError,
|
||||
InvalidArgumentError,
|
||||
buildRegistry,
|
||||
formatZodError,
|
||||
isStreamingMethod,
|
||||
type RpcAnyMethod,
|
||||
type RpcEnvelopeMeta,
|
||||
type PairingRpcContext,
|
||||
type RpcRegistry,
|
||||
type RpcRequest,
|
||||
type RpcResponse
|
||||
} from './core'
|
||||
|
||||
import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol'
|
||||
import type { FeatureInteractionId } from '../../../shared/feature-interactions'
|
||||
import {
|
||||
computerErrorData,
|
||||
errorResponse,
|
||||
mapBrowserError,
|
||||
mapEmulatorError,
|
||||
mapRuntimeError,
|
||||
successResponse
|
||||
} from './errors'
|
||||
import { errorResponse, successResponse } from './errors'
|
||||
import { ALL_RPC_METHODS } from './methods'
|
||||
import { emulatorProbe, emulatorProbeError } from '../../emulator/emulator-probe'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
import type { RuntimeCapability } from '../../../shared/protocol-version'
|
||||
import {
|
||||
OrchestrationMutationExecutor,
|
||||
authenticatedCallerFingerprint,
|
||||
getOrchestrationMutationExecutor,
|
||||
type OrchestrationMutationExecutor,
|
||||
type DurableMutationInvocation
|
||||
} from './orchestration-mutation-executor'
|
||||
import { orchestrationMigrationFence } from './orchestration-contract-fence'
|
||||
import { getRuntimeFeatureInteractionId } from './runtime-feature-interaction'
|
||||
import { recordRuntimeFeatureInteraction } from './runtime-feature-interaction'
|
||||
import { OrchestrationLegacyCompatibility } from './orchestration-legacy-compatibility'
|
||||
import type { RpcDispatchStreamingOptions } from './dispatcher-stream-options'
|
||||
import { invalidArgumentResponse, mapDispatcherError } from './dispatcher-error-response'
|
||||
|
||||
export type DispatcherOptions = {
|
||||
runtime: OrcaRuntimeService
|
||||
methods?: readonly RpcAnyMethod[]
|
||||
}
|
||||
export type DispatcherOptions = { runtime: OrcaRuntimeService; methods?: readonly RpcAnyMethod[] }
|
||||
|
||||
export class RpcDispatcher {
|
||||
private readonly runtime: OrcaRuntimeService
|
||||
private readonly registry: RpcRegistry
|
||||
private readonly orchestrationMutations: OrchestrationMutationExecutor
|
||||
private readonly legacyOrchestration: OrchestrationLegacyCompatibility
|
||||
|
||||
constructor({ runtime, methods = ALL_RPC_METHODS }: DispatcherOptions) {
|
||||
this.runtime = runtime
|
||||
this.registry = buildRegistry(methods)
|
||||
this.orchestrationMutations = new OrchestrationMutationExecutor(runtime)
|
||||
this.orchestrationMutations = getOrchestrationMutationExecutor(runtime)
|
||||
this.legacyOrchestration = new OrchestrationLegacyCompatibility(runtime)
|
||||
}
|
||||
|
||||
async dispatch(request: RpcRequest, options?: { signal?: AbortSignal }): Promise<RpcResponse> {
|
||||
@@ -76,9 +63,6 @@ export class RpcDispatcher {
|
||||
return parsedParams.error
|
||||
}
|
||||
|
||||
// Why: streaming methods are not supported over one-shot transports like
|
||||
// Unix sockets. They require a reply function that can be called multiple
|
||||
// times, which is only available via dispatchStreaming.
|
||||
if (isStreamingMethod(method)) {
|
||||
return errorResponse(
|
||||
request.id,
|
||||
@@ -88,29 +72,60 @@ export class RpcDispatcher {
|
||||
)
|
||||
}
|
||||
|
||||
const isEmulator = request.method.startsWith('emulator.')
|
||||
if (isEmulator) {
|
||||
if (request.method.startsWith('emulator.')) {
|
||||
emulatorProbe(`rpc ${request.method}`, request.params)
|
||||
}
|
||||
try {
|
||||
const invoke = (mutation?: DurableMutationInvocation) =>
|
||||
method.handler(parsedParams.value, {
|
||||
const compatibility = await this.legacyOrchestration.tryHandle(
|
||||
request,
|
||||
parsedParams.value,
|
||||
options?.signal
|
||||
)
|
||||
if (compatibility.handled) {
|
||||
return successResponse(request.id, meta, compatibility.result)
|
||||
}
|
||||
const effectiveParams = compatibility.params ?? parsedParams.value
|
||||
const legacyCoordinator = this.legacyOrchestration.createCoordinatorInvocation(
|
||||
request,
|
||||
compatibility.legacyCoordinatorAuthority
|
||||
)
|
||||
const invoke = (mutation?: DurableMutationInvocation) => {
|
||||
const legacyCoordinatorRunId = legacyCoordinator?.revalidate()
|
||||
return method.handler(effectiveParams, {
|
||||
runtime: this.runtime,
|
||||
signal: options?.signal,
|
||||
requestId: request.id,
|
||||
orchestrationCapability: request.orchestrationCapability,
|
||||
authenticatedCallerFingerprint: authenticatedCallerFingerprint(request),
|
||||
authenticatedCallerFingerprint:
|
||||
mutation?.identity.callerFingerprint ?? authenticatedCallerFingerprint(request),
|
||||
recordMutationReceipt: mutation?.recordReceipt,
|
||||
orchestrationMutation: mutation?.identity
|
||||
orchestrationMutation: mutation?.identity,
|
||||
legacyCoordinatorRunId,
|
||||
legacyCoordinatorAuthority: legacyCoordinator?.authority,
|
||||
revalidateLegacyCoordinator: legacyCoordinator?.revalidate,
|
||||
orchestrationCompatibilityCallerAuthority:
|
||||
compatibility.orchestrationCompatibilityCallerAuthority
|
||||
})
|
||||
const result = await this.orchestrationMutations.run(request, parsedParams.value, invoke)
|
||||
this.recordRuntimeFeatureInteraction(request.method, result, undefined, request.params)
|
||||
}
|
||||
const result = await this.orchestrationMutations.run(
|
||||
request,
|
||||
effectiveParams,
|
||||
invoke,
|
||||
legacyCoordinator?.mutationCallerFingerprint
|
||||
)
|
||||
recordRuntimeFeatureInteraction(
|
||||
this.runtime,
|
||||
request.method,
|
||||
result,
|
||||
undefined,
|
||||
request.params
|
||||
)
|
||||
return successResponse(request.id, meta, result)
|
||||
} catch (error) {
|
||||
if (isEmulator) {
|
||||
if (request.method.startsWith('emulator.')) {
|
||||
emulatorProbeError(`rpc ${request.method}`, error, { params: request.params })
|
||||
}
|
||||
return this.mapError(request, meta, error)
|
||||
return mapDispatcherError(request, meta, error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,20 +135,7 @@ export class RpcDispatcher {
|
||||
async dispatchStreaming(
|
||||
request: RpcRequest,
|
||||
reply: (response: string) => void,
|
||||
options?: {
|
||||
connectionId?: string
|
||||
signal?: AbortSignal
|
||||
clientId?: string
|
||||
pairedDeviceId?: string
|
||||
clientKind?: 'mobile' | 'runtime'
|
||||
clientCapabilities?: readonly RuntimeCapability[]
|
||||
pairing?: PairingRpcContext
|
||||
sendBinary?: (bytes: Uint8Array<ArrayBufferLike>) => boolean | void
|
||||
registerBinaryStreamHandler?: (
|
||||
streamId: number,
|
||||
handler: (frame: TerminalStreamFrame) => void
|
||||
) => () => void
|
||||
}
|
||||
options?: RpcDispatchStreamingOptions
|
||||
): Promise<void> {
|
||||
const meta = this.meta()
|
||||
const method = this.registry.get(request.method)
|
||||
@@ -160,8 +162,23 @@ export class RpcDispatcher {
|
||||
|
||||
if (!isStreamingMethod(method)) {
|
||||
try {
|
||||
const invoke = (mutation?: DurableMutationInvocation) =>
|
||||
method.handler(parsedParams.value, {
|
||||
const compatibility = await this.legacyOrchestration.tryHandle(
|
||||
request,
|
||||
parsedParams.value,
|
||||
options?.signal
|
||||
)
|
||||
if (compatibility.handled) {
|
||||
reply(JSON.stringify(successResponse(request.id, meta, compatibility.result)))
|
||||
return
|
||||
}
|
||||
const effectiveParams = compatibility.params ?? parsedParams.value
|
||||
const legacyCoordinator = this.legacyOrchestration.createCoordinatorInvocation(
|
||||
request,
|
||||
compatibility.legacyCoordinatorAuthority
|
||||
)
|
||||
const invoke = (mutation?: DurableMutationInvocation) => {
|
||||
const legacyCoordinatorRunId = legacyCoordinator?.revalidate()
|
||||
return method.handler(effectiveParams, {
|
||||
runtime: this.runtime,
|
||||
signal: options?.signal,
|
||||
requestId: request.id,
|
||||
@@ -171,25 +188,44 @@ export class RpcDispatcher {
|
||||
clientKind: options?.clientKind,
|
||||
clientCapabilities: options?.clientCapabilities,
|
||||
orchestrationCapability: request.orchestrationCapability,
|
||||
authenticatedCallerFingerprint: authenticatedCallerFingerprint(request),
|
||||
authenticatedCallerFingerprint:
|
||||
mutation?.identity.callerFingerprint ?? authenticatedCallerFingerprint(request),
|
||||
recordMutationReceipt: mutation?.recordReceipt,
|
||||
orchestrationMutation: mutation?.identity,
|
||||
pairing: options?.pairing,
|
||||
sendBinary: options?.sendBinary,
|
||||
registerBinaryStreamHandler: options?.registerBinaryStreamHandler
|
||||
registerBinaryStreamHandler: options?.registerBinaryStreamHandler,
|
||||
legacyCoordinatorRunId,
|
||||
legacyCoordinatorAuthority: legacyCoordinator?.authority,
|
||||
revalidateLegacyCoordinator: legacyCoordinator?.revalidate,
|
||||
orchestrationCompatibilityCallerAuthority:
|
||||
compatibility.orchestrationCompatibilityCallerAuthority
|
||||
})
|
||||
const result = await this.orchestrationMutations.run(request, parsedParams.value, invoke)
|
||||
this.recordRuntimeFeatureInteraction(request.method, result, undefined, request.params)
|
||||
}
|
||||
const result = await this.orchestrationMutations.run(
|
||||
request,
|
||||
effectiveParams,
|
||||
invoke,
|
||||
legacyCoordinator?.mutationCallerFingerprint
|
||||
)
|
||||
recordRuntimeFeatureInteraction(
|
||||
this.runtime,
|
||||
request.method,
|
||||
result,
|
||||
undefined,
|
||||
request.params
|
||||
)
|
||||
reply(JSON.stringify(successResponse(request.id, meta, result)))
|
||||
} catch (error) {
|
||||
reply(JSON.stringify(this.mapError(request, meta, error)))
|
||||
reply(JSON.stringify(mapDispatcherError(request, meta, error)))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const recordedStreamingFeatureInteractions = new Set<FeatureInteractionId>()
|
||||
const emit = (result: unknown): void => {
|
||||
this.recordRuntimeFeatureInteraction(
|
||||
recordRuntimeFeatureInteraction(
|
||||
this.runtime,
|
||||
request.method,
|
||||
result,
|
||||
recordedStreamingFeatureInteractions,
|
||||
@@ -212,20 +248,22 @@ export class RpcDispatcher {
|
||||
pairedDeviceId: options?.pairedDeviceId,
|
||||
clientKind: options?.clientKind,
|
||||
clientCapabilities: options?.clientCapabilities,
|
||||
orchestrationCapability: request.orchestrationCapability,
|
||||
pairing: options?.pairing,
|
||||
sendBinary: options?.sendBinary,
|
||||
registerBinaryStreamHandler: options?.registerBinaryStreamHandler
|
||||
},
|
||||
emit
|
||||
)
|
||||
this.recordRuntimeFeatureInteraction(
|
||||
recordRuntimeFeatureInteraction(
|
||||
this.runtime,
|
||||
request.method,
|
||||
result,
|
||||
recordedStreamingFeatureInteractions,
|
||||
request.params
|
||||
)
|
||||
} catch (error) {
|
||||
reply(JSON.stringify(this.mapError(request, meta, error)))
|
||||
reply(JSON.stringify(mapDispatcherError(request, meta, error)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,69 +279,13 @@ export class RpcDispatcher {
|
||||
const result = method.params.safeParse(rawParams)
|
||||
if (!result.success) {
|
||||
return {
|
||||
error: this.invalidArgumentResponse(request, meta, formatZodError(result.error))
|
||||
error: invalidArgumentResponse(request, meta, formatZodError(result.error))
|
||||
}
|
||||
}
|
||||
return { value: result.data }
|
||||
}
|
||||
|
||||
private mapError(request: RpcRequest, meta: RpcEnvelopeMeta, error: unknown): RpcResponse {
|
||||
if (error instanceof ZodError) {
|
||||
return this.invalidArgumentResponse(request, meta, formatZodError(error))
|
||||
}
|
||||
if (error instanceof InvalidArgumentError) {
|
||||
return this.invalidArgumentResponse(request, meta, error.message)
|
||||
}
|
||||
|
||||
// Why: browser methods throw BrowserError with a structured `code`;
|
||||
// every other runtime error has a plain-message code. Routing by method
|
||||
// prefix keeps the mapping a single decision rather than a per-method
|
||||
// flag callers must remember to set.
|
||||
if (request.method.startsWith('browser.')) {
|
||||
return mapBrowserError(request.id, meta, error)
|
||||
}
|
||||
if (request.method.startsWith('emulator.')) {
|
||||
return mapEmulatorError(request.id, meta, error)
|
||||
}
|
||||
return mapRuntimeError(request.id, meta, error)
|
||||
}
|
||||
|
||||
private invalidArgumentResponse(
|
||||
request: RpcRequest,
|
||||
meta: RpcEnvelopeMeta,
|
||||
message: string
|
||||
): RpcResponse {
|
||||
return errorResponse(
|
||||
request.id,
|
||||
meta,
|
||||
'invalid_argument',
|
||||
message,
|
||||
request.method.startsWith('computer.') ? computerErrorData('invalid_argument') : undefined
|
||||
)
|
||||
}
|
||||
|
||||
private meta(): RpcEnvelopeMeta {
|
||||
return { runtimeId: this.runtime.getRuntimeId() }
|
||||
}
|
||||
|
||||
private recordRuntimeFeatureInteraction(
|
||||
method: string,
|
||||
result: unknown,
|
||||
alreadyRecorded?: Set<FeatureInteractionId>,
|
||||
rawParams?: unknown
|
||||
): void {
|
||||
const id = getRuntimeFeatureInteractionId(method, result, rawParams)
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
if (alreadyRecorded?.has(id)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.runtime.recordFeatureInteraction(id)
|
||||
alreadyRecorded?.add(id)
|
||||
} catch {
|
||||
// Best-effort education state must not break runtime tools.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
|
||||
'agent_unconfigured',
|
||||
'terminal_worktree_mismatch',
|
||||
'request_mismatch',
|
||||
'legacy_read_only',
|
||||
'orchestration_migration_required',
|
||||
'operation_unknown',
|
||||
'question_not_found',
|
||||
|
||||
@@ -265,7 +265,7 @@ describe('orchestration federation control mail', () => {
|
||||
})
|
||||
await expect(waiting).resolves.toMatchObject({
|
||||
ok: true,
|
||||
result: { count: 0, timedOut: true }
|
||||
result: { count: 0 }
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -224,6 +224,7 @@ describe('orchestration federation', () => {
|
||||
])
|
||||
)
|
||||
expect(workerDb.listTasks()).toHaveLength(0)
|
||||
expect(vi.mocked(workerRuntime.createManagedWorktree).mock.calls[0]?.[0].activate).toBe(false)
|
||||
expect(workerRuntime.sendTerminalAgentPrompt).toHaveBeenCalledWith(
|
||||
'term_windows_worker',
|
||||
expect.stringContaining(`Your task ID is: ${task.id}`)
|
||||
|
||||
@@ -103,8 +103,12 @@ export const ORCHESTRATION_GATE_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'orchestration.gateCreate',
|
||||
params: GateCreateParams,
|
||||
handler: (params, { runtime }) => {
|
||||
handler: (params, { runtime, legacyCoordinatorRunId }) => {
|
||||
const db = runtime.getOrchestrationDb()
|
||||
const task = db.getTask(params.task)
|
||||
if (legacyCoordinatorRunId && task?.run_id !== legacyCoordinatorRunId) {
|
||||
throw new Error(`Task not found: ${params.task}`)
|
||||
}
|
||||
let options: string[] | undefined
|
||||
if (params.options) {
|
||||
try {
|
||||
@@ -129,8 +133,12 @@ export const ORCHESTRATION_GATE_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'orchestration.gateResolve',
|
||||
params: GateResolveParams,
|
||||
handler: (params, { runtime }) => {
|
||||
handler: (params, { runtime, legacyCoordinatorRunId }) => {
|
||||
const db = runtime.getOrchestrationDb()
|
||||
const existing = db.getGate(params.id)
|
||||
if (legacyCoordinatorRunId && existing?.run_id !== legacyCoordinatorRunId) {
|
||||
throw new Error(`Gate not found: ${params.id}`)
|
||||
}
|
||||
const gate = db.resolveGate(params.id, params.resolution)
|
||||
if (!gate) {
|
||||
throw new Error(`Gate not found: ${params.id}`)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { OptionalString, requiredString } from '../schemas'
|
||||
import { OptionalBoolean, OptionalString, requiredString } from '../schemas'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { OrchestrationError } from '../../orchestration/orchestration-error'
|
||||
|
||||
@@ -11,7 +11,8 @@ const RunCreateParams = z.object({
|
||||
|
||||
const RunUseParams = z.object({
|
||||
id: requiredString('Missing --id'),
|
||||
from: requiredString('Missing coordinator terminal')
|
||||
from: requiredString('Missing coordinator terminal'),
|
||||
takeoverLegacy: OptionalBoolean
|
||||
})
|
||||
|
||||
const RunCurrentParams = z.object({ from: requiredString('Missing coordinator terminal') })
|
||||
@@ -51,14 +52,33 @@ export const ORCHESTRATION_RUN_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'orchestration.runUse',
|
||||
params: RunUseParams,
|
||||
handler: (params, { runtime }) => {
|
||||
handler: (
|
||||
params,
|
||||
{
|
||||
runtime,
|
||||
legacyCoordinatorAuthority,
|
||||
orchestrationCompatibilityCallerAuthority: callerAuthority
|
||||
}
|
||||
) => {
|
||||
const paneKey = requireCallerPane(runtime, params.from)
|
||||
if (
|
||||
params.takeoverLegacy &&
|
||||
(callerAuthority?.terminalHandle !== params.from || callerAuthority.paneKey !== paneKey)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'legacy_read_only',
|
||||
'Legacy takeover must be invoked by the live coordinator agent terminal it will bind. No effects were applied.',
|
||||
{ effectsApplied: false }
|
||||
)
|
||||
}
|
||||
const db = runtime.getOrchestrationDb()
|
||||
const priorRun = db.getCurrentRunForPane(paneKey)
|
||||
const run = db.bindRun({
|
||||
runId: params.id,
|
||||
coordinatorHandle: params.from,
|
||||
coordinatorPaneKey: paneKey
|
||||
coordinatorPaneKey: paneKey,
|
||||
takeoverLegacy: params.takeoverLegacy,
|
||||
legacyCoordinatorAuthority
|
||||
})
|
||||
if (!run) {
|
||||
throw new OrchestrationError(
|
||||
|
||||
@@ -36,6 +36,21 @@ export type WorkerSetupReceipt = {
|
||||
| 'not_applicable'
|
||||
}
|
||||
|
||||
export function requireWorkerAuthority(runtime: OrcaRuntimeService, terminalHandle: string) {
|
||||
const authority = runtime.getOrchestrationDispatchAuthority(terminalHandle)
|
||||
const paneKey = authority?.paneKey ?? runtime.getTerminalPaneKey(terminalHandle)
|
||||
const processIncarnation =
|
||||
authority?.processIncarnation ?? runtime.getTerminalProcessIncarnation(terminalHandle)
|
||||
if (!paneKey || !processIncarnation) {
|
||||
throw new Error('stable_pane_required')
|
||||
}
|
||||
return {
|
||||
paneKey,
|
||||
processIncarnation,
|
||||
...(authority?.launchTokenHash ? { launchTokenHash: authority.launchTokenHash } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export async function createExistingWorktreeWorkerTerminal(args: {
|
||||
runtime: OrcaRuntimeService
|
||||
worktreeId: string
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
createExistingWorktreeWorkerTerminal,
|
||||
createWorkerWorktree,
|
||||
monitorWorkerSetup,
|
||||
requireWorkerAuthority,
|
||||
type WorkerEffect,
|
||||
type WorkerSetupReceipt
|
||||
} from './orchestration-worker-topology'
|
||||
@@ -235,16 +236,11 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [
|
||||
: `Agent did not become ready (${wait.status}).`
|
||||
)
|
||||
}
|
||||
const paneKey = runtime.getTerminalPaneKey(terminalHandle)
|
||||
const processIncarnation = runtime.getTerminalProcessIncarnation(terminalHandle)
|
||||
if (!paneKey || !processIncarnation) {
|
||||
throw new Error('stable_pane_required')
|
||||
}
|
||||
const terminalAuthority = requireWorkerAuthority(runtime, terminalHandle)
|
||||
const capability = db.prepareStartingWorkerAuthority({
|
||||
dispatchId: started.dispatch.id,
|
||||
handle: terminalHandle,
|
||||
paneKey,
|
||||
processIncarnation,
|
||||
...terminalAuthority,
|
||||
worktreeId: resolvedWorktree.id,
|
||||
effects,
|
||||
setupState: setupReceipt.state
|
||||
|
||||
@@ -1918,6 +1918,30 @@ describe('orchestration RPC methods', () => {
|
||||
expect(db.getDispatchContextById(result.dispatch.id)?.assignee_pane_key).toBe('tab_w:leaf_w')
|
||||
})
|
||||
|
||||
it('commits the target process launch token on a manual dispatch', async () => {
|
||||
setup()
|
||||
vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockReturnValue({
|
||||
runtimeId: runtime.getRuntimeId(),
|
||||
terminalHandle: 'term_a',
|
||||
ptyId: 'pty_a',
|
||||
worktreeId: 'repo::worktree',
|
||||
paneKey: 'tab_w:leaf_w',
|
||||
processIncarnation: 'runtime_test:term_a:1',
|
||||
launchTokenHash: 'launch-token-hash',
|
||||
hostScope: { kind: 'local', hostId: 'local' }
|
||||
})
|
||||
const task = db.createTask({ spec: 'work' })
|
||||
|
||||
const result = (await call('orchestration.dispatch', {
|
||||
task: task.id,
|
||||
to: 'term_a'
|
||||
})) as { dispatch: { id: string } }
|
||||
|
||||
expect(db.getDispatchContextById(result.dispatch.id)?.launch_token_hash).toBe(
|
||||
'launch-token-hash'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects dispatch for a pending task', async () => {
|
||||
setup()
|
||||
const parent = db.createTask({ spec: 'parent' })
|
||||
@@ -2162,6 +2186,32 @@ describe('orchestration RPC methods', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('commits the launched worker token with its durable authority', async () => {
|
||||
setup()
|
||||
mockCurrentWorkerStart()
|
||||
vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockReturnValue({
|
||||
runtimeId: runtime.getRuntimeId(),
|
||||
terminalHandle: 'term_worker',
|
||||
ptyId: 'pty_worker',
|
||||
worktreeId: 'repo::worktree',
|
||||
paneKey: 'tab_worker:leaf_worker',
|
||||
processIncarnation: 'runtime_test:term_worker:1',
|
||||
launchTokenHash: 'worker-launch-token-hash',
|
||||
hostScope: { kind: 'local', hostId: 'local' }
|
||||
})
|
||||
const task = db.createTask({ spec: 'persist worker identity' })
|
||||
|
||||
const result = (await call('orchestration.workerStart', {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
agent: 'codex'
|
||||
})) as { dispatchId: string }
|
||||
|
||||
expect(db.getDispatchContextById(result.dispatchId)?.launch_token_hash).toBe(
|
||||
'worker-launch-token-hash'
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces a worker terminal reveal failure without discarding the live worker', async () => {
|
||||
setup()
|
||||
mockCurrentWorkerStart()
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { OptionalFiniteNumber, OptionalString, OptionalBoolean, requiredString } from '../schemas'
|
||||
import type { MessageType, MessagePriority, TaskStatus } from '../../orchestration/db'
|
||||
import {
|
||||
LEGACY_CONTRACT_VERSION,
|
||||
type MessageType,
|
||||
type MessagePriority,
|
||||
type TaskStatus
|
||||
} from '../../orchestration/db'
|
||||
import { MESSAGE_TYPES } from '../../orchestration/types'
|
||||
import { buildDispatchPreamble } from '../../orchestration/preamble'
|
||||
import { formatMessageBanner } from '../../orchestration/formatter'
|
||||
@@ -51,6 +56,10 @@ function parseRemoteWorkerPayload(payload: string | undefined): Record<string, u
|
||||
}
|
||||
}
|
||||
|
||||
function isWorkerReportOutcome(value: unknown): value is 'succeeded' | 'failed' {
|
||||
return value === 'succeeded' || value === 'failed'
|
||||
}
|
||||
|
||||
const SendParams = z
|
||||
.object({
|
||||
to: OptionalString,
|
||||
@@ -107,6 +116,9 @@ const CheckParams = z
|
||||
// Why: one-release RPC compatibility only; the public CLI uses --format because no terminal input is injected.
|
||||
inject: OptionalBoolean,
|
||||
ack: OptionalString,
|
||||
compatibilityAck: OptionalString,
|
||||
compatibilityQuestionAck: OptionalString,
|
||||
compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(),
|
||||
run: OptionalString,
|
||||
wait: OptionalBoolean,
|
||||
timeoutMs: OptionalFiniteNumber
|
||||
@@ -205,7 +217,9 @@ const AskParams = z
|
||||
options: OptionalString,
|
||||
timeoutMs: OptionalFiniteNumber,
|
||||
from: OptionalString,
|
||||
run: OptionalString
|
||||
run: OptionalString,
|
||||
compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(),
|
||||
compatibilityWindowsCommand: z.enum(['orca', 'orca-ide']).optional()
|
||||
})
|
||||
.superRefine((params, ctx) => {
|
||||
if ((params.question ? 1 : 0) + (params.resume ? 1 : 0) !== 1) {
|
||||
@@ -241,6 +255,7 @@ function resolveRunScope(
|
||||
callerTerminalHandle?: string
|
||||
callerPaneKey?: string
|
||||
requireCurrentConsumer: boolean
|
||||
legacyCoordinatorRunId?: string
|
||||
}
|
||||
): RunRow {
|
||||
const db = runtime.getOrchestrationDb()
|
||||
@@ -252,6 +267,9 @@ function resolveRunScope(
|
||||
if (!params.requireCurrentConsumer && explicit) {
|
||||
return explicit
|
||||
}
|
||||
if (explicit && params.legacyCoordinatorRunId === explicit.id) {
|
||||
return explicit
|
||||
}
|
||||
if (!params.callerTerminalHandle) {
|
||||
throw new OrchestrationError(
|
||||
'run_required',
|
||||
@@ -366,6 +384,49 @@ function resolveMessageRun(
|
||||
return { run, dispatchId: dispatch?.id ?? dispatchId }
|
||||
}
|
||||
|
||||
function legacyWorkerDeliveryContract(
|
||||
runtime: OrcaRuntimeService,
|
||||
runId: string | undefined,
|
||||
recipient: string
|
||||
): 'legacy_direct' | undefined {
|
||||
if (!runId) {
|
||||
return undefined
|
||||
}
|
||||
if (!recipient.startsWith('dispatch:')) {
|
||||
return runtime
|
||||
.getOrchestrationDb()
|
||||
.resolveLegacyWorkerCandidate({ runId, terminalHandle: recipient })
|
||||
? 'legacy_direct'
|
||||
: undefined
|
||||
}
|
||||
const dispatch = runtime
|
||||
.getOrchestrationDb()
|
||||
.getDispatchContextById(recipient.slice('dispatch:'.length))
|
||||
return dispatch?.run_id === runId &&
|
||||
dispatch.contract_version === LEGACY_CONTRACT_VERSION &&
|
||||
(dispatch.status === 'pending' || dispatch.status === 'dispatched')
|
||||
? 'legacy_direct'
|
||||
: undefined
|
||||
}
|
||||
|
||||
function interruptedAcknowledgedCheck(
|
||||
runId: string,
|
||||
acknowledged: string,
|
||||
reason: 'consumer_fenced' | 'outcome_unknown' | 'waiter_exists'
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
runId,
|
||||
deliveryId: null,
|
||||
messages: [],
|
||||
count: 0,
|
||||
acknowledged,
|
||||
timedOut: false,
|
||||
cancelled: false,
|
||||
connectionLost: false,
|
||||
waitInterrupted: reason
|
||||
}
|
||||
}
|
||||
|
||||
function rejectFederatedExplicitTarget(params: { to?: string; run?: string }): void {
|
||||
if (params.to || params.run) {
|
||||
throw new OrchestrationError(
|
||||
@@ -382,7 +443,10 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'orchestration.send',
|
||||
params: SendParams,
|
||||
handler: async (params, { runtime, orchestrationCapability }) => {
|
||||
handler: async (
|
||||
params,
|
||||
{ runtime, orchestrationCapability, legacyCoordinatorRunId, revalidateLegacyCoordinator }
|
||||
) => {
|
||||
const db = runtime.getOrchestrationDb()
|
||||
const from = params.from ?? 'unknown'
|
||||
// Why: caller-supplied pane fields are only compatibility metadata; lifecycle authority uses the runtime-observed pane plus capability.
|
||||
@@ -463,6 +527,15 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
runId: params.run,
|
||||
payload: params.payload
|
||||
})
|
||||
if (
|
||||
params.type === 'worker_done' &&
|
||||
!isWorkerReportOutcome(parseRemoteWorkerPayload(params.payload).outcome)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'worker_done requires outcome=succeeded|failed for a current Dispatch.'
|
||||
)
|
||||
}
|
||||
if (params.to?.startsWith('task:')) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
@@ -514,6 +587,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
'Coordinator-to-worker control mail cannot report worker lifecycle.'
|
||||
)
|
||||
}
|
||||
revalidateLegacyCoordinator?.()
|
||||
const relay = db.enqueueFederationRelay({
|
||||
dispatchId,
|
||||
direction: 'to_worker',
|
||||
@@ -540,6 +614,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
}
|
||||
}
|
||||
// Point-to-point — existing single-recipient behavior
|
||||
revalidateLegacyCoordinator?.()
|
||||
const msg = db.insertMessage({
|
||||
from,
|
||||
to,
|
||||
@@ -550,7 +625,12 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
threadId: params.threadId,
|
||||
payload: params.payload,
|
||||
senderPaneKey,
|
||||
runId: routing.run?.id
|
||||
runId: routing.run?.id,
|
||||
deliveryContract: legacyWorkerDeliveryContract(
|
||||
runtime,
|
||||
routing.run?.id ?? legacyCoordinatorRunId,
|
||||
to
|
||||
)
|
||||
})
|
||||
const dispatch = routing.dispatchId
|
||||
? db.getDispatchContextById(routing.dispatchId)
|
||||
@@ -607,6 +687,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
throw new Error(`No recipients resolved for group address: ${to}`)
|
||||
}
|
||||
|
||||
revalidateLegacyCoordinator?.()
|
||||
const threadId = params.threadId ?? `thread_${Date.now()}`
|
||||
const messages = handles.map((handle) =>
|
||||
db.insertMessage({
|
||||
@@ -619,7 +700,12 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
threadId,
|
||||
payload: params.payload,
|
||||
senderPaneKey,
|
||||
runId: routing.run?.id
|
||||
runId: routing.run?.id,
|
||||
deliveryContract: legacyWorkerDeliveryContract(
|
||||
runtime,
|
||||
routing.run?.id ?? legacyCoordinatorRunId,
|
||||
handle
|
||||
)
|
||||
})
|
||||
)
|
||||
for (const message of messages) {
|
||||
@@ -633,7 +719,16 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'orchestration.check',
|
||||
params: CheckParams,
|
||||
handler: async (params, { runtime, signal }) => {
|
||||
handler: async (
|
||||
params,
|
||||
{
|
||||
runtime,
|
||||
signal,
|
||||
legacyCoordinatorRunId,
|
||||
revalidateLegacyCoordinator,
|
||||
recordMutationReceipt
|
||||
}
|
||||
) => {
|
||||
const db = runtime.getOrchestrationDb()
|
||||
const handle = params.terminal ?? 'unknown'
|
||||
const typeFilter = parseMessageTypes(params.types)
|
||||
@@ -646,7 +741,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
runId: params.run,
|
||||
callerTerminalHandle: handle,
|
||||
callerPaneKey: paneKey ?? undefined,
|
||||
requireCurrentConsumer: true
|
||||
requireCurrentConsumer: true,
|
||||
legacyCoordinatorRunId
|
||||
})
|
||||
const generation = run.consumer_generation
|
||||
const address = `run:${run.id}`
|
||||
@@ -659,6 +755,11 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
deliveryId: params.ack
|
||||
})
|
||||
: undefined
|
||||
if (acknowledged) {
|
||||
recordMutationReceipt?.(
|
||||
interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'outcome_unknown')
|
||||
)
|
||||
}
|
||||
if (params.peek || params.all || params.unread === false) {
|
||||
const history = db.getRunMailboxHistory(run.id, 100, typeFilter)
|
||||
const messages =
|
||||
@@ -722,14 +823,28 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
signal,
|
||||
exclusive: true
|
||||
})
|
||||
try {
|
||||
revalidateLegacyCoordinator?.()
|
||||
} catch (error) {
|
||||
if (!acknowledged) {
|
||||
throw error
|
||||
}
|
||||
return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'consumer_fenced')
|
||||
}
|
||||
const latestRun = db.getRun(run.id)
|
||||
if (!latestRun || latestRun.consumer_generation !== generation) {
|
||||
if (acknowledged) {
|
||||
return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'consumer_fenced')
|
||||
}
|
||||
throw new OrchestrationError(
|
||||
'consumer_fenced',
|
||||
'This mailbox consumer was replaced while waiting.'
|
||||
)
|
||||
}
|
||||
if (waitResult === 'waiter_exists') {
|
||||
if (acknowledged) {
|
||||
return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'waiter_exists')
|
||||
}
|
||||
throw new OrchestrationError(
|
||||
'waiter_exists',
|
||||
`Run ${run.id} already has an active actionable waiter.`
|
||||
@@ -911,13 +1026,28 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'orchestration.reply',
|
||||
params: ReplyParams,
|
||||
handler: async (params, { runtime }) => {
|
||||
handler: async (params, { runtime, legacyCoordinatorRunId }) => {
|
||||
const db = runtime.getOrchestrationDb()
|
||||
const original = db.getMessageById(params.id)
|
||||
if (!original) {
|
||||
throw new Error(`Message not found: ${params.id}`)
|
||||
}
|
||||
if (original.run_id === ORCHESTRATION_LEGACY_RUN_ID) {
|
||||
if (
|
||||
legacyCoordinatorRunId &&
|
||||
(original.run_id !== legacyCoordinatorRunId ||
|
||||
(params.run !== undefined && params.run !== legacyCoordinatorRunId))
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Message ${params.id} does not belong to this adopted Run.`,
|
||||
{ effectsApplied: false }
|
||||
)
|
||||
}
|
||||
if (
|
||||
original.run_id === ORCHESTRATION_LEGACY_RUN_ID ||
|
||||
original.delivery_contract === 'legacy_direct' ||
|
||||
original.delivery_contract === 'audit_only'
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'legacy_read_only',
|
||||
'Legacy orchestration messages are inspect-only; no reply was applied.',
|
||||
@@ -930,7 +1060,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
const run = resolveRunScope(runtime, {
|
||||
runId: params.run ?? question.run_id,
|
||||
callerTerminalHandle: params.from,
|
||||
requireCurrentConsumer: true
|
||||
requireCurrentConsumer: true,
|
||||
legacyCoordinatorRunId
|
||||
})
|
||||
const answered = db.answerQuestion({
|
||||
messageId: question.message_id,
|
||||
@@ -993,7 +1124,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'orchestration.taskCreate',
|
||||
params: TaskCreateParams,
|
||||
handler: (params, { runtime }) => {
|
||||
handler: (params, { runtime, legacyCoordinatorRunId }) => {
|
||||
const db = runtime.getOrchestrationDb()
|
||||
let deps: string[] | undefined
|
||||
if (params.deps) {
|
||||
@@ -1017,7 +1148,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
runId: resolveRunScope(runtime, {
|
||||
runId: params.run,
|
||||
callerTerminalHandle: params.callerTerminalHandle,
|
||||
requireCurrentConsumer: true
|
||||
requireCurrentConsumer: true,
|
||||
legacyCoordinatorRunId
|
||||
}).id
|
||||
})
|
||||
return { task }
|
||||
@@ -1027,7 +1159,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'orchestration.taskList',
|
||||
params: TaskListParams,
|
||||
handler: (params, { runtime }) => {
|
||||
handler: (params, { runtime, legacyCoordinatorRunId }) => {
|
||||
const db = runtime.getOrchestrationDb()
|
||||
const explicitRun = params.run ? db.getRun(params.run) : undefined
|
||||
const run =
|
||||
@@ -1036,7 +1168,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
: resolveRunScope(runtime, {
|
||||
runId: params.run,
|
||||
callerTerminalHandle: params.callerTerminalHandle,
|
||||
requireCurrentConsumer: params.run === undefined
|
||||
requireCurrentConsumer: params.run === undefined,
|
||||
legacyCoordinatorRunId
|
||||
})
|
||||
// Why: listTasksWithDispatch adds assignee_handle + dispatch_id (NULL for non-dispatched), so legacy-shape consumers are unaffected.
|
||||
const joined = db.listTasksWithDispatch({
|
||||
@@ -1063,12 +1196,13 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'orchestration.taskUpdate',
|
||||
params: TaskUpdateParams,
|
||||
handler: (params, { runtime }) => {
|
||||
handler: (params, { runtime, legacyCoordinatorRunId }) => {
|
||||
const db = runtime.getOrchestrationDb()
|
||||
const run = resolveRunScope(runtime, {
|
||||
runId: params.run,
|
||||
callerTerminalHandle: params.callerTerminalHandle,
|
||||
requireCurrentConsumer: true
|
||||
requireCurrentConsumer: true,
|
||||
legacyCoordinatorRunId
|
||||
})
|
||||
const existing = db.getTask(params.id)
|
||||
if (!existing || existing.run_id !== run.id) {
|
||||
@@ -1088,7 +1222,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'orchestration.dispatch',
|
||||
params: DispatchParams,
|
||||
handler: async (params, { runtime }) => {
|
||||
handler: async (params, { runtime, legacyCoordinatorRunId, revalidateLegacyCoordinator }) => {
|
||||
const db = runtime.getOrchestrationDb()
|
||||
const task = db.getTask(params.task)
|
||||
if (!task) {
|
||||
@@ -1097,7 +1231,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
const run = resolveRunScope(runtime, {
|
||||
runId: params.run,
|
||||
callerTerminalHandle: params.from,
|
||||
requireCurrentConsumer: true
|
||||
requireCurrentConsumer: true,
|
||||
legacyCoordinatorRunId
|
||||
})
|
||||
if (task.run_id !== run.id) {
|
||||
throw new OrchestrationError(
|
||||
@@ -1143,8 +1278,13 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
}
|
||||
}
|
||||
|
||||
const assigneePaneKey = runtime.getTerminalPaneKey(to) ?? undefined
|
||||
const processIncarnation = runtime.getTerminalProcessIncarnation(to) ?? undefined
|
||||
const dispatchAuthority = runtime.getOrchestrationDispatchAuthority(to)
|
||||
const assigneePaneKey =
|
||||
dispatchAuthority?.paneKey ?? runtime.getTerminalPaneKey(to) ?? undefined
|
||||
const processIncarnation =
|
||||
dispatchAuthority?.processIncarnation ??
|
||||
runtime.getTerminalProcessIncarnation(to) ??
|
||||
undefined
|
||||
if (params.inject && (!assigneePaneKey || !processIncarnation)) {
|
||||
throw new OrchestrationError(
|
||||
'stable_pane_required',
|
||||
@@ -1152,7 +1292,13 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
)
|
||||
}
|
||||
|
||||
const ctx = db.createDispatchContext(params.task, to, assigneePaneKey)
|
||||
revalidateLegacyCoordinator?.()
|
||||
const ctx = db.createDispatchContext(
|
||||
params.task,
|
||||
to,
|
||||
assigneePaneKey,
|
||||
dispatchAuthority?.launchTokenHash ?? undefined
|
||||
)
|
||||
const dispatchCapability = params.inject
|
||||
? db.mintDispatchCapability({
|
||||
dispatchId: ctx.id,
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence'
|
||||
import type {
|
||||
DispatchContextRow,
|
||||
LegacyCompatibilityPrincipalRow,
|
||||
LegacyPrincipalRole
|
||||
} from '../orchestration/types'
|
||||
import { OrchestrationError } from '../orchestration/orchestration-error'
|
||||
import type { OrcaRuntimeService, OrchestrationCompatibilityCallerAuthority } from '../orca-runtime'
|
||||
import { LEGACY_CONTRACT_VERSION } from '../orchestration/db'
|
||||
import type { RpcRequest } from './core'
|
||||
import {
|
||||
equivalentLegacyPaneKey,
|
||||
legacyCoordinatorReadOnly,
|
||||
legacyReadOnlyError
|
||||
} from './orchestration-legacy-process-identity'
|
||||
|
||||
export type LegacyPrincipalCandidate = {
|
||||
runId: string
|
||||
role: LegacyPrincipalRole
|
||||
dispatchId?: string
|
||||
terminalHandle: string
|
||||
paneKey: string
|
||||
}
|
||||
|
||||
export function resolveAttestedLegacyPrincipal(args: {
|
||||
runtime: OrcaRuntimeService
|
||||
evidence?: OrchestrationCompatibilityEvidence
|
||||
candidate: LegacyPrincipalCandidate
|
||||
authority?: OrchestrationCompatibilityCallerAuthority
|
||||
}): LegacyCompatibilityPrincipalRow {
|
||||
const authority = args.authority ?? verifyAttestedLegacyCandidate(args)
|
||||
return args.runtime.getOrchestrationDb().commitLegacyCompatibilityPrincipal({
|
||||
runId: args.candidate.runId,
|
||||
dispatchId: args.candidate.dispatchId,
|
||||
role: args.candidate.role,
|
||||
hostScope: JSON.stringify(authority.hostScope),
|
||||
terminalHandle: args.candidate.terminalHandle,
|
||||
paneKey: args.candidate.paneKey,
|
||||
launchTokenHash: authority.launchTokenHash,
|
||||
processIncarnation: authority.processIncarnation
|
||||
}).principal
|
||||
}
|
||||
|
||||
export function verifyAttestedLegacyCandidate(args: {
|
||||
runtime: OrcaRuntimeService
|
||||
evidence?: OrchestrationCompatibilityEvidence
|
||||
candidate: LegacyPrincipalCandidate
|
||||
}) {
|
||||
const authority = args.runtime.verifyOrchestrationCompatibilityCaller(args.evidence)
|
||||
if (
|
||||
!authority ||
|
||||
!equivalentLegacyPaneKey(args.candidate.paneKey, authority.paneKey) ||
|
||||
args.candidate.terminalHandle !== authority.terminalHandle
|
||||
) {
|
||||
throw legacyReadOnlyError()
|
||||
}
|
||||
if (
|
||||
args.candidate.role === 'worker' &&
|
||||
args.candidate.dispatchId &&
|
||||
!args.runtime.getOrchestrationDb().isDispatchProcessCurrent({
|
||||
dispatchId: args.candidate.dispatchId,
|
||||
paneKey: authority.paneKey,
|
||||
processIncarnation: authority.processIncarnation
|
||||
})
|
||||
) {
|
||||
throw legacyReadOnlyError()
|
||||
}
|
||||
return authority
|
||||
}
|
||||
|
||||
export class LegacyCompatibilityAuthority {
|
||||
constructor(private readonly runtime: OrcaRuntimeService) {}
|
||||
|
||||
resolveWorkerDispatch(
|
||||
request: RpcRequest,
|
||||
target: { terminalHandle?: string; dispatchId?: string; taskId?: string }
|
||||
): DispatchContextRow | undefined {
|
||||
const db = this.runtime.getOrchestrationDb()
|
||||
const evidence = request.orchestrationCompatibilityEvidence
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id
|
||||
const existing = adoptedRunId
|
||||
? db.resolveLegacyCompatibilityPrincipalByIdentity({
|
||||
runId: adoptedRunId,
|
||||
role: 'worker',
|
||||
terminalHandle: evidence?.terminalHandle,
|
||||
paneKey: evidence?.paneKey
|
||||
})
|
||||
: undefined
|
||||
if (existing?.dispatch_id) {
|
||||
verifyAttestedLegacyCandidate({
|
||||
runtime: this.runtime,
|
||||
evidence,
|
||||
candidate: candidateFromPrincipal(existing)
|
||||
})
|
||||
const settledDispatch = db.getDispatchContextById(existing.dispatch_id)
|
||||
if (
|
||||
settledDispatch &&
|
||||
(!target.dispatchId || target.dispatchId === settledDispatch.id) &&
|
||||
(!target.taskId || target.taskId === settledDispatch.task_id)
|
||||
) {
|
||||
return settledDispatch
|
||||
}
|
||||
}
|
||||
const dispatch = db.resolveLegacyWorkerCandidate({
|
||||
runId: adoptedRunId,
|
||||
terminalHandle: evidence?.terminalHandle ?? target.terminalHandle,
|
||||
paneKey: evidence?.paneKey,
|
||||
dispatchId: target.dispatchId,
|
||||
taskId: target.taskId
|
||||
})?.dispatch
|
||||
if (!dispatch && evidence) {
|
||||
const retained = db.resolveLegacyWorkerCandidate({
|
||||
runId: adoptedRunId,
|
||||
terminalHandle: target.terminalHandle,
|
||||
dispatchId: target.dispatchId,
|
||||
taskId: target.taskId
|
||||
})
|
||||
if (retained) {
|
||||
throw legacyReadOnlyError()
|
||||
}
|
||||
}
|
||||
return dispatch
|
||||
}
|
||||
|
||||
resolveAskDispatch(
|
||||
request: RpcRequest,
|
||||
params: { from?: string; resume?: string }
|
||||
): DispatchContextRow | undefined {
|
||||
const db = this.runtime.getOrchestrationDb()
|
||||
if (params.resume) {
|
||||
const question = db.getQuestion(params.resume)
|
||||
return question ? db.getDispatchContextById(question.dispatch_id) : undefined
|
||||
}
|
||||
return this.resolveWorkerDispatch(request, { terminalHandle: params.from })
|
||||
}
|
||||
|
||||
resolveCheckPrincipal(
|
||||
request: RpcRequest,
|
||||
terminalHandle?: string
|
||||
): LegacyCompatibilityPrincipalRow | undefined {
|
||||
const db = this.runtime.getOrchestrationDb()
|
||||
const adoption = db.getLegacyAdoption()
|
||||
if (!adoption) {
|
||||
return undefined
|
||||
}
|
||||
const evidence = request.orchestrationCompatibilityEvidence
|
||||
const existingWorker = db.resolveLegacyCompatibilityPrincipalByIdentity({
|
||||
runId: adoption.adopted_run_id,
|
||||
role: 'worker',
|
||||
terminalHandle: evidence?.terminalHandle,
|
||||
paneKey: evidence?.paneKey
|
||||
})
|
||||
if (existingWorker) {
|
||||
verifyAttestedLegacyCandidate({
|
||||
runtime: this.runtime,
|
||||
evidence,
|
||||
candidate: candidateFromPrincipal(existingWorker)
|
||||
})
|
||||
return existingWorker
|
||||
}
|
||||
const worker = db.resolveLegacyWorkerCandidate({
|
||||
runId: adoption.adopted_run_id,
|
||||
terminalHandle: evidence?.terminalHandle ?? terminalHandle,
|
||||
paneKey: evidence?.paneKey
|
||||
})
|
||||
if (worker) {
|
||||
return this.attestWorker(request, worker.dispatch)
|
||||
}
|
||||
if (evidence) {
|
||||
const retained = db.resolveLegacyWorkerCandidate({
|
||||
runId: adoption.adopted_run_id,
|
||||
terminalHandle
|
||||
})
|
||||
const settled = db.resolveLegacyCompatibilityPrincipalByIdentity({
|
||||
runId: adoption.adopted_run_id,
|
||||
role: 'worker',
|
||||
terminalHandle
|
||||
})
|
||||
if (retained || settled) {
|
||||
throw legacyReadOnlyError()
|
||||
}
|
||||
}
|
||||
return this.attestCoordinator(request, adoption.adopted_run_id, false)
|
||||
}
|
||||
|
||||
attestWorker(request: RpcRequest, dispatch: DispatchContextRow): LegacyCompatibilityPrincipalRow {
|
||||
if (dispatch.contract_version !== LEGACY_CONTRACT_VERSION) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Dispatch ${dispatch.id} does not use the legacy contract.`
|
||||
)
|
||||
}
|
||||
return resolveAttestedLegacyPrincipal({
|
||||
runtime: this.runtime,
|
||||
evidence: request.orchestrationCompatibilityEvidence,
|
||||
candidate: candidateFromDispatch(dispatch)
|
||||
})
|
||||
}
|
||||
|
||||
attestCoordinator(
|
||||
request: RpcRequest,
|
||||
runId: string,
|
||||
required = true
|
||||
): LegacyCompatibilityPrincipalRow | undefined {
|
||||
const db = this.runtime.getOrchestrationDb()
|
||||
const candidate = db.resolveLegacyCoordinatorCandidate({
|
||||
runId,
|
||||
terminalHandle: request.orchestrationCompatibilityEvidence?.terminalHandle,
|
||||
paneKey: request.orchestrationCompatibilityEvidence?.paneKey
|
||||
})
|
||||
if (!candidate) {
|
||||
if (required) {
|
||||
throw legacyCoordinatorReadOnly()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const principal = resolveAttestedLegacyPrincipal({
|
||||
runtime: this.runtime,
|
||||
evidence: request.orchestrationCompatibilityEvidence,
|
||||
candidate: {
|
||||
runId,
|
||||
role: 'coordinator',
|
||||
terminalHandle: candidate.terminalHandle,
|
||||
paneKey: candidate.paneKey
|
||||
}
|
||||
})
|
||||
return principal
|
||||
}
|
||||
}
|
||||
|
||||
function candidateFromDispatch(dispatch: DispatchContextRow): LegacyPrincipalCandidate {
|
||||
if (!dispatch.assignee_handle || !dispatch.assignee_pane_key) {
|
||||
throw new OrchestrationError(
|
||||
'legacy_read_only',
|
||||
`Dispatch ${dispatch.id} lacks durable process identity. No effects were applied.`
|
||||
)
|
||||
}
|
||||
return {
|
||||
runId: dispatch.run_id,
|
||||
role: 'worker',
|
||||
dispatchId: dispatch.id,
|
||||
terminalHandle: dispatch.assignee_handle,
|
||||
paneKey: dispatch.assignee_pane_key
|
||||
}
|
||||
}
|
||||
|
||||
function candidateFromPrincipal(
|
||||
principal: LegacyCompatibilityPrincipalRow
|
||||
): LegacyPrincipalCandidate {
|
||||
return {
|
||||
runId: principal.run_id,
|
||||
role: principal.role,
|
||||
dispatchId: principal.dispatch_id ?? undefined,
|
||||
terminalHandle: principal.terminal_handle,
|
||||
paneKey: principal.pane_key
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, vi } from 'vitest'
|
||||
import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence'
|
||||
import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { OrcaRuntimeService } from '../orca-runtime'
|
||||
import { OrchestrationDb } from '../orchestration/db'
|
||||
import type { RpcRequest, RpcResponse } from './core'
|
||||
import { RpcDispatcher } from './dispatcher'
|
||||
import { ORCHESTRATION_METHODS } from './methods/orchestration'
|
||||
|
||||
export const WORKER_HANDLE = 'term_legacy_worker'
|
||||
export const WORKER_PANE = 'tab_worker:33333333-3333-4333-8333-333333333333'
|
||||
export const COORDINATOR_HANDLE = 'term_legacy_coord'
|
||||
export const COORDINATOR_PANE = 'tab_coord:44444444-4444-4444-8444-444444444444'
|
||||
|
||||
type Transport = 'dispatch' | 'websocket'
|
||||
|
||||
export type LegacyCompatibilityDispatcherHarness = {
|
||||
db: OrchestrationDb
|
||||
dispatcher: RpcDispatcher
|
||||
runtime: OrcaRuntimeService
|
||||
adoptedRunId: string
|
||||
taskId: string
|
||||
dispatchId: string
|
||||
notify: ReturnType<typeof vi.spyOn>
|
||||
verify: ReturnType<typeof vi.spyOn>
|
||||
}
|
||||
|
||||
const tempDirs: string[] = []
|
||||
const databases: OrchestrationDb[] = []
|
||||
|
||||
export function cleanupLegacyCompatibilityDispatcherHarnesses(): void {
|
||||
for (const database of databases.splice(0)) {
|
||||
database.close()
|
||||
}
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
export function createHarness(): LegacyCompatibilityDispatcherHarness {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-legacy-dispatcher-'))
|
||||
tempDirs.push(dir)
|
||||
const dbPath = join(dir, 'orchestration.db')
|
||||
const before = new OrchestrationDb(dbPath)
|
||||
const task = before.createTask({
|
||||
spec: 'legacy assignment',
|
||||
createdByTerminalHandle: COORDINATOR_HANDLE
|
||||
})
|
||||
const dispatch = before.createDispatchContext(task.id, WORKER_HANDLE, WORKER_PANE)
|
||||
before.close()
|
||||
|
||||
const raw = new Database(dbPath)
|
||||
raw.exec(`
|
||||
UPDATE dispatch_contexts SET process_incarnation = 'process-1';
|
||||
DROP INDEX IF EXISTS idx_messages_delivery_contract;
|
||||
DROP TABLE legacy_mail_receipts;
|
||||
DROP TABLE legacy_operation_receipts;
|
||||
DROP TABLE legacy_compatibility_principals;
|
||||
DROP TABLE legacy_adoptions;
|
||||
`)
|
||||
raw.pragma('user_version = 18')
|
||||
raw.close()
|
||||
|
||||
const db = new OrchestrationDb(dbPath)
|
||||
databases.push(db)
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
|
||||
const runtime = new OrcaRuntimeService()
|
||||
runtime.setOrchestrationDb(db)
|
||||
vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) =>
|
||||
handle === COORDINATOR_HANDLE ? COORDINATOR_PANE : handle === WORKER_HANDLE ? WORKER_PANE : null
|
||||
)
|
||||
const verify = vi
|
||||
.spyOn(runtime, 'verifyOrchestrationCompatibilityCaller')
|
||||
.mockImplementation((evidence) => {
|
||||
const validWorker =
|
||||
evidence?.terminalHandle === WORKER_HANDLE && evidence.paneKey === WORKER_PANE
|
||||
const validCoordinator =
|
||||
evidence?.terminalHandle === COORDINATOR_HANDLE && evidence.paneKey === COORDINATOR_PANE
|
||||
if ((!validWorker && !validCoordinator) || !evidence?.launchToken) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
hostScope: { kind: 'local', hostId: 'local' },
|
||||
terminalHandle: evidence.terminalHandle as string,
|
||||
paneKey: evidence.paneKey as string,
|
||||
processIncarnation: 'process-1',
|
||||
launchTokenHash: createHash('sha256').update(evidence.launchToken).digest('hex')
|
||||
}
|
||||
})
|
||||
const notify = vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {})
|
||||
return {
|
||||
db,
|
||||
dispatcher: new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }),
|
||||
runtime,
|
||||
adoptedRunId,
|
||||
taskId: task.id,
|
||||
dispatchId: dispatch.id,
|
||||
notify,
|
||||
verify
|
||||
}
|
||||
}
|
||||
|
||||
export function evidence(
|
||||
role: 'worker' | 'coordinator',
|
||||
valid = true
|
||||
): OrchestrationCompatibilityEvidence {
|
||||
const worker = role === 'worker'
|
||||
return {
|
||||
terminalHandle: worker ? WORKER_HANDLE : COORDINATOR_HANDLE,
|
||||
paneKey: valid ? (worker ? WORKER_PANE : COORDINATOR_PANE) : 'tab_wrong:wrong-leaf',
|
||||
launchToken: `${role}-token`
|
||||
}
|
||||
}
|
||||
|
||||
export function request(
|
||||
method: string,
|
||||
params: unknown,
|
||||
proof: OrchestrationCompatibilityEvidence,
|
||||
invocationId: string
|
||||
): RpcRequest {
|
||||
return {
|
||||
id: `rpc_${invocationId}`,
|
||||
authToken: 'caller-token',
|
||||
method,
|
||||
params,
|
||||
orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION,
|
||||
orchestrationRequestId: invocationId,
|
||||
compatibilityInvocationId: invocationId,
|
||||
orchestrationCompatibilityEvidence: proof
|
||||
}
|
||||
}
|
||||
|
||||
export async function invoke(
|
||||
dispatcher: RpcDispatcher,
|
||||
rpcRequest: RpcRequest,
|
||||
transport: Transport
|
||||
): Promise<RpcResponse> {
|
||||
if (transport === 'dispatch') {
|
||||
return await dispatcher.dispatch(rpcRequest)
|
||||
}
|
||||
const replies: string[] = []
|
||||
await dispatcher.dispatchStreaming(rpcRequest, (reply) => replies.push(reply))
|
||||
expect(replies).toHaveLength(1)
|
||||
return JSON.parse(replies[0]) as RpcResponse
|
||||
}
|
||||
|
||||
export function counts(db: OrchestrationDb): Record<string, number> {
|
||||
const sqlite = (db as unknown as { db: Database.Database }).db
|
||||
return Object.fromEntries(
|
||||
[
|
||||
'messages',
|
||||
'legacy_compatibility_principals',
|
||||
'legacy_operation_receipts',
|
||||
'legacy_mail_receipts',
|
||||
'mutation_receipts'
|
||||
].map((table) => [
|
||||
table,
|
||||
(sqlite.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number }).count
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
export function escalationParams(harness: LegacyCompatibilityDispatcherHarness) {
|
||||
return {
|
||||
from: WORKER_HANDLE,
|
||||
to: COORDINATOR_HANDLE,
|
||||
subject: 'Blocked',
|
||||
type: 'escalation',
|
||||
payload: JSON.stringify({ taskId: harness.taskId, dispatchId: harness.dispatchId })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
import {
|
||||
cleanupLegacyCompatibilityDispatcherHarnesses,
|
||||
COORDINATOR_HANDLE,
|
||||
COORDINATOR_PANE,
|
||||
counts,
|
||||
createHarness,
|
||||
escalationParams,
|
||||
evidence,
|
||||
invoke,
|
||||
request,
|
||||
WORKER_HANDLE,
|
||||
WORKER_PANE
|
||||
} from './orchestration-legacy-compatibility-dispatcher-test-fixture'
|
||||
|
||||
afterEach(() => {
|
||||
cleanupLegacyCompatibilityDispatcherHarnesses()
|
||||
})
|
||||
|
||||
describe('legacy compatibility through RpcDispatcher', () => {
|
||||
it('rejects malformed current-contract input before compatibility attestation', async () => {
|
||||
const harness = createHarness()
|
||||
const before = counts(harness.db)
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.send',
|
||||
{ from: WORKER_HANDLE, type: 'escalation' },
|
||||
evidence('worker'),
|
||||
'malformed'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
|
||||
expect(harness.verify).not.toHaveBeenCalled()
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['dispatch', true],
|
||||
['dispatch', false],
|
||||
['websocket', true],
|
||||
['websocket', false]
|
||||
] as const)(
|
||||
'%s routes task-only escalation with valid proof=%s and zero partial effects',
|
||||
async (transport, valid) => {
|
||||
const harness = createHarness()
|
||||
const before = counts(harness.db)
|
||||
const response = await invoke(
|
||||
harness.dispatcher,
|
||||
request(
|
||||
'orchestration.send',
|
||||
escalationParams(harness),
|
||||
evidence('worker', valid),
|
||||
`${transport}-${valid}`
|
||||
),
|
||||
transport
|
||||
)
|
||||
|
||||
if (!valid) {
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'legacy_read_only' }
|
||||
})
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
expect(harness.notify).not.toHaveBeenCalled()
|
||||
return
|
||||
}
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
message: { type: 'escalation', delivery_contract: 'legacy_direct' },
|
||||
legacyCompatibility: { replayed: false }
|
||||
}
|
||||
})
|
||||
expect(counts(harness.db)).toEqual({
|
||||
...before,
|
||||
messages: before.messages + 1,
|
||||
legacy_compatibility_principals: before.legacy_compatibility_principals + 1,
|
||||
legacy_operation_receipts: before.legacy_operation_receipts + 1
|
||||
})
|
||||
expect(harness.notify).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
it('validates, infers, settles, and replays legacy worker completion exactly once', async () => {
|
||||
const harness = createHarness()
|
||||
const baseParams = {
|
||||
from: WORKER_HANDLE,
|
||||
to: COORDINATOR_HANDLE,
|
||||
type: 'worker_done',
|
||||
body: 'legacy result',
|
||||
payload: JSON.stringify({ taskId: harness.taskId, dispatchId: harness.dispatchId })
|
||||
}
|
||||
const before = counts(harness.db)
|
||||
const invalid = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.send',
|
||||
{ ...baseParams, subject: 'Completed', payload: JSON.stringify({ outcome: 'maybe' }) },
|
||||
evidence('worker'),
|
||||
'invalid-outcome'
|
||||
)
|
||||
)
|
||||
|
||||
expect(invalid).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
|
||||
const firstRequest = request(
|
||||
'orchestration.send',
|
||||
{ ...baseParams, subject: 'Completed' },
|
||||
evidence('worker'),
|
||||
'completion'
|
||||
)
|
||||
const first = await harness.dispatcher.dispatch(firstRequest)
|
||||
const replay = await harness.dispatcher.dispatch({ ...firstRequest, id: 'rpc_replay' })
|
||||
const mismatch = await harness.dispatcher.dispatch({
|
||||
...firstRequest,
|
||||
id: 'rpc_mismatch',
|
||||
params: { ...baseParams, subject: 'Changed completion' }
|
||||
})
|
||||
|
||||
expect(first).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
lifecycle: { action: 'settled', outcome: 'succeeded' },
|
||||
legacyCompatibility: { replayed: false }
|
||||
}
|
||||
})
|
||||
expect(replay).toMatchObject({
|
||||
ok: true,
|
||||
result: { legacyCompatibility: { replayed: true } }
|
||||
})
|
||||
expect(mismatch).toMatchObject({ ok: false, error: { code: 'request_mismatch' } })
|
||||
expect(harness.db.getTask(harness.taskId)?.status).toBe('completed')
|
||||
expect(harness.db.getDispatchContextById(harness.dispatchId)?.status).toBe('completed')
|
||||
expect(counts(harness.db)).toEqual({
|
||||
...before,
|
||||
messages: before.messages + 1,
|
||||
legacy_compatibility_principals: before.legacy_compatibility_principals + 1,
|
||||
legacy_operation_receipts: before.legacy_operation_receipts + 1
|
||||
})
|
||||
expect(harness.notify).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('replays an A-era completion without touching a newer current attempt', async () => {
|
||||
const harness = createHarness()
|
||||
const payload = JSON.stringify({
|
||||
taskId: harness.taskId,
|
||||
dispatchId: harness.dispatchId
|
||||
})
|
||||
const completion = harness.db.insertMessage({
|
||||
runId: harness.adoptedRunId,
|
||||
from: WORKER_HANDLE,
|
||||
to: COORDINATOR_HANDLE,
|
||||
subject: 'Failed: pinned A treated this as complete',
|
||||
body: 'persisted result',
|
||||
type: 'worker_done',
|
||||
payload,
|
||||
senderPaneKey: WORKER_PANE,
|
||||
deliveryContract: 'legacy_direct'
|
||||
})
|
||||
harness.db.settleWorkerReport({
|
||||
taskId: harness.taskId,
|
||||
dispatchId: harness.dispatchId,
|
||||
outcome: 'succeeded',
|
||||
result: 'persisted result'
|
||||
})
|
||||
harness.db.commitLegacyCompatibilityPrincipal({
|
||||
runId: harness.adoptedRunId,
|
||||
dispatchId: harness.dispatchId,
|
||||
role: 'worker',
|
||||
hostScope: JSON.stringify({ kind: 'local', hostId: 'local' }),
|
||||
terminalHandle: WORKER_HANDLE,
|
||||
paneKey: WORKER_PANE,
|
||||
launchTokenHash: createHash('sha256').update('worker-token').digest('hex'),
|
||||
processIncarnation: 'process-1'
|
||||
})
|
||||
harness.db.updateTaskStatus(harness.taskId, 'ready')
|
||||
const currentDispatch = harness.db.createDispatchContext(
|
||||
harness.taskId,
|
||||
'term_current_worker',
|
||||
'tab_current_worker:77777777-7777-4777-8777-777777777777',
|
||||
'current-launch-hash'
|
||||
)
|
||||
const currentTaskBefore = harness.db.getTask(harness.taskId)
|
||||
const currentDispatchBefore = harness.db.getDispatchContextById(currentDispatch.id)
|
||||
const before = counts(harness.db)
|
||||
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.send',
|
||||
{
|
||||
from: WORKER_HANDLE,
|
||||
to: COORDINATOR_HANDLE,
|
||||
subject: completion.subject,
|
||||
body: completion.body,
|
||||
type: 'worker_done',
|
||||
payload
|
||||
},
|
||||
evidence('worker'),
|
||||
'reconstruct-pinned-a-completion'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
message: { id: completion.id },
|
||||
lifecycle: { action: 'settled', outcome: 'succeeded', duplicate: true }
|
||||
}
|
||||
})
|
||||
expect(harness.db.getTask(harness.taskId)).toEqual(currentTaskBefore)
|
||||
expect(harness.db.getDispatchContextById(harness.dispatchId)?.status).toBe('completed')
|
||||
expect(harness.db.getDispatchContextById(currentDispatch.id)).toEqual(currentDispatchBefore)
|
||||
expect(counts(harness.db)).toEqual({
|
||||
...before,
|
||||
legacy_operation_receipts: before.legacy_operation_receipts + 1
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a legacy lifecycle recipient outside the adopted Run with zero effects', async () => {
|
||||
const harness = createHarness()
|
||||
const before = counts(harness.db)
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.send',
|
||||
{
|
||||
...escalationParams(harness),
|
||||
to: 'term_unrelated'
|
||||
},
|
||||
evidence('worker'),
|
||||
'wrong-recipient'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'request_mismatch' }
|
||||
})
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
expect(harness.verify).not.toHaveBeenCalled()
|
||||
expect(harness.notify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a reused pane whose live process incarnation is not the legacy worker', async () => {
|
||||
const harness = createHarness()
|
||||
const sqlite = (harness.db as unknown as { db: Database.Database }).db
|
||||
sqlite
|
||||
.prepare('UPDATE dispatch_contexts SET process_incarnation = ? WHERE id = ?')
|
||||
.run('different-process', harness.dispatchId)
|
||||
const before = counts(harness.db)
|
||||
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request('orchestration.send', escalationParams(harness), evidence('worker'), 'reused-pane')
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'legacy_read_only' } })
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
expect(harness.notify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a legacy question recipient outside the adopted Run with zero effects', async () => {
|
||||
const harness = createHarness()
|
||||
const before = counts(harness.db)
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.ask',
|
||||
{
|
||||
from: WORKER_HANDLE,
|
||||
to: 'term_unrelated',
|
||||
question: 'Proceed?',
|
||||
timeoutMs: 1
|
||||
},
|
||||
evidence('worker'),
|
||||
'wrong-question-recipient'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'request_mismatch' }
|
||||
})
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
expect(harness.verify).not.toHaveBeenCalled()
|
||||
expect(harness.notify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps distinct compatibility ask invocations on distinct questions', async () => {
|
||||
const harness = createHarness()
|
||||
const ask = {
|
||||
from: WORKER_HANDLE,
|
||||
to: COORDINATOR_HANDLE,
|
||||
question: 'Same question?',
|
||||
options: ['yes', 'no'],
|
||||
timeoutMs: 1
|
||||
}
|
||||
const first = await harness.dispatcher.dispatch(
|
||||
request('orchestration.ask', ask, evidence('worker'), 'ask-one')
|
||||
)
|
||||
const replay = await harness.dispatcher.dispatch(
|
||||
request('orchestration.ask', ask, evidence('worker'), 'ask-one')
|
||||
)
|
||||
const second = await harness.dispatcher.dispatch(
|
||||
request('orchestration.ask', ask, evidence('worker'), 'ask-two')
|
||||
)
|
||||
|
||||
expect(first).toMatchObject({ ok: true, result: { legacyCompatibility: { replayed: false } } })
|
||||
expect(replay).toMatchObject({ ok: true, result: { legacyCompatibility: { replayed: true } } })
|
||||
expect(second).toMatchObject({ ok: true, result: { legacyCompatibility: { replayed: false } } })
|
||||
const firstId = (first as { result: { messageId: string } }).result.messageId
|
||||
const replayId = (replay as { result: { messageId: string } }).result.messageId
|
||||
const secondId = (second as { result: { messageId: string } }).result.messageId
|
||||
expect(replayId).toBe(firstId)
|
||||
expect(secondId).not.toBe(firstId)
|
||||
expect(harness.db.getMessageById(firstId)?.type).toBe('decision_gate')
|
||||
|
||||
const coordinatorCheck = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{
|
||||
terminal: COORDINATOR_HANDLE,
|
||||
types: 'worker_done,escalation,decision_gate',
|
||||
format: true
|
||||
},
|
||||
evidence('coordinator'),
|
||||
'coordinator-check-decision-gates'
|
||||
)
|
||||
)
|
||||
expect(coordinatorCheck).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
messages: [
|
||||
{ id: firstId, type: 'decision_gate' },
|
||||
{ id: secondId, type: 'decision_gate' }
|
||||
],
|
||||
count: 2
|
||||
}
|
||||
})
|
||||
expect((coordinatorCheck as { result: { formatted: string } }).result.formatted).toContain(
|
||||
`orca orchestration reply --id ${firstId}`
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the normal wait budget when legacy check omits timeout', async () => {
|
||||
const harness = createHarness()
|
||||
const waitForMessage = vi
|
||||
.spyOn(harness.runtime, 'waitForMessage')
|
||||
.mockResolvedValue('timed_out')
|
||||
let clockReads = 0
|
||||
const now = vi.spyOn(Date, 'now').mockImplementation(() => {
|
||||
clockReads += 1
|
||||
return clockReads === 1 ? 0 : clockReads === 2 ? 1 : 120_001
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{ terminal: WORKER_HANDLE, wait: true },
|
||||
evidence('worker'),
|
||||
'legacy-check-default-timeout'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { messages: [], count: 0, timedOut: true }
|
||||
})
|
||||
expect(waitForMessage).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
now.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not infer an outcome for a current Dispatch when legacy adoption exists', async () => {
|
||||
const harness = createHarness()
|
||||
const run = harness.db.createRun({
|
||||
objective: 'current work',
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current_coord:55555555-5555-4555-8555-555555555555'
|
||||
})
|
||||
const task = harness.db.createTask({ spec: 'current assignment', runId: run.id })
|
||||
const dispatch = harness.db.createDispatchContext(
|
||||
task.id,
|
||||
'term_current_worker',
|
||||
'tab_current_worker:66666666-6666-4666-8666-666666666666',
|
||||
'current-launch-hash'
|
||||
)
|
||||
const before = counts(harness.db)
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.send',
|
||||
{
|
||||
from: 'term_current_worker',
|
||||
to: COORDINATOR_HANDLE,
|
||||
subject: 'Completed',
|
||||
type: 'worker_done',
|
||||
payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id })
|
||||
},
|
||||
{
|
||||
terminalHandle: 'term_current_worker',
|
||||
paneKey: 'tab_current_worker:66666666-6666-4666-8666-666666666666',
|
||||
launchToken: 'current-token'
|
||||
},
|
||||
'current-missing-outcome'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
|
||||
expect(harness.db.getTask(task.id)?.status).toBe('dispatched')
|
||||
expect(harness.db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched')
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
})
|
||||
|
||||
it('rejects invalid typed ACKs and consumes only the filtered legacy page', async () => {
|
||||
const harness = createHarness()
|
||||
await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.send',
|
||||
{
|
||||
...escalationParams(harness),
|
||||
type: 'worker_done',
|
||||
subject: 'Completed',
|
||||
payload: JSON.stringify({ taskId: harness.taskId, dispatchId: harness.dispatchId })
|
||||
},
|
||||
evidence('worker'),
|
||||
'settle-for-mail'
|
||||
)
|
||||
)
|
||||
const status = harness.db.insertMessage({
|
||||
runId: harness.adoptedRunId,
|
||||
from: COORDINATOR_HANDLE,
|
||||
to: WORKER_HANDLE,
|
||||
subject: 'status first',
|
||||
type: 'status',
|
||||
deliveryContract: 'legacy_direct'
|
||||
})
|
||||
const question = harness.db.insertMessage({
|
||||
runId: harness.adoptedRunId,
|
||||
from: COORDINATOR_HANDLE,
|
||||
to: WORKER_HANDLE,
|
||||
subject: 'question second',
|
||||
type: 'question',
|
||||
deliveryContract: 'legacy_direct'
|
||||
})
|
||||
const check = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{ terminal: WORKER_HANDLE, types: 'question' },
|
||||
evidence('worker'),
|
||||
'question-check'
|
||||
)
|
||||
)
|
||||
expect(check).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
messages: [{ id: question.id }],
|
||||
legacyCompatibility: { ackMessageIds: [question.id] }
|
||||
}
|
||||
})
|
||||
|
||||
const invalid = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{
|
||||
terminal: WORKER_HANDLE,
|
||||
compatibilityAck: JSON.stringify({
|
||||
messageIds: [question.id],
|
||||
types: ['not-a-message-type']
|
||||
})
|
||||
},
|
||||
evidence('worker'),
|
||||
'invalid-ack'
|
||||
)
|
||||
)
|
||||
expect(invalid).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
|
||||
expect(harness.db.getMessageById(status.id)?.read).toBe(0)
|
||||
expect(harness.db.getMessageById(question.id)?.read).toBe(0)
|
||||
|
||||
const acknowledged = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{
|
||||
terminal: WORKER_HANDLE,
|
||||
compatibilityAck: JSON.stringify({
|
||||
messageIds: [question.id],
|
||||
types: ['question']
|
||||
})
|
||||
},
|
||||
evidence('worker'),
|
||||
'valid-ack'
|
||||
)
|
||||
)
|
||||
expect(acknowledged).toMatchObject({
|
||||
ok: true,
|
||||
result: { acknowledged: [question.id], legacyCompatibility: { acknowledged: true } }
|
||||
})
|
||||
expect(harness.db.getMessageById(status.id)?.read).toBe(0)
|
||||
expect(harness.db.getMessageById(question.id)?.read).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects invalid legacy check types before attestation or mail consumption', async () => {
|
||||
const harness = createHarness()
|
||||
const message = harness.db.insertMessage({
|
||||
runId: harness.adoptedRunId,
|
||||
from: COORDINATOR_HANDLE,
|
||||
to: WORKER_HANDLE,
|
||||
subject: 'retained status',
|
||||
type: 'status',
|
||||
deliveryContract: 'legacy_direct'
|
||||
})
|
||||
const before = counts(harness.db)
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{ terminal: WORKER_HANDLE, types: 'status,not-a-message-type' },
|
||||
evidence('worker'),
|
||||
'invalid-types'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'invalid_argument', message: 'Invalid --types: not-a-message-type' }
|
||||
})
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
expect(harness.db.getMessageById(message.id)?.read).toBe(0)
|
||||
expect(harness.verify).not.toHaveBeenCalled()
|
||||
expect(harness.notify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('advertises only Run-addressed current delivery to a legacy coordinator', async () => {
|
||||
const harness = createHarness()
|
||||
harness.db.insertMessage({
|
||||
runId: harness.adoptedRunId,
|
||||
from: 'term_current_worker',
|
||||
to: WORKER_HANDLE,
|
||||
subject: 'worker-only current mail',
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
const workerOnly = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{ terminal: COORDINATOR_HANDLE },
|
||||
evidence('coordinator'),
|
||||
'worker-current-mail'
|
||||
)
|
||||
)
|
||||
expect(workerOnly).toMatchObject({
|
||||
ok: true,
|
||||
result: { legacyCompatibility: { currentDelivery: undefined } }
|
||||
})
|
||||
|
||||
harness.db.insertMessage({
|
||||
runId: harness.adoptedRunId,
|
||||
from: 'term_current_worker',
|
||||
to: `run:${harness.adoptedRunId}`,
|
||||
subject: 'Run current mail',
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
const runMail = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{ terminal: COORDINATOR_HANDLE },
|
||||
evidence('coordinator'),
|
||||
'run-current-mail'
|
||||
)
|
||||
)
|
||||
expect(runMail).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
legacyCompatibility: {
|
||||
currentDelivery: {
|
||||
runId: harness.adoptedRunId,
|
||||
checkCommand: expect.stringContaining(`--run ${harness.adoptedRunId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a legacy coordinator reply outside the adopted Run with zero effects', async () => {
|
||||
const harness = createHarness()
|
||||
const unrelatedRun = harness.db.createRun({
|
||||
objective: 'unrelated current work',
|
||||
coordinatorHandle: 'term_unrelated_coord',
|
||||
coordinatorPaneKey: 'tab_unrelated_coord:55555555-5555-4555-8555-555555555555'
|
||||
})
|
||||
const unrelated = harness.db.insertMessage({
|
||||
runId: unrelatedRun.id,
|
||||
from: 'term_unrelated_worker',
|
||||
to: `run:${unrelatedRun.id}`,
|
||||
subject: 'Unrelated current status',
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
const before = counts(harness.db)
|
||||
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.reply',
|
||||
{
|
||||
id: unrelated.id,
|
||||
body: 'Forged cross-Run reply',
|
||||
from: COORDINATOR_HANDLE,
|
||||
run: harness.adoptedRunId
|
||||
},
|
||||
evidence('coordinator'),
|
||||
'cross-run-reply'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'request_mismatch',
|
||||
data: { effectsApplied: false }
|
||||
}
|
||||
})
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
expect(harness.db.getMessageById(unrelated.id)?.read).toBe(0)
|
||||
expect(harness.notify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps explicit adopted Run task inspection available to unrelated callers', async () => {
|
||||
const harness = createHarness()
|
||||
const before = counts(harness.db)
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.taskList',
|
||||
{ run: harness.adoptedRunId, callerTerminalHandle: 'term_unrelated' },
|
||||
{
|
||||
terminalHandle: 'term_unrelated',
|
||||
paneKey: 'tab_unrelated:55555555-5555-4555-8555-555555555555',
|
||||
launchToken: 'unrelated-token'
|
||||
},
|
||||
'explicit-adopted-run-inspection'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
runId: harness.adoptedRunId,
|
||||
tasks: [expect.objectContaining({ id: harness.taskId })]
|
||||
}
|
||||
})
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
expect(harness.verify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['dispatch', 'websocket'] as const)(
|
||||
'%s binds an attested coordinator once and rejects contradictory proof before binding',
|
||||
async (transport) => {
|
||||
const invalidHarness = createHarness()
|
||||
const invalidBefore = counts(invalidHarness.db)
|
||||
const rejected = await invoke(
|
||||
invalidHarness.dispatcher,
|
||||
request(
|
||||
'orchestration.runUse',
|
||||
{ id: invalidHarness.adoptedRunId, from: COORDINATOR_HANDLE },
|
||||
evidence('coordinator', false),
|
||||
`run-use-invalid-${transport}`
|
||||
),
|
||||
transport
|
||||
)
|
||||
expect(rejected).toMatchObject({ ok: false, error: { code: 'legacy_read_only' } })
|
||||
expect(invalidHarness.db.getRun(invalidHarness.adoptedRunId)?.consumer_generation).toBe(0)
|
||||
expect(counts(invalidHarness.db)).toEqual(invalidBefore)
|
||||
|
||||
const harness = createHarness()
|
||||
const runUse = request(
|
||||
'orchestration.runUse',
|
||||
{ id: harness.adoptedRunId, from: COORDINATOR_HANDLE },
|
||||
evidence('coordinator'),
|
||||
`run-use-${transport}`
|
||||
)
|
||||
const first = await invoke(harness.dispatcher, runUse, transport)
|
||||
const replay = await invoke(
|
||||
harness.dispatcher,
|
||||
{ ...runUse, id: 'rpc_run-use-replay' },
|
||||
transport
|
||||
)
|
||||
expect(first).toMatchObject({
|
||||
ok: true,
|
||||
result: { binding: { consumerGeneration: 1 }, mutation: { replayed: false } }
|
||||
})
|
||||
expect(replay).toMatchObject({
|
||||
ok: true,
|
||||
result: { binding: { consumerGeneration: 1 }, mutation: { replayed: true } }
|
||||
})
|
||||
expect(harness.db.getRun(harness.adoptedRunId)?.consumer_generation).toBe(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('passes trusted coordinator scope to a failing handler without binding or receipts', async () => {
|
||||
const harness = createHarness()
|
||||
const before = counts(harness.db)
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.taskUpdate',
|
||||
{
|
||||
id: 'task_missing',
|
||||
status: 'completed',
|
||||
callerTerminalHandle: COORDINATOR_HANDLE
|
||||
},
|
||||
evidence('coordinator'),
|
||||
'missing-task'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'task_not_found' } })
|
||||
expect(harness.db.getRun(harness.adoptedRunId)?.consumer_generation).toBe(0)
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
})
|
||||
|
||||
it('rejects stale legacy coordinator proof after current takeover', async () => {
|
||||
const harness = createHarness()
|
||||
const principal = harness.db.commitLegacyCompatibilityPrincipal({
|
||||
runId: harness.adoptedRunId,
|
||||
role: 'coordinator',
|
||||
hostScope: JSON.stringify({ kind: 'local', hostId: 'local' }),
|
||||
terminalHandle: COORDINATOR_HANDLE,
|
||||
paneKey: COORDINATOR_PANE,
|
||||
launchTokenHash: createHash('sha256').update('coordinator-token').digest('hex'),
|
||||
processIncarnation: 'process-1'
|
||||
}).principal
|
||||
harness.db.settleWorkerReport({
|
||||
taskId: harness.taskId,
|
||||
dispatchId: harness.dispatchId,
|
||||
outcome: 'succeeded',
|
||||
result: 'done'
|
||||
})
|
||||
harness.db.bindRun({
|
||||
runId: harness.adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:55555555-5555-4555-8555-555555555555'
|
||||
})
|
||||
expect(harness.db.getLegacyCompatibilityPrincipal(principal.id)?.status).toBe('revoked')
|
||||
const before = counts(harness.db)
|
||||
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.taskList',
|
||||
{},
|
||||
evidence('coordinator'),
|
||||
'stale-coordinator-after-takeover'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'legacy_read_only' } })
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,170 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { LegacyCoordinatorAuthorityProof, RpcRequest } from './core'
|
||||
import type { OrcaRuntimeService, OrchestrationCompatibilityCallerAuthority } from '../orca-runtime'
|
||||
import { LegacyCompatibilityAuthority } from './orchestration-legacy-authority'
|
||||
import { handleLegacyLifecycleSend } from './orchestration-legacy-lifecycle'
|
||||
import { handleLegacyCheck, handleLegacyReply } from './orchestration-legacy-mail'
|
||||
import { handleLegacyAsk } from './orchestration-legacy-question'
|
||||
import type {
|
||||
LegacyAskParams,
|
||||
LegacyCheckParams,
|
||||
LegacyReplyParams,
|
||||
LegacySendParams
|
||||
} from './orchestration-legacy-operation'
|
||||
import { LegacyCoordinatorAuthority } from './orchestration-legacy-coordinator-authority'
|
||||
|
||||
const COORDINATOR_PREFLIGHT_METHODS = new Set([
|
||||
'orchestration.taskCreate',
|
||||
'orchestration.taskList',
|
||||
'orchestration.taskUpdate',
|
||||
'orchestration.dispatch',
|
||||
'orchestration.gateCreate',
|
||||
'orchestration.gateResolve',
|
||||
'orchestration.runUse',
|
||||
'orchestration.send',
|
||||
'orchestration.check',
|
||||
'orchestration.reply'
|
||||
])
|
||||
|
||||
export type LegacyCompatibilityRoute =
|
||||
| { handled: true; result: unknown }
|
||||
| {
|
||||
handled: false
|
||||
params?: unknown
|
||||
legacyCoordinatorAuthority?: LegacyCoordinatorAuthorityProof
|
||||
orchestrationCompatibilityCallerAuthority?: OrchestrationCompatibilityCallerAuthority
|
||||
}
|
||||
|
||||
export type LegacyCoordinatorInvocation = Readonly<{
|
||||
authority: LegacyCoordinatorAuthorityProof
|
||||
mutationCallerFingerprint: string
|
||||
revalidate: () => string
|
||||
}>
|
||||
|
||||
export class OrchestrationLegacyCompatibility {
|
||||
private readonly authority: LegacyCompatibilityAuthority
|
||||
private readonly coordinatorAuthority: LegacyCoordinatorAuthority
|
||||
|
||||
constructor(private readonly runtime: OrcaRuntimeService) {
|
||||
this.authority = new LegacyCompatibilityAuthority(runtime)
|
||||
this.coordinatorAuthority = new LegacyCoordinatorAuthority(runtime)
|
||||
}
|
||||
|
||||
async tryHandle(
|
||||
request: RpcRequest,
|
||||
params: unknown,
|
||||
signal?: AbortSignal
|
||||
): Promise<LegacyCompatibilityRoute> {
|
||||
if (!request.method.startsWith('orchestration.')) {
|
||||
return { handled: false }
|
||||
}
|
||||
const result = await this.route(request, params, signal)
|
||||
if (result !== undefined) {
|
||||
return { handled: true, result }
|
||||
}
|
||||
if (!COORDINATOR_PREFLIGHT_METHODS.has(request.method)) {
|
||||
return { handled: false }
|
||||
}
|
||||
const values = params as Record<string, unknown>
|
||||
if (request.method === 'orchestration.runUse' && values.takeoverLegacy === true) {
|
||||
const callerAuthority = this.runtime.verifyOrchestrationCompatibilityCaller(
|
||||
request.orchestrationCompatibilityEvidence
|
||||
)
|
||||
return {
|
||||
handled: false,
|
||||
...(callerAuthority ? { orchestrationCompatibilityCallerAuthority: callerAuthority } : {})
|
||||
}
|
||||
}
|
||||
const requestedRunId =
|
||||
request.method === 'orchestration.runUse' ? stringValue(values.id) : stringValue(values.run)
|
||||
if (request.method === 'orchestration.taskList' && requestedRunId) {
|
||||
return { handled: false }
|
||||
}
|
||||
const authority = this.coordinatorAuthority.resolve(request, requestedRunId)
|
||||
return authority
|
||||
? {
|
||||
handled: false,
|
||||
params: { ...values, run: authority.runId },
|
||||
legacyCoordinatorAuthority: authority
|
||||
}
|
||||
: { handled: false }
|
||||
}
|
||||
|
||||
revalidateCoordinatorAuthority(
|
||||
request: RpcRequest,
|
||||
proof: LegacyCoordinatorAuthorityProof
|
||||
): string {
|
||||
return this.coordinatorAuthority.revalidate(request, proof)
|
||||
}
|
||||
|
||||
createCoordinatorInvocation(
|
||||
request: RpcRequest,
|
||||
authority?: LegacyCoordinatorAuthorityProof
|
||||
): LegacyCoordinatorInvocation | undefined {
|
||||
return authority
|
||||
? {
|
||||
authority,
|
||||
mutationCallerFingerprint: legacyCoordinatorMutationCallerFingerprint(authority),
|
||||
revalidate: () => this.revalidateCoordinatorAuthority(request, authority)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
private async route(
|
||||
request: RpcRequest,
|
||||
params: unknown,
|
||||
signal?: AbortSignal
|
||||
): Promise<unknown | undefined> {
|
||||
if (request.method === 'orchestration.send') {
|
||||
return await handleLegacyLifecycleSend({
|
||||
runtime: this.runtime,
|
||||
authority: this.authority,
|
||||
request,
|
||||
params: params as LegacySendParams
|
||||
})
|
||||
}
|
||||
if (request.method === 'orchestration.check') {
|
||||
return await handleLegacyCheck({
|
||||
runtime: this.runtime,
|
||||
authority: this.authority,
|
||||
request,
|
||||
params: params as LegacyCheckParams,
|
||||
signal
|
||||
})
|
||||
}
|
||||
if (request.method === 'orchestration.ask') {
|
||||
return await handleLegacyAsk({
|
||||
runtime: this.runtime,
|
||||
authority: this.authority,
|
||||
request,
|
||||
params: params as LegacyAskParams,
|
||||
signal
|
||||
})
|
||||
}
|
||||
if (request.method === 'orchestration.reply') {
|
||||
return await handleLegacyReply({
|
||||
runtime: this.runtime,
|
||||
authority: this.authority,
|
||||
request,
|
||||
params: params as LegacyReplyParams
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function legacyCoordinatorMutationCallerFingerprint(
|
||||
authority: LegacyCoordinatorAuthorityProof
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(
|
||||
['legacy-coordinator-v1', authority.runId, authority.terminalHandle, authority.paneKey].join(
|
||||
'\0'
|
||||
)
|
||||
)
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { OrcaRuntimeService, OrchestrationCompatibilityCallerAuthority } from '../orca-runtime'
|
||||
import type { LegacyCompatibilityPrincipalRow } from '../orchestration/types'
|
||||
import type { LegacyCoordinatorAuthorityProof, RpcRequest } from './core'
|
||||
import {
|
||||
resolveAttestedLegacyPrincipal,
|
||||
verifyAttestedLegacyCandidate,
|
||||
type LegacyPrincipalCandidate
|
||||
} from './orchestration-legacy-authority'
|
||||
import {
|
||||
equivalentLegacyPaneKey,
|
||||
legacyCoordinatorReadOnly
|
||||
} from './orchestration-legacy-process-identity'
|
||||
|
||||
export class LegacyCoordinatorAuthority {
|
||||
constructor(private readonly runtime: OrcaRuntimeService) {}
|
||||
|
||||
resolve(
|
||||
request: RpcRequest,
|
||||
requestedRunId?: string
|
||||
): LegacyCoordinatorAuthorityProof | undefined {
|
||||
const db = this.runtime.getOrchestrationDb()
|
||||
const adoption = db.getLegacyAdoption()
|
||||
const requestedRun = requestedRunId ?? adoption?.adopted_run_id
|
||||
if (!adoption || requestedRun !== adoption.adopted_run_id) {
|
||||
return undefined
|
||||
}
|
||||
const candidate = db.resolveLegacyCoordinatorCandidate({
|
||||
runId: adoption.adopted_run_id,
|
||||
terminalHandle: request.orchestrationCompatibilityEvidence?.terminalHandle,
|
||||
paneKey: request.orchestrationCompatibilityEvidence?.paneKey
|
||||
})
|
||||
if (!candidate) {
|
||||
if (request.orchestrationCompatibilityEvidence) {
|
||||
const caller = this.runtime.verifyOrchestrationCompatibilityCaller(
|
||||
request.orchestrationCompatibilityEvidence
|
||||
)
|
||||
const run = db.getRun(adoption.adopted_run_id)
|
||||
if (
|
||||
caller &&
|
||||
run?.coordinator_handle === caller.terminalHandle &&
|
||||
run.coordinator_pane_key &&
|
||||
equivalentLegacyPaneKey(run.coordinator_pane_key, caller.paneKey)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
throw legacyCoordinatorReadOnly()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const existing = db.getLegacyCoordinatorPrincipal(adoption.adopted_run_id)
|
||||
const proofCandidate = existing
|
||||
? this.candidate(adoption.adopted_run_id, existing)
|
||||
: this.candidate(adoption.adopted_run_id, candidate)
|
||||
const attestation = verifyAttestedLegacyCandidate({
|
||||
runtime: this.runtime,
|
||||
evidence: request.orchestrationCompatibilityEvidence,
|
||||
candidate: proofCandidate
|
||||
})
|
||||
const run = db.getRun(adoption.adopted_run_id)
|
||||
if (!run || !this.bindingMatches(run, proofCandidate)) {
|
||||
throw legacyCoordinatorReadOnly()
|
||||
}
|
||||
if (existing && !this.principalMatchesAttestation(existing, attestation)) {
|
||||
throw legacyCoordinatorReadOnly()
|
||||
}
|
||||
const principal = existing
|
||||
? existing
|
||||
: request.method === 'orchestration.runUse'
|
||||
? resolveAttestedLegacyPrincipal({
|
||||
runtime: this.runtime,
|
||||
evidence: request.orchestrationCompatibilityEvidence,
|
||||
candidate: proofCandidate,
|
||||
authority: attestation
|
||||
})
|
||||
: undefined
|
||||
return {
|
||||
runId: run.id,
|
||||
principalId: principal?.id ?? null,
|
||||
terminalHandle: proofCandidate.terminalHandle,
|
||||
paneKey: proofCandidate.paneKey,
|
||||
consumerGeneration: run.consumer_generation
|
||||
}
|
||||
}
|
||||
|
||||
revalidate(request: RpcRequest, proof: LegacyCoordinatorAuthorityProof): string {
|
||||
const db = this.runtime.getOrchestrationDb()
|
||||
const adoption = db.getLegacyAdoption()
|
||||
const run = db.getRun(proof.runId)
|
||||
if (
|
||||
adoption?.adopted_run_id !== proof.runId ||
|
||||
!run ||
|
||||
run.consumer_generation !== proof.consumerGeneration
|
||||
) {
|
||||
throw legacyCoordinatorReadOnly()
|
||||
}
|
||||
const candidate = this.candidate(proof.runId, proof)
|
||||
const principal = db.getLegacyCoordinatorPrincipal(proof.runId)
|
||||
if (proof.principalId) {
|
||||
if (
|
||||
principal?.id !== proof.principalId ||
|
||||
principal.status !== 'committed' ||
|
||||
principal.terminal_handle !== proof.terminalHandle ||
|
||||
!equivalentLegacyPaneKey(principal.pane_key, proof.paneKey)
|
||||
) {
|
||||
throw legacyCoordinatorReadOnly()
|
||||
}
|
||||
} else if (
|
||||
principal &&
|
||||
(principal.status !== 'committed' ||
|
||||
principal.terminal_handle !== proof.terminalHandle ||
|
||||
!equivalentLegacyPaneKey(principal.pane_key, proof.paneKey))
|
||||
) {
|
||||
throw legacyCoordinatorReadOnly()
|
||||
}
|
||||
const attestation = verifyAttestedLegacyCandidate({
|
||||
runtime: this.runtime,
|
||||
evidence: request.orchestrationCompatibilityEvidence,
|
||||
candidate
|
||||
})
|
||||
if (principal && !this.principalMatchesAttestation(principal, attestation)) {
|
||||
throw legacyCoordinatorReadOnly()
|
||||
}
|
||||
if (!this.bindingMatches(run, candidate)) {
|
||||
throw legacyCoordinatorReadOnly()
|
||||
}
|
||||
return run.id
|
||||
}
|
||||
|
||||
private candidate(
|
||||
runId: string,
|
||||
identity: {
|
||||
terminalHandle?: string
|
||||
terminal_handle?: string
|
||||
paneKey?: string
|
||||
pane_key?: string
|
||||
}
|
||||
): LegacyPrincipalCandidate {
|
||||
return {
|
||||
runId,
|
||||
role: 'coordinator',
|
||||
terminalHandle: identity.terminalHandle ?? (identity.terminal_handle as string),
|
||||
paneKey: identity.paneKey ?? (identity.pane_key as string)
|
||||
}
|
||||
}
|
||||
|
||||
private bindingMatches(
|
||||
run: { coordinator_handle: string | null; coordinator_pane_key: string | null },
|
||||
candidate: LegacyPrincipalCandidate
|
||||
): boolean {
|
||||
return (
|
||||
!run.coordinator_pane_key ||
|
||||
(run.coordinator_handle === candidate.terminalHandle &&
|
||||
equivalentLegacyPaneKey(run.coordinator_pane_key, candidate.paneKey))
|
||||
)
|
||||
}
|
||||
|
||||
private principalMatchesAttestation(
|
||||
principal: LegacyCompatibilityPrincipalRow,
|
||||
attestation: OrchestrationCompatibilityCallerAuthority
|
||||
): boolean {
|
||||
return (
|
||||
principal.status === 'committed' &&
|
||||
principal.host_scope === JSON.stringify(attestation.hostScope) &&
|
||||
principal.terminal_handle === attestation.terminalHandle &&
|
||||
equivalentLegacyPaneKey(principal.pane_key, attestation.paneKey) &&
|
||||
principal.launch_token_hash === attestation.launchTokenHash &&
|
||||
principal.process_incarnation === attestation.processIncarnation
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version'
|
||||
import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { OrcaRuntimeService } from '../orca-runtime'
|
||||
import { OrchestrationDb } from '../orchestration/db'
|
||||
import type { RpcRequest, RpcResponse } from './core'
|
||||
import { RpcDispatcher } from './dispatcher'
|
||||
import { ORCHESTRATION_METHODS } from './methods/orchestration'
|
||||
|
||||
const COORDINATOR_HANDLE = 'term_legacy_coord'
|
||||
const COORDINATOR_PANE = 'tab_coord:44444444-4444-4444-8444-444444444444'
|
||||
const WORKER_HANDLE = 'term_legacy_worker'
|
||||
const WORKER_PANE = 'tab_worker:33333333-3333-4333-8333-333333333333'
|
||||
|
||||
type Transport = 'dispatch' | 'websocket'
|
||||
|
||||
type Harness = {
|
||||
db: OrchestrationDb
|
||||
dispatcher: RpcDispatcher
|
||||
runtime: OrcaRuntimeService
|
||||
adoptedRunId: string
|
||||
taskId: string
|
||||
dispatchId: string
|
||||
}
|
||||
|
||||
const tempDirs: string[] = []
|
||||
const databases: OrchestrationDb[] = []
|
||||
|
||||
afterEach(() => {
|
||||
databases.splice(0).forEach((database) => database.close())
|
||||
tempDirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true }))
|
||||
})
|
||||
|
||||
function createHarness(): Harness {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-legacy-coordinator-race-'))
|
||||
tempDirs.push(dir)
|
||||
const dbPath = join(dir, 'orchestration.db')
|
||||
const before = new OrchestrationDb(dbPath)
|
||||
const task = before.createTask({
|
||||
spec: 'legacy assignment',
|
||||
createdByTerminalHandle: COORDINATOR_HANDLE
|
||||
})
|
||||
const dispatch = before.createDispatchContext(task.id, WORKER_HANDLE, WORKER_PANE)
|
||||
before.close()
|
||||
|
||||
const raw = new Database(dbPath)
|
||||
raw.exec(`
|
||||
UPDATE dispatch_contexts SET process_incarnation = 'process-1';
|
||||
DROP INDEX IF EXISTS idx_messages_delivery_contract;
|
||||
DROP TABLE legacy_mail_receipts;
|
||||
DROP TABLE legacy_operation_receipts;
|
||||
DROP TABLE legacy_compatibility_principals;
|
||||
DROP TABLE legacy_adoptions;
|
||||
`)
|
||||
raw.pragma('user_version = 18')
|
||||
raw.close()
|
||||
|
||||
const db = new OrchestrationDb(dbPath)
|
||||
databases.push(db)
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
|
||||
const runtime = new OrcaRuntimeService()
|
||||
runtime.setOrchestrationDb(db)
|
||||
vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) =>
|
||||
handle === COORDINATOR_HANDLE ? COORDINATOR_PANE : handle === WORKER_HANDLE ? WORKER_PANE : null
|
||||
)
|
||||
vi.spyOn(runtime, 'verifyOrchestrationCompatibilityCaller').mockImplementation(
|
||||
(compatibilityEvidence) => {
|
||||
const coordinator =
|
||||
compatibilityEvidence?.terminalHandle === COORDINATOR_HANDLE &&
|
||||
compatibilityEvidence.paneKey === COORDINATOR_PANE &&
|
||||
compatibilityEvidence.launchToken === 'coordinator-token'
|
||||
const worker =
|
||||
compatibilityEvidence?.terminalHandle === WORKER_HANDLE &&
|
||||
compatibilityEvidence.paneKey === WORKER_PANE &&
|
||||
compatibilityEvidence.launchToken === 'worker-token'
|
||||
return coordinator || worker
|
||||
? {
|
||||
hostScope: { kind: 'local', hostId: 'local' },
|
||||
terminalHandle: coordinator ? COORDINATOR_HANDLE : WORKER_HANDLE,
|
||||
paneKey: coordinator ? COORDINATOR_PANE : WORKER_PANE,
|
||||
processIncarnation: 'process-1',
|
||||
launchTokenHash: createHash('sha256')
|
||||
.update(coordinator ? 'coordinator-token' : 'worker-token')
|
||||
.digest('hex')
|
||||
}
|
||||
: null
|
||||
}
|
||||
)
|
||||
return {
|
||||
db,
|
||||
dispatcher: new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }),
|
||||
runtime,
|
||||
adoptedRunId,
|
||||
taskId: task.id,
|
||||
dispatchId: dispatch.id
|
||||
}
|
||||
}
|
||||
|
||||
function evidence(
|
||||
role: 'coordinator' | 'worker' = 'coordinator'
|
||||
): OrchestrationCompatibilityEvidence {
|
||||
return {
|
||||
terminalHandle: role === 'coordinator' ? COORDINATOR_HANDLE : WORKER_HANDLE,
|
||||
paneKey: role === 'coordinator' ? COORDINATOR_PANE : WORKER_PANE,
|
||||
launchToken: role === 'coordinator' ? 'coordinator-token' : 'worker-token'
|
||||
}
|
||||
}
|
||||
|
||||
function request(
|
||||
method: string,
|
||||
params: unknown,
|
||||
invocationId: string,
|
||||
callerEvidence = evidence()
|
||||
): RpcRequest {
|
||||
return {
|
||||
id: `rpc_${invocationId}`,
|
||||
authToken: 'caller-token',
|
||||
method,
|
||||
params,
|
||||
orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION,
|
||||
orchestrationRequestId: invocationId,
|
||||
compatibilityInvocationId: invocationId,
|
||||
orchestrationCompatibilityEvidence: callerEvidence
|
||||
}
|
||||
}
|
||||
|
||||
async function invoke(
|
||||
dispatcher: RpcDispatcher,
|
||||
rpcRequest: RpcRequest,
|
||||
transport: Transport
|
||||
): Promise<RpcResponse> {
|
||||
if (transport === 'dispatch') {
|
||||
return await dispatcher.dispatch(rpcRequest)
|
||||
}
|
||||
const replies: string[] = []
|
||||
await dispatcher.dispatchStreaming(rpcRequest, (reply) => replies.push(reply))
|
||||
expect(replies).toHaveLength(1)
|
||||
return JSON.parse(replies[0]) as RpcResponse
|
||||
}
|
||||
|
||||
function installTakeoverDuringMutationPreflight(harness: Harness): void {
|
||||
const originalBegin = harness.db.beginMutationReceipt.bind(harness.db)
|
||||
vi.spyOn(harness.db, 'beginMutationReceipt').mockImplementation((identity) => {
|
||||
const begun = originalBegin(identity)
|
||||
settleLegacyWorkerAndTakeOver(harness, 'settled during takeover')
|
||||
return begun
|
||||
})
|
||||
}
|
||||
|
||||
function settleLegacyWorkerAndTakeOver(harness: Harness, result: string): void {
|
||||
harness.db.settleWorkerReport({
|
||||
taskId: harness.taskId,
|
||||
dispatchId: harness.dispatchId,
|
||||
outcome: 'succeeded',
|
||||
result
|
||||
})
|
||||
harness.db.bindRun({
|
||||
runId: harness.adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:55555555-5555-4555-8555-555555555555'
|
||||
})
|
||||
}
|
||||
|
||||
function mutationReceiptCount(db: OrchestrationDb): number {
|
||||
const sqlite = (db as unknown as { db: Database.Database }).db
|
||||
return (
|
||||
sqlite.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() as { count: number }
|
||||
).count
|
||||
}
|
||||
|
||||
function messageCount(db: OrchestrationDb): number {
|
||||
const sqlite = (db as unknown as { db: Database.Database }).db
|
||||
return (sqlite.prepare('SELECT COUNT(*) AS count FROM messages').get() as { count: number }).count
|
||||
}
|
||||
|
||||
describe('legacy coordinator takeover races', () => {
|
||||
it.each(['dispatch', 'websocket'] as const)(
|
||||
'%s routes a pre-bind coordinator send to the retained worker mailbox',
|
||||
async (transport) => {
|
||||
const harness = createHarness()
|
||||
|
||||
const response = await invoke(
|
||||
harness.dispatcher,
|
||||
request(
|
||||
'orchestration.send',
|
||||
{ from: COORDINATOR_HANDLE, to: WORKER_HANDLE, subject: 'continue safely' },
|
||||
`send-direct-${transport}`
|
||||
),
|
||||
transport
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
message: {
|
||||
run_id: harness.adoptedRunId,
|
||||
to_handle: WORKER_HANDLE,
|
||||
delivery_contract: 'legacy_direct'
|
||||
}
|
||||
}
|
||||
})
|
||||
const workerCheck = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{ terminal: WORKER_HANDLE },
|
||||
`worker-check-${transport}`,
|
||||
evidence('worker')
|
||||
)
|
||||
)
|
||||
expect(workerCheck).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
messages: [
|
||||
{
|
||||
subject: 'continue safely',
|
||||
run_id: harness.adoptedRunId,
|
||||
delivery_contract: 'legacy_direct'
|
||||
}
|
||||
],
|
||||
legacyCompatibility: { ackMessageIds: [expect.any(String)] }
|
||||
}
|
||||
})
|
||||
expect(
|
||||
harness.db.getInbox(100).some((message) => message.run_id === 'run_legacy_local')
|
||||
).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('replays coordinator mutations after transport authentication rotates', async () => {
|
||||
const harness = createHarness()
|
||||
const mutation = request(
|
||||
'orchestration.send',
|
||||
{ from: COORDINATOR_HANDLE, to: WORKER_HANDLE, subject: 'send once' },
|
||||
'coordinator-restart-send'
|
||||
)
|
||||
mutation.authToken = 'before-restart'
|
||||
|
||||
const first = await harness.dispatcher.dispatch(mutation)
|
||||
const restartedDispatcher = new RpcDispatcher({
|
||||
runtime: harness.runtime,
|
||||
methods: ORCHESTRATION_METHODS
|
||||
})
|
||||
const replay = await restartedDispatcher.dispatch({
|
||||
...mutation,
|
||||
id: 'rpc_coordinator-restart-send-retry',
|
||||
authToken: 'after-restart'
|
||||
})
|
||||
|
||||
expect(first).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
message: { subject: 'send once' },
|
||||
mutation: { requestId: 'coordinator-restart-send', replayed: false }
|
||||
}
|
||||
})
|
||||
expect(replay).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
message: { id: (first as { result: { message: { id: string } } }).result.message.id },
|
||||
mutation: { requestId: 'coordinator-restart-send', replayed: true }
|
||||
}
|
||||
})
|
||||
expect(messageCount(harness.db)).toBe(1)
|
||||
expect(mutationReceiptCount(harness.db)).toBe(1)
|
||||
})
|
||||
|
||||
it('routes an exact legacy Dispatch recipient to compatibility delivery', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.send',
|
||||
{
|
||||
from: COORDINATOR_HANDLE,
|
||||
to: `dispatch:${harness.dispatchId}`,
|
||||
subject: 'dispatch guidance'
|
||||
},
|
||||
'send-dispatch'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
message: {
|
||||
run_id: harness.adoptedRunId,
|
||||
to_handle: `dispatch:${harness.dispatchId}`,
|
||||
delivery_contract: 'legacy_direct'
|
||||
}
|
||||
}
|
||||
})
|
||||
const workerCheck = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{ terminal: WORKER_HANDLE },
|
||||
'worker-check-dispatch',
|
||||
evidence('worker')
|
||||
)
|
||||
)
|
||||
expect(workerCheck).toMatchObject({
|
||||
ok: true,
|
||||
result: { messages: [{ subject: 'dispatch guidance' }] }
|
||||
})
|
||||
})
|
||||
|
||||
it('partitions a coordinator group send by legacy recipient contract', async () => {
|
||||
const harness = createHarness()
|
||||
vi.spyOn(harness.runtime, 'listTerminals').mockResolvedValue({
|
||||
terminals: [
|
||||
{ handle: COORDINATOR_HANDLE },
|
||||
{ handle: WORKER_HANDLE },
|
||||
{ handle: 'term_current_worker' }
|
||||
],
|
||||
totalCount: 3,
|
||||
truncated: false
|
||||
} as never)
|
||||
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.send',
|
||||
{ from: COORDINATOR_HANDLE, to: '@all', subject: 'group guidance' },
|
||||
'send-group'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: true, result: { recipients: 2 } })
|
||||
const messages = harness.db
|
||||
.getInbox(100)
|
||||
.filter((message) => message.subject === 'group guidance')
|
||||
expect(messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
run_id: harness.adoptedRunId,
|
||||
to_handle: WORKER_HANDLE,
|
||||
delivery_contract: 'legacy_direct'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
run_id: harness.adoptedRunId,
|
||||
to_handle: 'term_current_worker',
|
||||
delivery_contract: 'current_delivery'
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['dispatch', 'websocket'] as const)(
|
||||
'%s rejects a coordinator send after takeover during durable preflight',
|
||||
async (transport) => {
|
||||
const harness = createHarness()
|
||||
installTakeoverDuringMutationPreflight(harness)
|
||||
const before = messageCount(harness.db)
|
||||
|
||||
const response = await invoke(
|
||||
harness.dispatcher,
|
||||
request(
|
||||
'orchestration.send',
|
||||
{ from: COORDINATOR_HANDLE, to: WORKER_HANDLE, subject: 'must not send' },
|
||||
`send-takeover-${transport}`
|
||||
),
|
||||
transport
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'legacy_read_only' } })
|
||||
expect(messageCount(harness.db)).toBe(before)
|
||||
expect(mutationReceiptCount(harness.db)).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it('revalidates a group send after asynchronous terminal discovery', async () => {
|
||||
const harness = createHarness()
|
||||
let resolveTerminals:
|
||||
| ((value: {
|
||||
terminals: { handle: string }[]
|
||||
totalCount: number
|
||||
truncated: boolean
|
||||
}) => void)
|
||||
| undefined
|
||||
let signalListingStarted: (() => void) | undefined
|
||||
const listingStarted = new Promise<void>((resolve) => {
|
||||
signalListingStarted = resolve
|
||||
})
|
||||
vi.spyOn(harness.runtime, 'listTerminals').mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveTerminals = resolve as typeof resolveTerminals
|
||||
signalListingStarted?.()
|
||||
}) as never
|
||||
)
|
||||
const before = messageCount(harness.db)
|
||||
const pending = harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.send',
|
||||
{ from: COORDINATOR_HANDLE, to: '@all', subject: 'must remain unsent' },
|
||||
'send-group-takeover'
|
||||
)
|
||||
)
|
||||
await listingStarted
|
||||
settleLegacyWorkerAndTakeOver(harness, 'settled during terminal discovery')
|
||||
resolveTerminals?.({
|
||||
terminals: [{ handle: COORDINATOR_HANDLE }, { handle: WORKER_HANDLE }],
|
||||
totalCount: 2,
|
||||
truncated: false
|
||||
})
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'legacy_read_only' }
|
||||
})
|
||||
expect(messageCount(harness.db)).toBe(before)
|
||||
expect(mutationReceiptCount(harness.db)).toBe(0)
|
||||
})
|
||||
|
||||
it('reports a committed ACK when coordinator takeover interrupts its wait', async () => {
|
||||
const harness = createHarness()
|
||||
const incoming = harness.db.insertMessage({
|
||||
runId: harness.adoptedRunId,
|
||||
from: WORKER_HANDLE,
|
||||
to: `run:${harness.adoptedRunId}`,
|
||||
subject: 'ready to acknowledge'
|
||||
})
|
||||
const first = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{ terminal: COORDINATOR_HANDLE, run: harness.adoptedRunId },
|
||||
'check-delivery-before-takeover'
|
||||
)
|
||||
)
|
||||
expect(first).toMatchObject({
|
||||
ok: true,
|
||||
result: { deliveryId: expect.any(String), messages: [{ id: incoming.id }] }
|
||||
})
|
||||
const deliveryId = (first as { result: { deliveryId: string } }).result.deliveryId
|
||||
let resolveWait: (() => void) | undefined
|
||||
let signalWaitStarted: (() => void) | undefined
|
||||
const waitStarted = new Promise<void>((resolve) => {
|
||||
signalWaitStarted = resolve
|
||||
})
|
||||
vi.spyOn(harness.runtime, 'waitForMessage').mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveWait = () => resolve('notified')
|
||||
signalWaitStarted?.()
|
||||
})
|
||||
)
|
||||
const pending = harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{
|
||||
terminal: COORDINATOR_HANDLE,
|
||||
run: harness.adoptedRunId,
|
||||
ack: deliveryId,
|
||||
wait: true,
|
||||
timeoutMs: 10_000
|
||||
},
|
||||
'check-ack-takeover'
|
||||
)
|
||||
)
|
||||
await waitStarted
|
||||
expect(harness.db.getMessageById(incoming.id)?.read).toBe(1)
|
||||
settleLegacyWorkerAndTakeOver(harness, 'settled during acknowledged wait')
|
||||
resolveWait?.()
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
acknowledged: deliveryId,
|
||||
count: 0,
|
||||
waitInterrupted: 'consumer_fenced',
|
||||
mutation: { requestId: 'check-ack-takeover', replayed: false }
|
||||
}
|
||||
})
|
||||
const sqlite = (harness.db as unknown as { db: Database.Database }).db
|
||||
expect(sqlite.prepare('SELECT state FROM mutation_receipts').get()).toEqual({
|
||||
state: 'completed'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps concurrent duplicates on the live ACK-and-wait result', async () => {
|
||||
const harness = createHarness()
|
||||
harness.db.insertMessage({
|
||||
runId: harness.adoptedRunId,
|
||||
from: WORKER_HANDLE,
|
||||
to: `run:${harness.adoptedRunId}`,
|
||||
subject: 'first delivery'
|
||||
})
|
||||
const firstCheck = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{ terminal: COORDINATOR_HANDLE, run: harness.adoptedRunId },
|
||||
'check-first-delivery'
|
||||
)
|
||||
)
|
||||
const deliveryId = (firstCheck as { result: { deliveryId: string } }).result.deliveryId
|
||||
let resolveWait: (() => void) | undefined
|
||||
let signalWaitStarted: (() => void) | undefined
|
||||
const waitStarted = new Promise<void>((resolve) => {
|
||||
signalWaitStarted = resolve
|
||||
})
|
||||
vi.spyOn(harness.runtime, 'waitForMessage').mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveWait = () => resolve('notified')
|
||||
signalWaitStarted?.()
|
||||
})
|
||||
)
|
||||
const ackRequest = request(
|
||||
'orchestration.check',
|
||||
{
|
||||
terminal: COORDINATOR_HANDLE,
|
||||
run: harness.adoptedRunId,
|
||||
ack: deliveryId,
|
||||
wait: true,
|
||||
timeoutMs: 10_000
|
||||
},
|
||||
'check-ack-concurrent'
|
||||
)
|
||||
const original = harness.dispatcher.dispatch(ackRequest)
|
||||
await waitStarted
|
||||
const secondLiveDispatcher = new RpcDispatcher({
|
||||
runtime: harness.runtime,
|
||||
methods: ORCHESTRATION_METHODS
|
||||
})
|
||||
const duplicate = secondLiveDispatcher.dispatch({
|
||||
...ackRequest,
|
||||
id: 'rpc_check-ack-concurrent-duplicate'
|
||||
})
|
||||
let duplicateSettled = false
|
||||
void duplicate.then(() => {
|
||||
duplicateSettled = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(duplicateSettled).toBe(false)
|
||||
|
||||
const arrived = harness.db.insertMessage({
|
||||
runId: harness.adoptedRunId,
|
||||
from: WORKER_HANDLE,
|
||||
to: `run:${harness.adoptedRunId}`,
|
||||
subject: 'arrived while waiting'
|
||||
})
|
||||
resolveWait?.()
|
||||
const [originalResult, duplicateResult] = await Promise.all([original, duplicate])
|
||||
expect(originalResult).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
messages: [{ id: arrived.id }],
|
||||
mutation: { requestId: 'check-ack-concurrent', replayed: false }
|
||||
}
|
||||
})
|
||||
expect(duplicateResult).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
messages: [{ id: arrived.id }],
|
||||
mutation: { requestId: 'check-ack-concurrent', replayed: true }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['dispatch', 'websocket'] as const)(
|
||||
'%s rejects run-use after takeover during durable preflight',
|
||||
async (transport) => {
|
||||
const harness = createHarness()
|
||||
installTakeoverDuringMutationPreflight(harness)
|
||||
|
||||
const response = await invoke(
|
||||
harness.dispatcher,
|
||||
request(
|
||||
'orchestration.runUse',
|
||||
{ id: harness.adoptedRunId, from: COORDINATOR_HANDLE },
|
||||
`run-use-takeover-${transport}`
|
||||
),
|
||||
transport
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'legacy_read_only' } })
|
||||
expect(harness.db.getRun(harness.adoptedRunId)).toMatchObject({
|
||||
coordinator_handle: 'term_current_coord',
|
||||
consumer_generation: 1
|
||||
})
|
||||
expect(mutationReceiptCount(harness.db)).toBe(0)
|
||||
expect(harness.db.getLegacyCoordinatorPrincipal(harness.adoptedRunId)?.status).toBe('revoked')
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['dispatch', 'websocket'] as const)(
|
||||
'%s rejects a mutation after takeover during durable preflight',
|
||||
async (transport) => {
|
||||
const harness = createHarness()
|
||||
const target = harness.db.createTask({
|
||||
spec: 'must remain ready',
|
||||
runId: harness.adoptedRunId
|
||||
})
|
||||
installTakeoverDuringMutationPreflight(harness)
|
||||
|
||||
const response = await invoke(
|
||||
harness.dispatcher,
|
||||
request(
|
||||
'orchestration.taskUpdate',
|
||||
{
|
||||
id: target.id,
|
||||
status: 'completed',
|
||||
callerTerminalHandle: COORDINATOR_HANDLE
|
||||
},
|
||||
`task-update-takeover-${transport}`
|
||||
),
|
||||
transport
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'legacy_read_only' } })
|
||||
expect(harness.db.getTask(target.id)?.status).toBe('ready')
|
||||
expect(mutationReceiptCount(harness.db)).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it('revalidates after asynchronous agent detection', async () => {
|
||||
const harness = createHarness()
|
||||
const target = harness.db.createTask({
|
||||
spec: 'must not dispatch after takeover',
|
||||
runId: harness.adoptedRunId
|
||||
})
|
||||
const targetHandle = 'term_current_worker'
|
||||
const targetPane = 'tab_current_worker:77777777-7777-4777-8777-777777777777'
|
||||
vi.spyOn(harness.runtime, 'getTerminalPaneKey').mockImplementation((handle: string) =>
|
||||
handle === COORDINATOR_HANDLE ? COORDINATOR_PANE : handle === targetHandle ? targetPane : null
|
||||
)
|
||||
vi.spyOn(harness.runtime, 'getTerminalProcessIncarnation').mockReturnValue('current-process')
|
||||
let resolveDetection: ((detected: boolean) => void) | undefined
|
||||
let signalDetectionStarted: (() => void) | undefined
|
||||
const detectionStarted = new Promise<void>((resolve) => {
|
||||
signalDetectionStarted = resolve
|
||||
})
|
||||
vi.spyOn(harness.runtime, 'isTerminalRunningAgent').mockImplementation(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveDetection = resolve
|
||||
signalDetectionStarted?.()
|
||||
})
|
||||
)
|
||||
const sendPrompt = vi
|
||||
.spyOn(harness.runtime, 'sendTerminalAgentPrompt')
|
||||
.mockResolvedValue({ handle: targetHandle, accepted: true, bytesWritten: 1 })
|
||||
|
||||
const pending = harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.dispatch',
|
||||
{ task: target.id, to: targetHandle, from: COORDINATOR_HANDLE, inject: true },
|
||||
'dispatch-detection-takeover'
|
||||
)
|
||||
)
|
||||
await detectionStarted
|
||||
settleLegacyWorkerAndTakeOver(harness, 'settled during detection')
|
||||
resolveDetection?.(true)
|
||||
const response = await pending
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'legacy_read_only' } })
|
||||
expect(harness.db.getTask(target.id)?.status).toBe('ready')
|
||||
expect(harness.db.getDispatchContext(target.id)).toBeUndefined()
|
||||
expect(sendPrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { RpcRequest } from './core'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
import type { MessageType } from '../orchestration/db'
|
||||
import { OrchestrationError } from '../orchestration/orchestration-error'
|
||||
import type { LegacyCompatibilityAuthority } from './orchestration-legacy-authority'
|
||||
import {
|
||||
inferLegacyWorkerOutcome,
|
||||
isWorkerOutcome,
|
||||
operationIdentity,
|
||||
parseLegacyPayload,
|
||||
stringValue,
|
||||
type LegacySendParams
|
||||
} from './orchestration-legacy-operation'
|
||||
|
||||
export async function handleLegacyLifecycleSend(args: {
|
||||
runtime: OrcaRuntimeService
|
||||
authority: LegacyCompatibilityAuthority
|
||||
request: RpcRequest
|
||||
params: LegacySendParams
|
||||
}): Promise<unknown | undefined> {
|
||||
const { runtime, authority, request, params } = args
|
||||
if (!['heartbeat', 'worker_done', 'escalation'].includes(params.type ?? '')) {
|
||||
return undefined
|
||||
}
|
||||
const payload = parseLegacyPayload(params.payload)
|
||||
const dispatch = authority.resolveWorkerDispatch(request, {
|
||||
terminalHandle: params.from,
|
||||
dispatchId: stringValue(payload.dispatchId),
|
||||
taskId: stringValue(payload.taskId)
|
||||
})
|
||||
if (!dispatch) {
|
||||
if (params.type === 'worker_done' && !isWorkerOutcome(payload.outcome)) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'worker_done requires outcome=succeeded|failed for a current Dispatch.'
|
||||
)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
if (!params.to) {
|
||||
throw new OrchestrationError('invalid_argument', 'Legacy lifecycle mail requires --to.')
|
||||
}
|
||||
const db = runtime.getOrchestrationDb()
|
||||
if (!db.isLegacyCoordinatorHandle(dispatch.run_id, params.to)) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Terminal ${params.to} is not a retained coordinator for this legacy Dispatch.`
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
params.type === 'worker_done' &&
|
||||
Object.hasOwn(payload, 'outcome') &&
|
||||
!isWorkerOutcome(payload.outcome)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'Legacy worker_done outcome must be succeeded or failed when provided.'
|
||||
)
|
||||
}
|
||||
const principal = authority.attestWorker(request, dispatch)
|
||||
const operation = operationIdentity(request, params.type as string, { ...params, payload })
|
||||
const inferredOutcome =
|
||||
params.type === 'worker_done'
|
||||
? isWorkerOutcome(payload.outcome)
|
||||
? payload.outcome
|
||||
: inferLegacyWorkerOutcome(params.subject)
|
||||
: undefined
|
||||
const existingId =
|
||||
params.type === 'worker_done'
|
||||
? db.findLegacyWorkerCompletion({
|
||||
principalId: principal.id,
|
||||
taskId: dispatch.task_id,
|
||||
recipientHandle: params.to,
|
||||
subject: params.subject,
|
||||
body: params.body ?? '',
|
||||
payload: params.payload ?? null
|
||||
})?.id
|
||||
: undefined
|
||||
const committed = db.commitLegacyLifecycleOperation({
|
||||
principalId: principal.id,
|
||||
operationKey: operation.key,
|
||||
method: request.method,
|
||||
payloadHash: operation.payloadHash,
|
||||
message: {
|
||||
existingId,
|
||||
to: params.to,
|
||||
subject: params.subject,
|
||||
body: params.body,
|
||||
type: params.type as MessageType,
|
||||
priority: params.priority,
|
||||
payload: params.payload
|
||||
},
|
||||
lifecycle:
|
||||
params.type === 'heartbeat'
|
||||
? { kind: 'heartbeat', at: new Date().toISOString() }
|
||||
: params.type === 'worker_done'
|
||||
? {
|
||||
kind: 'worker_report',
|
||||
taskId: dispatch.task_id,
|
||||
outcome: inferredOutcome as 'succeeded' | 'failed',
|
||||
result: params.body || params.subject
|
||||
}
|
||||
: { kind: 'message_only' }
|
||||
})
|
||||
if (!committed.duplicate) {
|
||||
runtime.notifyMessageArrived(committed.message.to_handle, committed.message.type)
|
||||
}
|
||||
return {
|
||||
message: committed.message,
|
||||
...(committed.settlement ? { lifecycle: committed.settlement } : {}),
|
||||
legacyCompatibility: { replayed: committed.duplicate }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { RpcRequest } from './core'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
import { OrchestrationError } from '../orchestration/orchestration-error'
|
||||
import { formatMessageBanner } from '../orchestration/formatter'
|
||||
import { ORCHESTRATION_MESSAGE_WAIT_DEFAULT_TIMEOUT_MS } from '../../../shared/orchestration-message-wait-timeout'
|
||||
import type { LegacyCompatibilityAuthority } from './orchestration-legacy-authority'
|
||||
import {
|
||||
operationIdentity,
|
||||
parseLegacyMailAck,
|
||||
parseLegacyQuestionAck,
|
||||
parseLegacyTypes,
|
||||
supportedLegacyHints,
|
||||
type LegacyCheckParams,
|
||||
type LegacyReplyParams
|
||||
} from './orchestration-legacy-operation'
|
||||
|
||||
export async function handleLegacyCheck(args: {
|
||||
runtime: OrcaRuntimeService
|
||||
authority: LegacyCompatibilityAuthority
|
||||
request: RpcRequest
|
||||
params: LegacyCheckParams
|
||||
signal?: AbortSignal
|
||||
}): Promise<unknown | undefined> {
|
||||
const { runtime, authority, request, params, signal } = args
|
||||
if (params.run) {
|
||||
return undefined
|
||||
}
|
||||
const typeFilter = parseLegacyTypes(params.types)
|
||||
const principal = authority.resolveCheckPrincipal(request, params.terminal)
|
||||
if (!principal) {
|
||||
return undefined
|
||||
}
|
||||
const db = runtime.getOrchestrationDb()
|
||||
if (params.compatibilityQuestionAck) {
|
||||
const answer = parseLegacyQuestionAck(params.compatibilityQuestionAck)
|
||||
const acknowledged = db.acknowledgeLegacyQuestionAnswer({
|
||||
principalId: principal.id,
|
||||
...answer
|
||||
})
|
||||
return {
|
||||
messages: [],
|
||||
count: 0,
|
||||
acknowledged: [answer.answerMessageId],
|
||||
duplicate: acknowledged.duplicate,
|
||||
legacyCompatibility: { acknowledged: true }
|
||||
}
|
||||
}
|
||||
if (params.compatibilityAck) {
|
||||
const ack = parseLegacyMailAck(params.compatibilityAck)
|
||||
const acknowledged = db.acknowledgeLegacyMail({
|
||||
principalId: principal.id,
|
||||
messageIds: ack.messageIds,
|
||||
types: ack.types
|
||||
})
|
||||
return {
|
||||
messages: [],
|
||||
count: 0,
|
||||
acknowledged: ack.messageIds,
|
||||
duplicate: acknowledged.duplicate,
|
||||
legacyCompatibility: { acknowledged: true }
|
||||
}
|
||||
}
|
||||
|
||||
const history = params.all || (params.unread === false && !params.peek)
|
||||
const readOnly = Boolean(params.peek || history)
|
||||
const read = () =>
|
||||
history
|
||||
? db.getLegacyMailHistory({ principalId: principal.id, types: typeFilter })
|
||||
: db.getLegacyMailPage({ principalId: principal.id, types: typeFilter })
|
||||
let page = read()
|
||||
const deadline =
|
||||
Date.now() + Math.max(params.timeoutMs ?? ORCHESTRATION_MESSAGE_WAIT_DEFAULT_TIMEOUT_MS, 0)
|
||||
while (page.messages.length === 0 && params.wait && !signal?.aborted && Date.now() < deadline) {
|
||||
if (principal.role === 'coordinator' && db.hasPendingCurrentDelivery(principal.run_id)) {
|
||||
break
|
||||
}
|
||||
await runtime.waitForMessage(
|
||||
principal.role === 'worker'
|
||||
? `dispatch:${principal.dispatch_id as string}`
|
||||
: principal.terminal_handle,
|
||||
{
|
||||
typeFilter,
|
||||
timeoutMs: Math.min(1_000, Math.max(deadline - Date.now(), 1)),
|
||||
signal
|
||||
}
|
||||
)
|
||||
page = read()
|
||||
}
|
||||
|
||||
const consuming = !readOnly
|
||||
const messageIds = consuming ? page.messages.map((message) => message.id) : []
|
||||
const formattingAuthority = readOnly
|
||||
? 'legacy_read_only'
|
||||
: page.recovery
|
||||
? 'legacy_recovery_replay'
|
||||
: 'legacy_compatibility'
|
||||
const formatted =
|
||||
params.format || params.inject
|
||||
? page.messages
|
||||
.map((message) =>
|
||||
formatMessageBanner(message, {
|
||||
authority: formattingAuthority,
|
||||
supportedActionHints: readOnly
|
||||
? []
|
||||
: supportedLegacyHints(message, principal, params.compatibilityCliCommand ?? 'orca')
|
||||
})
|
||||
)
|
||||
.join('\n\n')
|
||||
: undefined
|
||||
const currentDelivery =
|
||||
principal.role === 'coordinator' && db.hasPendingCurrentDelivery(principal.run_id)
|
||||
? {
|
||||
runId: principal.run_id,
|
||||
checkCommand: `${params.compatibilityCliCommand ?? 'orca'} orchestration check --run ${principal.run_id}`,
|
||||
ackCommand: `${params.compatibilityCliCommand ?? 'orca'} orchestration check --run ${principal.run_id} --ack <delivery-id>`
|
||||
}
|
||||
: undefined
|
||||
return {
|
||||
runId: principal.run_id,
|
||||
dispatchId: principal.dispatch_id,
|
||||
messages: page.messages,
|
||||
count: page.messages.length,
|
||||
formatted,
|
||||
timedOut: Boolean(params.wait && page.messages.length === 0 && !signal?.aborted),
|
||||
cancelled: signal?.aborted === true,
|
||||
connectionLost: signal?.aborted === true,
|
||||
legacyCompatibility: {
|
||||
recovery: page.recovery,
|
||||
readOnly,
|
||||
ackMessageIds: messageIds,
|
||||
currentDelivery
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleLegacyReply(args: {
|
||||
runtime: OrcaRuntimeService
|
||||
authority: LegacyCompatibilityAuthority
|
||||
request: RpcRequest
|
||||
params: LegacyReplyParams
|
||||
}): Promise<unknown | undefined> {
|
||||
const { runtime, authority, request, params } = args
|
||||
const db = runtime.getOrchestrationDb()
|
||||
const original = db.getMessageById(params.id)
|
||||
if (!original || original.delivery_contract !== 'legacy_direct') {
|
||||
return undefined
|
||||
}
|
||||
const principal = authority.attestCoordinator(request, original.run_id)
|
||||
if (!principal) {
|
||||
throw legacyCoordinatorReadOnly()
|
||||
}
|
||||
const operation = operationIdentity(request, 'reply', {
|
||||
questionId: params.id,
|
||||
body: params.body
|
||||
})
|
||||
const committed = db.commitLegacyReplyOperation({
|
||||
principalId: principal.id,
|
||||
operationKey: operation.key,
|
||||
method: request.method,
|
||||
payloadHash: operation.payloadHash,
|
||||
questionId: params.id,
|
||||
body: params.body
|
||||
})
|
||||
if (!committed.duplicate) {
|
||||
runtime.notifyMessageArrived(committed.question.asker_handle, 'status')
|
||||
}
|
||||
return {
|
||||
message: committed.message,
|
||||
question: committed.question,
|
||||
duplicate: committed.duplicate,
|
||||
legacyCompatibility: { replayed: committed.duplicate }
|
||||
}
|
||||
}
|
||||
|
||||
function legacyCoordinatorReadOnly(): OrchestrationError {
|
||||
return new OrchestrationError(
|
||||
'legacy_read_only',
|
||||
'This retained legacy coordinator could not prove its original process identity. No effects were applied.',
|
||||
{ effectsApplied: false }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import type { RpcRequest } from './core'
|
||||
import type { MessageType } from '../orchestration/db'
|
||||
import { MESSAGE_TYPES } from '../orchestration/types'
|
||||
import type { LegacyCompatibilityPrincipalRow, MessageRow } from '../orchestration/types'
|
||||
import { OrchestrationError } from '../orchestration/orchestration-error'
|
||||
|
||||
const MESSAGE_TYPE_SET = new Set<string>(MESSAGE_TYPES)
|
||||
|
||||
export type LegacySendParams = {
|
||||
from?: string
|
||||
to?: string
|
||||
subject: string
|
||||
body?: string
|
||||
type?: string
|
||||
priority?: 'normal' | 'high' | 'urgent'
|
||||
payload?: string
|
||||
}
|
||||
|
||||
export type LegacyCheckParams = {
|
||||
terminal?: string
|
||||
run?: string
|
||||
unread?: boolean
|
||||
peek?: boolean
|
||||
all?: boolean
|
||||
types?: string
|
||||
format?: boolean
|
||||
inject?: boolean
|
||||
wait?: boolean
|
||||
timeoutMs?: number
|
||||
compatibilityAck?: string
|
||||
compatibilityQuestionAck?: string
|
||||
compatibilityCliCommand?: 'orca' | 'orca-ide' | 'orca-dev'
|
||||
}
|
||||
|
||||
export type LegacyAskParams = {
|
||||
from?: string
|
||||
to?: string
|
||||
run?: string
|
||||
question?: string
|
||||
resume?: string
|
||||
options?: string
|
||||
timeoutMs?: number
|
||||
compatibilityCliCommand?: 'orca' | 'orca-ide' | 'orca-dev'
|
||||
compatibilityWindowsCommand?: 'orca' | 'orca-ide'
|
||||
}
|
||||
|
||||
export type LegacyReplyParams = {
|
||||
id: string
|
||||
body: string
|
||||
from?: string
|
||||
run?: string
|
||||
}
|
||||
|
||||
export function operationIdentity(
|
||||
request: RpcRequest,
|
||||
method: string,
|
||||
payload: unknown
|
||||
): { key: string; payloadHash: string } {
|
||||
const canonical = JSON.stringify(canonicalize(payload))
|
||||
const payloadHash = createHash('sha256').update(canonical).digest('hex')
|
||||
const semantic =
|
||||
method === 'worker_done' || method === 'reply' || method === 'ask'
|
||||
? `${method}:${payloadHash}`
|
||||
: `${method}:${randomUUID()}`
|
||||
return {
|
||||
key: request.compatibilityInvocationId
|
||||
? `invocation:${request.compatibilityInvocationId}`
|
||||
: `semantic:${semantic}`,
|
||||
payloadHash
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLegacyPayload(raw: string | undefined): Record<string, unknown> {
|
||||
if (!raw) {
|
||||
return {}
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
// Fall through to one stable validation error.
|
||||
}
|
||||
throw new OrchestrationError('invalid_argument', 'Message payload must be valid JSON.')
|
||||
}
|
||||
|
||||
export function parseLegacyMailAck(raw: string): { messageIds: string[]; types?: MessageType[] } {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
const messageIds = Array.isArray(parsed)
|
||||
? parsed
|
||||
: parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as { messageIds?: unknown }).messageIds
|
||||
: undefined
|
||||
const types =
|
||||
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as { types?: unknown }).types
|
||||
: undefined
|
||||
if (
|
||||
Array.isArray(messageIds) &&
|
||||
messageIds.length > 0 &&
|
||||
messageIds.every((value) => typeof value === 'string' && value.length > 0) &&
|
||||
(types === undefined ||
|
||||
(Array.isArray(types) &&
|
||||
types.every((value) => typeof value === 'string' && MESSAGE_TYPE_SET.has(value))))
|
||||
) {
|
||||
return {
|
||||
messageIds,
|
||||
types: types && types.length > 0 ? (types as MessageType[]) : undefined
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to one stable validation error.
|
||||
}
|
||||
throw new OrchestrationError('invalid_argument', 'Invalid compatibility acknowledgment.')
|
||||
}
|
||||
|
||||
export function parseLegacyQuestionAck(raw: string): {
|
||||
questionId: string
|
||||
answerMessageId: string
|
||||
} {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
!Array.isArray(parsed) &&
|
||||
typeof (parsed as { questionId?: unknown }).questionId === 'string' &&
|
||||
typeof (parsed as { answerMessageId?: unknown }).answerMessageId === 'string'
|
||||
) {
|
||||
return parsed as { questionId: string; answerMessageId: string }
|
||||
}
|
||||
} catch {
|
||||
// Fall through to one stable validation error.
|
||||
}
|
||||
throw new OrchestrationError('invalid_argument', 'Invalid legacy question acknowledgment.')
|
||||
}
|
||||
|
||||
export function parseLegacyTypes(raw: string | undefined): MessageType[] | undefined {
|
||||
const values = raw
|
||||
?.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
const invalidValues = values?.filter((value) => !MESSAGE_TYPE_SET.has(value))
|
||||
if (invalidValues && invalidValues.length > 0) {
|
||||
throw new OrchestrationError('invalid_argument', `Invalid --types: ${invalidValues.join(',')}`)
|
||||
}
|
||||
return values && values.length > 0 ? (values as MessageType[]) : undefined
|
||||
}
|
||||
|
||||
export function parseLegacyOptions(raw: string | undefined): string[] {
|
||||
return (
|
||||
raw
|
||||
?.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
export function supportedLegacyHints(
|
||||
message: MessageRow,
|
||||
principal: LegacyCompatibilityPrincipalRow,
|
||||
cliCommand: 'orca' | 'orca-ide' | 'orca-dev'
|
||||
): string[] {
|
||||
if (
|
||||
principal.role !== 'coordinator' ||
|
||||
(message.type !== 'question' && message.type !== 'decision_gate')
|
||||
) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
`${cliCommand} orchestration reply --id ${message.id} --from ${principal.terminal_handle} --body "..."`
|
||||
]
|
||||
}
|
||||
|
||||
export function inferLegacyWorkerOutcome(subject: string): 'succeeded' | 'failed' {
|
||||
const normalized = subject.trim()
|
||||
return normalized === 'Failed' || normalized.startsWith('Failed:') ? 'failed' : 'succeeded'
|
||||
}
|
||||
|
||||
export function isWorkerOutcome(value: unknown): value is 'succeeded' | 'failed' {
|
||||
return value === 'succeeded' || value === 'failed'
|
||||
}
|
||||
|
||||
export function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
export function normalizeLegacyText(value: string): string {
|
||||
return value.replace(/\r\n/g, '\n').trim()
|
||||
}
|
||||
|
||||
function canonicalize(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(canonicalize)
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value
|
||||
}
|
||||
const source = value as Record<string, unknown>
|
||||
return Object.fromEntries(
|
||||
Object.keys(source)
|
||||
.sort()
|
||||
.filter((key) => source[key] !== undefined)
|
||||
.map((key) => [key, canonicalize(source[key])])
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { OrchestrationError } from '../orchestration/orchestration-error'
|
||||
|
||||
export function equivalentLegacyPaneKey(
|
||||
a: string | null | undefined,
|
||||
b: string | null | undefined
|
||||
): boolean {
|
||||
if (!a || !b) {
|
||||
return false
|
||||
}
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
const aLeaf = parsePaneKey(a)?.leafId
|
||||
const bLeaf = parsePaneKey(b)?.leafId
|
||||
return Boolean(aLeaf && bLeaf && aLeaf === bLeaf)
|
||||
}
|
||||
|
||||
export function legacyReadOnlyError(): OrchestrationError {
|
||||
return new OrchestrationError(
|
||||
'legacy_read_only',
|
||||
'This retained legacy assignment could not prove authority from its original live process. No effects were applied.',
|
||||
{ effectsApplied: false }
|
||||
)
|
||||
}
|
||||
|
||||
export function legacyCoordinatorReadOnly(): OrchestrationError {
|
||||
return new OrchestrationError(
|
||||
'legacy_read_only',
|
||||
'This retained legacy coordinator could not prove its original process identity. No effects were applied.',
|
||||
{ effectsApplied: false }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence'
|
||||
import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { OrchestrationDb } from '../orchestration/db'
|
||||
import { OrcaRuntimeService } from '../orca-runtime'
|
||||
import type { RpcRequest } from './core'
|
||||
import { RpcDispatcher } from './dispatcher'
|
||||
import { ORCHESTRATION_METHODS } from './methods/orchestration'
|
||||
|
||||
const COORDINATOR_HANDLE = 'term_legacy_coord'
|
||||
const CURRENT_COORDINATOR_HANDLE = 'term_current_coord'
|
||||
const CURRENT_COORDINATOR_PANE = 'tab_current:55555555-5555-4555-8555-555555555555'
|
||||
const WORKER_HANDLE = 'term_legacy_worker'
|
||||
const WORKER_PANE = 'tab_worker:33333333-3333-4333-8333-333333333333'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
const databases: OrchestrationDb[] = []
|
||||
|
||||
afterEach(() => {
|
||||
databases.splice(0).forEach((database) => database.close())
|
||||
tempDirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true }))
|
||||
})
|
||||
|
||||
function createHarness(options?: { seedCutoverQuestion?: boolean; seedCutoverAnswer?: boolean }): {
|
||||
db: OrchestrationDb
|
||||
dispatcher: RpcDispatcher
|
||||
adoptedRunId: string
|
||||
dispatchId: string
|
||||
cutoverQuestionId?: string
|
||||
cutoverAnswerId?: string
|
||||
} {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-legacy-question-takeover-'))
|
||||
tempDirs.push(dir)
|
||||
const dbPath = join(dir, 'orchestration.db')
|
||||
const before = new OrchestrationDb(dbPath)
|
||||
const task = before.createTask({
|
||||
spec: 'legacy assignment',
|
||||
createdByTerminalHandle: COORDINATOR_HANDLE
|
||||
})
|
||||
const dispatch = before.createDispatchContext(task.id, WORKER_HANDLE, WORKER_PANE)
|
||||
const cutoverQuestion = options?.seedCutoverQuestion
|
||||
? before.insertMessage({
|
||||
from: WORKER_HANDLE,
|
||||
to: COORDINATOR_HANDLE,
|
||||
subject: 'Question',
|
||||
body: 'Continue after update?',
|
||||
type: 'decision_gate',
|
||||
payload: JSON.stringify({
|
||||
question: 'Continue after update?',
|
||||
options: ['yes', 'no']
|
||||
})
|
||||
})
|
||||
: undefined
|
||||
const cutoverAnswer =
|
||||
cutoverQuestion && options?.seedCutoverAnswer
|
||||
? before.insertMessage({
|
||||
from: COORDINATOR_HANDLE,
|
||||
to: WORKER_HANDLE,
|
||||
subject: 'Re: Question',
|
||||
body: 'Continue.',
|
||||
threadId: cutoverQuestion.id
|
||||
})
|
||||
: undefined
|
||||
before.close()
|
||||
|
||||
const raw = new Database(dbPath)
|
||||
raw.exec(`
|
||||
UPDATE dispatch_contexts SET process_incarnation = 'process-1';
|
||||
DROP INDEX IF EXISTS idx_messages_delivery_contract;
|
||||
DROP TABLE legacy_mail_receipts;
|
||||
DROP TABLE legacy_operation_receipts;
|
||||
DROP TABLE legacy_compatibility_principals;
|
||||
DROP TABLE legacy_adoptions;
|
||||
`)
|
||||
raw.pragma('user_version = 18')
|
||||
raw.close()
|
||||
|
||||
const db = new OrchestrationDb(dbPath)
|
||||
databases.push(db)
|
||||
const runtime = new OrcaRuntimeService()
|
||||
runtime.setOrchestrationDb(db)
|
||||
vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) =>
|
||||
handle === WORKER_HANDLE
|
||||
? WORKER_PANE
|
||||
: handle === CURRENT_COORDINATOR_HANDLE
|
||||
? CURRENT_COORDINATOR_PANE
|
||||
: null
|
||||
)
|
||||
vi.spyOn(runtime, 'verifyOrchestrationCompatibilityCaller').mockImplementation((evidence) => {
|
||||
const worker =
|
||||
evidence?.terminalHandle === WORKER_HANDLE &&
|
||||
evidence.paneKey === WORKER_PANE &&
|
||||
evidence.launchToken === 'worker-token'
|
||||
const coordinator =
|
||||
evidence?.terminalHandle === CURRENT_COORDINATOR_HANDLE &&
|
||||
evidence.paneKey === CURRENT_COORDINATOR_PANE &&
|
||||
evidence.launchToken === 'current-coordinator-token'
|
||||
const launchToken = worker
|
||||
? 'worker-token'
|
||||
: coordinator
|
||||
? 'current-coordinator-token'
|
||||
: undefined
|
||||
return launchToken
|
||||
? {
|
||||
hostScope: { kind: 'local', hostId: 'local' },
|
||||
terminalHandle: evidence?.terminalHandle as string,
|
||||
paneKey: evidence?.paneKey as string,
|
||||
processIncarnation: 'process-1',
|
||||
launchTokenHash: createHash('sha256').update(launchToken).digest('hex')
|
||||
}
|
||||
: null
|
||||
})
|
||||
return {
|
||||
db,
|
||||
dispatcher: new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }),
|
||||
adoptedRunId: db.getLegacyAdoption()?.adopted_run_id as string,
|
||||
dispatchId: dispatch.id,
|
||||
...(cutoverQuestion ? { cutoverQuestionId: cutoverQuestion.id } : {}),
|
||||
...(cutoverAnswer ? { cutoverAnswerId: cutoverAnswer.id } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function request(
|
||||
method: string,
|
||||
params: unknown,
|
||||
invocationId: string,
|
||||
evidence?: OrchestrationCompatibilityEvidence
|
||||
): RpcRequest {
|
||||
return {
|
||||
id: `rpc_${invocationId}`,
|
||||
authToken: 'caller-token',
|
||||
method,
|
||||
params,
|
||||
orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION,
|
||||
orchestrationRequestId: invocationId,
|
||||
compatibilityInvocationId: invocationId,
|
||||
orchestrationCompatibilityEvidence: evidence
|
||||
}
|
||||
}
|
||||
|
||||
const workerEvidence: OrchestrationCompatibilityEvidence = {
|
||||
terminalHandle: WORKER_HANDLE,
|
||||
paneKey: WORKER_PANE,
|
||||
launchToken: 'worker-token'
|
||||
}
|
||||
|
||||
describe('legacy question takeover compatibility', () => {
|
||||
it('reuses a pending ask that crossed the schema cutover', async () => {
|
||||
const harness = createHarness({ seedCutoverQuestion: true })
|
||||
|
||||
const asked = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.ask',
|
||||
{
|
||||
from: WORKER_HANDLE,
|
||||
to: COORDINATOR_HANDLE,
|
||||
question: 'Continue after update?',
|
||||
options: 'yes,no',
|
||||
timeoutMs: 0
|
||||
},
|
||||
'retry-cutover-ask',
|
||||
workerEvidence
|
||||
)
|
||||
)
|
||||
|
||||
expect(asked).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
messageId: harness.cutoverQuestionId,
|
||||
legacyCompatibility: { replayed: true }
|
||||
}
|
||||
})
|
||||
const sqlite = (harness.db as unknown as { db: Database.Database }).db
|
||||
expect(
|
||||
(
|
||||
sqlite
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM messages
|
||||
WHERE type IN ('decision_gate', 'question')`
|
||||
)
|
||||
.get() as { count: number }
|
||||
).count
|
||||
).toBe(1)
|
||||
expect(
|
||||
(sqlite.prepare('SELECT COUNT(*) AS count FROM question_threads').get() as { count: number })
|
||||
.count
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
it('resumes an answered pinned-A ask without duplicating it', async () => {
|
||||
const harness = createHarness({
|
||||
seedCutoverQuestion: true,
|
||||
seedCutoverAnswer: true
|
||||
})
|
||||
|
||||
const resumed = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.ask',
|
||||
{
|
||||
from: WORKER_HANDLE,
|
||||
resume: harness.cutoverQuestionId,
|
||||
timeoutMs: 0
|
||||
},
|
||||
'resume-answered-cutover-ask',
|
||||
workerEvidence
|
||||
)
|
||||
)
|
||||
|
||||
expect(resumed).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
answer: 'Continue.',
|
||||
answerMessageId: harness.cutoverAnswerId,
|
||||
messageId: harness.cutoverQuestionId,
|
||||
legacyCompatibility: {
|
||||
answerAcknowledgement: {
|
||||
questionId: harness.cutoverQuestionId,
|
||||
answerMessageId: harness.cutoverAnswerId
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
const sqlite = (harness.db as unknown as { db: Database.Database }).db
|
||||
expect(
|
||||
(sqlite.prepare('SELECT COUNT(*) AS count FROM question_threads').get() as { count: number })
|
||||
.count
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
it('resumes and idempotently acknowledges the exact current Run answer', async () => {
|
||||
const harness = createHarness()
|
||||
const asked = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.ask',
|
||||
{
|
||||
from: WORKER_HANDLE,
|
||||
to: COORDINATOR_HANDLE,
|
||||
question: 'Continue after takeover?',
|
||||
timeoutMs: 0
|
||||
},
|
||||
'ask-before-takeover',
|
||||
workerEvidence
|
||||
)
|
||||
)
|
||||
const questionId = (asked as { result: { messageId: string } }).result.messageId
|
||||
|
||||
await expect(
|
||||
harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.runUse',
|
||||
{
|
||||
id: harness.adoptedRunId,
|
||||
from: CURRENT_COORDINATOR_HANDLE,
|
||||
takeoverLegacy: true
|
||||
},
|
||||
'take-over',
|
||||
{
|
||||
terminalHandle: CURRENT_COORDINATOR_HANDLE,
|
||||
paneKey: CURRENT_COORDINATOR_PANE,
|
||||
launchToken: 'current-coordinator-token'
|
||||
}
|
||||
)
|
||||
)
|
||||
).resolves.toMatchObject({ ok: true })
|
||||
await expect(
|
||||
harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.reply',
|
||||
{
|
||||
id: questionId,
|
||||
body: 'Continue.',
|
||||
from: CURRENT_COORDINATOR_HANDLE,
|
||||
run: harness.adoptedRunId
|
||||
},
|
||||
'answer-after-takeover'
|
||||
)
|
||||
)
|
||||
).resolves.toMatchObject({ ok: true })
|
||||
|
||||
const resumed = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.ask',
|
||||
{ from: WORKER_HANDLE, resume: questionId, timeoutMs: 100 },
|
||||
'resume-after-takeover',
|
||||
workerEvidence
|
||||
)
|
||||
)
|
||||
expect(resumed).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
answer: 'Continue.',
|
||||
legacyCompatibility: {
|
||||
answerAcknowledgement: {
|
||||
questionId,
|
||||
answerMessageId: expect.any(String)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
const acknowledgement = (
|
||||
resumed as {
|
||||
result: {
|
||||
legacyCompatibility: {
|
||||
answerAcknowledgement: { questionId: string; answerMessageId: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
).result.legacyCompatibility.answerAcknowledgement
|
||||
expect(harness.db.getMessageById(acknowledgement.answerMessageId)).toMatchObject({
|
||||
run_id: harness.adoptedRunId,
|
||||
from_handle: `run:${harness.adoptedRunId}`,
|
||||
to_handle: `dispatch:${harness.dispatchId}`,
|
||||
delivery_contract: 'current_delivery',
|
||||
thread_id: questionId
|
||||
})
|
||||
|
||||
const acknowledge = (invocationId: string) =>
|
||||
harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{
|
||||
terminal: WORKER_HANDLE,
|
||||
compatibilityQuestionAck: JSON.stringify(acknowledgement)
|
||||
},
|
||||
invocationId,
|
||||
workerEvidence
|
||||
)
|
||||
)
|
||||
const sqlite = (harness.db as unknown as { db: Database.Database }).db
|
||||
sqlite
|
||||
.prepare('UPDATE messages SET to_handle = ? WHERE id = ?')
|
||||
.run('dispatch:foreign', acknowledgement.answerMessageId)
|
||||
await expect(acknowledge('ack-wrong-answer-route')).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'request_mismatch' }
|
||||
})
|
||||
sqlite
|
||||
.prepare('UPDATE messages SET to_handle = ? WHERE id = ?')
|
||||
.run(`dispatch:${harness.dispatchId}`, acknowledgement.answerMessageId)
|
||||
|
||||
await expect(acknowledge('ack-answer')).resolves.toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
acknowledged: [acknowledgement.answerMessageId],
|
||||
duplicate: false,
|
||||
legacyCompatibility: { acknowledged: true }
|
||||
}
|
||||
})
|
||||
await expect(acknowledge('ack-answer-repeat')).resolves.toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
acknowledged: [acknowledgement.answerMessageId],
|
||||
duplicate: true,
|
||||
legacyCompatibility: { acknowledged: true }
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import { clampOrchestrationAskTimeoutMs } from '../../../shared/orchestration-ask-timeout'
|
||||
import type { RpcRequest } from './core'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
import { OrchestrationError } from '../orchestration/orchestration-error'
|
||||
import { LEGACY_CONTRACT_VERSION } from '../orchestration/db'
|
||||
import type { LegacyCompatibilityAuthority } from './orchestration-legacy-authority'
|
||||
import {
|
||||
normalizeLegacyText,
|
||||
operationIdentity,
|
||||
parseLegacyOptions,
|
||||
type LegacyAskParams
|
||||
} from './orchestration-legacy-operation'
|
||||
|
||||
export async function handleLegacyAsk(args: {
|
||||
runtime: OrcaRuntimeService
|
||||
authority: LegacyCompatibilityAuthority
|
||||
request: RpcRequest
|
||||
params: LegacyAskParams
|
||||
signal?: AbortSignal
|
||||
}): Promise<unknown | undefined> {
|
||||
const { runtime, authority, request, params, signal } = args
|
||||
const dispatch = authority.resolveAskDispatch(request, params)
|
||||
if (!dispatch || dispatch.contract_version !== LEGACY_CONTRACT_VERSION) {
|
||||
return undefined
|
||||
}
|
||||
const db = runtime.getOrchestrationDb()
|
||||
if (!params.resume) {
|
||||
if (!params.to) {
|
||||
throw new OrchestrationError('invalid_argument', 'Legacy ask requires --to.')
|
||||
}
|
||||
if (!db.isLegacyCoordinatorHandle(dispatch.run_id, params.to)) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Terminal ${params.to} is not a retained coordinator for this legacy Dispatch.`
|
||||
)
|
||||
}
|
||||
}
|
||||
const principal = authority.attestWorker(request, dispatch)
|
||||
const timeoutMs = clampOrchestrationAskTimeoutMs(params.timeoutMs)
|
||||
let questionId = params.resume
|
||||
let duplicate = true
|
||||
if (questionId) {
|
||||
const question = db.getQuestion(questionId)
|
||||
if (!question || question.dispatch_id !== dispatch.id) {
|
||||
throw new OrchestrationError(
|
||||
'question_not_found',
|
||||
`Question ${questionId} does not belong to this legacy Dispatch.`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const question = params.question as string
|
||||
const options = parseLegacyOptions(params.options)
|
||||
const recipient = params.to as string
|
||||
const operation = operationIdentity(request, 'ask', {
|
||||
question: normalizeLegacyText(question),
|
||||
options: options.map(normalizeLegacyText),
|
||||
recipient
|
||||
})
|
||||
const priorReceipt = db.getLegacyOperationReceipt(principal.id, operation.key)
|
||||
let existingQuestionId: string | undefined
|
||||
if (!priorReceipt) {
|
||||
const matches = db.findLegacyQuestionsBySemanticIdentity({
|
||||
principalId: principal.id,
|
||||
question,
|
||||
options,
|
||||
recipientHandle: recipient
|
||||
})
|
||||
const unclaimed = matches.filter((match) => !match.claimedByOperation)
|
||||
const pending = unclaimed.filter((match) => match.question.status === 'pending')
|
||||
const lostAnswer = unclaimed.find(
|
||||
(match) => match.question.status === 'answered' && !match.answerAcknowledged
|
||||
)
|
||||
if (lostAnswer) {
|
||||
const cliCommand =
|
||||
params.compatibilityCliCommand ?? params.compatibilityWindowsCommand ?? 'orca'
|
||||
throw new OrchestrationError(
|
||||
'operation_unknown',
|
||||
`A matching legacy answer may have been accepted before the update. Run ${cliCommand} orchestration check --terminal ${principal.terminal_handle} before asking again.`
|
||||
)
|
||||
}
|
||||
if (pending.length > 1) {
|
||||
throw new OrchestrationError(
|
||||
'operation_unknown',
|
||||
'Multiple matching pending legacy questions exist; drain legacy check before retrying.'
|
||||
)
|
||||
}
|
||||
existingQuestionId = pending[0]?.question.message_id
|
||||
}
|
||||
const committed = db.commitLegacyAskOperation({
|
||||
question,
|
||||
principalId: principal.id,
|
||||
operationKey: operation.key,
|
||||
method: request.method,
|
||||
payloadHash: operation.payloadHash,
|
||||
options,
|
||||
recipientHandle: recipient,
|
||||
existingQuestionId
|
||||
})
|
||||
questionId = committed.question.message_id
|
||||
duplicate = committed.duplicate || Boolean(existingQuestionId)
|
||||
if (!duplicate) {
|
||||
runtime.notifyMessageArrived(committed.message.to_handle, committed.message.type)
|
||||
}
|
||||
}
|
||||
|
||||
if (params.compatibilityWindowsCommand && !params.resume) {
|
||||
return {
|
||||
answer: null,
|
||||
messageId: questionId,
|
||||
threadId: questionId,
|
||||
timedOut: false,
|
||||
cancelled: false,
|
||||
connectionLost: false,
|
||||
timeoutMs,
|
||||
legacyCompatibility: {
|
||||
resumeRequired: true,
|
||||
resumeCommand: `${params.compatibilityWindowsCommand} orchestration ask --resume ${questionId}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (true) {
|
||||
const current = db.getQuestion(questionId as string)
|
||||
if (!current || current.status === 'closed') {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Question ${questionId as string} closed because its Dispatch is inactive.`
|
||||
)
|
||||
}
|
||||
if (current.status === 'answered') {
|
||||
return {
|
||||
answer: current.answer_body,
|
||||
answerMessageId: current.answer_message_id,
|
||||
messageId: current.message_id,
|
||||
threadId: current.message_id,
|
||||
timedOut: false,
|
||||
cancelled: false,
|
||||
connectionLost: false,
|
||||
timeoutMs,
|
||||
legacyCompatibility: {
|
||||
replayed: duplicate,
|
||||
answerAcknowledgement: current.answer_message_id
|
||||
? {
|
||||
questionId: current.message_id,
|
||||
answerMessageId: current.answer_message_id
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
if (signal?.aborted || Date.now() >= deadline) {
|
||||
return {
|
||||
answer: null,
|
||||
messageId: questionId,
|
||||
threadId: questionId,
|
||||
timedOut: !signal?.aborted,
|
||||
cancelled: signal?.aborted === true,
|
||||
connectionLost: signal?.aborted === true,
|
||||
timeoutMs,
|
||||
legacyCompatibility: { replayed: duplicate, ackMessageIds: [] }
|
||||
}
|
||||
}
|
||||
await runtime.waitForMessage(principal.terminal_handle, {
|
||||
timeoutMs: Math.min(1_000, Math.max(deadline - Date.now(), 1)),
|
||||
signal
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence'
|
||||
import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { OrcaRuntimeService } from '../orca-runtime'
|
||||
import { OrchestrationDb } from '../orchestration/db'
|
||||
import type { RpcRequest } from './core'
|
||||
import { RpcDispatcher } from './dispatcher'
|
||||
import { ORCHESTRATION_METHODS } from './methods/orchestration'
|
||||
|
||||
const WORKER_HANDLE = 'term_legacy_worker'
|
||||
const WORKER_PANE = 'tab_worker:33333333-3333-4333-8333-333333333333'
|
||||
const COORDINATOR_HANDLE = 'term_legacy_coord'
|
||||
const COORDINATOR_PANE = 'tab_coord:44444444-4444-4444-8444-444444444444'
|
||||
const CURRENT_COORDINATOR_HANDLE = 'term_current_coord'
|
||||
const CURRENT_COORDINATOR_PANE = 'tab_current:55555555-5555-4555-8555-555555555555'
|
||||
|
||||
type Harness = {
|
||||
db: OrchestrationDb
|
||||
dispatcher: RpcDispatcher
|
||||
adoptedRunId: string
|
||||
taskId: string
|
||||
dispatchId: string
|
||||
}
|
||||
|
||||
const tempDirs: string[] = []
|
||||
const databases: OrchestrationDb[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const database of databases.splice(0)) {
|
||||
database.close()
|
||||
}
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function createHarness(): Harness {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-legacy-takeover-'))
|
||||
tempDirs.push(dir)
|
||||
const dbPath = join(dir, 'orchestration.db')
|
||||
const before = new OrchestrationDb(dbPath)
|
||||
const task = before.createTask({
|
||||
spec: 'legacy assignment',
|
||||
createdByTerminalHandle: COORDINATOR_HANDLE
|
||||
})
|
||||
const dispatch = before.createDispatchContext(task.id, WORKER_HANDLE, WORKER_PANE)
|
||||
before.close()
|
||||
|
||||
const raw = new Database(dbPath)
|
||||
raw.exec(`
|
||||
UPDATE dispatch_contexts SET process_incarnation = 'process-1';
|
||||
DROP INDEX IF EXISTS idx_messages_delivery_contract;
|
||||
DROP TABLE legacy_mail_receipts;
|
||||
DROP TABLE legacy_operation_receipts;
|
||||
DROP TABLE legacy_compatibility_principals;
|
||||
DROP TABLE legacy_adoptions;
|
||||
`)
|
||||
raw.pragma('user_version = 18')
|
||||
raw.close()
|
||||
|
||||
const db = new OrchestrationDb(dbPath)
|
||||
databases.push(db)
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
|
||||
const runtime = new OrcaRuntimeService()
|
||||
runtime.setOrchestrationDb(db)
|
||||
vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) =>
|
||||
handle === COORDINATOR_HANDLE
|
||||
? COORDINATOR_PANE
|
||||
: handle === WORKER_HANDLE
|
||||
? WORKER_PANE
|
||||
: handle === CURRENT_COORDINATOR_HANDLE
|
||||
? CURRENT_COORDINATOR_PANE
|
||||
: null
|
||||
)
|
||||
vi.spyOn(runtime, 'verifyOrchestrationCompatibilityCaller').mockImplementation((proof) => {
|
||||
const validWorker = proof?.terminalHandle === WORKER_HANDLE && proof.paneKey === WORKER_PANE
|
||||
const validCoordinator =
|
||||
proof?.terminalHandle === COORDINATOR_HANDLE && proof.paneKey === COORDINATOR_PANE
|
||||
const validCurrentCoordinator =
|
||||
proof?.terminalHandle === CURRENT_COORDINATOR_HANDLE &&
|
||||
proof.paneKey === CURRENT_COORDINATOR_PANE
|
||||
if ((!validWorker && !validCoordinator && !validCurrentCoordinator) || !proof?.launchToken) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
hostScope: { kind: 'local', hostId: 'local' },
|
||||
terminalHandle: proof.terminalHandle as string,
|
||||
paneKey: proof.paneKey as string,
|
||||
processIncarnation: 'process-1',
|
||||
launchTokenHash: createHash('sha256').update(proof.launchToken).digest('hex')
|
||||
}
|
||||
})
|
||||
vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {})
|
||||
return {
|
||||
db,
|
||||
dispatcher: new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }),
|
||||
adoptedRunId,
|
||||
taskId: task.id,
|
||||
dispatchId: dispatch.id
|
||||
}
|
||||
}
|
||||
|
||||
function evidence(
|
||||
role: 'worker' | 'coordinator' | 'current-coordinator'
|
||||
): OrchestrationCompatibilityEvidence {
|
||||
const worker = role === 'worker'
|
||||
const currentCoordinator = role === 'current-coordinator'
|
||||
return {
|
||||
terminalHandle: worker
|
||||
? WORKER_HANDLE
|
||||
: currentCoordinator
|
||||
? CURRENT_COORDINATOR_HANDLE
|
||||
: COORDINATOR_HANDLE,
|
||||
paneKey: worker
|
||||
? WORKER_PANE
|
||||
: currentCoordinator
|
||||
? CURRENT_COORDINATOR_PANE
|
||||
: COORDINATOR_PANE,
|
||||
launchToken: `${role}-token`
|
||||
}
|
||||
}
|
||||
|
||||
function request(
|
||||
method: string,
|
||||
params: unknown,
|
||||
proof: OrchestrationCompatibilityEvidence,
|
||||
invocationId: string
|
||||
): RpcRequest {
|
||||
return {
|
||||
id: `rpc_${invocationId}`,
|
||||
authToken: 'caller-token',
|
||||
method,
|
||||
params,
|
||||
orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION,
|
||||
orchestrationRequestId: invocationId,
|
||||
compatibilityInvocationId: invocationId,
|
||||
orchestrationCompatibilityEvidence: proof
|
||||
}
|
||||
}
|
||||
|
||||
function counts(db: OrchestrationDb): Record<string, number> {
|
||||
const sqlite = (db as unknown as { db: Database.Database }).db
|
||||
return Object.fromEntries(
|
||||
[
|
||||
'messages',
|
||||
'legacy_compatibility_principals',
|
||||
'legacy_operation_receipts',
|
||||
'legacy_mail_receipts',
|
||||
'mutation_receipts'
|
||||
].map((table) => [
|
||||
table,
|
||||
(sqlite.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number }).count
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
function escalationParams(harness: Harness) {
|
||||
return {
|
||||
from: WORKER_HANDLE,
|
||||
to: COORDINATOR_HANDLE,
|
||||
subject: 'Blocked',
|
||||
type: 'escalation',
|
||||
payload: JSON.stringify({ taskId: harness.taskId, dispatchId: harness.dispatchId })
|
||||
}
|
||||
}
|
||||
|
||||
describe('legacy compatibility after explicit takeover', () => {
|
||||
it('binds takeover to the authenticated invoking coordinator pane', async () => {
|
||||
const harness = createHarness()
|
||||
const before = harness.db.getRun(harness.adoptedRunId)
|
||||
|
||||
const spoofed = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.runUse',
|
||||
{
|
||||
id: harness.adoptedRunId,
|
||||
from: COORDINATOR_HANDLE,
|
||||
takeoverLegacy: true
|
||||
},
|
||||
evidence('current-coordinator'),
|
||||
'spoofed-takeover'
|
||||
)
|
||||
)
|
||||
|
||||
expect(spoofed).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'legacy_read_only', data: { effectsApplied: false } }
|
||||
})
|
||||
expect(harness.db.getRun(harness.adoptedRunId)).toEqual(before)
|
||||
|
||||
const bound = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.runUse',
|
||||
{
|
||||
id: harness.adoptedRunId,
|
||||
from: CURRENT_COORDINATOR_HANDLE,
|
||||
takeoverLegacy: true
|
||||
},
|
||||
evidence('current-coordinator'),
|
||||
'authenticated-takeover'
|
||||
)
|
||||
)
|
||||
|
||||
expect(bound).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
run: {
|
||||
coordinator_handle: CURRENT_COORDINATOR_HANDLE,
|
||||
coordinator_pane_key: CURRENT_COORDINATOR_PANE
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('does not let an uncommitted legacy coordinator attest after explicit takeover', async () => {
|
||||
const harness = createHarness()
|
||||
harness.db.bindRun({
|
||||
runId: harness.adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:55555555-5555-4555-8555-555555555555',
|
||||
takeoverLegacy: true
|
||||
})
|
||||
expect(harness.db.getLegacyCoordinatorPrincipal(harness.adoptedRunId)).toBeUndefined()
|
||||
const before = counts(harness.db)
|
||||
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.taskList',
|
||||
{},
|
||||
evidence('coordinator'),
|
||||
'uncommitted-coordinator-after-takeover'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'legacy_read_only' } })
|
||||
expect(harness.db.getLegacyCoordinatorPrincipal(harness.adoptedRunId)).toBeUndefined()
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
})
|
||||
|
||||
it.each(['escalation', 'worker_done'] as const)(
|
||||
'keeps the retained coordinator address routable for post-takeover %s',
|
||||
async (type) => {
|
||||
const harness = createHarness()
|
||||
harness.db.bindRun({
|
||||
runId: harness.adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:55555555-5555-4555-8555-555555555555',
|
||||
takeoverLegacy: true
|
||||
})
|
||||
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.send',
|
||||
{
|
||||
...escalationParams(harness),
|
||||
type,
|
||||
subject: type === 'worker_done' ? 'Completed' : 'Blocked'
|
||||
},
|
||||
evidence('worker'),
|
||||
`post-takeover-${type}`
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
message: {
|
||||
to_handle: `run:${harness.adoptedRunId}`,
|
||||
delivery_contract: 'current_delivery',
|
||||
type
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('routes future worker mail to Run Delivery after same-handle takeover', async () => {
|
||||
const harness = createHarness()
|
||||
harness.db.bindRun({
|
||||
runId: harness.adoptedRunId,
|
||||
coordinatorHandle: COORDINATOR_HANDLE,
|
||||
coordinatorPaneKey: COORDINATOR_PANE,
|
||||
takeoverLegacy: true
|
||||
})
|
||||
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.send',
|
||||
escalationParams(harness),
|
||||
evidence('worker'),
|
||||
'same-handle-takeover-mail'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
message: {
|
||||
to_handle: `run:${harness.adoptedRunId}`,
|
||||
delivery_contract: 'current_delivery',
|
||||
type: 'escalation'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps post-takeover legacy ask routed to the adopted Run', async () => {
|
||||
const harness = createHarness()
|
||||
harness.db.bindRun({
|
||||
runId: harness.adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:55555555-5555-4555-8555-555555555555',
|
||||
takeoverLegacy: true
|
||||
})
|
||||
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.ask',
|
||||
{
|
||||
from: WORKER_HANDLE,
|
||||
to: COORDINATOR_HANDLE,
|
||||
question: 'Continue after takeover?',
|
||||
timeoutMs: 1
|
||||
},
|
||||
evidence('worker'),
|
||||
'post-takeover-ask'
|
||||
)
|
||||
)
|
||||
const messageId = (response as { result?: { messageId?: string } }).result?.messageId
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { timedOut: true, messageId: expect.any(String) }
|
||||
})
|
||||
expect(harness.db.getMessageById(messageId as string)).toMatchObject({
|
||||
to_handle: `run:${harness.adoptedRunId}`,
|
||||
delivery_contract: 'current_delivery',
|
||||
type: 'question'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an active legacy worker usable across authenticated takeover', async () => {
|
||||
const harness = createHarness()
|
||||
await expect(
|
||||
harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.runUse',
|
||||
{ id: harness.adoptedRunId, from: COORDINATOR_HANDLE },
|
||||
evidence('coordinator'),
|
||||
'bind-original-coordinator'
|
||||
)
|
||||
)
|
||||
).resolves.toMatchObject({ ok: true })
|
||||
const pendingAsk = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.ask',
|
||||
{
|
||||
from: WORKER_HANDLE,
|
||||
to: COORDINATOR_HANDLE,
|
||||
question: 'Continue with the migration?',
|
||||
timeoutMs: 0
|
||||
},
|
||||
evidence('worker'),
|
||||
'pending-before-takeover'
|
||||
)
|
||||
)
|
||||
const questionId = (pendingAsk as { result: { messageId: string } }).result.messageId
|
||||
const unrelated = harness.db.insertMessage({
|
||||
runId: harness.adoptedRunId,
|
||||
from: WORKER_HANDLE,
|
||||
to: 'term_peer_worker',
|
||||
subject: 'peer-only',
|
||||
deliveryContract: 'legacy_direct'
|
||||
})
|
||||
const currentRequest = (method: string, params: unknown, invocationId: string) =>
|
||||
request(method, params, evidence('current-coordinator'), invocationId)
|
||||
const takeoverParams = {
|
||||
id: harness.adoptedRunId,
|
||||
from: CURRENT_COORDINATOR_HANDLE,
|
||||
takeoverLegacy: true
|
||||
}
|
||||
|
||||
const takeover = await harness.dispatcher.dispatch(
|
||||
currentRequest('orchestration.runUse', takeoverParams, 'explicit-takeover')
|
||||
)
|
||||
const repeated = await harness.dispatcher.dispatch(
|
||||
currentRequest('orchestration.runUse', takeoverParams, 'explicit-takeover-repeat')
|
||||
)
|
||||
|
||||
expect(takeover).toMatchObject({
|
||||
ok: true,
|
||||
result: { binding: { consumerGeneration: 2 } }
|
||||
})
|
||||
expect(repeated).toMatchObject({
|
||||
ok: true,
|
||||
result: { binding: { consumerGeneration: 2 } }
|
||||
})
|
||||
expect(harness.db.getDispatchContextById(harness.dispatchId)?.status).toBe('dispatched')
|
||||
expect(harness.db.getLegacyCoordinatorPrincipal(harness.adoptedRunId)?.status).toBe('revoked')
|
||||
expect(harness.db.getMessageById(unrelated.id)).toMatchObject({
|
||||
to_handle: 'term_peer_worker',
|
||||
delivery_contract: 'legacy_direct'
|
||||
})
|
||||
|
||||
const promoted = await harness.dispatcher.dispatch(
|
||||
currentRequest(
|
||||
'orchestration.check',
|
||||
{
|
||||
terminal: CURRENT_COORDINATOR_HANDLE,
|
||||
run: harness.adoptedRunId,
|
||||
format: true
|
||||
},
|
||||
'read-promoted-question'
|
||||
)
|
||||
)
|
||||
expect(promoted).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
messages: [
|
||||
{
|
||||
id: questionId,
|
||||
to_handle: `run:${harness.adoptedRunId}`,
|
||||
delivery_contract: 'current_delivery'
|
||||
}
|
||||
],
|
||||
formatted: expect.not.stringContaining(`--from run:${harness.adoptedRunId}`)
|
||||
}
|
||||
})
|
||||
const deliveryId = (promoted as { result: { deliveryId: string } }).result.deliveryId
|
||||
await expect(
|
||||
harness.dispatcher.dispatch(
|
||||
currentRequest(
|
||||
'orchestration.reply',
|
||||
{
|
||||
id: questionId,
|
||||
body: 'Yes, continue.',
|
||||
from: CURRENT_COORDINATOR_HANDLE,
|
||||
run: harness.adoptedRunId
|
||||
},
|
||||
'reply-after-takeover'
|
||||
)
|
||||
)
|
||||
).resolves.toMatchObject({ ok: true, result: { question: { status: 'answered' } } })
|
||||
await expect(
|
||||
harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.ask',
|
||||
{ from: WORKER_HANDLE, resume: questionId, timeoutMs: 100 },
|
||||
evidence('worker'),
|
||||
'resume-after-takeover'
|
||||
)
|
||||
)
|
||||
).resolves.toMatchObject({ ok: true, result: { answer: 'Yes, continue.' } })
|
||||
|
||||
const followUp = await harness.dispatcher.dispatch(
|
||||
currentRequest(
|
||||
'orchestration.send',
|
||||
{
|
||||
from: CURRENT_COORDINATOR_HANDLE,
|
||||
to: `dispatch:${harness.dispatchId}`,
|
||||
subject: 'Continue from the new coordinator'
|
||||
},
|
||||
'current-coordinator-to-legacy-worker'
|
||||
)
|
||||
)
|
||||
expect(followUp).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
message: {
|
||||
run_id: harness.adoptedRunId,
|
||||
to_handle: `dispatch:${harness.dispatchId}`,
|
||||
delivery_contract: 'legacy_direct'
|
||||
}
|
||||
}
|
||||
})
|
||||
const workerFollowUp = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{ terminal: WORKER_HANDLE },
|
||||
evidence('worker'),
|
||||
'legacy-worker-check-after-takeover'
|
||||
)
|
||||
)
|
||||
expect(workerFollowUp).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
messages: [{ subject: 'Continue from the new coordinator' }],
|
||||
legacyCompatibility: { ackMessageIds: [expect.any(String)] }
|
||||
}
|
||||
})
|
||||
const [followUpMessageId] = (
|
||||
workerFollowUp as { result: { legacyCompatibility: { ackMessageIds: string[] } } }
|
||||
).result.legacyCompatibility.ackMessageIds
|
||||
await expect(
|
||||
harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{
|
||||
terminal: WORKER_HANDLE,
|
||||
compatibilityAck: JSON.stringify({ messageIds: [followUpMessageId] })
|
||||
},
|
||||
evidence('worker'),
|
||||
'legacy-worker-ack-after-takeover'
|
||||
)
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
acknowledged: [followUpMessageId],
|
||||
legacyCompatibility: { acknowledged: true }
|
||||
}
|
||||
})
|
||||
|
||||
await harness.dispatcher.dispatch(
|
||||
currentRequest(
|
||||
'orchestration.check',
|
||||
{
|
||||
terminal: CURRENT_COORDINATOR_HANDLE,
|
||||
run: harness.adoptedRunId,
|
||||
ack: deliveryId
|
||||
},
|
||||
'ack-promoted-question'
|
||||
)
|
||||
)
|
||||
await expect(
|
||||
harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.send',
|
||||
{
|
||||
from: WORKER_HANDLE,
|
||||
to: COORDINATOR_HANDLE,
|
||||
subject: 'Need current coordinator',
|
||||
type: 'escalation',
|
||||
payload: JSON.stringify({ taskId: harness.taskId })
|
||||
},
|
||||
evidence('worker'),
|
||||
'escalation-after-takeover'
|
||||
)
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
message: {
|
||||
to_handle: `run:${harness.adoptedRunId}`,
|
||||
delivery_contract: 'current_delivery'
|
||||
}
|
||||
}
|
||||
})
|
||||
await expect(
|
||||
harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.taskList',
|
||||
{ callerTerminalHandle: COORDINATOR_HANDLE },
|
||||
evidence('coordinator'),
|
||||
'old-coordinator-fenced'
|
||||
)
|
||||
)
|
||||
).resolves.toMatchObject({ ok: false, error: { code: 'legacy_read_only' } })
|
||||
})
|
||||
})
|
||||
@@ -22,13 +22,14 @@ export class OrchestrationMutationExecutor {
|
||||
async run(
|
||||
request: RpcRequest,
|
||||
params: unknown,
|
||||
invoke: (mutation?: DurableMutationInvocation) => Promise<unknown> | unknown
|
||||
invoke: (mutation?: DurableMutationInvocation) => Promise<unknown> | unknown,
|
||||
callerFingerprintOverride?: string
|
||||
): Promise<unknown> {
|
||||
const requestId = request.orchestrationRequestId
|
||||
if (!requestId || !isOrchestrationMutation(request.method, params)) {
|
||||
return await invoke()
|
||||
}
|
||||
const callerFingerprint = authenticatedCallerFingerprint(request)
|
||||
const callerFingerprint = callerFingerprintOverride ?? authenticatedCallerFingerprint(request)
|
||||
const payloadHash = createHash('sha256')
|
||||
.update(JSON.stringify(canonicalize({ method: request.method, params })))
|
||||
.digest('hex')
|
||||
@@ -55,6 +56,10 @@ export class OrchestrationMutationExecutor {
|
||||
: db.beginMutationReceipt(identity)
|
||||
|
||||
if (begun.disposition === 'completed') {
|
||||
const active = this.inFlight.get(key)
|
||||
if (active) {
|
||||
return attachMutationReceipt(await active, requestId, true)
|
||||
}
|
||||
return attachMutationReceipt(JSON.parse(begun.row.receipt ?? 'null'), requestId, true)
|
||||
}
|
||||
if (begun.disposition === 'pending') {
|
||||
@@ -102,6 +107,20 @@ export class OrchestrationMutationExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
const executorsByRuntime = new WeakMap<OrcaRuntimeService, OrchestrationMutationExecutor>()
|
||||
|
||||
export function getOrchestrationMutationExecutor(
|
||||
runtime: OrcaRuntimeService
|
||||
): OrchestrationMutationExecutor {
|
||||
const existing = executorsByRuntime.get(runtime)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const executor = new OrchestrationMutationExecutor(runtime)
|
||||
executorsByRuntime.set(runtime, executor)
|
||||
return executor
|
||||
}
|
||||
|
||||
export function authenticatedCallerFingerprint(request: RpcRequest): string {
|
||||
const callerToken =
|
||||
request.authToken ||
|
||||
|
||||
@@ -252,7 +252,10 @@ describe('durable orchestration mutation ledger', () => {
|
||||
})
|
||||
|
||||
it('recovers a lost ask acceptance without creating a second question', async () => {
|
||||
const db = new OrchestrationDb(':memory:')
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-mutation-ask-recovery-'))
|
||||
paths.push(dir)
|
||||
const dbPath = join(dir, 'orchestration.db')
|
||||
const db = new OrchestrationDb(dbPath)
|
||||
const runtime = new OrcaRuntimeService()
|
||||
runtime.setOrchestrationDb(db)
|
||||
vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue('tab_worker:leaf_worker')
|
||||
@@ -290,7 +293,16 @@ describe('durable orchestration mutation ledger', () => {
|
||||
const first = firstDispatcher.dispatch(askRequest, { signal: controller.signal })
|
||||
await vi.waitFor(() => expect(db.getInbox(10)).toHaveLength(1))
|
||||
|
||||
const restartedDispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS })
|
||||
const restartedDb = new OrchestrationDb(dbPath)
|
||||
const restartedRuntime = new OrcaRuntimeService()
|
||||
restartedRuntime.setOrchestrationDb(restartedDb)
|
||||
vi.spyOn(restartedRuntime, 'getTerminalPaneKey').mockReturnValue('tab_worker:leaf_worker')
|
||||
vi.spyOn(restartedRuntime, 'getTerminalProcessIncarnation').mockReturnValue('runtime:pty:1')
|
||||
vi.spyOn(restartedRuntime, 'notifyMessageArrived').mockImplementation(() => {})
|
||||
const restartedDispatcher = new RpcDispatcher({
|
||||
runtime: restartedRuntime,
|
||||
methods: ORCHESTRATION_METHODS
|
||||
})
|
||||
const recovered = await restartedDispatcher.dispatch({ ...askRequest, id: 'rpc_ask_2' })
|
||||
expect(recovered).toMatchObject({
|
||||
ok: true,
|
||||
@@ -304,6 +316,7 @@ describe('durable orchestration mutation ledger', () => {
|
||||
|
||||
controller.abort()
|
||||
await first
|
||||
restartedDb.close()
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FeatureInteractionId } from '../../../shared/feature-interactions'
|
||||
import { isBrowserPaneUiRuntimeRpcParams } from '../../../shared/runtime-rpc-feature-interaction-source'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
|
||||
export function getRuntimeFeatureInteractionId(
|
||||
method: string,
|
||||
@@ -37,6 +38,25 @@ export function getRuntimeFeatureInteractionId(
|
||||
return method.startsWith('orchestration.') ? 'agent-orchestration' : null
|
||||
}
|
||||
|
||||
export function recordRuntimeFeatureInteraction(
|
||||
runtime: OrcaRuntimeService,
|
||||
method: string,
|
||||
result: unknown,
|
||||
alreadyRecorded?: Set<FeatureInteractionId>,
|
||||
rawParams?: unknown
|
||||
): void {
|
||||
const id = getRuntimeFeatureInteractionId(method, result, rawParams)
|
||||
if (!id || alreadyRecorded?.has(id)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
runtime.recordFeatureInteraction(id)
|
||||
alreadyRecorded?.add(id)
|
||||
} catch {
|
||||
// Best-effort education state must not break runtime tools.
|
||||
}
|
||||
}
|
||||
|
||||
function hasBooleanResult(value: unknown, key: string): boolean {
|
||||
return (
|
||||
value !== null && typeof value === 'object' && (value as Record<string, unknown>)[key] === true
|
||||
|
||||
@@ -1,20 +1,47 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type * as NodeCrypto from 'node:crypto'
|
||||
import { SshRelaySession } from './ssh-relay-session'
|
||||
import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures'
|
||||
|
||||
const { muxRequestMock } = vi.hoisted(() => ({ muxRequestMock: vi.fn() }))
|
||||
type MockMuxInstance = {
|
||||
requestHandlers: Map<string, (params: Record<string, unknown>) => Promise<unknown>>
|
||||
}
|
||||
|
||||
const { muxRequestMock, muxInstancesRaw } = vi.hoisted(() => ({
|
||||
muxRequestMock: vi.fn(),
|
||||
muxInstancesRaw: [] as unknown[]
|
||||
}))
|
||||
const muxInstances = muxInstancesRaw as MockMuxInstance[]
|
||||
|
||||
vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() }))
|
||||
vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') }))
|
||||
vi.mock('node:crypto', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeCrypto>()
|
||||
return { ...actual, randomUUID: vi.fn() }
|
||||
})
|
||||
vi.mock('./ssh-remote-orca-cli', () => ({
|
||||
runRemoteOrcaCli: vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' })
|
||||
}))
|
||||
vi.mock('./ssh-channel-multiplexer', () => ({
|
||||
SshChannelMultiplexer: class MockSshChannelMultiplexer {
|
||||
requestHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>()
|
||||
notify = vi.fn()
|
||||
request = muxRequestMock
|
||||
onNotification = vi.fn().mockReturnValue(() => {})
|
||||
onRequest = vi.fn().mockReturnValue(() => {})
|
||||
onRequest = vi.fn(
|
||||
(method: string, handler: (params: Record<string, unknown>) => Promise<unknown>) => {
|
||||
this.requestHandlers.set(method, handler)
|
||||
return () => this.requestHandlers.delete(method)
|
||||
}
|
||||
)
|
||||
onDispose = vi.fn().mockReturnValue(() => {})
|
||||
dispose = vi.fn()
|
||||
isDisposed = vi.fn().mockReturnValue(false)
|
||||
|
||||
constructor() {
|
||||
muxInstancesRaw.push(this)
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({
|
||||
@@ -98,13 +125,74 @@ function emitExitDuringAttach(payload: { id: string; code: number; incarnationId
|
||||
describe('SshRelaySession reconnect incarnation ordering', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
muxInstances.splice(0)
|
||||
delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
|
||||
muxRequestMock.mockReset()
|
||||
muxRequestMock.mockResolvedValue([])
|
||||
vi.mocked(randomUUID).mockReset()
|
||||
vi.mocked(randomUUID).mockReturnValue('00000000-0000-4000-8000-000000000001')
|
||||
mockDeploySuccess()
|
||||
vi.mocked(getPtyIdsForConnection).mockReturnValue([])
|
||||
})
|
||||
|
||||
it('keeps the winning reconnect incarnation when a stale health check resolves last', async () => {
|
||||
const initialIncarnation = '00000000-0000-4000-8000-000000000001'
|
||||
const winningIncarnation = '00000000-0000-4000-8000-000000000002'
|
||||
const staleIncarnation = '00000000-0000-4000-8000-000000000003'
|
||||
let resolveStaleHealthCheck!: (value: unknown) => void
|
||||
const staleHealthCheck = new Promise((resolve) => {
|
||||
resolveStaleHealthCheck = resolve
|
||||
})
|
||||
let resolveHomeCalls = 0
|
||||
muxRequestMock.mockImplementation((method: string) => {
|
||||
if (method !== 'session.resolveHome') {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
resolveHomeCalls += 1
|
||||
return resolveHomeCalls === 2 ? staleHealthCheck : Promise.resolve('/')
|
||||
})
|
||||
vi.mocked(randomUUID)
|
||||
.mockReturnValueOnce(initialIncarnation)
|
||||
.mockReturnValueOnce(winningIncarnation)
|
||||
.mockReturnValue(staleIncarnation)
|
||||
const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps()
|
||||
const runtime = {
|
||||
registerOrchestrationCompatibilitySshAttachment: vi.fn(
|
||||
(_targetId: string, connectionIncarnation: string) => ({
|
||||
attachmentId: `attachment-${connectionIncarnation}`,
|
||||
connectionIncarnation
|
||||
})
|
||||
),
|
||||
releaseOrchestrationCompatibilitySshAttachment: vi.fn()
|
||||
}
|
||||
const session = new SshRelaySession(
|
||||
'target-1',
|
||||
getMainWindow,
|
||||
mockStore,
|
||||
mockPortForward,
|
||||
runtime as never
|
||||
)
|
||||
await session.establish(mockConn)
|
||||
|
||||
const staleReconnect = session.reconnect(mockConn)
|
||||
await vi.waitFor(() => expect(resolveHomeCalls).toBe(2))
|
||||
await session.reconnect(mockConn)
|
||||
expect(session.getState()).toBe('ready')
|
||||
|
||||
resolveStaleHealthCheck('/')
|
||||
await staleReconnect
|
||||
|
||||
const winningCliHandler = muxInstances[2]?.requestHandlers.get('orca.cli')
|
||||
expect(winningCliHandler).toBeDefined()
|
||||
await winningCliHandler?.({ argv: ['status'], cwd: '/', env: {} })
|
||||
|
||||
expect(runtime.registerOrchestrationCompatibilitySshAttachment).toHaveBeenCalledWith(
|
||||
'target-1',
|
||||
winningIncarnation
|
||||
)
|
||||
expect(randomUUID).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('restores and persists exact incarnation proof from reconnect attach', async () => {
|
||||
const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps()
|
||||
const incarnationId = 'incarnation-reconnect'
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Why: single authority for all relay lifecycle state per SSH target (previously scattered across module Maps/Sets with duplicated paths).
|
||||
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { deployAndLaunchRelay } from './ssh-relay-deploy'
|
||||
import { execCommand } from './ssh-relay-deploy-helpers'
|
||||
import { isRelayVersionMismatchError } from './ssh-relay-version-mismatch-error'
|
||||
@@ -65,6 +66,10 @@ import {
|
||||
import type { Store } from '../persistence'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { runRemoteOrcaCli } from './ssh-remote-orca-cli'
|
||||
import {
|
||||
acknowledgeRemoteOrcaCliPostOutput,
|
||||
parseRemoteOrcaCliPostOutput
|
||||
} from './ssh-remote-orchestration-post-output'
|
||||
import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { isValidTerminalTabId } from '../../shared/terminal-tab-id'
|
||||
@@ -151,6 +156,7 @@ export class SshRelaySession {
|
||||
private remoteCliBridgeEnv: RemoteCliBridgeEnv | null = null
|
||||
private forwardedReattachReplayByPty = new Map<string, ForwardedReplayFingerprint>()
|
||||
private pendingPtyReattaches = new Map<string, PendingPtyReattach>()
|
||||
private activeCompatibilityAttachmentIds = new Set<string>()
|
||||
|
||||
constructor(
|
||||
readonly targetId: string,
|
||||
@@ -278,8 +284,15 @@ export class SshRelaySession {
|
||||
|
||||
// Why: round-trip the relay before registering providers so a closed --connect bridge fails fast instead of leaving a 'ready' session on a dead mux.
|
||||
await mux.request('session.resolveHome', { path: '~' })
|
||||
if (!ownsAttempt()) {
|
||||
if (!mux.isDisposed()) {
|
||||
mux.dispose()
|
||||
}
|
||||
throw new Error('Session disposed during establish')
|
||||
}
|
||||
const connectionIncarnation = randomUUID()
|
||||
|
||||
const registered = await this.registerProviders(mux, ownsAttempt)
|
||||
const registered = await this.registerProviders(mux, ownsAttempt, connectionIncarnation)
|
||||
if (!registered) {
|
||||
if (!mux.isDisposed()) {
|
||||
mux.dispose()
|
||||
@@ -387,8 +400,9 @@ export class SshRelaySession {
|
||||
}
|
||||
return
|
||||
}
|
||||
const connectionIncarnation = randomUUID()
|
||||
|
||||
const registered = await this.registerProviders(mux, ownsAttempt)
|
||||
const registered = await this.registerProviders(mux, ownsAttempt, connectionIncarnation)
|
||||
if (!registered) {
|
||||
if (!mux.isDisposed()) {
|
||||
mux.dispose()
|
||||
@@ -499,7 +513,8 @@ export class SshRelaySession {
|
||||
// Why: shared by establish() and reconnect() so both use the exact same registration sequence.
|
||||
private async registerProviders(
|
||||
mux: SshChannelMultiplexer,
|
||||
shouldContinue?: () => boolean
|
||||
shouldContinue: (() => boolean) | undefined,
|
||||
connectionIncarnation: string
|
||||
): Promise<boolean> {
|
||||
await this.registerRelayRoots(mux)
|
||||
if (shouldContinue && !shouldContinue()) {
|
||||
@@ -530,7 +545,7 @@ export class SshRelaySession {
|
||||
return false
|
||||
}
|
||||
|
||||
this.wireUpRemoteOrcaCli(mux)
|
||||
this.wireUpRemoteOrcaCli(mux, connectionIncarnation)
|
||||
|
||||
const ptyProvider = new SshPtyProvider(this.targetId, mux, this.remoteCliBridgeEnv ?? undefined)
|
||||
registerSshPtyProvider(this.targetId, ptyProvider)
|
||||
@@ -667,7 +682,7 @@ export class SshRelaySession {
|
||||
}
|
||||
}
|
||||
|
||||
private wireUpRemoteOrcaCli(mux: SshChannelMultiplexer): void {
|
||||
private wireUpRemoteOrcaCli(mux: SshChannelMultiplexer, connectionIncarnation: string): void {
|
||||
mux.onRequest('orca.cli', async (params) => {
|
||||
if (!this.runtime) {
|
||||
throw new Error('Orca runtime is unavailable')
|
||||
@@ -687,12 +702,54 @@ export class SshRelaySession {
|
||||
)
|
||||
: {}
|
||||
const stdin = typeof params.stdin === 'string' ? params.stdin : undefined
|
||||
return await runRemoteOrcaCli(this.runtime, {
|
||||
argv,
|
||||
cwd,
|
||||
env,
|
||||
...(stdin !== undefined ? { stdin } : {})
|
||||
})
|
||||
const runtimeAuthority = this.runtime.registerOrchestrationCompatibilitySshAttachment(
|
||||
this.targetId,
|
||||
connectionIncarnation
|
||||
)
|
||||
this.activeCompatibilityAttachmentIds.add(runtimeAuthority.attachmentId)
|
||||
try {
|
||||
return await runRemoteOrcaCli(this.runtime, {
|
||||
argv,
|
||||
cwd,
|
||||
env,
|
||||
...(stdin !== undefined ? { stdin } : {}),
|
||||
runtimeAuthority
|
||||
})
|
||||
} finally {
|
||||
this.activeCompatibilityAttachmentIds.delete(runtimeAuthority.attachmentId)
|
||||
this.runtime.releaseOrchestrationCompatibilitySshAttachment(runtimeAuthority.attachmentId)
|
||||
}
|
||||
})
|
||||
mux.onRequest('orca.cli.postOutput', async (params) => {
|
||||
if (!this.runtime) {
|
||||
throw new Error('Orca runtime is unavailable')
|
||||
}
|
||||
const rawEnv = params.env
|
||||
const env =
|
||||
rawEnv && typeof rawEnv === 'object' && !Array.isArray(rawEnv)
|
||||
? Object.fromEntries(
|
||||
Object.entries(rawEnv).filter(
|
||||
(entry): entry is [string, string] =>
|
||||
typeof entry[0] === 'string' && typeof entry[1] === 'string'
|
||||
)
|
||||
)
|
||||
: {}
|
||||
const runtimeAuthority = this.runtime.registerOrchestrationCompatibilitySshAttachment(
|
||||
this.targetId,
|
||||
connectionIncarnation
|
||||
)
|
||||
this.activeCompatibilityAttachmentIds.add(runtimeAuthority.attachmentId)
|
||||
try {
|
||||
await acknowledgeRemoteOrcaCliPostOutput(this.runtime, {
|
||||
postOutput: parseRemoteOrcaCliPostOutput(params.postOutput),
|
||||
env,
|
||||
runtimeAuthority
|
||||
})
|
||||
return { acknowledged: true }
|
||||
} finally {
|
||||
this.activeCompatibilityAttachmentIds.delete(runtimeAuthority.attachmentId)
|
||||
this.runtime.releaseOrchestrationCompatibilitySshAttachment(runtimeAuthority.attachmentId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -826,6 +883,10 @@ export class SshRelaySession {
|
||||
this.mux.dispose(reason)
|
||||
}
|
||||
this.mux = null
|
||||
for (const attachmentId of this.activeCompatibilityAttachmentIds) {
|
||||
this.runtime?.releaseOrchestrationCompatibilitySshAttachment(attachmentId)
|
||||
}
|
||||
this.activeCompatibilityAttachmentIds.clear()
|
||||
|
||||
if (reason === 'shutdown') {
|
||||
clearPtyOwnershipForConnection(this.targetId)
|
||||
|
||||
@@ -22,6 +22,12 @@ import {
|
||||
import { resolveOrchestrationAskClientTimeoutMs } from '../../shared/orchestration-ask-timeout'
|
||||
import { remoteCliRequestTimeoutMs } from '../../relay/remote-cli-timeout'
|
||||
import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay'
|
||||
import {
|
||||
ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV,
|
||||
ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV,
|
||||
ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV,
|
||||
ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV
|
||||
} from '../../shared/orchestration-compatibility-evidence'
|
||||
|
||||
type FakeChild = EventEmitter & {
|
||||
stdout: EventEmitter
|
||||
@@ -65,7 +71,12 @@ describe('buildHostCliEnv', () => {
|
||||
ORCA_TERMINAL_HANDLE: 'term_remote',
|
||||
ORCA_WORKTREE_ID: 'repo::/home/alice/wt',
|
||||
ORCA_PANE_KEY: 'pane-9',
|
||||
ORCA_AGENT_LAUNCH_TOKEN: 'launch-secret',
|
||||
ORCA_WORKSPACE_ID: 'ws-1',
|
||||
[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV]: 'wsl',
|
||||
[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV]: 'caller-host',
|
||||
[ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV]: 'caller-incarnation',
|
||||
[ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV]: 'caller-attachment',
|
||||
// Why: these are remote-machine paths and must not leak into the host
|
||||
// subprocess (PATH would break host binary lookup; user-data would
|
||||
// retarget the CLI at a different local instance).
|
||||
@@ -73,13 +84,24 @@ describe('buildHostCliEnv', () => {
|
||||
ORCA_USER_DATA_PATH: '/remote/user-data'
|
||||
},
|
||||
userDataPath: '/host/user-data',
|
||||
remoteCwd: '/home/alice/wt/sub'
|
||||
remoteCwd: '/home/alice/wt/sub',
|
||||
runtimeAuthority: {
|
||||
kind: 'ssh',
|
||||
targetId: 'saved-target',
|
||||
connectionIncarnation: 'connection-incarnation',
|
||||
attachmentId: 'runtime-attachment'
|
||||
}
|
||||
})
|
||||
|
||||
expect(env.ORCA_TERMINAL_HANDLE).toBe('term_remote')
|
||||
expect(env.ORCA_WORKTREE_ID).toBe('repo::/home/alice/wt')
|
||||
expect(env.ORCA_PANE_KEY).toBe('pane-9')
|
||||
expect(env.ORCA_AGENT_LAUNCH_TOKEN).toBe('launch-secret')
|
||||
expect(env.ORCA_WORKSPACE_ID).toBe('ws-1')
|
||||
expect(env[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV]).toBe('ssh')
|
||||
expect(env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV]).toBe('saved-target')
|
||||
expect(env[ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV]).toBe('connection-incarnation')
|
||||
expect(env[ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV]).toBe('runtime-attachment')
|
||||
expect(env.PATH).toBe('/host/bin')
|
||||
expect(env.ORCA_USER_DATA_PATH).toBe('/host/user-data')
|
||||
expect(env.ORCA_CLI_CWD).toBe('/home/alice/wt/sub')
|
||||
|
||||
@@ -17,20 +17,49 @@ import {
|
||||
parsePositiveSafeIntegerNumericText,
|
||||
parsePositiveSafeIntegerText
|
||||
} from '../../shared/timer-delay'
|
||||
import {
|
||||
ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV,
|
||||
ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV,
|
||||
ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV,
|
||||
ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV
|
||||
} from '../../shared/orchestration-compatibility-evidence'
|
||||
|
||||
export type SshCliRuntimeAuthority = {
|
||||
kind: 'ssh'
|
||||
targetId: string
|
||||
connectionIncarnation: string
|
||||
attachmentId: string
|
||||
}
|
||||
|
||||
export type RemoteOrcaCliRequest = {
|
||||
argv: string[]
|
||||
cwd: string
|
||||
env: Record<string, string>
|
||||
stdin?: string
|
||||
runtimeAuthority?: SshCliRuntimeAuthority
|
||||
}
|
||||
|
||||
export type RemoteOrcaCliResult = {
|
||||
stdout: string
|
||||
stderr: string
|
||||
exitCode: number
|
||||
postOutput?: RemoteOrcaCliPostOutput
|
||||
}
|
||||
|
||||
export type RemoteOrcaCliPostOutput =
|
||||
| {
|
||||
kind: 'legacy_check_ack'
|
||||
terminal: string
|
||||
messageIds: string[]
|
||||
types?: string[]
|
||||
}
|
||||
| {
|
||||
kind: 'legacy_question_ack'
|
||||
terminal: string
|
||||
questionId: string
|
||||
answerMessageId: string
|
||||
}
|
||||
|
||||
export type HostCliPassthroughOptions = {
|
||||
execPath?: string
|
||||
cliEntryPath?: string
|
||||
@@ -54,6 +83,7 @@ const REMOTE_CONTEXT_ENV_VARS = [
|
||||
'ORCA_TERMINAL_HANDLE',
|
||||
'ORCA_WORKTREE_ID',
|
||||
'ORCA_PANE_KEY',
|
||||
'ORCA_AGENT_LAUNCH_TOKEN',
|
||||
'ORCA_WORKSPACE_ID'
|
||||
] as const
|
||||
|
||||
@@ -106,6 +136,7 @@ export function buildHostCliEnv(args: {
|
||||
remoteEnv: Record<string, string>
|
||||
userDataPath: string
|
||||
remoteCwd: string
|
||||
runtimeAuthority?: SshCliRuntimeAuthority
|
||||
}): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { ...args.hostEnv }
|
||||
for (const key of REMOTE_CONTEXT_ENV_VARS) {
|
||||
@@ -127,6 +158,17 @@ export function buildHostCliEnv(args: {
|
||||
env.ORCA_NODE_REPL_EXTERNAL_MODULE = args.hostEnv.NODE_REPL_EXTERNAL_MODULE ?? ''
|
||||
delete env.NODE_OPTIONS
|
||||
delete env.NODE_REPL_EXTERNAL_MODULE
|
||||
delete env[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV]
|
||||
delete env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV]
|
||||
delete env[ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV]
|
||||
delete env[ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV]
|
||||
if (args.runtimeAuthority) {
|
||||
env[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV] = 'ssh'
|
||||
env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV] = args.runtimeAuthority.targetId
|
||||
env[ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV] =
|
||||
args.runtimeAuthority.connectionIncarnation
|
||||
env[ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV] = args.runtimeAuthority.attachmentId
|
||||
}
|
||||
env.ELECTRON_RUN_AS_NODE = '1'
|
||||
return env
|
||||
}
|
||||
@@ -177,7 +219,8 @@ export async function runHostOrcaCliPassthrough(
|
||||
hostEnv,
|
||||
remoteEnv: request.env,
|
||||
userDataPath,
|
||||
remoteCwd: request.cwd
|
||||
remoteCwd: request.cwd,
|
||||
runtimeAuthority: request.runtimeAuthority
|
||||
})
|
||||
|
||||
return await new Promise<RemoteOrcaCliResult>((resolve, reject) => {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { RpcResponse } from '../runtime/rpc/core'
|
||||
import type { ParsedRemoteCli } from './ssh-remote-cli-argument-error'
|
||||
import type { RemoteOrcaCliResult } from './ssh-remote-cli-host-passthrough'
|
||||
import { formatRemoteCli } from './ssh-remote-cli-format'
|
||||
import {
|
||||
formatRemoteOrchestrationAsk,
|
||||
getRemoteCliExitCode
|
||||
} from './ssh-remote-orchestration-ask-output'
|
||||
import { formatRemoteOrchestrationCheck } from './ssh-remote-orchestration-check-output'
|
||||
import { getRemoteCliPostOutput } from './ssh-remote-orchestration-post-output'
|
||||
import { resolveRemoteCliHandle } from './ssh-remote-cli-args'
|
||||
|
||||
export function formatInProcessRemoteCliResult(
|
||||
parsed: ParsedRemoteCli,
|
||||
env: Record<string, string>,
|
||||
response: RpcResponse,
|
||||
json: boolean
|
||||
): RemoteOrcaCliResult {
|
||||
const command = parsed.commandPath.join(' ')
|
||||
const formatted =
|
||||
command === 'orchestration check'
|
||||
? formatRemoteOrchestrationCheck(
|
||||
response,
|
||||
json,
|
||||
resolveRemoteCliHandle(parsed.flags, env, 'terminal'),
|
||||
parsed.flags.has('format')
|
||||
)
|
||||
: command === 'orchestration ask'
|
||||
? formatRemoteOrchestrationAsk(response, json)
|
||||
: json
|
||||
? { stdout: `${JSON.stringify(response, null, 2)}\n`, stderr: '' }
|
||||
: formatRemoteCli(response)
|
||||
const postOutput = getRemoteCliPostOutput(parsed, env, response)
|
||||
return {
|
||||
stdout: formatted.stdout,
|
||||
stderr: formatted.stderr,
|
||||
exitCode: getRemoteCliExitCode(command, response),
|
||||
...(postOutput ? { postOutput } : {})
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ const LEGACY_FALLBACK_OPTIONS: HostCliPassthroughOptions = {
|
||||
userDataPath: '/host/user-data',
|
||||
entryExists: () => false
|
||||
}
|
||||
|
||||
type FakeChild = EventEmitter & {
|
||||
stdout: EventEmitter
|
||||
stderr: EventEmitter
|
||||
@@ -80,6 +79,7 @@ describe('runRemoteOrcaCli', () => {
|
||||
}
|
||||
}
|
||||
}),
|
||||
getLegacyAdoption: vi.fn(() => undefined),
|
||||
getActiveDispatchForIdentity: vi.fn(() => undefined),
|
||||
getCurrentRunForPane: vi.fn(() => undefined),
|
||||
findActiveRemoteAttachmentForPane: vi.fn(() => undefined)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope'
|
||||
import { readOrchestrationCompatibilityEvidence } from '../../shared/orchestration-compatibility-evidence'
|
||||
import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version'
|
||||
import { RpcDispatcher } from '../runtime/rpc/dispatcher'
|
||||
import type { RpcResponse } from '../runtime/rpc/core'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { formatRemoteCli } from './ssh-remote-cli-format'
|
||||
import {
|
||||
HostCliUnavailableError,
|
||||
runHostOrcaCliPassthrough,
|
||||
@@ -24,9 +25,9 @@ import { buildRemoteCliError } from './ssh-remote-cli-error-response'
|
||||
import { getRemoteLinearHelp, tryDispatchRemoteLinearCli } from './ssh-remote-linear-cli'
|
||||
import {
|
||||
getRemoteOrchestrationPayload,
|
||||
hasRemoteLifecycleRejection,
|
||||
resolveRemoteOrchestrationSender
|
||||
} from './ssh-remote-orchestration-send'
|
||||
import { formatInProcessRemoteCliResult } from './ssh-remote-cli-in-process-result'
|
||||
|
||||
export type { RemoteOrcaCliRequest, RemoteOrcaCliResult } from './ssh-remote-cli-host-passthrough'
|
||||
|
||||
@@ -49,6 +50,7 @@ export async function runRemoteOrcaCli(
|
||||
): Promise<RemoteOrcaCliResult> {
|
||||
const parsed = parseRemoteCliArgs(request.argv)
|
||||
const json = parsed.flags.has('json')
|
||||
const command = parsed.commandPath.join(' ')
|
||||
|
||||
const interactiveMessage = HOST_INTERACTIVE_COMMANDS[parsed.commandPath[0] ?? '']
|
||||
if (interactiveMessage) {
|
||||
@@ -62,6 +64,17 @@ export async function runRemoteOrcaCli(
|
||||
return { stdout: '', stderr: `${interactiveMessage}\n`, exitCode: 1 }
|
||||
}
|
||||
|
||||
if (command === 'orchestration check' || command === 'orchestration ask') {
|
||||
// Why: compatibility ACKs must wait until relay stdout is observable; a host CLI child can only flush into main's capture pipe.
|
||||
return await runLegacyRemoteOrcaCli(
|
||||
runtime,
|
||||
request,
|
||||
parsed,
|
||||
json,
|
||||
new HostCliUnavailableError('output-ordered orchestration bridge required')
|
||||
)
|
||||
}
|
||||
|
||||
let passthroughFailure: HostCliUnavailableError | null = null
|
||||
try {
|
||||
return await runHostOrcaCliPassthrough(request, passthroughOptions)
|
||||
@@ -96,18 +109,10 @@ async function runLegacyRemoteOrcaCli(
|
||||
parsed,
|
||||
request.env,
|
||||
request.stdin,
|
||||
passthroughFailure.message
|
||||
passthroughFailure.message,
|
||||
request.runtimeAuthority
|
||||
)
|
||||
const formatted = json
|
||||
? { stdout: `${JSON.stringify(response, null, 2)}\n`, stderr: '' }
|
||||
: formatRemoteCli(response)
|
||||
return {
|
||||
stdout: formatted.stdout,
|
||||
stderr: formatted.stderr,
|
||||
// Why: the legacy SSH bridge bypasses the local CLI handler that turns
|
||||
// a persisted lifecycle rejection into an unsuccessful command.
|
||||
exitCode: response.ok && !hasRemoteLifecycleRejection(response.result) ? 0 : 1
|
||||
}
|
||||
return formatInProcessRemoteCliResult(parsed, request.env, response, json)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const code =
|
||||
@@ -134,9 +139,23 @@ async function dispatchRemoteCli(
|
||||
parsed: ParsedRemoteCli,
|
||||
env: Record<string, string>,
|
||||
stdin: string | undefined,
|
||||
passthroughFailureReason: string
|
||||
passthroughFailureReason: string,
|
||||
runtimeAuthority: RemoteOrcaCliRequest['runtimeAuthority']
|
||||
): Promise<RpcResponse> {
|
||||
const command = parsed.commandPath.join(' ')
|
||||
const inheritedEvidence = readOrchestrationCompatibilityEvidence(env)
|
||||
const orchestrationCompatibilityEvidence = runtimeAuthority
|
||||
? { ...inheritedEvidence, host: runtimeAuthority }
|
||||
: inheritedEvidence
|
||||
const compatibilityEnvelope: RuntimeOrchestrationEnvelope = {
|
||||
compatibilityInvocationId: randomUUID(),
|
||||
orchestrationRequestId:
|
||||
optionalRemoteCliString(parsed.flags, 'retry-request') ??
|
||||
(command === 'orchestration check' || command === 'orchestration ask'
|
||||
? randomUUID()
|
||||
: undefined),
|
||||
orchestrationCompatibilityEvidence
|
||||
}
|
||||
const linearResponse = await tryDispatchRemoteLinearCli(dispatcher, parsed, env, stdin)
|
||||
if (linearResponse) {
|
||||
return linearResponse
|
||||
@@ -187,33 +206,74 @@ async function dispatchRemoteCli(
|
||||
senderPaneKey: env.ORCA_PANE_KEY || undefined
|
||||
},
|
||||
{
|
||||
orchestrationCapability: optionalRemoteCliString(parsed.flags, 'dispatch-capability'),
|
||||
orchestrationRequestId: optionalRemoteCliString(parsed.flags, 'retry-request')
|
||||
...compatibilityEnvelope,
|
||||
orchestrationCapability: optionalRemoteCliString(parsed.flags, 'dispatch-capability')
|
||||
}
|
||||
)
|
||||
}
|
||||
case 'orchestration check':
|
||||
return await call(dispatcher, 'orchestration.check', {
|
||||
terminal: resolveRemoteCliHandle(parsed.flags, env, 'terminal'),
|
||||
terminalPaneKey: parsed.flags.has('terminal') ? undefined : env.ORCA_PANE_KEY || undefined,
|
||||
unread: parsed.flags.has('unread') ? true : undefined,
|
||||
all: parsed.flags.has('all') ? true : undefined,
|
||||
types: optionalRemoteCliString(parsed.flags, 'types'),
|
||||
inject: parsed.flags.has('inject') ? true : undefined,
|
||||
wait: parsed.flags.has('wait') ? true : undefined,
|
||||
timeoutMs: optionalRemoteCliNumber(parsed.flags, 'timeout-ms')
|
||||
})
|
||||
return await call(
|
||||
dispatcher,
|
||||
'orchestration.check',
|
||||
{
|
||||
terminal: resolveRemoteCliHandle(parsed.flags, env, 'terminal'),
|
||||
terminalPaneKey: parsed.flags.has('terminal')
|
||||
? undefined
|
||||
: env.ORCA_PANE_KEY || undefined,
|
||||
unread: parsed.flags.has('unread') ? true : parsed.flags.has('peek') ? false : undefined,
|
||||
peek: parsed.flags.has('peek') ? true : undefined,
|
||||
all: parsed.flags.has('all') ? true : undefined,
|
||||
types: optionalRemoteCliString(parsed.flags, 'types'),
|
||||
format: parsed.flags.has('format') ? true : undefined,
|
||||
inject: parsed.flags.has('inject') ? true : undefined,
|
||||
compatibilityCliCommand: 'orca',
|
||||
run: optionalRemoteCliString(parsed.flags, 'run'),
|
||||
ack: optionalRemoteCliString(parsed.flags, 'ack'),
|
||||
wait: parsed.flags.has('wait') ? true : undefined,
|
||||
timeoutMs: optionalRemoteCliNumber(parsed.flags, 'timeout-ms')
|
||||
},
|
||||
compatibilityEnvelope
|
||||
)
|
||||
case 'orchestration ask':
|
||||
return await call(
|
||||
dispatcher,
|
||||
'orchestration.ask',
|
||||
{
|
||||
to: optionalRemoteCliString(parsed.flags, 'to'),
|
||||
question: optionalRemoteCliString(parsed.flags, 'question'),
|
||||
resume: optionalRemoteCliString(parsed.flags, 'resume'),
|
||||
options: optionalRemoteCliString(parsed.flags, 'options'),
|
||||
timeoutMs: optionalRemoteCliNumber(parsed.flags, 'timeout-ms'),
|
||||
from: resolveRemoteCliHandle(parsed.flags, env, 'from'),
|
||||
run: optionalRemoteCliString(parsed.flags, 'run'),
|
||||
compatibilityCliCommand: 'orca'
|
||||
},
|
||||
{
|
||||
...compatibilityEnvelope,
|
||||
orchestrationCapability: optionalRemoteCliString(parsed.flags, 'dispatch-capability')
|
||||
}
|
||||
)
|
||||
case 'orchestration reply':
|
||||
return await call(dispatcher, 'orchestration.reply', {
|
||||
id: requiredRemoteCliString(parsed.flags, 'id'),
|
||||
body: requiredRemoteCliString(parsed.flags, 'body'),
|
||||
from: resolveRemoteCliHandle(parsed.flags, env, 'from')
|
||||
})
|
||||
return await call(
|
||||
dispatcher,
|
||||
'orchestration.reply',
|
||||
{
|
||||
id: requiredRemoteCliString(parsed.flags, 'id'),
|
||||
body: requiredRemoteCliString(parsed.flags, 'body'),
|
||||
from: resolveRemoteCliHandle(parsed.flags, env, 'from')
|
||||
},
|
||||
compatibilityEnvelope
|
||||
)
|
||||
case 'orchestration inbox':
|
||||
return await call(dispatcher, 'orchestration.inbox', {
|
||||
limit: optionalRemoteCliNumber(parsed.flags, 'limit'),
|
||||
terminal: optionalRemoteCliString(parsed.flags, 'terminal')
|
||||
})
|
||||
return await call(
|
||||
dispatcher,
|
||||
'orchestration.inbox',
|
||||
{
|
||||
limit: optionalRemoteCliNumber(parsed.flags, 'limit'),
|
||||
terminal: optionalRemoteCliString(parsed.flags, 'terminal')
|
||||
},
|
||||
compatibilityEnvelope
|
||||
)
|
||||
default:
|
||||
// Why: only reachable when the full host CLI could not be launched;
|
||||
// include that root cause so users can fix the install instead of
|
||||
@@ -239,6 +299,9 @@ async function call(
|
||||
orchestrationContractVersion: method.startsWith('orchestration.')
|
||||
? ORCHESTRATION_CONTRACT_VERSION
|
||||
: undefined,
|
||||
orchestrationRequestId: envelope?.orchestrationRequestId
|
||||
orchestrationRequestId: envelope?.orchestrationRequestId,
|
||||
compatibilityInvocationId:
|
||||
envelope?.orchestrationRequestId ?? envelope?.compatibilityInvocationId,
|
||||
orchestrationCompatibilityEvidence: envelope?.orchestrationCompatibilityEvidence
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { RpcResponse } from '../runtime/rpc/core'
|
||||
import { formatRemoteCli } from './ssh-remote-cli-format'
|
||||
import { hasRemoteLifecycleRejection } from './ssh-remote-orchestration-send'
|
||||
|
||||
export function formatRemoteOrchestrationAsk(
|
||||
response: RpcResponse,
|
||||
json: boolean
|
||||
): { stdout: string; stderr: string } {
|
||||
if (!response.ok || !isRecord(response.result)) {
|
||||
return json
|
||||
? { stdout: `${JSON.stringify(response, null, 2)}\n`, stderr: '' }
|
||||
: formatRemoteCli(response)
|
||||
}
|
||||
if (json) {
|
||||
return { stdout: `${JSON.stringify(response.result)}\n`, stderr: '' }
|
||||
}
|
||||
if (isRecord(response.result.legacyCompatibility)) {
|
||||
const compatibility = response.result.legacyCompatibility
|
||||
if (compatibility.resumeRequired === true && typeof compatibility.resumeCommand === 'string') {
|
||||
return {
|
||||
stdout:
|
||||
`Question ${String(response.result.messageId ?? 'unknown')} committed.\n` +
|
||||
`Resume with: ${compatibility.resumeCommand}\n`,
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
const answer = typeof response.result.answer === 'string' ? response.result.answer : ''
|
||||
const thread = typeof response.result.threadId === 'string' ? response.result.threadId : 'unknown'
|
||||
const timeoutMs =
|
||||
typeof response.result.timeoutMs === 'number' ? response.result.timeoutMs : undefined
|
||||
const stderr = response.result.timedOut
|
||||
? `ask timeout after ${timeoutMs ?? 0}ms (thread ${thread})\n`
|
||||
: response.result.cancelled
|
||||
? response.result.connectionLost
|
||||
? `ask connection closed (question ${String(response.result.messageId ?? 'unknown')})\n`
|
||||
: `ask cancelled (question ${String(response.result.messageId ?? 'unknown')})\n`
|
||||
: ''
|
||||
return { stdout: answer ? `${answer}\n` : '', stderr }
|
||||
}
|
||||
|
||||
export function getRemoteCliExitCode(command: string, response: RpcResponse): number {
|
||||
if (!response.ok || hasRemoteLifecycleRejection(response.result)) {
|
||||
return 1
|
||||
}
|
||||
if (
|
||||
command === 'orchestration ask' &&
|
||||
isRecord(response.result) &&
|
||||
isRecord(response.result.legacyCompatibility) &&
|
||||
response.result.legacyCompatibility.resumeRequired === true
|
||||
) {
|
||||
return 75
|
||||
}
|
||||
if (
|
||||
command === 'orchestration ask' &&
|
||||
isRecord(response.result) &&
|
||||
(response.result.timedOut === true || response.result.cancelled === true)
|
||||
) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
formatOrchestrationCheckText,
|
||||
prepareOrchestrationCheckOutput,
|
||||
type OrchestrationCheckOutput,
|
||||
type OrchestrationMessageSummary
|
||||
} from '../../shared/orchestration-check-output'
|
||||
import type { RpcResponse } from '../runtime/rpc/core'
|
||||
import { formatRemoteCli } from './ssh-remote-cli-format'
|
||||
|
||||
export function formatRemoteOrchestrationCheck(
|
||||
response: RpcResponse,
|
||||
json: boolean,
|
||||
terminal: string,
|
||||
formattedRequested: boolean
|
||||
): { stdout: string; stderr: string } {
|
||||
if (!response.ok || !isRecord(response.result)) {
|
||||
return json
|
||||
? { stdout: `${JSON.stringify(response, null, 2)}\n`, stderr: '' }
|
||||
: formatRemoteCli(response)
|
||||
}
|
||||
const messages = Array.isArray(response.result.messages)
|
||||
? response.result.messages.filter(isMessageSummary)
|
||||
: []
|
||||
const count = typeof response.result.count === 'number' ? response.result.count : messages.length
|
||||
const result = prepareOrchestrationCheckOutput(
|
||||
{ ...(response.result as OrchestrationCheckOutput), messages, count },
|
||||
terminal,
|
||||
formattedRequested
|
||||
)
|
||||
if (json) {
|
||||
return {
|
||||
stdout: `${JSON.stringify({ ...response, result }, null, 2)}\n`,
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
return {
|
||||
stdout: `${formatOrchestrationCheckText(result, terminal)}\n`,
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
|
||||
function isMessageSummary(value: unknown): value is OrchestrationMessageSummary {
|
||||
return isRecord(value) && typeof value.id === 'string' && typeof value.from_handle === 'string'
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getAppPath: () => '/host/app'
|
||||
}
|
||||
}))
|
||||
vi.mock('../persistence', () => ({
|
||||
getCanonicalUserDataPath: () => '/host/user-data'
|
||||
}))
|
||||
|
||||
import { OrchestrationDb } from '../runtime/orchestration/db'
|
||||
import { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import type Database from '../sqlite/sync-database'
|
||||
import type { HostCliPassthroughOptions } from './ssh-remote-cli-host-passthrough'
|
||||
import { runRemoteOrcaCli } from './ssh-remote-orca-cli'
|
||||
import { acknowledgeRemoteOrcaCliPostOutput } from './ssh-remote-orchestration-post-output'
|
||||
|
||||
const LEGACY_FALLBACK_OPTIONS: HostCliPassthroughOptions = {
|
||||
execPath: '/host/electron',
|
||||
cliEntryPath: '/host/app/out/cli/index.js',
|
||||
userDataPath: '/host/user-data',
|
||||
entryExists: () => false
|
||||
}
|
||||
const WORKER_HANDLE = 'term_legacy_ssh_worker'
|
||||
const WORKER_PANE = 'tab_legacy_ssh:33333333-3333-4333-8333-333333333333'
|
||||
const COORDINATOR_HANDLE = 'term_legacy_ssh_coord'
|
||||
const COORDINATOR_PANE = 'tab_legacy_coord:44444444-4444-4444-8444-444444444444'
|
||||
const WORKER_ENV = {
|
||||
ORCA_TERMINAL_HANDLE: WORKER_HANDLE,
|
||||
ORCA_PANE_KEY: WORKER_PANE,
|
||||
ORCA_AGENT_LAUNCH_TOKEN: 'legacy-ssh-token'
|
||||
}
|
||||
const COORDINATOR_ENV = {
|
||||
ORCA_TERMINAL_HANDLE: COORDINATOR_HANDLE,
|
||||
ORCA_PANE_KEY: COORDINATOR_PANE,
|
||||
ORCA_AGENT_LAUNCH_TOKEN: 'legacy-ssh-coordinator-token'
|
||||
}
|
||||
const RUNTIME_AUTHORITY = {
|
||||
kind: 'ssh' as const,
|
||||
targetId: 'saved-target',
|
||||
connectionIncarnation: 'connection-1',
|
||||
attachmentId: 'attachment-1'
|
||||
}
|
||||
|
||||
function createLegacyRuntime() {
|
||||
const db = new OrchestrationDb(':memory:')
|
||||
const run = db.createRun({
|
||||
objective: 'Adopted legacy SSH work',
|
||||
coordinatorHandle: COORDINATOR_HANDLE,
|
||||
coordinatorPaneKey: COORDINATOR_PANE
|
||||
})
|
||||
const task = db.createTask({
|
||||
spec: 'legacy SSH assignment',
|
||||
runId: run.id,
|
||||
createdByTerminalHandle: COORDINATOR_HANDLE
|
||||
})
|
||||
const dispatch = db.createDispatchContext(task.id, WORKER_HANDLE, WORKER_PANE)
|
||||
const sqlite = (db as unknown as { db: Database.Database }).db
|
||||
sqlite
|
||||
.prepare(
|
||||
`UPDATE dispatch_contexts
|
||||
SET contract_version = 0, process_incarnation = 'process-1'
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(dispatch.id)
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO legacy_adoptions (source_run_id, adopted_run_id, scheduler_state_lost)
|
||||
VALUES ('run_legacy_local', ?, 1)`
|
||||
)
|
||||
.run(run.id)
|
||||
sqlite
|
||||
.prepare(
|
||||
`UPDATE runs
|
||||
SET coordinator_handle = NULL, coordinator_pane_key = NULL, consumer_generation = 0
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(run.id)
|
||||
|
||||
const runtime = new OrcaRuntimeService()
|
||||
runtime.setOrchestrationDb(db)
|
||||
vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) =>
|
||||
handle === WORKER_HANDLE ? WORKER_PANE : handle === COORDINATOR_HANDLE ? COORDINATOR_PANE : null
|
||||
)
|
||||
vi.spyOn(runtime, 'verifyOrchestrationCompatibilityCaller').mockImplementation((evidence) => {
|
||||
const worker =
|
||||
evidence?.terminalHandle === WORKER_HANDLE &&
|
||||
evidence.paneKey === WORKER_PANE &&
|
||||
evidence.launchToken === WORKER_ENV.ORCA_AGENT_LAUNCH_TOKEN
|
||||
const coordinator =
|
||||
evidence?.terminalHandle === COORDINATOR_HANDLE &&
|
||||
evidence.paneKey === COORDINATOR_PANE &&
|
||||
evidence.launchToken === COORDINATOR_ENV.ORCA_AGENT_LAUNCH_TOKEN
|
||||
if (
|
||||
(!worker && !coordinator) ||
|
||||
evidence.host?.kind !== 'ssh' ||
|
||||
evidence.host.targetId !== RUNTIME_AUTHORITY.targetId ||
|
||||
evidence.host.connectionIncarnation !== RUNTIME_AUTHORITY.connectionIncarnation
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const identity = worker
|
||||
? {
|
||||
terminalHandle: WORKER_HANDLE,
|
||||
paneKey: WORKER_PANE,
|
||||
launchToken: WORKER_ENV.ORCA_AGENT_LAUNCH_TOKEN
|
||||
}
|
||||
: {
|
||||
terminalHandle: COORDINATOR_HANDLE,
|
||||
paneKey: COORDINATOR_PANE,
|
||||
launchToken: COORDINATOR_ENV.ORCA_AGENT_LAUNCH_TOKEN
|
||||
}
|
||||
return {
|
||||
hostScope: { kind: 'ssh', targetId: RUNTIME_AUTHORITY.targetId },
|
||||
terminalHandle: identity.terminalHandle,
|
||||
paneKey: identity.paneKey,
|
||||
processIncarnation: 'process-1',
|
||||
launchTokenHash: createHash('sha256').update(identity.launchToken).digest('hex')
|
||||
}
|
||||
})
|
||||
vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {})
|
||||
return { db, runtime, run: db.getRun(run.id)!, dispatch }
|
||||
}
|
||||
|
||||
describe('legacy SSH orchestration fallback', () => {
|
||||
it('acknowledges a consuming check only after remote output', async () => {
|
||||
const { db, runtime, run } = createLegacyRuntime()
|
||||
const message = db.insertMessage({
|
||||
runId: run.id,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: COORDINATOR_HANDLE,
|
||||
to: WORKER_HANDLE,
|
||||
subject: 'retained SSH mail'
|
||||
})
|
||||
const request = {
|
||||
argv: ['orchestration', 'check', '--unread', '--inject', '--json'],
|
||||
cwd: '/home/alice/repo',
|
||||
env: WORKER_ENV,
|
||||
runtimeAuthority: RUNTIME_AUTHORITY
|
||||
}
|
||||
|
||||
try {
|
||||
const first = await runRemoteOrcaCli(runtime, request, LEGACY_FALLBACK_OPTIONS)
|
||||
const replay = await runRemoteOrcaCli(runtime, request, LEGACY_FALLBACK_OPTIONS)
|
||||
|
||||
expect(JSON.parse(first.stdout)).toMatchObject({
|
||||
result: {
|
||||
messages: [{ id: message.id }],
|
||||
formatted: expect.stringContaining('retained SSH mail')
|
||||
}
|
||||
})
|
||||
expect(first.postOutput).toEqual({
|
||||
kind: 'legacy_check_ack',
|
||||
terminal: WORKER_HANDLE,
|
||||
messageIds: [message.id]
|
||||
})
|
||||
expect(JSON.parse(replay.stdout)).toMatchObject({
|
||||
result: { messages: [{ id: message.id }] }
|
||||
})
|
||||
expect(db.getMessageById(message.id)?.read).toBe(0)
|
||||
|
||||
await acknowledgeRemoteOrcaCliPostOutput(runtime, {
|
||||
postOutput: first.postOutput!,
|
||||
env: WORKER_ENV,
|
||||
runtimeAuthority: RUNTIME_AUTHORITY
|
||||
})
|
||||
const afterAck = await runRemoteOrcaCli(runtime, request, LEGACY_FALLBACK_OPTIONS)
|
||||
|
||||
expect(JSON.parse(afterAck.stdout)).toMatchObject({ result: { messages: [], count: 0 } })
|
||||
expect(afterAck.postOutput).toBeUndefined()
|
||||
expect(db.getMessageById(message.id)?.read).toBe(1)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps peek formatted and non-consuming', async () => {
|
||||
const { db, runtime, run } = createLegacyRuntime()
|
||||
const message = db.insertMessage({
|
||||
runId: run.id,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: COORDINATOR_HANDLE,
|
||||
to: WORKER_HANDLE,
|
||||
subject: 'inspect retained SSH mail'
|
||||
})
|
||||
|
||||
try {
|
||||
const peek = await runRemoteOrcaCli(
|
||||
runtime,
|
||||
{
|
||||
argv: ['orchestration', 'check', '--peek', '--format', '--json'],
|
||||
cwd: '/home/alice/repo',
|
||||
env: WORKER_ENV,
|
||||
runtimeAuthority: RUNTIME_AUTHORITY
|
||||
},
|
||||
LEGACY_FALLBACK_OPTIONS
|
||||
)
|
||||
|
||||
const output = JSON.parse(peek.stdout) as {
|
||||
result: { formatted: string }
|
||||
}
|
||||
expect(output).toMatchObject({
|
||||
result: {
|
||||
messages: [{ id: message.id }],
|
||||
formatted: expect.stringContaining('inspect retained SSH mail'),
|
||||
legacyCompatibility: { readOnly: true }
|
||||
}
|
||||
})
|
||||
expect(output.result.formatted).toContain(`${message.id} [legacy, read-only]`)
|
||||
expect(output.result.formatted).toContain(
|
||||
'Inspection only: reply and acknowledgment are unavailable.'
|
||||
)
|
||||
expect(output.result.formatted).not.toContain('orchestration reply')
|
||||
expect(peek.postOutput).toBeUndefined()
|
||||
expect(db.getMessageById(message.id)?.read).toBe(0)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('reads and acknowledges current Run delivery', async () => {
|
||||
const { db, runtime, run } = createLegacyRuntime()
|
||||
const message = db.insertMessage({
|
||||
runId: run.id,
|
||||
from: WORKER_HANDLE,
|
||||
to: `run:${run.id}`,
|
||||
subject: 'current Run mail'
|
||||
})
|
||||
const baseRequest = {
|
||||
cwd: '/home/alice/repo',
|
||||
env: COORDINATOR_ENV,
|
||||
runtimeAuthority: RUNTIME_AUTHORITY
|
||||
}
|
||||
const spawn = vi.fn()
|
||||
const hostCliAvailable = {
|
||||
...LEGACY_FALLBACK_OPTIONS,
|
||||
entryExists: () => true,
|
||||
spawn: spawn as HostCliPassthroughOptions['spawn']
|
||||
}
|
||||
|
||||
try {
|
||||
const checked = await runRemoteOrcaCli(
|
||||
runtime,
|
||||
{
|
||||
...baseRequest,
|
||||
argv: ['orchestration', 'check', '--run', run.id]
|
||||
},
|
||||
hostCliAvailable
|
||||
)
|
||||
expect(checked.stdout).toContain('Delivery ')
|
||||
expect(checked.stdout).toContain(`${message.id} [status] from=${WORKER_HANDLE}`)
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
|
||||
const checkedJson = await runRemoteOrcaCli(
|
||||
runtime,
|
||||
{
|
||||
...baseRequest,
|
||||
argv: ['orchestration', 'check', '--run', run.id, '--json']
|
||||
},
|
||||
hostCliAvailable
|
||||
)
|
||||
const result = JSON.parse(checkedJson.stdout) as {
|
||||
result: { deliveryId: string; messages: { id: string }[] }
|
||||
}
|
||||
expect(result.result.messages).toEqual([expect.objectContaining({ id: message.id })])
|
||||
|
||||
const acknowledged = await runRemoteOrcaCli(
|
||||
runtime,
|
||||
{
|
||||
...baseRequest,
|
||||
argv: [
|
||||
'orchestration',
|
||||
'check',
|
||||
'--run',
|
||||
run.id,
|
||||
'--ack',
|
||||
result.result.deliveryId,
|
||||
'--json'
|
||||
]
|
||||
},
|
||||
hostCliAvailable
|
||||
)
|
||||
|
||||
expect(JSON.parse(acknowledged.stdout)).toMatchObject({
|
||||
result: { acknowledged: result.result.deliveryId }
|
||||
})
|
||||
expect(db.getMessageById(message.id)?.read).toBe(1)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('replays an SSH legacy ask by retry request without creating another question', async () => {
|
||||
const { db, runtime } = createLegacyRuntime()
|
||||
const argv = [
|
||||
'orchestration',
|
||||
'ask',
|
||||
'--to',
|
||||
COORDINATOR_HANDLE,
|
||||
'--question',
|
||||
'Continue?',
|
||||
'--timeout-ms',
|
||||
'1',
|
||||
'--retry-request',
|
||||
'ssh-question-1',
|
||||
'--json'
|
||||
]
|
||||
const request = {
|
||||
argv,
|
||||
cwd: '/home/alice/repo',
|
||||
env: WORKER_ENV,
|
||||
runtimeAuthority: RUNTIME_AUTHORITY
|
||||
}
|
||||
|
||||
try {
|
||||
const first = await runRemoteOrcaCli(runtime, request, LEGACY_FALLBACK_OPTIONS)
|
||||
const replay = await runRemoteOrcaCli(runtime, request, LEGACY_FALLBACK_OPTIONS)
|
||||
const sqlite = (db as unknown as { db: Database.Database }).db
|
||||
|
||||
const firstResult = JSON.parse(first.stdout) as { messageId: string; timedOut: boolean }
|
||||
const replayResult = JSON.parse(replay.stdout) as { messageId: string; timedOut: boolean }
|
||||
expect(first).toMatchObject({ exitCode: 1 })
|
||||
expect(firstResult).toMatchObject({ messageId: expect.any(String), timedOut: true })
|
||||
expect(replay).toMatchObject({ exitCode: 1 })
|
||||
expect(replayResult).toMatchObject({ messageId: firstResult.messageId, timedOut: true })
|
||||
expect(
|
||||
(
|
||||
sqlite.prepare('SELECT COUNT(*) AS count FROM question_threads').get() as {
|
||||
count: number
|
||||
}
|
||||
).count
|
||||
).toBe(1)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('resumes and acknowledges a question answer', async () => {
|
||||
const { db, runtime, run, dispatch } = createLegacyRuntime()
|
||||
const pending = db.createQuestion({
|
||||
runId: run.id,
|
||||
dispatchId: dispatch.id,
|
||||
askerHandle: WORKER_HANDLE,
|
||||
question: 'Proceed?'
|
||||
})
|
||||
const answer = db.answerQuestion({
|
||||
messageId: pending.question.message_id,
|
||||
runId: run.id,
|
||||
consumerGeneration: run.consumer_generation,
|
||||
body: 'yes'
|
||||
})
|
||||
const sqlite = (db as unknown as { db: Database.Database }).db
|
||||
sqlite
|
||||
.prepare(
|
||||
`UPDATE messages
|
||||
SET delivery_contract = 'legacy_direct', read = 0
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(answer.message.id)
|
||||
|
||||
try {
|
||||
const result = await runRemoteOrcaCli(
|
||||
runtime,
|
||||
{
|
||||
argv: ['orchestration', 'ask', '--resume', pending.question.message_id, '--json'],
|
||||
cwd: '/home/alice/repo',
|
||||
env: WORKER_ENV,
|
||||
runtimeAuthority: RUNTIME_AUTHORITY
|
||||
},
|
||||
LEGACY_FALLBACK_OPTIONS
|
||||
)
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
answer: 'yes',
|
||||
messageId: pending.question.message_id,
|
||||
timedOut: false
|
||||
})
|
||||
expect(result.postOutput).toEqual({
|
||||
kind: 'legacy_question_ack',
|
||||
terminal: WORKER_HANDLE,
|
||||
questionId: pending.question.message_id,
|
||||
answerMessageId: answer.message.id
|
||||
})
|
||||
expect(db.getMessageById(answer.message.id)?.read).toBe(0)
|
||||
|
||||
await acknowledgeRemoteOrcaCliPostOutput(runtime, {
|
||||
postOutput: result.postOutput!,
|
||||
env: WORKER_ENV,
|
||||
runtimeAuthority: RUNTIME_AUTHORITY
|
||||
})
|
||||
|
||||
expect(db.getMessageById(answer.message.id)?.read).toBe(1)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { readOrchestrationCompatibilityEvidence } from '../../shared/orchestration-compatibility-evidence'
|
||||
import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import type { RpcResponse } from '../runtime/rpc/core'
|
||||
import { RpcDispatcher } from '../runtime/rpc/dispatcher'
|
||||
import type {
|
||||
RemoteOrcaCliPostOutput,
|
||||
RemoteOrcaCliRequest
|
||||
} from './ssh-remote-cli-host-passthrough'
|
||||
import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-cli-argument-error'
|
||||
import { optionalRemoteCliString, resolveRemoteCliHandle } from './ssh-remote-cli-args'
|
||||
|
||||
export async function acknowledgeRemoteOrcaCliPostOutput(
|
||||
runtime: OrcaRuntimeService,
|
||||
args: {
|
||||
postOutput: RemoteOrcaCliPostOutput
|
||||
env: Record<string, string>
|
||||
runtimeAuthority?: RemoteOrcaCliRequest['runtimeAuthority']
|
||||
}
|
||||
): Promise<void> {
|
||||
const inheritedEvidence = readOrchestrationCompatibilityEvidence(args.env)
|
||||
const orchestrationCompatibilityEvidence = args.runtimeAuthority
|
||||
? { ...inheritedEvidence, host: args.runtimeAuthority }
|
||||
: inheritedEvidence
|
||||
const params =
|
||||
args.postOutput.kind === 'legacy_check_ack'
|
||||
? {
|
||||
terminal: args.postOutput.terminal,
|
||||
compatibilityAck: JSON.stringify({
|
||||
messageIds: args.postOutput.messageIds,
|
||||
types: args.postOutput.types
|
||||
})
|
||||
}
|
||||
: {
|
||||
terminal: args.postOutput.terminal,
|
||||
compatibilityQuestionAck: JSON.stringify({
|
||||
questionId: args.postOutput.questionId,
|
||||
answerMessageId: args.postOutput.answerMessageId
|
||||
})
|
||||
}
|
||||
const response = await new RpcDispatcher({ runtime }).dispatch({
|
||||
id: `remote-cli-post-output-${randomUUID()}`,
|
||||
authToken: 'remote-cli',
|
||||
method: 'orchestration.check',
|
||||
params,
|
||||
orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION,
|
||||
compatibilityInvocationId: randomUUID(),
|
||||
orchestrationCompatibilityEvidence
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRemoteOrcaCliPostOutput(value: unknown): RemoteOrcaCliPostOutput {
|
||||
if (!isRecord(value) || typeof value.kind !== 'string' || typeof value.terminal !== 'string') {
|
||||
throw invalidPostOutput()
|
||||
}
|
||||
if (
|
||||
value.kind === 'legacy_check_ack' &&
|
||||
Array.isArray(value.messageIds) &&
|
||||
value.messageIds.every((id) => typeof id === 'string') &&
|
||||
(value.types === undefined ||
|
||||
(Array.isArray(value.types) && value.types.every((type) => typeof type === 'string')))
|
||||
) {
|
||||
return {
|
||||
kind: value.kind,
|
||||
terminal: value.terminal,
|
||||
messageIds: value.messageIds,
|
||||
...(value.types === undefined ? {} : { types: value.types })
|
||||
}
|
||||
}
|
||||
if (
|
||||
value.kind === 'legacy_question_ack' &&
|
||||
typeof value.questionId === 'string' &&
|
||||
typeof value.answerMessageId === 'string'
|
||||
) {
|
||||
return {
|
||||
kind: value.kind,
|
||||
terminal: value.terminal,
|
||||
questionId: value.questionId,
|
||||
answerMessageId: value.answerMessageId
|
||||
}
|
||||
}
|
||||
throw invalidPostOutput()
|
||||
}
|
||||
|
||||
export function getRemoteCliPostOutput(
|
||||
parsed: ParsedRemoteCli,
|
||||
env: Record<string, string>,
|
||||
response: RpcResponse
|
||||
): RemoteOrcaCliPostOutput | undefined {
|
||||
if (!response.ok || !isRecord(response.result)) {
|
||||
return undefined
|
||||
}
|
||||
const compatibility = response.result.legacyCompatibility
|
||||
if (!isRecord(compatibility)) {
|
||||
return undefined
|
||||
}
|
||||
const command = parsed.commandPath.join(' ')
|
||||
if (
|
||||
command === 'orchestration check' &&
|
||||
Array.isArray(compatibility.ackMessageIds) &&
|
||||
compatibility.ackMessageIds.length > 0 &&
|
||||
compatibility.ackMessageIds.every((id) => typeof id === 'string')
|
||||
) {
|
||||
const types = optionalRemoteCliString(parsed.flags, 'types')
|
||||
?.split(',')
|
||||
.map((type) => type.trim())
|
||||
.filter(Boolean)
|
||||
return {
|
||||
kind: 'legacy_check_ack',
|
||||
terminal: resolveRemoteCliHandle(parsed.flags, env, 'terminal'),
|
||||
messageIds: compatibility.ackMessageIds,
|
||||
...(types ? { types } : {})
|
||||
}
|
||||
}
|
||||
const acknowledgement = compatibility.answerAcknowledgement
|
||||
if (
|
||||
command === 'orchestration ask' &&
|
||||
response.result.answer !== null &&
|
||||
isRecord(acknowledgement) &&
|
||||
typeof acknowledgement.questionId === 'string' &&
|
||||
typeof acknowledgement.answerMessageId === 'string'
|
||||
) {
|
||||
return {
|
||||
kind: 'legacy_question_ack',
|
||||
terminal: resolveRemoteCliHandle(parsed.flags, env, 'from'),
|
||||
questionId: acknowledgement.questionId,
|
||||
answerMessageId: acknowledgement.answerMessageId
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function invalidPostOutput(): RemoteCliArgumentError {
|
||||
return new RemoteCliArgumentError('invalid_argument', 'Invalid SSH CLI post-output action.')
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
@@ -73,6 +73,13 @@ describe('startup ordering', () => {
|
||||
expect(desktopStartup).not.toContain('await managedWslCliReconciliationReady')
|
||||
expect(barrier).toContain('managedWslCliStartupBarrierReady')
|
||||
expect(barrier).not.toContain('managedWslCliReconciliationReady')
|
||||
expect(barrier).toContain("ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup'")
|
||||
expect(barrier).toContain('recoverLegacyWorkerTerminalsForRendererStartup({')
|
||||
expect(barrier).toContain('localPtyProviderStartupReady,')
|
||||
expect(barrier).toContain('await runtime?.refreshRestoredOrchestrationAuthority()')
|
||||
expect(barrier).toContain(
|
||||
'return runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true })'
|
||||
)
|
||||
})
|
||||
|
||||
it('exposes managed WSL reconciliation status to headless serve clients and diagnostics', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user