mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/retire-generated-workspace-names
# Conflicts: # src/renderer/src/store/slices/worktrees.ts
This commit is contained in:
@@ -29,6 +29,9 @@ on:
|
||||
- 'electron.vite.config.ts'
|
||||
- 'config/build-plugins/**'
|
||||
- 'src/main/daemon/**'
|
||||
- 'src/main/providers/local-pty-provider.ts'
|
||||
- 'src/main/providers/windows-shell-args.ts'
|
||||
- 'src/main/providers/windows-shell-preflight-runtime.windows.test.ts'
|
||||
- 'native/computer-use-macos/**'
|
||||
- 'native/computer-use-linux/**'
|
||||
- 'native/computer-use-windows/**'
|
||||
@@ -106,6 +109,7 @@ jobs:
|
||||
src/main/computer/macos-native-provider-socket.test.ts
|
||||
src/main/computer/macos-computer-use-permissions.test.ts
|
||||
src/main/computer/macos-computer-use-permission-status.test.ts
|
||||
src/main/providers/windows-shell-preflight-runtime.windows.test.ts
|
||||
src/main/computer/desktop-script-provider-client.test.ts
|
||||
src/main/computer/desktop-script-provider-cache.test.ts
|
||||
src/main/computer/desktop-script-provider-actions.test.ts
|
||||
|
||||
@@ -50,6 +50,27 @@ jobs:
|
||||
- name: Check reliability gate manifest
|
||||
run: pnpm run check:reliability-gates
|
||||
|
||||
- name: Check VM runtime rollback compatibility
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
if git diff --quiet --merge-base "$BASE_SHA" "$HEAD_SHA" -- \
|
||||
src/shared/ephemeral-vm-runtime-store.ts \
|
||||
src/shared/ephemeral-vm-runtime-feature-store.ts \
|
||||
src/shared/ephemeral-vm-runtime-rollback-projection.ts \
|
||||
src/shared/ephemeral-vm-runtimes.ts \
|
||||
src/shared/ephemeral-vm-recipes.ts \
|
||||
src/shared/orca-yaml-hook-types.ts \
|
||||
src/main/ephemeral-vm-runtime-service.ts \
|
||||
src/main/ephemeral-vm-runtime-provisioning-persistence.ts \
|
||||
src/main/ephemeral-vm-failed-start-cleanup.ts; then
|
||||
echo "VM runtime persistence is unchanged."
|
||||
exit 0
|
||||
fi
|
||||
node config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs \
|
||||
config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts
|
||||
|
||||
- name: Enforce max-lines ratchet
|
||||
run: pnpm run check:max-lines-ratchet
|
||||
|
||||
@@ -479,6 +500,9 @@ jobs:
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")"
|
||||
TEST_FILES="$(printf '%s\n' "$CHANGED" | grep -E '^tests/e2e/.*\.spec\.ts$' || true)"
|
||||
if printf '%s\n' "$CHANGED" | grep -Eq '^src/(main/ephemeral-vm-(runtime-(service|provisioning-persistence)|failed-start-cleanup)|shared/(ephemeral-vm-runtime-(store|feature-store|rollback-projection|runtimes)|ephemeral-vm-recipes|orca-yaml-hook-types))\.ts$'; then
|
||||
TEST_FILES="$(printf '%s\n%s\n' "$TEST_FILES" 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' | sort -u)"
|
||||
fi
|
||||
TEST_FILES_JSON="$(printf '%s\n' "$TEST_FILES" | jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')"
|
||||
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
|
||||
if [ "$TEST_FILES_JSON" != '[]' ]; then
|
||||
|
||||
@@ -178,7 +178,6 @@ inline src/relay/pty-handler.test.ts
|
||||
inline src/relay/pty-handler.ts
|
||||
inline src/relay/relay.ts
|
||||
inline src/relay/workspace-space-scan.ts
|
||||
inline src/renderer/src/App.tsx
|
||||
inline src/renderer/src/components/GitHubItemDialog.tsx
|
||||
inline src/renderer/src/components/GitLabItemDialog.tsx
|
||||
inline src/renderer/src/components/JiraIssueWorkspace.tsx
|
||||
@@ -306,7 +305,6 @@ inline src/renderer/src/store/slices/ui.test.ts
|
||||
inline src/renderer/src/store/slices/ui.ts
|
||||
inline src/renderer/src/store/slices/workspace-cleanup.ts
|
||||
inline src/renderer/src/store/slices/worktrees.test.ts
|
||||
inline src/renderer/src/store/slices/worktrees.ts
|
||||
inline src/renderer/src/web/web-preload-api.test.ts
|
||||
inline src/renderer/src/web/web-preload-api.ts
|
||||
inline src/renderer/src/web/web-runtime-client.ts
|
||||
|
||||
+472
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-08-13",
|
||||
"updatedAt": "2026-08-14",
|
||||
"policy": {
|
||||
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
|
||||
"blockingPromotion": {
|
||||
@@ -10,6 +10,223 @@
|
||||
}
|
||||
},
|
||||
"gates": [
|
||||
{
|
||||
"id": "ephemeral-vm-runtime.rollback-readable-sidecar",
|
||||
"title": "VM lifecycle stays rollback-readable and cleanup-retryable",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "ephemeral-vm-runtime-store",
|
||||
"layer": "desktop-main-persistence",
|
||||
"surfaces": [
|
||||
"VM runtime sidecar",
|
||||
"recipe lifecycle",
|
||||
"cleanup retry",
|
||||
"provisioned-root direct SSH"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["orca-server", "direct-ssh"],
|
||||
"coveredPlatforms": ["macos", "linux", "windows"],
|
||||
"coveredProviders": ["orca-server", "direct-ssh"],
|
||||
"coverageNotes": "A pinned exact-source harness runs the pre-#14352 reader and lifecycle service against baseline, affected-main, candidate, and fix-reverted stores. Platform-neutral tests cover store compatibility, user-cancellable cleanup, branch/ref adoption, and resume ownership integrity. The direct-SSH provisioned-root Electron journey remains the live transport and destroy proof; this desktop sidecar is not mobile-facing and changes no RPC or remote wire payload.",
|
||||
"motivatingLinks": [
|
||||
"https://linear.app/stably/issue/STA-4274",
|
||||
"https://github.com/stablyai/orca/pull/14352",
|
||||
"https://github.com/stablyai/orca/issues/13044"
|
||||
],
|
||||
"invariant": "A sidecar written by the new build cannot make the previous production build lose otherwise compatible VM runtimes. Downgrade lifecycle writes remain authoritative after re-upgrade, provisioned-root metadata remains recoverable, and corrupt or future feature metadata is preserved without poisoning the rollback-readable v1 store. Destroy attempts have no automatic wall-clock cutoff; a user stop terminates the process tree, retains cleanup ownership, and permits a fresh retry. Provider failure retains the SSH/project context needed for safe cleanup. Unrecoverable recipe context releases only the hidden SSH registration while preserving lifecycle/provider metadata. Provisioned-root create receives the source ref's pinned commit, adoption verifies the requested branch/ref, and resume preserves the connection mode plus stable SSH ownership identity while allowing endpoint rotation.",
|
||||
"oracle": "Write one ordinary schema-v1 runtime and one provisioned-root runtime. Require the exact pre-#14352 reader to return both, run the old destroy lifecycle against the projected provisioned record, then require the candidate to retain the cleaned state while restoring schema-v2 and checkout-mode metadata. The same old reader must reject affected-main and fix-reverted bytes. Malformed, oversized, unknown-record, and future-version feature sidecars must leave rollback-compatible records accessible and must not be overwritten during compatible lifecycle mutations. Require destroy to have no implicit deadline, stop a hung destroy through the public desktop action, require cleanup_failed plus a fresh successful retry, prove provider or project-host rollback failure retains every ownership record required to retry, and prove missing recipe context cannot leave an unremovable hidden SSH registration. Supply the source ref's captured commit to create, reject a provisioned checkout on the wrong branch/ref, and reject a resume that changes connection mode or SSH ownership.",
|
||||
"commands": [
|
||||
"node config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/ephemeral-vm-runtime-store.test.ts src/shared/ephemeral-vm-runtime-store-rollback.test.ts src/shared/ephemeral-vm-recipe-process.test.ts src/main/ephemeral-vm-runtime-service.test.ts src/main/ephemeral-vm-resume-integrity.test.ts src/main/ephemeral-vm-recipe-runner.test.ts src/main/ephemeral-vm-runtime-ssh-cleanup.test.ts src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts src/main/ipc/ephemeral-vm-provision-cancel.test.ts src/main/ipc/ephemeral-vm-provisioned-root-ref.test.ts src/main/ipc/ephemeral-vm.test.ts src/main/provisioned-root-ssh-adoption.test.ts src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts src/renderer/src/lib/provisioned-root-create-options.test.ts src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts",
|
||||
"ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ephemeral-vm-provisioned-root.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"SKIP_BUILD=1 ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ephemeral-vm-provisioned-root.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"pnpm exec playwright test tests/e2e/ephemeral-vm-cleanup-retry.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts",
|
||||
"config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs",
|
||||
"src/shared/ephemeral-vm-runtime-store-rollback.test.ts",
|
||||
"src/shared/ephemeral-vm-runtime-store.test.ts",
|
||||
"src/shared/ephemeral-vm-recipe-process.test.ts",
|
||||
"src/main/ephemeral-vm-runtime-service.test.ts",
|
||||
"src/main/ephemeral-vm-recipe-runner.test.ts",
|
||||
"src/main/ephemeral-vm-runtime-ssh-cleanup.test.ts",
|
||||
"src/main/ephemeral-vm-resume-integrity.test.ts",
|
||||
"src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts",
|
||||
"src/main/ipc/ephemeral-vm-provision-cancel.test.ts",
|
||||
"src/main/ipc/ephemeral-vm-provisioned-root-ref.test.ts",
|
||||
"src/main/ipc/ephemeral-vm.test.ts",
|
||||
"src/main/provisioned-root-ssh-adoption.test.ts",
|
||||
"src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx",
|
||||
"src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts",
|
||||
"src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts",
|
||||
"src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts",
|
||||
"src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts",
|
||||
"src/renderer/src/lib/provisioned-root-create-options.test.ts",
|
||||
"src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts",
|
||||
"tests/e2e/ephemeral-vm-cleanup-retry.spec.ts",
|
||||
"tests/e2e/ephemeral-vm-provisioned-root.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts",
|
||||
"assertions": [
|
||||
"the exact rollback reader accepts both projected records",
|
||||
"the rollback lifecycle service destroys the provisioned resource using schema-v1 provider data",
|
||||
"re-upgrade restores provisioned-root metadata without reverting lifecycle state"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/shared/ephemeral-vm-runtime-store-rollback.test.ts",
|
||||
"assertions": [
|
||||
"ordinary runtime bytes remain unchanged and create no feature sidecar",
|
||||
"malformed, future, oversized, and unknown feature data is preserved without poisoning v1 records",
|
||||
"current-main poisoned bytes migrate to a rollback-readable projection",
|
||||
"unchanged compatibility metadata is not rewritten during lifecycle updates"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ephemeral-vm-runtime-service.test.ts",
|
||||
"assertions": [
|
||||
"an unreadable companion rejects checkout-mode recipes before their create command runs",
|
||||
"post-create persistence failure destroys the provider resource when possible",
|
||||
"failed destroy persists a durable rollback-readable cleanup recovery record"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/shared/ephemeral-vm-recipe-process.test.ts",
|
||||
"assertions": [
|
||||
"recipe commands have no implicit wall-clock deadline",
|
||||
"a stopped process that ignores graceful termination is force-killed and settled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx",
|
||||
"assertions": [
|
||||
"a running cleanup exposes a persistent Stop cleanup action and becomes retryable after stopping"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts",
|
||||
"assertions": [
|
||||
"unrecoverable recipe context releases the hidden SSH registration but preserves failed lifecycle state"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts",
|
||||
"assertions": [
|
||||
"provider failure retains runtime-owned SSH targets and confirmed success releases them",
|
||||
"unknown runtime inventory retains an explicitly requested target"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts",
|
||||
"assertions": ["failed or unconfirmed project-host rollback prevents provider destroy"]
|
||||
},
|
||||
{
|
||||
"file": "src/main/provisioned-root-ssh-adoption.test.ts",
|
||||
"assertions": [
|
||||
"adoption rejects a checkout on an unrequested branch or ref",
|
||||
"tag and commit refs remain valid when the requested local branch is attached"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/ephemeral-vm-provisioned-root-ref.test.ts",
|
||||
"assertions": [
|
||||
"selected and default source ref commits are captured before provider create",
|
||||
"the create recipe receives both the symbolic ref and its pinned commit",
|
||||
"multi-remote bases pair the selected remote URL with a fetchable branch ref",
|
||||
"an unresolved source ref rejects before provider create"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/ephemeral-vm-provision-cancel.test.ts",
|
||||
"assertions": ["cancellation during source-ref resolution aborts before provider create"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts",
|
||||
"assertions": [
|
||||
"the captured source commit reaches the main-owned provisioned-root adoption request"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ephemeral-vm-resume-integrity.test.ts",
|
||||
"assertions": [
|
||||
"resume allows host and port rotation",
|
||||
"resume rejects connection-mode and stable SSH ownership drift"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/ephemeral-vm-cleanup-retry.spec.ts",
|
||||
"assertions": [
|
||||
"the rendered runtime row stops a live cleanup process without an automatic deadline and a retry succeeds"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/ephemeral-vm-provisioned-root.spec.ts",
|
||||
"assertions": [
|
||||
"default-base create adopts the recipe checkout at the captured source commit",
|
||||
"workspace removal invokes recipe destroy and removes the provider container"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-14",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "node config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 11,
|
||||
"summary": "The pinned baseline and affected-main oracles passed, candidate downgrade ran the exact old destroy service, candidate re-upgrade retained lifecycle state, and both affected-main and fix-reverted bytes failed the old reader as expected."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-14",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/ephemeral-vm-runtime-store.test.ts src/shared/ephemeral-vm-runtime-store-rollback.test.ts src/shared/ephemeral-vm-recipe-process.test.ts src/main/ephemeral-vm-runtime-service.test.ts src/main/ephemeral-vm-resume-integrity.test.ts src/main/ephemeral-vm-recipe-runner.test.ts src/main/ephemeral-vm-runtime-ssh-cleanup.test.ts src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts src/main/ipc/ephemeral-vm-provision-cancel.test.ts src/main/ipc/ephemeral-vm-provisioned-root-ref.test.ts src/main/ipc/ephemeral-vm.test.ts src/main/provisioned-root-ssh-adoption.test.ts src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts src/renderer/src/lib/provisioned-root-create-options.test.ts src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 3,
|
||||
"summary": "One hundred twenty-two focused store, recipe-process, lifecycle, cleanup-handler, rendered stop-action, retry-context, source-ref, adoption-integrity, resume-integrity, and provisioning-recovery tests passed."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-14",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ephemeral-vm-provisioned-root.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 22,
|
||||
"summary": "The final-tree full-build run completed the Docker Linux SSH journey: default-base create at the captured source commit, provisioned-root adoption, terminal, workspace removal, destroy, and provider-container cleanup."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 180,
|
||||
"scope": "pinned cross-version source oracle, focused lifecycle contracts, and one Docker SSH Electron journey"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "The deterministic contract runs are stable. Two local Electron attempts reached successful adoption and terminal use, then hit the existing final context-menu detach; the immediate build-reuse rerun completed removal and destroy. CI soak history has not started."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The same pinned oracle passes the pre-#14352 ordinary store, fails the affected-main mixed store with file-is-invalid, passes the candidate mixed store plus old-service destroy and re-upgrade, and fails again when the candidate compatibility files are reverted to affected main."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Ordinary writes remain byte-identical and create no feature sidecar. Reads add one bounded feature-file existence check; sorted feature comparisons avoid unchanged rewrites, and durable companion writes occur only when compatibility metadata changes. Cleanup adds one constant-size in-flight controller per active destroy and no automatic deadline; the visible settings section polls once per second only while cleanup is active. Provisioning adds one source-host Git ref-resolution command only when a ref was requested and runs it before provider create; adoption adds no remote subprocess beyond its existing checkout probes, and resume adds only in-memory comparisons."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Keep the pinned baseline/latest/candidate/revert oracle green in CI.",
|
||||
"Keep the Docker provisioned-root create/adopt/terminal/remove/destroy journey green.",
|
||||
"Keep user-stopped destroy, fresh retry, and retained-context failure tests green.",
|
||||
"Collect soak history without unexplained lifecycle or sidecar flakes."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No packaged-build downgrade installer journey; the exact released source reader and service are executed in-process.",
|
||||
"Live Docker SSH evidence runs locally on macOS and in the changed-file PR E2E lane on Ubuntu; Windows desktop behavior is covered by the platform-neutral filesystem codec and package job."
|
||||
],
|
||||
"demotionRule": "Demote if a new write fails the pinned rollback reader, a downgrade lifecycle mutation is lost after re-upgrade, malformed/future metadata overwrites recoverable bytes, ordinary v1 bytes change, user stop cannot settle a destroy process tree, cleanup loses retry ownership, an unrecoverable context leaves a permanent hidden SSH registration, or the focused gate flakes without an identified harness defect."
|
||||
},
|
||||
{
|
||||
"id": "terminal-session.shell-ready-exec-prompt-fallback",
|
||||
"title": "Startup exec falls back to the identified shell's line editor",
|
||||
@@ -883,7 +1100,7 @@
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-13",
|
||||
"date": "2026-08-14",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/client-context.test.ts mobile/src/transport/host-open-recovery.test.tsx mobile/src/transport/host-open-retry-scheduler.test.ts mobile/src/transport/rpc-session-liveness-watchdog.test.ts mobile/src/transport/rpc-session-liveness-integration.test.ts mobile/src/transport/mobile-relay-rpc-session.test.ts mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts mobile/src/transport/mobile-endpoint-nudge-router.test.ts mobile/src/transport/mobile-relay-e2ee-link.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts",
|
||||
@@ -3278,7 +3495,7 @@
|
||||
"platform": "linux",
|
||||
"command": "pnpm exec playwright test tests/e2e/daemon-generation-legacy-close-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 5.1,
|
||||
"durationSeconds": 6.6,
|
||||
"summary": "Current-head capability-gated oracle passed in E2E run https://github.com/stablyai/orca/actions/runs/30250731941/job/89928386794: capable v25/v26 roots and descendants survived with snapshot republish and post-close I/O, byte-identical legacy closes retained current-main shutdown behavior, and the unrelated fifth-worktree control survived."
|
||||
},
|
||||
{
|
||||
@@ -8645,10 +8862,14 @@
|
||||
"providers": ["local", "daemon", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local"],
|
||||
"coverageNotes": "Local macOS evidence covers the runtime PTY write contract, a live dev-runtime CLI repro, and a Haiku composer trace. The Claude render gate stays on the PTY-owning runtime, so paired and SSH callers use the same provider write/output path without a wire change. SSH, daemon, remote-runtime, Linux, and Windows live journeys remain gaps.",
|
||||
"motivatingLinks": ["https://github.com/stablyai/orca/issues/7226"],
|
||||
"invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and submit exactly once only after the agent can accept Enter. Claude must emit a post-paste composer marker and then settle, or reach the bounded fallback first.",
|
||||
"oracle": "Runtime tests assert the exact PTY write sequence, failure cleanup, and a Claude-shaped multi-frame render where the legacy 500 ms delay and first show-cursor marker are both premature. The candidate waits for post-marker output quiescence before one submit, resets that window on later frames, and still submits once at the hard deadline if output never settles. Orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness covers long Codex-like framing.",
|
||||
"coverageNotes": "Local macOS evidence covers the runtime PTY write contract, live Codex orchestration, a dev-runtime CLI repro, and Claude and Codex composer traces. The render gate stays on the PTY-owning runtime, so paired and SSH callers use the same provider write/output path without a wire change. SSH, daemon, remote-runtime, Linux, and Windows live journeys remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/7226",
|
||||
"https://github.com/stablyai/orca/issues/13821",
|
||||
"https://github.com/stablyai/orca/issues/14347"
|
||||
],
|
||||
"invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and submit exactly once only after the agent can accept Enter. Claude and Codex must emit a post-paste composer marker and then settle, or reach the bounded fallback first; a late marker receives a fresh settlement window.",
|
||||
"oracle": "Runtime tests assert the exact PTY write sequence, failure cleanup, and Claude- and Codex-shaped multi-frame renders where the legacy platform delay and first show-cursor marker are both premature. The candidate waits for post-marker output quiescence before one submit, resets that window on later frames, gives a late marker a fresh bounded window, and still submits once at the hard deadline if output never settles. Orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness covers long Codex-like framing.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"node tests/tools/repro-orchestration-long-prompt.mjs --cli out/bin/orca-dev --mode codex-like --size-kb 32 --timeout-ms 20000"
|
||||
@@ -8674,7 +8895,8 @@
|
||||
"file": "src/main/runtime/orca-runtime.test.ts",
|
||||
"assertions": [
|
||||
"runtime writes bracketed paste before a delayed submit",
|
||||
"Claude ignores unrelated and split pre-marker output, then waits for post-marker render quiescence before one submit",
|
||||
"Claude and Codex ignore unrelated and split pre-marker output, then wait for post-marker render quiescence before one submit",
|
||||
"a late Codex marker receives a fresh settlement window before one submit",
|
||||
"a silent Claude composer reaches one bounded fallback submit",
|
||||
"a marked Claude composer with continuous render output reaches one bounded fallback submit",
|
||||
"large prompt frames are chunked and reconstructed before submit",
|
||||
@@ -8706,6 +8928,15 @@
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-14",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 11.32,
|
||||
"summary": "4 files and 1,303 tests passed with one skipped. Claude and Codex both wait for post-marker quiescence, and a Codex marker arriving at 7.9 seconds receives a fresh window through its final slow frame. Exact-build live Codex workers accepted injected prompts without manual Enter, replied, called worker_done, and settled successfully in the rendered Electron UI."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-13",
|
||||
"runner": "local",
|
||||
@@ -8753,11 +8984,11 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The original Claude-shaped oracle fails on unmodified main and passes with the render gate. The hardened multi-frame oracle then fails on the first-marker candidate, which submits at 751 ms during an intermediate frame, and passes when the gate requires post-marker quiescence. The earlier live harness also reproduced the unsafe raw multiline contract before its framing fix. Intentional-break evidence remains local rather than a separate CI job."
|
||||
"evidence": "The original Claude-shaped oracle fails on unmodified main and passes with the render gate. The hardened multi-frame oracle then fails on the first-marker candidate, which submits at 751 ms during an intermediate frame, and passes when the gate requires post-marker quiescence. Codex used the legacy platform delay before this candidate; the late-marker oracle proves the original deadline would pre-empt slow-host settlement without re-arming. The earlier live harness also reproduced the unsafe raw multiline contract before its framing fix. Intentional-break evidence remains local rather than a separate CI job."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Agent prompt dispatch remains O(prompt bytes) with existing 16KB chunking. Claude adds one PTY-local output listener until 1.5s of post-marker output quiescence or an 8s fallback; each later frame resets one timer. Other agents retain the platform delay. No polling, provider listing, subprocess churn, hidden-pane wakeups, renderer work, or wire traffic is added."
|
||||
"evidence": "Agent prompt dispatch remains O(prompt bytes) with existing 16KB chunking. Claude and Codex add one PTY-local output listener until 1.5s of post-marker output quiescence or an 8s fallback; a late first marker restarts the bounded window and each later frame resets one quiet timer. Other agents retain the platform delay. No polling, provider listing, subprocess churn, hidden-pane wakeups, renderer work, or wire traffic is added."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Run the live harness in soak with a self-starting dev runtime or provider-contract fixture.",
|
||||
@@ -8771,6 +9002,237 @@
|
||||
],
|
||||
"demotionRule": "Demote or quarantine if the live harness flakes without a product bug or harness bug filed to the terminal-input owner."
|
||||
},
|
||||
{
|
||||
"id": "orchestration.notification-mailbox-consistency",
|
||||
"title": "Orchestration pointers expose the mailbox their terminal check consumes",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "orchestration",
|
||||
"layer": "runtime-db-rpc-contract",
|
||||
"surfaces": [
|
||||
"live-idle terminal notification",
|
||||
"Run binding and rebinding",
|
||||
"active Dispatch ownership",
|
||||
"orchestration check and acknowledgment",
|
||||
"outstanding Run Deliveries",
|
||||
"runtime restart and durable pointer cursors",
|
||||
"pointer transport settlement"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "daemon", "ssh"],
|
||||
"coverageNotes": "A deterministic isolated runtime/SQLite/RPC harness covers Run and Dispatch ownership, exact handle and remint-stable pane recipients, fixed Delivery replay and acknowledgment, restart, concurrent pollers, filtered waiters, and built-CLI parity with direct DB state. Schema v28 durably records every coordinator handle through SQLite triggers, backfills current handles on open, recreates routing triggers after old table rebuilds, and atomically canonicalizes coordinator-addressed inserts to run:<id>. A 125-row oracle and 151-row reconciliation suite cover paged old-handle backlogs, commit-without-notify recovery, duplicate Dispatch owners, cancellation, and lifecycle fencing. Federation tests use real databases, dropped acknowledgments, restart/reconnect, and protocol v1/v2 peers; duplicate read rows do not wake again. Daemon and SSH pointer settlement is deterministic, with SSH bounded at 60 seconds beyond the 20-second mux health window. No RPC parameter, stream opcode, or published payload changed. Live headed/headless paired-runtime, physical SSH, WSL, Linux, and Windows acceptance remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://linear.app/stably/issue/STA-4325",
|
||||
"https://github.com/stablyai/orca/issues/14522",
|
||||
"internal incident evidence: improve-vps-setup, 2026-08-10"
|
||||
],
|
||||
"invariant": "Each message has one stable row ID and authoritative recipient; coordinator-addressed current-delivery inserts are atomically owned by run:<id>. Pointer staging may set delivered_at but never consumes mail. Each Run consumer generation has at most one outstanding Delivery with a fixed ID and fixed message IDs; ordinary checks replay it until an explicit matching acknowledgment marks exactly those rows read. Rebinding fences the old generation, notification types/counts correspond to unread rows retrievable under the same authority, and federation replay imports each stable message identity once without re-waking an already-read duplicate.",
|
||||
"oracle": "Seed status, dispatch, and worker_done rows across direct-handle and canonical Run recipients in an isolated DB. Compare pointer count, RPC and built-CLI check output, direct SQLite rows, unread/peek/all/type filters, concurrent pollers, fixed Delivery IDs, explicit acknowledgment, restart, filtered check --wait, and coordinator remint. Route a 125-row old-handle backlog, inject a commit without notification, and require startup repair. Exercise duplicate Run/Dispatch owners, stale panes, 50-row pages, cancellation, lifecycle fencing, and absent PTYs. Drop a federation ACK, reconnect/restart v1/v2 peers, and require stable import plus no duplicate read-row wake. Hold a healthy SSH write past five seconds but below the 60-second settlement deadline, then separately exceed the bound and require retryable undelivered state.",
|
||||
"commands": [
|
||||
"pnpm run build:cli && pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-message-delivery-identity.test.ts --reporter=dot --testTimeout=5000",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/main/runtime/rpc/methods/orchestration.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/federation-sync.test.ts src/main/runtime/rpc/methods/orchestration-federation.test.ts src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts --reporter=dot"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/runtime/orchestration-message-delivery-identity.test.ts",
|
||||
"src/main/runtime/orchestration-mailbox-notification-consistency.test.ts",
|
||||
"src/main/runtime/orchestration-mailbox-detached-routing.test.ts",
|
||||
"src/main/runtime/orchestration-mailbox-routing-races.test.ts",
|
||||
"src/main/runtime/orchestration-mailbox-transport-settlement.test.ts",
|
||||
"src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts",
|
||||
"src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts",
|
||||
"src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts",
|
||||
"src/main/runtime/orchestration/formatter.test.ts",
|
||||
"src/main/providers/ssh-pty-provider.test.ts",
|
||||
"src/main/providers/ssh-pty-write.test.ts",
|
||||
"src/main/daemon/client.test.ts",
|
||||
"src/main/daemon/daemon-pty-router.test.ts",
|
||||
"src/main/daemon/degraded-daemon-pty-provider.test.ts",
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/main/runtime/terminal-send-stale-leaf-liveness.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration.test.ts",
|
||||
"src/main/runtime/orchestration/federation-sync.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-federation.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/runtime/orchestration-message-delivery-identity.test.ts",
|
||||
"assertions": [
|
||||
"status, dispatch, and worker_done rows keep stable message IDs across handle-to-Run routing",
|
||||
"notification count equals the concurrently replayed fixed Delivery batch",
|
||||
"unread, peek, all, and type filters preserve explicit acknowledgment semantics",
|
||||
"restart replays one outstanding Delivery ID without inserting another Delivery row",
|
||||
"a filtered check --wait wakes once without a duplicate terminal pointer",
|
||||
"a 125-row old-handle backlog is atomically rebound before coordinator identity changes",
|
||||
"committed coordinator mail is canonicalized without relying on notification delivery",
|
||||
"the built CLI returns the same fixed Delivery and count as isolated SQLite"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orchestration-mailbox-notification-consistency.test.ts",
|
||||
"assertions": [
|
||||
"Run A direct mail cannot point after the pane binds Run B",
|
||||
"a fresh runtime cannot replay the unactionable pointer",
|
||||
"a fresh runtime reconciles a database that already contains the production mismatch",
|
||||
"large persisted mismatches route in 50-row pages with coalesced per-mailbox wakes",
|
||||
"Run pointers pin the existing mixed-version-compatible --run identity",
|
||||
"direct and Dispatch mail remain durable without unpinned synthetic terminal turns",
|
||||
"a delayed pointer submit cannot press Enter after the agent becomes working",
|
||||
"an explicit check releases its staged pointer reservation without redrive",
|
||||
"accepted pointer text is durably deduplicated before delayed Enter and provider Enter refusal",
|
||||
"a known PTY exit releases staged rows for the replacement process",
|
||||
"a staged pointer follows its Run to a new live pane without cross-PTY duplication",
|
||||
"equivalent panes serialize one mailbox while newer mail arrives during Enter delay",
|
||||
"same-Run direct mail is delivered and acknowledged through the bound Run",
|
||||
"a submitted actionable pointer is durably deduplicated across restart without marking mail read",
|
||||
"newer Run mail is not pointed behind an outstanding fixed Delivery",
|
||||
"same-owner filtered checks drain later 50-row pages before waiting"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orchestration-mailbox-detached-routing.test.ts",
|
||||
"assertions": [
|
||||
"active worker direct mail is exposed by its Dispatch check after handle remint",
|
||||
"late direct mail is normalized when its displaced Run rebinds",
|
||||
"bound and filtered waiters wake only for residual mailbox types",
|
||||
"stale-leaf arrivals retain Run or Dispatch pane identity",
|
||||
"unowned detached-recipient mail remains directly addressable",
|
||||
"duplicate Dispatch ownership is deduplicated before the 51-row branch cap"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts",
|
||||
"assertions": [
|
||||
"v27 databases backfill current coordinator handles during v28 migration",
|
||||
"v28 triggers retain A-to-B-to-C coordinator history across old-runtime writes",
|
||||
"message-table rebuild migration recreates atomic coordinator-mail canonicalization"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orchestration/federation-sync.test.ts",
|
||||
"assertions": [
|
||||
"an acknowledged duplicate replay does not wake a waiter",
|
||||
"overlapping syncs preserve one durable forward-only checkpoint"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/methods/orchestration-federation.test.ts",
|
||||
"assertions": [
|
||||
"a lost relay acknowledgment retries without duplicating the home message",
|
||||
"a reordered relay gap converges without loss or duplication"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts",
|
||||
"assertions": [
|
||||
"protocol v1 and v2 completion acknowledgments replay after Run-home restart",
|
||||
"terminal settlement remains replayable until the worker durably acknowledges it"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orchestration-mailbox-transport-settlement.test.ts",
|
||||
"assertions": [
|
||||
"a rejected pointer transport stays undelivered and becomes restart-retryable"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/daemon/client.test.ts",
|
||||
"assertions": [
|
||||
"an asynchronous daemon socket write failure settles as rejected",
|
||||
"a wedged daemon socket write disconnects at its bounded settlement deadline"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts",
|
||||
"assertions": [
|
||||
"remote Dispatch attachment lookup remains bounded across pane remints and malformed suffix collisions"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orchestration-mailbox-routing-races.test.ts",
|
||||
"assertions": [
|
||||
"Run and Dispatch consumers are fenced after ownership changes during a routing-page yield",
|
||||
"current and stored reminted handles both drain in one explicit check",
|
||||
"explicit paging terminates at its indexed start-of-check sequence snapshot and stops on cancellation",
|
||||
"a fenced inactive Dispatch transfers unread rows to its Run in 50-row pages and coalesces arrival types for supported consumption and acknowledgment",
|
||||
"a cancelled inactive-Dispatch migration wakes partial progress and finishes only its captured snapshot in the background",
|
||||
"filtered Dispatch wait cancellation reconciles unrelated unread types before fencing",
|
||||
"unfiltered and filtered parked reservations union in either arrival order",
|
||||
"reminted Dispatch pane lookup uses a bounded partial expression index while exact legacy keys remain supported"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-13",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 8.22,
|
||||
"summary": "245 tests passed across mailbox identity, durable coordinator-handle migration, insertion-time canonicalization, duplicate-free 51-row ownership branch caps, unrestricted reservation merging, direct and Dispatch pointer suppression, persisted reconciliation, 50-row paging and filtered waits, cross-PTY serialization, lifecycle fencing, bounded daemon and SSH transport settlement, outstanding Deliveries, reminted Dispatch ownership, acknowledgment, cancellation, and bounded pane lookup."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-14",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/federation-sync.test.ts src/main/runtime/rpc/methods/orchestration-federation.test.ts src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 8.99,
|
||||
"summary": "52 tests passed with real OrchestrationDb rows, a deliberately dropped federation acknowledgment, reconnect/restart, forward-only checkpoints, duplicate read-row wake suppression, and protocol v1/v2 lifecycle settlement replay. The broader final federation/cross-version set passed 77/77."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-14",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm run build:cli && pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-message-delivery-identity.test.ts --reporter=dot --testTimeout=5000",
|
||||
"result": "passed",
|
||||
"durationSeconds": 8.42,
|
||||
"summary": "The byte-identical five-case STA-4325 oracle (SHA-256 76a2d672d27e04e5b1db2f52df3b303468bdf006be7cfd003bf443618a856c35) failed 5/5 on reported v1.4.180, latest main, and production-reverted candidate, then passed 5/5 on the final candidate. It aligns pointer counts with one fixed Delivery across concurrent pollers and filters, replays that Delivery through restart, routes a 125-row old-handle backlog before rebind, repairs committed mail without relying on a notification callback, and matches built-CLI output to direct isolated SQLite state."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-14",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/main/runtime/rpc/methods/orchestration.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 14.15,
|
||||
"summary": "1,293 tests passed and 1 was skipped across Run-bound pointer delivery, PTY retirement and respawn, stale-leaf liveness, direct-mail routing, filtered waiter ownership, canonical stored-recipient notification, and orchestration RPC behavior."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 20,
|
||||
"scope": "deterministic runtime, SQLite, and RPC contract tests"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "Repeated deterministic local macOS runs passed during four review rounds; controlled fake timers and lifecycle events replace arbitrary sleeps and wall-clock delivery assertions."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The byte-identical five-test oracle (SHA-256 76a2d672d27e04e5b1db2f52df3b303468bdf006be7cfd003bf443618a856c35) failed 5/5 on reported v1.4.180 commit 0b62333cf179, failed 5/5 on latest origin/main 9bb8836bb6, passed 5/5 on the final candidate, and failed 5/5 with candidate production changes reverted to 9bb8836bb6. Baselines announced two messages while check exposed one, returned no direct-only row after restart, left all 125 old-handle rows direct, failed commit-without-notify canonicalization, and made built CLI report one of two rows. The candidate aligned every count, stable message ID, fixed Delivery, acknowledgment, restart, backlog, and CLI/SQLite assertion. Live topology coverage remains a separate promotion gap."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Coordinator inserts use one SQLite trigger in the insertion transaction; current handles are backfilled and historical A-to-B-to-C bindings are trigger-recorded. Bulk rebind is one indexed O(backlog) UPDATE. Detached routing is an ownership-driven UNION whose coordinator, exact-Dispatch, and pane-Dispatch branches each cap at 51 duplicate-free rows before the final merge; full drains measured 40/77/187/1,040 ms for 5k/10k/20k/100k rows versus the prior 96/429/1,908 ms at 5k/10k/20k. Each transaction routes at most 50 rows and yields between pages. Typed partial indexes keep filtered Run peek at 0.08-0.13 ms over 100k rows. Trigger overhead measured about 0.6-1.0 microseconds per message. SSH pointer writes use one cleared, unrefed 60-second timer per pending write, beyond the 20-second mux health window. No subprocess, provider fanout, polling, persistent listener, renderer work, or wire payload was added."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Add isolated live restart and SSH or paired-runtime evidence.",
|
||||
"Accumulate stable soak runs before considering blocking promotion."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No headed Electron restart journey was required to prove the runtime/PTY/check boundary.",
|
||||
"No daemon, paired remote-runtime, live SSH, WSL, Linux, or Windows journey has been collected.",
|
||||
"Daemon and SSH PTY writes are transport-settled but remain owner-unacknowledged; capability-negotiated owner acknowledgments are separate protocol work.",
|
||||
"Direct and Dispatch mail stay durable and explicitly checkable but do not synthesize agent turns until a backward-compatible pinned action or passive UI exists.",
|
||||
"Passive renderer-native unread UI and opt-in coordinator wake policy remain separate product work."
|
||||
],
|
||||
"demotionRule": "Demote or quarantine if the deterministic oracle flakes without a product or harness bug owned by orchestration."
|
||||
},
|
||||
{
|
||||
"id": "orchestration.worker-terminal-delivery",
|
||||
"title": "Workers remain visible and observable across workspace entry and app restart",
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterAll, expect, test } from 'vitest'
|
||||
|
||||
const targetRoot = process.env.STA_4274_TARGET_ROOT
|
||||
const operation = process.env.STA_4274_OPERATION
|
||||
const ownedDirs: string[] = []
|
||||
|
||||
function makeOwnedDir(prefix: string): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), prefix))
|
||||
ownedDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
for (const dir of ownedDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
const ordinary = {
|
||||
id: 'ordinary-runtime',
|
||||
recipeId: 'ordinary-recipe',
|
||||
recipe: {
|
||||
id: 'ordinary-recipe',
|
||||
name: 'Ordinary VM',
|
||||
create: './ordinary-create.sh',
|
||||
destroy: './ordinary-destroy.sh'
|
||||
},
|
||||
status: 'running' as const,
|
||||
cleanupStatus: 'not_started' as const,
|
||||
createdAt: 1_000,
|
||||
updatedAt: 1_000,
|
||||
recipeResult: {
|
||||
schemaVersion: 1 as const,
|
||||
connection: {
|
||||
type: 'ssh' as const,
|
||||
projectRoot: '/workspace/ordinary',
|
||||
target: {
|
||||
label: 'Ordinary VM',
|
||||
host: 'ordinary.example.com',
|
||||
port: 22,
|
||||
username: 'developer'
|
||||
}
|
||||
},
|
||||
userData: { resourceId: 'ordinary-resource' }
|
||||
}
|
||||
}
|
||||
|
||||
const provisionedRoot = {
|
||||
id: 'provisioned-root-runtime',
|
||||
recipeId: 'provisioned-root-recipe',
|
||||
recipe: {
|
||||
id: 'provisioned-root-recipe',
|
||||
name: 'Provisioned Root VM',
|
||||
create: './provisioned-root-create.sh',
|
||||
destroy: './provisioned-root-destroy.sh',
|
||||
checkoutMode: 'provisioned-root' as const
|
||||
},
|
||||
status: 'running' as const,
|
||||
cleanupStatus: 'not_started' as const,
|
||||
createdAt: 2_000,
|
||||
updatedAt: 2_000,
|
||||
recipeResult: {
|
||||
schemaVersion: 2 as const,
|
||||
checkoutMode: 'provisioned-root' as const,
|
||||
connection: {
|
||||
type: 'ssh' as const,
|
||||
projectRoot: '/workspace/provisioned',
|
||||
target: {
|
||||
label: 'Provisioned Root VM',
|
||||
host: 'provisioned.example.com',
|
||||
port: 22,
|
||||
username: 'developer'
|
||||
}
|
||||
},
|
||||
userData: { resourceId: 'provisioned-resource' }
|
||||
}
|
||||
}
|
||||
|
||||
test.skipIf(!targetRoot || !operation)(`STA-4274 ${operation ?? 'disabled'}`, async () => {
|
||||
if (!targetRoot || !operation) {
|
||||
throw new Error('STA_4274_TARGET_ROOT and STA_4274_OPERATION are required')
|
||||
}
|
||||
const userDataPath = process.env.STA_4274_USER_DATA_PATH ?? makeOwnedDir('sta-4274-')
|
||||
const moduleUrl = pathToFileURL(
|
||||
resolve(targetRoot, 'src/shared/ephemeral-vm-runtime-store.ts')
|
||||
).href
|
||||
const store = await import(/* @vite-ignore */ moduleUrl)
|
||||
|
||||
if (operation === 'write-legacy') {
|
||||
store.upsertEphemeralVmRuntime(userDataPath, ordinary)
|
||||
return
|
||||
}
|
||||
if (operation === 'write-mixed') {
|
||||
store.upsertEphemeralVmRuntime(userDataPath, ordinary)
|
||||
store.upsertEphemeralVmRuntime(userDataPath, provisionedRoot)
|
||||
return
|
||||
}
|
||||
if (operation === 'read') {
|
||||
const runtimes = store.listEphemeralVmRuntimes(userDataPath)
|
||||
expect(runtimes.map((record: { id: string }) => record.id)).toEqual([
|
||||
'provisioned-root-runtime',
|
||||
'ordinary-runtime'
|
||||
])
|
||||
return
|
||||
}
|
||||
if (operation === 'read-rollback-projection') {
|
||||
const runtimes = store.listEphemeralVmRuntimes(userDataPath)
|
||||
expect(runtimes).toHaveLength(2)
|
||||
expect(runtimes[0].recipe).not.toHaveProperty('checkoutMode')
|
||||
expect(runtimes[0].recipeResult).toMatchObject({ schemaVersion: 1 })
|
||||
return
|
||||
}
|
||||
if (operation === 'mutate-lifecycle') {
|
||||
store.updateEphemeralVmRuntimeStatus(userDataPath, 'ordinary-runtime', {
|
||||
status: 'suspended',
|
||||
updatedAt: 3_000
|
||||
})
|
||||
const repoPath = makeOwnedDir('sta-4274-cleanup-')
|
||||
const cleanupPath = join(repoPath, 'cleanup.js')
|
||||
const proofPath = join(repoPath, 'cleanup-proof')
|
||||
writeFileSync(
|
||||
cleanupPath,
|
||||
[
|
||||
"let input = ''",
|
||||
"process.stdin.on('data', (chunk) => { input += chunk })",
|
||||
"process.stdin.on('end', () => {",
|
||||
' const payload = JSON.parse(input)',
|
||||
' if (payload.recipeResult.schemaVersion !== 1) process.exit(12)',
|
||||
" if (payload.recipeResult.userData.resourceId !== 'provisioned-resource') process.exit(13)",
|
||||
` require('fs').writeFileSync(${JSON.stringify(proofPath)}, 'destroyed')`,
|
||||
'})'
|
||||
].join('\n')
|
||||
)
|
||||
const serviceUrl = pathToFileURL(
|
||||
resolve(targetRoot, 'src/main/ephemeral-vm-runtime-service.ts')
|
||||
).href
|
||||
const service = await import(/* @vite-ignore */ serviceUrl)
|
||||
const result = await service.cleanupEphemeralVmRuntime({
|
||||
userDataPath,
|
||||
repoPath,
|
||||
runtimeId: 'provisioned-root-runtime',
|
||||
recipe: {
|
||||
id: 'provisioned-root-recipe',
|
||||
name: 'Provisioned Root VM',
|
||||
create: './provisioned-root-create.sh',
|
||||
destroy: `${JSON.stringify(process.execPath)} ${JSON.stringify(cleanupPath)}`
|
||||
},
|
||||
now: 3_000
|
||||
})
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
runtime: { status: 'cleaned', cleanupStatus: 'succeeded' }
|
||||
})
|
||||
expect(existsSync(proofPath)).toBe(true)
|
||||
return
|
||||
}
|
||||
if (operation === 'read-after-downgrade') {
|
||||
expect(store.listEphemeralVmRuntimes(userDataPath)).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'provisioned-root-runtime',
|
||||
status: 'cleaned',
|
||||
cleanupStatus: 'succeeded',
|
||||
recipe: expect.objectContaining({ checkoutMode: 'provisioned-root' }),
|
||||
recipeResult: expect.objectContaining({
|
||||
schemaVersion: 2,
|
||||
checkoutMode: 'provisioned-root'
|
||||
})
|
||||
}),
|
||||
expect.objectContaining({ id: 'ordinary-runtime', status: 'suspended' })
|
||||
])
|
||||
return
|
||||
}
|
||||
if (operation === 'read-legacy') {
|
||||
expect(
|
||||
store.listEphemeralVmRuntimes(userDataPath).map((record: { id: string }) => record.id)
|
||||
).toEqual(['ordinary-runtime'])
|
||||
return
|
||||
}
|
||||
throw new Error(`Unknown operation: ${operation}`)
|
||||
})
|
||||
@@ -14,6 +14,9 @@ const sshDockerRunner = readFileSync(
|
||||
const filterStep = prWorkflow.jobs['e2e-paths'].steps.find(
|
||||
(step) => step.name === 'Filter changed E2E specs'
|
||||
)
|
||||
const rollbackStep = prWorkflow.jobs.static_analysis.steps.find(
|
||||
(step) => step.name === 'Check VM runtime rollback compatibility'
|
||||
)
|
||||
const verifyStep = prWorkflow.jobs.verify.steps.find(
|
||||
(step) => step.name === 'Require successful checks'
|
||||
)
|
||||
@@ -121,4 +124,11 @@ describe('PR E2E gate contract', () => {
|
||||
expect(filterStep.run).toContain('--merge-base "$BASE" "$HEAD"')
|
||||
expect(filterStep.run).toContain('set -euo pipefail')
|
||||
})
|
||||
|
||||
it('scopes the VM rollback oracle to the PR range and recipe schema authorities', () => {
|
||||
expect(rollbackStep.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
|
||||
expect(rollbackStep.run).toContain('src/shared/ephemeral-vm-recipes.ts')
|
||||
expect(rollbackStep.run).toContain('src/shared/orca-yaml-hook-types.ts')
|
||||
expect(filterStep.run).toContain('ephemeral-vm-recipes|orca-yaml-hook-types')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import process from 'node:process'
|
||||
|
||||
const BASELINE_COMMIT = 'bf0c77d5bc800e19117084c27fd1441eda9134ad'
|
||||
const AFFECTED_MAIN_COMMIT = '25abb9368d98ad84a174f530e02f4228d2269062'
|
||||
const root = process.cwd()
|
||||
const driver = process.argv[2] ?? 'config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts'
|
||||
const config = 'config/vitest.config.ts'
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), 'orca-sta-4274-repro-'))
|
||||
|
||||
try {
|
||||
const baselineRoot = extractSource(BASELINE_COMMIT, 'baseline')
|
||||
const affectedRoot = extractSource(AFFECTED_MAIN_COMMIT, 'affected-main')
|
||||
const revertedRoot = extractSource('HEAD', 'candidate-reverted')
|
||||
restoreAffectedStoreFiles(revertedRoot)
|
||||
|
||||
const baselineData = makeDataDir('baseline-data')
|
||||
runOracle('baseline write', baselineRoot, 'write-legacy', baselineData)
|
||||
runOracle('baseline read', baselineRoot, 'read-legacy', baselineData)
|
||||
|
||||
const affectedData = makeDataDir('affected-data')
|
||||
runOracle('affected write', affectedRoot, 'write-mixed', affectedData)
|
||||
runOracle('affected current read', affectedRoot, 'read', affectedData)
|
||||
runOracle('affected rollback read', baselineRoot, 'read', affectedData, {
|
||||
expectFailure: 'file is invalid'
|
||||
})
|
||||
|
||||
const candidateData = makeDataDir('candidate-data')
|
||||
runOracle('candidate write', root, 'write-mixed', candidateData)
|
||||
runOracle(
|
||||
'candidate rollback projection',
|
||||
baselineRoot,
|
||||
'read-rollback-projection',
|
||||
candidateData
|
||||
)
|
||||
runOracle('rollback lifecycle mutation', baselineRoot, 'mutate-lifecycle', candidateData)
|
||||
runOracle('candidate re-upgrade', root, 'read-after-downgrade', candidateData)
|
||||
|
||||
const revertedData = makeDataDir('reverted-data')
|
||||
runOracle('reverted write', revertedRoot, 'write-mixed', revertedData)
|
||||
runOracle('reverted rollback read', baselineRoot, 'read', revertedData, {
|
||||
expectFailure: 'file is invalid'
|
||||
})
|
||||
} finally {
|
||||
rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
function extractSource(commit, name) {
|
||||
const destination = join(tempRoot, name)
|
||||
const archive = join(tempRoot, `${name}.tar`)
|
||||
mkdirSync(destination)
|
||||
run('git', ['archive', '--format=tar', `--output=${archive}`, commit, 'src/main', 'src/shared'])
|
||||
run('tar', ['-xf', archive, '-C', destination])
|
||||
return destination
|
||||
}
|
||||
|
||||
function restoreAffectedStoreFiles(destination) {
|
||||
for (const relativePath of [
|
||||
'src/shared/ephemeral-vm-runtime-store.ts',
|
||||
'src/shared/ephemeral-vm-runtimes.ts'
|
||||
]) {
|
||||
const result = run('git', ['show', `${AFFECTED_MAIN_COMMIT}:${relativePath}`], {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
writeFileSync(join(destination, relativePath), result.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
function makeDataDir(name) {
|
||||
const destination = join(tempRoot, name)
|
||||
mkdirSync(destination)
|
||||
return destination
|
||||
}
|
||||
|
||||
function runOracle(label, targetRoot, operation, userDataPath, options = {}) {
|
||||
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const result = spawnSync(pnpm, ['exec', 'vitest', 'run', driver, '--config', config], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
STA_4274_TARGET_ROOT: resolve(targetRoot),
|
||||
STA_4274_OPERATION: operation,
|
||||
STA_4274_USER_DATA_PATH: userDataPath
|
||||
}
|
||||
})
|
||||
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`
|
||||
if (options.expectFailure) {
|
||||
if (result.status === 0 || !output.includes(options.expectFailure)) {
|
||||
throw new Error(
|
||||
`${label} did not fail with ${JSON.stringify(options.expectFailure)}\n${output}`
|
||||
)
|
||||
}
|
||||
process.stdout.write(`EXPECTED_FAIL ${label}\n`)
|
||||
return
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${label} failed\n${output}`)
|
||||
}
|
||||
process.stdout.write(`PASS ${label}\n`)
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: root,
|
||||
encoding: options.encoding,
|
||||
maxBuffer: 16 * 1024 * 1024
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`${command} ${args.join(' ')} failed\n${String(result.stdout ?? '')}${String(result.stderr ?? '')}`
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -470,8 +470,19 @@ SSH mode is **fundamentally different from §7c/§7f**, not a relabeling of them
|
||||
`label`, `host`, `port`, `username` are required; the rest are optional — omit any you don't need.
|
||||
|
||||
For an explicitly requested one-VM-per-workspace checkout, the create script must read
|
||||
`ORCA_RECIPE_RESULT_SCHEMA_VERSION`, `ORCA_REPO_URL`, `ORCA_REPO_REF`, and `ORCA_REPO_BRANCH`, create
|
||||
that exact primary checkout at `projectRoot`, and emit the same SSH result with:
|
||||
`ORCA_RECIPE_RESULT_SCHEMA_VERSION`, `ORCA_REPO_URL`, `ORCA_REPO_REF`, `ORCA_REPO_REF_HEAD`, and
|
||||
`ORCA_REPO_BRANCH`. Use `ORCA_REPO_REF` to fetch the selected source, but create
|
||||
`ORCA_REPO_BRANCH` at the exact `ORCA_REPO_REF_HEAD` commit; resolving the symbolic ref again can race
|
||||
with an upstream update. `ORCA_REPO_URL` and `ORCA_REPO_REF` are a matched fetch pair, including when
|
||||
the desktop source uses multiple remotes. Return that primary checkout at `projectRoot` and emit the
|
||||
same SSH result with:
|
||||
|
||||
```bash
|
||||
[ -n "${ORCA_REPO_REF_HEAD:-}" ] || { echo "missing pinned source commit" >&2; exit 1; }
|
||||
git fetch origin "$ORCA_REPO_REF"
|
||||
git cat-file -e "${ORCA_REPO_REF_HEAD}^{commit}"
|
||||
git checkout -B "$ORCA_REPO_BRANCH" "$ORCA_REPO_REF_HEAD"
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -645,7 +656,8 @@ worked script in §7g). `pairingCode` is **not** used in SSH mode.
|
||||
|
||||
**Optional provisioned root** — only for direct SSH and only when explicitly requested. Add
|
||||
`checkoutMode: provisioned-root` to the recipe, require `ORCA_RECIPE_RESULT_SCHEMA_VERSION=2`, create
|
||||
the requested branch from `ORCA_REPO_REF` at the returned `projectRoot`, and emit schema version 2 plus
|
||||
the requested `ORCA_REPO_BRANCH` at the pinned `ORCA_REPO_REF_HEAD` commit (use `ORCA_REPO_REF` only
|
||||
to fetch that commit) at the returned `projectRoot`, and emit schema version 2 with
|
||||
`checkoutMode: "provisioned-root"`. All recipes without this field retain the schema-v1 behavior above.
|
||||
|
||||
Lifecycle hooks (all run locally):
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -57,4 +57,17 @@ describe('CLI error recovery', () => {
|
||||
|
||||
expect(output).toContain('Fix the command flags or RPC params')
|
||||
})
|
||||
|
||||
it('does not replace mutation recovery with generic runtime startup advice', () => {
|
||||
const error = new RuntimeClientError(
|
||||
'runtime_unavailable',
|
||||
'Re-issue the same command with --retry-request mutation_1.',
|
||||
{ orchestrationRequestId: 'mutation_1' }
|
||||
)
|
||||
|
||||
const output = formatCliError(error)
|
||||
|
||||
expect(output).toContain('--retry-request mutation_1')
|
||||
expect(output).not.toContain('orca open')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -78,6 +78,9 @@ export function printResult<TResult>(
|
||||
export function formatCliError(error: unknown, context: CliErrorContext = {}): string {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (error instanceof RuntimeClientError && error.code === 'runtime_unavailable') {
|
||||
if (hasOrchestrationRequestId(error.data)) {
|
||||
return message
|
||||
}
|
||||
return `${message}\nOrca is not running. Run 'orca open' first.`
|
||||
}
|
||||
// Why: error-specific recovery must win over the generic computer fallback.
|
||||
@@ -105,6 +108,14 @@ export function formatCliError(error: unknown, context: CliErrorContext = {}): s
|
||||
return message
|
||||
}
|
||||
|
||||
function hasOrchestrationRequestId(data: unknown): boolean {
|
||||
return (
|
||||
data !== null &&
|
||||
typeof data === 'object' &&
|
||||
typeof (data as { orchestrationRequestId?: unknown }).orchestrationRequestId === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
export function reportCliError(error: unknown, json: boolean, context: CliErrorContext = {}): void {
|
||||
if (json) {
|
||||
if (error instanceof RuntimeRpcFailureError) {
|
||||
|
||||
@@ -195,4 +195,47 @@ describe('orchestration gate commands carry caller identity', () => {
|
||||
expect(stderr).toContain('Pass --from <terminal-handle>')
|
||||
expect(callMock).not.toHaveBeenCalledWith('orchestration.gateCreate', expect.anything())
|
||||
})
|
||||
|
||||
it('reports idempotent recovery when a mutation connection drops', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_coord'
|
||||
callMock
|
||||
.mockResolvedValueOnce(okFixture('req_show', { terminal: { handle: 'term_coord' } }))
|
||||
.mockRejectedValueOnce(
|
||||
new RuntimeClientError(
|
||||
'runtime_unavailable',
|
||||
'The Orca runtime closed the connection before responding. Restart Orca and try again. Orchestration mutation request ID: mutation_1.',
|
||||
{
|
||||
orchestrationRequestId: 'mutation_1',
|
||||
failedStage: 'dispatch_input',
|
||||
residualResources: [
|
||||
{ kind: 'worktree', id: 'repo::child' },
|
||||
{ kind: 'terminal', id: 'term_worker' }
|
||||
]
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
await main(
|
||||
['orchestration', 'gate-create', '--task', 'task_1', '--question', 'ship?', '--json'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
const output = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as {
|
||||
error: { message: string; data: Record<string, unknown> }
|
||||
}
|
||||
expect(output.error.message).toContain('--retry-request mutation_1')
|
||||
expect(output.error.message).toContain('may already have taken effect')
|
||||
expect(output.error.message).toContain('Failed stage: dispatch_input')
|
||||
expect(output.error.message).toMatch(/Residual resources:.*repo::child.*term_worker/)
|
||||
expect(output.error.message).not.toMatch(/restart Orca/i)
|
||||
expect(output.error.data).toMatchObject({
|
||||
orchestrationRequestId: 'mutation_1',
|
||||
failedStage: 'dispatch_input',
|
||||
residualResources: expect.arrayContaining([
|
||||
expect.objectContaining({ kind: 'worktree', id: 'repo::child' }),
|
||||
expect.objectContaining({ kind: 'terminal', id: 'term_worker' })
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type LegacyCompatibilityResult,
|
||||
type OrchestrationMessageSummary as MessageSummary
|
||||
} from '../../shared/orchestration-check-output'
|
||||
import { orchestrationMutationRecoveryError } from '../orchestration-mutation-recovery'
|
||||
|
||||
// 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
|
||||
@@ -397,14 +398,13 @@ function callMutation<TResult>(
|
||||
options?: { timeoutMs?: number; orchestrationCapability?: string }
|
||||
) {
|
||||
const requestId = getOptionalStringFlag(flags, 'retry-request')
|
||||
if (!requestId) {
|
||||
return options
|
||||
const result = requestId
|
||||
? client.call<TResult>(method, params, { ...options, orchestrationRequestId: requestId })
|
||||
: options
|
||||
? client.call<TResult>(method, params, options)
|
||||
: client.call<TResult>(method, params)
|
||||
}
|
||||
return client.call<TResult>(method, params, {
|
||||
...options,
|
||||
orchestrationRequestId: requestId
|
||||
return result.catch((error) => {
|
||||
throw orchestrationMutationRecoveryError(error)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
|
||||
export function orchestrationMutationRecoveryError(error: unknown): unknown {
|
||||
if (!(error instanceof RuntimeClientError) || !isUnknownMutationOutcomeCode(error.code)) {
|
||||
return error
|
||||
}
|
||||
const data = objectRecord(error.data)
|
||||
const requestId = data?.orchestrationRequestId
|
||||
if (typeof requestId !== 'string' || requestId.length === 0) {
|
||||
return error
|
||||
}
|
||||
const message = [
|
||||
stripUnsafeRetryAdvice(error.message, requestId),
|
||||
'The orchestration mutation may already have taken effect; do not assume it failed.',
|
||||
`Re-issue the same command with --retry-request ${requestId} to recover idempotently. Do not retry this mutation without --retry-request.`,
|
||||
typeof data?.failedStage === 'string' ? `Failed stage: ${data.failedStage}.` : undefined,
|
||||
Array.isArray(data?.residualResources)
|
||||
? `Residual resources: ${JSON.stringify(data.residualResources)}.`
|
||||
: undefined
|
||||
].filter((line): line is string => line !== undefined)
|
||||
return new RuntimeClientError(error.code, message.join('\n'), error.data)
|
||||
}
|
||||
|
||||
function isUnknownMutationOutcomeCode(code: string): boolean {
|
||||
return [
|
||||
'runtime_unavailable',
|
||||
'remote_runtime_unavailable',
|
||||
'runtime_timeout',
|
||||
'invalid_runtime_response'
|
||||
].includes(code)
|
||||
}
|
||||
|
||||
function objectRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value !== null && typeof value === 'object'
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function stripUnsafeRetryAdvice(message: string, requestId: string): string {
|
||||
return message
|
||||
.replace(' Restart Orca and try again.', '')
|
||||
.replace(' Retry the command.', '')
|
||||
.replace(` Orchestration mutation request ID: ${requestId}.`, '')
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { join } from 'node:path'
|
||||
import { createServer, type Socket } from 'node:net'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY } from '../shared/protocol-version'
|
||||
import { RuntimeClient, RuntimeRpcFailureError } from './runtime-client'
|
||||
import { RuntimeClient, RuntimeClientError, RuntimeRpcFailureError } from './runtime-client'
|
||||
import { launchOrcaApp } from './runtime/launch'
|
||||
|
||||
vi.mock('./runtime/launch', () => ({
|
||||
@@ -415,6 +415,36 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves a dropped read-only orchestration failure exactly', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
let request: Record<string, unknown> | undefined
|
||||
const server = createServer((socket) => {
|
||||
sockets.add(socket)
|
||||
socket.once('close', () => sockets.delete(socket))
|
||||
socket.once('data', (data) => {
|
||||
request = JSON.parse(String(data).trim()) as Record<string, unknown>
|
||||
socket.end()
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeMetadata(userDataPath, endpoint)
|
||||
const client = new RuntimeClient(userDataPath, 100)
|
||||
|
||||
const failure = await client
|
||||
.call('orchestration.workerShow', { dispatch: 'ctx_1' })
|
||||
.catch((error: unknown) => error)
|
||||
|
||||
expect(failure).toBeInstanceOf(RuntimeClientError)
|
||||
expect((failure as RuntimeClientError).message).toBe(
|
||||
'The Orca runtime closed the connection before responding. Restart Orca and try again.'
|
||||
)
|
||||
expect((failure as RuntimeClientError).data).toBeUndefined()
|
||||
expect(request).toMatchObject({ method: 'orchestration.workerShow' })
|
||||
expect(request).not.toHaveProperty('orchestrationRequestId')
|
||||
})
|
||||
|
||||
it('allows a per-call timeout override for long runtime requests', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
|
||||
@@ -132,7 +132,9 @@ describe.skipIf(process.platform === 'win32')('runtime transport', () => {
|
||||
// pre-fix behavior would hang the full duration and trip vitest's own limit.
|
||||
const start = Date.now()
|
||||
await expect(sendRequest(metadata, 'status.get', undefined, 60000)).rejects.toMatchObject({
|
||||
code: 'runtime_unavailable'
|
||||
code: 'runtime_unavailable',
|
||||
message:
|
||||
'The Orca runtime closed the connection before responding. Restart Orca and try again.'
|
||||
})
|
||||
expect(Date.now() - start).toBeLessThan(5000)
|
||||
})
|
||||
|
||||
@@ -6,7 +6,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
{
|
||||
path: ['orchestration', 'run-create'],
|
||||
summary: 'Create and bind a lightweight orchestration Run',
|
||||
usage: 'orca orchestration run-create --objective <text> [--from <handle>] [--json]',
|
||||
usage:
|
||||
'orca orchestration run-create --objective <text> [--from <handle>] [--retry-request <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'objective', 'from', 'retry-request'],
|
||||
notes: [
|
||||
'A Run is a namespace and home inbox. It never schedules or places workers.',
|
||||
@@ -17,7 +18,7 @@ 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>] [--takeover-legacy] [--json]',
|
||||
'orca orchestration run-use --id <run_id> [--from <handle>] [--takeover-legacy] [--retry-request <id>] [--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.'
|
||||
@@ -45,7 +46,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['orchestration', 'send'],
|
||||
summary: 'Send an inter-agent message',
|
||||
usage:
|
||||
'orca orchestration send --subject <text> [--to <run:id|dispatch:id|legacy_handle>] [--run <run_id>] [--from <handle>] [--body <text>] [--type <type>] [--priority <level>] [--thread-id <id>] [--payload <json>] [--task-id <id>] [--dispatch-id <id>] [--outcome <succeeded|failed>] [--files-modified <csv>] [--report-path <path>] [--phase <text>] [--json]',
|
||||
'orca orchestration send --subject <text> [--to <run:id|dispatch:id|legacy_handle>] [--run <run_id>] [--from <handle>] [--body <text>] [--type <type>] [--priority <level>] [--thread-id <id>] [--payload <json>] [--task-id <id>] [--dispatch-id <id>] [--outcome <succeeded|failed>] [--files-modified <csv>] [--report-path <path>] [--phase <text>] [--retry-request <id>] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'to',
|
||||
@@ -80,7 +81,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['orchestration', 'check'],
|
||||
summary: 'Check messages for a terminal',
|
||||
usage:
|
||||
'orca orchestration check [--terminal <handle>] [--run <run_id>] [--ack <delivery_id>] [--unread | --peek | --all] [--types <type,...>] [--format] [--wait] [--timeout-ms <n>] [--json]\n' +
|
||||
'orca orchestration check [--terminal <handle>] [--run <run_id>] [--ack <delivery_id>] [--unread | --peek | --all] [--types <type,...>] [--format] [--wait] [--timeout-ms <n>] [--retry-request <id>] [--json]\n' +
|
||||
" default: return the bound Run's oldest unacknowledged FIFO batch.\n" +
|
||||
' --ack: acknowledge the prior whole batch before checking/waiting.\n' +
|
||||
' --peek: return only unread messages without marking them read.\n' +
|
||||
@@ -114,7 +115,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['orchestration', 'reply'],
|
||||
summary: 'Reply to a message',
|
||||
usage:
|
||||
'orca orchestration reply --id <msg_id> --body <text> [--run <run_id>] [--from <handle>] [--json]',
|
||||
'orca orchestration reply --id <msg_id> --body <text> [--run <run_id>] [--from <handle>] [--retry-request <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'id', 'body', 'run', 'from', 'retry-request']
|
||||
},
|
||||
{
|
||||
@@ -127,7 +128,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['orchestration', 'task-create'],
|
||||
summary: 'Create an orchestration task',
|
||||
usage:
|
||||
'orca orchestration task-create --spec <text> [--task-title <text>] [--display-name <text>] [--deps <json_array>] [--parent <task_id>] [--run <run_id>] [--from <handle>] [--json]',
|
||||
'orca orchestration task-create --spec <text> [--task-title <text>] [--display-name <text>] [--deps <json_array>] [--parent <task_id>] [--run <run_id>] [--from <handle>] [--retry-request <id>] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'spec',
|
||||
@@ -152,7 +153,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['orchestration', 'task-update'],
|
||||
summary: 'Update a task status',
|
||||
usage:
|
||||
'orca orchestration task-update --id <task_id> --status <status> [--result <json>] [--run <run_id>] [--from <handle>] [--json]',
|
||||
'orca orchestration task-update --id <task_id> --status <status> [--result <json>] [--run <run_id>] [--from <handle>] [--retry-request <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'id', 'status', 'result', 'run', 'from', 'retry-request'],
|
||||
notes: ['Valid --status values: pending, ready, dispatched, completed, failed, blocked.']
|
||||
},
|
||||
@@ -161,7 +162,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['orchestration', 'dispatch'],
|
||||
summary: 'Dispatch a task to a terminal',
|
||||
usage:
|
||||
'orca orchestration dispatch --task <task_id> --to <handle> [--from <handle>] [--run <run_id>] [--inject] [--dry-run] [--return-preamble] [--json]',
|
||||
'orca orchestration dispatch --task <task_id> --to <handle> [--from <handle>] [--run <run_id>] [--inject] [--dry-run] [--return-preamble] [--retry-request <id>] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'task',
|
||||
@@ -185,7 +186,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['orchestration', 'ask'],
|
||||
summary: 'Ask the coordinator a question and block until answered',
|
||||
usage:
|
||||
'orca orchestration ask (--question <text> | --resume <message_id>) [--to <run:id>] [--run <run_id>] [--options <csv>] [--timeout-ms <n>] [--from <handle>] [--json]',
|
||||
'orca orchestration ask (--question <text> | --resume <message_id>) [--to <run:id>] [--run <run_id>] [--options <csv>] [--timeout-ms <n>] [--from <handle>] [--retry-request <id>] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'to',
|
||||
@@ -236,14 +237,14 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['orchestration', 'gate-create'],
|
||||
summary: 'Create a decision gate blocking a task',
|
||||
usage:
|
||||
'orca orchestration gate-create --task <task_id> --question <text> [--options <json_array>] [--from <handle>] [--json]',
|
||||
'orca orchestration gate-create --task <task_id> --question <text> [--options <json_array>] [--from <handle>] [--retry-request <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'task', 'question', 'options', 'from', 'retry-request']
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'gate-resolve'],
|
||||
summary: 'Resolve a pending decision gate',
|
||||
usage:
|
||||
'orca orchestration gate-resolve --id <gate_id> --resolution <text> [--from <handle>] [--json]',
|
||||
'orca orchestration gate-resolve --id <gate_id> --resolution <text> [--from <handle>] [--retry-request <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'id', 'resolution', 'from', 'retry-request']
|
||||
},
|
||||
{
|
||||
@@ -257,7 +258,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
{
|
||||
path: ['orchestration', 'reset'],
|
||||
summary: 'Reset one explicit orchestration state scope',
|
||||
usage: 'orca orchestration reset (--all | --tasks | --messages) [--json]',
|
||||
usage:
|
||||
'orca orchestration reset (--all | --tasks | --messages) [--retry-request <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'all', 'tasks', 'messages', 'retry-request']
|
||||
}
|
||||
]
|
||||
|
||||
@@ -48,11 +48,13 @@ export async function readHooksJsonRemote(
|
||||
export async function writeHooksJsonRemote(
|
||||
sftp: SFTPWrapper,
|
||||
remotePath: string,
|
||||
config: HooksConfig
|
||||
config: HooksConfig,
|
||||
// Why: mirrors the local writer — a JSONC config supplies text edited in place.
|
||||
options?: { serialized?: string }
|
||||
): Promise<void> {
|
||||
const dir = dirnamePosix(remotePath)
|
||||
await mkdirpRemote(sftp, dir)
|
||||
const serialized = `${JSON.stringify(config, null, 2)}\n`
|
||||
const serialized = options?.serialized ?? `${JSON.stringify(config, null, 2)}\n`
|
||||
// Why: skip the write when on-disk content is identical so repeated
|
||||
// install() calls do not bump the file's mtime / inode unnecessarily.
|
||||
try {
|
||||
|
||||
@@ -316,7 +316,9 @@ function writeScriptWithAclRetry(scriptPath: string, content: string): void {
|
||||
export function writeHooksJson(
|
||||
configPath: string,
|
||||
config: HooksConfig,
|
||||
options?: { preserveMode?: boolean }
|
||||
// Why: `serialized` lets a JSONC config (Devin) supply text edited in place, so the
|
||||
// atomic write + rolling backup below stay shared instead of being reimplemented.
|
||||
options?: { preserveMode?: boolean; serialized?: string }
|
||||
): void {
|
||||
const writePath = resolveHooksJsonWritePath(configPath)
|
||||
const dir = dirname(writePath)
|
||||
@@ -325,7 +327,7 @@ export function writeHooksJson(
|
||||
// Why: temp+rename leaves the original untouched on a crash/disk-full mid-write.
|
||||
// Why randomUUID: avoids tmp-path collisions when two install() calls fire in the same millisecond.
|
||||
const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`)
|
||||
const serialized = `${JSON.stringify(config, null, 2)}\n`
|
||||
const serialized = options?.serialized ?? `${JSON.stringify(config, null, 2)}\n`
|
||||
const existingMode =
|
||||
options?.preserveMode === true && existsSync(writePath) ? statSync(writePath).mode : undefined
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { parse as parseJsonc } from 'jsonc-parser'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
@@ -415,7 +416,11 @@ describe('remote hook service installers', () => {
|
||||
expect(grokConfig.hooks.PostToolUse?.[0]?.matcher).toBe('.*')
|
||||
expect(grokConfig.hooks.StopFailure?.[0]?.matcher).toBeUndefined()
|
||||
|
||||
const devinConfig = JSON.parse(devin.fs.files.get('/home/dev/.config/devin/config.json')!) as {
|
||||
const devinText = devin.fs.files.get('/home/dev/.config/devin/config.json')!
|
||||
// Why: Devin config.json is JSONC — parse it as such, and assert the user's comment
|
||||
// survived. Asserting with JSON.parse would only pass if the install had stripped it.
|
||||
expect(devinText).toContain('// Existing Devin config comment')
|
||||
const devinConfig = parseJsonc(devinText) as {
|
||||
permissions: { mode: string }
|
||||
hooks: Record<string, { matcher?: string; hooks?: { command: string }[] }[]>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { join } from 'node:path'
|
||||
|
||||
// Why `orca-ide` on Linux: GNOME Orca ships /usr/bin/orca, so the CLI never claims that name.
|
||||
export const LINUX_CLI_COMMAND_NAME = 'orca-ide'
|
||||
|
||||
/** Absolute path of the CLI launcher this app ships in its own resources bundle.
|
||||
* Lives apart from cli-installer so callers that only need the path (PTY env
|
||||
* assembly) don't pull in the installer's `electron` dependency. */
|
||||
export function getBundledLauncherPath(
|
||||
platform: NodeJS.Platform,
|
||||
resourcesPath: string
|
||||
): string | null {
|
||||
if (platform === 'darwin') {
|
||||
return join(resourcesPath, 'bin', 'orca')
|
||||
}
|
||||
if (platform === 'linux') {
|
||||
return join(resourcesPath, 'bin', LINUX_CLI_COMMAND_NAME)
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
return join(resourcesPath, 'bin', 'orca.exe')
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { promisify } from 'node:util'
|
||||
import type { CliInstallMethod, CliInstallStatus } from '../../shared/cli-install-types'
|
||||
import { expandWindowsEnvironmentVariables } from '../../shared/windows-environment-expansion'
|
||||
import { buildAppImageCliWrapper } from './appimage-cli-wrapper'
|
||||
import { getBundledLauncherPath, LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path'
|
||||
import {
|
||||
invalidateWindowsUserPathRegistryCache,
|
||||
readFreshWindowsUserPathRegistry,
|
||||
@@ -29,7 +30,6 @@ import {
|
||||
const execFileAsync = promisify(execFile)
|
||||
const DEFAULT_MAC_COMMAND_PATH = '/usr/local/bin/orca'
|
||||
const DEV_COMMAND_NAME = 'orca-dev'
|
||||
const LINUX_COMMAND_NAME = 'orca-ide'
|
||||
const LEGACY_LINUX_COMMAND_NAME = 'orca'
|
||||
const DEV_LAUNCHER_DIR = ['cli', 'bin']
|
||||
const WINDOWS_PATH_WRITE_TIMEOUT_MS = 5_000
|
||||
@@ -88,7 +88,7 @@ export class CliInstaller {
|
||||
return DEV_COMMAND_NAME
|
||||
}
|
||||
// Why: packaged Linux uses `orca-ide` to avoid shadowing GNOME Orca's /usr/bin/orca.
|
||||
return this.platform === 'linux' ? LINUX_COMMAND_NAME : 'orca'
|
||||
return this.platform === 'linux' ? LINUX_CLI_COMMAND_NAME : 'orca'
|
||||
}
|
||||
|
||||
constructor(options: CliInstallerOptions = {}) {
|
||||
@@ -351,7 +351,7 @@ export class CliInstaller {
|
||||
if (this.platform === 'linux') {
|
||||
// Why: Linux lacks a privileged global command flow; ~/.local/bin is the least-surprising user-scoped dir.
|
||||
// Why `orca-ide`: GNOME Orca ships /usr/bin/orca, so avoid shadowing that screen reader.
|
||||
return join(this.homePath, '.local', 'bin', LINUX_COMMAND_NAME)
|
||||
return join(this.homePath, '.local', 'bin', LINUX_CLI_COMMAND_NAME)
|
||||
}
|
||||
|
||||
if (this.platform === 'win32') {
|
||||
@@ -1200,18 +1200,4 @@ function quotePowerShell(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`
|
||||
}
|
||||
|
||||
export function getBundledLauncherPath(
|
||||
platform: NodeJS.Platform,
|
||||
resourcesPath: string
|
||||
): string | null {
|
||||
if (platform === 'darwin') {
|
||||
return join(resourcesPath, 'bin', 'orca')
|
||||
}
|
||||
if (platform === 'linux') {
|
||||
return join(resourcesPath, 'bin', LINUX_COMMAND_NAME)
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
return join(resourcesPath, 'bin', 'orca.exe')
|
||||
}
|
||||
return null
|
||||
}
|
||||
export { getBundledLauncherPath }
|
||||
|
||||
@@ -632,6 +632,20 @@ describe('DaemonClient', () => {
|
||||
expect(writeSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an oversized settled notification without disconnecting', async () => {
|
||||
await startMockDaemon()
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
await client.ensureConnected()
|
||||
const internals = client as unknown as { controlSocket: Socket }
|
||||
const writeSpy = vi.spyOn(internals.controlSocket, 'write')
|
||||
|
||||
await expect(
|
||||
client.notifyWithSettlement('write', { data: 'x'.repeat(NDJSON_MAX_LINE_BYTES) })
|
||||
).resolves.toBe(false)
|
||||
expect(writeSpy).not.toHaveBeenCalled()
|
||||
expect(client.isConnected()).toBe(true)
|
||||
})
|
||||
|
||||
it('reports a dropped delivery when the socket write throws', async () => {
|
||||
await startMockDaemon()
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
@@ -644,5 +658,43 @@ describe('DaemonClient', () => {
|
||||
// Swallowed, not rethrown: a dead socket must not tear down the caller.
|
||||
expect(client.notify('write', { sessionId: 'session-1', data: 'hello' })).toBe(false)
|
||||
})
|
||||
|
||||
it('reports an asynchronous socket write failure at settlement', async () => {
|
||||
await startMockDaemon()
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
await client.ensureConnected()
|
||||
const internals = client as unknown as { controlSocket: Socket }
|
||||
vi.spyOn(internals.controlSocket, 'write').mockImplementation(((
|
||||
_data: string,
|
||||
callback: (error?: Error | null) => void
|
||||
) => {
|
||||
callback(new Error('EPIPE'))
|
||||
return false
|
||||
}) as Socket['write'])
|
||||
|
||||
await expect(
|
||||
client.notifyWithSettlement('write', { sessionId: 'session-1', data: 'hello' })
|
||||
).resolves.toBe(false)
|
||||
expect(client.isConnected()).toBe(false)
|
||||
})
|
||||
|
||||
it('disconnects a daemon socket whose write settlement exceeds its deadline', async () => {
|
||||
await startMockDaemon()
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
await client.ensureConnected()
|
||||
const internals = client as unknown as { controlSocket: Socket }
|
||||
vi.spyOn(internals.controlSocket, 'write').mockImplementation((() => true) as Socket['write'])
|
||||
vi.useFakeTimers()
|
||||
|
||||
const pending = client.notifyWithSettlement(
|
||||
'write',
|
||||
{ sessionId: 'session-1', data: 'hello' },
|
||||
5000
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
|
||||
await expect(pending).resolves.toBe(false)
|
||||
expect(client.isConnected()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ import { decodeDaemonResponseError } from './daemon-errors'
|
||||
const CONNECT_TIMEOUT_MS = 5000
|
||||
const CONNECTION_ATTEMPT_WAIT_MS = CONNECT_TIMEOUT_MS * 4
|
||||
const REQUEST_TIMEOUT_MS = 30000
|
||||
const NOTIFY_SETTLEMENT_TIMEOUT_MS = 5000
|
||||
|
||||
export type DaemonClientOptions = {
|
||||
socketPath: string
|
||||
@@ -247,6 +248,50 @@ export class DaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
async notifyWithSettlement(
|
||||
type: string,
|
||||
payload: unknown,
|
||||
timeoutMs = NOTIFY_SETTLEMENT_TIMEOUT_MS
|
||||
): Promise<boolean> {
|
||||
if (!this.connected || !this.controlSocket) {
|
||||
return false
|
||||
}
|
||||
|
||||
const id = `${NOTIFY_PREFIX}${++this.requestCounter}`
|
||||
const msg = { id, type, ...(payload !== undefined ? { payload } : {}) }
|
||||
let encoded: string
|
||||
try {
|
||||
encoded = encodeNdjson(msg)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const socket = this.controlSocket!
|
||||
const generation = this.connectionGeneration
|
||||
let settled = false
|
||||
const settle = (accepted: boolean): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolve(accepted)
|
||||
}
|
||||
const rejectAndDisconnect = (): void => {
|
||||
if (this.controlSocket === socket && this.connectionGeneration === generation) {
|
||||
this.handleDisconnect(generation)
|
||||
}
|
||||
settle(false)
|
||||
}
|
||||
const timer = setTimeout(rejectAndDisconnect, timeoutMs)
|
||||
try {
|
||||
socket.write(encoded, (error) => (error ? rejectAndDisconnect() : settle(true)))
|
||||
} catch {
|
||||
rejectAndDisconnect()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onEvent(listener: (event: unknown) => void): () => void {
|
||||
this.eventListeners.push(listener)
|
||||
return () => {
|
||||
|
||||
@@ -1064,7 +1064,21 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
}
|
||||
|
||||
write(id: string, data: string): void {
|
||||
write(id: string, data: string): boolean {
|
||||
const recoverable = this.prepareWrite(id)
|
||||
return this.finishWrite(id, this.client.notify('write', { sessionId: id, data }), recoverable)
|
||||
}
|
||||
|
||||
async writeWithSettlement(id: string, data: string): Promise<boolean> {
|
||||
const recoverable = this.prepareWrite(id)
|
||||
return this.finishWrite(
|
||||
id,
|
||||
await this.client.notifyWithSettlement('write', { sessionId: id, data }),
|
||||
recoverable
|
||||
)
|
||||
}
|
||||
|
||||
private prepareWrite(id: string): boolean {
|
||||
this.markSessionDirty(id)
|
||||
// Why recoverable and not just active: rejecting a write asks the pane to remount,
|
||||
// which only helps if this endpoint can come back. A legacy adapter has no respawn,
|
||||
@@ -1080,12 +1094,16 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
this.reconnectAfterWriteFailure()
|
||||
throw new PtyWriteUnavailableError(`Daemon PTY "${id}" is awaiting recovery`)
|
||||
}
|
||||
const delivered = this.client.notify('write', { sessionId: id, data })
|
||||
return recoverable
|
||||
}
|
||||
|
||||
private finishWrite(id: string, delivered: boolean, recoverable: boolean): boolean {
|
||||
if (!delivered && recoverable) {
|
||||
this.sessionsAwaitingDaemonRecovery.add(id)
|
||||
this.reconnectAfterWriteFailure()
|
||||
throw new PtyWriteUnavailableError(`Daemon PTY "${id}" is awaiting recovery`)
|
||||
}
|
||||
return delivered
|
||||
}
|
||||
|
||||
resize(id: string, cols: number, rows: number): void {
|
||||
|
||||
@@ -76,6 +76,7 @@ function createAdapter(
|
||||
write: vi.fn((id: string, data: string) => {
|
||||
writes.push({ id, data })
|
||||
}),
|
||||
writeWithSettlement: vi.fn(async () => true),
|
||||
resize: vi.fn(),
|
||||
setPtyBackgrounded: vi.fn(),
|
||||
getBufferSnapshot: vi.fn(async () => null),
|
||||
@@ -454,6 +455,18 @@ describe('DaemonPtyRouter', () => {
|
||||
expect(current.write).toHaveBeenCalledWith(fresh.id, 'new\n')
|
||||
})
|
||||
|
||||
it('routes settlement-aware writes to the owning daemon generation', async () => {
|
||||
const current = createAdapter('current')
|
||||
const legacy = createAdapter('legacy', ['legacy-session'])
|
||||
vi.mocked(legacy.writeWithSettlement).mockResolvedValue(false)
|
||||
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
|
||||
await router.discoverLegacySessions()
|
||||
|
||||
await expect(router.writeWithSettlement('legacy-session', 'pointer')).resolves.toBe(false)
|
||||
expect(legacy.writeWithSettlement).toHaveBeenCalledWith('legacy-session', 'pointer')
|
||||
expect(current.writeWithSettlement).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes background hints and authoritative snapshots to the session owner', async () => {
|
||||
const current = createAdapter('current')
|
||||
const legacy = createAdapter('legacy', ['legacy-session'])
|
||||
|
||||
@@ -89,8 +89,12 @@ export class DaemonPtyRouter implements IPtyProvider {
|
||||
return await this.ownerResolver.probe(id)
|
||||
}
|
||||
|
||||
write(id: string, data: string): void {
|
||||
this.adapterFor(id).write(id, data)
|
||||
write(id: string, data: string): boolean {
|
||||
return this.adapterFor(id).write(id, data)
|
||||
}
|
||||
|
||||
writeWithSettlement(id: string, data: string): Promise<boolean> {
|
||||
return this.adapterFor(id).writeWithSettlement(id, data)
|
||||
}
|
||||
|
||||
resize(id: string, cols: number, rows: number): void {
|
||||
|
||||
@@ -37,6 +37,7 @@ function createProvider(
|
||||
probePtyLiveness: vi.fn(async (id: string) => sessions.includes(id)),
|
||||
providesAgentSessionOwnerListings: vi.fn(() => authoritativeOwnerListings),
|
||||
write: vi.fn(),
|
||||
writeWithSettlement: vi.fn(async () => true),
|
||||
resize: vi.fn(),
|
||||
shutdown: vi.fn(async (id: string) => {
|
||||
const idx = sessions.indexOf(id)
|
||||
@@ -372,6 +373,20 @@ describe('DegradedDaemonPtyProvider', () => {
|
||||
expect(fallback.write).toHaveBeenCalledWith(fresh.id, 'new\n')
|
||||
})
|
||||
|
||||
it('preserves settlement through daemon and fallback routes', async () => {
|
||||
const current = createDaemonAdapter('daemon', ['daemon-session'])
|
||||
const fallback = createProvider('fallback')
|
||||
vi.mocked(current.writeWithSettlement).mockResolvedValue(false)
|
||||
const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback })
|
||||
await provider.discoverDaemonSessions()
|
||||
const fresh = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
await expect(provider.writeWithSettlement('daemon-session', 'old')).resolves.toBe(false)
|
||||
await expect(provider.writeWithSettlement(fresh.id, 'new')).resolves.toBe(true)
|
||||
expect(current.writeWithSettlement).toHaveBeenCalledWith('daemon-session', 'old')
|
||||
expect(fallback.writeWithSettlement).toHaveBeenCalledWith(fresh.id, 'new')
|
||||
})
|
||||
|
||||
it('routes later fresh PTYs to the daemon after spawn health recovers', async () => {
|
||||
const current = createDaemonAdapter('daemon')
|
||||
const fallback = createProvider('fallback')
|
||||
|
||||
@@ -108,8 +108,15 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
|
||||
this.sessionProviders.get(ptyId) ?? this.findProviderForExistingSession(ptyId)
|
||||
)?.providesAgentSessionOwnerListings?.(ptyId) === true
|
||||
|
||||
write(id: string, data: string): void {
|
||||
this.providerFor(id).write(id, data)
|
||||
write(id: string, data: string): boolean | void {
|
||||
return this.providerFor(id).write(id, data)
|
||||
}
|
||||
|
||||
async writeWithSettlement(id: string, data: string): Promise<boolean> {
|
||||
const provider = this.providerFor(id)
|
||||
return provider.writeWithSettlement
|
||||
? await provider.writeWithSettlement(id, data)
|
||||
: provider.write(id, data) !== false
|
||||
}
|
||||
|
||||
resize(id: string, cols: number, rows: number): void {
|
||||
|
||||
@@ -39,6 +39,7 @@ vi.mock('../pwsh', () => ({
|
||||
const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
|
||||
const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
|
||||
const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe'
|
||||
const CODEX_LAUNCH_PREFLIGHT = 'C:\\Program Files\\Orca\\orca.exe'
|
||||
vi.mock('../providers/windows-powershell-executable', () => ({
|
||||
resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') =>
|
||||
family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS,
|
||||
@@ -2457,7 +2458,8 @@ describe('createPtySubprocess', () => {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shellOverride: 'cmd.exe',
|
||||
terminalWindowsPowerShellImplementation: 'pwsh.exe'
|
||||
terminalWindowsPowerShellImplementation: 'pwsh.exe',
|
||||
env: { ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT }
|
||||
})
|
||||
} finally {
|
||||
if (platform) {
|
||||
@@ -2467,8 +2469,13 @@ describe('createPtySubprocess', () => {
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'cmd.exe',
|
||||
['/K', 'chcp 65001 > nul'],
|
||||
expect.any(Object)
|
||||
[
|
||||
'/K',
|
||||
'chcp 65001 > nul & if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE% agent hooks prepare-codex > nul 2>&1'
|
||||
],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE: '"' })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2517,7 +2524,8 @@ describe('createPtySubprocess', () => {
|
||||
rows: 24,
|
||||
cwd: 'C:\\repo\\orca',
|
||||
shellOverride: 'cmd.exe',
|
||||
command: `codex ${'x'.repeat(7000)}`
|
||||
command: `codex ${'x'.repeat(7000)}`,
|
||||
env: { ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT }
|
||||
})
|
||||
} finally {
|
||||
if (platform) {
|
||||
@@ -2527,7 +2535,10 @@ describe('createPtySubprocess', () => {
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'cmd.exe',
|
||||
['/K', 'chcp 65001 > nul'],
|
||||
[
|
||||
'/K',
|
||||
'chcp 65001 > nul & if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE% agent hooks prepare-codex > nul 2>&1'
|
||||
],
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(handle!.startupCommandDeliveredInShellArgs).toBeUndefined()
|
||||
@@ -2546,7 +2557,8 @@ describe('createPtySubprocess', () => {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: 'C:\\Users\\jin\\repo',
|
||||
shellOverride: 'C:\\PortableGit\\bin\\bash.exe'
|
||||
shellOverride: 'C:\\PortableGit\\bin\\bash.exe',
|
||||
env: { ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT }
|
||||
})
|
||||
} finally {
|
||||
if (platform) {
|
||||
@@ -2556,10 +2568,18 @@ describe('createPtySubprocess', () => {
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'C:\\PortableGit\\bin\\bash.exe',
|
||||
['-c', 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'],
|
||||
[
|
||||
'-c',
|
||||
expect.stringMatching(
|
||||
/^chcp\.com 65001 >\/dev\/null 2>&1; exec "\$BASH" --rcfile '.*shell-ready\/bash\/rcfile' -i$/
|
||||
)
|
||||
],
|
||||
expect.objectContaining({
|
||||
cwd: 'C:\\Users\\jin\\repo',
|
||||
env: expect.objectContaining({ CHERE_INVOKING: '1' })
|
||||
env: expect.objectContaining({
|
||||
CHERE_INVOKING: '1',
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
import { wrapShellSpawnForMacosTccAttribution } from '../providers/macos-tcc-login-shell'
|
||||
import { signalPosixPtyForegroundGroup } from '../pty/posix-pty-foreground-group'
|
||||
import { readPtsName } from '../pty/node-pty-pts-name'
|
||||
import { resolveWindowsShellLaunchArgs } from '../providers/windows-shell-args'
|
||||
import {
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV,
|
||||
resolveWindowsShellLaunchArgs
|
||||
} from '../providers/windows-shell-args'
|
||||
import {
|
||||
resolveEffectiveWindowsPowerShell,
|
||||
shouldProbeWindowsPowerShellAvailability,
|
||||
@@ -693,6 +696,13 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
}) ?? shellPath)
|
||||
: shellPath
|
||||
}
|
||||
if (
|
||||
pathWin32.basename(shellPath).toLowerCase() === 'cmd.exe' &&
|
||||
env.ORCA_CODEX_LAUNCH_PREFLIGHT
|
||||
) {
|
||||
// Why: node-pty backslash-escapes argv quotes; expand the quote inside cmd.exe instead.
|
||||
env[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV] = '"'
|
||||
}
|
||||
// Why: a bare `pwsh.exe` resolves to the Store App Execution Alias stub whose launch fails with ERROR_ACCESS_DENIED (5).
|
||||
windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({
|
||||
shellPath,
|
||||
@@ -714,7 +724,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
spawnCwd,
|
||||
getDefaultCwd(),
|
||||
resolvedWslContext,
|
||||
opts.command
|
||||
opts.command,
|
||||
env.ORCA_CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
shellArgs = resolved.shellArgs
|
||||
spawnCwd = resolved.effectiveCwd
|
||||
@@ -745,7 +756,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
{
|
||||
distro: codexHomeWslInfo.distro
|
||||
},
|
||||
opts.command
|
||||
opts.command,
|
||||
env.ORCA_CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
shellArgs = resolved.shellArgs
|
||||
spawnCwd = resolved.effectiveCwd
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { parse as parseJsonc, type ParseError } from 'jsonc-parser'
|
||||
import { applyEdits, modify, parse as parseJsonc, type ParseError } from 'jsonc-parser'
|
||||
import { isPlainObject, type HooksConfig } from '../agent-hooks/installer-utils'
|
||||
|
||||
/** Devin documents config.json as JSONC; stock JSON.parse rejects comments. */
|
||||
@@ -16,6 +16,60 @@ export function readDevinHooksConfig(configPath: string): HooksConfig | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** Original file text alongside its parsed form, so a write can edit the text in place. */
|
||||
export function readDevinHooksSource(
|
||||
configPath: string
|
||||
): { text: string | null; config: HooksConfig } | null {
|
||||
if (!existsSync(configPath)) {
|
||||
return { text: null, config: {} }
|
||||
}
|
||||
|
||||
let text: string
|
||||
try {
|
||||
text = readFileSync(configPath, 'utf-8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const config = parseDevinHooksConfigText(text, 'Devin config.json')
|
||||
return config === null ? null : { text, config }
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize by editing the original JSONC text one hook event at a time, so the user's
|
||||
* comments, key order, and formatting survive. A parse -> JSON.stringify round trip would
|
||||
* silently drop all of them.
|
||||
*/
|
||||
export function serializeDevinHooksConfig(
|
||||
originalText: string | null,
|
||||
nextConfig: HooksConfig
|
||||
): string {
|
||||
if (originalText === null) {
|
||||
return `${JSON.stringify(nextConfig, null, 2)}\n`
|
||||
}
|
||||
|
||||
const previous = parseJsonc(originalText) as HooksConfig | undefined
|
||||
const previousHooks = isPlainObject(previous?.hooks) ? (previous.hooks ?? {}) : {}
|
||||
const nextHooks = nextConfig.hooks ?? {}
|
||||
|
||||
let text = originalText
|
||||
// Why: touch only the events that actually changed, so comments attached to a user's
|
||||
// own untouched hook entries stay put.
|
||||
for (const eventName of new Set([...Object.keys(previousHooks), ...Object.keys(nextHooks)])) {
|
||||
const nextValue = nextHooks[eventName]
|
||||
if (JSON.stringify(previousHooks[eventName]) === JSON.stringify(nextValue)) {
|
||||
continue
|
||||
}
|
||||
text = applyEdits(
|
||||
text,
|
||||
// Why: `undefined` removes the key, which is how remove() drops an emptied event.
|
||||
modify(text, ['hooks', eventName], nextValue, {
|
||||
formattingOptions: { insertSpaces: true, tabSize: 2 }
|
||||
})
|
||||
)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
export function parseDevinHooksConfigText(
|
||||
text: string,
|
||||
diagnosticName: string
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { parse as parseJsonc } from 'jsonc-parser'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
@@ -104,7 +105,45 @@ describe('DevinHookService', () => {
|
||||
const status = new DevinHookService().install()
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
expect(JSON.parse(readFileSync(configPath, 'utf8')).hooks.UserPromptSubmit).toBeDefined()
|
||||
// Why: parse as JSONC, not JSON — asserting with JSON.parse would only pass once the
|
||||
// comment had been stripped, which is the regression this test exists to catch.
|
||||
expect(parseJsonc(readFileSync(configPath, 'utf8')).hooks.UserPromptSubmit).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps user comments and unrelated formatting when installing and removing', () => {
|
||||
const configPath = getDevinConfigPath()
|
||||
mkdirSync(dirname(configPath), { recursive: true })
|
||||
writeFileSync(
|
||||
configPath,
|
||||
`{
|
||||
// keep me: chosen deliberately
|
||||
"permissions": { "mode": "normal" },
|
||||
/* block comment */
|
||||
"hooks": {
|
||||
// the user's own hook
|
||||
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "mine.sh" }] }]
|
||||
}
|
||||
}
|
||||
`
|
||||
)
|
||||
|
||||
const service = new DevinHookService()
|
||||
service.install()
|
||||
|
||||
const installed = readFileSync(configPath, 'utf8')
|
||||
expect(installed).toContain('// keep me: chosen deliberately')
|
||||
expect(installed).toContain('/* block comment */')
|
||||
expect(installed).toContain("// the user's own hook")
|
||||
expect(installed).toContain('mine.sh')
|
||||
expect(parseJsonc(installed).permissions.mode).toBe('normal')
|
||||
expect(parseJsonc(installed).hooks.UserPromptSubmit).toBeDefined()
|
||||
|
||||
service.remove()
|
||||
|
||||
const removed = readFileSync(configPath, 'utf8')
|
||||
expect(removed).toContain('// keep me: chosen deliberately')
|
||||
expect(removed).toContain('mine.sh')
|
||||
expect(parseJsonc(removed).hooks.UserPromptSubmit).toBeUndefined()
|
||||
})
|
||||
|
||||
it('surfaces read_config_from overlap in status detail', () => {
|
||||
|
||||
@@ -32,7 +32,9 @@ import {
|
||||
mergeHookInstallDetail,
|
||||
parseDevinHooksConfigText,
|
||||
readConfigFromOrcaOverlapDetail,
|
||||
readDevinHooksConfig
|
||||
readDevinHooksConfig,
|
||||
readDevinHooksSource,
|
||||
serializeDevinHooksConfig
|
||||
} from './hook-config-json'
|
||||
|
||||
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
@@ -140,8 +142,8 @@ export class DevinHookService {
|
||||
install(): AgentHookInstallStatus {
|
||||
const configPath = getDevinConfigPath()
|
||||
const scriptPath = getDevinManagedScriptPath()
|
||||
const config = readDevinHooksConfig(configPath)
|
||||
if (!config) {
|
||||
const source = readDevinHooksSource(configPath)
|
||||
if (!source) {
|
||||
return {
|
||||
agent: 'devin',
|
||||
state: 'error',
|
||||
@@ -152,9 +154,15 @@ export class DevinHookService {
|
||||
}
|
||||
|
||||
const command = getDevinManagedCommand(scriptPath)
|
||||
const nextConfig = applyDevinManagedHooks(config, command, getDevinManagedScriptFileName())
|
||||
const nextConfig = applyDevinManagedHooks(
|
||||
source.config,
|
||||
command,
|
||||
getDevinManagedScriptFileName()
|
||||
)
|
||||
writeManagedScript(scriptPath, getManagedScript())
|
||||
writeHooksJson(configPath, nextConfig)
|
||||
writeHooksJson(configPath, nextConfig, {
|
||||
serialized: serializeDevinHooksConfig(source.text, nextConfig)
|
||||
})
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
@@ -187,7 +195,9 @@ export class DevinHookService {
|
||||
// Why: write script before settings so a mid-install failure never leaves settings.json referencing a missing script.
|
||||
// Why: SSH remotes use POSIX `.sh` hooks even when Orca runs on Windows; never derive remote script syntax from local OS.
|
||||
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
|
||||
await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig)
|
||||
await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig, {
|
||||
serialized: serializeDevinHooksConfig(body, nextConfig)
|
||||
})
|
||||
|
||||
return {
|
||||
agent: 'devin',
|
||||
@@ -209,8 +219,8 @@ export class DevinHookService {
|
||||
|
||||
remove(): AgentHookInstallStatus {
|
||||
const configPath = getDevinConfigPath()
|
||||
const config = readDevinHooksConfig(configPath)
|
||||
if (!config) {
|
||||
const source = readDevinHooksSource(configPath)
|
||||
if (!source) {
|
||||
return {
|
||||
agent: 'devin',
|
||||
state: 'error',
|
||||
@@ -220,11 +230,13 @@ export class DevinHookService {
|
||||
}
|
||||
}
|
||||
const { config: nextConfig, changed } = removeDevinManagedHooks(
|
||||
config,
|
||||
source.config,
|
||||
getDevinManagedScriptFileName()
|
||||
)
|
||||
if (changed) {
|
||||
writeHooksJson(configPath, nextConfig)
|
||||
writeHooksJson(configPath, nextConfig, {
|
||||
serialized: serializeDevinHooksConfig(source.text, nextConfig)
|
||||
})
|
||||
}
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import {
|
||||
getEphemeralVmRecipeResultConnection,
|
||||
type EphemeralVmRecipeResult
|
||||
} from '../shared/ephemeral-vm-recipes'
|
||||
import { upsertEphemeralVmRuntime } from '../shared/ephemeral-vm-runtime-store'
|
||||
import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes'
|
||||
import {
|
||||
upsertEphemeralVmRuntime,
|
||||
upsertEphemeralVmRuntimeRollbackRecovery
|
||||
} from '../shared/ephemeral-vm-runtime-store'
|
||||
import type { ProvisionEphemeralVmRuntimeArgs } from './ephemeral-vm-runtime-service'
|
||||
import {
|
||||
runEphemeralVmRecipeCleanup,
|
||||
@@ -17,15 +21,15 @@ type FailedStart = {
|
||||
export async function cleanupFailedEphemeralVmStart(
|
||||
args: ProvisionEphemeralVmRuntimeArgs,
|
||||
start: FailedStart
|
||||
): Promise<void> {
|
||||
): Promise<boolean> {
|
||||
const cleanupError = await getCleanupError(args, start)
|
||||
if (cleanupError === null) {
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
const now = args.now ?? Date.now()
|
||||
const connection = getEphemeralVmRecipeResultConnection(start.recipeResult)
|
||||
upsertEphemeralVmRuntime(args.userDataPath, {
|
||||
const recovery: EphemeralVmRuntimeRecord = {
|
||||
id: start.context.instanceId ?? start.context.recipeId,
|
||||
recipeId: args.recipe.id,
|
||||
recipe: args.recipe,
|
||||
@@ -42,7 +46,17 @@ export async function cleanupFailedEphemeralVmStart(
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
recipeResult: start.recipeResult
|
||||
})
|
||||
}
|
||||
try {
|
||||
upsertEphemeralVmRuntime(args.userDataPath, recovery)
|
||||
} catch (error) {
|
||||
if (!args.recipe.checkoutMode) {
|
||||
throw error
|
||||
}
|
||||
// Why: cleanup retry metadata must survive even when its feature companion is unreadable.
|
||||
upsertEphemeralVmRuntimeRollbackRecovery(args.userDataPath, recovery)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function getCleanupError(
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { Store } from './persistence'
|
||||
import type { Repo } from '../shared/repo-types'
|
||||
import { gitExecFileAsync } from './git/runner'
|
||||
import { resolveDefaultBaseRefWithLocalGit } from './git/repo'
|
||||
import { getLocalProjectGitExecOptions } from './project-runtime-git-options'
|
||||
import { resolveWorktreeCreateBase } from './worktree-create-base'
|
||||
import { resolveWorktreeAddBaseRef } from '../shared/worktree/base-ref'
|
||||
|
||||
const PROVISIONED_ROOT_SOURCE_REF_TIMEOUT_MS = 15_000
|
||||
|
||||
export async function resolveProvisionedRootSource(
|
||||
store: Store,
|
||||
repo: Repo,
|
||||
requestedRef?: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ ref: string; head: string; remoteUrl?: string } | null> {
|
||||
const gitOptions = {
|
||||
...getLocalProjectGitExecOptions(store, repo),
|
||||
...(signal ? { signal } : {})
|
||||
}
|
||||
const resolveHead = async (ref: string): Promise<string | null> => {
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`],
|
||||
{ ...gitOptions, timeout: PROVISIONED_ROOT_SOURCE_REF_TIMEOUT_MS }
|
||||
)
|
||||
return stdout.trim() || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const ref = await resolveWorktreeCreateBase({
|
||||
requestedBaseBranch: requestedRef?.trim() || undefined,
|
||||
repoWorktreeBaseRef: repo.worktreeBaseRef,
|
||||
resolveDefaultBaseRef: () => resolveDefaultBaseRefWithLocalGit(gitOptions),
|
||||
isBaseUsable: async (candidate) => Boolean(await resolveHead(candidate))
|
||||
})
|
||||
if (!ref) {
|
||||
return null
|
||||
}
|
||||
const head = await resolveHead(ref)
|
||||
if (!head) {
|
||||
return null
|
||||
}
|
||||
const remoteSource = await resolveRemoteSource(ref, resolveHead, gitOptions)
|
||||
return remoteSource ? { ...remoteSource, head } : { ref, head }
|
||||
}
|
||||
|
||||
async function resolveRemoteSource(
|
||||
ref: string,
|
||||
resolveHead: (ref: string) => Promise<string | null>,
|
||||
gitOptions: { cwd: string; wslDistro?: string; signal?: AbortSignal }
|
||||
): Promise<{ ref: string; remoteUrl: string } | null> {
|
||||
const qualified = await resolveWorktreeAddBaseRef(ref, async (candidate) =>
|
||||
Boolean(await resolveHead(candidate))
|
||||
)
|
||||
const prefix = 'refs/remotes/'
|
||||
if (!qualified.startsWith(prefix)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(['remote'], {
|
||||
...gitOptions,
|
||||
timeout: PROVISIONED_ROOT_SOURCE_REF_TIMEOUT_MS
|
||||
})
|
||||
const shortRef = qualified.slice(prefix.length)
|
||||
const remote = stdout
|
||||
.split(/\r?\n/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => shortRef.startsWith(`${entry}/`))
|
||||
.sort((left, right) => right.length - left.length)[0]
|
||||
const branch = remote ? shortRef.slice(remote.length + 1) : ''
|
||||
if (!remote || !branch) {
|
||||
return null
|
||||
}
|
||||
const { stdout: remoteUrl } = await gitExecFileAsync(['remote', 'get-url', remote], {
|
||||
...gitOptions,
|
||||
timeout: PROVISIONED_ROOT_SOURCE_REF_TIMEOUT_MS
|
||||
})
|
||||
return remoteUrl.trim() ? { ref: `refs/heads/${branch}`, remoteUrl: remoteUrl.trim() } : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
EphemeralVmRecipeConnection,
|
||||
EphemeralVmRecipeResult
|
||||
} from '../shared/ephemeral-vm-recipes'
|
||||
import { getProvisionedRootResumeIntegrityError } from './ephemeral-vm-resume-integrity'
|
||||
|
||||
type RecipeSshTarget = Extract<EphemeralVmRecipeConnection, { type: 'ssh' }>['target']
|
||||
|
||||
describe('getProvisionedRootResumeIntegrityError', () => {
|
||||
it('allows an SSH endpoint to rotate while preserving its ownership identity', () => {
|
||||
const previous = provisionedSshResult()
|
||||
const resumed = provisionedSshResult({ host: 'new-host', port: 2222, label: 'Resumed VM' })
|
||||
|
||||
expect(getProvisionedRootResumeIntegrityError(previous, resumed)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['config host', { configHost: 'other-alias' }],
|
||||
['username', { username: 'other-user' }],
|
||||
['identity file', { identityFile: '/keys/other' }],
|
||||
['identity agent', { identityAgent: '/agents/other' }],
|
||||
['identities-only setting', { identitiesOnly: true }],
|
||||
['proxy command', { proxyCommand: 'ssh proxy' }],
|
||||
['jump host', { jumpHost: 'bastion' }]
|
||||
])('rejects changed SSH %s ownership', (_label, target) => {
|
||||
expect(
|
||||
getProvisionedRootResumeIntegrityError(provisionedSshResult(), provisionedSshResult(target))
|
||||
).toContain('SSH ownership changed')
|
||||
})
|
||||
|
||||
it('rejects a resume that changes the connection type', () => {
|
||||
const resumed: EphemeralVmRecipeResult = {
|
||||
schemaVersion: 2,
|
||||
checkoutMode: 'provisioned-root',
|
||||
connection: {
|
||||
type: 'orca-server',
|
||||
pairingCode: 'pairing-code',
|
||||
projectRoot: '/workspace/orca'
|
||||
}
|
||||
}
|
||||
|
||||
expect(getProvisionedRootResumeIntegrityError(provisionedSshResult(), resumed)).toContain(
|
||||
'connection type changed'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function provisionedSshResult(target: Partial<RecipeSshTarget> = {}): EphemeralVmRecipeResult {
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
checkoutMode: 'provisioned-root',
|
||||
connection: {
|
||||
type: 'ssh',
|
||||
projectRoot: '/workspace/orca',
|
||||
target: {
|
||||
label: 'VM',
|
||||
host: 'host',
|
||||
port: 22,
|
||||
username: 'orca',
|
||||
identityFile: '/keys/orca',
|
||||
identityAgent: '/agents/orca',
|
||||
...target
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,58 @@
|
||||
import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path'
|
||||
import {
|
||||
getEphemeralVmRecipeResultCheckoutMode,
|
||||
getEphemeralVmRecipeResultConnection,
|
||||
getEphemeralVmRecipeResultProjectRoot,
|
||||
type EphemeralVmRecipeResult
|
||||
} from '../shared/ephemeral-vm-recipes'
|
||||
|
||||
export function provisionedRootChangedDuringResume(
|
||||
export function getProvisionedRootResumeIntegrityError(
|
||||
previous: EphemeralVmRecipeResult,
|
||||
resumed: EphemeralVmRecipeResult
|
||||
): boolean {
|
||||
): string | null {
|
||||
if (getEphemeralVmRecipeResultCheckoutMode(previous) !== 'provisioned-root') {
|
||||
return false
|
||||
return null
|
||||
}
|
||||
return (
|
||||
if (
|
||||
normalizeRuntimePathForComparison(getEphemeralVmRecipeResultProjectRoot(previous)) !==
|
||||
normalizeRuntimePathForComparison(getEphemeralVmRecipeResultProjectRoot(resumed))
|
||||
)
|
||||
) {
|
||||
return 'The provisioned workspace root changed while the runtime was suspended.'
|
||||
}
|
||||
const previousConnection = getEphemeralVmRecipeResultConnection(previous)
|
||||
const resumedConnection = getEphemeralVmRecipeResultConnection(resumed)
|
||||
if (previousConnection.type !== resumedConnection.type) {
|
||||
return 'The provisioned workspace connection type changed while the runtime was suspended.'
|
||||
}
|
||||
if (
|
||||
previousConnection.type === 'ssh' &&
|
||||
resumedConnection.type === 'ssh' &&
|
||||
sshOwnershipChanged(previousConnection.target, resumedConnection.target)
|
||||
) {
|
||||
return 'The provisioned workspace SSH ownership changed while the runtime was suspended.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const SSH_OWNERSHIP_FIELDS = [
|
||||
'configHost',
|
||||
'username',
|
||||
'identityFile',
|
||||
'identityAgent',
|
||||
'identitiesOnly',
|
||||
'proxyCommand',
|
||||
'jumpHost'
|
||||
] as const
|
||||
|
||||
function sshOwnershipChanged(
|
||||
previous: Extract<
|
||||
ReturnType<typeof getEphemeralVmRecipeResultConnection>,
|
||||
{ type: 'ssh' }
|
||||
>['target'],
|
||||
resumed: Extract<
|
||||
ReturnType<typeof getEphemeralVmRecipeResultConnection>,
|
||||
{ type: 'ssh' }
|
||||
>['target']
|
||||
): boolean {
|
||||
return SSH_OWNERSHIP_FIELDS.some((field) => previous[field] !== resumed[field])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { CleanupEphemeralVmRuntimeResult } from './ephemeral-vm-runtime-service'
|
||||
|
||||
type CleanupInFlight = {
|
||||
controller: AbortController
|
||||
promise: Promise<CleanupEphemeralVmRuntimeResult>
|
||||
}
|
||||
|
||||
const cleanupInFlight = new Map<string, CleanupInFlight>()
|
||||
|
||||
export function runControlledEphemeralVmRuntimeCleanup(args: {
|
||||
userDataPath: string
|
||||
runtimeId: string
|
||||
signal?: AbortSignal
|
||||
run: (signal: AbortSignal) => Promise<CleanupEphemeralVmRuntimeResult>
|
||||
}): Promise<CleanupEphemeralVmRuntimeResult> {
|
||||
const key = cleanupKey(args)
|
||||
const existing = cleanupInFlight.get(key)
|
||||
if (existing) {
|
||||
return existing.promise
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const forwardAbort = (): void => controller.abort()
|
||||
if (args.signal?.aborted) {
|
||||
forwardAbort()
|
||||
} else {
|
||||
args.signal?.addEventListener('abort', forwardAbort, { once: true })
|
||||
}
|
||||
const promise = args.run(controller.signal)
|
||||
const inFlight = { controller, promise }
|
||||
cleanupInFlight.set(key, inFlight)
|
||||
const forget = (): void => {
|
||||
args.signal?.removeEventListener('abort', forwardAbort)
|
||||
if (cleanupInFlight.get(key) === inFlight) {
|
||||
cleanupInFlight.delete(key)
|
||||
}
|
||||
}
|
||||
void promise.then(forget, forget)
|
||||
return promise
|
||||
}
|
||||
|
||||
export function stopEphemeralVmRuntimeCleanup(args: {
|
||||
userDataPath: string
|
||||
runtimeId: string
|
||||
}): Promise<CleanupEphemeralVmRuntimeResult> | null {
|
||||
const cleanup = cleanupInFlight.get(cleanupKey(args))
|
||||
if (!cleanup) {
|
||||
return null
|
||||
}
|
||||
cleanup.controller.abort()
|
||||
return cleanup.promise
|
||||
}
|
||||
|
||||
function cleanupKey(args: { userDataPath: string; runtimeId: string }): string {
|
||||
return `${args.userDataPath}\0${args.runtimeId}`
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { assertEphemeralVmRuntimeCheckoutModeCanPersist } from '../shared/ephemeral-vm-runtime-feature-store'
|
||||
import { getEphemeralVmRecipeResultConnection } from '../shared/ephemeral-vm-recipes'
|
||||
import {
|
||||
listEphemeralVmRuntimes,
|
||||
removeEphemeralVmRuntime,
|
||||
upsertEphemeralVmRuntime
|
||||
} from '../shared/ephemeral-vm-runtime-store'
|
||||
import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes'
|
||||
import type {
|
||||
ProvisionEphemeralVmRuntimeArgs,
|
||||
ProvisionEphemeralVmRuntimeResult
|
||||
} from './ephemeral-vm-runtime-service'
|
||||
import { cleanupFailedEphemeralVmStart } from './ephemeral-vm-failed-start-cleanup'
|
||||
|
||||
export type EphemeralVmCompatibilityPersistence = {
|
||||
instanceId: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export function prepareEphemeralVmCompatibilityPersistence(
|
||||
args: ProvisionEphemeralVmRuntimeArgs
|
||||
): EphemeralVmCompatibilityPersistence | null {
|
||||
if (!args.recipe.checkoutMode) {
|
||||
return null
|
||||
}
|
||||
listEphemeralVmRuntimes(args.userDataPath)
|
||||
const compatibility = {
|
||||
instanceId: `orca-${randomUUID()}`,
|
||||
createdAt: args.now ?? Date.now()
|
||||
}
|
||||
assertEphemeralVmRuntimeCheckoutModeCanPersist(args.userDataPath, {
|
||||
id: compatibility.instanceId,
|
||||
recipeId: args.recipe.id,
|
||||
createdAt: compatibility.createdAt,
|
||||
checkoutMode: args.recipe.checkoutMode
|
||||
})
|
||||
return compatibility
|
||||
}
|
||||
|
||||
export async function persistProvisionedEphemeralVmRuntime(
|
||||
args: ProvisionEphemeralVmRuntimeArgs,
|
||||
start: Extract<ProvisionEphemeralVmRuntimeResult, { ok: true }>['start'],
|
||||
compatibility: EphemeralVmCompatibilityPersistence | null
|
||||
): Promise<EphemeralVmRuntimeRecord> {
|
||||
const now = compatibility?.createdAt ?? args.now ?? Date.now()
|
||||
const connection = getEphemeralVmRecipeResultConnection(start.result)
|
||||
try {
|
||||
return upsertEphemeralVmRuntime(args.userDataPath, {
|
||||
id: start.context.instanceId ?? start.context.recipeId,
|
||||
recipeId: args.recipe.id,
|
||||
recipe: args.recipe,
|
||||
...(args.repoId ? { repoId: args.repoId } : {}),
|
||||
...(args.projectId ? { projectId: args.projectId } : {}),
|
||||
...(args.workspaceId ? { workspaceId: args.workspaceId } : {}),
|
||||
...(args.workspaceName ? { workspaceName: args.workspaceName } : {}),
|
||||
status: 'running',
|
||||
connectionMode: connection.type,
|
||||
cleanupStatus: args.recipe.destroyDisabled ? 'disabled' : 'not_started',
|
||||
...(args.recipe.destroyDisabled ? { cleanupDisabled: true } : {}),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
recipeResult: start.result
|
||||
})
|
||||
} catch (error) {
|
||||
if (compatibility) {
|
||||
const cleaned = await cleanupFailedEphemeralVmStart(args, {
|
||||
context: start.context,
|
||||
recipeResult: start.result
|
||||
})
|
||||
if (
|
||||
cleaned &&
|
||||
listEphemeralVmRuntimes(args.userDataPath).some(
|
||||
(runtime) => runtime.id === compatibility.instanceId
|
||||
)
|
||||
) {
|
||||
removeEphemeralVmRuntime(args.userDataPath, compatibility.instanceId)
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,22 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../shared/pairing'
|
||||
import {
|
||||
getEphemeralVmRuntimeStorePath,
|
||||
listEphemeralVmRuntimes,
|
||||
upsertEphemeralVmRuntime
|
||||
} from '../shared/ephemeral-vm-runtime-store'
|
||||
import {
|
||||
getEphemeralVmRuntimeFeatureStorePath,
|
||||
MAX_EPHEMERAL_VM_RUNTIME_FEATURE_STORE_FILE_BYTES
|
||||
} from '../shared/ephemeral-vm-runtime-feature-store'
|
||||
import {
|
||||
cleanupEphemeralVmRuntime,
|
||||
provisionEphemeralVmRuntime,
|
||||
resumeEphemeralVmRuntime
|
||||
resumeEphemeralVmRuntime,
|
||||
stopEphemeralVmRuntimeCleanup
|
||||
} from './ephemeral-vm-runtime-service'
|
||||
import type { OrcaVmRecipe } from '../shared/orca-yaml-hook-types'
|
||||
|
||||
@@ -154,6 +160,63 @@ describe('ephemeral VM runtime service', () => {
|
||||
expect(readFileSync(join(repoPath, 'cleanup-count.txt'), 'utf8')).toBe('x')
|
||||
})
|
||||
|
||||
it('stops a hung destroy and starts a fresh retry', async () => {
|
||||
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
|
||||
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
|
||||
const cleanupPath = join(repoPath, 'cleanup.js')
|
||||
const countPath = join(repoPath, 'cleanup-count.txt')
|
||||
writeFileSync(
|
||||
cleanupPath,
|
||||
`require('fs').appendFileSync(${JSON.stringify(countPath)}, 'x'); setInterval(() => {}, 1000)`
|
||||
)
|
||||
const recipe: OrcaVmRecipe = {
|
||||
id: 'cloud-sandbox',
|
||||
name: 'Cloud Sandbox',
|
||||
create: 'unused',
|
||||
destroy: nodeCommand(cleanupPath)
|
||||
}
|
||||
upsertEphemeralVmRuntime(userDataPath, {
|
||||
id: 'runtime-1',
|
||||
recipeId: recipe.id,
|
||||
recipe,
|
||||
status: 'running',
|
||||
cleanupStatus: 'not_started',
|
||||
createdAt: 1_000,
|
||||
updatedAt: 1_000,
|
||||
recipeResult: {
|
||||
schemaVersion: 1,
|
||||
connection: {
|
||||
type: 'ssh',
|
||||
projectRoot: '/workspace/repo',
|
||||
target: { label: 'VM', host: 'host', port: 22, username: 'orca' }
|
||||
}
|
||||
}
|
||||
})
|
||||
const cleanupArgs = {
|
||||
userDataPath,
|
||||
repoPath,
|
||||
recipe,
|
||||
runtimeId: 'runtime-1'
|
||||
}
|
||||
|
||||
const cleanup = cleanupEphemeralVmRuntime(cleanupArgs)
|
||||
await vi.waitFor(() => expect(readFileSync(countPath, 'utf8')).toBe('x'))
|
||||
const stopping = stopEphemeralVmRuntimeCleanup({ userDataPath, runtimeId: 'runtime-1' })
|
||||
expect(stopping).not.toBeNull()
|
||||
await expect(stopping).resolves.toMatchObject({
|
||||
ok: false,
|
||||
runtime: { status: 'cleanup_failed', cleanupStatus: 'failed' },
|
||||
error: 'Cleanup stopped by user.'
|
||||
})
|
||||
await expect(cleanup).resolves.toMatchObject({ ok: false })
|
||||
writeFileSync(cleanupPath, `require('fs').appendFileSync(${JSON.stringify(countPath)}, 'x')`)
|
||||
await expect(cleanupEphemeralVmRuntime(cleanupArgs)).resolves.toMatchObject({
|
||||
ok: true,
|
||||
runtime: { status: 'cleaned', cleanupStatus: 'succeeded' }
|
||||
})
|
||||
expect(readFileSync(countPath, 'utf8')).toBe('xx')
|
||||
})
|
||||
|
||||
it('does not persist a runtime when recipe output cannot be parsed', async () => {
|
||||
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
|
||||
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
|
||||
@@ -179,6 +242,151 @@ describe('ephemeral VM runtime service', () => {
|
||||
expect(listEphemeralVmRuntimes(userDataPath)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects an unwritable feature store before a checkout-mode recipe creates resources', async () => {
|
||||
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
|
||||
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
|
||||
const startPath = join(repoPath, 'start.js')
|
||||
const markerPath = join(repoPath, 'create-ran.txt')
|
||||
writeFileSync(startPath, `require('fs').writeFileSync(${JSON.stringify(markerPath)}, 'yes')`)
|
||||
const featurePath = getEphemeralVmRuntimeFeatureStorePath(userDataPath)
|
||||
writeFileSync(featurePath, '{}')
|
||||
truncateSync(featurePath, MAX_EPHEMERAL_VM_RUNTIME_FEATURE_STORE_FILE_BYTES + 1)
|
||||
|
||||
await expect(
|
||||
provisionEphemeralVmRuntime({
|
||||
userDataPath,
|
||||
repoPath,
|
||||
recipe: {
|
||||
id: 'cloud-sandbox',
|
||||
name: 'Cloud Sandbox',
|
||||
checkoutMode: 'provisioned-root',
|
||||
create: nodeCommand(startPath),
|
||||
destroyDisabled: true
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('Could not preserve ephemeral VM runtime compatibility metadata')
|
||||
expect(existsSync(markerPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an unreadable lifecycle store before a checkout-mode recipe creates resources', async () => {
|
||||
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
|
||||
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
|
||||
const startPath = join(repoPath, 'start.js')
|
||||
const markerPath = join(repoPath, 'create-ran.txt')
|
||||
writeFileSync(startPath, `require('fs').writeFileSync(${JSON.stringify(markerPath)}, 'yes')`)
|
||||
writeFileSync(getEphemeralVmRuntimeStorePath(userDataPath), '{')
|
||||
|
||||
await expect(
|
||||
provisionEphemeralVmRuntime({
|
||||
userDataPath,
|
||||
repoPath,
|
||||
recipe: {
|
||||
id: 'cloud-sandbox',
|
||||
name: 'Cloud Sandbox',
|
||||
checkoutMode: 'provisioned-root',
|
||||
create: nodeCommand(startPath),
|
||||
destroyDisabled: true
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('file is invalid')
|
||||
expect(existsSync(markerPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('destroys a checkout-mode resource when compatibility persistence fails after create', async () => {
|
||||
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
|
||||
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
|
||||
const startPath = join(repoPath, 'start.js')
|
||||
const cleanupPath = join(repoPath, 'cleanup.js')
|
||||
const cleanupMarkerPath = join(repoPath, 'cleanup-ran.txt')
|
||||
const featurePath = getEphemeralVmRuntimeFeatureStorePath(userDataPath)
|
||||
writeFileSync(
|
||||
startPath,
|
||||
[
|
||||
`require('fs').writeFileSync(${JSON.stringify(featurePath)}, '{')`,
|
||||
'console.log(JSON.stringify({',
|
||||
' schemaVersion: 2,',
|
||||
' checkoutMode: "provisioned-root",',
|
||||
' connection: {',
|
||||
' type: "ssh",',
|
||||
' projectRoot: "/workspace/repo",',
|
||||
' target: { label: "VM", host: "host", port: 22, username: "orca" }',
|
||||
' }',
|
||||
'}))'
|
||||
].join('\n')
|
||||
)
|
||||
writeFileSync(
|
||||
cleanupPath,
|
||||
`require('fs').writeFileSync(${JSON.stringify(cleanupMarkerPath)}, 'yes')`
|
||||
)
|
||||
|
||||
await expect(
|
||||
provisionEphemeralVmRuntime({
|
||||
userDataPath,
|
||||
repoPath,
|
||||
recipe: {
|
||||
id: 'cloud-sandbox',
|
||||
name: 'Cloud Sandbox',
|
||||
checkoutMode: 'provisioned-root',
|
||||
create: nodeCommand(startPath),
|
||||
destroy: nodeCommand(cleanupPath)
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('Could not preserve ephemeral VM runtime compatibility metadata')
|
||||
expect(readFileSync(cleanupMarkerPath, 'utf8')).toBe('yes')
|
||||
expect(listEphemeralVmRuntimes(userDataPath)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps rollback-readable cleanup recovery when post-create destroy also fails', async () => {
|
||||
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
|
||||
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
|
||||
const startPath = join(repoPath, 'start.js')
|
||||
const cleanupPath = join(repoPath, 'cleanup.js')
|
||||
const featurePath = getEphemeralVmRuntimeFeatureStorePath(userDataPath)
|
||||
writeFileSync(
|
||||
startPath,
|
||||
[
|
||||
`require('fs').writeFileSync(${JSON.stringify(featurePath)}, '{')`,
|
||||
'console.log(JSON.stringify({',
|
||||
' schemaVersion: 2,',
|
||||
' checkoutMode: "provisioned-root",',
|
||||
' connection: {',
|
||||
' type: "ssh",',
|
||||
' projectRoot: "/workspace/repo",',
|
||||
' target: { label: "VM", host: "host", port: 22, username: "orca" }',
|
||||
' },',
|
||||
' userData: { providerResourceId: "paid-vm" }',
|
||||
'}))'
|
||||
].join('\n')
|
||||
)
|
||||
writeFileSync(cleanupPath, 'process.exit(1)')
|
||||
|
||||
await expect(
|
||||
provisionEphemeralVmRuntime({
|
||||
userDataPath,
|
||||
repoPath,
|
||||
recipe: {
|
||||
id: 'cloud-sandbox',
|
||||
name: 'Cloud Sandbox',
|
||||
checkoutMode: 'provisioned-root',
|
||||
create: nodeCommand(startPath),
|
||||
destroy: nodeCommand(cleanupPath)
|
||||
},
|
||||
now: 1_000
|
||||
})
|
||||
).rejects.toThrow('Could not preserve ephemeral VM runtime compatibility metadata')
|
||||
expect(listEphemeralVmRuntimes(userDataPath)).toEqual([
|
||||
expect.objectContaining({
|
||||
status: 'cleanup_failed',
|
||||
cleanupStatus: 'failed',
|
||||
recipe: expect.not.objectContaining({ checkoutMode: expect.anything() }),
|
||||
recipeResult: expect.objectContaining({
|
||||
schemaVersion: 1,
|
||||
userData: { providerResourceId: 'paid-vm' }
|
||||
})
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('destroys a provisioned resource when its checkout handshake is incompatible', async () => {
|
||||
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
|
||||
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
|
||||
@@ -366,4 +574,69 @@ describe('ephemeral VM runtime service', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects provisioned-root connection-mode drift during resume', async () => {
|
||||
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
|
||||
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
|
||||
const resumePath = join(repoPath, 'resume.js')
|
||||
writeFileSync(
|
||||
resumePath,
|
||||
`console.log(${JSON.stringify(
|
||||
JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
checkoutMode: 'provisioned-root',
|
||||
connection: {
|
||||
type: 'orca-server',
|
||||
pairingCode: makePairingCode(),
|
||||
projectRoot: '/workspace/original'
|
||||
}
|
||||
})
|
||||
)})`
|
||||
)
|
||||
const recipe: OrcaVmRecipe = {
|
||||
id: 'cloud-sandbox',
|
||||
name: 'Cloud Sandbox',
|
||||
checkoutMode: 'provisioned-root',
|
||||
create: 'unused',
|
||||
resume: nodeCommand(resumePath),
|
||||
destroyDisabled: true
|
||||
}
|
||||
upsertEphemeralVmRuntime(userDataPath, {
|
||||
id: 'runtime-1',
|
||||
recipeId: recipe.id,
|
||||
recipe,
|
||||
status: 'suspended',
|
||||
connectionMode: 'ssh',
|
||||
cleanupStatus: 'disabled',
|
||||
cleanupDisabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
recipeResult: {
|
||||
schemaVersion: 2,
|
||||
checkoutMode: 'provisioned-root',
|
||||
connection: {
|
||||
type: 'ssh',
|
||||
projectRoot: '/workspace/original',
|
||||
target: { label: 'VM', host: 'host', port: 22, username: 'orca' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const resumed = await resumeEphemeralVmRuntime({
|
||||
userDataPath,
|
||||
repoPath,
|
||||
recipe,
|
||||
runtimeId: 'runtime-1'
|
||||
})
|
||||
|
||||
expect(resumed).toMatchObject({
|
||||
ok: false,
|
||||
error: 'The provisioned workspace connection type changed while the runtime was suspended.',
|
||||
runtime: {
|
||||
status: 'resume_failed',
|
||||
connectionMode: 'ssh',
|
||||
recipeResult: { connection: { type: 'ssh' } }
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { OrcaVmRecipe } from '../shared/orca-yaml-hook-types'
|
||||
import {
|
||||
listEphemeralVmRuntimes,
|
||||
updateEphemeralVmRuntimeStatus,
|
||||
upsertEphemeralVmRuntime
|
||||
updateEphemeralVmRuntimeStatus
|
||||
} from '../shared/ephemeral-vm-runtime-store'
|
||||
import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes'
|
||||
import { getEphemeralVmRecipeResultConnection } from '../shared/ephemeral-vm-recipes'
|
||||
import {
|
||||
runEphemeralVmRecipeCleanup,
|
||||
runEphemeralVmRecipeResume,
|
||||
@@ -16,7 +14,14 @@ import {
|
||||
type EphemeralVmRecipeStartSuccess
|
||||
} from './ephemeral-vm-recipe-runner'
|
||||
import { cleanupFailedEphemeralVmStart } from './ephemeral-vm-failed-start-cleanup'
|
||||
import { provisionedRootChangedDuringResume } from './ephemeral-vm-resume-integrity'
|
||||
import {
|
||||
persistProvisionedEphemeralVmRuntime,
|
||||
prepareEphemeralVmCompatibilityPersistence
|
||||
} from './ephemeral-vm-runtime-provisioning-persistence'
|
||||
import { getProvisionedRootResumeIntegrityError } from './ephemeral-vm-resume-integrity'
|
||||
import { runControlledEphemeralVmRuntimeCleanup } from './ephemeral-vm-runtime-cleanup-control'
|
||||
|
||||
export { stopEphemeralVmRuntimeCleanup } from './ephemeral-vm-runtime-cleanup-control'
|
||||
|
||||
export type ProvisionEphemeralVmRuntimeArgs = {
|
||||
userDataPath: string
|
||||
@@ -29,6 +34,7 @@ export type ProvisionEphemeralVmRuntimeArgs = {
|
||||
repoUrl?: string
|
||||
branch?: string
|
||||
ref?: string
|
||||
expectedRefHead?: string
|
||||
orcaVersion?: string
|
||||
now?: number
|
||||
signal?: AbortSignal
|
||||
@@ -94,11 +100,10 @@ export type ResumeEphemeralVmRuntimeResult =
|
||||
error: string
|
||||
}
|
||||
|
||||
const cleanupInFlight = new Map<string, Promise<CleanupEphemeralVmRuntimeResult>>()
|
||||
|
||||
export async function provisionEphemeralVmRuntime(
|
||||
args: ProvisionEphemeralVmRuntimeArgs
|
||||
): Promise<ProvisionEphemeralVmRuntimeResult> {
|
||||
const compatibility = prepareEphemeralVmCompatibilityPersistence(args)
|
||||
const start = await runEphemeralVmRecipeStart({
|
||||
repoPath: args.repoPath,
|
||||
recipe: args.recipe,
|
||||
@@ -109,7 +114,9 @@ export async function provisionEphemeralVmRuntime(
|
||||
repoUrl: args.repoUrl,
|
||||
branch: args.branch,
|
||||
ref: args.ref,
|
||||
orcaVersion: args.orcaVersion
|
||||
expectedRefHead: args.expectedRefHead,
|
||||
orcaVersion: args.orcaVersion,
|
||||
...(compatibility ? { instanceId: compatibility.instanceId } : {})
|
||||
},
|
||||
signal: args.signal,
|
||||
onStdout: args.onStdout,
|
||||
@@ -125,24 +132,7 @@ export async function provisionEphemeralVmRuntime(
|
||||
return { ok: false, start }
|
||||
}
|
||||
|
||||
const now = args.now ?? Date.now()
|
||||
const connection = getEphemeralVmRecipeResultConnection(start.result)
|
||||
const runtime = upsertEphemeralVmRuntime(args.userDataPath, {
|
||||
id: start.context.instanceId ?? start.context.recipeId,
|
||||
recipeId: args.recipe.id,
|
||||
recipe: args.recipe,
|
||||
...(args.repoId ? { repoId: args.repoId } : {}),
|
||||
...(args.projectId ? { projectId: args.projectId } : {}),
|
||||
...(args.workspaceId ? { workspaceId: args.workspaceId } : {}),
|
||||
...(args.workspaceName ? { workspaceName: args.workspaceName } : {}),
|
||||
status: 'running',
|
||||
connectionMode: connection.type,
|
||||
cleanupStatus: args.recipe.destroyDisabled ? 'disabled' : 'not_started',
|
||||
...(args.recipe.destroyDisabled ? { cleanupDisabled: true } : {}),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
recipeResult: start.result
|
||||
})
|
||||
const runtime = await persistProvisionedEphemeralVmRuntime(args, start, compatibility)
|
||||
|
||||
return { ok: true, start, runtime }
|
||||
}
|
||||
@@ -150,21 +140,12 @@ export async function provisionEphemeralVmRuntime(
|
||||
export function cleanupEphemeralVmRuntime(
|
||||
args: CleanupEphemeralVmRuntimeArgs
|
||||
): Promise<CleanupEphemeralVmRuntimeResult> {
|
||||
const key = `${args.userDataPath}\0${args.runtimeId}`
|
||||
const existing = cleanupInFlight.get(key)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const cleanup = cleanupEphemeralVmRuntimeOnce(args)
|
||||
cleanupInFlight.set(key, cleanup)
|
||||
const forget = (): void => {
|
||||
if (cleanupInFlight.get(key) === cleanup) {
|
||||
cleanupInFlight.delete(key)
|
||||
}
|
||||
}
|
||||
void cleanup.then(forget, forget)
|
||||
return cleanup
|
||||
return runControlledEphemeralVmRuntimeCleanup({
|
||||
userDataPath: args.userDataPath,
|
||||
runtimeId: args.runtimeId,
|
||||
signal: args.signal,
|
||||
run: (signal) => cleanupEphemeralVmRuntimeOnce({ ...args, signal })
|
||||
})
|
||||
}
|
||||
|
||||
async function cleanupEphemeralVmRuntimeOnce(
|
||||
@@ -282,13 +263,15 @@ export async function resumeEphemeralVmRuntime(
|
||||
return { ok: false, runtime: failed, error: resume.error }
|
||||
}
|
||||
|
||||
if (!resume.skipped && provisionedRootChangedDuringResume(existing.recipeResult, resume.result)) {
|
||||
const error = 'The provisioned workspace root changed while the runtime was suspended.'
|
||||
const resumeIntegrityError = resume.skipped
|
||||
? null
|
||||
: getProvisionedRootResumeIntegrityError(existing.recipeResult, resume.result)
|
||||
if (resumeIntegrityError) {
|
||||
const failed = updateEphemeralVmRuntimeStatus(args.userDataPath, existing.id, {
|
||||
status: 'resume_failed',
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
return { ok: false, runtime: failed, error }
|
||||
return { ok: false, runtime: failed, error: resumeIntegrityError }
|
||||
}
|
||||
|
||||
const runtime = updateEphemeralVmRuntimeStatus(args.userDataPath, existing.id, {
|
||||
|
||||
@@ -42,11 +42,20 @@ import {
|
||||
parseWorktreeList,
|
||||
removeWorktree,
|
||||
WORKTREE_ADD_TIMEOUT_MS,
|
||||
WORKTREE_ADD_TIMEOUT_MAX_MS,
|
||||
resolveWorktreeAddTimeoutMs,
|
||||
WORKTREE_LIST_TIMEOUT_MS
|
||||
} from './worktree'
|
||||
|
||||
beforeEach(() => {
|
||||
clearGitCapabilityStateForTests()
|
||||
// Why: addWorktree reads the override at call time, so a developer's ambient value must not leak in.
|
||||
// `undefined` deletes the key, matching production's unset case rather than an empty string.
|
||||
vi.stubEnv('ORCA_WORKTREE_ADD_TIMEOUT_MS', undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('listWorktrees in-flight sharing', () => {
|
||||
@@ -814,6 +823,20 @@ describe('addWorktree', () => {
|
||||
expect(WORKTREE_ADD_TIMEOUT_MS).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('raises the worktree add timeout from ORCA_WORKTREE_ADD_TIMEOUT_MS (#12696)', async () => {
|
||||
vi.stubEnv('ORCA_WORKTREE_ADD_TIMEOUT_MS', '600000')
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
||||
|
||||
await addWorktree('/repo', '/repo-feature', 'feature/test', 'feature/test', false, false, {
|
||||
checkoutExistingBranch: true
|
||||
})
|
||||
|
||||
const worktreeAddCall = gitExecFileAsyncMock.mock.calls.find(
|
||||
([argv]) => Array.isArray(argv) && argv[0] === 'worktree' && argv[1] === 'add'
|
||||
)
|
||||
expect(worktreeAddCall?.[1]).toMatchObject({ timeout: 600_000 })
|
||||
})
|
||||
|
||||
it('does not write branch base config when no base branch is provided', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'true\n' }) // push.autoSetupRemote already set
|
||||
@@ -1875,3 +1898,100 @@ describe('removeWorktree', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveWorktreeAddTimeoutMs', () => {
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
// Why: pin the literals so a future edit to either bound has to be deliberate.
|
||||
it('bounds the override to [180s, 30min]', () => {
|
||||
expect(WORKTREE_ADD_TIMEOUT_MS).toBe(180_000)
|
||||
expect(WORKTREE_ADD_TIMEOUT_MAX_MS).toBe(1_800_000)
|
||||
})
|
||||
|
||||
it('falls back to the default when the override is unset, blank, or unparseable', () => {
|
||||
expect(resolveWorktreeAddTimeoutMs({})).toBe(WORKTREE_ADD_TIMEOUT_MS)
|
||||
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: ' ' })).toBe(
|
||||
WORKTREE_ADD_TIMEOUT_MS
|
||||
)
|
||||
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: 'nope' })).toBe(
|
||||
WORKTREE_ADD_TIMEOUT_MS
|
||||
)
|
||||
})
|
||||
|
||||
it('raises the timeout up to the closed maximum', () => {
|
||||
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '300000' })).toBe(300_000)
|
||||
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '300000.9' })).toBe(300_000)
|
||||
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '999999999' })).toBe(
|
||||
1_800_000
|
||||
)
|
||||
})
|
||||
|
||||
// Why: `=Infinity` is the natural way to say "stop killing my checkout"; collapsing it to the
|
||||
// default would hand the operator back the exact failure they set the variable to escape.
|
||||
it('treats an infinite override as the maximum, not the default', () => {
|
||||
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: 'Infinity' })).toBe(
|
||||
1_800_000
|
||||
)
|
||||
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '1e400' })).toBe(1_800_000)
|
||||
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '-Infinity' })).toBe(
|
||||
WORKTREE_ADD_TIMEOUT_MS
|
||||
)
|
||||
// Why: `Infinity` is a number that got clamped, so reporting "is not a number" while naming the
|
||||
// number it used would misdirect exactly the operator this override exists for.
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"Infinity" is outside [180000, 1800000]ms; using 1800000ms')
|
||||
)
|
||||
})
|
||||
|
||||
// Why: trimming and fractional truncation alter the value but are not operator error.
|
||||
it('stays quiet when the value is unset, blank, or lands in range', () => {
|
||||
resolveWorktreeAddTimeoutMs({})
|
||||
resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: ' ' })
|
||||
resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '600000' })
|
||||
resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '300000.9' })
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: the seconds/ms mixup is the whole reason the floor exists — it must not be silent.
|
||||
it('warns with the range when it clamps an out-of-range value', () => {
|
||||
resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '300' })
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1)
|
||||
// Why: pin the prefix and variable name too — a warning nobody can grep for is not a diagnostic.
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[git/worktree] ORCA_WORKTREE_ADD_TIMEOUT_MS="300" is outside [180000, 1800000]ms; using 180000ms'
|
||||
)
|
||||
})
|
||||
|
||||
// Why: `600_000` copied out of worktree.ts is NaN, not out of range — a range complaint would misdirect.
|
||||
it('warns that an unparseable value is not a number rather than out of range', () => {
|
||||
resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '600_000' })
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1)
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"600_000" is not a number; using 180000ms')
|
||||
)
|
||||
})
|
||||
|
||||
// Why: `=300` means seconds to most operators; clamping it up keeps every create working instead of failing in 300ms.
|
||||
it('never lowers the timeout below the stall-guard default', () => {
|
||||
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '300' })).toBe(
|
||||
WORKTREE_ADD_TIMEOUT_MS
|
||||
)
|
||||
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '0' })).toBe(
|
||||
WORKTREE_ADD_TIMEOUT_MS
|
||||
)
|
||||
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '-1' })).toBe(
|
||||
WORKTREE_ADD_TIMEOUT_MS
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -91,13 +91,42 @@ const SPARSE_CHECKOUT_DETECTION_CONCURRENCY = 8
|
||||
|
||||
const PRUNABLE_EXISTENCE_PROBE_CONCURRENCY = 8
|
||||
|
||||
// Why: bound `git worktree add` so a OneDrive cloud-placeholder stall fails fast (STA-1292); generous enough for a legit large checkout (#7225).
|
||||
// Why: bound `git worktree add` so a OneDrive cloud-placeholder stall fails fast (STA-1292); ample for an ordinary large checkout, but not one behind a slow content filter (#12696).
|
||||
// Doubles as the floor for ORCA_WORKTREE_ADD_TIMEOUT_MS — lowering it to fail faster also lowers the minimum any override can request.
|
||||
export const WORKTREE_ADD_TIMEOUT_MS = 180_000
|
||||
// Why: ceiling for ORCA_WORKTREE_ADD_TIMEOUT_MS (#12696) — ~8x the slowest reported checkout (3.5 min). The cost is that a genuine stall now blocks a create for up to 30 min instead of 3.
|
||||
export const WORKTREE_ADD_TIMEOUT_MAX_MS = 30 * 60_000
|
||||
export const WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS = 30_000
|
||||
export const WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS = 30_000
|
||||
// Why: one wedged shared scan otherwise hangs every later list, including create's post-add re-list.
|
||||
export const WORKTREE_LIST_TIMEOUT_MS = 30_000
|
||||
|
||||
/**
|
||||
* `ORCA_WORKTREE_ADD_TIMEOUT_MS` clamped into [{@link WORKTREE_ADD_TIMEOUT_MS},
|
||||
* {@link WORKTREE_ADD_TIMEOUT_MAX_MS}]; unset, blank, or unparseable yields the default.
|
||||
* Warns when a non-blank value is rejected or clamped; trimming and fractional truncation are silent.
|
||||
* `env` is injectable for tests.
|
||||
*/
|
||||
export function resolveWorktreeAddTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
|
||||
const raw = env.ORCA_WORKTREE_ADD_TIMEOUT_MS?.trim()
|
||||
const requested = Math.floor(Number(raw))
|
||||
// Why: `=300` reads as seconds to most operators, so clamp rather than obey.
|
||||
const resolved = Number.isNaN(requested)
|
||||
? WORKTREE_ADD_TIMEOUT_MS
|
||||
: Math.min(Math.max(requested, WORKTREE_ADD_TIMEOUT_MS), WORKTREE_ADD_TIMEOUT_MAX_MS)
|
||||
// Why: an `isNaN` guard here would delete the unparseable-value warning — comparing against NaN is unequal, and that is what catches it.
|
||||
if (raw && resolved !== requested) {
|
||||
const problem = Number.isNaN(requested)
|
||||
? // Why: `600_000` copied out of this file is NaN, not out of range — say which.
|
||||
'is not a number'
|
||||
: `is outside [${WORKTREE_ADD_TIMEOUT_MS}, ${WORKTREE_ADD_TIMEOUT_MAX_MS}]ms`
|
||||
console.warn(
|
||||
`[git/worktree] ORCA_WORKTREE_ADD_TIMEOUT_MS="${raw}" ${problem}; using ${resolved}ms`
|
||||
)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
function gitExecOptions(
|
||||
cwd: string,
|
||||
options: GitWorktreeExecOptions = {}
|
||||
@@ -984,8 +1013,8 @@ async function performAddWorktree(
|
||||
}
|
||||
await gitExecFileAsync(args, {
|
||||
...gitExecOptions(repoPath, options),
|
||||
// Why: bound the checkout so a OneDrive cloud-placeholder stall (STA-1292) fails fast instead of hanging.
|
||||
timeout: WORKTREE_ADD_TIMEOUT_MS
|
||||
// Why: resolve per call — hoisting this to a module const would freeze the override at import.
|
||||
timeout: resolveWorktreeAddTimeoutMs()
|
||||
})
|
||||
|
||||
if (options.checkoutExistingBranch) {
|
||||
|
||||
@@ -293,6 +293,7 @@ describe('dashboard payload validation', () => {
|
||||
localWindowsConpty: true,
|
||||
osRelease: '10.0.22631',
|
||||
windowsShiftEnterEncoding: 'alt-enter',
|
||||
windowsInputRecordPasteNewline: 'alt-enter',
|
||||
ctrlEnterCsiU: false,
|
||||
kittyKeyboardAdvertised: false
|
||||
}
|
||||
@@ -307,6 +308,8 @@ describe('dashboard payload validation', () => {
|
||||
{ ...terminalInput, localWindowsConpty: 'true' },
|
||||
{ ...terminalInput, osRelease: 'x'.repeat(1_025) },
|
||||
{ ...terminalInput, windowsShiftEnterEncoding: 'enter' },
|
||||
{ ...terminalInput, forceBracketedMultilineTextPaste: false },
|
||||
{ ...terminalInput, windowsInputRecordPasteNewline: 'enter' },
|
||||
{ ...terminalInput, ctrlEnterCsiU: 'true' },
|
||||
{ ...terminalInput, kittyKeyboardAdvertised: 1 }
|
||||
]) {
|
||||
|
||||
@@ -53,6 +53,7 @@ const DASHBOARD_HOST_PLATFORMS = new Set([
|
||||
'win32'
|
||||
])
|
||||
const WINDOWS_SHIFT_ENTER_ENCODINGS = new Set(['alt-enter', 'csi-u'])
|
||||
const WINDOWS_INPUT_RECORD_PASTE_NEWLINES = new Set(['alt-enter', 'csi-u'])
|
||||
|
||||
function isBoundedString(value: unknown, maxLength: number, allowEmpty = false): value is string {
|
||||
return typeof value === 'string' && value.length <= maxLength && (allowEmpty || value.length > 0)
|
||||
@@ -308,6 +309,11 @@ function isDashboardTerminalInput(value: unknown): boolean {
|
||||
isOptionalBoundedString(input.osRelease, MAX_LABEL_LENGTH) &&
|
||||
typeof input.windowsShiftEnterEncoding === 'string' &&
|
||||
WINDOWS_SHIFT_ENTER_ENCODINGS.has(input.windowsShiftEnterEncoding) &&
|
||||
(input.forceBracketedMultilineTextPaste === undefined ||
|
||||
input.forceBracketedMultilineTextPaste === true) &&
|
||||
(input.windowsInputRecordPasteNewline === undefined ||
|
||||
(typeof input.windowsInputRecordPasteNewline === 'string' &&
|
||||
WINDOWS_INPUT_RECORD_PASTE_NEWLINES.has(input.windowsInputRecordPasteNewline))) &&
|
||||
typeof input.ctrlEnterCsiU === 'boolean' &&
|
||||
typeof input.kittyKeyboardAdvertised === 'boolean'
|
||||
)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
const handlers = new Map<string, (_event: unknown, args: never) => unknown>()
|
||||
const { getPathMock, handleMock, resolveProvisionedRootSourceMock } = vi.hoisted(() => ({
|
||||
getPathMock: vi.fn(),
|
||||
handleMock: vi.fn(),
|
||||
resolveProvisionedRootSourceMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: getPathMock },
|
||||
ipcMain: { handle: handleMock, removeHandler: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('../ephemeral-vm-provisioned-root-source', () => ({
|
||||
resolveProvisionedRootSource: resolveProvisionedRootSourceMock
|
||||
}))
|
||||
|
||||
import { registerEphemeralVmHandlers } from './ephemeral-vm'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('cancels source resolution before a provisioned-root recipe creates resources', async () => {
|
||||
const userDataPath = makeTempDir('orca-vm-cancel-user-data-')
|
||||
const repoPath = makeTempDir('orca-vm-cancel-repo-')
|
||||
const createMarker = join(repoPath, 'create-ran')
|
||||
getPathMock.mockReturnValue(userDataPath)
|
||||
handleMock.mockImplementation((channel: string, handler: never) => handlers.set(channel, handler))
|
||||
writeRecipe(repoPath, createMarker)
|
||||
resolveProvisionedRootSourceMock.mockImplementation(
|
||||
(_store, _repo, _ref, signal: AbortSignal | undefined) =>
|
||||
new Promise((resolve) =>
|
||||
signal?.addEventListener('abort', () => resolve(null), { once: true })
|
||||
)
|
||||
)
|
||||
registerEphemeralVmHandlers(makeStore(repoPath) as never)
|
||||
|
||||
const provision = handlers.get('ephemeralVm:provision')?.(null, {
|
||||
repoId: 'repo-1',
|
||||
recipeId: 'cloud-sandbox',
|
||||
provisionId: 'provision-source-ref'
|
||||
} as never) as Promise<{ ok: boolean; error?: string }>
|
||||
await vi.waitFor(() => expect(resolveProvisionedRootSourceMock).toHaveBeenCalledOnce())
|
||||
const cancelled = await handlers.get('ephemeralVm:cancelProvision')?.(null, {
|
||||
provisionId: 'provision-source-ref'
|
||||
} as never)
|
||||
const result = await provision
|
||||
|
||||
expect(cancelled).toEqual({ cancelled: true })
|
||||
expect(result).toEqual({ ok: false, error: 'Provisioning cancelled.', stdout: '', stderr: '' })
|
||||
expect(existsSync(createMarker)).toBe(false)
|
||||
})
|
||||
|
||||
function makeTempDir(prefix: string): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), prefix))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
function makeStore(repoPath: string) {
|
||||
const repo = { id: 'repo-1', path: repoPath, displayName: 'Repo', addedAt: 0 }
|
||||
return {
|
||||
getRepo: vi.fn((id: string) => (id === repo.id ? repo : null)),
|
||||
getRepos: vi.fn(() => [repo]),
|
||||
getSettings: vi.fn(() => ({ activeRuntimeEnvironmentId: null }))
|
||||
}
|
||||
}
|
||||
|
||||
function writeRecipe(repoPath: string, createMarker: string): void {
|
||||
writeFileSync(
|
||||
join(repoPath, 'orca.yaml'),
|
||||
[
|
||||
'environmentRecipes:',
|
||||
' - id: cloud-sandbox',
|
||||
' name: Cloud Sandbox',
|
||||
' checkoutMode: provisioned-root',
|
||||
` create: ${JSON.stringify(`"${process.execPath}" -e "require('node:fs').writeFileSync('${createMarker}','x')"`)}`,
|
||||
' destroy: none'
|
||||
].join('\n')
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
const handlers = new Map<string, (_event: unknown, args: never) => unknown>()
|
||||
const { connectRuntimeOwnedSshTargetMock, getPathMock, handleMock, removeHandlerMock } = vi.hoisted(
|
||||
() => ({
|
||||
connectRuntimeOwnedSshTargetMock: vi.fn(),
|
||||
getPathMock: vi.fn(),
|
||||
handleMock: vi.fn(),
|
||||
removeHandlerMock: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: getPathMock },
|
||||
ipcMain: { handle: handleMock, removeHandler: removeHandlerMock }
|
||||
}))
|
||||
|
||||
vi.mock('../ephemeral-vm-runtime-ssh', () => ({
|
||||
connectRuntimeOwnedSshTarget: connectRuntimeOwnedSshTargetMock,
|
||||
disconnectRuntimeOwnedSshTarget: vi.fn(),
|
||||
removeRuntimeOwnedSshTarget: vi.fn()
|
||||
}))
|
||||
|
||||
import { registerEphemeralVmHandlers } from './ephemeral-vm'
|
||||
|
||||
let userDataPath: string
|
||||
let repoPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'orca-vm-ref-user-data-'))
|
||||
repoPath = mkdtempSync(join(tmpdir(), 'orca-vm-ref-repo-'))
|
||||
getPathMock.mockReturnValue(userDataPath)
|
||||
handleMock.mockImplementation((channel: string, handler: never) => handlers.set(channel, handler))
|
||||
connectRuntimeOwnedSshTargetMock.mockResolvedValue({
|
||||
targetId: 'runtime-ssh-ref-test',
|
||||
target: { id: 'runtime-ssh-ref-test' }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
rmSync(repoPath, { recursive: true, force: true })
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('captures the source ref commit before a provisioned-root recipe starts', async () => {
|
||||
createGitFixtureCommit(repoPath)
|
||||
const expectedRefHead = createBranchCommit(repoPath, 'selected')
|
||||
const createCountPath = join(repoPath, 'create-count.txt')
|
||||
const createEnvPath = join(repoPath, 'create-env.json')
|
||||
writeRecipe(repoPath, createCountPath, createEnvPath)
|
||||
registerEphemeralVmHandlers(makeStore(repoPath) as never)
|
||||
|
||||
const result = await provision({ ref: 'selected' })
|
||||
|
||||
expect(result).toMatchObject({ ok: true, connectionType: 'ssh', expectedRefHead })
|
||||
expect(readFileSync(createCountPath, 'utf8')).toBe('x')
|
||||
expect(JSON.parse(readFileSync(createEnvPath, 'utf8'))).toEqual({
|
||||
ref: 'selected',
|
||||
refHead: expectedRefHead,
|
||||
repoUrl: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('captures the effective default ref when no start ref is selected', async () => {
|
||||
const expectedRefHead = createGitFixtureCommit(repoPath)
|
||||
const createCountPath = join(repoPath, 'create-count.txt')
|
||||
const createEnvPath = join(repoPath, 'create-env.json')
|
||||
writeRecipe(repoPath, createCountPath, createEnvPath)
|
||||
registerEphemeralVmHandlers(makeStore(repoPath) as never)
|
||||
|
||||
const result = await provision({})
|
||||
|
||||
expect(result).toMatchObject({ ok: true, connectionType: 'ssh', expectedRefHead })
|
||||
expect(JSON.parse(readFileSync(createEnvPath, 'utf8'))).toEqual({
|
||||
ref: 'main',
|
||||
refHead: expectedRefHead,
|
||||
repoUrl: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('pairs a multi-remote base with its fetch URL and remote branch ref', async () => {
|
||||
createGitFixtureCommit(repoPath)
|
||||
const expectedRefHead = createBranchCommit(repoPath, 'upstream-source')
|
||||
const remoteUrl = 'https://example.com/acme/upstream.git'
|
||||
execFileSync('git', ['remote', 'add', 'upstream', remoteUrl], { cwd: repoPath })
|
||||
execFileSync('git', ['update-ref', 'refs/remotes/upstream/main', expectedRefHead], {
|
||||
cwd: repoPath
|
||||
})
|
||||
const createCountPath = join(repoPath, 'create-count.txt')
|
||||
const createEnvPath = join(repoPath, 'create-env.json')
|
||||
writeRecipe(repoPath, createCountPath, createEnvPath)
|
||||
registerEphemeralVmHandlers(makeStore(repoPath) as never)
|
||||
|
||||
const result = await provision({ ref: 'upstream/main' })
|
||||
|
||||
expect(result).toMatchObject({ ok: true, connectionType: 'ssh', expectedRefHead })
|
||||
expect(JSON.parse(readFileSync(createEnvPath, 'utf8'))).toEqual({
|
||||
ref: 'refs/heads/main',
|
||||
refHead: expectedRefHead,
|
||||
repoUrl: remoteUrl
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an unresolved source ref before the recipe creates resources', async () => {
|
||||
createGitFixtureCommit(repoPath)
|
||||
const createCountPath = join(repoPath, 'create-count.txt')
|
||||
writeRecipe(repoPath, createCountPath)
|
||||
registerEphemeralVmHandlers(makeStore(repoPath) as never)
|
||||
|
||||
const result = await provision({ ref: 'refs/heads/missing' })
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: 'Could not resolve provisioned-root start ref: refs/heads/missing'
|
||||
})
|
||||
expect(() => readFileSync(createCountPath, 'utf8')).toThrow()
|
||||
})
|
||||
|
||||
function provision(args: { ref?: string }): Promise<unknown> {
|
||||
return handlers.get('ephemeralVm:provision')?.(null, {
|
||||
repoId: 'repo-1',
|
||||
recipeId: 'cloud-sandbox',
|
||||
branch: 'fix-sandbox',
|
||||
...(args.ref ? { ref: args.ref } : {})
|
||||
} as never) as Promise<unknown>
|
||||
}
|
||||
|
||||
function makeStore(path: string) {
|
||||
const repo = { id: 'repo-1', path, displayName: 'Repo', badgeColor: '#000', addedAt: 0 }
|
||||
return {
|
||||
getRepo: vi.fn((id: string) => (id === repo.id ? repo : null)),
|
||||
getRepos: vi.fn(() => [repo]),
|
||||
getSettings: vi.fn(() => ({ activeRuntimeEnvironmentId: null }))
|
||||
}
|
||||
}
|
||||
|
||||
function createGitFixtureCommit(path: string): string {
|
||||
execFileSync('git', ['init'], { cwd: path })
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: path })
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: path })
|
||||
execFileSync('git', ['branch', '-M', 'main'], { cwd: path })
|
||||
writeFileSync(join(path, 'fixture.txt'), 'fixture')
|
||||
execFileSync('git', ['add', 'fixture.txt'], { cwd: path })
|
||||
execFileSync('git', ['commit', '-m', 'fixture'], { cwd: path })
|
||||
return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: path, encoding: 'utf8' }).trim()
|
||||
}
|
||||
|
||||
function createBranchCommit(path: string, branch: string): string {
|
||||
execFileSync('git', ['checkout', '-b', branch], { cwd: path })
|
||||
writeFileSync(join(path, 'fixture.txt'), branch)
|
||||
execFileSync('git', ['add', 'fixture.txt'], { cwd: path })
|
||||
execFileSync('git', ['commit', '-m', branch], { cwd: path })
|
||||
const head = execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: path,
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
execFileSync('git', ['checkout', 'main'], { cwd: path })
|
||||
return head
|
||||
}
|
||||
|
||||
function writeRecipe(path: string, createCountPath: string, createEnvPath?: string): void {
|
||||
const startPath = join(path, 'start.js')
|
||||
writeFileSync(
|
||||
startPath,
|
||||
[
|
||||
`require('node:fs').appendFileSync(${JSON.stringify(createCountPath)}, 'x')`,
|
||||
...(createEnvPath
|
||||
? [
|
||||
`require('node:fs').writeFileSync(${JSON.stringify(createEnvPath)}, JSON.stringify({ref:process.env.ORCA_REPO_REF,refHead:process.env.ORCA_REPO_REF_HEAD,repoUrl:process.env.ORCA_REPO_URL}))`
|
||||
]
|
||||
: []),
|
||||
'console.log(JSON.stringify({schemaVersion:2,checkoutMode:"provisioned-root",',
|
||||
'connection:{type:"ssh",projectRoot:"/workspace/repo",',
|
||||
'target:{label:"Sandbox",host:"host",port:22,username:"root"}}}))'
|
||||
].join('\n')
|
||||
)
|
||||
writeFileSync(
|
||||
join(path, 'orca.yaml'),
|
||||
[
|
||||
'environmentRecipes:',
|
||||
' - id: cloud-sandbox',
|
||||
' name: Cloud Sandbox',
|
||||
' checkoutMode: provisioned-root',
|
||||
` create: ${JSON.stringify(`"${process.execPath}" "${startPath}"`)}`,
|
||||
' destroy: none'
|
||||
].join('\n')
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
@@ -29,6 +29,10 @@ import { registerEphemeralVmRuntimeHandlers } from './ephemeral-vm-runtime-handl
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
function nodeCommand(scriptPath: string): string {
|
||||
return `"${process.execPath}" "${scriptPath}"`
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
handleMock.mockReset()
|
||||
@@ -47,7 +51,7 @@ afterEach(() => {
|
||||
}
|
||||
})
|
||||
|
||||
it('removes the hidden SSH target when recipe context is unavailable', async () => {
|
||||
it('removes the hidden SSH target when provider cleanup cannot start', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-vm-runtime-handler-'))
|
||||
tempDirs.push(userDataPath)
|
||||
getPathMock.mockReturnValue(userDataPath)
|
||||
@@ -79,7 +83,68 @@ it('removes the hidden SSH target when recipe context is unavailable', async ()
|
||||
expect(cleaned).toMatchObject({
|
||||
status: 'cleanup_failed',
|
||||
cleanupStatus: 'failed',
|
||||
connectionMode: undefined,
|
||||
sshTargetId: undefined
|
||||
})
|
||||
expect(removeRuntimeOwnedSshTargetMock).toHaveBeenCalledWith('runtime-ssh-missing-context')
|
||||
})
|
||||
|
||||
it('stops in-flight cleanup and retains the runtime for retry', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-vm-runtime-handler-'))
|
||||
const repoPath = mkdtempSync(join(tmpdir(), 'orca-vm-runtime-repo-'))
|
||||
tempDirs.push(userDataPath, repoPath)
|
||||
getPathMock.mockReturnValue(userDataPath)
|
||||
const destroyPath = join(repoPath, 'destroy.js')
|
||||
const destroyStartedPath = join(repoPath, 'destroy-started.txt')
|
||||
writeFileSync(
|
||||
destroyPath,
|
||||
`require('fs').writeFileSync(${JSON.stringify(destroyStartedPath)}, 'yes'); setInterval(() => {}, 1000)`
|
||||
)
|
||||
upsertEphemeralVmRuntime(userDataPath, {
|
||||
id: 'runtime-stop',
|
||||
recipeId: 'cloud-sandbox',
|
||||
recipe: {
|
||||
id: 'cloud-sandbox',
|
||||
name: 'Cloud Sandbox',
|
||||
create: 'unused',
|
||||
destroy: nodeCommand(destroyPath)
|
||||
},
|
||||
repoId: 'repo-1',
|
||||
status: 'running',
|
||||
cleanupStatus: 'not_started',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
recipeResult: {
|
||||
schemaVersion: 1,
|
||||
connection: {
|
||||
type: 'ssh',
|
||||
projectRoot: '/workspace/repo',
|
||||
target: { label: 'VM', host: 'host', port: 22, username: 'orca' }
|
||||
}
|
||||
}
|
||||
})
|
||||
registerEphemeralVmRuntimeHandlers({
|
||||
getRepo: vi.fn(() => ({
|
||||
id: 'repo-1',
|
||||
path: repoPath,
|
||||
displayName: 'Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
}))
|
||||
} as never)
|
||||
|
||||
const cleanup = handlers.get('ephemeralVm:cleanup')?.(null, {
|
||||
runtimeId: 'runtime-stop'
|
||||
}) as Promise<{ status: string }>
|
||||
await vi.waitFor(() => expect(existsSync(destroyStartedPath)).toBe(true))
|
||||
const stopped = await handlers.get('ephemeralVm:stopCleanup')?.(null, {
|
||||
runtimeId: 'runtime-stop'
|
||||
})
|
||||
|
||||
expect(stopped).toMatchObject({
|
||||
status: 'cleanup_failed',
|
||||
cleanupStatus: 'failed',
|
||||
cleanupLastError: 'Cleanup stopped by user.'
|
||||
})
|
||||
await expect(cleanup).resolves.toMatchObject({ status: 'cleanup_failed' })
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import {
|
||||
cleanupEphemeralVmRuntime,
|
||||
resumeEphemeralVmRuntime,
|
||||
stopEphemeralVmRuntimeCleanup,
|
||||
suspendEphemeralVmRuntime
|
||||
} from '../ephemeral-vm-runtime-service'
|
||||
import { removeEphemeralVmRuntimeSshTarget } from '../ephemeral-vm-runtime-ssh-cleanup'
|
||||
@@ -44,6 +45,7 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
|
||||
ipcMain.removeHandler('ephemeralVm:attachWorkspace')
|
||||
ipcMain.removeHandler('ephemeralVm:listRuntimes')
|
||||
ipcMain.removeHandler('ephemeralVm:cleanup')
|
||||
ipcMain.removeHandler('ephemeralVm:stopCleanup')
|
||||
ipcMain.removeHandler('ephemeralVm:suspendWorkspace')
|
||||
ipcMain.removeHandler('ephemeralVm:resumeWorkspace')
|
||||
ipcMain.removeHandler('ephemeralVm:getCleanupCommand')
|
||||
@@ -111,8 +113,9 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
|
||||
// environment row; users can still remove that manually.
|
||||
}
|
||||
}
|
||||
// Remove even on cleanup_failed (removal is idempotent via the deterministic
|
||||
// id) so a terminal cleanup never orphans the hidden SSH target.
|
||||
if (!result.ok) {
|
||||
return result.runtime
|
||||
}
|
||||
return removeEphemeralVmRuntimeSshTarget({
|
||||
userDataPath,
|
||||
runtime: result.runtime,
|
||||
@@ -121,6 +124,35 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'ephemeralVm:stopCleanup',
|
||||
async (_event, args: { runtimeId: string }): Promise<EphemeralVmRuntimeRecord> => {
|
||||
const userDataPath = app.getPath('userData')
|
||||
const stopping = stopEphemeralVmRuntimeCleanup({
|
||||
userDataPath,
|
||||
runtimeId: args.runtimeId
|
||||
})
|
||||
if (stopping) {
|
||||
return (await stopping).runtime
|
||||
}
|
||||
|
||||
const runtime = listEphemeralVmRuntimes(userDataPath).find(
|
||||
(entry) => entry.id === args.runtimeId
|
||||
)
|
||||
if (!runtime) {
|
||||
throw new Error(`Unknown ephemeral VM runtime: ${args.runtimeId}`)
|
||||
}
|
||||
if (runtime.cleanupStatus !== 'running') {
|
||||
return runtime
|
||||
}
|
||||
return updateEphemeralVmRuntimeStatus(userDataPath, runtime.id, {
|
||||
status: 'cleanup_failed',
|
||||
cleanupStatus: 'failed',
|
||||
cleanupLastError: 'Cleanup was interrupted. Retry cleanup to continue.'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'ephemeralVm:suspendWorkspace',
|
||||
async (_event, args: { workspaceId: string }): Promise<EphemeralVmRuntimeRecord | null> => {
|
||||
|
||||
@@ -498,7 +498,7 @@ describe('registerEphemeralVmHandlers', () => {
|
||||
expect(listEnvironments(userDataPath)).toEqual([])
|
||||
})
|
||||
|
||||
it('removes the runtime-owned SSH target on cleanup even when destroy fails', async () => {
|
||||
it('retains the runtime-owned SSH target when destroy fails', async () => {
|
||||
const userDataPath = makeDir('orca-ephemeral-vm-ipc-user-data-')
|
||||
const repoPath = makeDir('orca-ephemeral-vm-ipc-repo-')
|
||||
getPathMock.mockReturnValue(userDataPath)
|
||||
@@ -523,8 +523,6 @@ describe('registerEphemeralVmHandlers', () => {
|
||||
'}))'
|
||||
].join('\n')
|
||||
)
|
||||
// Why: a failing destroy drives the cleanup_failed branch; the runtime-owned
|
||||
// SSH target must still be torn down so it never orphans (see Fix D).
|
||||
writeFileSync(destroyPath, 'process.exit(1)')
|
||||
writeFileSync(
|
||||
join(repoPath, 'orca.yaml'),
|
||||
@@ -554,9 +552,9 @@ describe('registerEphemeralVmHandlers', () => {
|
||||
|
||||
expect(cleaned).toEqual(expect.objectContaining({ status: 'cleanup_failed' }))
|
||||
expect(cleaned.cleanupLastError).toBeTruthy()
|
||||
expect(removeRuntimeOwnedSshTargetMock).toHaveBeenCalledWith('runtime-ssh-orca-instance-1')
|
||||
expect(cleaned.connectionMode).toBeUndefined()
|
||||
expect(cleaned.sshTargetId).toBeUndefined()
|
||||
expect(removeRuntimeOwnedSshTargetMock).not.toHaveBeenCalled()
|
||||
expect(cleaned.connectionMode).toBe('ssh')
|
||||
expect(cleaned.sshTargetId).toBe('runtime-ssh-orca-instance-1')
|
||||
})
|
||||
|
||||
it('retries hidden SSH teardown without rerunning completed provider cleanup', async () => {
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { registerEphemeralVmRuntimeHandlers } from './ephemeral-vm-runtime-handlers'
|
||||
import type { PluginService } from '../plugins/plugin-service'
|
||||
import { getApprovedPluginVmRecipes } from '../plugins/plugin-approved-vm-recipes'
|
||||
import { resolveProvisionedRootSource } from '../ephemeral-vm-provisioned-root-source'
|
||||
|
||||
const activeProvisionControllers = new Map<string, AbortController>()
|
||||
|
||||
@@ -52,6 +53,7 @@ export type EphemeralVmProvisionIpcResult =
|
||||
connectionType: 'ssh'
|
||||
runtime: EphemeralVmRuntimeRecord
|
||||
sshTargetId: string
|
||||
expectedRefHead?: string
|
||||
stderr: string
|
||||
warnings: EphemeralVmRecipeResultWarning[]
|
||||
}
|
||||
@@ -128,10 +130,6 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
|
||||
if (!recipe) {
|
||||
return { ok: false, error: `Recipe not found: ${args.recipeId}`, stdout: '', stderr: '' }
|
||||
}
|
||||
const repoUrl = getProvisionedRootRecipeRepoUrl(
|
||||
recipe.checkoutMode,
|
||||
repo.repo.gitRemoteIdentity?.remoteUrl
|
||||
)
|
||||
const controller = args.provisionId ? new AbortController() : null
|
||||
if (args.provisionId && controller) {
|
||||
activeProvisionControllers.set(args.provisionId, controller)
|
||||
@@ -151,6 +149,34 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
|
||||
// abort during the up-to-10s SSH connect window. Removing it in the provision
|
||||
// promise's own .finally() would deregister it before SSH connect even starts.
|
||||
try {
|
||||
let recipeRepoUrl = repo.repo.gitRemoteIdentity?.remoteUrl
|
||||
let sourceRef = args.ref
|
||||
let expectedRefHead: string | undefined
|
||||
if (recipe.checkoutMode === 'provisioned-root') {
|
||||
const source = await resolveProvisionedRootSource(
|
||||
store,
|
||||
repo.repo,
|
||||
args.ref,
|
||||
controller?.signal
|
||||
)
|
||||
if (controller?.signal.aborted) {
|
||||
return { ok: false, error: 'Provisioning cancelled.', stdout: '', stderr: '' }
|
||||
}
|
||||
if (!source) {
|
||||
return {
|
||||
ok: false,
|
||||
error: args.ref
|
||||
? `Could not resolve provisioned-root start ref: ${args.ref}`
|
||||
: 'Could not resolve a default provisioned-root start ref.',
|
||||
stdout: '',
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
sourceRef = source.ref
|
||||
expectedRefHead = source.head
|
||||
recipeRepoUrl = source.remoteUrl ?? recipeRepoUrl
|
||||
}
|
||||
const repoUrl = getProvisionedRootRecipeRepoUrl(recipe.checkoutMode, recipeRepoUrl)
|
||||
const result = await provisionEphemeralVmRuntime({
|
||||
userDataPath: app.getPath('userData'),
|
||||
repoPath: repo.repo.path,
|
||||
@@ -161,7 +187,8 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
|
||||
workspaceName: args.workspaceName,
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(args.branch ? { branch: args.branch } : {}),
|
||||
...(args.ref ? { ref: args.ref } : {}),
|
||||
...(sourceRef ? { ref: sourceRef } : {}),
|
||||
...(expectedRefHead ? { expectedRefHead } : {}),
|
||||
...(controller ? { signal: controller.signal } : {}),
|
||||
onStdout: (chunk) => sendProvisionEvent('stdout', chunk),
|
||||
onStderr: (chunk) => sendProvisionEvent('stderr', chunk)
|
||||
@@ -194,6 +221,7 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
|
||||
connectionType: 'ssh',
|
||||
runtime,
|
||||
sshTargetId: ssh.targetId,
|
||||
...(expectedRefHead ? { expectedRefHead } : {}),
|
||||
stderr: redactEphemeralVmRecipeDiagnosticText(result.start.stderr),
|
||||
warnings: getEphemeralVmRecipeResultWarnings(result.start.result)
|
||||
}
|
||||
|
||||
@@ -17,9 +17,13 @@ import { AGENT_SESSION_CLAIM_DIGEST_VERSION } from '../../shared/agent-session-h
|
||||
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
|
||||
import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors'
|
||||
import type * as Wsl from '../wsl'
|
||||
import { getBundledLauncherPath } from '../cli/bundled-cli-launcher-path'
|
||||
|
||||
const isWindowsHost = process.platform === 'win32'
|
||||
const posixOnlyIt = isWindowsHost ? it.skip : it
|
||||
const BUNDLED_RESOURCES_PATH = join('/tmp', 'orca-bundled-resources')
|
||||
// Why: this suite forces darwin before every test, including on Linux CI.
|
||||
const BUNDLED_CLI_PATH = getBundledLauncherPath('darwin', BUNDLED_RESOURCES_PATH) as string
|
||||
// Why: bare shells no longer mkdir ~/.omp; OMP status lives under userData (#10196).
|
||||
const expectedOmpStatusExtension = posix.join(
|
||||
'/tmp/orca-user-data',
|
||||
@@ -133,7 +137,8 @@ vi.mock('fs', () => ({
|
||||
writeFileSync: writeFileSyncMock,
|
||||
chmodSync: chmodSyncMock,
|
||||
constants: {
|
||||
X_OK: 1
|
||||
X_OK: 1,
|
||||
R_OK: 4
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -1939,6 +1944,42 @@ describe('registerPtyHandlers', () => {
|
||||
]
|
||||
}
|
||||
|
||||
// Why: the Codex launch preflight now carries the bundled CLI's verified absolute
|
||||
// path, so these cases need a resources root whose launcher passes the exec check.
|
||||
async function withBundledCli<T>(
|
||||
run: () => Promise<T>,
|
||||
options?: { launcherExecutable?: boolean }
|
||||
): Promise<T> {
|
||||
const launcherExecutable = options?.launcherExecutable ?? true
|
||||
const previousResourcesPath = process.resourcesPath
|
||||
Object.defineProperty(process, 'resourcesPath', {
|
||||
configurable: true,
|
||||
value: BUNDLED_RESOURCES_PATH
|
||||
})
|
||||
// Why: only teach the launcher path to look like an executable file; every other
|
||||
// stat/access keeps the permissive default the rest of the harness relies on.
|
||||
statSyncMock.mockImplementation((target: string) => ({
|
||||
isDirectory: () => target !== BUNDLED_CLI_PATH,
|
||||
isFile: () => target === BUNDLED_CLI_PATH,
|
||||
mode: 0o755
|
||||
}))
|
||||
if (!launcherExecutable) {
|
||||
accessSyncMock.mockImplementation((target: string) => {
|
||||
if (target === BUNDLED_CLI_PATH) {
|
||||
throw new Error(`EACCES: ${target}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
Object.defineProperty(process, 'resourcesPath', {
|
||||
configurable: true,
|
||||
value: previousResourcesPath
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe('spawn environment', () => {
|
||||
it('refreshes the outer Windows PATH for a WSL spawn without forwarding it', async () => {
|
||||
const originalPlatform = process.platform
|
||||
@@ -2133,10 +2174,40 @@ describe('registerPtyHandlers', () => {
|
||||
})
|
||||
|
||||
it('injects the selected Codex home into Orca terminal PTYs', async () => {
|
||||
const env = await spawnAndGetEnv(undefined, undefined, () => TEST_CODEX_HOME)
|
||||
const env = await withBundledCli(() =>
|
||||
spawnAndGetEnv(undefined, undefined, () => TEST_CODEX_HOME)
|
||||
)
|
||||
expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME)
|
||||
expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME)
|
||||
expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBe('orca')
|
||||
// Why (STA-4270): a bare name would be resolved by the post-profile PATH the codex()
|
||||
// wrapper inherits, so the preflight must carry the CLI's verified absolute path.
|
||||
expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBe(BUNDLED_CLI_PATH)
|
||||
})
|
||||
|
||||
it('skips the Codex launch preflight when the bundled CLI is not executable', async () => {
|
||||
const env = await withBundledCli(
|
||||
() => spawnAndGetEnv(undefined, undefined, () => TEST_CODEX_HOME),
|
||||
{ launcherExecutable: false }
|
||||
)
|
||||
|
||||
expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME)
|
||||
expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBeUndefined()
|
||||
})
|
||||
|
||||
// Why (STA-4270): profile scripts run before the codex() wrapper and routinely prepend
|
||||
// directories to PATH, so a scratch `orca` there must never become the preflight.
|
||||
it('pins the Codex launch preflight to the bundled CLI even when PATH leads elsewhere', async () => {
|
||||
const env = await withBundledCli(() =>
|
||||
spawnAndGetEnv(
|
||||
{ PATH: `/tmp/hijack-scratch${delimiter}/usr/bin` },
|
||||
undefined,
|
||||
() => TEST_CODEX_HOME
|
||||
)
|
||||
)
|
||||
|
||||
expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBe(BUNDLED_CLI_PATH)
|
||||
expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).not.toBe('orca')
|
||||
expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT.startsWith('/tmp/hijack-scratch')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not install the Codex launch preflight when Codex hooks are disabled', async () => {
|
||||
|
||||
+14
-3
@@ -1864,7 +1864,9 @@ export function buildPtyHostEnv(
|
||||
hooksEnabled: opts.codexStatusHooksEnabled ?? opts.agentStatusHooksEnabled,
|
||||
isPackaged: opts.isPackaged,
|
||||
isWsl: opts.isWsl,
|
||||
managedHomePath: opts.selectedCodexHomePath
|
||||
managedHomePath: opts.selectedCodexHomePath,
|
||||
userDataPath: opts.userDataPath,
|
||||
resourcesPath: opts.resourcesPath
|
||||
})
|
||||
if (preflightCommand) {
|
||||
baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT = preflightCommand
|
||||
@@ -5428,8 +5430,17 @@ export function registerPtyHandlers(
|
||||
},
|
||||
write: (ptyId, data) => {
|
||||
try {
|
||||
getProviderForPty(ptyId).write(ptyId, data)
|
||||
return true
|
||||
return getProviderForPty(ptyId).write(ptyId, data) !== false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
writeWithSettlement: async (ptyId, data) => {
|
||||
try {
|
||||
const provider = getProviderForPty(ptyId)
|
||||
return provider.writeWithSettlement
|
||||
? await provider.writeWithSettlement(ptyId, data)
|
||||
: provider.write(ptyId, data) !== false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ vi.mock('../pty-descendant-termination', () => ({
|
||||
const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
|
||||
const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
|
||||
const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe'
|
||||
const CODEX_LAUNCH_PREFLIGHT = 'C:\\Program Files\\Orca\\orca.exe'
|
||||
vi.mock('./windows-powershell-executable', () => ({
|
||||
resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') =>
|
||||
family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS,
|
||||
@@ -1371,7 +1372,13 @@ describe('LocalPtyProvider', () => {
|
||||
const originalProgramFiles = process.env.ProgramFiles
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
process.env.ProgramFiles = 'C:\\Program Files'
|
||||
provider.configure({ getWindowsShell: () => 'git-bash' })
|
||||
provider.configure({
|
||||
getWindowsShell: () => 'git-bash',
|
||||
buildSpawnEnv: (_id, env) => ({
|
||||
...env,
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
await provider.spawn({
|
||||
@@ -1392,12 +1399,52 @@ describe('LocalPtyProvider', () => {
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
['-c', 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'],
|
||||
[
|
||||
'-c',
|
||||
expect.stringMatching(
|
||||
/^chcp\.com 65001 >\/dev\/null 2>&1; exec "\$BASH" --rcfile '.*shell-ready\/bash\/rcfile' -i$/
|
||||
)
|
||||
],
|
||||
expect.objectContaining({
|
||||
cwd: 'C:\\Users\\jin\\repo',
|
||||
env: expect.objectContaining({
|
||||
CHERE_INVOKING: '1',
|
||||
PYTHONUTF8: '1'
|
||||
PYTHONUTF8: '1',
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('runs the Codex preflight once in the cmd.exe startup chain', async () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
provider.configure({
|
||||
getWindowsShell: () => 'cmd.exe',
|
||||
buildSpawnEnv: (_id, env) => ({
|
||||
...env,
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
await provider.spawn({ cols: 80, rows: 24, cwd: 'C:\\Users\\jin\\repo' })
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'cmd.exe',
|
||||
[
|
||||
'/K',
|
||||
'chcp 65001 > nul & if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE% agent hooks prepare-codex > nul 2>&1'
|
||||
],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT,
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE: '"'
|
||||
})
|
||||
})
|
||||
)
|
||||
@@ -1407,12 +1454,12 @@ describe('LocalPtyProvider', () => {
|
||||
describe('write', () => {
|
||||
it('writes data to the PTY process', async () => {
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
provider.write(id, 'hello')
|
||||
expect(provider.write(id, 'hello')).toBe(true)
|
||||
expect(mockProc.write).toHaveBeenCalledWith('hello')
|
||||
})
|
||||
|
||||
it('is a no-op for unknown PTY ids', () => {
|
||||
provider.write('nonexistent', 'hello')
|
||||
expect(provider.write('nonexistent', 'hello')).toBe(false)
|
||||
expect(mockProc.write).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/* eslint-disable max-lines -- Why: splitting spawn() would scatter tightly coupled PTY lifecycle logic (scan → ready → write → exit) with no cleaner ownership seam. */
|
||||
import { basename, delimiter, win32 as pathWin32 } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { resolveWindowsShellLaunchArgs } from './windows-shell-args'
|
||||
import {
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV,
|
||||
resolveWindowsShellLaunchArgs
|
||||
} from './windows-shell-args'
|
||||
import {
|
||||
resolveEffectiveWindowsPowerShell,
|
||||
shouldProbeWindowsPowerShellAvailability,
|
||||
@@ -777,6 +780,30 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
delete finalEnv.CODEX_HOME
|
||||
delete finalEnv.ORCA_CODEX_HOME
|
||||
}
|
||||
|
||||
const shellBasename = pathWin32.basename(shellPath).toLowerCase()
|
||||
const codexLaunchPreflightCommand = finalEnv.ORCA_CODEX_LAUNCH_PREFLIGHT
|
||||
if (
|
||||
codexLaunchPreflightCommand &&
|
||||
(shellBasename === 'cmd.exe' || isWindowsGitBashShellPath(shellPath))
|
||||
) {
|
||||
if (shellBasename === 'cmd.exe') {
|
||||
// Why: node-pty backslash-escapes argv quotes; expand the quote inside cmd.exe instead.
|
||||
finalEnv[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV] = '"'
|
||||
}
|
||||
const resolved = resolveWindowsShellLaunchArgs(
|
||||
shellPath,
|
||||
cwd,
|
||||
defaultCwd,
|
||||
launchWslContext,
|
||||
args.command,
|
||||
codexLaunchPreflightCommand
|
||||
)
|
||||
shellArgs = resolved.shellArgs
|
||||
effectiveCwd = resolved.effectiveCwd
|
||||
validationCwd = resolved.validationCwd
|
||||
startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true
|
||||
}
|
||||
}
|
||||
seedPowerlevel10kWizardEnv(finalEnv, { envToDelete: args.envToDelete })
|
||||
if (
|
||||
@@ -1123,15 +1150,20 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
hasPty(id: string): boolean {
|
||||
return ptyProcesses.has(id)
|
||||
}
|
||||
write(id: string, data: string): void {
|
||||
write(id: string, data: string): boolean {
|
||||
// Cooked PTYs echo private DSR/OSC replies; CPR/DA remain immediate (#13137, #7329).
|
||||
if (extractOnlyCookedEchoSafeQueryReplies(data)) {
|
||||
const ingress = startupIngressByPty.get(id)
|
||||
if (ingress?.answerLiveQueryReply(data)) {
|
||||
return
|
||||
return true
|
||||
}
|
||||
}
|
||||
ptyProcesses.get(id)?.write(data)
|
||||
const proc = ptyProcesses.get(id)
|
||||
if (!proc) {
|
||||
return false
|
||||
}
|
||||
proc.write(data)
|
||||
return true
|
||||
}
|
||||
resize(id: string, cols: number, rows: number): void {
|
||||
ptyProcesses.get(id)?.resize(cols, rows)
|
||||
|
||||
@@ -126,7 +126,8 @@ export type IPtyProvider = {
|
||||
hasPty?: (id: string) => boolean
|
||||
/** Exact provider readback: false only when the provider answered that the PTY is absent. */
|
||||
probePtyLiveness?: (id: string) => Promise<boolean | null>
|
||||
write(id: string, data: string): void
|
||||
write(id: string, data: string): boolean | void
|
||||
writeWithSettlement?: (id: string, data: string) => Promise<boolean>
|
||||
resize(id: string, cols: number, rows: number): void
|
||||
/**
|
||||
* Producer-side flow control: stop/restart reading the underlying PTY so a
|
||||
|
||||
@@ -23,6 +23,7 @@ import { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race'
|
||||
import { SshAgentSessionCapabilities } from './ssh-agent-session-capabilities'
|
||||
import type { PtyProcessInspection } from './pty-process-inspection'
|
||||
import { SSH_SESSION_EXPIRED_ERROR } from './ssh-pty-errors'
|
||||
import { writeToSshPty, writeToSshPtyWithSettlement } from './ssh-pty-write'
|
||||
|
||||
// Why: sequential relay teardown calls share one absolute budget; convert to the mux-relative timeout only at dispatch.
|
||||
function relayTimeoutOptions(deadlineMs: number | undefined): { timeoutMs: number } | undefined {
|
||||
@@ -204,8 +205,12 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
})
|
||||
}
|
||||
|
||||
write(id: string, data: string): void {
|
||||
this.mux.notify('pty.data', { id: this.toRelayPtyId(id), data })
|
||||
write(id: string, data: string): boolean {
|
||||
return writeToSshPty(this.mux, this.toRelayPtyId(id), data)
|
||||
}
|
||||
|
||||
writeWithSettlement(id: string, data: string): Promise<boolean> {
|
||||
return writeToSshPtyWithSettlement(this.mux, this.toRelayPtyId(id), data)
|
||||
}
|
||||
|
||||
resize(id: string, cols: number, rows: number): void {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SshPtyProvider } from './ssh-pty-provider'
|
||||
import { SSH_PTY_WRITE_SETTLEMENT_TIMEOUT_MS } from './ssh-pty-write'
|
||||
|
||||
describe('SSH PTY writes', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('rejects writes synchronously after the transport is disposed', () => {
|
||||
const mux = {
|
||||
isDisposed: vi.fn().mockReturnValue(true),
|
||||
notify: vi.fn(),
|
||||
onNotification: vi.fn()
|
||||
}
|
||||
const provider = new SshPtyProvider('conn-1', mux as never)
|
||||
|
||||
expect(provider.write('ssh:conn-1@@pty-1', 'pointer')).toBe(false)
|
||||
expect(mux.notify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a failed transport settlement instead of enqueue acceptance', async () => {
|
||||
let settle: ((result: { ok: true } | { ok: false; error: Error }) => void) | undefined
|
||||
const mux = {
|
||||
isDisposed: vi.fn().mockReturnValue(false),
|
||||
notify: vi.fn(),
|
||||
notifyWithSettlement: vi.fn((_method, _params, onSettled) => {
|
||||
settle = onSettled
|
||||
}),
|
||||
onNotification: vi.fn()
|
||||
}
|
||||
const provider = new SshPtyProvider('conn-1', mux as never)
|
||||
|
||||
const pending = provider.writeWithSettlement('ssh:conn-1@@pty-1', 'pointer')
|
||||
expect(mux.notifyWithSettlement).toHaveBeenCalledWith(
|
||||
'pty.data',
|
||||
{ id: 'pty-1', data: 'pointer' },
|
||||
expect.any(Function)
|
||||
)
|
||||
settle?.({ ok: false, error: new Error('transport rejected write') })
|
||||
|
||||
await expect(pending).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('rejects settled writes immediately after the transport is disposed', async () => {
|
||||
const mux = {
|
||||
isDisposed: vi.fn().mockReturnValue(true),
|
||||
notifyWithSettlement: vi.fn(),
|
||||
onNotification: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
const provider = new SshPtyProvider('conn-1', mux as never)
|
||||
|
||||
await expect(provider.writeWithSettlement('ssh:conn-1@@pty-1', 'pointer')).resolves.toBe(false)
|
||||
expect(mux.notifyWithSettlement).not.toHaveBeenCalled()
|
||||
expect(mux.dispose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disconnects a transport whose write settlement never arrives', async () => {
|
||||
vi.useFakeTimers()
|
||||
const mux = {
|
||||
isDisposed: vi.fn().mockReturnValue(false),
|
||||
notify: vi.fn(),
|
||||
notifyWithSettlement: vi.fn(),
|
||||
onNotification: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
const provider = new SshPtyProvider('conn-1', mux as never)
|
||||
const pending = provider.writeWithSettlement('ssh:conn-1@@pty-1', 'pointer')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(SSH_PTY_WRITE_SETTLEMENT_TIMEOUT_MS - 1)
|
||||
expect(mux.dispose).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
await expect(pending).resolves.toBe(false)
|
||||
expect(mux.dispose).toHaveBeenCalledWith('connection_lost')
|
||||
})
|
||||
|
||||
it('accepts a healthy settlement after the mux health window', async () => {
|
||||
vi.useFakeTimers()
|
||||
let settle: ((result: { ok: true }) => void) | undefined
|
||||
const mux = {
|
||||
isDisposed: vi.fn().mockReturnValue(false),
|
||||
notify: vi.fn(),
|
||||
notifyWithSettlement: vi.fn(
|
||||
(_method: string, _params: unknown, callback: (result: { ok: true }) => void) => {
|
||||
settle = callback
|
||||
}
|
||||
),
|
||||
onNotification: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
const provider = new SshPtyProvider('conn-1', mux as never)
|
||||
const pending = provider.writeWithSettlement('ssh:conn-1@@pty-1', 'pointer')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(SSH_PTY_WRITE_SETTLEMENT_TIMEOUT_MS - 1)
|
||||
settle?.({ ok: true })
|
||||
|
||||
await expect(pending).resolves.toBe(true)
|
||||
expect(mux.dispose).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import { TIMEOUT_MS } from '../ssh/relay-protocol'
|
||||
|
||||
// Allow ordinary-lane backpressure to clear well beyond the mux health window.
|
||||
export const SSH_PTY_WRITE_SETTLEMENT_TIMEOUT_MS = TIMEOUT_MS * 3
|
||||
|
||||
export function writeToSshPty(
|
||||
mux: SshChannelMultiplexer,
|
||||
relayPtyId: string,
|
||||
data: string
|
||||
): boolean {
|
||||
if (mux.isDisposed()) {
|
||||
return false
|
||||
}
|
||||
mux.notify('pty.data', { id: relayPtyId, data })
|
||||
return !mux.isDisposed()
|
||||
}
|
||||
|
||||
export function writeToSshPtyWithSettlement(
|
||||
mux: SshChannelMultiplexer,
|
||||
relayPtyId: string,
|
||||
data: string
|
||||
): Promise<boolean> {
|
||||
if (mux.isDisposed()) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const finish = (accepted: boolean): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolve(accepted)
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
mux.dispose('connection_lost')
|
||||
finish(false)
|
||||
}, SSH_PTY_WRITE_SETTLEMENT_TIMEOUT_MS)
|
||||
timer.unref?.()
|
||||
mux.notifyWithSettlement('pty.data', { id: relayPtyId, data }, (result) => finish(result.ok))
|
||||
})
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
import { resolveSetupRunnerCommand } from '../../shared/setup-runner-command'
|
||||
import { resolveWindowsShellLaunchArgs } from './windows-shell-args'
|
||||
|
||||
const CODEX_LAUNCH_PREFLIGHT = 'C:\\Program Files\\Orca\\orca.exe'
|
||||
const CMD_CODEX_LAUNCH_PREFLIGHT =
|
||||
'if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE% agent hooks prepare-codex > nul 2>&1'
|
||||
|
||||
function expectedWslArgs(linuxCwd: string, distro?: string): string[] {
|
||||
const command = `cd '${linuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && ${buildWslInteractiveLoginShellCommand()}`
|
||||
const shellArgs = ['--', 'sh', '-c', escapeWslShCommandForWindows(command)]
|
||||
@@ -24,6 +28,14 @@ function expectedPowerShellRestoreCwdCommand(cwdLiteral: string): string {
|
||||
return `try { Set-Location -LiteralPath ${cwdLiteral} -ErrorAction Stop } catch { Write-Warning "Failed to restore working directory: $_" }`
|
||||
}
|
||||
|
||||
function getGitBashRcfilePath(command: string): string {
|
||||
const rcfilePath = command.match(/--rcfile '([^']+)'/)?.[1]
|
||||
if (!rcfilePath) {
|
||||
throw new Error(`Git Bash launch command has no rcfile: ${command}`)
|
||||
}
|
||||
return rcfilePath
|
||||
}
|
||||
|
||||
describe('resolveWindowsShellLaunchArgs', () => {
|
||||
let previousUserDataPath: string | undefined
|
||||
let userDataPath: string
|
||||
@@ -44,8 +56,15 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
})
|
||||
|
||||
it('returns cmd.exe args with chcp 65001 for UTF-8 output', () => {
|
||||
const result = resolveWindowsShellLaunchArgs('cmd.exe', 'C:\\Users\\alice', 'C:\\Users\\alice')
|
||||
expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul'])
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'cmd.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
undefined,
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
expect(result.shellArgs).toEqual(['/K', `chcp 65001 > nul & ${CMD_CODEX_LAUNCH_PREFLIGHT}`])
|
||||
expect(result.startupCommandDeliveredInShellArgs).toBeUndefined()
|
||||
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
|
||||
expect(result.validationCwd).toBe('C:\\Users\\alice')
|
||||
@@ -57,9 +76,13 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
'codex --no-alt-screen'
|
||||
'codex --no-alt-screen',
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul & codex --no-alt-screen'])
|
||||
expect(result.shellArgs).toEqual([
|
||||
'/K',
|
||||
`chcp 65001 > nul & ${CMD_CODEX_LAUNCH_PREFLIGHT} & codex --no-alt-screen`
|
||||
])
|
||||
expect(result.startupCommandDeliveredInShellArgs).toBe(true)
|
||||
})
|
||||
|
||||
@@ -83,9 +106,10 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
`codex ${'x'.repeat(7000)}`
|
||||
`codex ${'x'.repeat(7000)}`,
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul'])
|
||||
expect(result.shellArgs).toEqual(['/K', `chcp 65001 > nul & ${CMD_CODEX_LAUNCH_PREFLIGHT}`])
|
||||
expect(result.startupCommandDeliveredInShellArgs).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -216,11 +240,32 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
expect(decodePowerShellCommand(result)).toContain(expectedPowerShellRestoreCwdCommand("'C:\\'"))
|
||||
})
|
||||
|
||||
it('does not change PowerShell args when the cmd/Git Bash preflight is provided', () => {
|
||||
const baseline = resolveWindowsShellLaunchArgs(
|
||||
'powershell.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice'
|
||||
)
|
||||
const withWindowsPreflight = resolveWindowsShellLaunchArgs(
|
||||
'powershell.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
undefined,
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
|
||||
expect(withWindowsPreflight).toEqual(baseline)
|
||||
})
|
||||
|
||||
it('starts Git Bash as an interactive login shell with UTF-8 console setup', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
'/c',
|
||||
'C:\\Users\\alice'
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
undefined,
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
|
||||
// Why: Git Bash inherits the ConPTY OEM code page (CP437), so a byte-writing
|
||||
@@ -231,7 +276,8 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
expect(result.shellArgs[0]).toBe('-c')
|
||||
const bashCommand = result.shellArgs[1]
|
||||
expect(bashCommand).toContain('chcp.com 65001')
|
||||
expect(bashCommand).toMatch(/exec "\$BASH" --login -i$/)
|
||||
expect(bashCommand).toContain('--rcfile')
|
||||
expect(bashCommand).toMatch(/exec "\$BASH" .* -i$/)
|
||||
// The UTF-8 switch must run before the login shell is exec'd.
|
||||
expect(bashCommand.indexOf('chcp.com')).toBeLessThan(bashCommand.indexOf('exec'))
|
||||
// Must stay fail-open: `;` (not `&&`) so a missing chcp.com can't abort the
|
||||
@@ -239,6 +285,52 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
expect(bashCommand).not.toContain('&&')
|
||||
expect(result.effectiveCwd).toBe('C:\\')
|
||||
expect(result.validationCwd).toBe('C:\\')
|
||||
|
||||
const bashRcfile = readFileSync(getGitBashRcfilePath(bashCommand), 'utf8')
|
||||
expect(bashRcfile).toContain('"${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex')
|
||||
expect(bashRcfile).not.toContain(CODEX_LAUNCH_PREFLIGHT)
|
||||
})
|
||||
|
||||
it('keeps Git Bash startup commands on stdin delivery with the preflight wrapper', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
'codex --no-alt-screen',
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
|
||||
expect(result.shellArgs[1]).toContain('chcp.com 65001')
|
||||
expect(result.shellArgs[1]).toContain('--rcfile')
|
||||
expect(result.startupCommandDeliveredInShellArgs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('quotes a spaced preflight path through each shell environment', () => {
|
||||
const cmd = resolveWindowsShellLaunchArgs(
|
||||
'cmd.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
undefined,
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
const gitBash = resolveWindowsShellLaunchArgs(
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
undefined,
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
|
||||
expect(cmd.shellArgs[1]).toContain(
|
||||
'call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%'
|
||||
)
|
||||
expect(cmd.shellArgs[1]).not.toContain('"')
|
||||
expect(cmd.shellArgs[1]).not.toContain(CODEX_LAUNCH_PREFLIGHT)
|
||||
const rcfilePath = getGitBashRcfilePath(gitBash.shellArgs[1])
|
||||
expect(readFileSync(rcfilePath, 'utf8')).toContain('"${ORCA_CODEX_LAUNCH_PREFLIGHT}"')
|
||||
})
|
||||
|
||||
it('does not apply Git Bash launch args to unrelated bash.exe paths', () => {
|
||||
@@ -254,13 +346,22 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
})
|
||||
|
||||
it('translates Windows cwd to /mnt/<drive>/... for wsl.exe', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
const baseline = resolveWindowsShellLaunchArgs(
|
||||
'wsl.exe',
|
||||
'C:\\Users\\alice\\code',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
'codex'
|
||||
)
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'wsl.exe',
|
||||
'C:\\Users\\alice\\code',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
'codex',
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
expect(result).toEqual(baseline)
|
||||
expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/code'))
|
||||
expect(result.startupCommandDeliveredInShellArgs).toBeUndefined()
|
||||
// Why: WSL cannot cd into a Windows path, so node-pty must start from the
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
escapeWslShCommandForWindows,
|
||||
quotePosixShell
|
||||
} from '../../shared/wsl-login-shell-command'
|
||||
import { ensureShellReadyWrappersAt } from './local-pty-shell-ready'
|
||||
import { ensureShellReadyWrappersAt, getMarkerlessShellLaunchConfig } from './local-pty-shell-ready'
|
||||
import {
|
||||
encodePowerShellCommand,
|
||||
getPowerShellOsc133Bootstrap
|
||||
@@ -17,6 +17,8 @@ const CMD_EXE_COMMAND_LINE_MAX_CHARS = 8191
|
||||
const STARTUP_COMMAND_TEXT_MAX_CHARS = 6000
|
||||
const POWERSHELL_ENCODED_COMMAND_ARG_MAX_CHARS = 28_000
|
||||
const CMD_UTF8_SETUP_COMMAND = 'chcp 65001 > nul'
|
||||
export const ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV = 'ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE'
|
||||
const CMD_CODEX_LAUNCH_PREFLIGHT = `if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %${ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV}%%ORCA_CODEX_LAUNCH_PREFLIGHT%%${ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV}% agent hooks prepare-codex > nul 2>&1`
|
||||
// Why: Git for Windows' bash inherits the ConPTY console's OEM code page
|
||||
// (CP437), so a TUI that writes UTF-8 bytes straight to the console — agents
|
||||
// like Claude Code use WriteFile, not WriteConsoleW — renders as mojibake
|
||||
@@ -25,6 +27,22 @@ const CMD_UTF8_SETUP_COMMAND = 'chcp 65001 > nul'
|
||||
// `&&`) keeps startup working even if chcp.com is missing.
|
||||
const GIT_BASH_UTF8_LOGIN_COMMAND = 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'
|
||||
|
||||
function getGitBashLaunchCommand(codexLaunchPreflightCommand?: string): string {
|
||||
if (!codexLaunchPreflightCommand) {
|
||||
return GIT_BASH_UTF8_LOGIN_COMMAND
|
||||
}
|
||||
|
||||
ensureShellReadyWrappersAt()
|
||||
const wrapperArgs = getMarkerlessShellLaunchConfig('bash').args
|
||||
if (!wrapperArgs) {
|
||||
return GIT_BASH_UTF8_LOGIN_COMMAND
|
||||
}
|
||||
const bashArgs = [...wrapperArgs, '-i']
|
||||
.map((arg) => (arg.startsWith('-') ? arg : quotePosixShell(arg.replace(/\\/g, '/'))))
|
||||
.join(' ')
|
||||
return `chcp.com 65001 >/dev/null 2>&1; exec "$BASH" ${bashArgs}`
|
||||
}
|
||||
|
||||
/** Result of resolving a Windows shell to its launch args + effective cwd.
|
||||
*
|
||||
* Why this module exists: both the in-process LocalPtyProvider and the
|
||||
@@ -159,20 +177,21 @@ export function resolveWindowsShellLaunchArgs(
|
||||
cwd: string,
|
||||
defaultCwd: string,
|
||||
wslContext?: WindowsShellWslContext,
|
||||
startupCommand?: string
|
||||
startupCommand?: string,
|
||||
codexLaunchPreflightCommand?: string
|
||||
): WindowsShellLaunchArgs {
|
||||
const shellBasename = pathWin32.basename(shellPath).toLowerCase()
|
||||
const nativeCwd = normalizeWindowsTerminalCwd(cwd)
|
||||
|
||||
if (shellBasename === 'cmd.exe') {
|
||||
const shellArgStartupCommand = getCmdShellArgStartupCommand(startupCommand)
|
||||
const startupCommands = [
|
||||
CMD_UTF8_SETUP_COMMAND,
|
||||
...(codexLaunchPreflightCommand ? [CMD_CODEX_LAUNCH_PREFLIGHT] : []),
|
||||
...(shellArgStartupCommand ? [shellArgStartupCommand] : [])
|
||||
]
|
||||
return {
|
||||
shellArgs: [
|
||||
'/K',
|
||||
shellArgStartupCommand
|
||||
? `${CMD_UTF8_SETUP_COMMAND} & ${shellArgStartupCommand}`
|
||||
: CMD_UTF8_SETUP_COMMAND
|
||||
],
|
||||
shellArgs: ['/K', startupCommands.join(' & ')],
|
||||
...(shellArgStartupCommand ? { startupCommandDeliveredInShellArgs: true } : {}),
|
||||
effectiveCwd: nativeCwd,
|
||||
validationCwd: nativeCwd
|
||||
@@ -195,7 +214,7 @@ export function resolveWindowsShellLaunchArgs(
|
||||
|
||||
if (isWindowsGitBashShellPath(shellPath)) {
|
||||
return {
|
||||
shellArgs: ['-c', GIT_BASH_UTF8_LOGIN_COMMAND],
|
||||
shellArgs: ['-c', getGitBashLaunchCommand(codexLaunchPreflightCommand)],
|
||||
effectiveCwd: nativeCwd,
|
||||
validationCwd: nativeCwd
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
linkSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import * as pty from 'node-pty'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { resolveGitBashPath } from '../git-bash'
|
||||
import {
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV,
|
||||
resolveWindowsShellLaunchArgs
|
||||
} from './windows-shell-args'
|
||||
|
||||
const describeWindows = process.platform === 'win32' ? describe : describe.skip
|
||||
const tempDirs: string[] = []
|
||||
|
||||
function makeTempDir(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca shell preflight '))
|
||||
tempDirs.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function linkNodeExecutable(destination: string): void {
|
||||
try {
|
||||
linkSync(process.execPath, destination)
|
||||
} catch {
|
||||
copyFileSync(process.execPath, destination)
|
||||
}
|
||||
}
|
||||
|
||||
function writeFailingPreflight(root: string): string {
|
||||
const executable = join(root, 'orca preflight.exe')
|
||||
linkNodeExecutable(executable)
|
||||
writeFileSync(
|
||||
join(root, 'agent'),
|
||||
[
|
||||
"const { writeFileSync } = require('node:fs')",
|
||||
"if (process.argv.slice(2).join(' ') !== 'hooks prepare-codex') process.exit(2)",
|
||||
"writeFileSync(process.env.ORCA_PREFLIGHT_MARKER, 'ran')",
|
||||
'process.exit(7)'
|
||||
].join('\n')
|
||||
)
|
||||
return executable
|
||||
}
|
||||
|
||||
function withPathEntry(env: NodeJS.ProcessEnv, entry: string): NodeJS.ProcessEnv {
|
||||
const result = { ...env }
|
||||
const pathKey = Object.keys(result).find((key) => key.toLowerCase() === 'path')
|
||||
const inheritedPath = pathKey ? result[pathKey] : ''
|
||||
if (pathKey) {
|
||||
delete result[pathKey]
|
||||
}
|
||||
result.PATH = `${entry};${inheritedPath ?? ''}`
|
||||
return result
|
||||
}
|
||||
|
||||
async function runPty(options: {
|
||||
shellPath: string
|
||||
shellArgs: string[]
|
||||
cwd: string
|
||||
env: NodeJS.ProcessEnv
|
||||
input?: string
|
||||
timeoutMs?: number
|
||||
}): Promise<string> {
|
||||
const proc = pty.spawn(options.shellPath, options.shellArgs, {
|
||||
name: 'xterm-256color',
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
cwd: options.cwd,
|
||||
env: options.env
|
||||
})
|
||||
let output = ''
|
||||
proc.onData((data) => {
|
||||
output += data
|
||||
})
|
||||
const exitPromise = new Promise<number>((resolve) => {
|
||||
proc.onExit(({ exitCode }) => resolve(exitCode))
|
||||
})
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(
|
||||
() => reject(new Error(`timed out waiting for Windows shell PTY:\n${output}`)),
|
||||
options.timeoutMs ?? 10_000
|
||||
)
|
||||
})
|
||||
|
||||
try {
|
||||
if (options.input) {
|
||||
proc.write(options.input.replaceAll('\n', '\r'))
|
||||
}
|
||||
const exitCode = await Promise.race([exitPromise, timeoutPromise])
|
||||
expect(exitCode, output).toBe(0)
|
||||
return output
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
try {
|
||||
proc.kill()
|
||||
} catch {
|
||||
// The PTY may already have exited.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of tempDirs.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describeWindows('Windows Codex shell preflight runtime', () => {
|
||||
it('runs a spaced executable through cmd.exe and continues after failure', async () => {
|
||||
const root = makeTempDir()
|
||||
const preflight = writeFailingPreflight(root)
|
||||
const preflightMarker = join(root, 'cmd-preflight-ran')
|
||||
const startupMarker = join(root, 'cmd-started')
|
||||
const resolved = resolveWindowsShellLaunchArgs(
|
||||
'cmd.exe',
|
||||
root,
|
||||
root,
|
||||
undefined,
|
||||
'echo launched>cmd-started & exit /b 0',
|
||||
preflight
|
||||
)
|
||||
|
||||
await runPty({
|
||||
shellPath: 'cmd.exe',
|
||||
shellArgs: resolved.shellArgs,
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: preflight,
|
||||
[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV]: '"',
|
||||
ORCA_PREFLIGHT_MARKER: preflightMarker
|
||||
}
|
||||
})
|
||||
|
||||
expect(readFileSync(preflightMarker, 'utf8')).toBe('ran')
|
||||
expect(readFileSync(startupMarker, 'utf8').trim()).toBe('launched')
|
||||
})
|
||||
|
||||
it('runs the typed-Codex wrapper through Git Bash without MSYS switch rewriting', async () => {
|
||||
const gitBash = resolveGitBashPath()
|
||||
expect(gitBash).not.toBeNull()
|
||||
if (!gitBash) {
|
||||
return
|
||||
}
|
||||
|
||||
const root = makeTempDir()
|
||||
const preflight = writeFailingPreflight(root)
|
||||
const codexExecutable = join(root, 'codex.exe')
|
||||
linkNodeExecutable(codexExecutable)
|
||||
const preflightMarker = join(root, 'git-bash-preflight-ran')
|
||||
const codexMarker = join(root, 'git-bash-codex-ran')
|
||||
const previousUserDataPath = process.env.ORCA_USER_DATA_PATH
|
||||
process.env.ORCA_USER_DATA_PATH = join(root, 'user data')
|
||||
|
||||
try {
|
||||
const resolved = resolveWindowsShellLaunchArgs(
|
||||
gitBash,
|
||||
root,
|
||||
root,
|
||||
undefined,
|
||||
undefined,
|
||||
preflight
|
||||
)
|
||||
await runPty({
|
||||
shellPath: gitBash,
|
||||
shellArgs: resolved.shellArgs,
|
||||
cwd: root,
|
||||
env: {
|
||||
...withPathEntry(process.env, root),
|
||||
CHERE_INVOKING: '1',
|
||||
HOME: root,
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: preflight,
|
||||
ORCA_PREFLIGHT_MARKER: preflightMarker,
|
||||
ORCA_CODEX_MARKER: codexMarker,
|
||||
TERM: 'xterm-256color'
|
||||
},
|
||||
input:
|
||||
"codex -e \"require('node:fs').writeFileSync(process.env.ORCA_CODEX_MARKER,'ran')\"\nexit\n",
|
||||
// Paired "Windows low spec" QA measured 12.7–15.8s across four runs: Git Bash
|
||||
// cold-starts two large Node executables for AV scanning, so allow 25s without
|
||||
// inflating the faster cmd.exe budget.
|
||||
timeoutMs: 25_000
|
||||
})
|
||||
} finally {
|
||||
if (previousUserDataPath === undefined) {
|
||||
delete process.env.ORCA_USER_DATA_PATH
|
||||
} else {
|
||||
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
|
||||
}
|
||||
}
|
||||
|
||||
expect(existsSync(preflightMarker)).toBe(true)
|
||||
expect(readFileSync(codexMarker, 'utf8')).toBe('ran')
|
||||
})
|
||||
})
|
||||
@@ -73,6 +73,119 @@ describe('adoptProvisionedRootSshCheckout', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a recipe checkout on a branch Orca did not request', async () => {
|
||||
seedRuntime(userDataPath, projectRoot)
|
||||
registerSshGitProvider(connectionId, {
|
||||
listWorktrees: vi
|
||||
.fn()
|
||||
.mockResolvedValue([gitWorktree(projectRoot, { branch: 'refs/heads/wrong-branch' })]),
|
||||
exec: sparseCheckoutProbe(false)
|
||||
} as never)
|
||||
const { store, setWorktreeMeta } = makeStore()
|
||||
|
||||
await expect(
|
||||
adoptProvisionedRootSshCheckout({
|
||||
userDataPath,
|
||||
request: request(projectRoot),
|
||||
repo: repo(projectRoot),
|
||||
store,
|
||||
isRepoCurrent: () => true
|
||||
})
|
||||
).rejects.toThrow('requested branch')
|
||||
expect(setWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a recipe checkout that did not start from the requested ref', async () => {
|
||||
seedRuntime(userDataPath, projectRoot)
|
||||
const exec = sparseCheckoutProbe(false)
|
||||
registerSshGitProvider(connectionId, {
|
||||
listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot, { head: 'wrong-head' })]),
|
||||
exec
|
||||
} as never)
|
||||
const { store, setWorktreeMeta } = makeStore()
|
||||
|
||||
await expect(
|
||||
adoptProvisionedRootSshCheckout({
|
||||
userDataPath,
|
||||
request: {
|
||||
...request(projectRoot),
|
||||
baseBranch: 'v1.2.3',
|
||||
expectedRefHead: 'expected-head'
|
||||
},
|
||||
repo: repo(projectRoot),
|
||||
store,
|
||||
isRepoCurrent: () => true
|
||||
})
|
||||
).rejects.toThrow('requested ref')
|
||||
expect(exec).toHaveBeenCalledOnce()
|
||||
expect(setWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts an attached requested branch created from a tag or commit ref', async () => {
|
||||
seedRuntime(userDataPath, projectRoot)
|
||||
const exec = sparseCheckoutProbe(false)
|
||||
registerSshGitProvider(connectionId, {
|
||||
listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot)]),
|
||||
exec
|
||||
} as never)
|
||||
const { store } = makeStore()
|
||||
|
||||
await expect(
|
||||
adoptProvisionedRootSshCheckout({
|
||||
userDataPath,
|
||||
request: {
|
||||
...request(projectRoot),
|
||||
baseBranch: 'v1.2.3',
|
||||
expectedRefHead: 'abc123'
|
||||
},
|
||||
repo: repo(projectRoot),
|
||||
store,
|
||||
isRepoCurrent: () => true
|
||||
})
|
||||
).resolves.toMatchObject({ worktree: { branch: 'refs/heads/fix-sandbox' } })
|
||||
})
|
||||
|
||||
it('rejects a requested ref without its source-host commit identity', async () => {
|
||||
seedRuntime(userDataPath, projectRoot)
|
||||
registerSshGitProvider(connectionId, {
|
||||
listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot)]),
|
||||
exec: sparseCheckoutProbe(false)
|
||||
} as never)
|
||||
const { store, setWorktreeMeta } = makeStore()
|
||||
|
||||
await expect(
|
||||
adoptProvisionedRootSshCheckout({
|
||||
userDataPath,
|
||||
request: { ...request(projectRoot), baseBranch: 'missing-ref' },
|
||||
repo: repo(projectRoot),
|
||||
store,
|
||||
isRepoCurrent: () => true
|
||||
})
|
||||
).rejects.toThrow('ref identity is missing')
|
||||
expect(setWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses an explicit branch override as the requested local branch', async () => {
|
||||
seedRuntime(userDataPath, projectRoot)
|
||||
registerSshGitProvider(connectionId, {
|
||||
listWorktrees: vi
|
||||
.fn()
|
||||
.mockResolvedValue([gitWorktree(projectRoot, { branch: 'refs/heads/feature/review' })]),
|
||||
exec: sparseCheckoutProbe(false)
|
||||
} as never)
|
||||
const { store } = makeStore()
|
||||
|
||||
await expect(
|
||||
adoptProvisionedRootSshCheckout({
|
||||
userDataPath,
|
||||
request: { ...request(projectRoot), branchNameOverride: 'feature/review' },
|
||||
repo: repo(projectRoot),
|
||||
store,
|
||||
isRepoCurrent: () => true
|
||||
})
|
||||
).resolves.toMatchObject({ worktree: { branch: 'refs/heads/feature/review' } })
|
||||
})
|
||||
|
||||
it('rejects a linked worktree and sparse checkout', async () => {
|
||||
seedRuntime(userDataPath, projectRoot)
|
||||
const listWorktrees = vi
|
||||
|
||||
@@ -90,6 +90,16 @@ export async function adoptProvisionedRootSshCheckout(args: {
|
||||
if (gitWorktree.isSparse || sparseCheckoutEnabled) {
|
||||
throw new Error('Provisioned-root recipes cannot adopt a sparse checkout.')
|
||||
}
|
||||
const requestedBranch = request.branchNameOverride ?? request.name
|
||||
if (gitWorktree.branch !== `refs/heads/${requestedBranch}`) {
|
||||
throw new Error("The recipe projectRoot is not checked out on Orca's requested branch.")
|
||||
}
|
||||
if (request.baseBranch && !request.expectedRefHead) {
|
||||
throw new Error('The requested provisioned-root ref identity is missing.')
|
||||
}
|
||||
if (request.expectedRefHead && gitWorktree.head !== request.expectedRefHead) {
|
||||
throw new Error("The recipe projectRoot was not created from Orca's requested ref.")
|
||||
}
|
||||
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
attachEphemeralVmRuntimeToWorkspace({
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { getBundledLauncherPath } from '../cli/bundled-cli-launcher-path'
|
||||
import { getDaemonBashShellReadyRcfileContent } from '../daemon/shell-ready'
|
||||
import { resolveCodexShellLaunchPreflightCommand } from './codex-shell-launch-preflight'
|
||||
|
||||
// Why (STA-4270): the generated rcfile sources /etc/profile and the user's profile
|
||||
// *before* it defines the codex() wrapper, so a profile PATH prepend decides what an
|
||||
// unqualified preflight command resolves to. This drives the real generated rcfile
|
||||
// through a real bash and uses filesystem markers — not scraped output — as the oracle.
|
||||
|
||||
const roots: string[] = []
|
||||
const bashAvailable = process.platform !== 'win32' && existsSync('/bin/bash')
|
||||
|
||||
type Fixture = {
|
||||
root: string
|
||||
resourcesPath: string
|
||||
intendedMarker: string
|
||||
hijackMarker: string
|
||||
codexMarker: string
|
||||
homePath: string
|
||||
}
|
||||
|
||||
function writeStub(path: string, markerPath: string): void {
|
||||
mkdirSync(join(path, '..'), { recursive: true })
|
||||
writeFileSync(path, `#!/bin/sh\nprintf '%s\\n' "$*" > ${JSON.stringify(markerPath)}\n`)
|
||||
chmodSync(path, 0o755)
|
||||
}
|
||||
|
||||
function buildFixture(): Fixture {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-codex-profile-path-'))
|
||||
roots.push(root)
|
||||
const resourcesPath = join(root, 'resources')
|
||||
const hijackDir = join(root, 'hijack')
|
||||
const codexDir = join(root, 'codex-bin')
|
||||
const homePath = join(root, 'home')
|
||||
const intendedMarker = join(root, 'intended-ran')
|
||||
const hijackMarker = join(root, 'hijack-ran')
|
||||
const codexMarker = join(root, 'codex-ran')
|
||||
mkdirSync(hijackDir, { recursive: true })
|
||||
mkdirSync(codexDir, { recursive: true })
|
||||
mkdirSync(homePath, { recursive: true })
|
||||
|
||||
// The CLI Orca ships, at the absolute path Orca controls.
|
||||
writeStub(getBundledLauncherPath(process.platform, resourcesPath) as string, intendedMarker)
|
||||
// The impostor a user's own bin directory could hold under every CLI name Orca uses.
|
||||
for (const name of ['orca', 'orca-ide', 'orca-dev']) {
|
||||
writeStub(join(hijackDir, name), hijackMarker)
|
||||
}
|
||||
writeStub(join(codexDir, 'codex'), codexMarker)
|
||||
|
||||
// Why: /etc/profile runs first and macOS path_helper rebuilds PATH from /etc/paths,
|
||||
// so the profile must re-prepend both dirs to model a real user's dotfile winning.
|
||||
writeFileSync(
|
||||
join(homePath, '.bash_profile'),
|
||||
`export PATH=${JSON.stringify(codexDir)}:"$PATH"\nexport PATH=${JSON.stringify(hijackDir)}:"$PATH"\n`
|
||||
)
|
||||
return { root, resourcesPath, intendedMarker, hijackMarker, codexMarker, homePath }
|
||||
}
|
||||
|
||||
function launchCodexThroughRcfile(fixture: Fixture, preflightValue: string): void {
|
||||
const rcfilePath = join(fixture.root, 'rcfile')
|
||||
writeFileSync(rcfilePath, getDaemonBashShellReadyRcfileContent(), 'utf8')
|
||||
execFileSync('/bin/bash', ['--rcfile', rcfilePath, '-i', '-c', 'codex --version'], {
|
||||
stdio: 'ignore',
|
||||
env: {
|
||||
HOME: fixture.homePath,
|
||||
// Why: the hijack dir is deliberately absent here — only the profile adds it.
|
||||
PATH: ['/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(delimiter),
|
||||
TERM: 'dumb',
|
||||
SHELL: '/bin/bash',
|
||||
ORCA_SHELL_READY_MARKER: '0',
|
||||
ORCA_SHELL_STARTUP_IDENTITY: '0',
|
||||
ORCA_CODEX_HOME: join(fixture.root, 'codex-home'),
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: preflightValue
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe.skipIf(!bashAvailable)('Codex preflight under a profile-rewritten PATH', () => {
|
||||
it('sources the user profile before emitting the codex wrapper', () => {
|
||||
const rcfile = getDaemonBashShellReadyRcfileContent()
|
||||
|
||||
expect(rcfile.indexOf('source "$HOME/.bash_profile"')).toBeLessThan(
|
||||
rcfile.indexOf('ORCA_CODEX_LAUNCH_PREFLIGHT')
|
||||
)
|
||||
})
|
||||
|
||||
it('runs the bundled CLI the resolver picked, not the impostor the profile put first', () => {
|
||||
const fixture = buildFixture()
|
||||
const preflightCommand = resolveCodexShellLaunchPreflightCommand({
|
||||
hooksEnabled: true,
|
||||
isPackaged: true,
|
||||
managedHomePath: join(fixture.root, 'codex-home'),
|
||||
userDataPath: join(fixture.root, 'user-data'),
|
||||
resourcesPath: fixture.resourcesPath
|
||||
})
|
||||
expect(preflightCommand).toBe(getBundledLauncherPath(process.platform, fixture.resourcesPath))
|
||||
|
||||
launchCodexThroughRcfile(fixture, preflightCommand as string)
|
||||
|
||||
expect(existsSync(fixture.intendedMarker)).toBe(true)
|
||||
expect(existsSync(fixture.hijackMarker)).toBe(false)
|
||||
// Guard against a vacuous pass: the wrapper must still have reached `command codex`.
|
||||
expect(existsSync(fixture.codexMarker)).toBe(true)
|
||||
})
|
||||
|
||||
// Why: pins the defect itself, so a regression back to an unqualified name fails here.
|
||||
it('would run the impostor if the preflight carried an unqualified command name', () => {
|
||||
const fixture = buildFixture()
|
||||
|
||||
launchCodexThroughRcfile(fixture, 'orca')
|
||||
|
||||
expect(existsSync(fixture.hijackMarker)).toBe(true)
|
||||
expect(existsSync(fixture.intendedMarker)).toBe(false)
|
||||
expect(existsSync(fixture.codexMarker)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { delimiter, isAbsolute, join } from 'node:path'
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
@@ -198,21 +198,112 @@ describe('PowerShell Codex shell launch preflight', () => {
|
||||
})
|
||||
|
||||
describe('Codex shell launch preflight command', () => {
|
||||
it('uses the packaged or development CLI only for native managed homes', () => {
|
||||
function makeCliRoot(): { root: string; userDataPath: string; resourcesPath: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-codex-preflight-cli-'))
|
||||
roots.push(root)
|
||||
const userDataPath = join(root, 'user-data')
|
||||
const resourcesPath = join(root, 'resources')
|
||||
mkdirSync(join(userDataPath, 'cli', 'bin'), { recursive: true })
|
||||
mkdirSync(join(resourcesPath, 'bin'), { recursive: true })
|
||||
return { root, userDataPath, resourcesPath }
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ platform: 'darwin' as const, bundled: 'orca' },
|
||||
{ platform: 'linux' as const, bundled: 'orca-ide' },
|
||||
{ platform: 'win32' as const, bundled: 'orca.exe' }
|
||||
])('carries the verified bundled $platform launcher as an absolute path', (config) => {
|
||||
const { userDataPath, resourcesPath } = makeCliRoot()
|
||||
const launcherPath = join(resourcesPath, 'bin', config.bundled)
|
||||
writeExecutable(launcherPath, '#!/bin/sh\nexit 0\n')
|
||||
|
||||
expect(
|
||||
resolveCodexShellLaunchPreflightCommand({
|
||||
hooksEnabled: true,
|
||||
isPackaged: true,
|
||||
managedHomePath: '/managed/home'
|
||||
managedHomePath: '/managed/home',
|
||||
userDataPath,
|
||||
resourcesPath,
|
||||
platform: config.platform
|
||||
})
|
||||
).toBe('orca')
|
||||
).toBe(launcherPath)
|
||||
})
|
||||
|
||||
it('carries the verified dev launcher as an absolute path', () => {
|
||||
const { userDataPath, resourcesPath } = makeCliRoot()
|
||||
const launcherPath = join(userDataPath, 'cli', 'bin', 'orca-dev')
|
||||
writeExecutable(launcherPath, '#!/bin/sh\nexit 0\n')
|
||||
|
||||
expect(
|
||||
resolveCodexShellLaunchPreflightCommand({
|
||||
hooksEnabled: true,
|
||||
isPackaged: false,
|
||||
managedHomePath: '/managed/home'
|
||||
managedHomePath: '/managed/home',
|
||||
userDataPath,
|
||||
resourcesPath,
|
||||
platform: 'darwin'
|
||||
})
|
||||
).toBe('orca-dev')
|
||||
).toBe(launcherPath)
|
||||
})
|
||||
|
||||
it('never returns an unqualified command name that a profile-rewritten PATH could hijack', () => {
|
||||
const { userDataPath, resourcesPath } = makeCliRoot()
|
||||
writeExecutable(join(resourcesPath, 'bin', 'orca'), '#!/bin/sh\nexit 0\n')
|
||||
writeExecutable(join(userDataPath, 'cli', 'bin', 'orca-dev'), '#!/bin/sh\nexit 0\n')
|
||||
|
||||
for (const isPackaged of [true, false]) {
|
||||
const command = resolveCodexShellLaunchPreflightCommand({
|
||||
hooksEnabled: true,
|
||||
isPackaged,
|
||||
managedHomePath: '/managed/home',
|
||||
userDataPath,
|
||||
resourcesPath,
|
||||
platform: 'darwin'
|
||||
})
|
||||
expect(command).not.toBeNull()
|
||||
expect(isAbsolute(command as string)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'the launcher file is missing', create: null },
|
||||
{ label: 'the launcher is not executable', create: 0o644 },
|
||||
{ label: 'the launcher path is a directory', create: 'directory' as const }
|
||||
])('skips the preflight when $label', (config) => {
|
||||
const { userDataPath, resourcesPath } = makeCliRoot()
|
||||
const launcherPath = join(resourcesPath, 'bin', 'orca')
|
||||
if (config.create === 'directory') {
|
||||
mkdirSync(launcherPath)
|
||||
} else if (config.create !== null) {
|
||||
writeFileSync(launcherPath, '#!/bin/sh\nexit 0\n')
|
||||
chmodSync(launcherPath, config.create)
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveCodexShellLaunchPreflightCommand({
|
||||
hooksEnabled: true,
|
||||
isPackaged: true,
|
||||
managedHomePath: '/managed/home',
|
||||
userDataPath,
|
||||
resourcesPath,
|
||||
platform: 'darwin'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('skips the preflight when the packaged build exposes no resources root', () => {
|
||||
const { userDataPath } = makeCliRoot()
|
||||
|
||||
expect(
|
||||
resolveCodexShellLaunchPreflightCommand({
|
||||
hooksEnabled: true,
|
||||
isPackaged: true,
|
||||
managedHomePath: '/managed/home',
|
||||
userDataPath,
|
||||
resourcesPath: null,
|
||||
platform: 'darwin'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -220,6 +311,103 @@ describe('Codex shell launch preflight command', () => {
|
||||
{ hooksEnabled: true, isWsl: true, managedHomePath: '/managed/home' },
|
||||
{ hooksEnabled: true, isWsl: false, managedHomePath: null }
|
||||
])('does not enable an unsupported preflight for %o', (options) => {
|
||||
expect(resolveCodexShellLaunchPreflightCommand({ ...options, isPackaged: true })).toBeNull()
|
||||
const { userDataPath, resourcesPath } = makeCliRoot()
|
||||
writeExecutable(join(resourcesPath, 'bin', 'orca'), '#!/bin/sh\nexit 0\n')
|
||||
|
||||
expect(
|
||||
resolveCodexShellLaunchPreflightCommand({
|
||||
...options,
|
||||
isPackaged: true,
|
||||
userDataPath,
|
||||
resourcesPath,
|
||||
platform: 'darwin'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// Why: the resolved value is now an absolute path, and app bundles (macOS) and
|
||||
// Program Files (Windows) both put spaces in it.
|
||||
describe.skipIf(process.platform === 'win32')('Codex preflight paths containing spaces', () => {
|
||||
function writeSpacedPreflight(root: string): { preflightPath: string; markerPath: string } {
|
||||
const dir = join(root, 'Orca Dev.app', 'Contents', 'Resources', 'bin')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const markerPath = join(root, 'preflight-ran')
|
||||
const preflightPath = join(dir, 'orca')
|
||||
writeExecutable(
|
||||
preflightPath,
|
||||
`#!/bin/sh\n[ "$1 $2 $3" = "agent hooks prepare-codex" ] || exit 2\nprintf ran > ${JSON.stringify(markerPath)}\n`
|
||||
)
|
||||
return { preflightPath, markerPath }
|
||||
}
|
||||
|
||||
it('invokes a POSIX preflight whose absolute path contains spaces', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-codex-spaced-posix-'))
|
||||
roots.push(root)
|
||||
const bin = join(root, 'bin')
|
||||
mkdirSync(bin)
|
||||
writeExecutable(join(bin, 'codex'), '#!/bin/sh\nexit 0\n')
|
||||
const { preflightPath, markerPath } = writeSpacedPreflight(root)
|
||||
|
||||
execFileSync(
|
||||
'/bin/bash',
|
||||
['--noprofile', '--norc', '-c', `${getPosixCodexShellLaunchPreflight()}\ncodex`],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`,
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: preflightPath
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(existsSync(markerPath)).toBe(true)
|
||||
})
|
||||
|
||||
it.skipIf(!fishAvailable)('invokes a fish preflight whose absolute path contains spaces', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-codex-spaced-fish-'))
|
||||
roots.push(root)
|
||||
const bin = join(root, 'bin')
|
||||
mkdirSync(bin)
|
||||
writeExecutable(join(bin, 'codex'), '#!/bin/sh\nexit 0\n')
|
||||
const { preflightPath, markerPath } = writeSpacedPreflight(root)
|
||||
|
||||
execFileSync('fish', ['--no-config', '-c', `${getFishCodexShellLaunchPreflight()}\ncodex`], {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`,
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: preflightPath
|
||||
}
|
||||
})
|
||||
|
||||
expect(existsSync(markerPath)).toBe(true)
|
||||
})
|
||||
|
||||
it.skipIf(!pwshAvailable)(
|
||||
'invokes a PowerShell preflight whose absolute path contains spaces',
|
||||
() => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-codex-spaced-pwsh-'))
|
||||
roots.push(root)
|
||||
const bin = join(root, 'bin')
|
||||
mkdirSync(bin)
|
||||
writeExecutable(join(bin, 'codex'), '#!/bin/sh\nprintf launched\n')
|
||||
const { preflightPath, markerPath } = writeSpacedPreflight(root)
|
||||
|
||||
const result = spawnSync(
|
||||
'pwsh',
|
||||
['-NoLogo', '-NoProfile', '-Command', `${getPowerShellCodexShellLaunchPreflight()}\ncodex`],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`,
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: preflightPath
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(existsSync(markerPath)).toBe(true)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,13 +1,61 @@
|
||||
export function resolveCodexShellLaunchPreflightCommand(options: {
|
||||
import { accessSync, constants, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { getBundledLauncherPath } from '../cli/bundled-cli-launcher-path'
|
||||
|
||||
const DEV_LAUNCHER_DIR = ['cli', 'bin']
|
||||
const DEV_COMMAND_NAME = 'orca-dev'
|
||||
|
||||
export type CodexShellLaunchPreflightCommandOptions = {
|
||||
hooksEnabled: boolean
|
||||
isPackaged: boolean
|
||||
isWsl?: boolean
|
||||
managedHomePath: string | null
|
||||
}): string | null {
|
||||
/** Where the dev launcher is written; `join(userDataPath, 'cli', 'bin')` is also what managed dev PTYs prepend to PATH. */
|
||||
userDataPath: string
|
||||
/** Packaged app resources root; the bundled launcher lives under it. */
|
||||
resourcesPath?: string | null
|
||||
/** Test seam. */
|
||||
platform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
/** Absolute path of the Orca CLI the preflight must execute, or null to skip it.
|
||||
*
|
||||
* Why absolute: the value rides in ORCA_CODEX_LAUNCH_PREFLIGHT and is invoked
|
||||
* from the codex() wrapper, which shell-ready emits *after* the user's profile
|
||||
* scripts run. Those scripts routinely rewrite PATH, so an unqualified name
|
||||
* would be resolved against a PATH Orca neither controls nor can predict —
|
||||
* handing Orca's managed Codex environment to an unidentified program. When no
|
||||
* path verifies, skipping the preflight is the predictable degradation. */
|
||||
export function resolveCodexShellLaunchPreflightCommand(
|
||||
options: CodexShellLaunchPreflightCommandOptions
|
||||
): string | null {
|
||||
if (!options.hooksEnabled || options.isWsl || !options.managedHomePath) {
|
||||
return null
|
||||
}
|
||||
return options.isPackaged ? 'orca' : 'orca-dev'
|
||||
const platform = options.platform ?? process.platform
|
||||
const candidate = options.isPackaged
|
||||
? options.resourcesPath
|
||||
? getBundledLauncherPath(platform, options.resourcesPath)
|
||||
: null
|
||||
: join(
|
||||
options.userDataPath,
|
||||
...DEV_LAUNCHER_DIR,
|
||||
platform === 'win32' ? `${DEV_COMMAND_NAME}.cmd` : DEV_COMMAND_NAME
|
||||
)
|
||||
return candidate && isExecutableFileOnDisk(candidate, platform) ? candidate : null
|
||||
}
|
||||
|
||||
function isExecutableFileOnDisk(path: string, platform: NodeJS.Platform): boolean {
|
||||
try {
|
||||
if (!statSync(path).isFile()) {
|
||||
return false
|
||||
}
|
||||
// Why: Windows has no exec bit, so a readable launcher file is the strongest signal available.
|
||||
accessSync(path, platform === 'win32' ? constants.R_OK : constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function getPosixCodexShellLaunchPreflight(): string {
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('addOrcaWslInteropEnv', () => {
|
||||
ORCA_TERMINAL_HANDLE: 'term_wsl',
|
||||
ORCA_USER_DATA_PATH: 'C:\\Users\\jin\\AppData\\Roaming\\Orca',
|
||||
ORCA_CLI_COMMAND: 'orca-ide',
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: 'orca',
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe',
|
||||
ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts',
|
||||
ORCA_PRIME_AGENT_STATUS_EXTENSION: 'C:\\stale\\orca-agent-status.ts',
|
||||
ORCA_PANE_KEY: 'tab-1:leaf-1',
|
||||
|
||||
@@ -1159,6 +1159,42 @@ class InMemoryOrchestrationMessages {
|
||||
return [...this.runs.values()].find((run) => run.coordinator_pane_key === paneKey)
|
||||
}
|
||||
|
||||
hasUndeliveredDirectMessageForRun(runId: string, directHandle: string): boolean {
|
||||
return this.messages.some(
|
||||
(message) =>
|
||||
message.run_id === runId &&
|
||||
message.to_handle === directHandle &&
|
||||
message.read === 0 &&
|
||||
!message.delivered_at
|
||||
)
|
||||
}
|
||||
|
||||
routeUnreadDirectMessagesToRunMailbox(
|
||||
runId: string,
|
||||
directHandle: string
|
||||
): { routedCount: number; hasMore: boolean; types: MessageType[] } {
|
||||
const routed = this.messages.filter(
|
||||
(message) =>
|
||||
message.run_id === runId && message.to_handle === directHandle && message.read === 0
|
||||
)
|
||||
for (const message of routed) {
|
||||
message.to_handle = `run:${runId}`
|
||||
}
|
||||
return {
|
||||
routedCount: routed.length,
|
||||
hasMore: false,
|
||||
types: [...new Set(routed.map((message) => message.type))]
|
||||
}
|
||||
}
|
||||
|
||||
areUnreadMessages(toHandle: string, ids: string[]): boolean {
|
||||
return ids.every((id) =>
|
||||
this.messages.some(
|
||||
(message) => message.id === id && message.to_handle === toHandle && message.read === 0
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
markAsDelivered(ids: string[]): void {
|
||||
const deliveredIds = new Set(ids)
|
||||
for (const message of this.messages) {
|
||||
@@ -1168,6 +1204,15 @@ class InMemoryOrchestrationMessages {
|
||||
}
|
||||
}
|
||||
|
||||
markAsUndelivered(ids: string[]): void {
|
||||
const releasedIds = new Set(ids)
|
||||
for (const message of this.messages) {
|
||||
if (releasedIds.has(message.id) && message.read === 0) {
|
||||
message.delivered_at = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
|
||||
@@ -1178,6 +1223,15 @@ function setInMemoryOrchestrationMessages(
|
||||
runtime.setOrchestrationDb(db as unknown as OrchestrationDb)
|
||||
}
|
||||
|
||||
function bindSinglePtyRun(db: InMemoryOrchestrationMessages, terminalHandle: string): string {
|
||||
db.setRun({
|
||||
id: 'run_test',
|
||||
coordinator_handle: terminalHandle,
|
||||
coordinator_pane_key: 'tab-1:pane:1'
|
||||
})
|
||||
return 'run:run_test'
|
||||
}
|
||||
|
||||
function expectStablePaneKeyEnv(env: Record<string, string>): string {
|
||||
expect(env.ORCA_TAB_ID).toMatch(UUID_RE)
|
||||
const leafId = env.ORCA_PANE_KEY?.slice(`${env.ORCA_TAB_ID}:`.length)
|
||||
@@ -16720,75 +16774,78 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('waits for Claude output to settle after its first render marker before one submit', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const writes: string[] = []
|
||||
let composerReady = false
|
||||
let prematureEnters = 0
|
||||
let submissions = 0
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
|
||||
write: (_ptyId, data) => {
|
||||
writes.push(data)
|
||||
if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
|
||||
setTimeout(() => {
|
||||
runtime.onPtyData('pty-bg', 'partial redraw without cursor', Date.now())
|
||||
}, 650)
|
||||
setTimeout(() => {
|
||||
runtime.onPtyData('pty-bg', '\x1b[?2', Date.now())
|
||||
}, 750)
|
||||
setTimeout(() => {
|
||||
runtime.onPtyData('pty-bg', '5h intermediate frame', Date.now())
|
||||
}, 751)
|
||||
setTimeout(() => {
|
||||
runtime.onPtyData('pty-bg', 'continued composer render', Date.now())
|
||||
}, 900)
|
||||
setTimeout(() => {
|
||||
composerReady = true
|
||||
runtime.onPtyData('pty-bg', 'final composer frame', Date.now())
|
||||
}, 1_000)
|
||||
}
|
||||
if (data === '\r') {
|
||||
if (composerReady) {
|
||||
submissions += 1
|
||||
} else {
|
||||
prematureEnters += 1
|
||||
it.each(['claude', 'codex'] as const)(
|
||||
'waits for %s output to settle after its first render marker before one submit',
|
||||
async (agent) => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const writes: string[] = []
|
||||
let composerReady = false
|
||||
let prematureEnters = 0
|
||||
let submissions = 0
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
|
||||
write: (_ptyId, data) => {
|
||||
writes.push(data)
|
||||
if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
|
||||
setTimeout(() => {
|
||||
runtime.onPtyData('pty-bg', 'partial redraw without cursor', Date.now())
|
||||
}, 650)
|
||||
setTimeout(() => {
|
||||
runtime.onPtyData('pty-bg', '\x1b[?2', Date.now())
|
||||
}, 750)
|
||||
setTimeout(() => {
|
||||
runtime.onPtyData('pty-bg', '5h intermediate frame', Date.now())
|
||||
}, 751)
|
||||
setTimeout(() => {
|
||||
runtime.onPtyData('pty-bg', 'continued composer render', Date.now())
|
||||
}, 900)
|
||||
setTimeout(() => {
|
||||
composerReady = true
|
||||
runtime.onPtyData('pty-bg', 'final composer frame', Date.now())
|
||||
}, 1_000)
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
const assertAuthority = vi.fn()
|
||||
if (data === '\r') {
|
||||
if (composerReady) {
|
||||
submissions += 1
|
||||
} else {
|
||||
prematureEnters += 1
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
|
||||
launchAgent: agent
|
||||
})
|
||||
const assertAuthority = vi.fn()
|
||||
|
||||
const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', {
|
||||
beforeWrite: assertAuthority
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', {
|
||||
beforeWrite: assertAuthority
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
expect(writes).not.toContain('\r')
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
expect(writes).not.toContain('\r')
|
||||
await vi.advanceTimersByTimeAsync(101)
|
||||
expect(writes).not.toContain('\r')
|
||||
await vi.advanceTimersByTimeAsync(1_748)
|
||||
expect(writes).not.toContain('\r')
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await sendPromise
|
||||
expect(prematureEnters).toBe(0)
|
||||
expect(submissions).toBe(1)
|
||||
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
|
||||
expect(assertAuthority).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
expect(writes).not.toContain('\r')
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
expect(writes).not.toContain('\r')
|
||||
await vi.advanceTimersByTimeAsync(101)
|
||||
expect(writes).not.toContain('\r')
|
||||
await vi.advanceTimersByTimeAsync(1_748)
|
||||
expect(writes).not.toContain('\r')
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await sendPromise
|
||||
expect(prematureEnters).toBe(0)
|
||||
expect(submissions).toBe(1)
|
||||
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
|
||||
expect(assertAuthority).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
it('submits a silent Claude composer once after the bounded render fallback', async () => {
|
||||
vi.useFakeTimers()
|
||||
@@ -16820,6 +16877,46 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('gives a late Codex render marker a fresh quiescence window', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const writes: string[] = []
|
||||
let composerReady = false
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
|
||||
write: (_ptyId, data) => {
|
||||
writes.push(data)
|
||||
if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
|
||||
setTimeout(() => runtime.onPtyData('pty-bg', '\x1b[?25h', Date.now()), 7_900)
|
||||
setTimeout(() => {
|
||||
composerReady = true
|
||||
runtime.onPtyData('pty-bg', 'final slow composer frame', Date.now())
|
||||
}, 8_100)
|
||||
}
|
||||
return true
|
||||
},
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
|
||||
const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change')
|
||||
await vi.advanceTimersByTimeAsync(8_000)
|
||||
expect(writes).not.toContain('\r')
|
||||
await vi.advanceTimersByTimeAsync(1_599)
|
||||
expect(writes).not.toContain('\r')
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await sendPromise
|
||||
expect(composerReady).toBe(true)
|
||||
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds a Claude render that never settles to one fallback submit', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -16848,7 +16945,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change')
|
||||
await vi.advanceTimersByTimeAsync(7_999)
|
||||
await vi.advanceTimersByTimeAsync(8_099)
|
||||
expect(writes).not.toContain('\r')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
@@ -19619,6 +19716,7 @@ describe('OrcaRuntimeService', () => {
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
const mailbox = bindSinglePtyRun(db, terminal.handle)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
db.setActiveCoordinatorRun({ coordinator_handle: 'term_other' })
|
||||
@@ -19633,10 +19731,10 @@ describe('OrcaRuntimeService', () => {
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(write).toHaveBeenCalledWith('pty-1', '\r')
|
||||
|
||||
const unread = db.getUnreadMessages(terminal.handle)
|
||||
const unread = db.getUnreadMessages(mailbox)
|
||||
expect(unread).toHaveLength(1)
|
||||
expect(unread[0].read).toBe(0)
|
||||
expect(unread[0].delivered_at).toBeNull()
|
||||
expect(unread[0].delivered_at).toEqual(expect.any(String))
|
||||
db.close()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
@@ -19658,6 +19756,7 @@ describe('OrcaRuntimeService', () => {
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
const mailbox = bindSinglePtyRun(db, terminal.handle)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
db.setActiveCoordinatorRun({ coordinator_handle: terminal.handle })
|
||||
@@ -19679,10 +19778,10 @@ describe('OrcaRuntimeService', () => {
|
||||
)
|
||||
expect(submitWrites).toHaveLength(1)
|
||||
|
||||
const unread = db.getUnreadMessages(terminal.handle)
|
||||
const unread = db.getUnreadMessages(mailbox)
|
||||
expect(unread).toHaveLength(1)
|
||||
expect(unread[0].read).toBe(0)
|
||||
expect(unread[0].delivered_at).toBeNull()
|
||||
expect(unread[0].delivered_at).toEqual(expect.any(String))
|
||||
db.close()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
@@ -19704,6 +19803,7 @@ describe('OrcaRuntimeService', () => {
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
const mailbox = bindSinglePtyRun(db, terminal.handle)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;\u280b Cursor Agent\x07', 100)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Cursor ready\x07', 101)
|
||||
db.insertMessage({ from: 'term_sender', to: terminal.handle, subject: 'hello cursor' })
|
||||
@@ -19720,10 +19820,10 @@ describe('OrcaRuntimeService', () => {
|
||||
)
|
||||
expect(submitWrites).toHaveLength(0)
|
||||
|
||||
const unread = db.getUnreadMessages(terminal.handle)
|
||||
const unread = db.getUnreadMessages(mailbox)
|
||||
expect(unread).toHaveLength(1)
|
||||
expect(unread[0].read).toBe(0)
|
||||
expect(unread[0].delivered_at).toBeNull()
|
||||
expect(unread[0].delivered_at).toEqual(expect.any(String))
|
||||
db.close()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
@@ -19745,6 +19845,7 @@ describe('OrcaRuntimeService', () => {
|
||||
syncSinglePty(runtime, 'pty-1', { tabTitle: 'cursor-repro-branch' })
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
bindSinglePtyRun(db, terminal.handle)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;. Investigate Cursor Agent\x07', 100)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;* Investigate Cursor Agent\x07', 101)
|
||||
db.insertMessage({ from: 'term_sender', to: terminal.handle, subject: 'hello claude' })
|
||||
@@ -19778,6 +19879,7 @@ describe('OrcaRuntimeService', () => {
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
bindSinglePtyRun(db, terminal.handle)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
db.insertMessage({ from: 'term_sender', to: terminal.handle, subject: 'hello' })
|
||||
@@ -34624,6 +34726,7 @@ describe('OrcaRuntimeService', () => {
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
bindSinglePtyRun(db, terminal.handle)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' })
|
||||
@@ -34646,7 +34749,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(write).not.toHaveBeenCalledWith('pty-1', expect.stringContaining('after wait'))
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(write).toHaveBeenCalledWith('pty-1', '\r')
|
||||
expect(message.delivered_at).toBeNull()
|
||||
expect(message.delivered_at).toEqual(expect.any(String))
|
||||
db.close()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
@@ -34689,7 +34792,7 @@ describe('OrcaRuntimeService', () => {
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
'\nYou have 1 orchestration message. Run `orca orchestration check`.\n'
|
||||
'\nYou have 1 orchestration message. Run `orca orchestration check --run run_mailbox`.\n'
|
||||
)
|
||||
expect(write).not.toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
@@ -34697,7 +34800,7 @@ describe('OrcaRuntimeService', () => {
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(write).toHaveBeenCalledWith('pty-1', '\r')
|
||||
expect(message.delivered_at).toBeNull()
|
||||
expect(message.delivered_at).toEqual(expect.any(String))
|
||||
|
||||
runtime.notifyMessageArrived('run:run_mailbox', 'worker_done')
|
||||
await Promise.resolve()
|
||||
@@ -34772,7 +34875,7 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
expect(pointers()).toHaveLength(2)
|
||||
expect(pointers()[1]?.[1]).toContain('You have 2 orchestration messages')
|
||||
expect(pointers()[1]?.[1]).toContain('You have 1 orchestration message')
|
||||
db.close()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
@@ -34794,6 +34897,7 @@ describe('OrcaRuntimeService', () => {
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
bindSinglePtyRun(db, terminal.handle)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
db.insertMessage({
|
||||
from: 'term_worker',
|
||||
@@ -34845,8 +34949,13 @@ describe('OrcaRuntimeService', () => {
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
bindSinglePtyRun(db, terminal.handle)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
db.insertMessage({ from: 'term_worker', to: terminal.handle, subject: 'wait for idle' })
|
||||
db.insertMessage({
|
||||
from: 'term_worker',
|
||||
to: terminal.handle,
|
||||
subject: 'wait for idle'
|
||||
})
|
||||
runtime.notifyMessageArrived(terminal.handle, 'status')
|
||||
await Promise.resolve()
|
||||
|
||||
@@ -34880,11 +34989,16 @@ describe('OrcaRuntimeService', () => {
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
bindSinglePtyRun(db, terminal.handle)
|
||||
runtime.registerPreAllocatedHandleForPty('pty-1', terminal.handle)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
runtime.markRendererReloading(1)
|
||||
db.insertMessage({ from: 'term_worker', to: terminal.handle, subject: 'restored' })
|
||||
db.insertMessage({
|
||||
from: 'term_worker',
|
||||
to: terminal.handle,
|
||||
subject: 'restored'
|
||||
})
|
||||
setInMemoryOrchestrationMessages(runtime, db)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
@@ -34916,9 +35030,14 @@ describe('OrcaRuntimeService', () => {
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
bindSinglePtyRun(db, terminal.handle)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
db.insertMessage({ from: 'term_worker', to: terminal.handle, subject: 'once' })
|
||||
db.insertMessage({
|
||||
from: 'term_worker',
|
||||
to: terminal.handle,
|
||||
subject: 'once'
|
||||
})
|
||||
runtime.notifyMessageArrived(terminal.handle, 'status')
|
||||
await Promise.resolve()
|
||||
await vi.advanceTimersByTimeAsync(2_500)
|
||||
@@ -34950,6 +35069,7 @@ describe('OrcaRuntimeService', () => {
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
bindSinglePtyRun(db, terminal.handle)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
db.insertMessage({
|
||||
@@ -35037,7 +35157,7 @@ describe('OrcaRuntimeService', () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
'\nYou have 1 orchestration message. Run `orca orchestration check`.\n'
|
||||
'\nYou have 1 orchestration message. Run `orca orchestration check --run run_codex_native_title`.\n'
|
||||
)
|
||||
})
|
||||
db.close()
|
||||
@@ -35056,6 +35176,7 @@ describe('OrcaRuntimeService', () => {
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
bindSinglePtyRun(db, terminal.handle)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
const message = db.insertMessage({
|
||||
from: 'sender',
|
||||
@@ -35089,6 +35210,7 @@ describe('OrcaRuntimeService', () => {
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
bindSinglePtyRun(db, terminal.handle)
|
||||
runtime.seedTerminalRestoreTail('pty-1', { lastTitle: 'Codex done' })
|
||||
const message = db.insertMessage({
|
||||
from: 'sender',
|
||||
@@ -35109,7 +35231,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect.stringContaining('You have 1 orchestration message')
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(600)
|
||||
expect(message.delivered_at).toBeNull()
|
||||
expect(message.delivered_at).toEqual(expect.any(String))
|
||||
db.close()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
@@ -35131,6 +35253,7 @@ describe('OrcaRuntimeService', () => {
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
bindSinglePtyRun(db, terminal.handle)
|
||||
// Why: the persisted title is historical — the agent may have gone busy
|
||||
// across the relaunch, so a seeded 'idle' must not authorize a PTY write.
|
||||
runtime.seedTerminalRestoreTail('pty-1', { lastTitle: 'Codex done' })
|
||||
@@ -35156,7 +35279,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect.stringContaining('You have 1 orchestration message')
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(600)
|
||||
expect(message.delivered_at).toBeNull()
|
||||
expect(message.delivered_at).toEqual(expect.any(String))
|
||||
db.close()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
@@ -35178,6 +35301,7 @@ describe('OrcaRuntimeService', () => {
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
const mailbox = bindSinglePtyRun(db, terminal.handle)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' })
|
||||
@@ -35188,8 +35312,8 @@ describe('OrcaRuntimeService', () => {
|
||||
// shared in-flight promise put a no-waiter notify inside that window, so the
|
||||
// push must not inject rows the resolved check is about to return.
|
||||
const consumed = runtime
|
||||
.waitForMessage(terminal.handle, { timeoutMs: 5_000 })
|
||||
.then(() => db.getUnreadMessages(terminal.handle).map((row) => (row.read = 1)))
|
||||
.waitForMessage(mailbox, { timeoutMs: 5_000 })
|
||||
.then(() => db.getUnreadMessages(mailbox).map((row) => (row.read = 1)))
|
||||
const first = db.insertMessage({ from: 'sender', to: terminal.handle, subject: 'pulled' })
|
||||
runtime.notifyMessageArrived(terminal.handle, 'status')
|
||||
const second = db.insertMessage({
|
||||
@@ -35225,6 +35349,7 @@ describe('OrcaRuntimeService', () => {
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
const mailbox = bindSinglePtyRun(db, terminal.handle)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' })
|
||||
@@ -35234,7 +35359,7 @@ describe('OrcaRuntimeService', () => {
|
||||
// `status` notify is unclaimed and pushes, but the worker_done row landing
|
||||
// in the same drain belongs to this waiter's check — injecting it too would
|
||||
// deliver that completion twice (pane + check return).
|
||||
const waitPromise = runtime.waitForMessage(terminal.handle, {
|
||||
const waitPromise = runtime.waitForMessage(mailbox, {
|
||||
typeFilter: ['worker_done'],
|
||||
timeoutMs: 5_000
|
||||
})
|
||||
@@ -35259,10 +35384,10 @@ describe('OrcaRuntimeService', () => {
|
||||
.map(([, data]) => data)
|
||||
.filter((data): data is string => typeof data === 'string')
|
||||
expect(payloads).toContain(
|
||||
'\nYou have 1 orchestration message. Run `orca orchestration check`.\n'
|
||||
'\nYou have 1 orchestration message. Run `orca orchestration check --run run_test`.\n'
|
||||
)
|
||||
expect(payloads.some((data) => data.includes('reserved completion'))).toBe(false)
|
||||
expect(status.delivered_at).toBeNull()
|
||||
expect(status.delivered_at).toEqual(expect.any(String))
|
||||
expect(done.delivered_at).toBeNull()
|
||||
db.close()
|
||||
} finally {
|
||||
@@ -35285,6 +35410,7 @@ describe('OrcaRuntimeService', () => {
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
const mailbox = bindSinglePtyRun(db, terminal.handle)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' })
|
||||
@@ -35300,7 +35426,7 @@ describe('OrcaRuntimeService', () => {
|
||||
// blocks before the deferred push runs still owns this row, so only the
|
||||
// push-time read of live waiters can keep it out of the pane.
|
||||
runtime.notifyMessageArrived(terminal.handle, 'status')
|
||||
const waitPromise = runtime.waitForMessage(terminal.handle, {
|
||||
const waitPromise = runtime.waitForMessage(mailbox, {
|
||||
typeFilter: ['status'],
|
||||
timeoutMs: 5_000
|
||||
})
|
||||
@@ -35387,6 +35513,7 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
setInMemoryOrchestrationMessages(runtime, db)
|
||||
const [republished] = (await runtime.listTerminals()).terminals
|
||||
bindSinglePtyRun(db, republished.handle)
|
||||
const message = db.insertMessage({
|
||||
from: 'sender',
|
||||
to: republished.handle,
|
||||
@@ -35419,6 +35546,7 @@ describe('OrcaRuntimeService', () => {
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
bindSinglePtyRun(db, terminal.handle)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' })
|
||||
@@ -35444,7 +35572,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect.stringContaining('You have 1 orchestration message')
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(600)
|
||||
expect(message.delivered_at).toBeNull()
|
||||
expect(message.delivered_at).toEqual(expect.any(String))
|
||||
db.close()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
@@ -35476,6 +35604,7 @@ describe('OrcaRuntimeService', () => {
|
||||
runtime.onPtyExit('pty-1', 0)
|
||||
runtime.onPtySpawned('pty-1', undefined, { awaitsRegistration: false })
|
||||
setInMemoryOrchestrationMessages(runtime, db)
|
||||
bindSinglePtyRun(db, terminal.handle)
|
||||
const message = db.insertMessage({
|
||||
from: 'sender',
|
||||
to: terminal.handle,
|
||||
@@ -35498,7 +35627,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect.stringContaining('You have 1 orchestration message')
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(600)
|
||||
expect(message.delivered_at).toBeNull()
|
||||
expect(message.delivered_at).toEqual(expect.any(String))
|
||||
db.close()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
@@ -35520,6 +35649,7 @@ describe('OrcaRuntimeService', () => {
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
const mailbox = bindSinglePtyRun(db, terminal.handle)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' })
|
||||
@@ -35527,7 +35657,7 @@ describe('OrcaRuntimeService', () => {
|
||||
// Why: a `check --wait --types worker_done` waiter never returns a status
|
||||
// row — check re-reads under the same filter — so it is not the consumer
|
||||
// and treating it as one would strand the message (#12536).
|
||||
const waitPromise = runtime.waitForMessage(terminal.handle, {
|
||||
const waitPromise = runtime.waitForMessage(mailbox, {
|
||||
typeFilter: ['worker_done'],
|
||||
timeoutMs: 5_000
|
||||
})
|
||||
@@ -35547,7 +35677,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect.stringContaining('You have 1 orchestration message')
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(600)
|
||||
expect(message.delivered_at).toBeNull()
|
||||
expect(message.delivered_at).toEqual(expect.any(String))
|
||||
|
||||
// The filtered waiter stays blocked; the push did not consume its wake.
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
@@ -35573,6 +35703,7 @@ describe('OrcaRuntimeService', () => {
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
const mailbox = bindSinglePtyRun(db, terminal.handle)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' })
|
||||
@@ -35585,7 +35716,7 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
// Why: blocked orchestration.check --wait is an explicit pull; push must
|
||||
// not stamp delivered_at or type into the pane (double delivery, #12584).
|
||||
const waitPromise = runtime.waitForMessage(terminal.handle, { timeoutMs: 5_000 })
|
||||
const waitPromise = runtime.waitForMessage(mailbox, { timeoutMs: 5_000 })
|
||||
runtime.notifyMessageArrived(terminal.handle, 'status')
|
||||
|
||||
await expect(waitPromise).resolves.toBe('notified')
|
||||
@@ -35612,6 +35743,7 @@ describe('OrcaRuntimeService', () => {
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
bindSinglePtyRun(db, terminal.handle)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' })
|
||||
@@ -35659,6 +35791,7 @@ describe('OrcaRuntimeService', () => {
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
bindSinglePtyRun(db, terminal.handle)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' })
|
||||
@@ -35686,7 +35819,7 @@ describe('OrcaRuntimeService', () => {
|
||||
// timer-only settle (CodeRabbit settling-timeout gap, #12584).
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
expect(write).toHaveBeenCalledWith('pty-1', '\r')
|
||||
expect(first.delivered_at).toBeNull()
|
||||
expect(first.delivered_at).toEqual(expect.any(String))
|
||||
expect(
|
||||
write.mock.calls.filter(
|
||||
([, payload]) =>
|
||||
@@ -35695,9 +35828,9 @@ describe('OrcaRuntimeService', () => {
|
||||
).toHaveLength(2)
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
expect.stringContaining('You have 2 orchestration messages')
|
||||
expect.stringContaining('You have 1 orchestration message')
|
||||
)
|
||||
expect(second.delivered_at).toBeNull()
|
||||
expect(second.delivered_at).toEqual(expect.any(String))
|
||||
db.close()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
@@ -35717,6 +35850,7 @@ describe('OrcaRuntimeService', () => {
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
bindSinglePtyRun(db, terminal.handle)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' })
|
||||
|
||||
+132
-372
@@ -4,7 +4,6 @@
|
||||
import {
|
||||
detectAgentStatusFromTitle,
|
||||
isClaudeManagementTitle,
|
||||
isCursorAgentTitle,
|
||||
isCursorNativeAgentTitle,
|
||||
isOpenCodeNativeTitle,
|
||||
isQuarterCircleSpinnerOnlyAgentTitle,
|
||||
@@ -167,8 +166,14 @@ import {
|
||||
releaseFederationAckCheckpoint
|
||||
} from './orchestration/federation-ack-checkpoints'
|
||||
import { syncFederatedDispatch } from './orchestration/federation-sync'
|
||||
import { formatMessagePointer } from './orchestration/formatter'
|
||||
import { MailPointerRepointScheduler } from './orchestration/mail-pointer-repoint-scheduler'
|
||||
import { OrchestrationMailboxOwner } from './orchestration/mailbox-owner'
|
||||
import { OrchestrationMailboxNotificationCoordinator } from './orchestration/mailbox-notification-coordinator'
|
||||
import { OrchestrationMailboxDeliveryTarget } from './orchestration/mailbox-delivery-target'
|
||||
import {
|
||||
OrchestrationMailboxPointerDelivery,
|
||||
type OrchestrationMessageWaiter
|
||||
} from './orchestration/mailbox-pointer-delivery'
|
||||
import { selectExactWorkerProviderSession } from './orchestration/worker-provider-session'
|
||||
import type {
|
||||
Automation,
|
||||
@@ -1346,13 +1351,6 @@ type RuntimeLeafRecord = RuntimeSyncedLeaf & {
|
||||
paneTitleUpdatedAt: number | null
|
||||
}
|
||||
|
||||
function isCursorAgentOrchestrationTarget(
|
||||
leaf: RuntimeLeafRecord,
|
||||
tabTitle: string | null | undefined
|
||||
): boolean {
|
||||
return [leaf.lastOscTitle, leaf.paneTitle, tabTitle].some(isCursorAgentTitle)
|
||||
}
|
||||
|
||||
type RuntimePtyWorktreeRecord = {
|
||||
ptyId: string
|
||||
incarnationId: PtyIncarnationId | null
|
||||
@@ -1770,6 +1768,7 @@ type RuntimePtyController = {
|
||||
agentSessionEnsure?: AgentSessionClaimedSpawnResult
|
||||
}>
|
||||
write(ptyId: string, data: string): boolean
|
||||
writeWithSettlement?(ptyId: string, data: string): Promise<boolean>
|
||||
/** Attach-only adoption of a live local daemon session so its output streams
|
||||
* to main without a renderer pane; never creates, resizes, or focuses.
|
||||
* False on doubt (absent session, SSH-scoped id, non-daemon provider). */
|
||||
@@ -1878,10 +1877,10 @@ const BRACKETED_PASTE_BEGIN = '\x1b[200~'
|
||||
const BRACKETED_PASTE_END = '\x1b[201~'
|
||||
const BRACKETED_PASTE_QUIET_MS = 1500
|
||||
const DRAFT_PASTE_READY_TIMEOUT_MS = 8000
|
||||
const CLAUDE_AGENT_PROMPT_RENDER_TIMEOUT_MS = 8000
|
||||
const CLAUDE_AGENT_PROMPT_RENDER_QUIET_MS = 1500
|
||||
// Why: Claude emits show-cursor while rendering its composer; output must settle afterward.
|
||||
const CLAUDE_AGENT_PROMPT_RENDER_MARKER = '\x1b[?25h'
|
||||
const AGENT_PROMPT_RENDER_TIMEOUT_MS = 8000
|
||||
const AGENT_PROMPT_RENDER_QUIET_MS = 1500
|
||||
// Why: Claude and Codex emit show-cursor while rendering pasted composer content.
|
||||
const AGENT_PROMPT_RENDER_MARKER = '\x1b[?25h'
|
||||
const MOBILE_TERMINAL_SURFACE_TIMEOUT_MS = 10_000
|
||||
// Why: the split already failed; the caller waits on this teardown only to learn whether the
|
||||
// fallback kill is needed, so keep it short — an unreachable host must not stall the rejection.
|
||||
@@ -2111,9 +2110,8 @@ type TerminalWaiter = {
|
||||
abortCleanup: (() => void) | null
|
||||
}
|
||||
|
||||
type MessageWaiter = {
|
||||
type MessageWaiter = OrchestrationMessageWaiter & {
|
||||
handle: string
|
||||
typeFilter: string[] | undefined
|
||||
resolve: (result: MessageWaitResult) => void
|
||||
timeout: NodeJS.Timeout | null
|
||||
abortCleanup: (() => void) | null
|
||||
@@ -2121,24 +2119,6 @@ type MessageWaiter = {
|
||||
|
||||
export type MessageWaitResult = 'notified' | 'timed_out' | 'cancelled' | 'waiter_exists'
|
||||
|
||||
// Why: an unfiltered waiter claims every type. A row a live waiter will return
|
||||
// from orchestration.check must not also be pushed into the pane — check reads
|
||||
// by `read`, push stamps `delivered_at`, so neither hides the row from the other.
|
||||
function messageTypeHasLiveWaiter(
|
||||
waiters: Set<MessageWaiter> | undefined,
|
||||
messageType: string
|
||||
): boolean {
|
||||
if (!waiters) {
|
||||
return false
|
||||
}
|
||||
for (const waiter of waiters) {
|
||||
if (!waiter.typeFilter || waiter.typeFilter.includes(messageType)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function omitUndefinedProperties<T extends Record<string, unknown>>(value: T): Partial<T> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter(([, entry]) => entry !== undefined)
|
||||
@@ -2913,10 +2893,6 @@ export class OrcaRuntimeService {
|
||||
private handles = new Map<string, TerminalHandleRecord>()
|
||||
private handleByLeafKey = new Map<string, string>()
|
||||
private handleByPtyId = new Map<string, string>()
|
||||
// Why: pointer state is process-local; one harmless replay after restart avoids a wire or schema change.
|
||||
private readonly lastPointedMessageSequenceByHandle = new Map<string, number>()
|
||||
// Why: a waiter can reserve an older row while a newer row advances the sequence watermark.
|
||||
private readonly pointedMessageIdsByHandle = new Map<string, Set<string>>()
|
||||
private readonly mailPointerRepointScheduler = new MailPointerRepointScheduler((handle) =>
|
||||
this.repointPendingMessagesForHandle(handle)
|
||||
)
|
||||
@@ -2959,6 +2935,58 @@ export class OrcaRuntimeService {
|
||||
private ptyDelayedForegroundSnapshotTitleObservations = new Map<string, number>()
|
||||
private _orchestrationDb: OrchestrationDb | null = null
|
||||
private messageWaitersByHandle = new Map<string, Set<MessageWaiter>>()
|
||||
private readonly orchestrationMailboxOwner = new OrchestrationMailboxOwner({
|
||||
getDb: () => this._orchestrationDb,
|
||||
getLeaf: (leafKey) => this.leaves.get(leafKey),
|
||||
getLeafKey: (tabId, leafId) => this.getLeafKey(tabId, leafId),
|
||||
getTerminalHandleForLeafKey: (leafKey) => this.handleByLeafKey.get(leafKey),
|
||||
getTerminalProcessIncarnation: (handle) => this.getTerminalProcessIncarnation(handle),
|
||||
onRoutedMessageTypes: (mailboxHandle, types) =>
|
||||
this.orchestrationMailboxNotifications.wakeRoutedMessageWaiters(mailboxHandle, types),
|
||||
onForeignMailboxRouted: (mailboxHandle, messageType) =>
|
||||
this.notifyMessageArrived(mailboxHandle, messageType)
|
||||
})
|
||||
private readonly orchestrationMailboxDeliveryTarget = new OrchestrationMailboxDeliveryTarget({
|
||||
getDb: () => this._orchestrationDb,
|
||||
getTerminalHandleForPaneKey: (paneKey) => this.getTerminalHandleForPaneKey(paneKey),
|
||||
hasTerminalHandle: (handle) => this.handles.has(handle),
|
||||
canProbePtyLiveness: () => Boolean(this.ptyController?.probePtyLiveness),
|
||||
controllerKnowsPtyIsLive: (ptyId) => this.controllerKnowsPtyIsLive(ptyId),
|
||||
isLeafPtyProvenAbsent: (ptyId) => this.isLeafPtyProvenAbsent(ptyId)
|
||||
})
|
||||
private readonly orchestrationMailboxPointerDelivery =
|
||||
new OrchestrationMailboxPointerDelivery<MessageWaiter>({
|
||||
mailboxOwner: this.orchestrationMailboxOwner,
|
||||
deliveryTarget: this.orchestrationMailboxDeliveryTarget,
|
||||
getDb: () => this._orchestrationDb,
|
||||
getLeaf: (leafKey) => this.leaves.get(leafKey),
|
||||
getLeafKey: (tabId, leafId) => this.getLeafKey(tabId, leafId),
|
||||
getLiveLeafForHandle: (handle) => this.getLiveLeafForHandle(handle).leaf,
|
||||
getMessageWaiters: (mailboxHandle) => this.messageWaitersByHandle.get(mailboxHandle),
|
||||
getTabTitle: (tabId) => this.tabs.get(tabId)?.title,
|
||||
getTerminalHandleForLeafKey: (leafKey) => this.handleByLeafKey.get(leafKey),
|
||||
isLeafPtyProvenAbsent: (ptyId) => this.isLeafPtyProvenAbsent(ptyId),
|
||||
redriveMailbox: (mailboxHandle, reservedTypes) =>
|
||||
this.deliverPendingMessagesForHandle(mailboxHandle, reservedTypes),
|
||||
writePty: (ptyId, data) => this.writeOrchestrationPointerPty(ptyId, data)
|
||||
})
|
||||
private readonly orchestrationMailboxNotifications =
|
||||
new OrchestrationMailboxNotificationCoordinator<MessageWaiter>({
|
||||
mailboxOwner: this.orchestrationMailboxOwner,
|
||||
pointerDelivery: this.orchestrationMailboxPointerDelivery,
|
||||
getDb: () => this._orchestrationDb,
|
||||
getLiveLeafForHandle: (handle) => this.getLiveLeafForHandle(handle).leaf,
|
||||
getPaneKeyForHandle: (handle) => {
|
||||
const record = this.handles.get(handle)
|
||||
return record ? `${record.tabId}:${record.leafId}` : undefined
|
||||
},
|
||||
getMessageWaiters: (mailboxHandle) => this.messageWaitersByHandle.get(mailboxHandle),
|
||||
hasTerminalHandle: (handle) => this.handles.has(handle),
|
||||
deliverForHandle: (handle, reservedTypes) =>
|
||||
this.deliverPendingMessagesForHandle(handle, reservedTypes),
|
||||
notifyMessageArrived: (handle, messageType) => this.notifyMessageArrived(handle, messageType),
|
||||
resolveMessageWaiter: (waiter) => this.resolveMessageWaiter(waiter, 'notified')
|
||||
})
|
||||
// Why: mobile clients subscribe to terminal output via terminal.subscribe.
|
||||
// These listeners fire on every onPtyData call, enabling real-time streaming
|
||||
// without polling. Keyed by ptyId for O(1) lookup per data event.
|
||||
@@ -3957,8 +3985,6 @@ export class OrcaRuntimeService {
|
||||
setOrchestrationDb(db: OrchestrationDb): void {
|
||||
this.stopOrchestrationFederationRelay()
|
||||
this.mailPointerRepointScheduler.clear()
|
||||
this.lastPointedMessageSequenceByHandle.clear()
|
||||
this.pointedMessageIdsByHandle.clear()
|
||||
this._orchestrationDb = db
|
||||
this.ensureOrchestrationFederationRelay()
|
||||
this.scheduleRestoredMessageRepoints()
|
||||
@@ -13938,7 +13964,7 @@ export class OrcaRuntimeService {
|
||||
// Why: a cold restore can respawn under the same session id within the
|
||||
// delayed-Enter window; the armed Enter would inject \r into the
|
||||
// replacement and stamp rows it never received.
|
||||
this.retirePendingMessageDeliveryForPty(ptyId)
|
||||
this.retireOrchestrationMailboxDeliveryForPty(ptyId)
|
||||
|
||||
if (this.terminalFitOverrides.has(ptyId)) {
|
||||
this.terminalFitOverrides.delete(ptyId)
|
||||
@@ -16863,10 +16889,6 @@ export class OrcaRuntimeService {
|
||||
// verdict per ptyId serves the burst instead of a probe round-trip each call.
|
||||
private readonly provenAbsentLeafPtyVerdicts = new Map<string, number>()
|
||||
private readonly leafPtyAbsenceProbes = new Map<string, Promise<boolean>>()
|
||||
// Why: probe dedupe shares one promise across callers, but each caller's
|
||||
// continuation would re-deliver the same unread rows; arm one per pty.
|
||||
private readonly probeDeferredDeliveryPtyIds = new Set<string>()
|
||||
|
||||
private controllerKnowsPtyIsLive(ptyId: string): boolean {
|
||||
try {
|
||||
return this.ptyController?.hasPty?.(ptyId) === true
|
||||
@@ -17571,7 +17593,7 @@ export class OrcaRuntimeService {
|
||||
suffixFailureError?: string
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const renderGate = this.createClaudeAgentPromptRenderGate(ptyId)
|
||||
const renderGate = this.createAgentPromptRenderGate(ptyId)
|
||||
let wrotePasteBytes = false
|
||||
let completedPaste = false
|
||||
try {
|
||||
@@ -17622,13 +17644,14 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
private createClaudeAgentPromptRenderGate(ptyId: string): {
|
||||
private createAgentPromptRenderGate(ptyId: string): {
|
||||
arm: () => void
|
||||
wait: () => Promise<void>
|
||||
dispose: () => void
|
||||
} | null {
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
if ((pty?.launchAgent ?? pty?.foregroundAgent) !== 'claude') {
|
||||
const agent = pty?.launchAgent ?? pty?.foregroundAgent
|
||||
if (agent !== 'claude' && agent !== 'codex') {
|
||||
return null
|
||||
}
|
||||
let armed = false
|
||||
@@ -17661,7 +17684,13 @@ export class OrcaRuntimeService {
|
||||
if (quietTimer) {
|
||||
clearTimeout(quietTimer)
|
||||
}
|
||||
quietTimer = setTimeout(finish, CLAUDE_AGENT_PROMPT_RENDER_QUIET_MS)
|
||||
quietTimer = setTimeout(finish, AGENT_PROMPT_RENDER_QUIET_MS)
|
||||
}
|
||||
const armHardTimer = (): void => {
|
||||
if (hardTimer) {
|
||||
clearTimeout(hardTimer)
|
||||
}
|
||||
hardTimer = setTimeout(finish, AGENT_PROMPT_RENDER_TIMEOUT_MS)
|
||||
}
|
||||
const unsubscribe = this.subscribeToTerminalData(ptyId, (data) => {
|
||||
if (!armed || settled) {
|
||||
@@ -17669,11 +17698,13 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
if (!observedMarker) {
|
||||
const combined = markerCarry + data
|
||||
markerCarry = combined.slice(-(CLAUDE_AGENT_PROMPT_RENDER_MARKER.length - 1))
|
||||
if (!combined.includes(CLAUDE_AGENT_PROMPT_RENDER_MARKER)) {
|
||||
markerCarry = combined.slice(-(AGENT_PROMPT_RENDER_MARKER.length - 1))
|
||||
if (!combined.includes(AGENT_PROMPT_RENDER_MARKER)) {
|
||||
return
|
||||
}
|
||||
observedMarker = true
|
||||
// Why: a slow initial redraw must still receive the full settlement window.
|
||||
armHardTimer()
|
||||
}
|
||||
armQuietTimer()
|
||||
})
|
||||
@@ -17686,7 +17717,9 @@ export class OrcaRuntimeService {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
hardTimer = setTimeout(finish, CLAUDE_AGENT_PROMPT_RENDER_TIMEOUT_MS)
|
||||
if (!hardTimer) {
|
||||
armHardTimer()
|
||||
}
|
||||
await rendered
|
||||
},
|
||||
dispose: () => {
|
||||
@@ -32425,36 +32458,60 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
deliverPendingMessagesForHandle(handle: string, reservedTypes?: ReadonlySet<string>): void {
|
||||
let terminalHandle = handle
|
||||
if (!this.handles.has(terminalHandle)) {
|
||||
const runId = handle.startsWith('run:') ? handle.slice('run:'.length) : ''
|
||||
const coordinatorHandle = runId
|
||||
? this._orchestrationDb?.getRun(runId)?.coordinator_handle
|
||||
: null
|
||||
if (!coordinatorHandle || !this.handles.has(coordinatorHandle)) {
|
||||
return
|
||||
}
|
||||
terminalHandle = coordinatorHandle
|
||||
}
|
||||
this.orchestrationMailboxNotifications.deliverForHandle(handle, reservedTypes)
|
||||
}
|
||||
|
||||
private writeOrchestrationPointerPty(ptyId: string, data: string): boolean | Promise<boolean> {
|
||||
try {
|
||||
const { leaf } = this.getLiveLeafForHandle(terminalHandle)
|
||||
// Why lastAgentStatusObservedLive: a cold restore seeds `idle` from the
|
||||
// title persisted at snapshot time, so an agent that went busy across the
|
||||
// relaunch still reads idle until its first live frame. Pushing on that
|
||||
// would type a message plus Enter into a working agent. Seeded state waits
|
||||
// for a live observation to authorize it.
|
||||
if (leaf.lastAgentStatus === 'idle' && leaf.lastAgentStatusObservedLive) {
|
||||
this.deliverPendingMessages(leaf, { mailboxHandle: handle, reservedTypes })
|
||||
if (this.ptyController?.writeWithSettlement) {
|
||||
return this.ptyController.writeWithSettlement(ptyId, data).catch(() => false)
|
||||
}
|
||||
return this.ptyController?.write(ptyId, data) ?? false
|
||||
} catch {
|
||||
// Unknown/stale handles can't be pointed now; the persisted message stays available via explicit check or future idle delivery.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private retireOrchestrationMailboxDeliveryForPty(ptyId: string): void {
|
||||
this.orchestrationMailboxNotifications.retirePty(ptyId)
|
||||
for (const leaf of this.getLeavesForPty(ptyId)) {
|
||||
const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId))
|
||||
if (handle) {
|
||||
this.mailPointerRepointScheduler.schedule(handle)
|
||||
}
|
||||
const run = this._orchestrationDb?.getCurrentRunForPane?.(`${leaf.tabId}:${leaf.leafId}`)
|
||||
if (run) {
|
||||
this.mailPointerRepointScheduler.schedule(`run:${run.id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRestoredMessageRepoints(): void {
|
||||
const handles = this._orchestrationDb?.getUndeliveredUnreadMailboxHandles?.() ?? []
|
||||
let handles: string[]
|
||||
try {
|
||||
handles = this._orchestrationDb?.getUndeliveredUnreadMailboxHandles?.() ?? []
|
||||
} catch (error) {
|
||||
console.warn('[orchestration] failed to scan restored mailboxes', error)
|
||||
return
|
||||
}
|
||||
for (const handle of handles) {
|
||||
if (!handle.startsWith('dispatch:')) {
|
||||
try {
|
||||
if (handle.startsWith('dispatch:')) {
|
||||
continue
|
||||
}
|
||||
if (handle.startsWith('run:')) {
|
||||
this.mailPointerRepointScheduler.schedule(handle)
|
||||
continue
|
||||
}
|
||||
const routed = this.orchestrationMailboxOwner.routeDetachedDirectMessages(handle)
|
||||
for (const mailbox of routed.mailboxes) {
|
||||
this.mailPointerRepointScheduler.schedule(mailbox.mailboxHandle)
|
||||
}
|
||||
if (!routed.hasMore) {
|
||||
this.mailPointerRepointScheduler.schedule(handle)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[orchestration] failed to restore mailbox ${handle}`, error)
|
||||
this.mailPointerRepointScheduler.schedule(handle)
|
||||
}
|
||||
}
|
||||
@@ -32469,14 +32526,7 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
private deliverPendingMessagesForLeaf(leaf: RuntimeLeafRecord): void {
|
||||
this.deliverPendingMessages(leaf)
|
||||
if (!this._orchestrationDb) {
|
||||
return
|
||||
}
|
||||
const run = this._orchestrationDb.getCurrentRunForPane?.(`${leaf.tabId}:${leaf.leafId}`)
|
||||
if (run) {
|
||||
this.deliverPendingMessages(leaf, { mailboxHandle: `run:${run.id}` })
|
||||
}
|
||||
this.orchestrationMailboxNotifications.deliverForLeaf(leaf)
|
||||
}
|
||||
|
||||
// Why: wake blocking orchestration.check --wait calls on this handle so they return the new message immediately instead of polling.
|
||||
@@ -32484,44 +32534,7 @@ export class OrcaRuntimeService {
|
||||
if (!handle.startsWith('dispatch:')) {
|
||||
this.mailPointerRepointScheduler.schedule(handle)
|
||||
}
|
||||
// Why: push-on-idle is driven by status transitions; a message that
|
||||
// arrives while the recipient is already idle never sees a transition, so
|
||||
// deliver now (#12536). deliverPendingMessagesForHandle no-ops when the
|
||||
// leaf is not idle. Main's messageDeliveryFlights serialize mid-Enter
|
||||
// re-notifies without a separate settle barrier.
|
||||
// Why skip when a waiter will consume this: a blocked check owns the row,
|
||||
// so also pointing and submitting the pane would wake it twice. The pull wins.
|
||||
// Why "will consume" and not "exists": a waiter filtered to other types
|
||||
// never returns this row — check re-reads under the same filter on timeout
|
||||
// — so treating it as the consumer strands the message in exactly the
|
||||
// already-idle state #12536 is about.
|
||||
const waiters = this.messageWaitersByHandle.get(handle)
|
||||
// Why: don't wake a coordinator waiting for worker_done/escalation on heartbeat noise it would misread as idleness.
|
||||
const consumers = waiters
|
||||
? [...waiters].filter(
|
||||
(waiter) => !messageType || !waiter.typeFilter || waiter.typeFilter.includes(messageType)
|
||||
)
|
||||
: []
|
||||
if (consumers.length === 0) {
|
||||
// Why snapshot the reservation here: every remaining waiter filters this
|
||||
// type out, but the push reads ALL pending rows. A waiter resolved later in
|
||||
// this same drain is gone by the time the push runs, so reading waiters
|
||||
// then would miss what its check is about to return. Captured now, the
|
||||
// types those waiters claim stay out of the batch.
|
||||
const reservedTypes = new Set(waiters ? [...waiters].flatMap((w) => w.typeFilter ?? []) : [])
|
||||
// Why queueMicrotask: resolveMessageWaiter removes the waiter synchronously
|
||||
// but its check handler marks the rows read a microtask later. Two sends
|
||||
// that resumed adjacently off one shared in-flight promise (group send
|
||||
// awaits listTerminals) put a no-waiter notify inside that window, where a
|
||||
// synchronous push would inject rows the resolved check is about to return.
|
||||
// Deferring one hop puts the push behind any already-queued check, and it
|
||||
// re-reads undelivered rows when it runs so nothing strands.
|
||||
queueMicrotask(() => this.deliverPendingMessagesForHandle(handle, reservedTypes))
|
||||
return
|
||||
}
|
||||
for (const waiter of consumers) {
|
||||
this.resolveMessageWaiter(waiter, 'notified')
|
||||
}
|
||||
this.orchestrationMailboxNotifications.notifyMessageArrived(handle, messageType)
|
||||
}
|
||||
|
||||
waitForMessage(
|
||||
@@ -33107,259 +33120,6 @@ export class OrcaRuntimeService {
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: the whole pointer→Enter span must be single-flight per pty. Triggers
|
||||
// landing mid-flight park their mailbox and re-run once on settle. The
|
||||
// flight object is the settle identity: a stale settle surviving an exit
|
||||
// retire must not clear a newer same-id flight or flush its parked trigger.
|
||||
private readonly messageDeliveryFlightsByPtyId = new Map<
|
||||
string,
|
||||
{ enterTimer: ReturnType<typeof setTimeout> | null }
|
||||
>()
|
||||
|
||||
private readonly parkedMessageRedeliveriesByPtyId = new Map<
|
||||
string,
|
||||
Map<string, { leaf: RuntimeLeafRecord; reservedTypes?: ReadonlySet<string> }>
|
||||
>()
|
||||
|
||||
private settlePendingMessageDelivery(
|
||||
ptyId: string,
|
||||
flight: { enterTimer: ReturnType<typeof setTimeout> | null }
|
||||
): void {
|
||||
if (this.messageDeliveryFlightsByPtyId.get(ptyId) !== flight) {
|
||||
return
|
||||
}
|
||||
this.messageDeliveryFlightsByPtyId.delete(ptyId)
|
||||
const parked = this.parkedMessageRedeliveriesByPtyId.get(ptyId)
|
||||
if (!parked) {
|
||||
return
|
||||
}
|
||||
this.parkedMessageRedeliveriesByPtyId.delete(ptyId)
|
||||
for (const [mailboxHandle, delivery] of parked) {
|
||||
this.deliverPendingMessages(delivery.leaf, {
|
||||
mailboxHandle,
|
||||
reservedTypes: delivery.reservedTypes
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a dead session's Enter or watermark must not affect a same-id cold restore.
|
||||
private retirePendingMessageDeliveryForPty(ptyId: string): void {
|
||||
const flight = this.messageDeliveryFlightsByPtyId.get(ptyId)
|
||||
if (flight?.enterTimer != null) {
|
||||
clearTimeout(flight.enterTimer)
|
||||
}
|
||||
this.messageDeliveryFlightsByPtyId.delete(ptyId)
|
||||
this.parkedMessageRedeliveriesByPtyId.delete(ptyId)
|
||||
for (const leaf of this.getLeavesForPty(ptyId)) {
|
||||
const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId))
|
||||
if (handle) {
|
||||
this.lastPointedMessageSequenceByHandle.delete(handle)
|
||||
this.pointedMessageIdsByHandle.delete(handle)
|
||||
this.mailPointerRepointScheduler.schedule(handle)
|
||||
}
|
||||
const run = this._orchestrationDb?.getCurrentRunForPane?.(`${leaf.tabId}:${leaf.leafId}`)
|
||||
if (run) {
|
||||
this.lastPointedMessageSequenceByHandle.delete(`run:${run.id}`)
|
||||
this.pointedMessageIdsByHandle.delete(`run:${run.id}`)
|
||||
this.mailPointerRepointScheduler.schedule(`run:${run.id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: normal delivery stays event-driven; the bounded mailbox retry only repairs missed liveness edges.
|
||||
private deliverPendingMessages(
|
||||
leaf: RuntimeLeafRecord,
|
||||
options: {
|
||||
mailboxHandle?: string
|
||||
reservedTypes?: ReadonlySet<string>
|
||||
skipAbsenceProbe?: boolean
|
||||
} = {}
|
||||
): void {
|
||||
if (!this._orchestrationDb) {
|
||||
return
|
||||
}
|
||||
|
||||
const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId))
|
||||
if (!handle) {
|
||||
return
|
||||
}
|
||||
const mailboxHandle = options.mailboxHandle ?? handle
|
||||
|
||||
if (leaf.ptyId && this.messageDeliveryFlightsByPtyId.has(leaf.ptyId)) {
|
||||
let parked = this.parkedMessageRedeliveriesByPtyId.get(leaf.ptyId)
|
||||
if (!parked) {
|
||||
parked = new Map()
|
||||
this.parkedMessageRedeliveriesByPtyId.set(leaf.ptyId, parked)
|
||||
}
|
||||
const priorReservedTypes = parked.get(mailboxHandle)?.reservedTypes
|
||||
const reservedTypes =
|
||||
priorReservedTypes || options.reservedTypes
|
||||
? new Set([...(priorReservedTypes ?? []), ...(options.reservedTypes ?? [])])
|
||||
: undefined
|
||||
parked.set(mailboxHandle, { leaf, reservedTypes })
|
||||
return
|
||||
}
|
||||
|
||||
// Why filter here and not at the trigger: the push reads every pending row,
|
||||
// not just the one that woke it, so a row a pull has claimed would be typed
|
||||
// into the pane AND returned by that pull's check. Live waiters cover the
|
||||
// still-blocked case; reservedTypes carries the notify-time snapshot for a
|
||||
// waiter resolved later in the same drain, which is already gone from the map.
|
||||
const waiters = this.messageWaitersByHandle.get(mailboxHandle)
|
||||
const pending = this._orchestrationDb.getUndeliveredUnreadMessages(mailboxHandle)
|
||||
const pendingIds = new Set(pending.map((message) => message.id))
|
||||
const pointedIds = this.pointedMessageIdsByHandle.get(mailboxHandle)
|
||||
if (pointedIds) {
|
||||
for (const id of pointedIds) {
|
||||
if (!pendingIds.has(id)) {
|
||||
pointedIds.delete(id)
|
||||
}
|
||||
}
|
||||
if (pointedIds.size === 0) {
|
||||
this.pointedMessageIdsByHandle.delete(mailboxHandle)
|
||||
}
|
||||
}
|
||||
const unread = pending.filter(
|
||||
(message) =>
|
||||
!options.reservedTypes?.has(message.type) &&
|
||||
!messageTypeHasLiveWaiter(waiters, message.type)
|
||||
)
|
||||
if (unread.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const watermark = this.lastPointedMessageSequenceByHandle.get(mailboxHandle) ?? -1
|
||||
const priorPointedIds = this.pointedMessageIdsByHandle.get(mailboxHandle)
|
||||
if (
|
||||
!unread.some(
|
||||
(message) => message.sequence > watermark || priorPointedIds?.has(message.id) !== true
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!leaf.writable || !leaf.ptyId) {
|
||||
return
|
||||
}
|
||||
const newestSequence = unread.at(-1)?.sequence
|
||||
if (newestSequence === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!options.skipAbsenceProbe &&
|
||||
this.ptyController?.probePtyLiveness &&
|
||||
!this.controllerKnowsPtyIsLive(leaf.ptyId)
|
||||
) {
|
||||
// Why: a fire-and-forget write to a prior process's ptyId reports success
|
||||
// and would mark these delivered while losing them. Proven absence keeps
|
||||
// them queued for a future surface; unknown liveness still delivers.
|
||||
const probedPtyId = leaf.ptyId
|
||||
// Why: triggers arriving mid-probe must not each arm a continuation — the
|
||||
// Every continuation would re-read the same unread rows. The single armed
|
||||
// continuation re-reads fresh rows when it fires, so nothing is lost.
|
||||
if (this.probeDeferredDeliveryPtyIds.has(probedPtyId)) {
|
||||
return
|
||||
}
|
||||
this.probeDeferredDeliveryPtyIds.add(probedPtyId)
|
||||
void this.isLeafPtyProvenAbsent(probedPtyId)
|
||||
.then((absent) => {
|
||||
this.probeDeferredDeliveryPtyIds.delete(probedPtyId)
|
||||
if (!absent && leaf.ptyId === probedPtyId) {
|
||||
// Why a macrotask and not the stale reservation snapshot: a `remote:`
|
||||
// pty answers the probe null before its first await, so this chain can
|
||||
// settle in microtasks and overtake the resumption of a check resolved
|
||||
// meanwhile — that check's waiter is already out of the map and its
|
||||
// rows are not yet read, so the push would inject what it returns.
|
||||
// Yielding the turn lets every queued check mark its rows read first;
|
||||
// re-reading then (rather than replaying a reservation this probe may
|
||||
// have outlived) is what keeps an orphaned row from stranding.
|
||||
setTimeout(() => {
|
||||
// Why current state, not the closure: the gate that authorized this
|
||||
// push ran before the probe. A same-id cold restore inside the probe
|
||||
// window keeps ptyId identical and makes the leaf writable again, so
|
||||
// an id-only check would type the pointer plus Enter into a process
|
||||
// whose idle was never observed. Re-read the live-idle gate.
|
||||
const currentLeaf = this.leaves.get(this.getLeafKey(leaf.tabId, leaf.leafId))
|
||||
if (
|
||||
currentLeaf?.ptyId === probedPtyId &&
|
||||
currentLeaf.lastAgentStatus === 'idle' &&
|
||||
currentLeaf.lastAgentStatusObservedLive
|
||||
) {
|
||||
this.deliverPendingMessages(currentLeaf, {
|
||||
mailboxHandle,
|
||||
skipAbsenceProbe: true
|
||||
})
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.probeDeferredDeliveryPtyIds.delete(probedPtyId)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const deliveryPtyId = leaf.ptyId
|
||||
const flight: { enterTimer: ReturnType<typeof setTimeout> | null } = { enterTimer: null }
|
||||
this.messageDeliveryFlightsByPtyId.set(deliveryPtyId, flight)
|
||||
// Why: every sync outcome — failed write, Cursor branch, or a throw —
|
||||
// must end the flight here, or a leaked flag parks this pty's deliveries
|
||||
// forever. Only an armed Enter hands settling to its own callback.
|
||||
let settlesInEnterCallback = false
|
||||
try {
|
||||
const payload = formatMessagePointer(unread.length)
|
||||
const wrote = this.ptyController?.write(deliveryPtyId, payload) ?? false
|
||||
if (!wrote) {
|
||||
return
|
||||
}
|
||||
this.lastPointedMessageSequenceByHandle.set(
|
||||
mailboxHandle,
|
||||
Math.max(watermark, newestSequence)
|
||||
)
|
||||
const pointedIdsAfterWrite =
|
||||
this.pointedMessageIdsByHandle.get(mailboxHandle) ?? new Set<string>()
|
||||
for (const message of unread) {
|
||||
pointedIdsAfterWrite.add(message.id)
|
||||
}
|
||||
this.pointedMessageIdsByHandle.set(mailboxHandle, pointedIdsAfterWrite)
|
||||
|
||||
const tabTitle = this.tabs.get(leaf.tabId)?.title
|
||||
if (isCursorAgentOrchestrationTarget(leaf, tabTitle)) {
|
||||
// Why: Cursor Agent treats injected PTY text as editable prompt input, so submitting must stay under user control.
|
||||
return
|
||||
}
|
||||
|
||||
// Why: agent TUIs can swallow a \r in the same PTY write; submit separately after a delay.
|
||||
flight.enterTimer = setTimeout(() => {
|
||||
try {
|
||||
// Why current state, not the closure: graph resync replaces leaf
|
||||
// objects, so the captured record can read writable=true after the
|
||||
// pty died, and an exit retire may have superseded this flight.
|
||||
if (this.messageDeliveryFlightsByPtyId.get(deliveryPtyId) !== flight) {
|
||||
return
|
||||
}
|
||||
const currentLeaf = this.leaves.get(this.getLeafKey(leaf.tabId, leaf.leafId))
|
||||
if (!currentLeaf || currentLeaf.ptyId !== deliveryPtyId || !currentLeaf.writable) {
|
||||
return
|
||||
}
|
||||
this.ptyController?.write(deliveryPtyId, '\r')
|
||||
} catch {
|
||||
// Terminal may have closed during the delay; mail remains queued for check.
|
||||
} finally {
|
||||
// Why finally: every outcome — submit, refusal, throw — ends the flight,
|
||||
// and settle re-runs any trigger parked during it so nothing strands.
|
||||
this.settlePendingMessageDelivery(deliveryPtyId, flight)
|
||||
}
|
||||
}, 500)
|
||||
settlesInEnterCallback = true
|
||||
} finally {
|
||||
if (!settlesInEnterCallback) {
|
||||
this.settlePendingMessageDelivery(deliveryPtyId, flight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resolveWaiter(waiter: TerminalWaiter, result: RuntimeTerminalWait): void {
|
||||
this.removeWaiter(waiter)
|
||||
waiter.resolve(result)
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
import { rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
checkBoundMailbox,
|
||||
createBoundRun,
|
||||
createDatabase,
|
||||
createRuntime,
|
||||
driveToLiveIdle,
|
||||
insertDirectRunMessage,
|
||||
LEAF_ID,
|
||||
PANE_KEY,
|
||||
pointerCount,
|
||||
PTY_ID,
|
||||
sqliteFor,
|
||||
temporaryDirectories,
|
||||
TERMINAL_HANDLE
|
||||
} from './orchestration-mailbox-notification-test-harness'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: vi.fn(() => tmpdir()), isPackaged: false },
|
||||
BrowserWindow: { fromId: vi.fn(() => null) },
|
||||
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
|
||||
webContents: { fromId: vi.fn(() => null) }
|
||||
}))
|
||||
|
||||
describe('orchestration detached mailbox routing', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('routes active worker direct mail without injecting an unpinned Dispatch pointer', async () => {
|
||||
vi.useFakeTimers()
|
||||
const db = createDatabase('orca-mailbox-dispatch-')
|
||||
const harness = createRuntime(db)
|
||||
const run = db.createRun({
|
||||
objective: 'Worker Run',
|
||||
coordinatorHandle: 'term_coordinator',
|
||||
coordinatorPaneKey:
|
||||
'33333333-3333-4333-8333-333333333333:44444444-4444-4444-8444-444444444444'
|
||||
})
|
||||
const task = db.createTask({ spec: 'Worker task', runId: run.id })
|
||||
const dispatch = db.createDispatchContext(task.id, TERMINAL_HANDLE, PANE_KEY)
|
||||
await driveToLiveIdle(harness.runtime)
|
||||
const message = db.insertMessage({
|
||||
from: 'term_coordinator',
|
||||
to: TERMINAL_HANDLE,
|
||||
subject: 'Worker status',
|
||||
runId: run.id
|
||||
})
|
||||
|
||||
harness.runtime.notifyMessageArrived(TERMINAL_HANDLE, message.type)
|
||||
await Promise.resolve()
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
const checked = await checkBoundMailbox(harness.runtime)
|
||||
|
||||
expect(pointerCount(harness.write)).toBe(0)
|
||||
expect(checked).toMatchObject({ runId: run.id, dispatchId: dispatch.id, count: 1 })
|
||||
expect(checked.messages).toEqual([expect.objectContaining({ id: message.id })])
|
||||
expect(db.getMessageById(message.id)).toMatchObject({
|
||||
to_handle: `dispatch:${dispatch.id}`,
|
||||
read: 1,
|
||||
delivered_at: null
|
||||
})
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('normalizes direct mail that arrives while its Run is displaced', async () => {
|
||||
vi.useFakeTimers()
|
||||
const db = createDatabase('orca-mailbox-late-rebind-')
|
||||
const harness = createRuntime(db)
|
||||
const runA = createBoundRun(db, 'Run A')
|
||||
const runB = createBoundRun(db, 'Run B')
|
||||
const rebound = db.bindRun({
|
||||
runId: runA.id,
|
||||
coordinatorHandle: 'term_new_coordinator',
|
||||
coordinatorPaneKey:
|
||||
'55555555-5555-4555-8555-555555555555:66666666-6666-4666-8666-666666666666'
|
||||
})
|
||||
const waiting = harness.runtime.waitForMessage(`run:${runA.id}`, { timeoutMs: 5_000 })
|
||||
const message = db.insertMessage({
|
||||
from: 'term_worker',
|
||||
to: TERMINAL_HANDLE,
|
||||
subject: 'Late Run A status',
|
||||
runId: runA.id
|
||||
})
|
||||
sqliteFor(db)
|
||||
.prepare('UPDATE messages SET to_handle = ? WHERE id = ?')
|
||||
.run(TERMINAL_HANDLE, message.id)
|
||||
harness.runtime.notifyMessageArrived(TERMINAL_HANDLE, message.type)
|
||||
await Promise.resolve()
|
||||
|
||||
await expect(waiting).resolves.toBe('notified')
|
||||
expect(pointerCount(harness.write)).toBe(0)
|
||||
expect(db.getMessageById(message.id)?.to_handle).toBe(`run:${runA.id}`)
|
||||
expect(db.getCurrentRunForPane(PANE_KEY)?.id).toBe(runB.id)
|
||||
|
||||
const delivery = db.getOrCreateRunDelivery({
|
||||
runId: runA.id,
|
||||
consumerGeneration: rebound!.consumer_generation
|
||||
})
|
||||
|
||||
expect(delivery?.messages).toEqual([expect.objectContaining({ id: message.id })])
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('preserves an active Dispatch as owner of displaced direct mail', async () => {
|
||||
vi.useFakeTimers()
|
||||
const db = createDatabase('orca-mailbox-displaced-dispatch-')
|
||||
const harness = createRuntime(db)
|
||||
const workerRun = db.createRun({
|
||||
objective: 'Worker Run',
|
||||
coordinatorHandle: 'term_worker_coordinator',
|
||||
coordinatorPaneKey:
|
||||
'55555555-5555-4555-8555-555555555555:66666666-6666-4666-8666-666666666666'
|
||||
})
|
||||
const task = db.createTask({ spec: 'Worker task', runId: workerRun.id })
|
||||
const dispatch = db.createDispatchContext(
|
||||
task.id,
|
||||
'term_mailbox_before_remint',
|
||||
`99999999-9999-4999-8999-999999999999:${LEAF_ID}`
|
||||
)
|
||||
createBoundRun(db, 'Current coordinator Run')
|
||||
const waiting = harness.runtime.waitForMessage(`dispatch:${dispatch.id}`, {
|
||||
timeoutMs: 5_000
|
||||
})
|
||||
const message = db.insertMessage({
|
||||
from: 'term_worker_coordinator',
|
||||
to: TERMINAL_HANDLE,
|
||||
subject: 'Late worker instruction',
|
||||
runId: workerRun.id
|
||||
})
|
||||
|
||||
harness.runtime.notifyMessageArrived(TERMINAL_HANDLE, message.type)
|
||||
await expect(waiting).resolves.toBe('notified')
|
||||
expect(pointerCount(harness.write)).toBe(0)
|
||||
expect(db.getMessageById(message.id)?.to_handle).toBe(`dispatch:${dispatch.id}`)
|
||||
|
||||
const currentRun = db.getCurrentRunForPane(PANE_KEY)
|
||||
db.bindRun({
|
||||
runId: currentRun!.id,
|
||||
coordinatorHandle: 'term_rebound_coordinator',
|
||||
coordinatorPaneKey:
|
||||
'77777777-7777-4777-8777-777777777777:88888888-8888-4888-8888-888888888888'
|
||||
})
|
||||
const checked = await checkBoundMailbox(harness.runtime)
|
||||
expect(checked).toMatchObject({ runId: workerRun.id, dispatchId: dispatch.id, count: 1 })
|
||||
expect(checked.messages).toEqual([expect.objectContaining({ id: message.id })])
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('uses bounded ownership lookup for direct arrivals while the agent is working', async () => {
|
||||
vi.useFakeTimers()
|
||||
const db = createDatabase('orca-mailbox-bounded-')
|
||||
const harness = createRuntime(db)
|
||||
const run = createBoundRun(db, 'Working Run')
|
||||
await harness.runtime.listTerminals()
|
||||
harness.runtime.onPtyData(PTY_ID, '\x1b]0;Codex working\x07', 1)
|
||||
const fullMailboxScan = vi.spyOn(db, 'getUndeliveredUnreadMessages')
|
||||
const message = insertDirectRunMessage(db, run.id, 'Direct coordinator status')
|
||||
|
||||
harness.runtime.notifyMessageArrived(TERMINAL_HANDLE, message.type)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(fullMailboxScan).not.toHaveBeenCalled()
|
||||
expect(pointerCount(harness.write)).toBe(0)
|
||||
expect(db.getMessageById(message.id)?.to_handle).toBe(`run:${run.id}`)
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('wakes a bound Run waiter when same-Run direct mail arrives', async () => {
|
||||
vi.useFakeTimers()
|
||||
const db = createDatabase('orca-mailbox-waiter-')
|
||||
const harness = createRuntime(db)
|
||||
const run = createBoundRun(db, 'Waiting Run')
|
||||
await driveToLiveIdle(harness.runtime)
|
||||
|
||||
const waiting = checkBoundMailbox(harness.runtime, { wait: true })
|
||||
const internals = harness.runtime as unknown as {
|
||||
messageWaitersByHandle: Map<string, Set<unknown>>
|
||||
}
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
if (internals.messageWaitersByHandle.has(`run:${run.id}`)) {
|
||||
break
|
||||
}
|
||||
await Promise.resolve()
|
||||
}
|
||||
expect(internals.messageWaitersByHandle.has(`run:${run.id}`)).toBe(true)
|
||||
|
||||
const message = insertDirectRunMessage(db, run.id, 'Wake the Run waiter')
|
||||
harness.runtime.notifyMessageArrived(TERMINAL_HANDLE, message.type)
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
|
||||
await expect(waiting).resolves.toMatchObject({
|
||||
runId: run.id,
|
||||
count: 1,
|
||||
messages: [expect.objectContaining({ id: message.id })]
|
||||
})
|
||||
expect(pointerCount(harness.write)).toBe(0)
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('routes a stale-leaf arrival to its bound Run waiter', async () => {
|
||||
const db = createDatabase('orca-mailbox-stale-leaf-waiter-')
|
||||
const harness = createRuntime(db)
|
||||
const run = createBoundRun(db, 'Stale leaf waiter')
|
||||
const waiting = checkBoundMailbox(harness.runtime, { wait: true })
|
||||
const waiterInternals = harness.runtime as unknown as {
|
||||
messageWaitersByHandle: Map<string, Set<unknown>>
|
||||
}
|
||||
await vi.waitFor(() => {
|
||||
expect(waiterInternals.messageWaitersByHandle.has(`run:${run.id}`)).toBe(true)
|
||||
})
|
||||
const internals = harness.runtime as unknown as { leaves: Map<string, unknown> }
|
||||
internals.leaves.clear()
|
||||
const message = insertDirectRunMessage(db, run.id, 'Arrived during graph remount')
|
||||
|
||||
harness.runtime.notifyMessageArrived(TERMINAL_HANDLE, message.type)
|
||||
|
||||
await expect(waiting).resolves.toMatchObject({
|
||||
runId: run.id,
|
||||
count: 1,
|
||||
messages: [expect.objectContaining({ id: message.id })]
|
||||
})
|
||||
expect(db.getMessageById(message.id)?.to_handle).toBe(`run:${run.id}`)
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('keeps an unowned detached-recipient message in its direct mailbox', async () => {
|
||||
const db = createDatabase('orca-mailbox-detached-direct-')
|
||||
const harness = createRuntime(db)
|
||||
const run = createBoundRun(db, 'Detached direct recipient')
|
||||
const detachedHandle = 'term_detached_worker'
|
||||
const waiting = harness.runtime.waitForMessage(detachedHandle, { timeoutMs: 5_000 })
|
||||
const message = db.insertMessage({
|
||||
from: TERMINAL_HANDLE,
|
||||
to: detachedHandle,
|
||||
subject: 'Instruction before Dispatch ownership exists',
|
||||
type: 'dispatch',
|
||||
runId: run.id,
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
|
||||
harness.runtime.notifyMessageArrived(detachedHandle, message.type)
|
||||
|
||||
await expect(waiting).resolves.toBe('notified')
|
||||
expect(db.getMessageById(message.id)?.to_handle).toBe(detachedHandle)
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('wakes canonical and direct waiters for mixed detached ownership', async () => {
|
||||
const db = createDatabase('orca-mailbox-detached-mixed-')
|
||||
const harness = createRuntime(db)
|
||||
const detachedHandle = 'term_retired_coordinator'
|
||||
const ownedRun = db.createRun({
|
||||
objective: 'Retired coordinator ownership',
|
||||
coordinatorHandle: detachedHandle,
|
||||
coordinatorPaneKey:
|
||||
'55555555-5555-4555-8555-555555555555:66666666-6666-4666-8666-666666666666'
|
||||
})
|
||||
const directRun = createBoundRun(db, 'Unowned detached recipient')
|
||||
const canonicalWait = harness.runtime.waitForMessage(`run:${ownedRun.id}`, {
|
||||
timeoutMs: 5_000
|
||||
})
|
||||
const directWait = harness.runtime.waitForMessage(detachedHandle, { timeoutMs: 5_000 })
|
||||
const owned = db.insertMessage({
|
||||
from: 'term_worker',
|
||||
to: detachedHandle,
|
||||
subject: 'Owned old coordinator mail',
|
||||
type: 'status',
|
||||
runId: ownedRun.id,
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
sqliteFor(db)
|
||||
.prepare('UPDATE messages SET to_handle = ? WHERE id = ?')
|
||||
.run(detachedHandle, owned.id)
|
||||
const direct = db.insertMessage({
|
||||
from: TERMINAL_HANDLE,
|
||||
to: detachedHandle,
|
||||
subject: 'Unowned direct mail',
|
||||
type: 'status',
|
||||
runId: directRun.id,
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
|
||||
harness.runtime.notifyMessageArrived(detachedHandle, 'status')
|
||||
|
||||
await expect(Promise.all([canonicalWait, directWait])).resolves.toEqual([
|
||||
'notified',
|
||||
'notified'
|
||||
])
|
||||
expect(db.getMessageById(owned.id)?.to_handle).toBe(`run:${ownedRun.id}`)
|
||||
expect(db.getMessageById(direct.id)?.to_handle).toBe(detachedHandle)
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('does not wake a filtered direct waiter for a routed type', async () => {
|
||||
const db = createDatabase('orca-mailbox-detached-filtered-direct-')
|
||||
const harness = createRuntime(db)
|
||||
const detachedHandle = 'term_detached_filtered'
|
||||
const ownedRun = db.createRun({
|
||||
objective: 'Owned detached mail',
|
||||
coordinatorHandle: detachedHandle,
|
||||
coordinatorPaneKey:
|
||||
'55555555-5555-4555-8555-555555555555:66666666-6666-4666-8666-666666666666'
|
||||
})
|
||||
const unownedRun = createBoundRun(db, 'Unowned detached mail')
|
||||
const waiting = harness.runtime.waitForMessage(detachedHandle, {
|
||||
typeFilter: ['worker_done'],
|
||||
timeoutMs: 5_000
|
||||
})
|
||||
let settled = false
|
||||
void waiting.then(() => {
|
||||
settled = true
|
||||
})
|
||||
db.insertMessage({
|
||||
from: TERMINAL_HANDLE,
|
||||
to: detachedHandle,
|
||||
subject: 'Unowned status',
|
||||
type: 'status',
|
||||
runId: unownedRun.id,
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
const owned = db.insertMessage({
|
||||
from: 'term_worker',
|
||||
to: detachedHandle,
|
||||
subject: 'Owned completion',
|
||||
type: 'worker_done',
|
||||
runId: ownedRun.id,
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
sqliteFor(db)
|
||||
.prepare('UPDATE messages SET to_handle = ? WHERE id = ?')
|
||||
.run(detachedHandle, owned.id)
|
||||
|
||||
harness.runtime.notifyMessageArrived(detachedHandle, owned.type)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(settled).toBe(false)
|
||||
expect(db.getMessageById(owned.id)?.to_handle).toBe(`run:${ownedRun.id}`)
|
||||
harness.runtime.cancelMessageWaiters(detachedHandle)
|
||||
await expect(waiting).resolves.toBe('cancelled')
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('routes a stale-leaf reminted handle to its Dispatch waiter', async () => {
|
||||
const db = createDatabase('orca-mailbox-stale-leaf-reminted-dispatch-')
|
||||
const harness = createRuntime(db)
|
||||
const run = db.createRun({
|
||||
objective: 'Reminted Dispatch',
|
||||
coordinatorHandle: 'term_coordinator',
|
||||
coordinatorPaneKey:
|
||||
'55555555-5555-4555-8555-555555555555:66666666-6666-4666-8666-666666666666'
|
||||
})
|
||||
const task = db.createTask({ spec: 'Reminted worker', runId: run.id })
|
||||
const dispatch = db.createDispatchContext(task.id, 'term_before_remint', PANE_KEY)
|
||||
const waiting = harness.runtime.waitForMessage(`dispatch:${dispatch.id}`, {
|
||||
typeFilter: ['dispatch'],
|
||||
timeoutMs: 5_000
|
||||
})
|
||||
const internals = harness.runtime as unknown as { leaves: Map<string, unknown> }
|
||||
internals.leaves.clear()
|
||||
const message = db.insertMessage({
|
||||
from: 'term_coordinator',
|
||||
to: TERMINAL_HANDLE,
|
||||
subject: 'Instruction after remint',
|
||||
type: 'dispatch',
|
||||
runId: run.id,
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
|
||||
harness.runtime.notifyMessageArrived(TERMINAL_HANDLE, message.type)
|
||||
|
||||
await expect(waiting).resolves.toBe('notified')
|
||||
expect(db.getMessageById(message.id)?.to_handle).toBe(`dispatch:${dispatch.id}`)
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('deduplicates Dispatch ownership before limiting a routing page', () => {
|
||||
const db = createDatabase('orca-mailbox-duplicate-dispatch-owners-')
|
||||
const run = db.createRun({
|
||||
objective: 'Duplicate Dispatch ownership',
|
||||
coordinatorHandle: 'term_coordinator',
|
||||
coordinatorPaneKey:
|
||||
'55555555-5555-4555-8555-555555555555:66666666-6666-4666-8666-666666666666'
|
||||
})
|
||||
const task = db.createTask({ spec: 'Duplicate worker ownership', runId: run.id })
|
||||
const detachedHandle = 'term_duplicate_dispatch_owner'
|
||||
const sqlite = sqliteFor(db)
|
||||
const insertDispatch = sqlite.prepare(
|
||||
`INSERT INTO dispatch_contexts (id, run_id, task_id, assignee_handle, status)
|
||||
VALUES (?, ?, ?, ?, 'dispatched')`
|
||||
)
|
||||
for (let index = 0; index < 51; index += 1) {
|
||||
insertDispatch.run(`ctx_duplicate_${index}`, run.id, task.id, detachedHandle)
|
||||
}
|
||||
const messages = Array.from({ length: 52 }, (_, index) =>
|
||||
db.insertMessage({
|
||||
from: 'term_coordinator',
|
||||
to: detachedHandle,
|
||||
subject: `Dispatch message ${index}`,
|
||||
type: 'dispatch',
|
||||
runId: run.id,
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
)
|
||||
|
||||
expect(db.routeForeignDirectMessagesToOwnedMailboxes(detachedHandle)).toMatchObject({
|
||||
routedCount: 50,
|
||||
hasMore: true
|
||||
})
|
||||
expect(db.routeForeignDirectMessagesToOwnedMailboxes(detachedHandle)).toMatchObject({
|
||||
routedCount: 2,
|
||||
hasMore: false
|
||||
})
|
||||
expect(
|
||||
messages.every((message) => db.getMessageById(message.id)?.to_handle.startsWith('dispatch:'))
|
||||
).toBe(true)
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('does not rebind active Dispatch mail through coordinator history', () => {
|
||||
const db = createDatabase('orca-mailbox-coordinator-dispatch-overlap-')
|
||||
const run = db.createRun({
|
||||
objective: 'Coordinator and Dispatch overlap',
|
||||
coordinatorHandle: TERMINAL_HANDLE,
|
||||
coordinatorPaneKey: PANE_KEY
|
||||
})
|
||||
const task = db.createTask({ spec: 'Same-handle worker', runId: run.id })
|
||||
db.createDispatchContext(task.id, TERMINAL_HANDLE, PANE_KEY)
|
||||
const message = db.insertMessage({
|
||||
from: 'term_sender',
|
||||
to: TERMINAL_HANDLE,
|
||||
subject: 'Dispatch-owned mail',
|
||||
type: 'dispatch',
|
||||
runId: run.id,
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
|
||||
db.bindRun({
|
||||
runId: run.id,
|
||||
coordinatorHandle: 'term_new_coordinator',
|
||||
coordinatorPaneKey:
|
||||
'55555555-5555-4555-8555-555555555555:66666666-6666-4666-8666-666666666666'
|
||||
})
|
||||
|
||||
expect(db.getMessageById(message.id)?.to_handle).toBe(TERMINAL_HANDLE)
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('does not route active Dispatch mail through a paged Run check', () => {
|
||||
const db = createDatabase('orca-mailbox-paged-coordinator-dispatch-overlap-')
|
||||
const run = db.createRun({
|
||||
objective: 'Paged coordinator and Dispatch overlap',
|
||||
coordinatorHandle: TERMINAL_HANDLE,
|
||||
coordinatorPaneKey: PANE_KEY
|
||||
})
|
||||
const task = db.createTask({ spec: 'Same-handle worker', runId: run.id })
|
||||
const dispatch = db.createDispatchContext(task.id, TERMINAL_HANDLE, PANE_KEY)
|
||||
const message = db.insertMessage({
|
||||
from: 'term_sender',
|
||||
to: TERMINAL_HANDLE,
|
||||
subject: 'Dispatch-owned paged mail',
|
||||
type: 'dispatch',
|
||||
runId: run.id,
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
|
||||
expect(db.routeUnreadDirectMessagesToRunMailbox(run.id, TERMINAL_HANDLE)).toMatchObject({
|
||||
routedCount: 0,
|
||||
hasMore: false
|
||||
})
|
||||
expect(db.getMessageById(message.id)?.to_handle).toBe(TERMINAL_HANDLE)
|
||||
expect(
|
||||
db.routeUnreadDirectMessagesToDispatchMailbox(dispatch.id, run.id, TERMINAL_HANDLE)
|
||||
).toMatchObject({ routedCount: 1, hasMore: false })
|
||||
expect(db.getMessageById(message.id)?.to_handle).toBe(`dispatch:${dispatch.id}`)
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('uses primary-key lookups for bounded routing updates', () => {
|
||||
const db = createDatabase('orca-mailbox-bounded-routing-indexes-')
|
||||
const sqlite = sqliteFor(db)
|
||||
const run = db.createRun({
|
||||
objective: 'Bounded routing indexes',
|
||||
coordinatorHandle: TERMINAL_HANDLE,
|
||||
coordinatorPaneKey: PANE_KEY
|
||||
})
|
||||
const directMessage = db.insertMessage({
|
||||
from: 'term_sender',
|
||||
to: TERMINAL_HANDLE,
|
||||
subject: 'Direct Run mail',
|
||||
type: 'status',
|
||||
runId: run.id,
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
sqlite
|
||||
.prepare('UPDATE messages SET to_handle = ? WHERE id = ?')
|
||||
.run(TERMINAL_HANDLE, directMessage.id)
|
||||
const task = db.createTask({ spec: 'Dispatch migration', runId: run.id })
|
||||
const dispatch = db.createDispatchContext(task.id, 'term_dispatch', PANE_KEY)
|
||||
db.insertMessage({
|
||||
from: 'term_sender',
|
||||
to: `dispatch:${dispatch.id}`,
|
||||
subject: 'Dispatch mailbox mail',
|
||||
type: 'status',
|
||||
runId: run.id,
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
const foreignHandle = 'term_foreign_bounded_routing'
|
||||
const foreignRun = db.createRun({
|
||||
objective: 'Foreign routing index',
|
||||
coordinatorHandle: foreignHandle,
|
||||
coordinatorPaneKey:
|
||||
'55555555-5555-4555-8555-555555555555:66666666-6666-4666-8666-666666666666'
|
||||
})
|
||||
const foreignMessage = db.insertMessage({
|
||||
from: 'term_sender',
|
||||
to: foreignHandle,
|
||||
subject: 'Foreign direct mail',
|
||||
type: 'status',
|
||||
runId: foreignRun.id,
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
sqlite
|
||||
.prepare('UPDATE messages SET to_handle = ? WHERE id = ?')
|
||||
.run(foreignHandle, foreignMessage.id)
|
||||
const prepare = vi.spyOn(sqlite, 'prepare')
|
||||
|
||||
db.routeUnreadDirectMessagesToRunMailbox(run.id, TERMINAL_HANDLE)
|
||||
db.routeUnreadDispatchMailboxToRunMailbox(dispatch.id, run.id)
|
||||
db.routeForeignDirectMessagesToOwnedMailboxes(foreignHandle)
|
||||
|
||||
const updateSql = [
|
||||
...new Set(
|
||||
prepare.mock.calls
|
||||
.map(([sql]) => sql)
|
||||
.filter((sql) => sql.includes('UPDATE messages') && sql.includes('id IN'))
|
||||
)
|
||||
]
|
||||
expect(updateSql).toHaveLength(3)
|
||||
for (const sql of updateSql) {
|
||||
expect(sql).toContain('INDEXED BY idx_messages_id')
|
||||
const parameters = Array.from({ length: sql.split('?').length - 1 }, () => 'probe')
|
||||
const plan = sqlite.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...parameters) as {
|
||||
detail: string
|
||||
}[]
|
||||
expect(plan.map((row) => row.detail).join(' ')).toContain('idx_messages_id')
|
||||
}
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,775 @@
|
||||
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 { OrchestrationDb } from './orchestration/db'
|
||||
import {
|
||||
checkBoundMailbox,
|
||||
createBoundRun,
|
||||
createDatabase,
|
||||
createRuntime,
|
||||
driveToLiveIdle,
|
||||
insertDirectRunMessage,
|
||||
LAUNCH_TOKEN,
|
||||
LEAF_ID,
|
||||
PANE_KEY,
|
||||
pointerCount,
|
||||
PTY_ID,
|
||||
registerSecondPane,
|
||||
SECOND_LAUNCH_TOKEN,
|
||||
SECOND_LEAF_ID,
|
||||
SECOND_PANE_KEY,
|
||||
SECOND_PTY_ID,
|
||||
SECOND_TERMINAL_HANDLE,
|
||||
sqliteFor,
|
||||
temporaryDirectories,
|
||||
TERMINAL_HANDLE
|
||||
} from './orchestration-mailbox-notification-test-harness'
|
||||
import { RpcDispatcher } from './rpc/dispatcher'
|
||||
import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: vi.fn(() => tmpdir()), isPackaged: false },
|
||||
BrowserWindow: { fromId: vi.fn(() => null) },
|
||||
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
|
||||
webContents: { fromId: vi.fn(() => null) }
|
||||
}))
|
||||
|
||||
describe('orchestration notification mailbox consistency', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('never points direct Run A mail after the pane is rebound to Run B', async () => {
|
||||
vi.useFakeTimers()
|
||||
const db = createDatabase('orca-mailbox-consistency-')
|
||||
const first = createRuntime(db)
|
||||
const runA = createBoundRun(db, 'Run A')
|
||||
const staleDirectMessage = insertDirectRunMessage(db, runA.id, 'Run A completion')
|
||||
const runB = createBoundRun(db, 'Run B')
|
||||
|
||||
await driveToLiveIdle(first.runtime)
|
||||
const checked = await checkBoundMailbox(first.runtime)
|
||||
const mismatch = pointerCount(first.write) > 0 && checked.count === 0
|
||||
|
||||
expect(mismatch).toBe(false)
|
||||
expect(checked).toMatchObject({
|
||||
runId: runB.id,
|
||||
deliveryId: null,
|
||||
count: 0,
|
||||
messages: []
|
||||
})
|
||||
expect(db.getMessageById(staleDirectMessage.id)).toMatchObject({
|
||||
read: 0,
|
||||
delivered_at: null
|
||||
})
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('does not point unbound direct mail that a later Run binding would hide', async () => {
|
||||
vi.useFakeTimers()
|
||||
const db = createDatabase('orca-mailbox-unbound-bind-race-')
|
||||
const harness = createRuntime(db)
|
||||
const runA = createBoundRun(db, 'Unbound Run A')
|
||||
const message = insertDirectRunMessage(db, runA.id, 'Run A before later binding')
|
||||
db.bindRun({
|
||||
runId: runA.id,
|
||||
coordinatorHandle: SECOND_TERMINAL_HANDLE,
|
||||
coordinatorPaneKey: SECOND_PANE_KEY
|
||||
})
|
||||
sqliteFor(db)
|
||||
.prepare('UPDATE messages SET to_handle = ? WHERE id = ?')
|
||||
.run(TERMINAL_HANDLE, message.id)
|
||||
|
||||
await driveToLiveIdle(harness.runtime)
|
||||
const runB = createBoundRun(db, 'Later Run B')
|
||||
const checked = await checkBoundMailbox(harness.runtime)
|
||||
|
||||
expect(pointerCount(harness.write)).toBe(0)
|
||||
expect(checked).toMatchObject({ runId: runB.id, deliveryId: null, count: 0, messages: [] })
|
||||
expect(db.getMessageById(message.id)).toMatchObject({
|
||||
to_handle: TERMINAL_HANDLE,
|
||||
read: 0,
|
||||
delivered_at: null
|
||||
})
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('reconciles the persisted production mismatch after runtime restart', async () => {
|
||||
vi.useFakeTimers()
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-mailbox-upgrade-restart-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const dbPath = join(directory, 'orchestration.db')
|
||||
const oldDb = new OrchestrationDb(dbPath)
|
||||
const runA = createBoundRun(oldDb, 'Persisted Run A')
|
||||
const message = insertDirectRunMessage(oldDb, runA.id, 'Persisted Run A completion')
|
||||
createBoundRun(oldDb, 'Persisted Run B')
|
||||
sqliteFor(oldDb)
|
||||
.prepare('UPDATE messages SET to_handle = ? WHERE id = ?')
|
||||
.run(TERMINAL_HANDLE, message.id)
|
||||
oldDb.close()
|
||||
|
||||
const restartedDb = new OrchestrationDb(dbPath)
|
||||
const restarted = createRuntime(restartedDb)
|
||||
await driveToLiveIdle(restarted.runtime)
|
||||
|
||||
expect(pointerCount(restarted.write)).toBe(0)
|
||||
expect(restartedDb.getMessageById(message.id)).toMatchObject({
|
||||
to_handle: `run:${runA.id}`,
|
||||
read: 0,
|
||||
delivered_at: null
|
||||
})
|
||||
|
||||
registerSecondPane(restarted.runtime)
|
||||
restartedDb.bindRun({
|
||||
runId: runA.id,
|
||||
coordinatorHandle: SECOND_TERMINAL_HANDLE,
|
||||
coordinatorPaneKey: SECOND_PANE_KEY
|
||||
})
|
||||
restarted.runtime.onPtyData(SECOND_PTY_ID, '\x1b]0;Codex working\x07', 1)
|
||||
restarted.runtime.onPtyData(SECOND_PTY_ID, '\x1b]0;Codex done\x07', 2)
|
||||
const checked = await checkBoundMailbox(restarted.runtime, {
|
||||
terminal: SECOND_TERMINAL_HANDLE,
|
||||
paneKey: SECOND_PANE_KEY,
|
||||
launchToken: SECOND_LAUNCH_TOKEN
|
||||
})
|
||||
|
||||
expect(checked).toMatchObject({ runId: runA.id, count: 1 })
|
||||
expect(checked.messages).toEqual([expect.objectContaining({ id: message.id })])
|
||||
restartedDb.close()
|
||||
})
|
||||
|
||||
it('pages a large persisted mismatch and coalesces each mailbox wake', async () => {
|
||||
vi.useFakeTimers()
|
||||
const db = createDatabase('orca-mailbox-paged-reconciliation-')
|
||||
const harness = createRuntime(db)
|
||||
const runA = createBoundRun(db, 'Backlog Run A')
|
||||
const messages = Array.from({ length: 151 }, (_, index) =>
|
||||
insertDirectRunMessage(db, runA.id, `Backlog message ${index}`)
|
||||
)
|
||||
createBoundRun(db, 'Backlog Run B')
|
||||
const placeholders = messages.map(() => '?').join(',')
|
||||
sqliteFor(db)
|
||||
.prepare(`UPDATE messages SET to_handle = ? WHERE id IN (${placeholders})`)
|
||||
.run(TERMINAL_HANDLE, ...messages.map((message) => message.id))
|
||||
const notify = vi.spyOn(harness.runtime, 'notifyMessageArrived')
|
||||
const directRemaining = (): number =>
|
||||
(
|
||||
sqliteFor(db)
|
||||
.prepare('SELECT COUNT(*) AS count FROM messages WHERE to_handle = ?')
|
||||
.get(TERMINAL_HANDLE) as { count: number }
|
||||
).count
|
||||
|
||||
await driveToLiveIdle(harness.runtime)
|
||||
expect(directRemaining()).toBe(101)
|
||||
expect(notify).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersToNextTimerAsync()
|
||||
expect(directRemaining()).toBe(51)
|
||||
expect(notify).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersToNextTimerAsync()
|
||||
expect(directRemaining()).toBe(1)
|
||||
expect(notify).toHaveBeenCalledTimes(3)
|
||||
await vi.advanceTimersToNextTimerAsync()
|
||||
expect(directRemaining()).toBe(0)
|
||||
expect(notify).toHaveBeenCalledTimes(4)
|
||||
expect(pointerCount(harness.write)).toBe(0)
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('uses composite indexes for direct ownership reconciliation', () => {
|
||||
const db = createDatabase('orca-mailbox-routing-indexes-')
|
||||
const sqlite = sqliteFor(db)
|
||||
const plan = (sql: string, ...params: string[]): string =>
|
||||
(sqlite.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...params) as { detail: string }[])
|
||||
.map((row) => row.detail)
|
||||
.join(' ')
|
||||
|
||||
expect(
|
||||
plan(
|
||||
`SELECT * FROM dispatch_contexts
|
||||
WHERE run_id = ? AND assignee_handle = ? AND status IN ('pending', 'dispatched')
|
||||
ORDER BY rowid DESC LIMIT 1`,
|
||||
'run_indexed',
|
||||
TERMINAL_HANDLE
|
||||
)
|
||||
).toContain('idx_dispatch_active_run_assignee_handle')
|
||||
expect(
|
||||
plan(
|
||||
`SELECT * FROM dispatch_contexts
|
||||
WHERE run_id = ? AND assignee_pane_key IS NOT NULL
|
||||
AND status IN ('pending', 'dispatched') AND instr(assignee_pane_key, ':') > 1
|
||||
AND substr(assignee_pane_key, instr(assignee_pane_key, ':') + 1) = ?
|
||||
ORDER BY rowid DESC LIMIT 1`,
|
||||
'run_indexed',
|
||||
LEAF_ID
|
||||
)
|
||||
).toContain('idx_dispatch_active_run_pane_leaf')
|
||||
expect(
|
||||
plan(
|
||||
`SELECT 1 FROM messages INDEXED BY idx_messages_undelivered_direct_run
|
||||
WHERE run_id = ? AND to_handle = ? AND read = 0 AND delivered_at IS NULL
|
||||
AND delivery_contract = 'current_delivery' LIMIT 1`,
|
||||
'run_indexed',
|
||||
TERMINAL_HANDLE
|
||||
)
|
||||
).toContain('idx_messages_undelivered_direct_run')
|
||||
expect(
|
||||
plan(
|
||||
`SELECT * FROM messages INDEXED BY idx_messages_unread_current_run_type
|
||||
WHERE run_id = ? AND to_handle = ? AND read = 0
|
||||
AND delivery_contract = 'current_delivery' AND type IN (?)
|
||||
ORDER BY sequence LIMIT 100`,
|
||||
'run_indexed',
|
||||
'run:run_indexed',
|
||||
'worker_done'
|
||||
)
|
||||
).toContain('idx_messages_unread_current_run_type')
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('finds owned mail without scanning an older unowned direct backlog', () => {
|
||||
const db = createDatabase('orca-mailbox-owned-routing-plan-')
|
||||
const detachedHandle = 'term_backlogged_detached'
|
||||
const ownedRun = db.createRun({
|
||||
objective: 'Owned after backlog',
|
||||
coordinatorHandle: detachedHandle,
|
||||
coordinatorPaneKey:
|
||||
'55555555-5555-4555-8555-555555555555:66666666-6666-4666-8666-666666666666'
|
||||
})
|
||||
const unownedRun = createBoundRun(db, 'Unowned backlog')
|
||||
for (let index = 0; index < 500; index += 1) {
|
||||
db.insertMessage({
|
||||
from: TERMINAL_HANDLE,
|
||||
to: detachedHandle,
|
||||
subject: `Unowned ${index}`,
|
||||
type: 'status',
|
||||
runId: unownedRun.id,
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
}
|
||||
const owned = db.insertMessage({
|
||||
from: 'term_worker',
|
||||
to: detachedHandle,
|
||||
subject: 'Owned after unowned backlog',
|
||||
type: 'worker_done',
|
||||
runId: ownedRun.id,
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
sqliteFor(db)
|
||||
.prepare('UPDATE messages SET to_handle = ? WHERE id = ?')
|
||||
.run(detachedHandle, owned.id)
|
||||
const prepare = vi.spyOn(sqliteFor(db), 'prepare')
|
||||
|
||||
expect(db.routeForeignDirectMessagesToOwnedMailboxes(detachedHandle)).toMatchObject({
|
||||
routedCount: 1,
|
||||
hasMore: false
|
||||
})
|
||||
|
||||
const routingSql = prepare.mock.calls
|
||||
.map(([sql]) => sql)
|
||||
.find((sql) => sql.includes('FROM run_coordinator_handles AS coordinator'))
|
||||
expect(routingSql).toContain('INDEXED BY idx_messages_undelivered_direct_run')
|
||||
expect(routingSql).not.toContain('NOT EXISTS')
|
||||
expect(db.getMessageById(owned.id)?.to_handle).toBe(`run:${ownedRun.id}`)
|
||||
expect(db.getUnreadDirectMessageTypes(detachedHandle)).toEqual(['status'])
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('does not submit or replay a pointer after ownership changes during Enter delay', async () => {
|
||||
vi.useFakeTimers()
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-mailbox-restart-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const dbPath = join(directory, 'orchestration.db')
|
||||
const firstDb = new OrchestrationDb(dbPath)
|
||||
const first = createRuntime(firstDb)
|
||||
const runA = createBoundRun(firstDb, 'Run A')
|
||||
const message = insertDirectRunMessage(firstDb, runA.id, 'Run A completion')
|
||||
await driveToLiveIdle(first.runtime)
|
||||
expect(pointerCount(first.write)).toBe(1)
|
||||
|
||||
const runB = createBoundRun(firstDb, 'Run B')
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(first.write.mock.calls.filter(([, payload]) => payload === '\r')).toHaveLength(0)
|
||||
expect(firstDb.getMessageById(message.id)?.delivered_at).toBeNull()
|
||||
firstDb.close()
|
||||
|
||||
const restartedDb = new OrchestrationDb(dbPath)
|
||||
const restarted = createRuntime(restartedDb)
|
||||
await driveToLiveIdle(restarted.runtime)
|
||||
const checked = await checkBoundMailbox(restarted.runtime)
|
||||
const replayedMismatch = pointerCount(restarted.write) > 0 && checked.count === 0
|
||||
|
||||
expect(replayedMismatch).toBe(false)
|
||||
expect(checked).toMatchObject({ runId: runB.id, deliveryId: null, count: 0, messages: [] })
|
||||
expect(restartedDb.getMessageById(message.id)).toMatchObject({
|
||||
read: 0,
|
||||
delivered_at: null
|
||||
})
|
||||
restartedDb.close()
|
||||
})
|
||||
|
||||
it('does not replay a staged same-Run pointer when the runtime restarts before Enter', async () => {
|
||||
vi.useFakeTimers()
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-mailbox-staged-restart-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const dbPath = join(directory, 'orchestration.db')
|
||||
const firstDb = new OrchestrationDb(dbPath)
|
||||
const first = createRuntime(firstDb)
|
||||
const run = createBoundRun(firstDb, 'Staged restart Run')
|
||||
const message = insertDirectRunMessage(firstDb, run.id, 'Visible before restart')
|
||||
|
||||
await driveToLiveIdle(first.runtime)
|
||||
expect(pointerCount(first.write)).toBe(1)
|
||||
expect(first.write.mock.calls.filter(([, payload]) => payload === '\r')).toHaveLength(0)
|
||||
expect(firstDb.getMessageById(message.id)?.delivered_at).toEqual(expect.any(String))
|
||||
firstDb.close()
|
||||
|
||||
const restartedDb = new OrchestrationDb(dbPath)
|
||||
const restarted = createRuntime(restartedDb)
|
||||
await driveToLiveIdle(restarted.runtime)
|
||||
const checked = await checkBoundMailbox(restarted.runtime)
|
||||
|
||||
expect(pointerCount(restarted.write)).toBe(0)
|
||||
expect(checked).toMatchObject({ runId: run.id, count: 1 })
|
||||
expect(checked.messages).toEqual([expect.objectContaining({ id: message.id })])
|
||||
restartedDb.close()
|
||||
})
|
||||
|
||||
it('fences the pointed mailbox instead of checking a rebound empty Run', async () => {
|
||||
vi.useFakeTimers()
|
||||
const db = createDatabase('orca-mailbox-post-submit-rebind-')
|
||||
const harness = createRuntime(db)
|
||||
const runA = createBoundRun(db, 'Run A')
|
||||
insertDirectRunMessage(db, runA.id, 'Run A completion')
|
||||
|
||||
await driveToLiveIdle(harness.runtime)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(harness.write).toHaveBeenCalledWith(
|
||||
PTY_ID,
|
||||
expect.stringContaining(`orchestration check --run ${runA.id}`)
|
||||
)
|
||||
|
||||
db.bindRun({
|
||||
runId: runA.id,
|
||||
coordinatorHandle: 'term_new_coordinator',
|
||||
coordinatorPaneKey:
|
||||
'55555555-5555-4555-8555-555555555555:66666666-6666-4666-8666-666666666666'
|
||||
})
|
||||
const runB = createBoundRun(db, 'Run B')
|
||||
const response = await new RpcDispatcher({
|
||||
runtime: harness.runtime,
|
||||
methods: ORCHESTRATION_METHODS
|
||||
}).dispatch({
|
||||
id: 'req-pointed-mailbox-fenced',
|
||||
authToken: 'test-auth-token',
|
||||
method: 'orchestration.check',
|
||||
orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION,
|
||||
orchestrationCompatibilityEvidence: {
|
||||
terminalHandle: TERMINAL_HANDLE,
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: LAUNCH_TOKEN
|
||||
},
|
||||
params: { terminal: TERMINAL_HANDLE, run: runA.id }
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'consumer_fenced' }
|
||||
})
|
||||
const generic = await checkBoundMailbox(harness.runtime)
|
||||
expect(generic).toMatchObject({ runId: runB.id, count: 0 })
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('releases a staged pointer when its Run moves to another live pane', async () => {
|
||||
vi.useFakeTimers()
|
||||
const db = createDatabase('orca-mailbox-live-rebind-')
|
||||
const harness = createRuntime(db)
|
||||
const runA = createBoundRun(db, 'Run A')
|
||||
const message = insertDirectRunMessage(db, runA.id, 'Run A completion')
|
||||
await driveToLiveIdle(harness.runtime)
|
||||
expect(pointerCount(harness.write)).toBe(1)
|
||||
|
||||
createBoundRun(db, 'Run B')
|
||||
registerSecondPane(harness.runtime, SECOND_LEAF_ID, false)
|
||||
db.bindRun({
|
||||
runId: runA.id,
|
||||
coordinatorHandle: SECOND_TERMINAL_HANDLE,
|
||||
coordinatorPaneKey: SECOND_PANE_KEY
|
||||
})
|
||||
harness.runtime.onPtyData(SECOND_PTY_ID, '\x1b]0;Codex working\x07', 1)
|
||||
harness.runtime.onPtyData(SECOND_PTY_ID, '\x1b]0;Codex done\x07', 2)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
expect(
|
||||
harness.write.mock.calls.filter(
|
||||
([ptyId, payload]) =>
|
||||
ptyId === SECOND_PTY_ID && String(payload).includes('orca orchestration check')
|
||||
)
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
harness.write.mock.calls.filter(([ptyId, payload]) => ptyId === PTY_ID && payload === '\r')
|
||||
).toHaveLength(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
const checked = await checkBoundMailbox(harness.runtime, {
|
||||
terminal: SECOND_TERMINAL_HANDLE,
|
||||
paneKey: SECOND_PANE_KEY,
|
||||
launchToken: SECOND_LAUNCH_TOKEN
|
||||
})
|
||||
expect(checked).toMatchObject({ runId: runA.id, count: 1 })
|
||||
expect(checked.messages).toEqual([expect.objectContaining({ id: message.id })])
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('serializes equivalent panes while newer mail arrives during Enter delay', async () => {
|
||||
vi.useFakeTimers()
|
||||
const db = createDatabase('orca-mailbox-equivalent-pane-flight-')
|
||||
const harness = createRuntime(db)
|
||||
const run = createBoundRun(db, 'Equivalent pane Run')
|
||||
const first = insertDirectRunMessage(db, run.id, 'First status')
|
||||
await driveToLiveIdle(harness.runtime)
|
||||
expect(pointerCount(harness.write)).toBe(1)
|
||||
|
||||
registerSecondPane(harness.runtime, LEAF_ID)
|
||||
const second = db.insertMessage({
|
||||
from: 'term_worker',
|
||||
to: `run:${run.id}`,
|
||||
subject: 'Second status',
|
||||
runId: run.id
|
||||
})
|
||||
harness.runtime.onPtyData(SECOND_PTY_ID, '\x1b]0;Codex working\x07', 1)
|
||||
harness.runtime.onPtyData(SECOND_PTY_ID, '\x1b]0;Codex done\x07', 2)
|
||||
await Promise.resolve()
|
||||
expect(
|
||||
harness.write.mock.calls.filter(
|
||||
([ptyId, payload]) =>
|
||||
ptyId === SECOND_PTY_ID && String(payload).includes('orca orchestration check')
|
||||
)
|
||||
).toHaveLength(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(
|
||||
harness.write.mock.calls.filter(
|
||||
([ptyId, payload]) =>
|
||||
ptyId === PTY_ID && String(payload).includes('orca orchestration check')
|
||||
)
|
||||
).toHaveLength(2)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
const checked = await checkBoundMailbox(harness.runtime)
|
||||
expect(checked).toMatchObject({ runId: run.id, count: 2 })
|
||||
expect(checked.messages).toEqual(
|
||||
[first, second].map((message) => expect.objectContaining({ id: message.id }))
|
||||
)
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('does not submit or duplicate a pointer after the agent becomes working', async () => {
|
||||
vi.useFakeTimers()
|
||||
const db = createDatabase('orca-mailbox-working-before-enter-')
|
||||
const harness = createRuntime(db)
|
||||
const run = createBoundRun(db, 'Working-before-Enter Run')
|
||||
const message = insertDirectRunMessage(db, run.id, 'Actionable status')
|
||||
|
||||
await driveToLiveIdle(harness.runtime)
|
||||
expect(pointerCount(harness.write)).toBe(1)
|
||||
harness.runtime.onPtyData(PTY_ID, '\x1b]0;Codex working\x07', 3)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
expect(harness.write.mock.calls.filter(([, payload]) => payload === '\r')).toHaveLength(0)
|
||||
expect(db.getMessageById(message.id)?.delivered_at).toEqual(expect.any(String))
|
||||
harness.runtime.onPtyData(PTY_ID, '\x1b]0;Codex done\x07', 4)
|
||||
await Promise.resolve()
|
||||
expect(pointerCount(harness.write)).toBe(1)
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('releases staged pointer state when an explicit check owns the batch', async () => {
|
||||
vi.useFakeTimers()
|
||||
const db = createDatabase('orca-mailbox-explicit-check-')
|
||||
const harness = createRuntime(db)
|
||||
const run = createBoundRun(db, 'Explicit-check Run')
|
||||
insertDirectRunMessage(db, run.id, 'Checked before Enter')
|
||||
|
||||
await driveToLiveIdle(harness.runtime)
|
||||
expect(pointerCount(harness.write)).toBe(1)
|
||||
const checked = await checkBoundMailbox(harness.runtime)
|
||||
expect(checked).toMatchObject({ runId: run.id, count: 1 })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
const redrive = vi.spyOn(harness.runtime, 'deliverPendingMessagesForHandle')
|
||||
harness.runtime.onPtyExit(PTY_ID, 0)
|
||||
await Promise.resolve()
|
||||
expect(redrive).not.toHaveBeenCalled()
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('routes same-Run direct mail through the bound check and acknowledgment path', async () => {
|
||||
vi.useFakeTimers()
|
||||
const db = createDatabase('orca-mailbox-current-run-')
|
||||
const harness = createRuntime(db)
|
||||
const run = createBoundRun(db, 'Current Run')
|
||||
const messages = Array.from({ length: 51 }, (_, index) =>
|
||||
db.insertMessage({
|
||||
from: 'term_worker',
|
||||
to: TERMINAL_HANDLE,
|
||||
subject: `Direct coordinator status ${index}`,
|
||||
runId: run.id
|
||||
})
|
||||
)
|
||||
const notificationQuery = vi.spyOn(db, 'getUndeliveredUnreadMessages')
|
||||
const submitRevalidation = vi.spyOn(db, 'areUnreadMessages')
|
||||
const prepare = vi.spyOn(sqliteFor(db), 'prepare')
|
||||
|
||||
await driveToLiveIdle(harness.runtime)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
const checked = await checkBoundMailbox(harness.runtime)
|
||||
|
||||
expect(pointerCount(harness.write)).toBe(1)
|
||||
expect(harness.write).toHaveBeenCalledWith(
|
||||
PTY_ID,
|
||||
expect.stringContaining('You have 50 orchestration messages')
|
||||
)
|
||||
expect(notificationQuery.mock.results[0]?.value).toHaveLength(50)
|
||||
expect(submitRevalidation).toHaveBeenCalledWith(
|
||||
`run:${run.id}`,
|
||||
messages.slice(0, 50).map((message) => message.id)
|
||||
)
|
||||
const revalidationSql = prepare.mock.calls
|
||||
.map(([sql]) => sql)
|
||||
.find((sql) => sql.includes('SELECT COUNT(*)') && sql.includes('id IN'))
|
||||
expect(revalidationSql).toContain('INDEXED BY idx_messages_id')
|
||||
const revalidationPlan = sqliteFor(db)
|
||||
.prepare(`EXPLAIN QUERY PLAN ${revalidationSql}`)
|
||||
.all(`run:${run.id}`, ...messages.slice(0, 50).map((message) => message.id)) as {
|
||||
detail: string
|
||||
}[]
|
||||
expect(revalidationPlan.map((row) => row.detail).join(' ')).toContain('idx_messages_id')
|
||||
expect(checked).toMatchObject({ runId: run.id, count: 50 })
|
||||
expect(checked.deliveryId).toBeTruthy()
|
||||
expect(checked.messages).toEqual(
|
||||
messages.slice(0, 50).map((message) => expect.objectContaining({ id: message.id }))
|
||||
)
|
||||
expect(db.getMessageById(messages[0]!.id)).toMatchObject({
|
||||
to_handle: `run:${run.id}`,
|
||||
read: 0,
|
||||
delivered_at: expect.any(String)
|
||||
})
|
||||
expect(db.getMessageById(messages[50]!.id)?.delivered_at).toBeNull()
|
||||
|
||||
const acknowledged = await checkBoundMailbox(harness.runtime, { ack: checked.deliveryId! })
|
||||
expect(acknowledged.acknowledged).toBe(checked.deliveryId)
|
||||
expect(acknowledged.messages).toEqual([expect.objectContaining({ id: messages[50]!.id })])
|
||||
expect(db.getMessageById(messages[0]!.id)?.read).toBe(1)
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('does not replay a successfully submitted actionable pointer after restart', async () => {
|
||||
vi.useFakeTimers()
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-mailbox-actionable-restart-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const dbPath = join(directory, 'orchestration.db')
|
||||
const firstDb = new OrchestrationDb(dbPath)
|
||||
const first = createRuntime(firstDb)
|
||||
const run = createBoundRun(firstDb, 'Restart-safe Run')
|
||||
const message = insertDirectRunMessage(firstDb, run.id, 'Actionable status')
|
||||
|
||||
await driveToLiveIdle(first.runtime)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(pointerCount(first.write)).toBe(1)
|
||||
expect(firstDb.getMessageById(message.id)).toMatchObject({
|
||||
read: 0,
|
||||
delivered_at: expect.any(String)
|
||||
})
|
||||
firstDb.close()
|
||||
|
||||
const restartedDb = new OrchestrationDb(dbPath)
|
||||
const restarted = createRuntime(restartedDb)
|
||||
await driveToLiveIdle(restarted.runtime)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
const checked = await checkBoundMailbox(restarted.runtime)
|
||||
|
||||
expect(pointerCount(restarted.write)).toBe(0)
|
||||
expect(checked).toMatchObject({ runId: run.id, count: 1 })
|
||||
expect(checked.messages).toEqual([expect.objectContaining({ id: message.id })])
|
||||
expect(checked.deliveryId).toBeTruthy()
|
||||
restartedDb.close()
|
||||
})
|
||||
|
||||
it('does not replay a visible pointer when the provider rejects Enter', async () => {
|
||||
vi.useFakeTimers()
|
||||
const db = createDatabase('orca-mailbox-provider-refusal-')
|
||||
const first = createRuntime(db)
|
||||
const recordWrite = first.write as unknown as (id: string, payload: string) => unknown
|
||||
const write = vi.fn((ptyId: string, data: string) => {
|
||||
recordWrite(ptyId, data)
|
||||
return data !== '\r'
|
||||
})
|
||||
first.runtime.setPtyController({
|
||||
write,
|
||||
kill: vi.fn(),
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const run = createBoundRun(db, 'Provider-refusal Run')
|
||||
const message = insertDirectRunMessage(db, run.id, 'Retry after refusal')
|
||||
|
||||
await driveToLiveIdle(first.runtime)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(pointerCount(first.write)).toBe(1)
|
||||
expect(db.getMessageById(message.id)?.delivered_at).toEqual(expect.any(String))
|
||||
|
||||
const restarted = createRuntime(db)
|
||||
await driveToLiveIdle(restarted.runtime)
|
||||
expect(pointerCount(restarted.write)).toBe(0)
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('does not point newer Run mail behind an outstanding Delivery', async () => {
|
||||
vi.useFakeTimers()
|
||||
const db = createDatabase('orca-mailbox-outstanding-')
|
||||
const harness = createRuntime(db)
|
||||
const run = createBoundRun(db, 'Outstanding Delivery Run')
|
||||
const firstMessage = db.insertMessage({
|
||||
from: 'term_worker',
|
||||
to: `run:${run.id}`,
|
||||
subject: 'First status',
|
||||
runId: run.id
|
||||
})
|
||||
const firstDelivery = await checkBoundMailbox(harness.runtime)
|
||||
const newerMessage = db.insertMessage({
|
||||
from: 'term_worker',
|
||||
to: `run:${run.id}`,
|
||||
subject: 'Newer status',
|
||||
runId: run.id
|
||||
})
|
||||
|
||||
await driveToLiveIdle(harness.runtime)
|
||||
const replayed = await checkBoundMailbox(harness.runtime)
|
||||
|
||||
expect(pointerCount(harness.write)).toBe(0)
|
||||
expect(replayed.messages).toEqual([expect.objectContaining({ id: firstMessage.id })])
|
||||
expect(replayed.deliveryId).toBe(firstDelivery.deliveryId)
|
||||
|
||||
const next = await checkBoundMailbox(harness.runtime, { ack: firstDelivery.deliveryId! })
|
||||
expect(next.messages).toEqual([expect.objectContaining({ id: newerMessage.id })])
|
||||
expect(next.deliveryId).not.toBe(firstDelivery.deliveryId)
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('drains persisted Run pages before installing a filtered waiter', async () => {
|
||||
const db = createDatabase('orca-mailbox-filtered-run-backlog-')
|
||||
const harness = createRuntime(db)
|
||||
const run = createBoundRun(db, 'Filtered Run backlog')
|
||||
for (let index = 0; index < 50; index += 1) {
|
||||
insertDirectRunMessage(db, run.id, `Status ${index}`)
|
||||
}
|
||||
const question = db.insertMessage({
|
||||
from: 'term_worker',
|
||||
to: TERMINAL_HANDLE,
|
||||
subject: 'Question behind first page',
|
||||
type: 'question',
|
||||
runId: run.id
|
||||
})
|
||||
sqliteFor(db)
|
||||
.prepare('UPDATE messages SET to_handle = ? WHERE id = ?')
|
||||
.run(TERMINAL_HANDLE, question.id)
|
||||
|
||||
const checked = await checkBoundMailbox(harness.runtime, {
|
||||
wait: true,
|
||||
types: 'question'
|
||||
})
|
||||
|
||||
expect(checked).toMatchObject({ runId: run.id, count: 50 })
|
||||
expect(checked.messages).not.toContainEqual(expect.objectContaining({ id: question.id }))
|
||||
expect(db.getMessageById(question.id)?.to_handle).toBe(`run:${run.id}`)
|
||||
const next = await checkBoundMailbox(harness.runtime, {
|
||||
ack: checked.deliveryId!,
|
||||
types: 'question'
|
||||
})
|
||||
expect(next.messages).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: question.id })])
|
||||
)
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('wakes a filtered waiter when reconciliation moves its type on a later page', async () => {
|
||||
const db = createDatabase('orca-mailbox-filtered-reconciliation-wake-')
|
||||
const harness = createRuntime(db)
|
||||
const run = createBoundRun(db, 'Filtered reconciliation wake')
|
||||
const waiting = checkBoundMailbox(harness.runtime, { wait: true, types: 'question' })
|
||||
const internals = harness.runtime as unknown as {
|
||||
messageWaitersByHandle: Map<string, Set<unknown>>
|
||||
}
|
||||
await vi.waitFor(() => {
|
||||
expect(internals.messageWaitersByHandle.has(`run:${run.id}`)).toBe(true)
|
||||
})
|
||||
for (let index = 0; index < 50; index += 1) {
|
||||
insertDirectRunMessage(db, run.id, `Status before question ${index}`)
|
||||
}
|
||||
const question = db.insertMessage({
|
||||
from: 'term_worker',
|
||||
to: TERMINAL_HANDLE,
|
||||
subject: 'Question moved by continuation',
|
||||
type: 'question',
|
||||
runId: run.id
|
||||
})
|
||||
sqliteFor(db)
|
||||
.prepare('UPDATE messages SET to_handle = ? WHERE id = ?')
|
||||
.run(TERMINAL_HANDLE, question.id)
|
||||
const arrivingStatus = insertDirectRunMessage(db, run.id, 'Status arrival trigger')
|
||||
|
||||
harness.runtime.notifyMessageArrived(TERMINAL_HANDLE, arrivingStatus.type)
|
||||
const checked = await waiting
|
||||
|
||||
expect(checked).toMatchObject({ runId: run.id, count: 50 })
|
||||
expect(checked.messages).not.toContainEqual(expect.objectContaining({ id: question.id }))
|
||||
expect(db.getMessageById(question.id)?.to_handle).toBe(`run:${run.id}`)
|
||||
const next = await checkBoundMailbox(harness.runtime, {
|
||||
ack: checked.deliveryId!,
|
||||
types: 'question'
|
||||
})
|
||||
expect(next.messages).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: question.id })])
|
||||
)
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('drains persisted Dispatch pages before installing a filtered waiter', async () => {
|
||||
const db = createDatabase('orca-mailbox-filtered-dispatch-backlog-')
|
||||
const harness = createRuntime(db)
|
||||
const run = db.createRun({
|
||||
objective: 'Filtered Dispatch backlog',
|
||||
coordinatorHandle: 'term_coordinator',
|
||||
coordinatorPaneKey:
|
||||
'55555555-5555-4555-8555-555555555555:66666666-6666-4666-8666-666666666666'
|
||||
})
|
||||
const task = db.createTask({ spec: 'Worker task', runId: run.id })
|
||||
const dispatch = db.createDispatchContext(task.id, TERMINAL_HANDLE, PANE_KEY)
|
||||
for (let index = 0; index < 50; index += 1) {
|
||||
insertDirectRunMessage(db, run.id, `Worker status ${index}`)
|
||||
}
|
||||
const question = db.insertMessage({
|
||||
from: 'term_coordinator',
|
||||
to: TERMINAL_HANDLE,
|
||||
subject: 'Worker question behind first page',
|
||||
type: 'question',
|
||||
runId: run.id
|
||||
})
|
||||
|
||||
const checked = await checkBoundMailbox(harness.runtime, {
|
||||
wait: true,
|
||||
types: 'question'
|
||||
})
|
||||
|
||||
expect(checked).toMatchObject({ runId: run.id, dispatchId: dispatch.id, count: 1 })
|
||||
expect(checked.messages).toEqual([expect.objectContaining({ id: question.id })])
|
||||
expect(db.getMessageById(question.id)?.to_handle).toBe(`dispatch:${dispatch.id}`)
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,235 @@
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, vi } from 'vitest'
|
||||
import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version'
|
||||
import type Database from '../sqlite/sync-database'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { OrchestrationDb } from './orchestration/db'
|
||||
import { RpcDispatcher } from './rpc/dispatcher'
|
||||
import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration'
|
||||
|
||||
export const TAB_ID = '11111111-1111-4111-8111-111111111111'
|
||||
export const LEAF_ID = '22222222-2222-4222-8222-222222222222'
|
||||
export const PANE_KEY = `${TAB_ID}:${LEAF_ID}`
|
||||
export const PTY_ID = 'pty-mailbox'
|
||||
export const TERMINAL_HANDLE = 'term_mailbox_consistency'
|
||||
export const SECOND_TAB_ID = '33333333-3333-4333-8333-333333333333'
|
||||
export const SECOND_LEAF_ID = '44444444-4444-4444-8444-444444444444'
|
||||
export const SECOND_PANE_KEY = `${SECOND_TAB_ID}:${SECOND_LEAF_ID}`
|
||||
export const SECOND_PTY_ID = 'pty-mailbox-second'
|
||||
export const SECOND_TERMINAL_HANDLE = 'term_mailbox_consistency_second'
|
||||
export const SECOND_LAUNCH_TOKEN = 'mailbox-consistency-second-launch'
|
||||
export const WORKTREE_ID = 'repo-mailbox::/tmp/mailbox'
|
||||
export const LAUNCH_TOKEN = 'mailbox-consistency-launch'
|
||||
export const temporaryDirectories: string[] = []
|
||||
|
||||
export function createDatabase(prefix: string): OrchestrationDb {
|
||||
const directory = mkdtempSync(join(tmpdir(), prefix))
|
||||
temporaryDirectories.push(directory)
|
||||
return new OrchestrationDb(join(directory, 'orchestration.db'))
|
||||
}
|
||||
|
||||
export function sqliteFor(db: OrchestrationDb): Database.Database {
|
||||
return (db as unknown as { db: Database.Database }).db
|
||||
}
|
||||
|
||||
export function createBoundRun(db: OrchestrationDb, objective: string) {
|
||||
return db.createRun({
|
||||
objective,
|
||||
coordinatorHandle: TERMINAL_HANDLE,
|
||||
coordinatorPaneKey: PANE_KEY
|
||||
})
|
||||
}
|
||||
|
||||
export function insertDirectRunMessage(db: OrchestrationDb, runId: string, subject: string) {
|
||||
const message = db.insertMessage({
|
||||
from: 'term_worker',
|
||||
to: TERMINAL_HANDLE,
|
||||
subject,
|
||||
type: 'status',
|
||||
runId,
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
sqliteFor(db)
|
||||
.prepare('UPDATE messages SET to_handle = ? WHERE id = ?')
|
||||
.run(TERMINAL_HANDLE, message.id)
|
||||
return db.getMessageById(message.id)!
|
||||
}
|
||||
|
||||
export type MailboxNotificationHarness = {
|
||||
runtime: OrcaRuntimeService
|
||||
write: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
export type MailboxCheckResult = {
|
||||
runId: string
|
||||
dispatchId?: string
|
||||
deliveryId: string | null
|
||||
count: number
|
||||
messages: unknown[]
|
||||
acknowledged?: string | null
|
||||
}
|
||||
|
||||
export type MailboxCheckOptions = {
|
||||
ack?: string
|
||||
wait?: boolean
|
||||
terminal?: string
|
||||
paneKey?: string
|
||||
launchToken?: string
|
||||
types?: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export function createRuntime(db: OrchestrationDb): MailboxNotificationHarness {
|
||||
const runtime = new OrcaRuntimeService(null, undefined, {
|
||||
attestAgentHookCompatibilityAuthority: ({ paneKey }) =>
|
||||
paneKey === PANE_KEY || paneKey.startsWith(`${SECOND_TAB_ID}:`)
|
||||
? { paneKey, source: 'current_hook' }
|
||||
: null
|
||||
})
|
||||
const write = vi.fn(() => true)
|
||||
runtime.setOrchestrationDb(db)
|
||||
runtime.setPtyController({
|
||||
write,
|
||||
kill: vi.fn(),
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.registerPty(PTY_ID, WORKTREE_ID, null, {
|
||||
tabId: TAB_ID,
|
||||
leafId: LEAF_ID,
|
||||
incarnationId: 'mailbox-incarnation',
|
||||
agentLaunchAuthority: { launchToken: LAUNCH_TOKEN, launchAgent: 'codex' }
|
||||
})
|
||||
runtime.registerPreAllocatedHandleForPty(PTY_ID, TERMINAL_HANDLE)
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'Codex',
|
||||
activeLeafId: LEAF_ID,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
leafId: LEAF_ID,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: PTY_ID
|
||||
}
|
||||
]
|
||||
})
|
||||
return { runtime, write }
|
||||
}
|
||||
|
||||
export function registerSecondPane(
|
||||
runtime: OrcaRuntimeService,
|
||||
leafId = SECOND_LEAF_ID,
|
||||
includeFirstPane = true
|
||||
): void {
|
||||
runtime.registerPty(SECOND_PTY_ID, WORKTREE_ID, null, {
|
||||
tabId: SECOND_TAB_ID,
|
||||
leafId,
|
||||
incarnationId: 'mailbox-second-incarnation',
|
||||
agentLaunchAuthority: { launchToken: SECOND_LAUNCH_TOKEN, launchAgent: 'codex' }
|
||||
})
|
||||
runtime.registerPreAllocatedHandleForPty(SECOND_PTY_ID, SECOND_TERMINAL_HANDLE)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
...(includeFirstPane
|
||||
? [
|
||||
{
|
||||
tabId: TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'Codex',
|
||||
activeLeafId: LEAF_ID,
|
||||
layout: null
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
tabId: SECOND_TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'Codex',
|
||||
activeLeafId: leafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
...(includeFirstPane
|
||||
? [
|
||||
{
|
||||
tabId: TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
leafId: LEAF_ID,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: PTY_ID
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
tabId: SECOND_TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
leafId,
|
||||
paneRuntimeId: 2,
|
||||
ptyId: SECOND_PTY_ID
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
export async function driveToLiveIdle(runtime: OrcaRuntimeService): Promise<void> {
|
||||
await runtime.listTerminals()
|
||||
runtime.onPtyData(PTY_ID, '\x1b]0;Codex working\x07', 1)
|
||||
runtime.onPtyData(PTY_ID, '\x1b]0;Codex done\x07', 2)
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
export function pointerCount(write: ReturnType<typeof vi.fn>): number {
|
||||
return write.mock.calls.filter(([, payload]) =>
|
||||
String(payload).includes('orca orchestration check')
|
||||
).length
|
||||
}
|
||||
|
||||
export async function checkBoundMailbox(
|
||||
runtime: OrcaRuntimeService,
|
||||
options: MailboxCheckOptions = {}
|
||||
): Promise<MailboxCheckResult> {
|
||||
const response = await dispatchMailboxCheck(runtime, options)
|
||||
expect(response.ok).toBe(true)
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
return response.result as MailboxCheckResult
|
||||
}
|
||||
|
||||
export async function dispatchMailboxCheck(
|
||||
runtime: OrcaRuntimeService,
|
||||
options: MailboxCheckOptions = {}
|
||||
) {
|
||||
const terminal = options.terminal ?? TERMINAL_HANDLE
|
||||
return new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }).dispatch(
|
||||
{
|
||||
id: 'req-mailbox-consistency',
|
||||
authToken: 'test-auth-token',
|
||||
method: 'orchestration.check',
|
||||
orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION,
|
||||
orchestrationCompatibilityEvidence: {
|
||||
terminalHandle: terminal,
|
||||
paneKey: options.paneKey ?? PANE_KEY,
|
||||
launchToken: options.launchToken ?? LAUNCH_TOKEN
|
||||
},
|
||||
params: {
|
||||
terminal,
|
||||
...(options.types ? { types: options.types } : {}),
|
||||
...(options.ack ? { ack: options.ack } : {}),
|
||||
...(options.wait ? { wait: true, timeoutMs: 5_000 } : {})
|
||||
}
|
||||
},
|
||||
{ signal: options.signal }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
import { rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { OrchestrationMailboxPointerState } from './orchestration/mailbox-pointer-state'
|
||||
import {
|
||||
checkBoundMailbox,
|
||||
createBoundRun,
|
||||
createDatabase,
|
||||
createRuntime,
|
||||
dispatchMailboxCheck,
|
||||
insertDirectRunMessage,
|
||||
LEAF_ID,
|
||||
PANE_KEY,
|
||||
registerSecondPane,
|
||||
SECOND_LAUNCH_TOKEN,
|
||||
SECOND_PANE_KEY,
|
||||
SECOND_TERMINAL_HANDLE,
|
||||
sqliteFor,
|
||||
temporaryDirectories,
|
||||
TERMINAL_HANDLE
|
||||
} from './orchestration-mailbox-notification-test-harness'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: vi.fn(() => tmpdir()), isPackaged: false },
|
||||
BrowserWindow: { fromId: vi.fn(() => null) },
|
||||
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
|
||||
webContents: { fromId: vi.fn(() => null) }
|
||||
}))
|
||||
|
||||
describe('orchestration mailbox routing races', () => {
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fences a Run consumer rebound during a routing-page yield', async () => {
|
||||
const db = createDatabase('orca-mailbox-run-routing-fence-')
|
||||
const harness = createRuntime(db)
|
||||
const run = createBoundRun(db, 'Run routing fence')
|
||||
for (let index = 0; index < 51; index += 1) {
|
||||
insertDirectRunMessage(db, run.id, `Before rebind ${index}`)
|
||||
}
|
||||
const route = db.routeUnreadDirectMessagesToRunMailbox.bind(db)
|
||||
let scheduled = false
|
||||
vi.spyOn(db, 'routeUnreadDirectMessagesToRunMailbox').mockImplementation((...args) => {
|
||||
const page = route(...args)
|
||||
if (page.hasMore && !scheduled) {
|
||||
scheduled = true
|
||||
setImmediate(() => createBoundRun(db, 'Replacement Run'))
|
||||
}
|
||||
return page
|
||||
})
|
||||
|
||||
const response = await dispatchMailboxCheck(harness.runtime)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'consumer_fenced' } })
|
||||
expect(db.getUnreadMessages(`run:${run.id}`)).toHaveLength(51)
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('fences a Dispatch completed during a routing-page yield', async () => {
|
||||
const db = createDatabase('orca-mailbox-dispatch-routing-fence-')
|
||||
const harness = createRuntime(db)
|
||||
registerSecondPane(harness.runtime)
|
||||
const run = db.createRun({
|
||||
objective: 'Dispatch routing fence',
|
||||
coordinatorHandle: SECOND_TERMINAL_HANDLE,
|
||||
coordinatorPaneKey: SECOND_PANE_KEY
|
||||
})
|
||||
const task = db.createTask({ spec: 'Worker task', runId: run.id })
|
||||
const dispatch = db.createDispatchContext(task.id, TERMINAL_HANDLE, PANE_KEY)
|
||||
for (let index = 0; index < 151; index += 1) {
|
||||
insertDirectRunMessage(db, run.id, `Before completion ${index}`)
|
||||
}
|
||||
const route = db.routeUnreadDirectMessagesToDispatchMailbox.bind(db)
|
||||
let scheduled = false
|
||||
vi.spyOn(db, 'routeUnreadDirectMessagesToDispatchMailbox').mockImplementation((...args) => {
|
||||
const page = route(...args)
|
||||
if (page.hasMore && !scheduled) {
|
||||
scheduled = true
|
||||
setImmediate(() => db.completeDispatch(dispatch.id))
|
||||
}
|
||||
return page
|
||||
})
|
||||
const migrate = db.routeUnreadDispatchMailboxToRunMailbox.bind(db)
|
||||
const migrationSpy = vi
|
||||
.spyOn(db, 'routeUnreadDispatchMailboxToRunMailbox')
|
||||
.mockImplementation((...args) => migrate(...args))
|
||||
const arrivalSpy = vi.spyOn(harness.runtime, 'notifyMessageArrived')
|
||||
|
||||
const response = await dispatchMailboxCheck(harness.runtime)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'dispatch_inactive' } })
|
||||
expect(db.getUnreadMessages(`dispatch:${dispatch.id}`)).toHaveLength(0)
|
||||
expect(migrationSpy).toHaveBeenCalledTimes(4)
|
||||
expect(arrivalSpy).toHaveBeenCalledOnce()
|
||||
expect(arrivalSpy).toHaveBeenCalledWith(`run:${run.id}`, 'status')
|
||||
const plan = sqliteFor(db)
|
||||
.prepare(
|
||||
`EXPLAIN QUERY PLAN SELECT id, type FROM messages
|
||||
INDEXED BY idx_messages_unread_current_inbox
|
||||
WHERE to_handle = ? AND read = 0 AND delivery_contract = 'current_delivery'
|
||||
ORDER BY sequence LIMIT ?`
|
||||
)
|
||||
.all(`dispatch:${dispatch.id}`, 51) as { detail: string }[]
|
||||
expect(plan.map((row) => row.detail).join(' ')).toContain('idx_messages_unread_current_inbox')
|
||||
const first = await checkBoundMailbox(harness.runtime, {
|
||||
terminal: SECOND_TERMINAL_HANDLE,
|
||||
paneKey: SECOND_PANE_KEY,
|
||||
launchToken: SECOND_LAUNCH_TOKEN
|
||||
})
|
||||
const second = await checkBoundMailbox(harness.runtime, {
|
||||
ack: first.deliveryId!,
|
||||
terminal: SECOND_TERMINAL_HANDLE,
|
||||
paneKey: SECOND_PANE_KEY,
|
||||
launchToken: SECOND_LAUNCH_TOKEN
|
||||
})
|
||||
const third = await checkBoundMailbox(harness.runtime, {
|
||||
ack: second.deliveryId!,
|
||||
terminal: SECOND_TERMINAL_HANDLE,
|
||||
paneKey: SECOND_PANE_KEY,
|
||||
launchToken: SECOND_LAUNCH_TOKEN
|
||||
})
|
||||
const fourth = await checkBoundMailbox(harness.runtime, {
|
||||
ack: third.deliveryId!,
|
||||
terminal: SECOND_TERMINAL_HANDLE,
|
||||
paneKey: SECOND_PANE_KEY,
|
||||
launchToken: SECOND_LAUNCH_TOKEN
|
||||
})
|
||||
const acknowledged = await checkBoundMailbox(harness.runtime, {
|
||||
ack: fourth.deliveryId!,
|
||||
terminal: SECOND_TERMINAL_HANDLE,
|
||||
paneKey: SECOND_PANE_KEY,
|
||||
launchToken: SECOND_LAUNCH_TOKEN
|
||||
})
|
||||
expect(first.count).toBe(50)
|
||||
expect(second.count).toBe(50)
|
||||
expect(third.count).toBe(50)
|
||||
expect(fourth.count).toBe(1)
|
||||
expect(acknowledged).toMatchObject({ count: 0, acknowledged: fourth.deliveryId })
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('reconciles filtered-out mail when a Dispatch wait loses authority', async () => {
|
||||
const db = createDatabase('orca-mailbox-dispatch-wait-fence-')
|
||||
const harness = createRuntime(db)
|
||||
registerSecondPane(harness.runtime)
|
||||
const run = db.createRun({
|
||||
objective: 'Dispatch wait fence',
|
||||
coordinatorHandle: SECOND_TERMINAL_HANDLE,
|
||||
coordinatorPaneKey: SECOND_PANE_KEY
|
||||
})
|
||||
const task = db.createTask({ spec: 'Waiting worker', runId: run.id })
|
||||
const dispatch = db.createDispatchContext(task.id, TERMINAL_HANDLE, PANE_KEY)
|
||||
const status = insertDirectRunMessage(db, run.id, 'Filtered-out status')
|
||||
const controller = new AbortController()
|
||||
const waiting = dispatchMailboxCheck(harness.runtime, {
|
||||
wait: true,
|
||||
types: 'question',
|
||||
signal: controller.signal
|
||||
})
|
||||
const internals = harness.runtime as unknown as {
|
||||
messageWaitersByHandle: Map<string, Set<unknown>>
|
||||
}
|
||||
await vi.waitFor(() => {
|
||||
expect(internals.messageWaitersByHandle.has(`dispatch:${dispatch.id}`)).toBe(true)
|
||||
})
|
||||
|
||||
db.completeDispatch(dispatch.id)
|
||||
controller.abort()
|
||||
const response = await waiting
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'runtime_error' } })
|
||||
await vi.waitFor(() => {
|
||||
expect(db.getUnreadMessages(`dispatch:${dispatch.id}`)).toHaveLength(0)
|
||||
})
|
||||
const checked = await checkBoundMailbox(harness.runtime, {
|
||||
terminal: SECOND_TERMINAL_HANDLE,
|
||||
paneKey: SECOND_PANE_KEY,
|
||||
launchToken: SECOND_LAUNCH_TOKEN
|
||||
})
|
||||
expect(checked.messages).toEqual([expect.objectContaining({ id: status.id })])
|
||||
const acknowledged = await checkBoundMailbox(harness.runtime, {
|
||||
ack: checked.deliveryId!,
|
||||
terminal: SECOND_TERMINAL_HANDLE,
|
||||
paneKey: SECOND_PANE_KEY,
|
||||
launchToken: SECOND_LAUNCH_TOKEN
|
||||
})
|
||||
expect(acknowledged).toMatchObject({ count: 0, acknowledged: checked.deliveryId })
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('finishes only the captured inactive-Dispatch snapshot after cancellation', async () => {
|
||||
const db = createDatabase('orca-mailbox-dispatch-migration-cancel-')
|
||||
const harness = createRuntime(db)
|
||||
registerSecondPane(harness.runtime)
|
||||
const run = db.createRun({
|
||||
objective: 'Dispatch migration cancellation',
|
||||
coordinatorHandle: SECOND_TERMINAL_HANDLE,
|
||||
coordinatorPaneKey: SECOND_PANE_KEY
|
||||
})
|
||||
const task = db.createTask({ spec: 'Cancelled worker check', runId: run.id })
|
||||
const dispatch = db.createDispatchContext(task.id, TERMINAL_HANDLE, PANE_KEY)
|
||||
for (let index = 0; index < 151; index += 1) {
|
||||
insertDirectRunMessage(db, run.id, `Before cancelled migration ${index}`)
|
||||
}
|
||||
const route = db.routeUnreadDirectMessagesToDispatchMailbox.bind(db)
|
||||
let completed = false
|
||||
vi.spyOn(db, 'routeUnreadDirectMessagesToDispatchMailbox').mockImplementation((...args) => {
|
||||
const page = route(...args)
|
||||
if (page.hasMore && !completed) {
|
||||
completed = true
|
||||
setImmediate(() => db.completeDispatch(dispatch.id))
|
||||
}
|
||||
return page
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const migrate = db.routeUnreadDispatchMailboxToRunMailbox.bind(db)
|
||||
let cancelled = false
|
||||
let postSnapshot: ReturnType<typeof db.insertMessage> | undefined
|
||||
const migrationSpy = vi
|
||||
.spyOn(db, 'routeUnreadDispatchMailboxToRunMailbox')
|
||||
.mockImplementation((...args) => {
|
||||
const page = migrate(...args)
|
||||
if (page.hasMore && !cancelled) {
|
||||
cancelled = true
|
||||
postSnapshot = db.insertMessage({
|
||||
from: 'term_remote_worker',
|
||||
to: `dispatch:${dispatch.id}`,
|
||||
subject: 'After cancelled migration snapshot',
|
||||
type: 'status',
|
||||
runId: run.id
|
||||
})
|
||||
setImmediate(() => controller.abort())
|
||||
}
|
||||
return page
|
||||
})
|
||||
const arrivalSpy = vi.spyOn(harness.runtime, 'notifyMessageArrived')
|
||||
|
||||
const response = await dispatchMailboxCheck(harness.runtime, { signal: controller.signal })
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'runtime_error' } })
|
||||
await vi.waitFor(() => {
|
||||
expect(migrationSpy).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
expect(db.getUnreadMessages(`dispatch:${dispatch.id}`)).toEqual([
|
||||
expect.objectContaining({ id: postSnapshot?.id })
|
||||
])
|
||||
expect(arrivalSpy).toHaveBeenCalledTimes(2)
|
||||
expect(arrivalSpy).toHaveBeenNthCalledWith(1, `run:${run.id}`, 'status')
|
||||
expect(arrivalSpy).toHaveBeenNthCalledWith(2, `run:${run.id}`, 'status')
|
||||
expect(
|
||||
sqliteFor(db)
|
||||
.prepare('SELECT COUNT(*) AS count FROM messages WHERE to_handle = ?')
|
||||
.get(`run:${run.id}`)
|
||||
).toEqual({ count: 151 })
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('drains current and stored Run handles in one check', async () => {
|
||||
const db = createDatabase('orca-mailbox-split-run-handles-')
|
||||
const harness = createRuntime(db)
|
||||
const run = db.createRun({
|
||||
objective: 'Split Run handles',
|
||||
coordinatorHandle: 'term_previous_coordinator',
|
||||
coordinatorPaneKey: PANE_KEY
|
||||
})
|
||||
const current = insertDirectRunMessage(db, run.id, 'Current handle')
|
||||
const previous = db.insertMessage({
|
||||
from: 'term_worker',
|
||||
to: 'term_previous_coordinator',
|
||||
subject: 'Previous handle',
|
||||
type: 'status',
|
||||
runId: run.id
|
||||
})
|
||||
|
||||
const checked = await checkBoundMailbox(harness.runtime)
|
||||
|
||||
expect(checked.messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: current.id }),
|
||||
expect.objectContaining({ id: previous.id })
|
||||
])
|
||||
)
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('drains current and stored Dispatch handles in one check', async () => {
|
||||
const db = createDatabase('orca-mailbox-split-dispatch-handles-')
|
||||
const harness = createRuntime(db)
|
||||
const run = db.createRun({
|
||||
objective: 'Split Dispatch handles',
|
||||
coordinatorHandle: 'term_coordinator',
|
||||
coordinatorPaneKey:
|
||||
'55555555-5555-4555-8555-555555555555:66666666-6666-4666-8666-666666666666'
|
||||
})
|
||||
const task = db.createTask({ spec: 'Worker task', runId: run.id })
|
||||
db.createDispatchContext(task.id, 'term_previous_worker', PANE_KEY)
|
||||
const current = insertDirectRunMessage(db, run.id, 'Current worker handle')
|
||||
const previous = db.insertMessage({
|
||||
from: 'term_coordinator',
|
||||
to: 'term_previous_worker',
|
||||
subject: 'Previous worker handle',
|
||||
type: 'status',
|
||||
runId: run.id
|
||||
})
|
||||
|
||||
const checked = await checkBoundMailbox(harness.runtime)
|
||||
|
||||
expect(checked.messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: current.id }),
|
||||
expect.objectContaining({ id: previous.id })
|
||||
])
|
||||
)
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('routes only the direct-mail snapshot captured when the check starts', async () => {
|
||||
const db = createDatabase('orca-mailbox-routing-snapshot-')
|
||||
const harness = createRuntime(db)
|
||||
const run = createBoundRun(db, 'Routing snapshot')
|
||||
for (let index = 0; index < 51; index += 1) {
|
||||
insertDirectRunMessage(db, run.id, `Initial ${index}`)
|
||||
}
|
||||
const route = db.routeUnreadDirectMessagesToRunMailbox.bind(db)
|
||||
let inserted = false
|
||||
const routeSpy = vi
|
||||
.spyOn(db, 'routeUnreadDirectMessagesToRunMailbox')
|
||||
.mockImplementation((...args) => {
|
||||
const page = route(...args)
|
||||
if (page.hasMore && !inserted) {
|
||||
inserted = true
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
insertDirectRunMessage(db, run.id, `Concurrent ${index}`)
|
||||
}
|
||||
}
|
||||
return page
|
||||
})
|
||||
|
||||
await checkBoundMailbox(harness.runtime)
|
||||
|
||||
expect(routeSpy).toHaveBeenCalledTimes(2)
|
||||
expect(db.getUnreadMessages(TERMINAL_HANDLE)).toHaveLength(60)
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('stops paged routing when the check is cancelled', async () => {
|
||||
const db = createDatabase('orca-mailbox-routing-cancel-')
|
||||
const harness = createRuntime(db)
|
||||
const run = createBoundRun(db, 'Routing cancellation')
|
||||
for (let index = 0; index < 101; index += 1) {
|
||||
insertDirectRunMessage(db, run.id, `Before cancellation ${index}`)
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const route = db.routeUnreadDirectMessagesToRunMailbox.bind(db)
|
||||
let scheduled = false
|
||||
const routeSpy = vi
|
||||
.spyOn(db, 'routeUnreadDirectMessagesToRunMailbox')
|
||||
.mockImplementation((...args) => {
|
||||
const page = route(...args)
|
||||
if (!scheduled) {
|
||||
scheduled = true
|
||||
setImmediate(() => controller.abort())
|
||||
}
|
||||
return page
|
||||
})
|
||||
|
||||
const response = await dispatchMailboxCheck(harness.runtime, {
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'runtime_error' } })
|
||||
expect(routeSpy).toHaveBeenCalledOnce()
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('keeps an unfiltered redrive unrestricted when reservations merge', () => {
|
||||
const state = new OrchestrationMailboxPointerState()
|
||||
|
||||
state.parkRedelivery('run:run_test', new Set())
|
||||
state.parkRedelivery('run:run_test', new Set(['worker_done']))
|
||||
|
||||
expect(state.takeRedelivery('run:run_test', false)).toBeNull()
|
||||
|
||||
state.parkRedelivery('run:run_test', new Set(['worker_done']))
|
||||
state.parkRedelivery('run:run_test', new Set())
|
||||
|
||||
expect(state.takeRedelivery('run:run_test', false)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an unfiltered parked delivery unrestricted when reservations merge', () => {
|
||||
const state = new OrchestrationMailboxPointerState()
|
||||
const leaf = {} as never
|
||||
|
||||
const unfilteredFirst = state.beginFlight('pty_unfiltered_first')
|
||||
state.parkDelivery('pty_unfiltered_first', 'run:run_test', leaf, new Set())
|
||||
state.parkDelivery('pty_unfiltered_first', 'run:run_test', leaf, new Set(['worker_done']))
|
||||
|
||||
expect(
|
||||
state.settleFlight('pty_unfiltered_first', unfilteredFirst)?.get('run:run_test')
|
||||
?.reservedTypes
|
||||
).toBeUndefined()
|
||||
|
||||
const unfilteredLast = state.beginFlight('pty_unfiltered_last')
|
||||
state.parkDelivery('pty_unfiltered_last', 'run:run_test', leaf, new Set(['worker_done']))
|
||||
state.parkDelivery('pty_unfiltered_last', 'run:run_test', leaf, new Set())
|
||||
|
||||
expect(
|
||||
state.settleFlight('pty_unfiltered_last', unfilteredLast)?.get('run:run_test')?.reservedTypes
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses a bounded indexed pane lookup for reminted Dispatch identity', () => {
|
||||
const db = createDatabase('orca-mailbox-dispatch-pane-index-')
|
||||
const run = db.createRun({
|
||||
objective: 'Dispatch pane index',
|
||||
coordinatorHandle: 'term_coordinator',
|
||||
coordinatorPaneKey:
|
||||
'55555555-5555-4555-8555-555555555555:66666666-6666-4666-8666-666666666666'
|
||||
})
|
||||
const task = db.createTask({ spec: 'Valid worker', runId: run.id })
|
||||
const valid = db.createDispatchContext(task.id, 'term_old', PANE_KEY)
|
||||
const collisionTask = db.createTask({ spec: 'Malformed collision', runId: run.id })
|
||||
db.createDispatchContext(collisionTask.id, 'term_collision', `:${LEAF_ID}`)
|
||||
|
||||
expect(db.getActiveDispatchForIdentity('term_reminted', PANE_KEY)?.id).toBe(valid.id)
|
||||
const plan = sqliteFor(db)
|
||||
.prepare(
|
||||
`EXPLAIN QUERY PLAN SELECT * FROM dispatch_contexts
|
||||
WHERE assignee_pane_key IS NOT NULL
|
||||
AND status IN ('pending', 'dispatched') AND instr(assignee_pane_key, ':') > 1
|
||||
AND substr(assignee_pane_key, instr(assignee_pane_key, ':') + 1) = ?
|
||||
ORDER BY rowid DESC LIMIT 1`
|
||||
)
|
||||
.all(LEAF_ID) as { detail: string }[]
|
||||
expect(plan.map((row) => row.detail).join(' ')).toContain('idx_dispatch_assignee_pane_leaf')
|
||||
const snapshotPlan = sqliteFor(db)
|
||||
.prepare(
|
||||
`EXPLAIN QUERY PLAN SELECT sequence FROM messages
|
||||
WHERE run_id = ? AND to_handle = ? AND read = 0
|
||||
AND delivery_contract = 'current_delivery'
|
||||
ORDER BY sequence DESC LIMIT 1`
|
||||
)
|
||||
.all(run.id, TERMINAL_HANDLE) as { detail: string }[]
|
||||
expect(snapshotPlan.map((row) => row.detail).join(' ')).toContain(
|
||||
'idx_messages_delivery_contract'
|
||||
)
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createBoundRun,
|
||||
createDatabase,
|
||||
createRuntime,
|
||||
driveToLiveIdle,
|
||||
insertDirectRunMessage,
|
||||
pointerCount,
|
||||
temporaryDirectories
|
||||
} from './orchestration-mailbox-notification-test-harness'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: vi.fn(() => tmpdir()), isPackaged: false },
|
||||
BrowserWindow: { fromId: vi.fn(() => null) },
|
||||
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
|
||||
webContents: { fromId: vi.fn(() => null) }
|
||||
}))
|
||||
|
||||
describe('orchestration mailbox transport settlement', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not durably stage a pointer until transport settlement succeeds', async () => {
|
||||
vi.useFakeTimers()
|
||||
const db = createDatabase('orca-mailbox-transport-settlement-')
|
||||
const first = createRuntime(db)
|
||||
const observedWrite = vi.fn((_ptyId: string, _data: string) => true)
|
||||
let settleWrite: ((accepted: boolean) => void) | undefined
|
||||
first.runtime.setPtyController({
|
||||
write: observedWrite,
|
||||
writeWithSettlement: vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
settleWrite = resolve
|
||||
})
|
||||
),
|
||||
kill: vi.fn(),
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const run = createBoundRun(db, 'SSH settlement Run')
|
||||
const message = insertDirectRunMessage(db, run.id, 'Rejected SSH pointer')
|
||||
|
||||
await driveToLiveIdle(first.runtime)
|
||||
expect(pointerCount(observedWrite)).toBe(0)
|
||||
expect(db.getMessageById(message.id)?.delivered_at).toBeNull()
|
||||
|
||||
settleWrite?.(false)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(pointerCount(observedWrite)).toBe(0)
|
||||
expect(db.getMessageById(message.id)?.delivered_at).toBeNull()
|
||||
|
||||
const restarted = createRuntime(db)
|
||||
await driveToLiveIdle(restarted.runtime)
|
||||
await Promise.resolve()
|
||||
expect(pointerCount(restarted.write)).toBe(1)
|
||||
expect(db.getMessageById(message.id)?.delivered_at).toEqual(expect.any(String))
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,513 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, 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 { OrcaRuntimeService } from './orca-runtime'
|
||||
import { OrchestrationDb } from './orchestration/db'
|
||||
import { RpcDispatcher } from './rpc/dispatcher'
|
||||
import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration'
|
||||
import { OrcaRuntimeRpcServer } from './runtime-rpc'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: vi.fn(() => tmpdir()), isPackaged: false },
|
||||
BrowserWindow: { fromId: vi.fn(() => null) },
|
||||
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
|
||||
webContents: { fromId: vi.fn(() => null) }
|
||||
}))
|
||||
|
||||
const TAB_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const LEAF_ID = '22222222-2222-4222-8222-222222222222'
|
||||
const PANE_KEY = `${TAB_ID}:${LEAF_ID}`
|
||||
const PTY_ID = 'pty-sta-4325'
|
||||
const TERMINAL_HANDLE = 'term_sta_4325'
|
||||
const REMINTED_TERMINAL_HANDLE = 'term_sta_4325_reminted'
|
||||
const WORKTREE_ID = 'repo-sta-4325::/tmp/sta-4325'
|
||||
const LAUNCH_TOKEN = 'sta-4325-launch'
|
||||
const temporaryDirectories: string[] = []
|
||||
const CLI_PATH = join(process.cwd(), 'out', 'cli', 'index.js')
|
||||
const itIfCliBuilt = existsSync(CLI_PATH) ? it : it.skip
|
||||
|
||||
type MessageResult = { id: string; type: string; read: number }
|
||||
type CheckResult = {
|
||||
runId: string
|
||||
deliveryId: string | null
|
||||
messages: MessageResult[]
|
||||
count: number
|
||||
replayed?: boolean
|
||||
acknowledged?: string | null
|
||||
}
|
||||
|
||||
type Sqlite = {
|
||||
prepare: (sql: string) => {
|
||||
all: (...params: unknown[]) => unknown[]
|
||||
run: (...params: unknown[]) => unknown
|
||||
}
|
||||
}
|
||||
|
||||
function sqliteFor(db: OrchestrationDb): Sqlite {
|
||||
return (db as unknown as { db: Sqlite }).db
|
||||
}
|
||||
|
||||
function createDatabase(prefix: string): { db: OrchestrationDb; path: string } {
|
||||
const directory = mkdtempSync(join(tmpdir(), prefix))
|
||||
temporaryDirectories.push(directory)
|
||||
const path = join(directory, 'orchestration.db')
|
||||
return { db: new OrchestrationDb(path), path }
|
||||
}
|
||||
|
||||
function createRuntime(
|
||||
db: OrchestrationDb,
|
||||
terminalHandle = TERMINAL_HANDLE
|
||||
): {
|
||||
runtime: OrcaRuntimeService
|
||||
write: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const runtime = new OrcaRuntimeService(null, undefined, {
|
||||
attestAgentHookCompatibilityAuthority: ({ paneKey }) =>
|
||||
paneKey === PANE_KEY ? { paneKey, source: 'current_hook' } : null
|
||||
})
|
||||
const write = vi.fn(() => true)
|
||||
runtime.setOrchestrationDb(db)
|
||||
runtime.setPtyController({ write, kill: vi.fn(), getForegroundProcess: async () => null })
|
||||
runtime.registerPty(PTY_ID, WORKTREE_ID, null, {
|
||||
tabId: TAB_ID,
|
||||
leafId: LEAF_ID,
|
||||
incarnationId: 'sta-4325-incarnation',
|
||||
agentLaunchAuthority: { launchToken: LAUNCH_TOKEN, launchAgent: 'codex' }
|
||||
})
|
||||
runtime.registerPreAllocatedHandleForPty(PTY_ID, terminalHandle)
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'Codex',
|
||||
activeLeafId: LEAF_ID,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
leafId: LEAF_ID,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: PTY_ID
|
||||
}
|
||||
]
|
||||
})
|
||||
return { runtime, write }
|
||||
}
|
||||
|
||||
async function driveToLiveIdle(runtime: OrcaRuntimeService): Promise<void> {
|
||||
await runtime.listTerminals()
|
||||
runtime.onPtyData(PTY_ID, '\x1b]0;Codex working\x07', 1)
|
||||
runtime.onPtyData(PTY_ID, '\x1b]0;Codex done\x07', 2)
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
async function check(
|
||||
runtime: OrcaRuntimeService,
|
||||
params: Record<string, unknown> = {}
|
||||
): Promise<CheckResult> {
|
||||
const terminal = typeof params.terminal === 'string' ? params.terminal : TERMINAL_HANDLE
|
||||
const response = await new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }).dispatch({
|
||||
id: `req-sta-4325-${Math.random()}`,
|
||||
authToken: 'test-auth-token',
|
||||
method: 'orchestration.check',
|
||||
orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION,
|
||||
orchestrationCompatibilityEvidence: {
|
||||
terminalHandle: terminal,
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: LAUNCH_TOKEN
|
||||
},
|
||||
params: { terminal, ...params }
|
||||
})
|
||||
expect(response.ok).toBe(true)
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
return response.result as CheckResult
|
||||
}
|
||||
|
||||
function pointerPayloads(write: ReturnType<typeof vi.fn>): string[] {
|
||||
return write.mock.calls
|
||||
.map(([, payload]) => String(payload))
|
||||
.filter((payload) => payload.includes('orca orchestration check'))
|
||||
}
|
||||
|
||||
async function runBuiltCli(
|
||||
userDataPath: string,
|
||||
args: string[]
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
const child = spawn(process.execPath, [CLI_PATH, ...args], {
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_USER_DATA_PATH: userDataPath,
|
||||
ORCA_TERMINAL_HANDLE: TERMINAL_HANDLE,
|
||||
ORCA_PANE_KEY: PANE_KEY
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
const stdout: string[] = []
|
||||
const stderr: string[] = []
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stdout.on('data', (chunk) => stdout.push(chunk))
|
||||
child.stderr.on('data', (chunk) => stderr.push(chunk))
|
||||
const exitCode = await new Promise<number>((resolve, reject) => {
|
||||
child.once('close', (code) => resolve(code ?? 1))
|
||||
child.once('error', reject)
|
||||
})
|
||||
return { exitCode, stdout: stdout.join(''), stderr: stderr.join('') }
|
||||
}
|
||||
|
||||
describe('STA-4325 message and delivery identity', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps pointer counts, filters, message IDs, and one fixed Delivery aligned through ack', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { db } = createDatabase('orca-sta-4325-identity-')
|
||||
const { runtime, write } = createRuntime(db)
|
||||
const run = db.createRun({
|
||||
objective: 'STA-4325 identity',
|
||||
coordinatorHandle: TERMINAL_HANDLE,
|
||||
coordinatorPaneKey: PANE_KEY
|
||||
})
|
||||
const status = db.insertMessage({
|
||||
from: 'term_worker_a',
|
||||
to: TERMINAL_HANDLE,
|
||||
subject: 'stale status',
|
||||
type: 'status',
|
||||
runId: run.id,
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
const dispatch = db.insertMessage({
|
||||
from: 'term_worker_b',
|
||||
to: `run:${run.id}`,
|
||||
subject: 'dispatch receipt',
|
||||
type: 'dispatch',
|
||||
runId: run.id,
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
const done = db.insertMessage({
|
||||
from: 'term_worker_c',
|
||||
to: TERMINAL_HANDLE,
|
||||
subject: 'worker complete',
|
||||
type: 'worker_done',
|
||||
runId: run.id,
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
|
||||
await driveToLiveIdle(runtime)
|
||||
const [first, concurrent] = await Promise.all([check(runtime), check(runtime)])
|
||||
const expectedIds = [status.id, dispatch.id, done.id]
|
||||
|
||||
expect(pointerPayloads(write)).toEqual([expect.stringContaining('3 orchestration messages')])
|
||||
expect(first).toMatchObject({ runId: run.id, count: 3, replayed: false })
|
||||
expect(concurrent).toMatchObject({ runId: run.id, count: 3, replayed: true })
|
||||
expect(first.deliveryId).toBeTruthy()
|
||||
expect(concurrent.deliveryId).toBe(first.deliveryId)
|
||||
expect(first.messages.map((message) => message.id)).toEqual(expectedIds)
|
||||
expect(concurrent.messages.map((message) => message.id)).toEqual(expectedIds)
|
||||
|
||||
const peeked = await check(runtime, { peek: true, unread: false })
|
||||
const filtered = await check(runtime, { peek: true, unread: false, types: 'worker_done' })
|
||||
const all = await check(runtime, { all: true })
|
||||
expect(peeked.messages.map((message) => message.id)).toEqual(
|
||||
expect.arrayContaining(expectedIds)
|
||||
)
|
||||
expect(peeked.count).toBe(3)
|
||||
expect(filtered.messages.map((message) => message.id)).toEqual([done.id])
|
||||
expect(all.messages.map((message) => message.id)).toEqual(expect.arrayContaining(expectedIds))
|
||||
expect(all.count).toBe(3)
|
||||
|
||||
const deliveryRowsBeforeAck = sqliteFor(db)
|
||||
.prepare('SELECT id, status, message_ids FROM deliveries ORDER BY rowid')
|
||||
.all() as { id: string; status: string; message_ids: string }[]
|
||||
expect(deliveryRowsBeforeAck).toEqual([
|
||||
{ id: first.deliveryId, status: 'outstanding', message_ids: JSON.stringify(expectedIds) }
|
||||
])
|
||||
for (const id of expectedIds) {
|
||||
expect(db.getMessageById(id)).toMatchObject({ to_handle: `run:${run.id}`, read: 0 })
|
||||
}
|
||||
|
||||
const acknowledged = await check(runtime, { ack: first.deliveryId })
|
||||
expect(acknowledged).toMatchObject({
|
||||
count: 0,
|
||||
deliveryId: null,
|
||||
acknowledged: first.deliveryId
|
||||
})
|
||||
expect((await check(runtime, { unread: true })).count).toBe(0)
|
||||
const acknowledgedHistory = await check(runtime, { all: true })
|
||||
expect(acknowledgedHistory.messages.map((message) => message.id)).toEqual(
|
||||
expect.arrayContaining(expectedIds)
|
||||
)
|
||||
expect(acknowledgedHistory.count).toBe(3)
|
||||
expect(sqliteFor(db).prepare('SELECT id, status FROM deliveries ORDER BY rowid').all()).toEqual(
|
||||
[{ id: first.deliveryId, status: 'acknowledged' }]
|
||||
)
|
||||
for (const id of expectedIds) {
|
||||
expect(db.getMessageById(id)?.read).toBe(1)
|
||||
}
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('replays one outstanding Delivery across restart and wakes a filtered waiter once', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fixture = createDatabase('orca-sta-4325-restart-')
|
||||
const firstRuntime = createRuntime(fixture.db)
|
||||
const run = fixture.db.createRun({
|
||||
objective: 'STA-4325 restart',
|
||||
coordinatorHandle: TERMINAL_HANDLE,
|
||||
coordinatorPaneKey: PANE_KEY
|
||||
})
|
||||
const status = fixture.db.insertMessage({
|
||||
from: 'term_worker',
|
||||
to: TERMINAL_HANDLE,
|
||||
subject: 'survive restart',
|
||||
type: 'status',
|
||||
runId: run.id,
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
await driveToLiveIdle(firstRuntime.runtime)
|
||||
const beforeRestart = await check(firstRuntime.runtime)
|
||||
expect(beforeRestart.messages.map((message) => message.id)).toEqual([status.id])
|
||||
fixture.db.close()
|
||||
|
||||
const reopened = new OrchestrationDb(fixture.path)
|
||||
const restarted = createRuntime(reopened, REMINTED_TERMINAL_HANDLE)
|
||||
await driveToLiveIdle(restarted.runtime)
|
||||
const afterRestart = await check(restarted.runtime, { terminal: REMINTED_TERMINAL_HANDLE })
|
||||
expect(afterRestart).toMatchObject({ deliveryId: beforeRestart.deliveryId, replayed: true })
|
||||
expect(afterRestart.messages.map((message) => message.id)).toEqual([status.id])
|
||||
expect(sqliteFor(reopened).prepare('SELECT id FROM deliveries').all()).toEqual([
|
||||
{ id: beforeRestart.deliveryId }
|
||||
])
|
||||
|
||||
await check(restarted.runtime, {
|
||||
terminal: REMINTED_TERMINAL_HANDLE,
|
||||
ack: afterRestart.deliveryId
|
||||
})
|
||||
const waiting = check(restarted.runtime, {
|
||||
terminal: REMINTED_TERMINAL_HANDLE,
|
||||
wait: true,
|
||||
types: 'worker_done',
|
||||
timeoutMs: 5_000
|
||||
})
|
||||
const internals = restarted.runtime as unknown as {
|
||||
messageWaitersByHandle: Map<string, Set<unknown>>
|
||||
}
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
if (internals.messageWaitersByHandle.has(`run:${run.id}`)) {
|
||||
break
|
||||
}
|
||||
await Promise.resolve()
|
||||
}
|
||||
expect(internals.messageWaitersByHandle.has(`run:${run.id}`)).toBe(true)
|
||||
|
||||
const done = reopened.insertMessage({
|
||||
from: 'term_worker',
|
||||
to: TERMINAL_HANDLE,
|
||||
subject: 'done after restart',
|
||||
type: 'worker_done',
|
||||
runId: run.id,
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
restarted.runtime.notifyMessageArrived(done.to_handle, done.type)
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
|
||||
await expect(waiting).resolves.toMatchObject({
|
||||
runId: run.id,
|
||||
count: 1,
|
||||
messages: [expect.objectContaining({ id: done.id })]
|
||||
})
|
||||
expect(pointerPayloads(restarted.write)).toHaveLength(0)
|
||||
reopened.close()
|
||||
})
|
||||
|
||||
it('routes the complete direct backlog before rebinding forgets its old handle', () => {
|
||||
const fixture = createDatabase('orca-sta-4325-rebind-backlog-')
|
||||
const first = fixture.db.createRun({
|
||||
objective: 'Old coordinator',
|
||||
coordinatorHandle: TERMINAL_HANDLE,
|
||||
coordinatorPaneKey: PANE_KEY
|
||||
})
|
||||
for (let index = 0; index < 125; index += 1) {
|
||||
const message = fixture.db.insertMessage({
|
||||
from: 'term_worker',
|
||||
to: TERMINAL_HANDLE,
|
||||
subject: `backlog ${index}`,
|
||||
type: 'status',
|
||||
runId: first.id,
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
sqliteFor(fixture.db)
|
||||
.prepare('UPDATE messages SET to_handle = ? WHERE id = ?')
|
||||
.run(TERMINAL_HANDLE, message.id)
|
||||
}
|
||||
const plan = sqliteFor(fixture.db)
|
||||
.prepare(
|
||||
`EXPLAIN QUERY PLAN UPDATE messages SET to_handle = ?
|
||||
WHERE run_id = ? AND to_handle = ? AND read = 0
|
||||
AND delivery_contract = 'current_delivery'`
|
||||
)
|
||||
.all(`run:${first.id}`, first.id, TERMINAL_HANDLE) as { detail: string }[]
|
||||
expect(plan.map((row) => row.detail).join(' ')).toMatch(
|
||||
/SEARCH messages USING INDEX (idx_messages_delivery_contract|idx_messages_unread_current_inbox)/
|
||||
)
|
||||
fixture.db.createRun({
|
||||
objective: 'Replacement coordinator',
|
||||
coordinatorHandle: TERMINAL_HANDLE,
|
||||
coordinatorPaneKey: PANE_KEY
|
||||
})
|
||||
expect(fixture.db.getRun(first.id)?.coordinator_handle).toBeNull()
|
||||
fixture.db.close()
|
||||
|
||||
const reopened = new OrchestrationDb(fixture.path)
|
||||
expect(
|
||||
sqliteFor(reopened)
|
||||
.prepare(
|
||||
`SELECT to_handle, COUNT(*) AS count FROM messages
|
||||
WHERE run_id = ? GROUP BY to_handle ORDER BY to_handle`
|
||||
)
|
||||
.all(first.id)
|
||||
).toEqual([{ to_handle: `run:${first.id}`, count: 125 }])
|
||||
reopened.close()
|
||||
})
|
||||
|
||||
it('repairs committed mail sent to a forgotten handle after restart', async () => {
|
||||
const fixture = createDatabase('orca-sta-4325-late-old-handle-')
|
||||
const run = fixture.db.createRun({
|
||||
objective: 'Late old-handle arrival',
|
||||
coordinatorHandle: TERMINAL_HANDLE,
|
||||
coordinatorPaneKey: PANE_KEY
|
||||
})
|
||||
fixture.db.bindRun({
|
||||
runId: run.id,
|
||||
coordinatorHandle: REMINTED_TERMINAL_HANDLE,
|
||||
coordinatorPaneKey: PANE_KEY
|
||||
})
|
||||
const done = fixture.db.insertMessage({
|
||||
from: 'term_worker',
|
||||
to: TERMINAL_HANDLE,
|
||||
subject: 'arrived after rebind',
|
||||
type: 'worker_done',
|
||||
runId: run.id,
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
expect(fixture.db.getMessageById(done.id)?.to_handle).toBe(`run:${run.id}`)
|
||||
expect(
|
||||
fixture.db
|
||||
.getOrCreateRunDelivery({
|
||||
runId: run.id,
|
||||
consumerGeneration: fixture.db.getRun(run.id)!.consumer_generation
|
||||
})
|
||||
?.messages.map((message) => message.id)
|
||||
).toEqual([done.id])
|
||||
fixture.db.close()
|
||||
|
||||
const reopened = new OrchestrationDb(fixture.path)
|
||||
const restarted = createRuntime(reopened, REMINTED_TERMINAL_HANDLE)
|
||||
await driveToLiveIdle(restarted.runtime)
|
||||
const checked = await check(restarted.runtime, { terminal: REMINTED_TERMINAL_HANDLE })
|
||||
|
||||
expect(checked).toMatchObject({
|
||||
runId: run.id,
|
||||
count: 1,
|
||||
messages: [expect.objectContaining({ id: done.id })]
|
||||
})
|
||||
expect(reopened.getMessageById(done.id)?.to_handle).toBe(`run:${run.id}`)
|
||||
reopened.close()
|
||||
})
|
||||
|
||||
itIfCliBuilt(
|
||||
'keeps the built CLI count and Delivery output aligned with isolated SQLite state',
|
||||
async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-sta-4325-cli-'))
|
||||
temporaryDirectories.push(userDataPath)
|
||||
const db = new OrchestrationDb(join(userDataPath, 'orchestration.db'))
|
||||
const runtime = new OrcaRuntimeService()
|
||||
runtime.setOrchestrationDb(db)
|
||||
vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) =>
|
||||
handle === TERMINAL_HANDLE ? PANE_KEY : null
|
||||
)
|
||||
const run = db.createRun({
|
||||
objective: 'STA-4325 built CLI',
|
||||
coordinatorHandle: TERMINAL_HANDLE,
|
||||
coordinatorPaneKey: PANE_KEY
|
||||
})
|
||||
const status = db.insertMessage({
|
||||
from: 'term_worker',
|
||||
to: TERMINAL_HANDLE,
|
||||
subject: 'direct status',
|
||||
type: 'status',
|
||||
runId: run.id,
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
const done = db.insertMessage({
|
||||
from: 'term_worker',
|
||||
to: `run:${run.id}`,
|
||||
subject: 'canonical done',
|
||||
type: 'worker_done',
|
||||
runId: run.id,
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
||||
await server.start()
|
||||
|
||||
try {
|
||||
const first = await runBuiltCli(userDataPath, ['orchestration', 'check', '--json'])
|
||||
expect(first.exitCode, first.stderr).toBe(0)
|
||||
const firstPayload = JSON.parse(first.stdout) as { result: CheckResult }
|
||||
expect(firstPayload.result).toMatchObject({ runId: run.id, count: 2, replayed: false })
|
||||
expect(firstPayload.result.messages.map((message) => message.id)).toEqual([
|
||||
status.id,
|
||||
done.id
|
||||
])
|
||||
|
||||
const replay = await runBuiltCli(userDataPath, ['orchestration', 'check', '--json'])
|
||||
expect(replay.exitCode, replay.stderr).toBe(0)
|
||||
const replayPayload = JSON.parse(replay.stdout) as { result: CheckResult }
|
||||
expect(replayPayload.result).toMatchObject({
|
||||
count: 2,
|
||||
deliveryId: firstPayload.result.deliveryId,
|
||||
replayed: true
|
||||
})
|
||||
expect(sqliteFor(db).prepare('SELECT id, status FROM deliveries').all()).toEqual([
|
||||
{ id: firstPayload.result.deliveryId, status: 'outstanding' }
|
||||
])
|
||||
expect(db.getMessageById(status.id)).toMatchObject({
|
||||
to_handle: `run:${run.id}`,
|
||||
read: 0
|
||||
})
|
||||
|
||||
const acknowledged = await runBuiltCli(userDataPath, [
|
||||
'orchestration',
|
||||
'check',
|
||||
'--ack',
|
||||
firstPayload.result.deliveryId!,
|
||||
'--json'
|
||||
])
|
||||
expect(acknowledged.exitCode, acknowledged.stderr).toBe(0)
|
||||
expect(JSON.parse(acknowledged.stdout)).toMatchObject({
|
||||
result: { count: 0, deliveryId: null, acknowledged: firstPayload.result.deliveryId }
|
||||
})
|
||||
expect(db.getMessageById(status.id)?.read).toBe(1)
|
||||
expect(db.getMessageById(done.id)?.read).toBe(1)
|
||||
} finally {
|
||||
await server.stop()
|
||||
db.close()
|
||||
}
|
||||
},
|
||||
30_000
|
||||
)
|
||||
})
|
||||
@@ -87,12 +87,25 @@ function parseWorkerTerminalPriorOwnerIds(value: string): string[] | null {
|
||||
}
|
||||
}
|
||||
|
||||
const MESSAGE_ID_UPDATE_BATCH_SIZE = 500
|
||||
export const ORCHESTRATION_DELIVERY_BATCH_LIMIT = 50
|
||||
|
||||
export type MailboxRoutingPage = {
|
||||
routedCount: number
|
||||
hasMore: boolean
|
||||
types: MessageType[]
|
||||
}
|
||||
|
||||
export type ForeignDirectMailboxRoutingPage = MailboxRoutingPage & {
|
||||
mailboxes: { mailboxHandle: string; types: MessageType[] }[]
|
||||
}
|
||||
// Why: indexable pre-filter for isEquivalentPaneKey — equal strings and equal leaves both share the
|
||||
// text after the first ':', so this narrows candidates without deciding equivalence itself.
|
||||
const RUN_PANE_KEY_MATCH_SUFFIX_SQL =
|
||||
"substr(coordinator_pane_key, instr(coordinator_pane_key, ':') + 1)"
|
||||
const DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL =
|
||||
"substr(assignee_pane_key, instr(assignee_pane_key, ':') + 1)"
|
||||
const REMOTE_ATTACHMENT_PANE_KEY_MATCH_SUFFIX_SQL = "substr(pane_key, instr(pane_key, ':') + 1)"
|
||||
|
||||
function paneKeyMatchSuffix(paneKey: string): string {
|
||||
const colon = paneKey.indexOf(':')
|
||||
@@ -297,8 +310,8 @@ type RunListCursor = {
|
||||
id: string
|
||||
}
|
||||
|
||||
// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup, v23 worker terminal resource ownership, v24 creator-incarnation authority, v25 active Dispatch handle lookup, v26 indexed mutation receipt capacity, v27 durable federation acknowledgments.
|
||||
const SCHEMA_VERSION = 27
|
||||
// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup, v23 worker terminal resource ownership, v24 creator-incarnation authority, v25 active Dispatch handle lookup, v26 indexed mutation receipt capacity, v27 durable federation acknowledgments, v28 durable local mutation caller identity.
|
||||
const SCHEMA_VERSION = 28
|
||||
|
||||
function hardenOrchestrationDatabaseFiles(dbPath: (string & {}) | ':memory:'): void {
|
||||
if (dbPath === ':memory:' || process.platform === 'win32') {
|
||||
@@ -322,6 +335,7 @@ export class OrchestrationDb {
|
||||
// emptiness so the non-orchestration majority short-circuits the whole
|
||||
// per-terminal fan-out. Only createDispatchContext flips this false→true.
|
||||
private hasAnyDispatchContextsCache: boolean | undefined
|
||||
private localMutationCallerFingerprint: string | undefined
|
||||
|
||||
constructor(dbPath: (string & {}) | ':memory:') {
|
||||
this.db = new Database(dbPath)
|
||||
@@ -330,6 +344,8 @@ export class OrchestrationDb {
|
||||
this.db.pragma('busy_timeout = 5000')
|
||||
this.createTables()
|
||||
this.migrate()
|
||||
this.createCoordinatorMailRoutingTrigger()
|
||||
this.rememberCurrentRunCoordinatorHandles()
|
||||
hardenOrchestrationDatabaseFiles(dbPath)
|
||||
}
|
||||
|
||||
@@ -376,6 +392,38 @@ export class OrchestrationDb {
|
||||
CREATE INDEX IF NOT EXISTS idx_inbox ON messages(to_handle, read);
|
||||
CREATE INDEX IF NOT EXISTS idx_thread ON messages(thread_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS run_coordinator_handles (
|
||||
run_id TEXT NOT NULL,
|
||||
terminal_handle TEXT NOT NULL,
|
||||
first_bound_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (run_id, terminal_handle)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_run_coordinator_handles_handle
|
||||
ON run_coordinator_handles(terminal_handle, run_id);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_runs_remember_coordinator_insert
|
||||
AFTER INSERT ON runs
|
||||
WHEN NEW.legacy = 0 AND NEW.coordinator_handle IS NOT NULL
|
||||
BEGIN
|
||||
INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle)
|
||||
VALUES (NEW.id, NEW.coordinator_handle);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_runs_remember_coordinator_update
|
||||
AFTER UPDATE OF coordinator_handle ON runs
|
||||
WHEN NEW.legacy = 0 AND NEW.coordinator_handle IS NOT NULL
|
||||
BEGIN
|
||||
INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle)
|
||||
VALUES (NEW.id, NEW.coordinator_handle);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_runs_forget_coordinator_handles
|
||||
AFTER DELETE ON runs
|
||||
BEGIN
|
||||
DELETE FROM run_coordinator_handles WHERE run_id = OLD.id;
|
||||
END;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deliveries (
|
||||
id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL,
|
||||
@@ -405,6 +453,11 @@ export class OrchestrationDb {
|
||||
PRIMARY KEY (caller_fingerprint, request_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mutation_caller_identities (
|
||||
transport TEXT PRIMARY KEY,
|
||||
caller_fingerprint TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS worker_dispatches (
|
||||
dispatch_id TEXT PRIMARY KEY,
|
||||
runtime_epoch TEXT,
|
||||
@@ -510,6 +563,12 @@ export class OrchestrationDb {
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_dispatch_attachments_active_pane
|
||||
ON remote_dispatch_attachments(pane_key)
|
||||
WHERE state IN ('starting', 'ready');
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_dispatch_attachments_active_pane_suffix
|
||||
ON remote_dispatch_attachments(${REMOTE_ATTACHMENT_PANE_KEY_MATCH_SUFFIX_SQL})
|
||||
WHERE state IN ('starting', 'ready') AND pane_key IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS federation_relay_items (
|
||||
dispatch_id TEXT NOT NULL,
|
||||
@@ -624,7 +683,7 @@ export class OrchestrationDb {
|
||||
scheduler_lost_at TEXT
|
||||
);
|
||||
`)
|
||||
this.createUndeliveredInboxIndexIfPossible()
|
||||
this.createMailboxDeliveryIndexesIfPossible()
|
||||
}
|
||||
|
||||
// Why: CREATE TABLE IF NOT EXISTS won't alter existing DBs; migrate in a txn that bumps user_version only on success (atomic all-or-nothing).
|
||||
@@ -1012,12 +1071,29 @@ export class OrchestrationDb {
|
||||
'ALTER TABLE federated_dispatches ADD COLUMN to_home_acknowledged_sequence INTEGER NOT NULL DEFAULT 0'
|
||||
)
|
||||
}
|
||||
if (current < 28) {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS mutation_caller_identities (
|
||||
transport TEXT PRIMARY KEY,
|
||||
caller_fingerprint TEXT NOT NULL UNIQUE
|
||||
);
|
||||
`)
|
||||
}
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_pane_leaf
|
||||
ON dispatch_contexts(${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL})
|
||||
WHERE assignee_pane_key IS NOT NULL AND status IN ('pending', 'dispatched');
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_active_run_assignee_handle
|
||||
ON dispatch_contexts(run_id, assignee_handle)
|
||||
WHERE assignee_handle IS NOT NULL AND status IN ('pending', 'dispatched');
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_active_run_pane_leaf
|
||||
ON dispatch_contexts(run_id, ${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL})
|
||||
WHERE assignee_pane_key IS NOT NULL AND status IN ('pending', 'dispatched');
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_active_assignee_pane_key
|
||||
ON dispatch_contexts(assignee_pane_key)
|
||||
WHERE assignee_pane_key IS NOT NULL AND status IN ('pending', 'dispatched');
|
||||
`)
|
||||
this.createUndeliveredInboxIndexIfPossible()
|
||||
this.createMailboxDeliveryIndexesIfPossible()
|
||||
|
||||
this.db.pragma(`user_version = ${SCHEMA_VERSION}`)
|
||||
this.db.exec('COMMIT')
|
||||
@@ -1400,13 +1476,36 @@ export class OrchestrationDb {
|
||||
return rows.some((r) => r.name === column)
|
||||
}
|
||||
|
||||
private createUndeliveredInboxIndexIfPossible(): void {
|
||||
if (!this.hasColumn('messages', 'delivered_at')) {
|
||||
private createMailboxDeliveryIndexesIfPossible(): void {
|
||||
const hasDeliveredAt = this.hasColumn('messages', 'delivered_at')
|
||||
if (hasDeliveredAt) {
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_undelivered_inbox
|
||||
ON messages(to_handle, read, delivered_at, sequence)
|
||||
`)
|
||||
}
|
||||
|
||||
if (
|
||||
!hasDeliveredAt ||
|
||||
!this.hasColumn('messages', 'run_id') ||
|
||||
!this.hasColumn('messages', 'delivery_contract')
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_undelivered_inbox
|
||||
ON messages(to_handle, read, delivered_at, sequence)
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_undelivered_direct_run
|
||||
ON messages(run_id, to_handle, sequence)
|
||||
WHERE read = 0 AND delivered_at IS NULL
|
||||
AND delivery_contract = 'current_delivery';
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_unread_current_inbox
|
||||
ON messages(to_handle, sequence)
|
||||
WHERE read = 0 AND delivery_contract = 'current_delivery';
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_unread_current_inbox_type
|
||||
ON messages(to_handle, type, sequence)
|
||||
WHERE read = 0 AND delivery_contract = 'current_delivery';
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_unread_current_run_type
|
||||
ON messages(run_id, to_handle, type, sequence)
|
||||
WHERE read = 0 AND delivery_contract = 'current_delivery';
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -1427,6 +1526,34 @@ export class OrchestrationDb {
|
||||
|
||||
// ── Durable mutation receipts ──
|
||||
|
||||
getOrCreateLocalMutationCallerFingerprint(): string {
|
||||
if (this.localMutationCallerFingerprint) {
|
||||
return this.localMutationCallerFingerprint
|
||||
}
|
||||
const transport = 'local_authenticated_transport'
|
||||
const existing = this.db
|
||||
.prepare('SELECT caller_fingerprint FROM mutation_caller_identities WHERE transport = ?')
|
||||
.get(transport) as { caller_fingerprint: string } | undefined
|
||||
if (existing) {
|
||||
this.localMutationCallerFingerprint = existing.caller_fingerprint
|
||||
return this.localMutationCallerFingerprint
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO mutation_caller_identities (transport, caller_fingerprint)
|
||||
VALUES (?, ?)`
|
||||
)
|
||||
.run(transport, randomBytes(32).toString('hex'))
|
||||
const created = this.db
|
||||
.prepare('SELECT caller_fingerprint FROM mutation_caller_identities WHERE transport = ?')
|
||||
.get(transport) as { caller_fingerprint: string } | undefined
|
||||
if (!created) {
|
||||
throw new Error('Failed to create the local orchestration mutation caller identity.')
|
||||
}
|
||||
this.localMutationCallerFingerprint = created.caller_fingerprint
|
||||
return this.localMutationCallerFingerprint
|
||||
}
|
||||
|
||||
beginMutationReceipt(params: {
|
||||
callerFingerprint: string
|
||||
requestId: string
|
||||
@@ -1784,6 +1911,7 @@ export class OrchestrationDb {
|
||||
return { terminalHandle: params.terminalHandle, paneKey: params.paneKey }
|
||||
}
|
||||
|
||||
// Why: caller-side fence jurisdiction. Widening this fences more callers, so keep it narrow.
|
||||
isLegacyCoordinatorHandle(runId: string, terminalHandle: string): boolean {
|
||||
const principal = this.getLegacyCoordinatorPrincipal(runId)
|
||||
if (principal) {
|
||||
@@ -1792,6 +1920,20 @@ export class OrchestrationDb {
|
||||
return this.getUniqueLegacyCoordinatorHandle(runId) === terminalHandle
|
||||
}
|
||||
|
||||
// Why: recipient-side permit for legacy lifecycle mail. After a takeover revokes the old principal
|
||||
// the replacement coordinator is only reachable through the Run binding.
|
||||
isLegacyCoordinatorDeliveryTarget(runId: string, terminalHandle: string): boolean {
|
||||
if (this.isLegacyCoordinatorHandle(runId, terminalHandle)) {
|
||||
return true
|
||||
}
|
||||
if (this.getRunRaw(runId)?.coordinator_handle !== terminalHandle) {
|
||||
return false
|
||||
}
|
||||
// Why: must match resolveLegacyWorkerCoordinatorDelivery's takeover test. A still-committed
|
||||
// principal routes legacy_direct to this handle, and no reader can see that mailbox.
|
||||
return this.getLegacyCoordinatorPrincipal(runId)?.status !== 'committed'
|
||||
}
|
||||
|
||||
findLegacyWorkerCompletion(params: {
|
||||
principalId: string
|
||||
taskId: string
|
||||
@@ -1967,7 +2109,10 @@ export class OrchestrationDb {
|
||||
recovery: boolean
|
||||
} {
|
||||
const principal = this.requireLegacyMailPrincipal(params.principalId)
|
||||
const limit = Math.min(Math.max(params.limit ?? 50, 1), 50)
|
||||
const limit = Math.min(
|
||||
Math.max(params.limit ?? ORCHESTRATION_DELIVERY_BATCH_LIMIT, 1),
|
||||
ORCHESTRATION_DELIVERY_BATCH_LIMIT
|
||||
)
|
||||
const addressSql =
|
||||
principal.role === 'worker' ? '(m.to_handle = ? OR m.to_handle = ?)' : 'm.to_handle = ?'
|
||||
const addressParams =
|
||||
@@ -2248,6 +2393,7 @@ export class OrchestrationDb {
|
||||
) VALUES (?, ?, ?, ?, 1, 0)`
|
||||
)
|
||||
.run(id, params.objective, params.coordinatorHandle, params.coordinatorPaneKey)
|
||||
this.rememberRunCoordinatorHandle(id, params.coordinatorHandle)
|
||||
this.db.exec('COMMIT')
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
@@ -2364,6 +2510,14 @@ export class OrchestrationDb {
|
||||
)
|
||||
}
|
||||
this.unbindOtherRunsForPane(params.coordinatorPaneKey, params.runId)
|
||||
for (const handle of new Set(
|
||||
[run.coordinator_handle, params.coordinatorHandle].filter((value): value is string =>
|
||||
Boolean(value)
|
||||
)
|
||||
)) {
|
||||
this.rememberRunCoordinatorHandle(params.runId, handle)
|
||||
this.routeAllUnreadDirectMessagesToRunMailbox(params.runId, handle)
|
||||
}
|
||||
if (
|
||||
(params.takeoverLegacy && !takeoverAlreadyApplied) ||
|
||||
!sameBinding ||
|
||||
@@ -2470,6 +2624,9 @@ export class OrchestrationDb {
|
||||
private unbindOtherRunsForPane(paneKey: string, exceptRunId?: string): void {
|
||||
for (const run of this.runsBoundToPane(paneKey)) {
|
||||
if (run.id !== exceptRunId) {
|
||||
if (run.coordinator_handle) {
|
||||
this.routeAllUnreadDirectMessagesToRunMailbox(run.id, run.coordinator_handle)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE runs
|
||||
@@ -2490,6 +2647,54 @@ export class OrchestrationDb {
|
||||
}
|
||||
}
|
||||
|
||||
private rememberRunCoordinatorHandle(runId: string, terminalHandle: string): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) VALUES (?, ?)`
|
||||
)
|
||||
.run(runId, terminalHandle)
|
||||
}
|
||||
|
||||
private rememberCurrentRunCoordinatorHandles(): void {
|
||||
this.db.exec(`
|
||||
INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle)
|
||||
SELECT id, coordinator_handle FROM runs
|
||||
WHERE legacy = 0 AND coordinator_handle IS NOT NULL
|
||||
`)
|
||||
}
|
||||
|
||||
private createCoordinatorMailRoutingTrigger(): void {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
this.db.exec(`
|
||||
DROP TRIGGER IF EXISTS trg_messages_route_coordinator_mail;
|
||||
CREATE TRIGGER trg_messages_route_coordinator_mail
|
||||
AFTER INSERT ON messages
|
||||
WHEN NEW.read = 0 AND NEW.delivery_contract = 'current_delivery'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM runs
|
||||
WHERE runs.id = NEW.run_id AND runs.legacy = 0
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM run_coordinator_handles
|
||||
WHERE run_id = NEW.run_id AND terminal_handle = NEW.to_handle
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM dispatch_contexts
|
||||
WHERE run_id = NEW.run_id AND assignee_handle = NEW.to_handle
|
||||
AND status IN ('pending', 'dispatched')
|
||||
)
|
||||
BEGIN
|
||||
UPDATE messages SET to_handle = 'run:' || NEW.run_id WHERE sequence = NEW.sequence;
|
||||
END;
|
||||
`)
|
||||
this.db.exec('COMMIT')
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private fenceOutstandingDelivery(runId: string): void {
|
||||
this.db
|
||||
.prepare(
|
||||
@@ -2687,7 +2892,10 @@ export class OrchestrationDb {
|
||||
limit?: number
|
||||
wakeTypes?: MessageType[]
|
||||
}): { delivery: DeliveryRow; messages: MessageRow[]; replayed: boolean } | undefined {
|
||||
const limit = Math.min(Math.max(params.limit ?? 50, 1), 50)
|
||||
const limit = Math.min(
|
||||
Math.max(params.limit ?? ORCHESTRATION_DELIVERY_BATCH_LIMIT, 1),
|
||||
ORCHESTRATION_DELIVERY_BATCH_LIMIT
|
||||
)
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
this.requireCurrentConsumer(params.runId, params.consumerGeneration)
|
||||
@@ -2832,6 +3040,31 @@ export class OrchestrationDb {
|
||||
)
|
||||
}
|
||||
|
||||
getUnreadRunMailbox(runId: string, limit = 100, types?: MessageType[]): MessageRow[] {
|
||||
const address = `run:${runId}`
|
||||
const conditions = [
|
||||
'run_id = ?',
|
||||
'to_handle = ?',
|
||||
'read = 0',
|
||||
"delivery_contract = 'current_delivery'"
|
||||
]
|
||||
const params: (string | number)[] = [runId, address]
|
||||
if (types?.length) {
|
||||
conditions.push(`type IN (${types.map(() => '?').join(',')})`)
|
||||
params.push(...types)
|
||||
}
|
||||
const indexClause = types?.length ? ' INDEXED BY idx_messages_unread_current_run_type' : ''
|
||||
params.push(Math.max(1, Math.floor(limit)))
|
||||
return exposeMessageListTimestamps(
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT * FROM messages${indexClause} WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY sequence ASC LIMIT ?`
|
||||
)
|
||||
.all(...params) as MessageRow[]
|
||||
)
|
||||
}
|
||||
|
||||
// ── Messages ──
|
||||
|
||||
insertMessage(msg: {
|
||||
@@ -3494,29 +3727,38 @@ export class OrchestrationDb {
|
||||
}
|
||||
|
||||
// Why: delivered_at IS NULL filter — push-on-idle delivers each row at most once; read (set only by check) wouldn't prevent replay.
|
||||
getUndeliveredUnreadMessages(toHandle: string, types?: MessageType[]): MessageRow[] {
|
||||
if (types && types.length > 0) {
|
||||
const placeholders = types.map(() => '?').join(',')
|
||||
return exposeMessageListTimestamps(
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT * FROM messages
|
||||
WHERE to_handle = ? AND read = 0 AND delivered_at IS NULL
|
||||
AND delivery_contract = 'current_delivery'
|
||||
AND type IN (${placeholders}) ORDER BY sequence`
|
||||
)
|
||||
.all(toHandle, ...types) as MessageRow[]
|
||||
)
|
||||
getUndeliveredUnreadMessages(
|
||||
toHandle: string,
|
||||
types?: MessageType[],
|
||||
options?: { excludeTypes?: readonly string[]; limit?: number }
|
||||
): MessageRow[] {
|
||||
const conditions = [
|
||||
'to_handle = ?',
|
||||
'read = 0',
|
||||
'delivered_at IS NULL',
|
||||
"delivery_contract = 'current_delivery'"
|
||||
]
|
||||
const params: (string | number)[] = [toHandle]
|
||||
if (types?.length) {
|
||||
conditions.push(`type IN (${types.map(() => '?').join(',')})`)
|
||||
params.push(...types)
|
||||
}
|
||||
if (options?.excludeTypes?.length) {
|
||||
conditions.push(`type NOT IN (${options.excludeTypes.map(() => '?').join(',')})`)
|
||||
params.push(...options.excludeTypes)
|
||||
}
|
||||
const limitSql = options?.limit === undefined ? '' : ' LIMIT ?'
|
||||
if (options?.limit !== undefined) {
|
||||
params.push(Math.max(1, Math.floor(options.limit)))
|
||||
}
|
||||
return exposeMessageListTimestamps(
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT * FROM messages
|
||||
WHERE to_handle = ? AND read = 0 AND delivered_at IS NULL
|
||||
AND delivery_contract = 'current_delivery'
|
||||
ORDER BY sequence`
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY sequence${limitSql}`
|
||||
)
|
||||
.all(toHandle) as MessageRow[]
|
||||
.all(...params) as MessageRow[]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3532,6 +3774,389 @@ export class OrchestrationDb {
|
||||
).map((row) => row.to_handle)
|
||||
}
|
||||
|
||||
hasUndeliveredDirectMessageForRun(runId: string, directHandle: string): boolean {
|
||||
return Boolean(
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT 1 FROM messages INDEXED BY idx_messages_undelivered_direct_run
|
||||
WHERE run_id = ? AND to_handle = ? AND read = 0 AND delivered_at IS NULL
|
||||
AND delivery_contract = 'current_delivery'
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(runId, directHandle)
|
||||
)
|
||||
}
|
||||
|
||||
hasOutstandingRunDelivery(runId: string): boolean {
|
||||
return Boolean(
|
||||
this.db
|
||||
.prepare("SELECT 1 FROM deliveries WHERE run_id = ? AND status = 'outstanding' LIMIT 1")
|
||||
.get(runId)
|
||||
)
|
||||
}
|
||||
|
||||
getLatestUnreadDirectMessageSequenceForRun(
|
||||
runId: string,
|
||||
directHandle: string
|
||||
): number | undefined {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT sequence FROM messages
|
||||
WHERE run_id = ? AND to_handle = ? AND read = 0
|
||||
AND delivery_contract = 'current_delivery'
|
||||
ORDER BY sequence DESC LIMIT 1`
|
||||
)
|
||||
.get(runId, directHandle) as { sequence: number } | undefined
|
||||
return row?.sequence
|
||||
}
|
||||
|
||||
// Why: change mailbox ownership without changing unread or acknowledgment state.
|
||||
private routeDirectMessagePage(
|
||||
mailboxHandle: string,
|
||||
runId: string,
|
||||
directHandle: string,
|
||||
throughSequence?: number,
|
||||
preserveActiveDispatchOwnership = false
|
||||
): MailboxRoutingPage {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const throughClause = throughSequence === undefined ? '' : ' AND sequence <= ?'
|
||||
const dispatchOwnershipClause = preserveActiveDispatchOwnership
|
||||
? ` AND NOT EXISTS (
|
||||
SELECT 1 FROM dispatch_contexts
|
||||
WHERE dispatch_contexts.run_id = messages.run_id
|
||||
AND dispatch_contexts.assignee_handle = messages.to_handle
|
||||
AND dispatch_contexts.status IN ('pending', 'dispatched')
|
||||
)`
|
||||
: ''
|
||||
const params: (string | number)[] = [runId, directHandle]
|
||||
if (throughSequence !== undefined) {
|
||||
params.push(throughSequence)
|
||||
}
|
||||
params.push(ORCHESTRATION_DELIVERY_BATCH_LIMIT + 1)
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT id, type FROM messages
|
||||
WHERE run_id = ? AND to_handle = ? AND read = 0
|
||||
AND delivery_contract = 'current_delivery'${throughClause}${dispatchOwnershipClause}
|
||||
ORDER BY sequence LIMIT ?`
|
||||
)
|
||||
.all(...params) as {
|
||||
id: string
|
||||
type: MessageType
|
||||
}[]
|
||||
const page = rows.slice(0, ORCHESTRATION_DELIVERY_BATCH_LIMIT)
|
||||
if (page.length === 0) {
|
||||
this.db.exec('COMMIT')
|
||||
return { routedCount: 0, hasMore: false, types: [] }
|
||||
}
|
||||
const placeholders = page.map(() => '?').join(',')
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE messages INDEXED BY idx_messages_id SET to_handle = ?
|
||||
WHERE run_id = ? AND to_handle = ? AND read = 0
|
||||
AND delivery_contract = 'current_delivery' AND id IN (${placeholders})`
|
||||
)
|
||||
.run(mailboxHandle, runId, directHandle, ...page.map((row) => row.id))
|
||||
this.db.exec('COMMIT')
|
||||
return {
|
||||
routedCount: Number(result.changes),
|
||||
hasMore: rows.length > ORCHESTRATION_DELIVERY_BATCH_LIMIT,
|
||||
types: [...new Set(page.map((row) => row.type))]
|
||||
}
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private routeAllUnreadDirectMessagesToRunMailbox(runId: string, directHandle: string): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE messages SET to_handle = ?
|
||||
WHERE run_id = ? AND to_handle = ? AND read = 0
|
||||
AND delivery_contract = 'current_delivery'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM dispatch_contexts
|
||||
WHERE dispatch_contexts.run_id = messages.run_id
|
||||
AND dispatch_contexts.assignee_handle = messages.to_handle
|
||||
AND dispatch_contexts.status IN ('pending', 'dispatched')
|
||||
)`
|
||||
)
|
||||
.run(`run:${runId}`, runId, directHandle)
|
||||
}
|
||||
|
||||
routeUnreadDirectMessagesToRunMailbox(
|
||||
runId: string,
|
||||
directHandle: string,
|
||||
throughSequence?: number
|
||||
): MailboxRoutingPage {
|
||||
return this.routeDirectMessagePage(`run:${runId}`, runId, directHandle, throughSequence, true)
|
||||
}
|
||||
|
||||
routeUnreadDirectMessagesToDispatchMailbox(
|
||||
dispatchId: string,
|
||||
runId: string,
|
||||
directHandle: string,
|
||||
throughSequence?: number
|
||||
): MailboxRoutingPage {
|
||||
return this.routeDirectMessagePage(
|
||||
`dispatch:${dispatchId}`,
|
||||
runId,
|
||||
directHandle,
|
||||
throughSequence
|
||||
)
|
||||
}
|
||||
|
||||
routeUnreadDispatchMailboxToRunMailbox(
|
||||
dispatchId: string,
|
||||
runId: string,
|
||||
throughSequence?: number
|
||||
): MailboxRoutingPage {
|
||||
const dispatchMailbox = `dispatch:${dispatchId}`
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const throughClause = throughSequence === undefined ? '' : ' AND sequence <= ?'
|
||||
const params: (string | number)[] = [runId, dispatchMailbox]
|
||||
if (throughSequence !== undefined) {
|
||||
params.push(throughSequence)
|
||||
}
|
||||
params.push(ORCHESTRATION_DELIVERY_BATCH_LIMIT + 1)
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT id, type FROM messages INDEXED BY idx_messages_unread_current_inbox
|
||||
WHERE run_id = ? AND to_handle = ? AND read = 0
|
||||
AND delivery_contract = 'current_delivery'${throughClause}
|
||||
ORDER BY sequence LIMIT ?`
|
||||
)
|
||||
.all(...params) as {
|
||||
id: string
|
||||
type: MessageType
|
||||
}[]
|
||||
const page = rows.slice(0, ORCHESTRATION_DELIVERY_BATCH_LIMIT)
|
||||
if (page.length === 0) {
|
||||
this.db.exec('COMMIT')
|
||||
return { routedCount: 0, hasMore: false, types: [] }
|
||||
}
|
||||
const placeholders = page.map(() => '?').join(',')
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE messages INDEXED BY idx_messages_id SET to_handle = ?
|
||||
WHERE run_id = ? AND to_handle = ? AND read = 0
|
||||
AND delivery_contract = 'current_delivery'
|
||||
AND id IN (${placeholders})`
|
||||
)
|
||||
.run(`run:${runId}`, runId, dispatchMailbox, ...page.map((row) => row.id))
|
||||
this.db.exec('COMMIT')
|
||||
return {
|
||||
routedCount: Number(result.changes),
|
||||
hasMore: rows.length > ORCHESTRATION_DELIVERY_BATCH_LIMIT,
|
||||
types: [...new Set(page.map((row) => row.type))]
|
||||
}
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
getLatestUnreadMessageSequence(mailboxHandle: string): number | undefined {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT sequence FROM messages INDEXED BY idx_messages_unread_current_inbox
|
||||
WHERE to_handle = ? AND read = 0 AND delivery_contract = 'current_delivery'
|
||||
ORDER BY sequence DESC LIMIT 1`
|
||||
)
|
||||
.get(mailboxHandle) as { sequence: number } | undefined
|
||||
return row?.sequence
|
||||
}
|
||||
|
||||
private findActiveDispatchForDirectMessageOwner(
|
||||
runId: string,
|
||||
directHandle: string,
|
||||
paneKey?: string
|
||||
): DispatchContextRow | undefined {
|
||||
const exact = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM dispatch_contexts
|
||||
WHERE run_id = ? AND assignee_handle = ? AND status IN ('pending', 'dispatched')
|
||||
ORDER BY rowid DESC LIMIT 1`
|
||||
)
|
||||
.get(runId, directHandle) as DispatchContextRow | undefined
|
||||
if (exact || !paneKey || !parsePaneKey(paneKey)) {
|
||||
return exact
|
||||
}
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM dispatch_contexts
|
||||
WHERE run_id = ? AND assignee_pane_key IS NOT NULL
|
||||
AND status IN ('pending', 'dispatched') AND instr(assignee_pane_key, ':') > 1
|
||||
AND ${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL} = ?
|
||||
ORDER BY rowid DESC LIMIT 1`
|
||||
)
|
||||
.get(runId, paneKeyMatchSuffix(paneKey)) as DispatchContextRow | undefined
|
||||
}
|
||||
|
||||
routeForeignDirectMessagesToOwnedMailboxes(
|
||||
directHandle: string,
|
||||
currentRunId?: string,
|
||||
paneKey?: string
|
||||
): ForeignDirectMailboxRoutingPage {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const runExclusion = currentRunId === undefined ? '' : ' AND candidate.run_id <> ?'
|
||||
const paneSuffix = paneKey && parsePaneKey(paneKey) ? paneKeyMatchSuffix(paneKey) : undefined
|
||||
const exclusionParams = currentRunId === undefined ? [] : [currentRunId]
|
||||
const branches = [
|
||||
`SELECT candidate.id, candidate.run_id, candidate.type, candidate.sequence
|
||||
FROM run_coordinator_handles AS coordinator
|
||||
JOIN runs AS owner_run ON owner_run.id = coordinator.run_id AND owner_run.legacy = 0
|
||||
JOIN messages AS candidate INDEXED BY idx_messages_undelivered_direct_run
|
||||
ON candidate.run_id = coordinator.run_id
|
||||
AND candidate.to_handle = coordinator.terminal_handle
|
||||
WHERE coordinator.terminal_handle = ?${runExclusion}
|
||||
AND candidate.read = 0 AND candidate.delivered_at IS NULL
|
||||
AND candidate.delivery_contract = 'current_delivery'`,
|
||||
`SELECT candidate.id, candidate.run_id, candidate.type, candidate.sequence
|
||||
FROM (
|
||||
SELECT direct_dispatch.run_id
|
||||
FROM dispatch_contexts AS direct_dispatch
|
||||
INDEXED BY idx_dispatch_active_assignee_handle
|
||||
JOIN runs AS owner_run
|
||||
ON owner_run.id = direct_dispatch.run_id AND owner_run.legacy = 0
|
||||
WHERE direct_dispatch.assignee_handle = ?
|
||||
AND direct_dispatch.status IN ('pending', 'dispatched')
|
||||
GROUP BY direct_dispatch.run_id
|
||||
) AS dispatch_owner
|
||||
JOIN messages AS candidate INDEXED BY idx_messages_undelivered_direct_run
|
||||
ON candidate.run_id = dispatch_owner.run_id AND candidate.to_handle = ?
|
||||
WHERE 1 = 1${runExclusion}
|
||||
AND candidate.read = 0 AND candidate.delivered_at IS NULL
|
||||
AND candidate.delivery_contract = 'current_delivery'`
|
||||
]
|
||||
const branchParams: (string | number)[][] = [
|
||||
[directHandle, ...exclusionParams, ORCHESTRATION_DELIVERY_BATCH_LIMIT + 1],
|
||||
[directHandle, directHandle, ...exclusionParams, ORCHESTRATION_DELIVERY_BATCH_LIMIT + 1]
|
||||
]
|
||||
if (paneSuffix !== undefined) {
|
||||
branches.push(
|
||||
`SELECT candidate.id, candidate.run_id, candidate.type, candidate.sequence
|
||||
FROM (
|
||||
SELECT pane_dispatch.run_id
|
||||
FROM dispatch_contexts AS pane_dispatch INDEXED BY idx_dispatch_assignee_pane_leaf
|
||||
JOIN runs AS owner_run
|
||||
ON owner_run.id = pane_dispatch.run_id AND owner_run.legacy = 0
|
||||
WHERE pane_dispatch.assignee_pane_key IS NOT NULL
|
||||
AND pane_dispatch.status IN ('pending', 'dispatched')
|
||||
AND instr(pane_dispatch.assignee_pane_key, ':') > 1
|
||||
AND ${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL} = ?
|
||||
GROUP BY pane_dispatch.run_id
|
||||
) AS pane_owner
|
||||
JOIN messages AS candidate INDEXED BY idx_messages_undelivered_direct_run
|
||||
ON candidate.run_id = pane_owner.run_id AND candidate.to_handle = ?
|
||||
WHERE 1 = 1${runExclusion}
|
||||
AND candidate.read = 0 AND candidate.delivered_at IS NULL
|
||||
AND candidate.delivery_contract = 'current_delivery'`
|
||||
)
|
||||
branchParams.push([
|
||||
paneSuffix,
|
||||
directHandle,
|
||||
...exclusionParams,
|
||||
ORCHESTRATION_DELIVERY_BATCH_LIMIT + 1
|
||||
])
|
||||
}
|
||||
const limitedBranches = branches.map(
|
||||
(branch) => `SELECT * FROM (${branch} ORDER BY candidate.sequence LIMIT ?)`
|
||||
)
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT id, run_id, type FROM (${limitedBranches.join(' UNION ')})
|
||||
ORDER BY sequence LIMIT ?`
|
||||
)
|
||||
.all(...branchParams.flat(), ORCHESTRATION_DELIVERY_BATCH_LIMIT + 1) as {
|
||||
id: string
|
||||
run_id: string
|
||||
type: MessageType
|
||||
}[]
|
||||
const page = rows.slice(0, ORCHESTRATION_DELIVERY_BATCH_LIMIT)
|
||||
if (page.length === 0) {
|
||||
this.db.exec('COMMIT')
|
||||
return { routedCount: 0, hasMore: false, types: [], mailboxes: [] }
|
||||
}
|
||||
const runIds = [...new Set(page.map((row) => row.run_id))]
|
||||
const dispatchByRun = new Map<string, DispatchContextRow>()
|
||||
for (const runId of runIds) {
|
||||
const dispatch = this.findActiveDispatchForDirectMessageOwner(runId, directHandle, paneKey)
|
||||
if (dispatch) {
|
||||
dispatchByRun.set(runId, dispatch)
|
||||
}
|
||||
}
|
||||
const idsByMailbox = new Map<string, string[]>()
|
||||
const byMailbox = new Map<string, Set<MessageType>>()
|
||||
for (const row of page) {
|
||||
const dispatch = dispatchByRun.get(row.run_id)
|
||||
const mailboxHandle = dispatch ? `dispatch:${dispatch.id}` : `run:${row.run_id}`
|
||||
const ids = idsByMailbox.get(mailboxHandle) ?? []
|
||||
ids.push(row.id)
|
||||
idsByMailbox.set(mailboxHandle, ids)
|
||||
const types = byMailbox.get(mailboxHandle) ?? new Set<MessageType>()
|
||||
types.add(row.type)
|
||||
byMailbox.set(mailboxHandle, types)
|
||||
}
|
||||
for (const [mailboxHandle, ids] of idsByMailbox) {
|
||||
const placeholders = ids.map(() => '?').join(',')
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE messages INDEXED BY idx_messages_id SET to_handle = ?
|
||||
WHERE to_handle = ? AND read = 0 AND delivered_at IS NULL
|
||||
AND delivery_contract = 'current_delivery' AND id IN (${placeholders})`
|
||||
)
|
||||
.run(mailboxHandle, directHandle, ...ids)
|
||||
}
|
||||
this.db.exec('COMMIT')
|
||||
return {
|
||||
routedCount: page.length,
|
||||
hasMore: rows.length > ORCHESTRATION_DELIVERY_BATCH_LIMIT,
|
||||
types: [...new Set(page.map((row) => row.type))],
|
||||
mailboxes: [...byMailbox].map(([mailboxHandle, types]) => ({
|
||||
mailboxHandle,
|
||||
types: [...types]
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
getUnreadDirectMessageTypes(directHandle: string): MessageType[] {
|
||||
return (
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT DISTINCT type FROM messages INDEXED BY idx_messages_unread_current_inbox_type
|
||||
WHERE to_handle = ? AND read = 0 AND delivery_contract = 'current_delivery'
|
||||
ORDER BY type`
|
||||
)
|
||||
.all(directHandle) as { type: MessageType }[]
|
||||
).map((row) => row.type)
|
||||
}
|
||||
|
||||
areUnreadMessages(toHandle: string, ids: string[]): boolean {
|
||||
let matched = 0
|
||||
for (let offset = 0; offset < ids.length; offset += MESSAGE_ID_UPDATE_BATCH_SIZE) {
|
||||
const batch = ids.slice(offset, offset + MESSAGE_ID_UPDATE_BATCH_SIZE)
|
||||
const placeholders = batch.map(() => '?').join(',')
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count FROM messages INDEXED BY idx_messages_id
|
||||
WHERE to_handle = ? AND read = 0 AND delivery_contract = 'current_delivery'
|
||||
AND id IN (${placeholders})`
|
||||
)
|
||||
.get(toHandle, ...batch) as { count: number }
|
||||
matched += row.count
|
||||
}
|
||||
return matched === ids.length
|
||||
}
|
||||
|
||||
getAllMessages(toHandle: string, limit = 20): MessageRow[] {
|
||||
return exposeMessageListTimestamps(
|
||||
this.db
|
||||
@@ -3557,13 +4182,26 @@ export class OrchestrationDb {
|
||||
|
||||
// Why: use datetime('now') so delivered_at matches the space-format UTC shape of the table's other timestamps for correct ordering (§3.2).
|
||||
markAsDelivered(ids: string[]): void {
|
||||
if (ids.length === 0) {
|
||||
return
|
||||
for (let offset = 0; offset < ids.length; offset += MESSAGE_ID_UPDATE_BATCH_SIZE) {
|
||||
const batch = ids.slice(offset, offset + MESSAGE_ID_UPDATE_BATCH_SIZE)
|
||||
const placeholders = batch.map(() => '?').join(',')
|
||||
this.db
|
||||
.prepare(`UPDATE messages SET delivered_at = datetime('now') WHERE id IN (${placeholders})`)
|
||||
.run(...batch)
|
||||
}
|
||||
}
|
||||
|
||||
markAsUndelivered(ids: string[]): void {
|
||||
for (let offset = 0; offset < ids.length; offset += MESSAGE_ID_UPDATE_BATCH_SIZE) {
|
||||
const batch = ids.slice(offset, offset + MESSAGE_ID_UPDATE_BATCH_SIZE)
|
||||
const placeholders = batch.map(() => '?').join(',')
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE messages SET delivered_at = NULL
|
||||
WHERE read = 0 AND id IN (${placeholders})`
|
||||
)
|
||||
.run(...batch)
|
||||
}
|
||||
const placeholders = ids.map(() => '?').join(',')
|
||||
this.db
|
||||
.prepare(`UPDATE messages SET delivered_at = datetime('now') WHERE id IN (${placeholders})`)
|
||||
.run(...ids)
|
||||
}
|
||||
|
||||
markAsReadAndDelivered(ids: string[]): void {
|
||||
@@ -4982,14 +5620,24 @@ export class OrchestrationDb {
|
||||
}
|
||||
|
||||
findActiveRemoteAttachmentForPane(paneKey: string): RemoteDispatchAttachmentRow | undefined {
|
||||
const rows = this.db
|
||||
if (!parsePaneKey(paneKey)) {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM remote_dispatch_attachments
|
||||
WHERE state IN ('starting', 'ready') AND pane_key = ?
|
||||
ORDER BY rowid DESC LIMIT 1`
|
||||
)
|
||||
.get(paneKey) as RemoteDispatchAttachmentRow | undefined
|
||||
}
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM remote_dispatch_attachments
|
||||
WHERE state IN ('starting', 'ready') AND pane_key IS NOT NULL
|
||||
ORDER BY rowid DESC`
|
||||
AND instr(pane_key, ':') > 1
|
||||
AND ${REMOTE_ATTACHMENT_PANE_KEY_MATCH_SUFFIX_SQL} = ?
|
||||
ORDER BY rowid DESC LIMIT 1`
|
||||
)
|
||||
.all() as RemoteDispatchAttachmentRow[]
|
||||
return rows.find((row) => row.pane_key && isEquivalentPaneKey(row.pane_key, paneKey))
|
||||
.get(paneKeyMatchSuffix(paneKey)) as RemoteDispatchAttachmentRow | undefined
|
||||
}
|
||||
|
||||
enqueueFederationRelay(params: {
|
||||
@@ -6671,21 +7319,28 @@ export class OrchestrationDb {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const actives = this.db
|
||||
const exactPane = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM dispatch_contexts
|
||||
WHERE assignee_pane_key = ? AND status IN ('pending', 'dispatched')
|
||||
ORDER BY rowid DESC LIMIT 1`
|
||||
)
|
||||
.get(assigneePaneKey) as DispatchContextRow | undefined
|
||||
if (exactPane) {
|
||||
return exactPane
|
||||
}
|
||||
if (!parsePaneKey(assigneePaneKey)) {
|
||||
return undefined
|
||||
}
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM dispatch_contexts
|
||||
WHERE assignee_pane_key IS NOT NULL
|
||||
AND status IN ('pending', 'dispatched')
|
||||
AND ${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL} = ?`
|
||||
AND status IN ('pending', 'dispatched') AND instr(assignee_pane_key, ':') > 1
|
||||
AND ${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL} = ?
|
||||
ORDER BY rowid DESC LIMIT 1`
|
||||
)
|
||||
.all(paneKeyMatchSuffix(assigneePaneKey)) as DispatchContextRow[]
|
||||
|
||||
for (const row of actives) {
|
||||
if (row.assignee_pane_key && isEquivalentPaneKey(row.assignee_pane_key, assigneePaneKey)) {
|
||||
return row
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
.get(paneKeyMatchSuffix(assigneePaneKey)) as DispatchContextRow | undefined
|
||||
}
|
||||
|
||||
getLatestDispatchForTerminal(handle: string): DispatchContextRow | undefined {
|
||||
@@ -7062,6 +7717,7 @@ export class OrchestrationDb {
|
||||
DELETE FROM dispatch_contexts;
|
||||
DELETE FROM tasks;
|
||||
DELETE FROM messages;
|
||||
DELETE FROM run_coordinator_handles;
|
||||
DELETE FROM runs;
|
||||
INSERT INTO runs (id, objective, home_database, consumer_generation, legacy)
|
||||
VALUES ('${LEGACY_RUN_ID}', 'Legacy orchestration state (inspect only)', 'this_database', 0, 1);
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('federation acknowledgment migration', () => {
|
||||
const oldDb = new Database(dbPath)
|
||||
oldDb.exec('ALTER TABLE federated_dispatches DROP COLUMN to_home_acknowledged_sequence')
|
||||
oldDb.pragma('user_version = 26')
|
||||
expect(resolveOrchestrationMigrationStartVersion(oldDb, 26, 27)).toBe(26)
|
||||
expect(resolveOrchestrationMigrationStartVersion(oldDb, 26, 28)).toBe(26)
|
||||
oldDb
|
||||
.prepare(
|
||||
`INSERT INTO federated_dispatches (
|
||||
@@ -41,7 +41,7 @@ describe('federation acknowledgment migration', () => {
|
||||
db = new OrchestrationDb(dbPath)
|
||||
const sqlite = (db as unknown as { db: Database.Database }).db
|
||||
|
||||
expect(sqlite.pragma('user_version', { simple: true })).toBe(27)
|
||||
expect(sqlite.pragma('user_version', { simple: true })).toBe(28)
|
||||
expect(db.getFederatedDispatch('ctx_migrated')).toMatchObject({
|
||||
to_home_imported_sequence: 2,
|
||||
to_home_acknowledged_sequence: 0
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from '../orca-runtime'
|
||||
import { OrchestrationDb } from './db'
|
||||
import {
|
||||
acquireFederationAckLease,
|
||||
clearFederationAckCheckpoints,
|
||||
@@ -118,6 +119,96 @@ describe('federation relay parsing', () => {
|
||||
})
|
||||
|
||||
describe('federation relay acknowledgments', () => {
|
||||
it('does not wake a waiter for an acknowledged duplicate replay', async () => {
|
||||
const db = new OrchestrationDb(':memory:')
|
||||
const run = db.createRun({
|
||||
objective: 'Federation replay wake',
|
||||
coordinatorHandle: 'term_coordinator',
|
||||
coordinatorPaneKey: 'tab_coordinator:11111111-1111-4111-8111-111111111111'
|
||||
})
|
||||
const task = db.createTask({ spec: 'Remote work', runId: run.id })
|
||||
const { dispatch } = db.createStartingWorkerDispatch({
|
||||
taskId: task.id,
|
||||
startOptions: {},
|
||||
federation: {
|
||||
environmentId: 'environment_windows',
|
||||
environmentName: 'windows',
|
||||
peerFingerprint: 'windows_peer_fingerprint',
|
||||
protocolVersion: 3
|
||||
}
|
||||
})
|
||||
db.recordWorkerStage({ dispatchId: dispatch.id, stage: 'ready', state: 'ready' })
|
||||
const relayItem = (sequence: number) => ({
|
||||
dispatch_id: dispatch.id,
|
||||
direction: 'to_home' as const,
|
||||
sequence,
|
||||
message_id: `msg_federated_${sequence}`,
|
||||
kind: 'status',
|
||||
payload: JSON.stringify({
|
||||
subject: `Remote status ${sequence}`,
|
||||
body: `Update ${sequence}`,
|
||||
type: 'status'
|
||||
})
|
||||
})
|
||||
let pulled = [relayItem(1)]
|
||||
let rejectAck = true
|
||||
const runtime = new OrcaRuntimeService()
|
||||
runtime.setOrchestrationDb(db)
|
||||
vi.spyOn(runtime, 'resolveOrchestrationWorkerServer').mockReturnValue({
|
||||
peerFingerprint: 'windows_peer_fingerprint'
|
||||
} as never)
|
||||
vi.spyOn(runtime, 'callOrchestrationWorkerServer').mockImplementation(
|
||||
async (_environmentId, method) => {
|
||||
if (method === 'orchestration.federationPull') {
|
||||
return { runtimeEpoch: 'remote_epoch_1', items: pulled }
|
||||
}
|
||||
if (method === 'orchestration.federationAck') {
|
||||
if (rejectAck) {
|
||||
rejectAck = false
|
||||
throw new Error('ack response lost before remote mutation')
|
||||
}
|
||||
return { acknowledgedThrough: pulled.at(-1)?.sequence ?? 0 }
|
||||
}
|
||||
throw new Error(`Unexpected method ${method}`)
|
||||
}
|
||||
)
|
||||
|
||||
await expect(syncFederatedDispatch(runtime, dispatch.id)).rejects.toThrow(
|
||||
'ack response lost before remote mutation'
|
||||
)
|
||||
const first = db.getOrCreateRunDelivery({
|
||||
runId: run.id,
|
||||
consumerGeneration: run.consumer_generation
|
||||
})
|
||||
expect(first?.messages.map((message) => message.id)).toEqual(['msg_federated_1'])
|
||||
db.acknowledgeRunDelivery({
|
||||
runId: run.id,
|
||||
consumerGeneration: run.consumer_generation,
|
||||
deliveryId: first!.delivery.id
|
||||
})
|
||||
const waiting = runtime.waitForMessage(`run:${run.id}`, {
|
||||
typeFilter: ['status'],
|
||||
timeoutMs: 5_000
|
||||
})
|
||||
let settled = false
|
||||
void waiting.then(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
await syncFederatedDispatch(runtime, dispatch.id)
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
expect(settled).toBe(false)
|
||||
expect(db.getMessageById('msg_federated_1')?.read).toBe(1)
|
||||
|
||||
pulled = [relayItem(2)]
|
||||
await syncFederatedDispatch(runtime, dispatch.id)
|
||||
await expect(waiting).resolves.toBe('notified')
|
||||
expect(db.getUnreadMessages(`run:${run.id}`).map((message) => message.id)).toEqual([
|
||||
'msg_federated_2'
|
||||
])
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('drains a terminal retry from the page after the first terminal report', async () => {
|
||||
const pending = Array.from({ length: 51 }, (_, index) => {
|
||||
const sequence = index + 1
|
||||
|
||||
@@ -129,7 +129,9 @@ async function syncFederatedDispatchPages(
|
||||
})
|
||||
}
|
||||
cursor = item.sequence
|
||||
runtime.notifyMessageArrived(stored.message.to_handle, stored.message.type)
|
||||
if (stored.message.read === 0) {
|
||||
runtime.notifyMessageArrived(stored.message.to_handle, stored.message.type)
|
||||
}
|
||||
imported += stored.duplicate ? 0 : 1
|
||||
}
|
||||
|
||||
|
||||
@@ -186,8 +186,8 @@ describe('formatMessagesForInjection', () => {
|
||||
|
||||
describe('formatMessagePointer', () => {
|
||||
it('formats a singular pointer without message content', () => {
|
||||
expect(formatMessagePointer(1)).toBe(
|
||||
'\nYou have 1 orchestration message. Run `orca orchestration check`.\n'
|
||||
expect(formatMessagePointer(1, 'run:run_1')).toBe(
|
||||
'\nYou have 1 orchestration message. Run `orca orchestration check --run run_1`.\n'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -108,7 +108,10 @@ export function formatMessagesForInjection(messages: MessageRow[]): string {
|
||||
return `\n--- Orchestration Messages (${messages.length}) ---\n${banners}\n---\n`
|
||||
}
|
||||
|
||||
export function formatMessagePointer(count: number): string {
|
||||
export function formatMessagePointer(count: number, mailboxHandle?: string): string {
|
||||
const noun = count === 1 ? 'message' : 'messages'
|
||||
return `\nYou have ${count} orchestration ${noun}. Run \`orca orchestration check\`.\n`
|
||||
const runFlag = mailboxHandle?.startsWith('run:')
|
||||
? ` --run ${mailboxHandle.slice('run:'.length)}`
|
||||
: ''
|
||||
return `\nYou have ${count} orchestration ${noun}. Run \`orca orchestration check${runFlag}\`.\n`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { OrchestrationDb } from './db'
|
||||
import type { OrchestrationMailboxLeaf } from './mailbox-owner'
|
||||
|
||||
type OrchestrationMailboxDeliveryTargetDependencies = {
|
||||
getDb: () => OrchestrationDb | null
|
||||
getTerminalHandleForPaneKey: (paneKey: string) => string | null
|
||||
hasTerminalHandle: (handle: string) => boolean
|
||||
canProbePtyLiveness: () => boolean
|
||||
controllerKnowsPtyIsLive: (ptyId: string) => boolean
|
||||
isLeafPtyProvenAbsent: (ptyId: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export class OrchestrationMailboxDeliveryTarget {
|
||||
private readonly probeDeferredPtyIds = new Set<string>()
|
||||
|
||||
constructor(private readonly deps: OrchestrationMailboxDeliveryTargetDependencies) {}
|
||||
|
||||
resolveTerminalHandle(handle: string): string | null {
|
||||
if (this.deps.hasTerminalHandle(handle)) {
|
||||
return handle
|
||||
}
|
||||
const db = this.deps.getDb()
|
||||
const runId = handle.startsWith('run:') ? handle.slice('run:'.length) : ''
|
||||
const dispatchId = handle.startsWith('dispatch:') ? handle.slice('dispatch:'.length) : ''
|
||||
const dispatch = dispatchId ? db?.getDispatchContextById?.(dispatchId) : undefined
|
||||
const remote =
|
||||
dispatchId && !dispatch ? db?.getRemoteDispatchAttachment?.(dispatchId) : undefined
|
||||
const paneKey = dispatch?.assignee_pane_key ?? remote?.pane_key
|
||||
const ownerHandle = runId
|
||||
? db?.getRun(runId)?.coordinator_handle
|
||||
: ((paneKey ? this.deps.getTerminalHandleForPaneKey(paneKey) : null) ??
|
||||
dispatch?.assignee_handle ??
|
||||
remote?.terminal_handle)
|
||||
return ownerHandle && this.deps.hasTerminalHandle(ownerHandle) ? ownerHandle : null
|
||||
}
|
||||
|
||||
deferForAbsenceProbe(
|
||||
leaf: OrchestrationMailboxLeaf,
|
||||
mailboxHandle: string,
|
||||
skipAbsenceProbe: boolean | undefined,
|
||||
redeliver: (leaf: OrchestrationMailboxLeaf, ptyId: string, mailboxHandle: string) => void
|
||||
): boolean {
|
||||
const ptyId = leaf.ptyId
|
||||
if (
|
||||
!ptyId ||
|
||||
skipAbsenceProbe ||
|
||||
!this.deps.canProbePtyLiveness() ||
|
||||
this.deps.controllerKnowsPtyIsLive(ptyId)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (this.probeDeferredPtyIds.has(ptyId)) {
|
||||
return true
|
||||
}
|
||||
this.probeDeferredPtyIds.add(ptyId)
|
||||
void this.deps
|
||||
.isLeafPtyProvenAbsent(ptyId)
|
||||
.then((absent) => {
|
||||
this.probeDeferredPtyIds.delete(ptyId)
|
||||
if (!absent && leaf.ptyId === ptyId) {
|
||||
setTimeout(() => redeliver(leaf, ptyId, mailboxHandle), 0)
|
||||
}
|
||||
})
|
||||
.catch(() => this.probeDeferredPtyIds.delete(ptyId))
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { OrchestrationDb } from './db'
|
||||
import type {
|
||||
OrchestrationMailboxLeaf,
|
||||
OrchestrationMailboxOwner,
|
||||
RoutedOrchestrationMailbox
|
||||
} from './mailbox-owner'
|
||||
import type {
|
||||
OrchestrationMailboxPointerDelivery,
|
||||
OrchestrationMessageWaiter
|
||||
} from './mailbox-pointer-delivery'
|
||||
|
||||
type NotificationCoordinatorDependencies<TWaiter extends OrchestrationMessageWaiter> = {
|
||||
mailboxOwner: OrchestrationMailboxOwner
|
||||
pointerDelivery: OrchestrationMailboxPointerDelivery<TWaiter>
|
||||
getDb: () => OrchestrationDb | null
|
||||
getLiveLeafForHandle: (handle: string) => OrchestrationMailboxLeaf
|
||||
getPaneKeyForHandle: (handle: string) => string | undefined
|
||||
getMessageWaiters: (mailboxHandle: string) => ReadonlySet<TWaiter> | undefined
|
||||
hasTerminalHandle: (handle: string) => boolean
|
||||
deliverForHandle: (handle: string, reservedTypes?: ReadonlySet<string>) => void
|
||||
notifyMessageArrived: (handle: string, messageType: string) => void
|
||||
resolveMessageWaiter: (waiter: TWaiter) => void
|
||||
}
|
||||
|
||||
export class OrchestrationMailboxNotificationCoordinator<
|
||||
TWaiter extends OrchestrationMessageWaiter
|
||||
> {
|
||||
constructor(private readonly deps: NotificationCoordinatorDependencies<TWaiter>) {}
|
||||
|
||||
deliverForHandle(handle: string, reservedTypes?: ReadonlySet<string>): void {
|
||||
this.deps.pointerDelivery.deliverForHandle(handle, reservedTypes)
|
||||
}
|
||||
|
||||
deliverForLeaf(leaf: OrchestrationMailboxLeaf): void {
|
||||
this.notifyForwarded(this.deps.mailboxOwner.routeForeignDirectMessages(leaf))
|
||||
const mailboxHandle = this.deps.mailboxOwner.resolve(leaf)
|
||||
if (mailboxHandle) {
|
||||
this.deps.pointerDelivery.deliver(leaf, { mailboxHandle })
|
||||
}
|
||||
}
|
||||
|
||||
notifyMessageArrived(handle: string, messageType?: string): void {
|
||||
const { mailboxHandle, forwarded, directTypes } = this.resolveArrivedMailboxes(handle)
|
||||
this.notifyForwarded(forwarded)
|
||||
if (!mailboxHandle) {
|
||||
return
|
||||
}
|
||||
const waiters = this.deps.getMessageWaiters(mailboxHandle)
|
||||
const arrivedTypes = directTypes ?? (messageType ? [messageType] : undefined)
|
||||
const consumers = waiters
|
||||
? [...waiters].filter(
|
||||
(waiter) =>
|
||||
!arrivedTypes ||
|
||||
!waiter.typeFilter ||
|
||||
arrivedTypes.some((type) => waiter.typeFilter?.includes(type))
|
||||
)
|
||||
: []
|
||||
if (consumers.length === 0) {
|
||||
const reservedTypes = new Set(
|
||||
waiters ? [...waiters].flatMap((waiter) => waiter.typeFilter ?? []) : []
|
||||
)
|
||||
// Let a check awakened in this drain mark its rows read before the push re-reads them.
|
||||
queueMicrotask(() => this.deps.deliverForHandle(mailboxHandle, reservedTypes))
|
||||
return
|
||||
}
|
||||
for (const waiter of consumers) {
|
||||
this.deps.resolveMessageWaiter(waiter)
|
||||
}
|
||||
}
|
||||
|
||||
wakeRoutedMessageWaiters(mailboxHandle: string, types: readonly string[]): void {
|
||||
const waiters = [...(this.deps.getMessageWaiters(mailboxHandle) ?? [])]
|
||||
if (waiters.length === 0 || types.length === 0) {
|
||||
return
|
||||
}
|
||||
// Snapshot ownership before the routed rows wake and remove their waiters.
|
||||
queueMicrotask(() => {
|
||||
const liveWaiters = this.deps.getMessageWaiters(mailboxHandle)
|
||||
for (const waiter of waiters) {
|
||||
if (
|
||||
liveWaiters?.has(waiter) &&
|
||||
(!waiter.typeFilter || types.some((type) => waiter.typeFilter?.includes(type)))
|
||||
) {
|
||||
this.deps.resolveMessageWaiter(waiter)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
retirePty(ptyId: string): void {
|
||||
this.deps.pointerDelivery.retirePty(ptyId)
|
||||
}
|
||||
|
||||
private resolveArrivedMailboxes(handle: string): {
|
||||
mailboxHandle: string | null
|
||||
forwarded: RoutedOrchestrationMailbox[]
|
||||
directTypes?: string[]
|
||||
} {
|
||||
if (handle.startsWith('run:') || handle.startsWith('dispatch:')) {
|
||||
return { mailboxHandle: handle, forwarded: [] }
|
||||
}
|
||||
if (!this.deps.getDb()) {
|
||||
return { mailboxHandle: handle, forwarded: [] }
|
||||
}
|
||||
if (!this.deps.hasTerminalHandle(handle)) {
|
||||
return this.resolveDetachedMailbox(handle)
|
||||
}
|
||||
try {
|
||||
const leaf = this.deps.getLiveLeafForHandle(handle)
|
||||
return {
|
||||
mailboxHandle: this.deps.mailboxOwner.resolve(leaf, handle, {
|
||||
requireRequestedMail: true
|
||||
}),
|
||||
forwarded: this.deps.mailboxOwner.routeForeignDirectMessages(leaf)
|
||||
}
|
||||
} catch {
|
||||
return this.resolveDetachedMailbox(handle)
|
||||
}
|
||||
}
|
||||
|
||||
private resolveDetachedMailbox(handle: string): {
|
||||
mailboxHandle: string | null
|
||||
forwarded: RoutedOrchestrationMailbox[]
|
||||
directTypes?: string[]
|
||||
} {
|
||||
const routed = this.deps.mailboxOwner.routeDetachedDirectMessages(
|
||||
handle,
|
||||
this.deps.getPaneKeyForHandle(handle)
|
||||
)
|
||||
const directTypes = routed.hasMore
|
||||
? []
|
||||
: (this.deps.getDb()?.getUnreadDirectMessageTypes(handle) ?? [])
|
||||
return {
|
||||
mailboxHandle: directTypes.length > 0 ? handle : null,
|
||||
forwarded: routed.mailboxes,
|
||||
directTypes
|
||||
}
|
||||
}
|
||||
|
||||
private notifyForwarded(forwarded: readonly RoutedOrchestrationMailbox[]): void {
|
||||
for (const routed of forwarded) {
|
||||
for (const messageType of routed.types) {
|
||||
this.deps.notifyMessageArrived(routed.mailboxHandle, messageType)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import type { AgentStatus } from '../../../shared/agent-detection'
|
||||
import type { OrchestrationDb } from './db'
|
||||
|
||||
export type OrchestrationMailboxLeaf = {
|
||||
tabId: string
|
||||
leafId: string
|
||||
ptyId: string | null
|
||||
writable: boolean
|
||||
lastAgentStatus: AgentStatus | null
|
||||
lastAgentStatusObservedLive: boolean
|
||||
lastOscTitle: string | null
|
||||
paneTitle?: string | null
|
||||
}
|
||||
|
||||
export type RoutedOrchestrationMailbox = {
|
||||
mailboxHandle: string
|
||||
types: string[]
|
||||
}
|
||||
|
||||
export type DetachedMailboxRoutingPage = {
|
||||
mailboxes: RoutedOrchestrationMailbox[]
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
type OrchestrationMailboxOwnerDependencies = {
|
||||
getDb: () => OrchestrationDb | null
|
||||
getLeaf: (leafKey: string) => OrchestrationMailboxLeaf | undefined
|
||||
getLeafKey: (tabId: string, leafId: string) => string
|
||||
getTerminalHandleForLeafKey: (leafKey: string) => string | undefined
|
||||
getTerminalProcessIncarnation: (terminalHandle: string) => string | null
|
||||
onRoutedMessageTypes: (mailboxHandle: string, types: readonly string[]) => void
|
||||
onForeignMailboxRouted: (mailboxHandle: string, messageType: string) => void
|
||||
}
|
||||
|
||||
export class OrchestrationMailboxOwner {
|
||||
private readonly pendingDirectReconciliations = new Set<string>()
|
||||
private readonly pendingDetachedDirectReconciliations = new Set<string>()
|
||||
|
||||
constructor(private readonly deps: OrchestrationMailboxOwnerDependencies) {}
|
||||
|
||||
resolve(
|
||||
leaf: OrchestrationMailboxLeaf,
|
||||
requestedMailbox?: string,
|
||||
options: { requireRequestedMail?: boolean; routeDirectMail?: boolean } = {}
|
||||
): string | null {
|
||||
const db = this.deps.getDb()
|
||||
if (!db) {
|
||||
return null
|
||||
}
|
||||
const leafKey = this.deps.getLeafKey(leaf.tabId, leaf.leafId)
|
||||
const terminalHandle = this.deps.getTerminalHandleForLeafKey(leafKey)
|
||||
if (!terminalHandle) {
|
||||
return null
|
||||
}
|
||||
const paneKey = `${leaf.tabId}:${leaf.leafId}`
|
||||
const run = db.getCurrentRunForPane?.(paneKey)
|
||||
if (run) {
|
||||
return this.resolveRunMailbox(db, leaf, terminalHandle, run.id, requestedMailbox, options)
|
||||
}
|
||||
|
||||
const dispatch = db.getActiveDispatchForIdentity?.(terminalHandle, paneKey)
|
||||
if (dispatch) {
|
||||
return this.resolveDispatchMailbox(
|
||||
db,
|
||||
leaf,
|
||||
terminalHandle,
|
||||
dispatch.id,
|
||||
dispatch.run_id,
|
||||
requestedMailbox,
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
const remoteAttachment = db.findActiveRemoteAttachmentForPane?.(paneKey)
|
||||
if (remoteAttachment) {
|
||||
const isCurrent = db.isRemoteAttachmentProcessCurrent?.({
|
||||
dispatchId: remoteAttachment.dispatch_id,
|
||||
paneKey,
|
||||
processIncarnation: this.deps.getTerminalProcessIncarnation(terminalHandle)
|
||||
})
|
||||
if (!isCurrent || (options.requireRequestedMail && requestedMailbox === terminalHandle)) {
|
||||
return null
|
||||
}
|
||||
const dispatchMailbox = `dispatch:${remoteAttachment.dispatch_id}`
|
||||
return !requestedMailbox || requestedMailbox === dispatchMailbox ? dispatchMailbox : null
|
||||
}
|
||||
|
||||
return !requestedMailbox || requestedMailbox === terminalHandle ? terminalHandle : null
|
||||
}
|
||||
|
||||
routeForeignDirectMessages(leaf: OrchestrationMailboxLeaf): RoutedOrchestrationMailbox[] {
|
||||
const db = this.deps.getDb()
|
||||
if (!db) {
|
||||
return []
|
||||
}
|
||||
const leafKey = this.deps.getLeafKey(leaf.tabId, leaf.leafId)
|
||||
const terminalHandle = this.deps.getTerminalHandleForLeafKey(leafKey)
|
||||
if (!terminalHandle) {
|
||||
return []
|
||||
}
|
||||
const paneKey = `${leaf.tabId}:${leaf.leafId}`
|
||||
const ownerMailbox = this.resolve(leaf, undefined, { routeDirectMail: false })
|
||||
const ownerRunId = ownerMailbox?.startsWith('run:')
|
||||
? ownerMailbox.slice('run:'.length)
|
||||
: ownerMailbox?.startsWith('dispatch:')
|
||||
? db.getDispatchContextById?.(ownerMailbox.slice('dispatch:'.length))?.run_id
|
||||
: undefined
|
||||
if (!ownerRunId) {
|
||||
return []
|
||||
}
|
||||
const routed = db.routeForeignDirectMessagesToOwnedMailboxes?.(
|
||||
terminalHandle,
|
||||
ownerRunId,
|
||||
paneKey
|
||||
)
|
||||
if (routed?.hasMore) {
|
||||
this.scheduleDirectReconciliation(leaf)
|
||||
}
|
||||
return routed?.mailboxes ?? []
|
||||
}
|
||||
|
||||
routeDetachedDirectMessages(directHandle: string, paneKey?: string): DetachedMailboxRoutingPage {
|
||||
const routed = this.deps
|
||||
.getDb()
|
||||
?.routeForeignDirectMessagesToOwnedMailboxes?.(directHandle, undefined, paneKey)
|
||||
if (routed?.hasMore) {
|
||||
this.scheduleDetachedDirectReconciliation(directHandle, paneKey)
|
||||
}
|
||||
return { mailboxes: routed?.mailboxes ?? [], hasMore: routed?.hasMore ?? false }
|
||||
}
|
||||
|
||||
private resolveRunMailbox(
|
||||
db: OrchestrationDb,
|
||||
leaf: OrchestrationMailboxLeaf,
|
||||
terminalHandle: string,
|
||||
runId: string,
|
||||
requestedMailbox: string | undefined,
|
||||
options: { requireRequestedMail?: boolean; routeDirectMail?: boolean }
|
||||
): string | null {
|
||||
const runMailbox = `run:${runId}`
|
||||
if (
|
||||
requestedMailbox &&
|
||||
requestedMailbox !== terminalHandle &&
|
||||
requestedMailbox !== runMailbox
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const hasDirectMail =
|
||||
options.routeDirectMail === false
|
||||
? false
|
||||
: (db.hasUndeliveredDirectMessageForRun?.(runId, terminalHandle) ?? false)
|
||||
if (options.requireRequestedMail && requestedMailbox === terminalHandle && !hasDirectMail) {
|
||||
return null
|
||||
}
|
||||
if (hasDirectMail) {
|
||||
const routed = db.routeUnreadDirectMessagesToRunMailbox?.(runId, terminalHandle)
|
||||
this.deps.onRoutedMessageTypes(runMailbox, routed?.types ?? [])
|
||||
if (routed?.hasMore) {
|
||||
this.scheduleDirectReconciliation(leaf)
|
||||
}
|
||||
}
|
||||
return runMailbox
|
||||
}
|
||||
|
||||
private resolveDispatchMailbox(
|
||||
db: OrchestrationDb,
|
||||
leaf: OrchestrationMailboxLeaf,
|
||||
terminalHandle: string,
|
||||
dispatchId: string,
|
||||
runId: string,
|
||||
requestedMailbox: string | undefined,
|
||||
options: { requireRequestedMail?: boolean; routeDirectMail?: boolean }
|
||||
): string | null {
|
||||
const dispatchMailbox = `dispatch:${dispatchId}`
|
||||
if (
|
||||
requestedMailbox &&
|
||||
requestedMailbox !== terminalHandle &&
|
||||
requestedMailbox !== dispatchMailbox
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const hasDirectMail =
|
||||
options.routeDirectMail === false
|
||||
? false
|
||||
: (db.hasUndeliveredDirectMessageForRun?.(runId, terminalHandle) ?? false)
|
||||
if (options.requireRequestedMail && requestedMailbox === terminalHandle && !hasDirectMail) {
|
||||
return null
|
||||
}
|
||||
if (hasDirectMail) {
|
||||
const routed = db.routeUnreadDirectMessagesToDispatchMailbox?.(
|
||||
dispatchId,
|
||||
runId,
|
||||
terminalHandle
|
||||
)
|
||||
this.deps.onRoutedMessageTypes(dispatchMailbox, routed?.types ?? [])
|
||||
if (routed?.hasMore) {
|
||||
this.scheduleDirectReconciliation(leaf)
|
||||
}
|
||||
}
|
||||
return dispatchMailbox
|
||||
}
|
||||
|
||||
private scheduleDirectReconciliation(leaf: OrchestrationMailboxLeaf): void {
|
||||
const leafKey = this.deps.getLeafKey(leaf.tabId, leaf.leafId)
|
||||
if (this.pendingDirectReconciliations.has(leafKey)) {
|
||||
return
|
||||
}
|
||||
this.pendingDirectReconciliations.add(leafKey)
|
||||
setImmediate(() => {
|
||||
this.pendingDirectReconciliations.delete(leafKey)
|
||||
const currentLeaf = this.deps.getLeaf(leafKey)
|
||||
if (!currentLeaf || currentLeaf.ptyId !== leaf.ptyId) {
|
||||
return
|
||||
}
|
||||
for (const routed of this.routeForeignDirectMessages(currentLeaf)) {
|
||||
for (const messageType of routed.types) {
|
||||
this.deps.onForeignMailboxRouted(routed.mailboxHandle, messageType)
|
||||
}
|
||||
}
|
||||
this.resolve(currentLeaf)
|
||||
})
|
||||
}
|
||||
|
||||
private scheduleDetachedDirectReconciliation(directHandle: string, paneKey?: string): void {
|
||||
if (this.pendingDetachedDirectReconciliations.has(directHandle)) {
|
||||
return
|
||||
}
|
||||
this.pendingDetachedDirectReconciliations.add(directHandle)
|
||||
setImmediate(() => {
|
||||
this.pendingDetachedDirectReconciliations.delete(directHandle)
|
||||
const routed = this.routeDetachedDirectMessages(directHandle, paneKey)
|
||||
for (const mailbox of routed.mailboxes) {
|
||||
for (const messageType of mailbox.types) {
|
||||
this.deps.onForeignMailboxRouted(mailbox.mailboxHandle, messageType)
|
||||
}
|
||||
}
|
||||
if (!routed.hasMore) {
|
||||
for (const messageType of this.deps.getDb()?.getUnreadDirectMessageTypes(directHandle) ??
|
||||
[]) {
|
||||
this.deps.onForeignMailboxRouted(directHandle, messageType)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { isCursorAgentTitle } from '../../../shared/agent-detection'
|
||||
import { ORCHESTRATION_DELIVERY_BATCH_LIMIT, type OrchestrationDb } from './db'
|
||||
import { formatMessagePointer } from './formatter'
|
||||
import type { OrchestrationMailboxDeliveryTarget } from './mailbox-delivery-target'
|
||||
import {
|
||||
hasUnfilteredOrchestrationWaiter,
|
||||
messageTypeHasOrchestrationWaiter,
|
||||
shouldReleaseOrchestrationPointer,
|
||||
type OrchestrationMessageWaiter
|
||||
} from './mailbox-pointer-eligibility'
|
||||
import type { OrchestrationMailboxLeaf, OrchestrationMailboxOwner } from './mailbox-owner'
|
||||
import {
|
||||
OrchestrationMailboxPointerState,
|
||||
type OrchestrationMailboxDeliveryFlight
|
||||
} from './mailbox-pointer-state'
|
||||
import { submitOrchestrationMailboxPointer } from './mailbox-pointer-submit'
|
||||
|
||||
export type { OrchestrationMessageWaiter } from './mailbox-pointer-eligibility'
|
||||
|
||||
type PointerDeliveryDependencies<TWaiter extends OrchestrationMessageWaiter> = {
|
||||
mailboxOwner: OrchestrationMailboxOwner
|
||||
deliveryTarget: OrchestrationMailboxDeliveryTarget
|
||||
getDb: () => OrchestrationDb | null
|
||||
getLeaf: (leafKey: string) => OrchestrationMailboxLeaf | undefined
|
||||
getLeafKey: (tabId: string, leafId: string) => string
|
||||
getLiveLeafForHandle: (handle: string) => OrchestrationMailboxLeaf
|
||||
getMessageWaiters: (mailboxHandle: string) => ReadonlySet<TWaiter> | undefined
|
||||
getTabTitle: (tabId: string) => string | null | undefined
|
||||
getTerminalHandleForLeafKey: (leafKey: string) => string | undefined
|
||||
isLeafPtyProvenAbsent: (ptyId: string) => Promise<boolean>
|
||||
redriveMailbox: (mailboxHandle: string, reservedTypes?: ReadonlySet<string>) => void
|
||||
writePty: (ptyId: string, data: string) => boolean | Promise<boolean>
|
||||
}
|
||||
|
||||
export class OrchestrationMailboxPointerDelivery<TWaiter extends OrchestrationMessageWaiter> {
|
||||
private readonly state = new OrchestrationMailboxPointerState()
|
||||
constructor(private readonly deps: PointerDeliveryDependencies<TWaiter>) {}
|
||||
|
||||
deliverForHandle(handle: string, reservedTypes?: ReadonlySet<string>): void {
|
||||
const terminalHandle = this.deps.deliveryTarget.resolveTerminalHandle(handle)
|
||||
if (!terminalHandle) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const leaf = this.deps.getLiveLeafForHandle(terminalHandle)
|
||||
if (leaf.lastAgentStatus !== 'idle' || !leaf.lastAgentStatusObservedLive) {
|
||||
return
|
||||
}
|
||||
const mailboxHandle = this.deps.mailboxOwner.resolve(leaf, handle)
|
||||
if (mailboxHandle) {
|
||||
this.deliver(leaf, { mailboxHandle, reservedTypes })
|
||||
}
|
||||
} catch {
|
||||
// Persisted mail remains available to explicit check or a later idle edge.
|
||||
}
|
||||
}
|
||||
|
||||
deliver(
|
||||
leaf: OrchestrationMailboxLeaf,
|
||||
options: {
|
||||
mailboxHandle: string
|
||||
reservedTypes?: ReadonlySet<string>
|
||||
skipAbsenceProbe?: boolean
|
||||
}
|
||||
): void {
|
||||
const db = this.deps.getDb()
|
||||
const mailboxHandle = options.mailboxHandle
|
||||
if (!db || !mailboxHandle.startsWith('run:')) {
|
||||
return
|
||||
}
|
||||
if (!this.deps.getTerminalHandleForLeafKey(this.leafKey(leaf))) {
|
||||
return
|
||||
}
|
||||
if (db.hasOutstandingRunDelivery?.(mailboxHandle.slice('run:'.length))) {
|
||||
return
|
||||
}
|
||||
if (leaf.ptyId && this.state.hasFlight(leaf.ptyId)) {
|
||||
this.state.parkDelivery(leaf.ptyId, mailboxHandle, leaf, options.reservedTypes)
|
||||
return
|
||||
}
|
||||
if (this.state.hasActiveWatermark(mailboxHandle)) {
|
||||
this.parkRedelivery(mailboxHandle, options.reservedTypes)
|
||||
return
|
||||
}
|
||||
|
||||
const waiters = this.deps.getMessageWaiters(mailboxHandle)
|
||||
if (hasUnfilteredOrchestrationWaiter(waiters)) {
|
||||
return
|
||||
}
|
||||
const excludedTypes = new Set(options.reservedTypes)
|
||||
for (const waiter of waiters ?? []) {
|
||||
for (const type of waiter.typeFilter ?? []) {
|
||||
excludedTypes.add(type)
|
||||
}
|
||||
}
|
||||
const unread = db
|
||||
.getUndeliveredUnreadMessages(mailboxHandle, undefined, {
|
||||
excludeTypes: [...excludedTypes],
|
||||
limit: ORCHESTRATION_DELIVERY_BATCH_LIMIT
|
||||
})
|
||||
.filter(
|
||||
(message) =>
|
||||
!options.reservedTypes?.has(message.type) &&
|
||||
!messageTypeHasOrchestrationWaiter(waiters, message.type)
|
||||
)
|
||||
.slice(0, ORCHESTRATION_DELIVERY_BATCH_LIMIT)
|
||||
if (unread.length === 0 || !leaf.writable || !leaf.ptyId) {
|
||||
return
|
||||
}
|
||||
const newestSequence = unread.at(-1)?.sequence
|
||||
if (newestSequence === undefined) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!this.state.releaseSupersededWatermark(
|
||||
mailboxHandle,
|
||||
newestSequence,
|
||||
leaf.ptyId,
|
||||
this.leafKey(leaf)
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
this.deps.deliveryTarget.deferForAbsenceProbe(
|
||||
leaf,
|
||||
mailboxHandle,
|
||||
options.skipAbsenceProbe,
|
||||
(probedLeaf, ptyId, probedMailbox) =>
|
||||
this.redeliverAfterProbe(probedLeaf, ptyId, probedMailbox)
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.stagePointer(leaf, mailboxHandle, unread, newestSequence)
|
||||
}
|
||||
|
||||
parkRedelivery(mailboxHandle: string, reservedTypes?: ReadonlySet<string>): void {
|
||||
this.state.parkRedelivery(mailboxHandle, reservedTypes)
|
||||
}
|
||||
|
||||
retirePty(ptyId: string): void {
|
||||
const { flight, releasedMailboxes } = this.state.retirePty(ptyId)
|
||||
if (flight?.enterTimer != null) {
|
||||
clearTimeout(flight.enterTimer)
|
||||
}
|
||||
if (flight?.stagedMessageIds.length) {
|
||||
this.deps.getDb()?.markAsUndelivered(flight.stagedMessageIds)
|
||||
}
|
||||
for (const mailboxHandle of releasedMailboxes) {
|
||||
this.redrive(mailboxHandle, true)
|
||||
}
|
||||
}
|
||||
|
||||
private redeliverAfterProbe(
|
||||
leaf: OrchestrationMailboxLeaf,
|
||||
ptyId: string,
|
||||
mailboxHandle: string
|
||||
): void {
|
||||
const currentLeaf = this.deps.getLeaf(this.leafKey(leaf))
|
||||
if (
|
||||
currentLeaf?.ptyId === ptyId &&
|
||||
currentLeaf.lastAgentStatus === 'idle' &&
|
||||
currentLeaf.lastAgentStatusObservedLive
|
||||
) {
|
||||
this.deliver(currentLeaf, { mailboxHandle, skipAbsenceProbe: true })
|
||||
}
|
||||
}
|
||||
|
||||
private stagePointer(
|
||||
leaf: OrchestrationMailboxLeaf,
|
||||
mailboxHandle: string,
|
||||
unread: readonly { id: string; type: string; sequence: number }[],
|
||||
newestSequence: number
|
||||
): void {
|
||||
const ptyId = leaf.ptyId
|
||||
if (!ptyId) {
|
||||
return
|
||||
}
|
||||
const flight = this.state.beginFlight(ptyId)
|
||||
const writeResult = this.deps.writePty(
|
||||
ptyId,
|
||||
formatMessagePointer(unread.length, mailboxHandle)
|
||||
)
|
||||
if (typeof writeResult === 'boolean') {
|
||||
this.finishPointerWrite(
|
||||
leaf,
|
||||
mailboxHandle,
|
||||
unread,
|
||||
newestSequence,
|
||||
ptyId,
|
||||
flight,
|
||||
writeResult
|
||||
)
|
||||
return
|
||||
}
|
||||
void writeResult
|
||||
.then(
|
||||
(accepted) =>
|
||||
this.finishPointerWrite(
|
||||
leaf,
|
||||
mailboxHandle,
|
||||
unread,
|
||||
newestSequence,
|
||||
ptyId,
|
||||
flight,
|
||||
accepted
|
||||
),
|
||||
() =>
|
||||
this.finishPointerWrite(leaf, mailboxHandle, unread, newestSequence, ptyId, flight, false)
|
||||
)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
private finishPointerWrite(
|
||||
leaf: OrchestrationMailboxLeaf,
|
||||
mailboxHandle: string,
|
||||
unread: readonly { id: string; type: string; sequence: number }[],
|
||||
newestSequence: number,
|
||||
ptyId: string,
|
||||
flight: OrchestrationMailboxDeliveryFlight,
|
||||
accepted: boolean
|
||||
): void {
|
||||
let delayedSettle = false
|
||||
try {
|
||||
if (!accepted || !this.state.isCurrentFlight(ptyId, flight)) {
|
||||
return
|
||||
}
|
||||
const db = this.deps.getDb()
|
||||
if (
|
||||
!db ||
|
||||
shouldReleaseOrchestrationPointer(
|
||||
db,
|
||||
mailboxHandle,
|
||||
unread,
|
||||
this.deps.getMessageWaiters(mailboxHandle)
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
flight.stagedMessageIds = unread.map((message) => message.id)
|
||||
db.markAsDelivered(flight.stagedMessageIds)
|
||||
this.state.setWatermark(mailboxHandle, newestSequence, ptyId, this.leafKey(leaf))
|
||||
if (
|
||||
[leaf.lastOscTitle, leaf.paneTitle, this.deps.getTabTitle(leaf.tabId)].some(
|
||||
isCursorAgentTitle
|
||||
)
|
||||
) {
|
||||
this.state.clearWatermark(mailboxHandle, newestSequence, ptyId)
|
||||
this.redrive(mailboxHandle)
|
||||
return
|
||||
}
|
||||
flight.enterTimer = setTimeout(
|
||||
() =>
|
||||
submitOrchestrationMailboxPointer(
|
||||
{
|
||||
mailboxOwner: this.deps.mailboxOwner,
|
||||
state: this.state,
|
||||
getDb: this.deps.getDb,
|
||||
getLeaf: this.deps.getLeaf,
|
||||
getLeafKey: this.deps.getLeafKey,
|
||||
getMessageWaiters: this.deps.getMessageWaiters,
|
||||
isLeafPtyProvenAbsent: this.deps.isLeafPtyProvenAbsent,
|
||||
writePty: this.deps.writePty,
|
||||
settle: (settledPtyId, settledFlight) => this.settle(settledPtyId, settledFlight),
|
||||
redrive: (redriveMailbox, force) => this.redrive(redriveMailbox, force)
|
||||
},
|
||||
{ leaf, mailboxHandle, messages: unread, newestSequence, ptyId, flight }
|
||||
),
|
||||
500
|
||||
)
|
||||
delayedSettle = true
|
||||
} finally {
|
||||
if (!delayedSettle) {
|
||||
this.settle(ptyId, flight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private settle(ptyId: string, flight: OrchestrationMailboxDeliveryFlight): void {
|
||||
const parked = this.state.settleFlight(ptyId, flight)
|
||||
if (!parked) {
|
||||
return
|
||||
}
|
||||
for (const [mailboxHandle, delivery] of parked) {
|
||||
const currentLeaf = this.deps.getLeaf(this.leafKey(delivery.leaf))
|
||||
if (
|
||||
currentLeaf?.ptyId !== ptyId ||
|
||||
this.deps.mailboxOwner.resolve(currentLeaf, mailboxHandle) !== mailboxHandle
|
||||
) {
|
||||
this.parkRedelivery(mailboxHandle, delivery.reservedTypes)
|
||||
this.redrive(mailboxHandle)
|
||||
} else {
|
||||
this.deliver(currentLeaf, { mailboxHandle, reservedTypes: delivery.reservedTypes })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private redrive(mailboxHandle: string, force = false): void {
|
||||
const parkedTypes = this.state.takeRedelivery(mailboxHandle, force)
|
||||
if (parkedTypes === undefined) {
|
||||
return
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
this.deps.redriveMailbox(mailboxHandle, parkedTypes ?? undefined)
|
||||
} catch {
|
||||
// Durable mail remains available to explicit check or a later idle edge.
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private leafKey(leaf: OrchestrationMailboxLeaf): string {
|
||||
return this.deps.getLeafKey(leaf.tabId, leaf.leafId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { OrchestrationDb } from './db'
|
||||
|
||||
export type OrchestrationMessageWaiter = { typeFilter: string[] | undefined }
|
||||
|
||||
export function messageTypeHasOrchestrationWaiter(
|
||||
waiters: ReadonlySet<OrchestrationMessageWaiter> | undefined,
|
||||
messageType: string
|
||||
): boolean {
|
||||
for (const waiter of waiters ?? []) {
|
||||
if (!waiter.typeFilter || waiter.typeFilter.includes(messageType)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function hasUnfilteredOrchestrationWaiter(
|
||||
waiters: ReadonlySet<OrchestrationMessageWaiter> | undefined
|
||||
): boolean {
|
||||
for (const waiter of waiters ?? []) {
|
||||
if (!waiter.typeFilter) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function shouldReleaseOrchestrationPointer(
|
||||
db: OrchestrationDb | null,
|
||||
mailboxHandle: string,
|
||||
messages: readonly { id: string; type: string }[],
|
||||
waiters: ReadonlySet<OrchestrationMessageWaiter> | undefined
|
||||
): boolean {
|
||||
if (
|
||||
mailboxHandle.startsWith('run:') &&
|
||||
db?.hasOutstandingRunDelivery?.(mailboxHandle.slice('run:'.length))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (messages.some((message) => messageTypeHasOrchestrationWaiter(waiters, message.type))) {
|
||||
return true
|
||||
}
|
||||
return !(
|
||||
db?.areUnreadMessages?.(
|
||||
mailboxHandle,
|
||||
messages.map((message) => message.id)
|
||||
) ?? true
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { OrchestrationMailboxLeaf } from './mailbox-owner'
|
||||
|
||||
export type OrchestrationMailboxDeliveryFlight = {
|
||||
enterTimer: ReturnType<typeof setTimeout> | null
|
||||
stagedMessageIds: string[]
|
||||
}
|
||||
|
||||
export type ParkedOrchestrationMailboxDelivery = {
|
||||
leaf: OrchestrationMailboxLeaf
|
||||
reservedTypes?: ReadonlySet<string>
|
||||
}
|
||||
|
||||
export class OrchestrationMailboxPointerState {
|
||||
private readonly flightsByPtyId = new Map<string, OrchestrationMailboxDeliveryFlight>()
|
||||
private readonly parkedDeliveriesByPtyId = new Map<
|
||||
string,
|
||||
Map<string, ParkedOrchestrationMailboxDelivery>
|
||||
>()
|
||||
private readonly watermarkByMailbox = new Map<
|
||||
string,
|
||||
{ ptyId: string; sequence: number; leafKey: string; active: boolean }
|
||||
>()
|
||||
private readonly watermarkMailboxesByPtyId = new Map<string, Set<string>>()
|
||||
private readonly parkedTypesByMailbox = new Map<string, ReadonlySet<string> | null>()
|
||||
|
||||
hasFlight(ptyId: string): boolean {
|
||||
return this.flightsByPtyId.has(ptyId)
|
||||
}
|
||||
|
||||
beginFlight(ptyId: string): OrchestrationMailboxDeliveryFlight {
|
||||
const flight = { enterTimer: null, stagedMessageIds: [] }
|
||||
this.flightsByPtyId.set(ptyId, flight)
|
||||
return flight
|
||||
}
|
||||
|
||||
isCurrentFlight(ptyId: string, flight: OrchestrationMailboxDeliveryFlight): boolean {
|
||||
return this.flightsByPtyId.get(ptyId) === flight
|
||||
}
|
||||
|
||||
settleFlight(
|
||||
ptyId: string,
|
||||
flight: OrchestrationMailboxDeliveryFlight
|
||||
): Map<string, ParkedOrchestrationMailboxDelivery> | null {
|
||||
if (!this.isCurrentFlight(ptyId, flight)) {
|
||||
return null
|
||||
}
|
||||
this.flightsByPtyId.delete(ptyId)
|
||||
const parked = this.parkedDeliveriesByPtyId.get(ptyId) ?? null
|
||||
this.parkedDeliveriesByPtyId.delete(ptyId)
|
||||
return parked
|
||||
}
|
||||
|
||||
parkDelivery(
|
||||
ptyId: string,
|
||||
mailboxHandle: string,
|
||||
leaf: OrchestrationMailboxLeaf,
|
||||
reservedTypes?: ReadonlySet<string>
|
||||
): void {
|
||||
const parked = this.parkedDeliveriesByPtyId.get(ptyId) ?? new Map()
|
||||
const priorEntry = parked.get(mailboxHandle)
|
||||
const prior = priorEntry?.reservedTypes
|
||||
const current = reservedTypes && reservedTypes.size > 0 ? reservedTypes : undefined
|
||||
let merged: Set<string> | undefined
|
||||
if (!priorEntry) {
|
||||
merged = current ? new Set(current) : undefined
|
||||
} else if (prior && current) {
|
||||
merged = new Set([...prior, ...current])
|
||||
}
|
||||
parked.set(mailboxHandle, { leaf, reservedTypes: merged })
|
||||
this.parkedDeliveriesByPtyId.set(ptyId, parked)
|
||||
}
|
||||
|
||||
hasActiveWatermark(mailboxHandle: string): boolean {
|
||||
return this.watermarkByMailbox.get(mailboxHandle)?.active === true
|
||||
}
|
||||
|
||||
releaseSupersededWatermark(
|
||||
mailboxHandle: string,
|
||||
newestSequence: number,
|
||||
ptyId: string | null,
|
||||
leafKey: string
|
||||
): boolean {
|
||||
const owner = this.watermarkByMailbox.get(mailboxHandle)
|
||||
if (newestSequence > (owner?.sequence ?? -1)) {
|
||||
return true
|
||||
}
|
||||
return Boolean(
|
||||
owner &&
|
||||
!owner.active &&
|
||||
!(owner.ptyId === ptyId && owner.leafKey === leafKey) &&
|
||||
this.clearWatermark(mailboxHandle, owner.sequence, owner.ptyId)
|
||||
)
|
||||
}
|
||||
|
||||
setWatermark(mailbox: string, sequence: number, ptyId: string, leafKey: string): void {
|
||||
const prior = this.watermarkByMailbox.get(mailbox)
|
||||
if (prior && prior.ptyId !== ptyId) {
|
||||
this.removeWatermarkPtyIndex(mailbox, prior.ptyId)
|
||||
}
|
||||
this.watermarkByMailbox.set(mailbox, { ptyId, sequence, leafKey, active: true })
|
||||
const mailboxes = this.watermarkMailboxesByPtyId.get(ptyId) ?? new Set<string>()
|
||||
mailboxes.add(mailbox)
|
||||
this.watermarkMailboxesByPtyId.set(ptyId, mailboxes)
|
||||
}
|
||||
|
||||
clearWatermark(mailbox: string, sequence: number, ptyId: string): boolean {
|
||||
const owner = this.watermarkByMailbox.get(mailbox)
|
||||
if (!owner || owner.ptyId !== ptyId || owner.sequence !== sequence) {
|
||||
return false
|
||||
}
|
||||
this.watermarkByMailbox.delete(mailbox)
|
||||
this.removeWatermarkPtyIndex(mailbox, ptyId)
|
||||
return true
|
||||
}
|
||||
|
||||
deactivateWatermark(mailbox: string, sequence: number, ptyId: string): boolean {
|
||||
const owner = this.watermarkByMailbox.get(mailbox)
|
||||
if (!owner || owner.ptyId !== ptyId || owner.sequence !== sequence) {
|
||||
return false
|
||||
}
|
||||
owner.active = false
|
||||
return true
|
||||
}
|
||||
|
||||
parkRedelivery(mailboxHandle: string, reservedTypes?: ReadonlySet<string>): void {
|
||||
const prior = this.parkedTypesByMailbox.get(mailboxHandle)
|
||||
const hasPrior = this.parkedTypesByMailbox.has(mailboxHandle)
|
||||
const current = reservedTypes && reservedTypes.size > 0 ? reservedTypes : undefined
|
||||
if (!hasPrior) {
|
||||
this.parkedTypesByMailbox.set(mailboxHandle, current ? new Set(current) : null)
|
||||
return
|
||||
}
|
||||
if (prior == null || current === undefined) {
|
||||
this.parkedTypesByMailbox.set(mailboxHandle, null)
|
||||
return
|
||||
}
|
||||
this.parkedTypesByMailbox.set(mailboxHandle, new Set([...prior, ...current]))
|
||||
}
|
||||
|
||||
takeRedelivery(mailboxHandle: string, force: boolean): ReadonlySet<string> | null | undefined {
|
||||
const parkedTypes = this.parkedTypesByMailbox.get(mailboxHandle)
|
||||
if (!force && parkedTypes === undefined) {
|
||||
return undefined
|
||||
}
|
||||
this.parkedTypesByMailbox.delete(mailboxHandle)
|
||||
return parkedTypes ?? null
|
||||
}
|
||||
|
||||
retirePty(ptyId: string): {
|
||||
flight: OrchestrationMailboxDeliveryFlight | undefined
|
||||
releasedMailboxes: string[]
|
||||
} {
|
||||
const flight = this.flightsByPtyId.get(ptyId)
|
||||
this.flightsByPtyId.delete(ptyId)
|
||||
this.parkedDeliveriesByPtyId.delete(ptyId)
|
||||
const releasedMailboxes: string[] = []
|
||||
for (const mailboxHandle of this.watermarkMailboxesByPtyId.get(ptyId) ?? []) {
|
||||
const owner = this.watermarkByMailbox.get(mailboxHandle)
|
||||
if (owner?.ptyId === ptyId && this.clearWatermark(mailboxHandle, owner.sequence, ptyId)) {
|
||||
releasedMailboxes.push(mailboxHandle)
|
||||
}
|
||||
}
|
||||
this.watermarkMailboxesByPtyId.delete(ptyId)
|
||||
return { flight, releasedMailboxes }
|
||||
}
|
||||
|
||||
private removeWatermarkPtyIndex(mailbox: string, ptyId: string): void {
|
||||
const mailboxes = this.watermarkMailboxesByPtyId.get(ptyId)
|
||||
mailboxes?.delete(mailbox)
|
||||
if (mailboxes?.size === 0) {
|
||||
this.watermarkMailboxesByPtyId.delete(ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { OrchestrationDb } from './db'
|
||||
import {
|
||||
shouldReleaseOrchestrationPointer,
|
||||
type OrchestrationMessageWaiter
|
||||
} from './mailbox-pointer-eligibility'
|
||||
import type { OrchestrationMailboxLeaf, OrchestrationMailboxOwner } from './mailbox-owner'
|
||||
import type {
|
||||
OrchestrationMailboxDeliveryFlight,
|
||||
OrchestrationMailboxPointerState
|
||||
} from './mailbox-pointer-state'
|
||||
|
||||
type PointerSubmitDependencies<TWaiter extends OrchestrationMessageWaiter> = {
|
||||
mailboxOwner: OrchestrationMailboxOwner
|
||||
state: OrchestrationMailboxPointerState
|
||||
getDb: () => OrchestrationDb | null
|
||||
getLeaf: (leafKey: string) => OrchestrationMailboxLeaf | undefined
|
||||
getLeafKey: (tabId: string, leafId: string) => string
|
||||
getMessageWaiters: (mailboxHandle: string) => ReadonlySet<TWaiter> | undefined
|
||||
isLeafPtyProvenAbsent: (ptyId: string) => Promise<boolean>
|
||||
writePty: (ptyId: string, data: string) => boolean | Promise<boolean>
|
||||
settle: (ptyId: string, flight: OrchestrationMailboxDeliveryFlight) => void
|
||||
redrive: (mailboxHandle: string, force?: boolean) => void
|
||||
}
|
||||
|
||||
export function submitOrchestrationMailboxPointer<TWaiter extends OrchestrationMessageWaiter>(
|
||||
deps: PointerSubmitDependencies<TWaiter>,
|
||||
input: {
|
||||
leaf: OrchestrationMailboxLeaf
|
||||
mailboxHandle: string
|
||||
messages: readonly { id: string; type: string }[]
|
||||
newestSequence: number
|
||||
ptyId: string
|
||||
flight: OrchestrationMailboxDeliveryFlight
|
||||
}
|
||||
): void {
|
||||
let clearAndRedrive = false
|
||||
let submitted = false
|
||||
let releaseWithoutRedrive = false
|
||||
let finalizeReservation = true
|
||||
void deps
|
||||
.isLeafPtyProvenAbsent(input.ptyId)
|
||||
.then(async (absent) => {
|
||||
if (absent) {
|
||||
clearAndRedrive = true
|
||||
return
|
||||
}
|
||||
if (!deps.state.isCurrentFlight(input.ptyId, input.flight)) {
|
||||
finalizeReservation = false
|
||||
return
|
||||
}
|
||||
const currentLeaf = deps.getLeaf(deps.getLeafKey(input.leaf.tabId, input.leaf.leafId))
|
||||
if (!currentLeaf || currentLeaf.ptyId !== input.ptyId || !currentLeaf.writable) {
|
||||
clearAndRedrive = true
|
||||
} else if (deps.mailboxOwner.resolve(currentLeaf) !== input.mailboxHandle) {
|
||||
clearAndRedrive = true
|
||||
} else if (
|
||||
currentLeaf.lastAgentStatus === 'idle' &&
|
||||
currentLeaf.lastAgentStatusObservedLive
|
||||
) {
|
||||
if (
|
||||
shouldReleaseOrchestrationPointer(
|
||||
deps.getDb(),
|
||||
input.mailboxHandle,
|
||||
input.messages,
|
||||
deps.getMessageWaiters(input.mailboxHandle)
|
||||
)
|
||||
) {
|
||||
releaseWithoutRedrive = true
|
||||
} else {
|
||||
submitted = await deps.writePty(input.ptyId, '\r')
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
let released = false
|
||||
if (finalizeReservation) {
|
||||
if (clearAndRedrive) {
|
||||
deps.getDb()?.markAsUndelivered(input.messages.map((message) => message.id))
|
||||
}
|
||||
released =
|
||||
submitted || clearAndRedrive || releaseWithoutRedrive
|
||||
? deps.state.clearWatermark(input.mailboxHandle, input.newestSequence, input.ptyId)
|
||||
: deps.state.deactivateWatermark(input.mailboxHandle, input.newestSequence, input.ptyId)
|
||||
}
|
||||
deps.settle(input.ptyId, input.flight)
|
||||
if (released && !releaseWithoutRedrive) {
|
||||
deps.redrive(input.mailboxHandle, clearAndRedrive)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -103,7 +103,7 @@ describe('mutation receipt capacity schema', () => {
|
||||
|
||||
db = new OrchestrationDb(dbPath)
|
||||
const sqlite = sqliteFor(db)
|
||||
expect(sqlite.pragma('user_version', { simple: true })).toBe(27)
|
||||
expect(sqlite.pragma('user_version', { simple: true })).toBe(28)
|
||||
expect(sqlite.prepare('SELECT receipt_count FROM mutation_receipt_ledger').get()).toEqual({
|
||||
receipt_count: 20
|
||||
})
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('creator authority lookup performance', () => {
|
||||
const taskDetails = taskPlan.map((row) => row.detail).join(' | ')
|
||||
const paneDetails = panePlan.map((row) => row.detail).join(' | ')
|
||||
|
||||
expect(taskDetails).toContain('idx_dispatch_active_assignee_handle')
|
||||
expect(taskDetails).toContain('idx_dispatch_active_run_assignee_handle')
|
||||
expect(taskDetails).not.toMatch(/SCAN (?:runs|rebound)/)
|
||||
expect(paneDetails).toContain('idx_dispatch_assignee_pane_leaf')
|
||||
})
|
||||
|
||||
@@ -240,7 +240,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => {
|
||||
|
||||
db = new OrchestrationDb(dbPath)
|
||||
const sqlite = sqliteFor(db)
|
||||
expect(sqlite.pragma('user_version', { simple: true })).toBe(27)
|
||||
expect(sqlite.pragma('user_version', { simple: true })).toBe(28)
|
||||
expect(db.getDispatchContextById(dispatch.id)).toMatchObject({ assignee_handle: 'term_worker' })
|
||||
expect(db.getTask(task.id)).toMatchObject({
|
||||
created_by_pane_key: null,
|
||||
@@ -277,7 +277,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => {
|
||||
|
||||
db.close()
|
||||
db = new OrchestrationDb(dbPath)
|
||||
expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(27)
|
||||
expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(28)
|
||||
expect(db.getDispatchContextById(dispatch.id)).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -309,7 +309,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => {
|
||||
|
||||
db = new OrchestrationDb(dbPath)
|
||||
const sqlite = sqliteFor(db)
|
||||
expect(sqlite.pragma('user_version', { simple: true })).toBe(27)
|
||||
expect(sqlite.pragma('user_version', { simple: true })).toBe(28)
|
||||
expect(db.getTask(task.id)).toMatchObject({
|
||||
created_by_pane_key: 'tab_creator:leaf_creator',
|
||||
created_by_process_incarnation: 'pty_creator:incarnation-a',
|
||||
@@ -328,7 +328,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => {
|
||||
|
||||
db.close()
|
||||
db = new OrchestrationDb(dbPath)
|
||||
expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(27)
|
||||
expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(28)
|
||||
expect(db.getTask(task.id)?.created_by_process_incarnation).toBe('pty_creator:incarnation-a')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,6 +57,9 @@ describe('OrchestrationDb reset scopes', () => {
|
||||
expect(db!.getTask(state.task.id)).toBeUndefined()
|
||||
expect(db!.getWorkerDispatch(state.started.dispatch.id)).toBeUndefined()
|
||||
expect(db!.getFederatedDispatch(state.started.dispatch.id)).toBeUndefined()
|
||||
const sqlite = (db as unknown as { db: { prepare: (sql: string) => { all: () => unknown[] } } })
|
||||
.db
|
||||
expect(sqlite.prepare('SELECT * FROM run_coordinator_handles').all()).toEqual([])
|
||||
// The ledger survives so a lost reset response cannot replay as a new mutation.
|
||||
expect(db!.getMutationReceipt('caller_1', 'request_1')).toBeDefined()
|
||||
expect(db!.getInbox()).toEqual([])
|
||||
|
||||
@@ -163,6 +163,30 @@ describe('OrchestrationDb Run state', () => {
|
||||
expect(replacement?.messages.map((message) => message.subject)).toEqual(['one'])
|
||||
})
|
||||
|
||||
it('does not move a mismatched Run through another Run Dispatch mailbox', () => {
|
||||
const d = createDb()
|
||||
const runA = createBoundRun(d)
|
||||
const runB = d.createRun({
|
||||
objective: 'Dispatch owner',
|
||||
coordinatorHandle: 'term_other',
|
||||
coordinatorPaneKey: 'tab_other:22222222-2222-4222-9222-222222222222'
|
||||
})
|
||||
const task = d.createTask({ spec: 'work', runId: runB.id })
|
||||
const dispatch = d.createDispatchContext(task.id, 'term_worker')
|
||||
const mismatched = d.insertMessage({
|
||||
from: 'worker',
|
||||
to: `dispatch:${dispatch.id}`,
|
||||
subject: 'wrong Run',
|
||||
runId: runA.id
|
||||
})
|
||||
|
||||
expect(d.routeUnreadDispatchMailboxToRunMailbox(dispatch.id, runB.id)).toMatchObject({
|
||||
routedCount: 0,
|
||||
hasMore: false
|
||||
})
|
||||
expect(d.getMessageById(mismatched.id)?.to_handle).toBe(`dispatch:${dispatch.id}`)
|
||||
})
|
||||
|
||||
it('replays an outstanding batch after reopening the database', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-delivery-'))
|
||||
const dbPath = join(dir, 'orchestration.db')
|
||||
|
||||
@@ -288,6 +288,51 @@ describe('OrchestrationDb worker Dispatch state', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds remote attachment lookup across pane remints and malformed suffix collisions', () => {
|
||||
const d = createDb()
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
const attach = (dispatchId: string, paneKey: string): void => {
|
||||
d.createRemoteDispatchAttachment({
|
||||
dispatchId,
|
||||
taskId: `task_${dispatchId}`,
|
||||
homePeerFingerprint: 'home_peer',
|
||||
protocolVersion: 1,
|
||||
runtimeEpoch: 'worker_epoch',
|
||||
mutationReceipt: {
|
||||
callerFingerprint: 'home_peer',
|
||||
requestId: `request_${dispatchId}`,
|
||||
method: 'orchestration.federationAttachStart',
|
||||
payloadHash: `payload_${dispatchId}`
|
||||
}
|
||||
})
|
||||
d.prepareRemoteAttachmentAuthority({
|
||||
dispatchId,
|
||||
paneKey,
|
||||
processIncarnation: `process_${dispatchId}`,
|
||||
worktreeId: 'repo::worktree',
|
||||
terminalHandle: `term_${dispatchId}`,
|
||||
setupState: 'not_applicable',
|
||||
effects: []
|
||||
})
|
||||
}
|
||||
|
||||
attach('ctx_valid_old', `tab_old:${leafId}`)
|
||||
for (let index = 0; index < 64; index += 1) {
|
||||
attach(`ctx_malformed_${index}`, `:${leafId}`)
|
||||
}
|
||||
|
||||
expect(d.findActiveRemoteAttachmentForPane(`tab_reminted:${leafId}`)?.dispatch_id).toBe(
|
||||
'ctx_valid_old'
|
||||
)
|
||||
attach('ctx_valid_new', `tab_new:${leafId}`)
|
||||
expect(d.findActiveRemoteAttachmentForPane(`tab_reminted:${leafId}`)?.dispatch_id).toBe(
|
||||
'ctx_valid_new'
|
||||
)
|
||||
expect(d.findActiveRemoteAttachmentForPane(`tab_old:${leafId}`)?.dispatch_id).toBe(
|
||||
'ctx_valid_new'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns already-settled when completion wins before stop', () => {
|
||||
const d = createDb()
|
||||
const task = d.createTask({ spec: 'race' })
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { OrchestrationDb } from './db'
|
||||
|
||||
describe('Run coordinator handle history migration', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
let tempDir: string | undefined
|
||||
|
||||
afterEach(() => {
|
||||
db?.close()
|
||||
db = undefined
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
tempDir = undefined
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves v27 coordinator authority for mail arriving after a rebind', () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'orca-run-coordinator-handle-migration-'))
|
||||
const dbPath = join(tempDir, 'orchestration.db')
|
||||
db = new OrchestrationDb(dbPath)
|
||||
const run = db.createRun({
|
||||
objective: 'migration authority',
|
||||
coordinatorHandle: 'term_old',
|
||||
coordinatorPaneKey: 'tab:leaf'
|
||||
})
|
||||
db.close()
|
||||
db = undefined
|
||||
|
||||
const oldDb = new Database(dbPath)
|
||||
oldDb.exec('DROP TABLE run_coordinator_handles')
|
||||
oldDb.pragma('user_version = 27')
|
||||
oldDb.close()
|
||||
|
||||
db = new OrchestrationDb(dbPath)
|
||||
const sqlite = (db as unknown as { db: Database.Database }).db
|
||||
expect(sqlite.pragma('user_version', { simple: true })).toBe(28)
|
||||
expect(
|
||||
sqlite
|
||||
.prepare('SELECT run_id, terminal_handle FROM run_coordinator_handles WHERE run_id = ?')
|
||||
.all(run.id)
|
||||
).toEqual([{ run_id: run.id, terminal_handle: 'term_old' }])
|
||||
|
||||
db.bindRun({
|
||||
runId: run.id,
|
||||
coordinatorHandle: 'term_new',
|
||||
coordinatorPaneKey: 'tab:leaf'
|
||||
})
|
||||
const late = db.insertMessage({
|
||||
runId: run.id,
|
||||
from: 'worker',
|
||||
to: 'term_old',
|
||||
subject: 'late completion',
|
||||
type: 'worker_done'
|
||||
})
|
||||
sqlite.prepare('UPDATE messages SET to_handle = ? WHERE id = ?').run('term_old', late.id)
|
||||
|
||||
expect(db.routeForeignDirectMessagesToOwnedMailboxes('term_old')).toMatchObject({
|
||||
routedCount: 1,
|
||||
mailboxes: [{ mailboxHandle: `run:${run.id}`, types: ['worker_done'] }]
|
||||
})
|
||||
expect(db.getMessageById(late.id)?.to_handle).toBe(`run:${run.id}`)
|
||||
})
|
||||
|
||||
it('self-converges a v28 database created before coordinator history existed', () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'orca-run-coordinator-v28-convergence-'))
|
||||
const dbPath = join(tempDir, 'orchestration.db')
|
||||
db = new OrchestrationDb(dbPath)
|
||||
const run = db.createRun({
|
||||
objective: 'schema collision',
|
||||
coordinatorHandle: 'term_existing',
|
||||
coordinatorPaneKey: 'tab:leaf'
|
||||
})
|
||||
db.close()
|
||||
db = undefined
|
||||
|
||||
const v28Db = new Database(dbPath)
|
||||
v28Db.exec('DROP TABLE run_coordinator_handles')
|
||||
expect(v28Db.pragma('user_version', { simple: true })).toBe(28)
|
||||
v28Db.close()
|
||||
|
||||
db = new OrchestrationDb(dbPath)
|
||||
const sqlite = (db as unknown as { db: Database.Database }).db
|
||||
expect(sqlite.pragma('user_version', { simple: true })).toBe(28)
|
||||
expect(
|
||||
sqlite
|
||||
.prepare('SELECT run_id, terminal_handle FROM run_coordinator_handles WHERE run_id = ?')
|
||||
.all(run.id)
|
||||
).toEqual([{ run_id: run.id, terminal_handle: 'term_existing' }])
|
||||
})
|
||||
|
||||
it('records every coordinator rebind performed by an older runtime', () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'orca-run-coordinator-old-runtime-'))
|
||||
const dbPath = join(tempDir, 'orchestration.db')
|
||||
db = new OrchestrationDb(dbPath)
|
||||
const run = db.createRun({
|
||||
objective: 'mixed-version authority',
|
||||
coordinatorHandle: 'term_v28',
|
||||
coordinatorPaneKey: 'tab:leaf'
|
||||
})
|
||||
db.close()
|
||||
db = undefined
|
||||
|
||||
const oldRuntimeDb = new Database(dbPath)
|
||||
oldRuntimeDb
|
||||
.prepare('UPDATE runs SET coordinator_handle = ? WHERE id = ?')
|
||||
.run('term_v27_first', run.id)
|
||||
oldRuntimeDb
|
||||
.prepare('UPDATE runs SET coordinator_handle = ? WHERE id = ?')
|
||||
.run('term_v27_second', run.id)
|
||||
expect(oldRuntimeDb.pragma('user_version', { simple: true })).toBe(28)
|
||||
oldRuntimeDb.close()
|
||||
|
||||
db = new OrchestrationDb(dbPath)
|
||||
const sqlite = (db as unknown as { db: Database.Database }).db
|
||||
expect(
|
||||
sqlite
|
||||
.prepare(
|
||||
'SELECT terminal_handle FROM run_coordinator_handles WHERE run_id = ? ORDER BY terminal_handle'
|
||||
)
|
||||
.all(run.id)
|
||||
).toEqual([
|
||||
{ terminal_handle: 'term_v27_first' },
|
||||
{ terminal_handle: 'term_v27_second' },
|
||||
{ terminal_handle: 'term_v28' }
|
||||
])
|
||||
const late = db.insertMessage({
|
||||
runId: run.id,
|
||||
from: 'worker',
|
||||
to: 'term_v27_first',
|
||||
subject: 'late intermediate completion',
|
||||
type: 'worker_done'
|
||||
})
|
||||
expect(db.getMessageById(late.id)?.to_handle).toBe(`run:${run.id}`)
|
||||
})
|
||||
|
||||
it('reinstalls coordinator routing after a migration removes the trigger', () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'orca-run-coordinator-trigger-migration-'))
|
||||
const dbPath = join(tempDir, 'orchestration.db')
|
||||
db = new OrchestrationDb(dbPath)
|
||||
const run = db.createRun({
|
||||
objective: 'trigger migration',
|
||||
coordinatorHandle: 'term_migrated',
|
||||
coordinatorPaneKey: 'tab:leaf'
|
||||
})
|
||||
db.close()
|
||||
db = undefined
|
||||
|
||||
const oldDb = new Database(dbPath)
|
||||
oldDb.exec('DROP TRIGGER trg_messages_route_coordinator_mail')
|
||||
oldDb.pragma('user_version = 27')
|
||||
oldDb.close()
|
||||
|
||||
db = new OrchestrationDb(dbPath)
|
||||
const message = db.insertMessage({
|
||||
runId: run.id,
|
||||
from: 'worker',
|
||||
to: 'term_migrated',
|
||||
subject: 'first post-migration message'
|
||||
})
|
||||
|
||||
expect(db.getMessageById(message.id)?.to_handle).toBe(`run:${run.id}`)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import { RpcDispatcher } from './dispatcher'
|
||||
import { defineMethod, InvalidArgumentError, type RpcRequest } from './core'
|
||||
@@ -37,6 +37,20 @@ const METHODS = [
|
||||
handler: () => {
|
||||
throw new InvalidArgumentError('Async validation rejected payload')
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'orchestration.inspectCaller',
|
||||
params: z.object({}),
|
||||
handler: (_params, { authenticatedCallerFingerprint }) => ({
|
||||
authenticatedCallerFingerprint
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'orchestration.federationInspectCaller',
|
||||
params: z.object({}),
|
||||
handler: (_params, { authenticatedCallerFingerprint }) => ({
|
||||
authenticatedCallerFingerprint
|
||||
})
|
||||
})
|
||||
]
|
||||
|
||||
@@ -109,12 +123,16 @@ describe('RpcDispatcher computer-use validation errors', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('maps async validation errors to invalid_argument without shadowing Zod formatting', async () => {
|
||||
it('maps async validation errors over streaming transport without shadowing Zod formatting', async () => {
|
||||
const messages: string[] = []
|
||||
const dispatcher = new RpcDispatcher({ runtime: makeRuntime(), methods: METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest('orchestration.invalidArgument', {}))
|
||||
await dispatcher.dispatchStreaming(
|
||||
makeRequest('orchestration.invalidArgument', {}),
|
||||
(message) => messages.push(message)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
expect(JSON.parse(messages[0]!)).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'invalid_argument',
|
||||
@@ -122,4 +140,35 @@ describe('RpcDispatcher computer-use validation errors', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards a paired caller fingerprint without requiring local orchestration state', async () => {
|
||||
const dispatcher = new RpcDispatcher({ runtime: makeRuntime(), methods: METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest('orchestration.inspectCaller', {}), {
|
||||
authenticatedCallerFingerprint: 'paired-caller'
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { authenticatedCallerFingerprint: 'paired-caller' }
|
||||
})
|
||||
})
|
||||
|
||||
it('provides local identity to read-only federation authorization', async () => {
|
||||
const getOrCreateLocalMutationCallerFingerprint = vi.fn(() => 'local-caller')
|
||||
const runtime = Object.assign(makeRuntime(), {
|
||||
getOrchestrationDb: () => ({ getOrCreateLocalMutationCallerFingerprint })
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('orchestration.federationInspectCaller', {})
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { authenticatedCallerFingerprint: 'local-caller' }
|
||||
})
|
||||
expect(getOrCreateLocalMutationCallerFingerprint).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protoc
|
||||
import type { PairingRpcContext } from './core'
|
||||
|
||||
export type RpcDispatchStreamingOptions = {
|
||||
authenticatedCallerFingerprint?: string
|
||||
connectionId?: string
|
||||
signal?: AbortSignal
|
||||
clientId?: string
|
||||
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
} from './core'
|
||||
|
||||
import type { FeatureInteractionId } from '../../../shared/feature-interactions'
|
||||
import { isOrchestrationMutation } from '../../../shared/orchestration-rpc-contract'
|
||||
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 {
|
||||
authenticatedCallerFingerprint,
|
||||
getOrchestrationMutationExecutor,
|
||||
type OrchestrationMutationExecutor,
|
||||
type DurableMutationInvocation
|
||||
@@ -41,7 +41,10 @@ export class RpcDispatcher {
|
||||
this.legacyOrchestration = new OrchestrationLegacyCompatibility(runtime)
|
||||
}
|
||||
|
||||
async dispatch(request: RpcRequest, options?: { signal?: AbortSignal }): Promise<RpcResponse> {
|
||||
async dispatch(
|
||||
request: RpcRequest,
|
||||
options?: { signal?: AbortSignal; authenticatedCallerFingerprint?: string }
|
||||
): Promise<RpcResponse> {
|
||||
const meta = this.meta()
|
||||
const method = this.registry.get(request.method)
|
||||
if (!method) {
|
||||
@@ -89,6 +92,11 @@ export class RpcDispatcher {
|
||||
request,
|
||||
compatibility.legacyCoordinatorAuthority
|
||||
)
|
||||
const authenticatedCallerFingerprint =
|
||||
options?.authenticatedCallerFingerprint ??
|
||||
(needsLocalCallerFingerprint(request, effectiveParams)
|
||||
? this.orchestrationMutations.getLocalAuthenticatedCallerFingerprint()
|
||||
: undefined)
|
||||
const invoke = (mutation?: DurableMutationInvocation) => {
|
||||
const legacyCoordinatorRunId = legacyCoordinator?.revalidate()
|
||||
return method.handler(effectiveParams, {
|
||||
@@ -97,7 +105,7 @@ export class RpcDispatcher {
|
||||
requestId: request.id,
|
||||
orchestrationCapability: request.orchestrationCapability,
|
||||
authenticatedCallerFingerprint:
|
||||
mutation?.identity.callerFingerprint ?? authenticatedCallerFingerprint(request),
|
||||
mutation?.identity.callerFingerprint ?? authenticatedCallerFingerprint,
|
||||
recordMutationReceipt: mutation?.recordReceipt,
|
||||
orchestrationMutation: mutation?.identity,
|
||||
legacyCoordinatorRunId,
|
||||
@@ -112,7 +120,7 @@ export class RpcDispatcher {
|
||||
request,
|
||||
effectiveParams,
|
||||
invoke,
|
||||
legacyCoordinator?.mutationCallerFingerprint
|
||||
legacyCoordinator?.mutationCallerFingerprint ?? authenticatedCallerFingerprint
|
||||
)
|
||||
recordRuntimeFeatureInteraction(
|
||||
this.runtime,
|
||||
@@ -177,6 +185,11 @@ export class RpcDispatcher {
|
||||
request,
|
||||
compatibility.legacyCoordinatorAuthority
|
||||
)
|
||||
const authenticatedCallerFingerprint =
|
||||
options?.authenticatedCallerFingerprint ??
|
||||
(needsLocalCallerFingerprint(request, effectiveParams)
|
||||
? this.orchestrationMutations.getLocalAuthenticatedCallerFingerprint()
|
||||
: undefined)
|
||||
const invoke = (mutation?: DurableMutationInvocation) => {
|
||||
const legacyCoordinatorRunId = legacyCoordinator?.revalidate()
|
||||
return method.handler(effectiveParams, {
|
||||
@@ -190,7 +203,7 @@ export class RpcDispatcher {
|
||||
clientCapabilities: options?.clientCapabilities,
|
||||
orchestrationCapability: request.orchestrationCapability,
|
||||
authenticatedCallerFingerprint:
|
||||
mutation?.identity.callerFingerprint ?? authenticatedCallerFingerprint(request),
|
||||
mutation?.identity.callerFingerprint ?? authenticatedCallerFingerprint,
|
||||
recordMutationReceipt: mutation?.recordReceipt,
|
||||
orchestrationMutation: mutation?.identity,
|
||||
pairing: options?.pairing,
|
||||
@@ -208,7 +221,7 @@ export class RpcDispatcher {
|
||||
request,
|
||||
effectiveParams,
|
||||
invoke,
|
||||
legacyCoordinator?.mutationCallerFingerprint
|
||||
legacyCoordinator?.mutationCallerFingerprint ?? authenticatedCallerFingerprint
|
||||
)
|
||||
recordRuntimeFeatureInteraction(
|
||||
this.runtime,
|
||||
@@ -291,3 +304,10 @@ export class RpcDispatcher {
|
||||
return { runtimeId: this.runtime.getRuntimeId() }
|
||||
}
|
||||
}
|
||||
|
||||
function needsLocalCallerFingerprint(request: RpcRequest, params: unknown): boolean {
|
||||
return (
|
||||
request.method.startsWith('orchestration.federation') ||
|
||||
(!!request.orchestrationRequestId && isOrchestrationMutation(request.method, params))
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user