mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 08:03:58 +00:00
fix(terminal): centralize prompt delivery ownership
This commit is contained in:
@@ -471,15 +471,6 @@
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/terminal.test.ts src/main/runtime/rpc/terminal-agent-prompt-send.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/agent-prompt-delivery-state-machine.test.ts src/main/runtime/agent-prompt-delivery-outcome.test.ts src/main/runtime/agent-prompt-submission-runtime.test.ts src/main/runtime/agent-prompt-submission-verification.test.ts src/main/runtime/rpc/methods/orchestration-composed-workers.test.ts src/main/runtime/rpc/methods/orchestration-federation-start-receipt.test.ts src/shared/agent-prompt-injection.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 27.54,
|
||||
"summary": "Eleven runtime, RPC, CLI, and state-machine files passed 1,296 tests with one skip. Deterministic OpenCode readiness, lifecycle-only acknowledgement, current foreground policy, input exclusion, and local/federated unknown-outcome ownership all passed."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-14",
|
||||
"runner": "local",
|
||||
@@ -2305,24 +2296,6 @@
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"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/agent-prompt-delivery-state-machine.test.ts src/main/runtime/agent-prompt-delivery-outcome.test.ts src/main/runtime/agent-prompt-submission-runtime.test.ts src/main/runtime/agent-prompt-submission-verification.test.ts src/main/runtime/rpc/methods/orchestration-composed-workers.test.ts src/main/runtime/rpc/methods/orchestration-federation-start-receipt.test.ts src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 23.47,
|
||||
"summary": "Ten runtime and orchestration files passed 1,320 tests with one skip. The owner-level delivery state machine and both local and federated start_unknown receipts passed alongside existing dispatch and coordinator coverage."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec playwright test tests/e2e/orchestration-worker-settlement-release-cli.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 26.3,
|
||||
"summary": "A rebuilt Electron plus compiled CLI orchestration topology passed through worker start, settlement, and terminal release in 18.1 seconds of test execution."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-14",
|
||||
"runner": "local",
|
||||
@@ -9766,15 +9739,15 @@
|
||||
"providers": ["local", "daemon", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos", "windows"],
|
||||
"coveredProviders": ["local"],
|
||||
"coverageNotes": "Prior source-built macOS and physical Windows Electron journeys cover the public CLI through RPC, runtime identity, real PTY/ConPTY writes, and a delayed fake Codex composer. Current deterministic runtime tests cover fresh OpenCode readiness, current foreground policy, strict lifecycle acknowledgement, unknown-outcome ownership, and input exclusion. The legacy Electron fixture no longer identifies its fake foreground process and is an explicit harness gap; SSH, daemon, and remote-runtime remain unproved.",
|
||||
"coverageNotes": "Prior source-built macOS and physical Windows Electron journeys cover the public CLI through RPC, runtime identity, real PTY/ConPTY writes, and a delayed fake Codex composer. Current deterministic tests cover fresh OpenCode readiness, initial and pre-submit foreground authority, legacy inspection fallback, lifecycle-only acknowledgement, provider-boundary input arbitration, admitted-user paste closure, generation-neutral large renderer input, bounded query-reply deferral, startup and mailbox prompt ownership, queued retry exclusion, and local/federated unknown-outcome ownership. The legacy Electron fixture no longer identifies its fake foreground process and is an explicit harness gap; live SSH, daemon, and remote-runtime remain unproved.",
|
||||
"motivatingLinks": [
|
||||
"https://linear.app/stably/issue/STA-4328",
|
||||
"https://github.com/stablyai/orca/issues/14525"
|
||||
],
|
||||
"invariant": "A public CLI send containing non-empty text plus Enter and no interrupt identifies agent-prompt intent. The PTY-owning runtime exclusively owns input for the attempt, chooses readiness policy from the current foreground agent, and sends Enter only after fresh post-paste readiness. Only a fresh lifecycle transition acknowledges delivery; redraw bytes never do. Any failure after prompt bytes were written is operation_unknown, and orchestration retains the exact terminal in start_unknown rather than making it transferable. Non-target agents and older clients keep the direct terminal contract.",
|
||||
"oracle": "Withhold OpenCode readiness beyond the old 500 ms deadline, emit a stale pre-attempt marker and then one split fresh marker, and require zero premature Enter plus one lifecycle-acknowledged Enter. A swallowed Enter followed by autonomous redraw output must remain operation_unknown. Missing readiness must emit no Enter but still remain operation_unknown because the paste reached the composer. Current foreground OpenCode must override stale Codex launch metadata and vice versa. Concurrent direct input must be rejected while the prompt owns PTY input, and prompt paste must be refused while direct input is already in flight. Local and federated orchestration receipts must persist operation_unknown as start_unknown, and the exact local terminal must not be transferable.",
|
||||
"invariant": "A public CLI send containing non-empty text plus Enter and no interrupt identifies agent-prompt intent. The provider PTY-input owner grants one generation-aware prompt transaction across renderer, RPC/mobile, orchestration, startup, and internal writers. Admitted user input has priority: it safely closes an open prompt or automation paste, invalidates the unacknowledged prompt, and proceeds without declaring the provider unavailable; unversioned large renderer input cannot stale-invalidate the owner. Complete query replies are deferred only until the paste frame closes, with overflow preempting while preserving reply order. The runtime revalidates the foreground target before at most one Enter, and only fresh lifecycle evidence acknowledges delivery. Any unacknowledged post-byte failure is operation_unknown, and orchestration retains the exact terminal in start_unknown rather than transferring or retrying it.",
|
||||
"oracle": "Withhold OpenCode readiness beyond the old 500 ms deadline, emit a stale pre-attempt marker and then one split fresh marker, and require zero premature Enter plus one lifecycle-acknowledged Enter. A swallowed Enter followed by autonomous redraw output must remain operation_unknown. Missing readiness emits no Enter but remains operation_unknown because paste bytes reached the composer. Current foreground identity must override stale launch metadata, a switch to shell before submit must emit no Enter, and unavailable legacy inspection must retain bounded metadata fallback. Manual input must close an open paste, cancel prompt authority, and proceed without WriteUnavailable recovery; query replies must be delivered after the paste frame. Partial-paste, post-paste, and post-Enter generation reset must remain unknown; an unknown attempt must prevent already-queued prompt delivery. Local and federated receipts persist operation_unknown as start_unknown, and the exact local terminal is not transferable.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/terminal.test.ts src/main/runtime/rpc/terminal-agent-prompt-send.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/agent-prompt-delivery-state-machine.test.ts src/main/runtime/agent-prompt-delivery-outcome.test.ts src/main/runtime/agent-prompt-submission-runtime.test.ts src/main/runtime/agent-prompt-submission-verification.test.ts src/main/runtime/rpc/methods/orchestration-composed-workers.test.ts src/main/runtime/rpc/methods/orchestration-federation-start-receipt.test.ts src/shared/agent-prompt-injection.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/terminal.test.ts src/main/pty-input-transaction.test.ts src/main/ipc/pty-write-ipc-validation.test.ts src/main/runtime/rpc/terminal-agent-prompt-send.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/agent-prompt-delivery-state-machine.test.ts src/main/runtime/agent-prompt-delivery-outcome.test.ts src/main/runtime/agent-prompt-submission-runtime.test.ts src/main/runtime/agent-prompt-submission-verification.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/runtime/rpc/methods/orchestration-composed-workers.test.ts src/main/runtime/rpc/methods/orchestration-federation-start-receipt.test.ts src/shared/agent-prompt-injection.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/terminal.test.ts src/main/runtime/rpc/terminal-agent-prompt-send.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/orca-runtime.test.ts src/shared/agent-prompt-injection.test.ts",
|
||||
"pnpm run build:cli && pnpm exec playwright test tests/e2e/terminal-send-agent-prompt-submit.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"pnpm.cmd exec playwright test tests/e2e/terminal-send-agent-prompt-submit.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
@@ -9785,10 +9758,17 @@
|
||||
"src/main/runtime/rpc/terminal-agent-prompt-send.test.ts",
|
||||
"src/main/runtime/rpc/terminal-send.test.ts",
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/main/pty-input-transaction.test.ts",
|
||||
"src/main/ipc/pty-write-ipc-validation.test.ts",
|
||||
"src/main/runtime/agent-prompt-delivery-state-machine.test.ts",
|
||||
"src/main/runtime/agent-prompt-delivery-outcome.test.ts",
|
||||
"src/main/runtime/agent-prompt-submission-runtime.test.ts",
|
||||
"src/main/runtime/agent-prompt-submission-verification.test.ts",
|
||||
"src/main/runtime/terminal-send-stale-leaf-liveness.test.ts",
|
||||
"src/main/runtime/orchestration-mailbox-notification-consistency.test.ts",
|
||||
"src/main/runtime/orchestration-mailbox-transport-settlement.test.ts",
|
||||
"src/main/runtime/orchestration/mailbox-pointer-state.test.ts",
|
||||
"src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-composed-workers.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-federation-start-receipt.test.ts",
|
||||
"src/shared/agent-prompt-injection.test.ts",
|
||||
@@ -9826,9 +9806,24 @@
|
||||
"stale and split OpenCode markers cannot authorize a premature Enter",
|
||||
"a swallowed Enter plus redraw remains operation_unknown",
|
||||
"current foreground identity owns readiness policy",
|
||||
"direct and prompt input cannot interleave"
|
||||
"generation reset after partial paste, complete paste, or Enter remains operation_unknown",
|
||||
"manual input cancels unacknowledged delivery while query replies remain outside the paste frame",
|
||||
"foreground target changes before submit emit no Enter while unavailable legacy inspection retains fallback",
|
||||
"an unknown attempt prevents already-queued automated prompt delivery"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/pty-write-ipc-validation.test.ts",
|
||||
"assertions": [
|
||||
"renderer input closes and cancels an unacknowledged prompt transaction without provider recovery",
|
||||
"a large renderer write uses generation-neutral admission and closes the prompt before its first chunk",
|
||||
"terminal query replies remain deliverable after the prompt paste frame"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/terminal-send-stale-leaf-liveness.test.ts",
|
||||
"assertions": ["restored leaf-only OpenCode uses fresh readiness instead of fixed delay"]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/agent-prompt-submission-verification.test.ts",
|
||||
"assertions": [
|
||||
@@ -9903,18 +9898,18 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The fake-composer oracle was red against the installed pre-fix runtime with prematureEnters=1 and rescueSent=true. It also caught a source-built late-identity race with a complete paste frame but one premature Enter before the render gate began resolving foreground identity. The final candidate is green on macOS and Windows with prematureEnters=0 and rescueSent=false."
|
||||
"evidence": "The exact deterministic state-machine test against origin/main ab3b1d07 is red at the old 500 ms boundary: OpenCode receives Enter before fresh readiness, and eight of nine candidate assertions fail. The provider-owner rework is locally green in focused tests, including transport-attempt ambiguity, post-byte ownership fencing, admitted-user paste closure, generation-neutral large renderer input, automation framing, and released open-paste retention; final full-gate and fix-disabled evidence is pending. Historical macOS/Windows Electron results apply to the earlier Claude/Codex gate, not this candidate."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Agent prompts remain O(prompt bytes), add one targeted foreground-process read, one PTY-local readiness listener, bounded timers, and the existing 50 ms lifecycle verification poll. Direct terminal sends add two O(1) map operations so an active prompt transaction cannot be interleaved; they add no polling, provider listing, subprocess, scan, or renderer work. Reservations, listeners, and timers are disposed on success, rejection, cancellation, and generation replacement."
|
||||
"evidence": "Agent prompts remain O(prompt bytes), add two targeted foreground-process reads, one PTY-local readiness listener, bounded timers, and the existing 50 ms lifecycle verification poll. The provider arbiter uses O(1) per-PTY ownership lookup; compatible interactive writers share ownership, prompt delivery is exclusive, and query-reply deferral is capped at 64 entries and 4096 code units. Transactions, listeners, and timers are disposed on success, rejection, user preemption, cancellation, and generation replacement. No provider listing, subprocess, scan, or renderer polling is added."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect live Codex and Claude CLI sends on representative slow systems.",
|
||||
"Retain mixed-version evidence that old hosts strip the optional intent and new hosts accept old clients without a protocol bump."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Live SSH/daemon/remote-runtime evidence is not yet attached.",
|
||||
"No live OpenCode, Windows ConPTY, Linux PTY, SSH, daemon, or remote-runtime journey has exercised this candidate.",
|
||||
"Only Claude, Codex, and OpenCode opt into settlement; every other agent retains the legacy behavior until independently validated.",
|
||||
"A local command that produces output without a lifecycle transition remains explicitly operation_unknown; PTY redraw output is insufficient proof of prompt consumption.",
|
||||
"The current terminal-send Electron fixture is red before the product oracle because its fake agent no longer satisfies fresh foreground identity; current candidate evidence is deterministic runtime plus orchestration topology E2E.",
|
||||
@@ -9941,7 +9936,7 @@
|
||||
"providers": ["local", "daemon", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local"],
|
||||
"coverageNotes": "Local macOS evidence covers the runtime PTY write contract, live Codex orchestration, a dev-runtime CLI repro, deterministic OpenCode readiness, strict acknowledgement, and local/federated unknown-outcome ownership. The state machine stays on the PTY-owning runtime and uses the existing operation_unknown/start_unknown contract without a wire change. SSH, daemon, remote-runtime, Linux, and Windows live journeys remain gaps.",
|
||||
"coverageNotes": "Local macOS evidence covers the provider-boundary PTY input transaction, runtime write contract, deterministic tracked and leaf-only OpenCode readiness, final foreground revalidation, strict acknowledgement, user/query arbitration, startup and mailbox prompt migration, queued retry exclusion, and local/federated unknown-outcome ownership. The state machine uses the existing operation_unknown/start_unknown contract without a wire change. Live SSH, daemon, remote-runtime, Linux, and Windows journeys remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/7226",
|
||||
"https://github.com/stablyai/orca/issues/13821",
|
||||
@@ -9950,20 +9945,27 @@
|
||||
"https://linear.app/stably/issue/STA-5122",
|
||||
"https://linear.app/stably/issue/STA-5150"
|
||||
],
|
||||
"invariant": "Injected orchestration prompts must be one exclusive PTY input transaction: one sanitized bracketed-paste frame, current-provider readiness, at most one Enter, and strict lifecycle acknowledgement. Once any prompt byte is written, every unacknowledged exit is operation_unknown. Local and federated orchestration must durably retain the exact terminal in start_unknown, with no transfer or automated retry authority. PTY output alone never proves submission.",
|
||||
"oracle": "The deterministic OpenCode oracle withholds readiness beyond the old delay, ignores a stale marker, emits a split fresh marker, and requires zero premature Enter plus one lifecycle-acknowledged Enter. Independent negative signals require operation_unknown for missing readiness and for swallowed Enter followed by autonomous redraw output. Input exclusion tests prevent direct/prompt interleaving. Local and federated receipt tests require operation_unknown to persist as start_unknown, and the local exact terminal transfer lookup must return no candidate.",
|
||||
"invariant": "Injected orchestration prompts, startup follow-ups, and mailbox pointers use one exclusive PTY prompt transaction: one sanitized bracketed-paste frame, current-provider readiness, pre-submit foreground and ownership revalidation, at most one Enter, and strict lifecycle acknowledgement. Admitted user input safely preempts an unacknowledged prompt after closing its frame; internal automation cannot preempt it; query replies cannot splice into its paste frame. Once any prompt byte is attempted at the provider transport, every later outcome remains operation_unknown and cannot transfer or retry; pre-attempt ownership changes may still redrive. Local and federated worker starts retain the exact terminal in start_unknown. Mailbox delivery durably stages immediately before its first provider write, so persistence failure emits no input and any later rejection or timeout suppresses automatic replay. Provider transport publication is not execution-host receipt; no host acknowledgement or new wire contract is claimed.",
|
||||
"oracle": "The deterministic OpenCode oracle withholds readiness beyond the old delay, ignores a stale marker, emits a split fresh marker, and requires zero premature Enter plus one lifecycle-acknowledged Enter. Independent negative signals require operation_unknown for missing readiness, swallowed Enter plus redraw, foreground change, user preemption, and generation reset after any prompt byte. Query replies are held until the paste frame closes, and a restored leaf-only session uses the same readiness policy. An unknown attempt cancels already-queued prompt authority. Local and federated receipts persist start_unknown; mailbox ownership/status changes prevent Enter, durable staging failure prevents the first provider write, and a rejected or timed-out attempted pointer is not replayed after restart or followed by newer automation.",
|
||||
"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/agent-prompt-delivery-state-machine.test.ts src/main/runtime/agent-prompt-delivery-outcome.test.ts src/main/runtime/agent-prompt-submission-runtime.test.ts src/main/runtime/agent-prompt-submission-verification.test.ts src/main/runtime/rpc/methods/orchestration-composed-workers.test.ts src/main/runtime/rpc/methods/orchestration-federation-start-receipt.test.ts src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/pty-input-transaction.test.ts src/main/ipc/pty-write-ipc-validation.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/agent-prompt-delivery-state-machine.test.ts src/main/runtime/agent-prompt-delivery-outcome.test.ts src/main/runtime/agent-prompt-submission-runtime.test.ts src/main/runtime/agent-prompt-submission-verification.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/runtime/orchestration/mailbox-pointer-state.test.ts src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts src/main/runtime/rpc/methods/orchestration-composed-workers.test.ts src/main/runtime/rpc/methods/orchestration-federation-start-receipt.test.ts src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"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-tasks-dispatch.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"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/shared/agent-prompt-injection.test.ts",
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/main/pty-input-transaction.test.ts",
|
||||
"src/main/ipc/pty-write-ipc-validation.test.ts",
|
||||
"src/main/runtime/agent-prompt-delivery-state-machine.test.ts",
|
||||
"src/main/runtime/agent-prompt-delivery-outcome.test.ts",
|
||||
"src/main/runtime/agent-prompt-submission-runtime.test.ts",
|
||||
"src/main/runtime/agent-prompt-submission-verification.test.ts",
|
||||
"src/main/runtime/terminal-send-stale-leaf-liveness.test.ts",
|
||||
"src/main/runtime/orchestration-mailbox-notification-consistency.test.ts",
|
||||
"src/main/runtime/orchestration-mailbox-transport-settlement.test.ts",
|
||||
"src/main/runtime/orchestration/mailbox-pointer-state.test.ts",
|
||||
"src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-composed-workers.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-federation-start-receipt.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts",
|
||||
@@ -10000,7 +10002,26 @@
|
||||
"one fresh split readiness marker authorizes one Enter",
|
||||
"redraw output after a swallowed Enter remains unknown",
|
||||
"current foreground policy overrides stale launch metadata",
|
||||
"direct input cannot interleave with prompt delivery"
|
||||
"post-mutation generation replacement and pre-submit foreground changes remain unknown",
|
||||
"manual input preempts safely and query replies cannot splice into paste",
|
||||
"unknown delivery revokes queued automated prompt authority"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/pty-input-transaction.test.ts",
|
||||
"assertions": [
|
||||
"interactive input closes the paste, invalidates prompt authority, and proceeds",
|
||||
"bounded query replies flush after paste and generation replacement fences stale tokens"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/pty-write-ipc-validation.test.ts",
|
||||
"assertions": ["real renderer IPC and runtime writes share one input owner"]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orchestration-mailbox-transport-settlement.test.ts",
|
||||
"assertions": [
|
||||
"a mutated but lifecycle-unacknowledged mailbox prompt is not replayed after restart"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -10043,6 +10064,15 @@
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/terminal.test.ts src/main/pty-input-transaction.test.ts src/main/ipc/pty-write-ipc-validation.test.ts src/main/runtime/rpc/terminal-agent-prompt-send.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/agent-prompt-delivery-state-machine.test.ts src/main/runtime/agent-prompt-delivery-outcome.test.ts src/main/runtime/agent-prompt-submission-runtime.test.ts src/main/runtime/agent-prompt-submission-verification.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/runtime/orchestration/mailbox-pointer-state.test.ts src/main/runtime/rpc/methods/orchestration-composed-workers.test.ts src/main/runtime/rpc/methods/orchestration-federation-start-receipt.test.ts src/shared/agent-prompt-injection.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 15,
|
||||
"summary": "Eighteen state-machine, PTY owner, IPC, runtime, RPC, orchestration, and mailbox files passed 1,393 tests with one skip. Durable pre-attempt staging, inactive unknown-watermark fencing, readiness, foreground revalidation, input arbitration, and local/federated unknown ownership all passed."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-14",
|
||||
"runner": "local",
|
||||
@@ -10099,11 +10129,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. 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."
|
||||
"evidence": "The exact OpenCode state-machine test fails on origin/main ab3b1d07 with Enter present at the old 500 ms deadline. The rebased provider-owner candidate is green in focused tests. Disabling readiness rejection or post-mutation operation_unknown wrapping makes the same oracle red; a final full candidate/fix-disabled run is pending. Earlier Claude/Codex framing and quiescence evidence remains historical context."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Agent prompt dispatch remains O(prompt bytes) with existing 16KB chunking. Claude, Codex, and OpenCode add one targeted foreground read and one PTY-local listener until quiescence or the bounded readiness deadline; submission verification keeps the existing bounded lifecycle poll. Direct terminal sends add O(1) reservation bookkeeping only. All reservations, timers, and listeners are disposed on every settlement path. No provider listing, repeated subprocess, hidden-pane wakeup, renderer work, scan, or wire traffic is added."
|
||||
"evidence": "Agent prompt dispatch remains O(prompt bytes) with existing 16KB chunking. Claude, Codex, and OpenCode add initial and pre-submit foreground reads plus one PTY-local listener until quiescence or the bounded readiness deadline; submission verification keeps the existing bounded lifecycle poll. The provider owner adds O(1) per-PTY lookup and bounds deferred query replies to 64 entries and 4096 code units without polling. All transactions, timers, and listeners are disposed on every settlement path. No provider listing, repeated subprocess, hidden-pane wakeup, renderer work, scan, or wire traffic is added."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Run the live harness in soak with a self-starting dev runtime or provider-contract fixture.",
|
||||
@@ -10133,7 +10163,7 @@
|
||||
"orchestration check and acknowledgment",
|
||||
"outstanding Run Deliveries",
|
||||
"runtime restart and durable pointer cursors",
|
||||
"pointer transport settlement"
|
||||
"pointer provider-attempt ambiguity"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
|
||||
@@ -10146,7 +10176,7 @@
|
||||
"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.",
|
||||
"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. Durably stage before the first provider write, fail closed before input if persistence fails, and treat SSH/daemon settlement timeout or rejection as delivery-unknown with no automated replay.",
|
||||
"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",
|
||||
@@ -10204,7 +10234,7 @@
|
||||
"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",
|
||||
"a pre-mutation staged pointer may follow 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",
|
||||
@@ -10255,7 +10285,8 @@
|
||||
{
|
||||
"file": "src/main/runtime/orchestration-mailbox-transport-settlement.test.ts",
|
||||
"assertions": [
|
||||
"a rejected pointer transport stays undelivered and becomes restart-retryable"
|
||||
"a provider transport attempt is durably ambiguous before input and cannot replay after rejection or timeout",
|
||||
"durable staging failure prevents the first provider write"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -10286,6 +10317,15 @@
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"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": 5.72,
|
||||
"summary": "Thirteen mailbox, database, daemon, and SSH files passed 225 tests. Provider rejection/timeout remains conservatively ambiguous after durable staging, while staging failure prevents the first write."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-13",
|
||||
"runner": "local",
|
||||
|
||||
@@ -90,6 +90,85 @@ describe('registerPtyHandlers', () => {
|
||||
).toBe(false)
|
||||
expect(mockProc.proc.write).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('closes prompt paste before accepting renderer input', async () => {
|
||||
const mockProc = createMockProc()
|
||||
spawnMock.mockReturnValue(mockProc.proc)
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
getDriver: vi.fn(() => ({ kind: 'desktop' })),
|
||||
noteTerminalSpawnCommand: vi.fn(),
|
||||
getPtyOutputSequence: vi.fn().mockReturnValue(0),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyData: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => null),
|
||||
preAllocateHandleForPty: vi.fn()
|
||||
}
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
const result = (await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})) as { id: string }
|
||||
const controller = runtime.setPtyController.mock.calls[0]?.[0]
|
||||
const transaction = controller.beginInputTransaction(result.id, 1)
|
||||
mainWindow.webContents.send.mockClear()
|
||||
|
||||
await expect(transaction.write('\x1b[200~prompt')).resolves.toBe(true)
|
||||
getPtyWriteListener()(mainWindowIpcEvent, { id: result.id, data: '\x1b[?0u' })
|
||||
expect(mockProc.proc.write.mock.calls.map(([data]) => data)).toEqual(['\x1b[200~prompt'])
|
||||
getPtyWriteListener()(mainWindowIpcEvent, { id: result.id, data: 'manual' })
|
||||
expect(
|
||||
handlers.get('pty:writeAccepted')!(mainWindowIpcEvent, {
|
||||
id: result.id,
|
||||
data: '\x03'
|
||||
})
|
||||
).toBe(true)
|
||||
await expect(transaction.invalidated).resolves.toBe('terminal_input_superseded')
|
||||
expect(mainWindow.webContents.send).not.toHaveBeenCalledWith(
|
||||
'pty:writeUnavailable',
|
||||
expect.anything()
|
||||
)
|
||||
|
||||
expect(mockProc.proc.write.mock.calls.map(([data]) => data)).toEqual([
|
||||
'\x1b[200~prompt',
|
||||
'\x1b[201~',
|
||||
'\x1b[?0u',
|
||||
'manual',
|
||||
'\x03'
|
||||
])
|
||||
})
|
||||
it('admits a large renderer write without a sentinel generation race', async () => {
|
||||
const mockProc = createMockProc()
|
||||
spawnMock.mockReturnValue(mockProc.proc)
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
getDriver: vi.fn(() => ({ kind: 'desktop' })),
|
||||
noteTerminalSpawnCommand: vi.fn(),
|
||||
getPtyOutputSequence: vi.fn().mockReturnValue(0),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyData: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => null),
|
||||
preAllocateHandleForPty: vi.fn()
|
||||
}
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
const result = (await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})) as { id: string }
|
||||
const controller = runtime.setPtyController.mock.calls[0]?.[0]
|
||||
const prompt = controller.beginInputTransaction(result.id, 1, 'agent-prompt')
|
||||
await expect(prompt.write('\x1b[200~prompt')).resolves.toBe(true)
|
||||
|
||||
const large = 'm'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES + 1)
|
||||
await expect(
|
||||
handlers.get('pty:writeAccepted')!(mainWindowIpcEvent, { id: result.id, data: large })
|
||||
).resolves.toBe(true)
|
||||
await expect(prompt.invalidated).resolves.toBe('terminal_input_superseded')
|
||||
expect(mockProc.proc.write.mock.calls[0]?.[0]).toBe('\x1b[200~prompt')
|
||||
expect(mockProc.proc.write.mock.calls[1]?.[0]).toBe('\x1b[201~')
|
||||
expect(mockProc.proc.write.mock.calls[2]?.[0]).toBe('m'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES))
|
||||
})
|
||||
it('asks the renderer to remount when the provider rejects a stale daemon write', async () => {
|
||||
const write = vi.fn(() => {
|
||||
throw new PtyWriteUnavailableError('daemon generation lost')
|
||||
|
||||
+42
-34
@@ -165,6 +165,7 @@ import { mergePersistedWindowsPath, resolvePathEnvKey } from '../pty/windows-env
|
||||
import { addOrcaWslInteropEnv, stampWslOrchestrationCompatibilityHost } from '../pty/wsl-orca-env'
|
||||
import { resolveCodexShellLaunchPreflightCommand } from '../pty/codex-shell-launch-preflight'
|
||||
import { PtyProducerFlowController } from './pty-producer-flow-control'
|
||||
import { PtyInputTransactionOwner } from '../pty-input-transaction'
|
||||
import { beginTerminalInstall } from './watcher-removal-gate'
|
||||
import {
|
||||
clearHiddenRendererPtyDeliveryState,
|
||||
@@ -4535,6 +4536,28 @@ export function registerPtyHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
const ptyInputTransactions = new PtyInputTransactionOwner(
|
||||
(ptyId, data) => {
|
||||
try {
|
||||
return getProviderForPty(ptyId).write(ptyId, data) !== false
|
||||
} catch (error) {
|
||||
reportUnavailablePtyWrite(ptyId, error)
|
||||
return false
|
||||
}
|
||||
},
|
||||
async (ptyId, data) => {
|
||||
try {
|
||||
const provider = getProviderForPty(ptyId)
|
||||
return provider.writeWithSettlement
|
||||
? await provider.writeWithSettlement(ptyId, data)
|
||||
: provider.write(ptyId, data) !== false
|
||||
} catch (error) {
|
||||
reportUnavailablePtyWrite(ptyId, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Why: route through getProviderForPty() so CLI commands work for remote PTYs too; localProvider would silently fail for them.
|
||||
runtime?.setPtyController({
|
||||
claimStablePaneCreate: (args) => {
|
||||
@@ -5526,23 +5549,11 @@ export function registerPtyHandlers(
|
||||
finishTerminalInstall()
|
||||
}
|
||||
},
|
||||
write: (ptyId, data) => {
|
||||
try {
|
||||
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
|
||||
}
|
||||
},
|
||||
write: (ptyId, data) => ptyInputTransactions.write(ptyId, data),
|
||||
beginInputTransaction: (ptyId, generation, kind) =>
|
||||
ptyInputTransactions.begin(ptyId, generation, kind),
|
||||
invalidateInputTransactions: (ptyId, generation) =>
|
||||
ptyInputTransactions.acknowledgeGeneration(ptyId, generation),
|
||||
probePtyLiveness: async (ptyId) => {
|
||||
try {
|
||||
// Why: no locally routed provider can authoritatively answer for a
|
||||
@@ -7240,36 +7251,29 @@ export function registerPtyHandlers(
|
||||
}
|
||||
|
||||
const writePtyProviderInputWithinLimit = (
|
||||
provider: IPtyProvider,
|
||||
id: string,
|
||||
data: string
|
||||
): boolean | Promise<boolean> => {
|
||||
const chunks = iterateTerminalInputChunks(data)
|
||||
const first = chunks.next()
|
||||
if (first.done) {
|
||||
provider.write(id, data)
|
||||
return true
|
||||
return ptyInputTransactions.write(id, data)
|
||||
}
|
||||
const second = chunks.next()
|
||||
if (second.done) {
|
||||
provider.write(id, first.value)
|
||||
return true
|
||||
return ptyInputTransactions.write(id, first.value)
|
||||
}
|
||||
return writePtyProviderInputChunks(provider, id, chunks, first.value, second.value)
|
||||
return writePtyProviderInputChunks(id, chunks, first.value, second.value)
|
||||
}
|
||||
|
||||
const writePtyProviderInput = (
|
||||
provider: IPtyProvider,
|
||||
id: string,
|
||||
data: string
|
||||
): boolean | Promise<boolean> => {
|
||||
const writePtyProviderInput = (id: string, data: string): boolean | Promise<boolean> => {
|
||||
try {
|
||||
const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data)
|
||||
if (typeof tooLarge === 'boolean') {
|
||||
return tooLarge ? false : writePtyProviderInputWithinLimit(provider, id, data)
|
||||
return tooLarge ? false : writePtyProviderInputWithinLimit(id, data)
|
||||
}
|
||||
return tooLarge
|
||||
.then((result) => (result ? false : writePtyProviderInputWithinLimit(provider, id, data)))
|
||||
.then((result) => (result ? false : writePtyProviderInputWithinLimit(id, data)))
|
||||
.catch((error) => {
|
||||
reportUnavailablePtyWrite(id, error)
|
||||
return false
|
||||
@@ -7281,17 +7285,19 @@ export function registerPtyHandlers(
|
||||
}
|
||||
|
||||
const writePtyProviderInputChunks = async (
|
||||
provider: IPtyProvider,
|
||||
id: string,
|
||||
chunks: Iterator<string>,
|
||||
firstChunk: string,
|
||||
secondChunk: string
|
||||
): Promise<boolean> => {
|
||||
const transaction = ptyInputTransactions.beginUnversionedInteractive(id)
|
||||
try {
|
||||
let chunk: IteratorResult<string> = { done: false, value: firstChunk }
|
||||
let nextChunk: IteratorResult<string> = { done: false, value: secondChunk }
|
||||
while (!chunk.done) {
|
||||
provider.write(id, chunk.value)
|
||||
if (!(await transaction.write(chunk.value))) {
|
||||
return false
|
||||
}
|
||||
if (!nextChunk.done) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
@@ -7302,6 +7308,8 @@ export function registerPtyHandlers(
|
||||
} catch (error) {
|
||||
reportUnavailablePtyWrite(id, error)
|
||||
return false
|
||||
} finally {
|
||||
transaction.release()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7355,7 +7363,7 @@ export function registerPtyHandlers(
|
||||
if (visibleRendererPtys.has(args.id)) {
|
||||
clearHiddenRendererResizeOutput(args.id)
|
||||
}
|
||||
return writePtyProviderInput(provider, args.id, args.data)
|
||||
return writePtyProviderInput(args.id, args.data)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
@@ -7380,7 +7388,7 @@ export function registerPtyHandlers(
|
||||
if (visibleRendererPtys.has(args.id)) {
|
||||
clearHiddenRendererResizeOutput(args.id)
|
||||
}
|
||||
return writePtyProviderInput(provider, args.id, args.data)
|
||||
return writePtyProviderInput(args.id, args.data)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import type {
|
||||
PtyInputInvalidationReason,
|
||||
PtyInputTransaction,
|
||||
PtyInputWriteResult
|
||||
} from './pty-input-transaction'
|
||||
export type GenerationToken = { generation: number | null; active: boolean }
|
||||
export type TransactionToken = GenerationToken & {
|
||||
reason: PtyInputInvalidationReason | null
|
||||
invalidate: (reason: PtyInputInvalidationReason) => void
|
||||
}
|
||||
export type OwnedTokenState<T extends GenerationToken> = {
|
||||
tokens: Set<T>
|
||||
pasteOpen: boolean
|
||||
pasteMutationPending: boolean
|
||||
pendingQueryReplies: string[]
|
||||
pendingQueryReplyCodeUnits: number
|
||||
}
|
||||
export function invalidateLazyGenerations<T extends GenerationToken, R extends string>(
|
||||
lazyByPtyId: Map<string, Set<T>>,
|
||||
ptyId: string,
|
||||
generation: number,
|
||||
except: T | undefined,
|
||||
invalidate: (token: T, reason: R) => void,
|
||||
reason: R
|
||||
): void {
|
||||
for (const token of lazyByPtyId.get(ptyId) ?? []) {
|
||||
if (token !== except && token.generation !== null && token.generation !== generation) {
|
||||
invalidate(token, reason)
|
||||
}
|
||||
}
|
||||
pruneLazyTokens(lazyByPtyId, ptyId)
|
||||
}
|
||||
export function pruneLazyTokens<T extends GenerationToken>(
|
||||
lazyByPtyId: Map<string, Set<T>>,
|
||||
ptyId: string
|
||||
): void {
|
||||
const lazy = lazyByPtyId.get(ptyId)
|
||||
if (!lazy) {
|
||||
return
|
||||
}
|
||||
for (const token of lazy) {
|
||||
if (!token.active) {
|
||||
lazy.delete(token)
|
||||
}
|
||||
}
|
||||
if (lazy.size === 0) {
|
||||
lazyByPtyId.delete(ptyId)
|
||||
}
|
||||
}
|
||||
export function releaseLazyToken<T extends GenerationToken>(
|
||||
lazyByPtyId: Map<string, Set<T>>,
|
||||
ptyId: string,
|
||||
token: T,
|
||||
deactivate: boolean
|
||||
): void {
|
||||
const lazy = lazyByPtyId.get(ptyId)
|
||||
lazy?.delete(token)
|
||||
if (lazy?.size === 0) {
|
||||
lazyByPtyId.delete(ptyId)
|
||||
}
|
||||
if (deactivate) {
|
||||
token.active = false
|
||||
}
|
||||
}
|
||||
export function releaseOwnerToken<T extends GenerationToken>(
|
||||
ownerByPtyId: Map<string, OwnedTokenState<T>>,
|
||||
ptyId: string,
|
||||
owner: OwnedTokenState<T>,
|
||||
token: T,
|
||||
flush: (owner: OwnedTokenState<T>) => boolean
|
||||
): void {
|
||||
if (ownerByPtyId.get(ptyId) !== owner || !owner.tokens.delete(token)) {
|
||||
return
|
||||
}
|
||||
token.active = false
|
||||
if (owner.tokens.size > 0 || owner.pasteOpen || owner.pasteMutationPending) {
|
||||
return
|
||||
}
|
||||
if (flush(owner)) {
|
||||
ownerByPtyId.delete(ptyId)
|
||||
}
|
||||
}
|
||||
export function invalidateOwner<T extends GenerationToken, R extends string>(
|
||||
ownerByPtyId: Map<string, OwnedTokenState<T>>,
|
||||
ptyId: string,
|
||||
owner: OwnedTokenState<T>,
|
||||
reason: R,
|
||||
invalidate: (token: T, reason: R) => void
|
||||
): void {
|
||||
if (ownerByPtyId.get(ptyId) !== owner) {
|
||||
return
|
||||
}
|
||||
ownerByPtyId.delete(ptyId)
|
||||
for (const token of owner.tokens) {
|
||||
invalidate(token, reason)
|
||||
}
|
||||
owner.tokens.clear()
|
||||
}
|
||||
export function createTransactionToken(generation: number | null): TransactionToken {
|
||||
return { generation, active: true, reason: null, invalidate: () => undefined }
|
||||
}
|
||||
export function transactionForToken(
|
||||
token: TransactionToken,
|
||||
operations: { write: (data: string) => PtyInputWriteResult; release: () => void }
|
||||
): PtyInputTransaction {
|
||||
return {
|
||||
write: operations.write,
|
||||
invalidated: new Promise<PtyInputInvalidationReason>((resolve) => {
|
||||
token.invalidate = resolve
|
||||
}),
|
||||
get active() {
|
||||
return token.active
|
||||
},
|
||||
get invalidationReason() {
|
||||
return token.reason
|
||||
},
|
||||
release: operations.release
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
AGENT_PROMPT_BRACKETED_PASTE_END,
|
||||
AGENT_PROMPT_BRACKETED_PASTE_START
|
||||
} from '../shared/agent-prompt-injection'
|
||||
import { extractOnlyTerminalQueryReplies } from '../shared/terminal-query-reply'
|
||||
export const MAX_PENDING_QUERY_REPLIES = 64
|
||||
export const MAX_PENDING_QUERY_REPLY_CODE_UNITS = 4096
|
||||
export function isPtyInputTransactionQueryReply(data: string): boolean {
|
||||
return (
|
||||
data.length <= MAX_PENDING_QUERY_REPLY_CODE_UNITS &&
|
||||
extractOnlyTerminalQueryReplies(data) !== null
|
||||
)
|
||||
}
|
||||
export type PendingQueryReplyState = {
|
||||
pendingQueryReplies: string[]
|
||||
pendingQueryReplyCodeUnits: number
|
||||
}
|
||||
export function canQueueQueryReply(state: PendingQueryReplyState, reply: string): boolean {
|
||||
return (
|
||||
state.pendingQueryReplies.length < MAX_PENDING_QUERY_REPLIES &&
|
||||
state.pendingQueryReplyCodeUnits + reply.length <= MAX_PENDING_QUERY_REPLY_CODE_UNITS
|
||||
)
|
||||
}
|
||||
export function enqueueQueryReply(state: PendingQueryReplyState, reply: string): void {
|
||||
state.pendingQueryReplies.push(reply)
|
||||
state.pendingQueryReplyCodeUnits += reply.length
|
||||
}
|
||||
export function flushQueryReplies(
|
||||
state: PendingQueryReplyState,
|
||||
write: (data: string) => boolean
|
||||
): boolean {
|
||||
while (state.pendingQueryReplies.length > 0) {
|
||||
const reply = state.pendingQueryReplies[0]
|
||||
if (!write(reply)) {
|
||||
return false
|
||||
}
|
||||
state.pendingQueryReplies.shift()
|
||||
state.pendingQueryReplyCodeUnits -= reply.length
|
||||
}
|
||||
state.pendingQueryReplyCodeUnits = 0
|
||||
return true
|
||||
}
|
||||
export function nextPasteState(initial: boolean, data: string): boolean {
|
||||
let open = initial
|
||||
let cursor = 0
|
||||
while (cursor < data.length) {
|
||||
const start = data.indexOf(AGENT_PROMPT_BRACKETED_PASTE_START, cursor)
|
||||
const end = data.indexOf(AGENT_PROMPT_BRACKETED_PASTE_END, cursor)
|
||||
if (start === -1 && end === -1) {
|
||||
break
|
||||
}
|
||||
if (start !== -1 && (end === -1 || start < end)) {
|
||||
open = true
|
||||
cursor = start + AGENT_PROMPT_BRACKETED_PASTE_START.length
|
||||
} else {
|
||||
open = false
|
||||
cursor = end + AGENT_PROMPT_BRACKETED_PASTE_END.length
|
||||
}
|
||||
}
|
||||
return open
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { PtyInputTransactionOwner, type PtyInputWriteResult } from './pty-input-transaction'
|
||||
|
||||
const START = '\x1b[200~'
|
||||
const END = '\x1b[201~'
|
||||
|
||||
describe('PTY input transaction owner', () => {
|
||||
it('keeps the unowned per-keystroke path as one direct provider write', () => {
|
||||
const writer = vi.fn(() => true)
|
||||
const owner = new PtyInputTransactionOwner(writer)
|
||||
|
||||
expect(owner.write('pty-1', 'a')).toBe(true)
|
||||
expect(writer).toHaveBeenCalledOnce()
|
||||
expect(writer).toHaveBeenCalledWith('pty-1', 'a')
|
||||
})
|
||||
|
||||
it('defers interactive admission until its first write', async () => {
|
||||
const writer = vi.fn(() => true)
|
||||
const owner = new PtyInputTransactionOwner(writer)
|
||||
const prompt = owner.begin('pty-1', 1, 'agent-prompt')!
|
||||
prompt.write(`${START}prompt`)
|
||||
|
||||
const interactive = owner.begin('pty-1', 1, 'interactive')!
|
||||
expect(prompt.active).toBe(true)
|
||||
expect(writer).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(interactive.write('manual')).toBe(true)
|
||||
expect(prompt.active).toBe(false)
|
||||
expect(prompt.invalidationReason).toBe('terminal_input_superseded')
|
||||
await expect(prompt.invalidated).resolves.toBe('terminal_input_superseded')
|
||||
expect(writer.mock.calls).toEqual([
|
||||
['pty-1', `${START}prompt`],
|
||||
['pty-1', END],
|
||||
['pty-1', 'manual']
|
||||
])
|
||||
})
|
||||
|
||||
it('uses provider settlement and exposes lifecycle invalidation synchronously', async () => {
|
||||
let settle!: (accepted: boolean) => void
|
||||
const settledWriter = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
settle = resolve
|
||||
})
|
||||
)
|
||||
const owner = new PtyInputTransactionOwner(
|
||||
vi.fn(() => true),
|
||||
settledWriter
|
||||
)
|
||||
const prompt = owner.begin('pty-1', 1, 'agent-prompt')!
|
||||
|
||||
const write = prompt.write('prompt')
|
||||
expect(settledWriter).toHaveBeenCalledWith('pty-1', 'prompt')
|
||||
owner.acknowledgeGeneration('pty-1', 2)
|
||||
expect(prompt.active).toBe(false)
|
||||
expect(prompt.invalidationReason).toBe('terminal_handle_stale')
|
||||
settle(true)
|
||||
|
||||
await expect(write).resolves.toBe(true)
|
||||
await expect(prompt.invalidated).resolves.toBe('terminal_handle_stale')
|
||||
})
|
||||
|
||||
it('updates framing before reentrant replies and rolls it back on rejection', async () => {
|
||||
const writes: string[] = []
|
||||
let owner!: PtyInputTransactionOwner
|
||||
const settledWriter = vi.fn((_ptyId: string, data: string): PtyInputWriteResult => {
|
||||
writes.push(data)
|
||||
if (data === `${START}prompt`) {
|
||||
expect(owner.write('pty-1', '\x1b[?0u')).toBe(true)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
const directWriter = vi.fn((_ptyId: string, data: string) => {
|
||||
writes.push(data)
|
||||
return true
|
||||
})
|
||||
owner = new PtyInputTransactionOwner(directWriter, settledWriter)
|
||||
const prompt = owner.begin('pty-1', 1, 'agent-prompt')!
|
||||
|
||||
expect(prompt.write(`${START}prompt`)).toBe(false)
|
||||
expect(writes).toEqual([`${START}prompt`, '\x1b[?0u'])
|
||||
expect(owner.write('pty-1', 'manual')).toBe(true)
|
||||
await expect(prompt.invalidated).resolves.toBe('terminal_input_superseded')
|
||||
expect(writes).toEqual([`${START}prompt`, '\x1b[?0u', 'manual'])
|
||||
})
|
||||
|
||||
it('retains a reentrant reply when paste close is rejected', async () => {
|
||||
const writes: string[] = []
|
||||
let owner!: PtyInputTransactionOwner
|
||||
let rejectClose = true
|
||||
const settledWriter = vi.fn((_ptyId: string, data: string): PtyInputWriteResult => {
|
||||
writes.push(data)
|
||||
if (data === END && rejectClose) {
|
||||
expect(owner.write('pty-1', '\x1b[6n')).toBe(true)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
owner = new PtyInputTransactionOwner((_ptyId, data) => {
|
||||
writes.push(data)
|
||||
return true
|
||||
}, settledWriter)
|
||||
const prompt = owner.begin('pty-1', 1, 'agent-prompt')!
|
||||
expect(prompt.write(`${START}prompt`)).toBe(true)
|
||||
|
||||
expect(prompt.write(END)).toBe(false)
|
||||
expect(writes).toEqual([`${START}prompt`, END])
|
||||
rejectClose = false
|
||||
expect(owner.write('pty-1', 'manual')).toBe(true)
|
||||
await expect(prompt.invalidated).resolves.toBe('terminal_input_superseded')
|
||||
expect(writes).toEqual([`${START}prompt`, END, END, '\x1b[6n', 'manual'])
|
||||
})
|
||||
|
||||
it('blocks manual input when closing an open paste is rejected', () => {
|
||||
const writes: string[] = []
|
||||
const writer = (_ptyId: string, data: string): boolean => {
|
||||
writes.push(data)
|
||||
return data !== END
|
||||
}
|
||||
const owner = new PtyInputTransactionOwner(writer, writer)
|
||||
const prompt = owner.begin('pty-1', 1, 'agent-prompt')!
|
||||
expect(prompt.write(`${START}prompt`)).toBe(true)
|
||||
|
||||
expect(owner.write('pty-1', 'manual')).toBe(false)
|
||||
expect(prompt.active).toBe(true)
|
||||
expect(writes).toEqual([`${START}prompt`, END])
|
||||
})
|
||||
|
||||
it('closes and preempts instead of dropping a query reply on queue overflow', async () => {
|
||||
const writes: string[] = []
|
||||
const owner = new PtyInputTransactionOwner((_ptyId, data) => {
|
||||
writes.push(data)
|
||||
return true
|
||||
})
|
||||
const prompt = owner.begin('pty-1', 1, 'agent-prompt')!
|
||||
prompt.write(`${START}prompt`)
|
||||
const replies = Array.from({ length: 65 }, (_, index) => `\x1b[${index + 1};1R`)
|
||||
|
||||
for (const reply of replies) {
|
||||
expect(owner.write('pty-1', reply)).toBe(true)
|
||||
}
|
||||
|
||||
await expect(prompt.invalidated).resolves.toBe('terminal_input_superseded')
|
||||
expect(writes).toEqual([`${START}prompt`, END, ...replies])
|
||||
})
|
||||
|
||||
it('gives external input priority over an automation transaction', async () => {
|
||||
const writer = vi.fn(() => true)
|
||||
const owner = new PtyInputTransactionOwner(writer)
|
||||
const automation = owner.begin('pty-1', 1, 'automation')!
|
||||
|
||||
expect(owner.write('pty-1', 'manual')).toBe(true)
|
||||
expect(automation.active).toBe(false)
|
||||
await expect(automation.invalidated).resolves.toBe('terminal_input_superseded')
|
||||
expect(automation.write('stale')).toBe(false)
|
||||
})
|
||||
|
||||
it('owns paste framing for automation and closes it before external input', async () => {
|
||||
const writes: string[] = []
|
||||
const owner = new PtyInputTransactionOwner((_ptyId, data) => {
|
||||
writes.push(data)
|
||||
return true
|
||||
})
|
||||
const automation = owner.begin('pty-1', 1, 'automation')!
|
||||
expect(automation.write(`${START}draft`)).toBe(true)
|
||||
expect(owner.write('pty-1', 'manual')).toBe(true)
|
||||
|
||||
await expect(automation.invalidated).resolves.toBe('terminal_input_superseded')
|
||||
expect(writes).toEqual([`${START}draft`, END, 'manual'])
|
||||
})
|
||||
|
||||
it('retains released paste framing until it can be closed', () => {
|
||||
const writes: string[] = []
|
||||
let rejectClose = true
|
||||
const owner = new PtyInputTransactionOwner((_ptyId, data) => {
|
||||
writes.push(data)
|
||||
return data !== END || !rejectClose
|
||||
})
|
||||
const automation = owner.begin('pty-1', 1, 'automation')!
|
||||
expect(automation.write(`${START}draft`)).toBe(true)
|
||||
automation.release()
|
||||
|
||||
expect(owner.write('pty-1', 'first')).toBe(false)
|
||||
rejectClose = false
|
||||
expect(owner.write('pty-1', 'second')).toBe(true)
|
||||
expect(writes).toEqual([`${START}draft`, END, END, 'second'])
|
||||
})
|
||||
|
||||
it('lets unversioned renderer input safely preempt a versioned prompt', async () => {
|
||||
const writes: string[] = []
|
||||
const owner = new PtyInputTransactionOwner((_ptyId, data) => {
|
||||
writes.push(data)
|
||||
return true
|
||||
})
|
||||
const prompt = owner.begin('pty-1', 7, 'agent-prompt')!
|
||||
expect(prompt.write(`${START}prompt`)).toBe(true)
|
||||
const renderer = owner.beginUnversionedInteractive('pty-1')
|
||||
|
||||
expect(renderer.write('manual-1')).toBe(true)
|
||||
expect(renderer.write('manual-2')).toBe(true)
|
||||
|
||||
await expect(prompt.invalidated).resolves.toBe('terminal_input_superseded')
|
||||
expect(writes).toEqual([`${START}prompt`, END, 'manual-1', 'manual-2'])
|
||||
})
|
||||
|
||||
it('does not let internal automation preempt a prompt', () => {
|
||||
const owner = new PtyInputTransactionOwner(() => true)
|
||||
const prompt = owner.begin('pty-1', 1, 'agent-prompt')!
|
||||
|
||||
expect(owner.begin('pty-1', 1, 'automation')).toBeNull()
|
||||
expect(prompt.active).toBe(true)
|
||||
})
|
||||
|
||||
it('joins interactive transactions only when each attempts a write', () => {
|
||||
const owner = new PtyInputTransactionOwner(() => true)
|
||||
const first = owner.begin('pty-1', 1, 'interactive')!
|
||||
const second = owner.begin('pty-1', 1, 'interactive')!
|
||||
|
||||
expect(first.write('a')).toBe(true)
|
||||
expect(second.write('b')).toBe(true)
|
||||
first.release()
|
||||
expect(second.write('c')).toBe(true)
|
||||
})
|
||||
|
||||
it('invalidates lazy and admitted transactions on lifecycle acknowledgement', async () => {
|
||||
const owner = new PtyInputTransactionOwner(() => true)
|
||||
const lazy = owner.begin('pty-1', 1, 'interactive')!
|
||||
const prompt = owner.begin('pty-2', 1, 'agent-prompt')!
|
||||
|
||||
owner.acknowledgeGeneration('pty-1', 2)
|
||||
owner.acknowledgeGeneration('pty-2', 2)
|
||||
|
||||
expect(lazy.active).toBe(false)
|
||||
expect(prompt.active).toBe(false)
|
||||
await expect(lazy.invalidated).resolves.toBe('terminal_handle_stale')
|
||||
await expect(prompt.invalidated).resolves.toBe('terminal_handle_stale')
|
||||
expect(lazy.write('stale')).toBe(false)
|
||||
expect(prompt.write('stale')).toBe(false)
|
||||
})
|
||||
|
||||
it('invalidates an older lazy writer before a newer generation claims the PTY', async () => {
|
||||
const owner = new PtyInputTransactionOwner(() => true)
|
||||
const stale = owner.begin('pty-1', 1, 'interactive')!
|
||||
const current = owner.begin('pty-1', 2, 'interactive')!
|
||||
|
||||
await expect(stale.invalidated).resolves.toBe('terminal_handle_stale')
|
||||
expect(stale.write('stale')).toBe(false)
|
||||
expect(current.write('current')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not let a stale release clear a newer transaction', () => {
|
||||
const owner = new PtyInputTransactionOwner(() => true)
|
||||
const first = owner.begin('pty-1', 1, 'automation')!
|
||||
owner.acknowledgeGeneration('pty-1', 2)
|
||||
const second = owner.begin('pty-1', 2, 'automation')!
|
||||
|
||||
first.release()
|
||||
|
||||
expect(first.write('stale')).toBe(false)
|
||||
expect(second.write('current')).toBe(true)
|
||||
})
|
||||
|
||||
it('isolates ownership by PTY id', () => {
|
||||
const owner = new PtyInputTransactionOwner(() => true)
|
||||
const transaction = owner.begin('pty-1', 1, 'agent-prompt')!
|
||||
|
||||
expect(owner.write('pty-2', 'manual')).toBe(true)
|
||||
expect(owner.begin('pty-2', 1, 'automation')).not.toBeNull()
|
||||
transaction.release()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,295 @@
|
||||
import { AGENT_PROMPT_BRACKETED_PASTE_END } from '../shared/agent-prompt-injection'
|
||||
import {
|
||||
canQueueQueryReply,
|
||||
enqueueQueryReply,
|
||||
flushQueryReplies,
|
||||
nextPasteState,
|
||||
isPtyInputTransactionQueryReply
|
||||
} from './pty-input-transaction-state'
|
||||
export { isPtyInputTransactionQueryReply } from './pty-input-transaction-state'
|
||||
import {
|
||||
createTransactionToken,
|
||||
invalidateLazyGenerations,
|
||||
invalidateOwner,
|
||||
pruneLazyTokens,
|
||||
releaseLazyToken,
|
||||
releaseOwnerToken,
|
||||
transactionForToken
|
||||
} from './pty-input-transaction-lifecycle'
|
||||
import type { TransactionToken } from './pty-input-transaction-lifecycle'
|
||||
export type PtyInputTransactionKind = 'agent-prompt' | 'automation' | 'interactive'
|
||||
export type PtyInputInvalidationReason = 'terminal_handle_stale' | 'terminal_input_superseded'
|
||||
export type PtyInputWriteResult = boolean | Promise<boolean>
|
||||
export type PtyInputTransaction = Readonly<{
|
||||
write: (data: string) => PtyInputWriteResult
|
||||
invalidated: Promise<PtyInputInvalidationReason>
|
||||
readonly active: boolean
|
||||
readonly invalidationReason: PtyInputInvalidationReason | null
|
||||
release: () => void
|
||||
}>
|
||||
type PtyInputWriter = (ptyId: string, data: string) => boolean
|
||||
type SettledPtyInputWriter = (ptyId: string, data: string) => PtyInputWriteResult
|
||||
type InputToken = TransactionToken
|
||||
type InputOwner = {
|
||||
generation: number | null
|
||||
kind: PtyInputTransactionKind
|
||||
tokens: Set<InputToken>
|
||||
pasteOpen: boolean
|
||||
pasteMutationPending: boolean
|
||||
pendingQueryReplies: string[]
|
||||
pendingQueryReplyCodeUnits: number
|
||||
}
|
||||
export class PtyInputTransactionOwner {
|
||||
private readonly ownerByPtyId = new Map<string, InputOwner>()
|
||||
private readonly lazyInteractiveByPtyId = new Map<string, Set<InputToken>>()
|
||||
constructor(
|
||||
private readonly writeInput: PtyInputWriter,
|
||||
private readonly writeInputWithSettlement: SettledPtyInputWriter = writeInput
|
||||
) {}
|
||||
write(ptyId: string, data: string): boolean {
|
||||
const owner = this.ownerByPtyId.get(ptyId)
|
||||
if (!owner || owner.kind === 'interactive') {
|
||||
return this.writeInput(ptyId, data)
|
||||
}
|
||||
if (isPtyInputTransactionQueryReply(data)) {
|
||||
if (!owner.pasteOpen && !owner.pasteMutationPending) {
|
||||
return this.writeInput(ptyId, data)
|
||||
}
|
||||
if (canQueueQueryReply(owner, data)) {
|
||||
enqueueQueryReply(owner, data)
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (!this.interruptOwner(ptyId, owner)) {
|
||||
return false
|
||||
}
|
||||
return this.writeInput(ptyId, data)
|
||||
}
|
||||
begin(
|
||||
ptyId: string,
|
||||
generation: number,
|
||||
kind: PtyInputTransactionKind = 'agent-prompt'
|
||||
): PtyInputTransaction | null {
|
||||
if (kind === 'interactive') {
|
||||
if (generation !== null) {
|
||||
invalidateLazyGenerations(
|
||||
this.lazyInteractiveByPtyId,
|
||||
ptyId,
|
||||
generation,
|
||||
undefined,
|
||||
(token, reason) => this.invalidateToken(token, reason),
|
||||
'terminal_handle_stale'
|
||||
)
|
||||
}
|
||||
return this.beginLazyInteractive(ptyId, generation)
|
||||
}
|
||||
if (this.ownerByPtyId.has(ptyId)) {
|
||||
return null
|
||||
}
|
||||
invalidateLazyGenerations(
|
||||
this.lazyInteractiveByPtyId,
|
||||
ptyId,
|
||||
generation,
|
||||
undefined,
|
||||
(token, reason) => this.invalidateToken(token, reason),
|
||||
'terminal_handle_stale'
|
||||
)
|
||||
const owner: InputOwner = {
|
||||
generation,
|
||||
kind,
|
||||
tokens: new Set(),
|
||||
pasteOpen: false,
|
||||
pasteMutationPending: false,
|
||||
pendingQueryReplies: [],
|
||||
pendingQueryReplyCodeUnits: 0
|
||||
}
|
||||
this.ownerByPtyId.set(ptyId, owner)
|
||||
return this.joinOwner(ptyId, owner)
|
||||
}
|
||||
beginUnversionedInteractive(ptyId: string): PtyInputTransaction {
|
||||
return this.beginLazyInteractive(ptyId, null)
|
||||
}
|
||||
acknowledgeGeneration(ptyId: string, generation: number): void {
|
||||
const owner = this.ownerByPtyId.get(ptyId)
|
||||
if (owner && (owner.generation === null || owner.generation !== generation)) {
|
||||
invalidateOwner(this.ownerByPtyId, ptyId, owner, 'terminal_handle_stale', (token, reason) =>
|
||||
this.invalidateToken(token, reason)
|
||||
)
|
||||
}
|
||||
for (const token of this.lazyInteractiveByPtyId.get(ptyId) ?? []) {
|
||||
if (token.active && (token.generation === null || token.generation !== generation)) {
|
||||
this.invalidateToken(token, 'terminal_handle_stale')
|
||||
}
|
||||
}
|
||||
pruneLazyTokens(this.lazyInteractiveByPtyId, ptyId)
|
||||
}
|
||||
private beginLazyInteractive(ptyId: string, generation: number | null): PtyInputTransaction {
|
||||
const token = createTransactionToken(generation)
|
||||
const lazy = this.lazyInteractiveByPtyId.get(ptyId) ?? new Set<InputToken>()
|
||||
lazy.add(token)
|
||||
this.lazyInteractiveByPtyId.set(ptyId, lazy)
|
||||
let joinedOwner: InputOwner | null = null
|
||||
return transactionForToken(token, {
|
||||
write: (data) => {
|
||||
if (!token.active) {
|
||||
return false
|
||||
}
|
||||
if (!joinedOwner) {
|
||||
joinedOwner = this.claimInteractive(ptyId, generation, token)
|
||||
if (!joinedOwner) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return this.writeOwned(ptyId, joinedOwner, token, data)
|
||||
},
|
||||
release: () => {
|
||||
if (joinedOwner) {
|
||||
this.releaseOwnerToken(ptyId, joinedOwner, token)
|
||||
} else {
|
||||
releaseLazyToken(this.lazyInteractiveByPtyId, ptyId, token, true)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
private claimInteractive(
|
||||
ptyId: string,
|
||||
generation: number | null,
|
||||
lazyToken: InputToken
|
||||
): InputOwner | null {
|
||||
const current = this.ownerByPtyId.get(ptyId)
|
||||
if (
|
||||
current &&
|
||||
current.generation !== null &&
|
||||
generation !== null &&
|
||||
current.generation !== generation
|
||||
) {
|
||||
releaseLazyToken(this.lazyInteractiveByPtyId, ptyId, lazyToken, false)
|
||||
this.invalidateToken(lazyToken, 'terminal_handle_stale')
|
||||
return null
|
||||
}
|
||||
if (current && current.kind !== 'interactive' && !this.interruptOwner(ptyId, current)) {
|
||||
return null
|
||||
}
|
||||
let owner = this.ownerByPtyId.get(ptyId)
|
||||
if (!owner) {
|
||||
owner = {
|
||||
generation,
|
||||
kind: 'interactive',
|
||||
tokens: new Set(),
|
||||
pasteOpen: false,
|
||||
pasteMutationPending: false,
|
||||
pendingQueryReplies: [],
|
||||
pendingQueryReplyCodeUnits: 0
|
||||
}
|
||||
this.ownerByPtyId.set(ptyId, owner)
|
||||
if (generation !== null) {
|
||||
invalidateLazyGenerations(
|
||||
this.lazyInteractiveByPtyId,
|
||||
ptyId,
|
||||
generation,
|
||||
lazyToken,
|
||||
(token, reason) => this.invalidateToken(token, reason),
|
||||
'terminal_handle_stale'
|
||||
)
|
||||
}
|
||||
}
|
||||
if (
|
||||
owner.kind !== 'interactive' ||
|
||||
(owner.generation !== null && generation !== null && owner.generation !== generation)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
releaseLazyToken(this.lazyInteractiveByPtyId, ptyId, lazyToken, false)
|
||||
owner.tokens.add(lazyToken)
|
||||
return owner
|
||||
}
|
||||
private joinOwner(ptyId: string, owner: InputOwner): PtyInputTransaction {
|
||||
const token = createTransactionToken(owner.generation)
|
||||
owner.tokens.add(token)
|
||||
return transactionForToken(token, {
|
||||
write: (data) => this.writeOwned(ptyId, owner, token, data),
|
||||
release: () => this.releaseOwnerToken(ptyId, owner, token)
|
||||
})
|
||||
}
|
||||
private writeOwned(
|
||||
ptyId: string,
|
||||
owner: InputOwner,
|
||||
token: InputToken,
|
||||
data: string
|
||||
): PtyInputWriteResult {
|
||||
if (!token.active || this.ownerByPtyId.get(ptyId) !== owner || !owner.tokens.has(token)) {
|
||||
return false
|
||||
}
|
||||
const pasteWasOpen = owner.pasteOpen
|
||||
const pasteWillBeOpen = nextPasteState(pasteWasOpen, data)
|
||||
owner.pasteMutationPending = true
|
||||
if (pasteWillBeOpen) {
|
||||
owner.pasteOpen = true
|
||||
}
|
||||
let result: PtyInputWriteResult
|
||||
try {
|
||||
result =
|
||||
owner.kind === 'agent-prompt'
|
||||
? this.writeInputWithSettlement(ptyId, data)
|
||||
: this.writeInput(ptyId, data)
|
||||
} catch {
|
||||
this.finishSettledWrite(ptyId, owner, pasteWasOpen, pasteWillBeOpen, false)
|
||||
return false
|
||||
}
|
||||
if (result instanceof Promise) {
|
||||
return result.then(
|
||||
(accepted) =>
|
||||
this.finishSettledWrite(ptyId, owner, pasteWasOpen, pasteWillBeOpen, accepted),
|
||||
() => this.finishSettledWrite(ptyId, owner, pasteWasOpen, pasteWillBeOpen, false)
|
||||
)
|
||||
}
|
||||
return this.finishSettledWrite(ptyId, owner, pasteWasOpen, pasteWillBeOpen, result)
|
||||
}
|
||||
private finishSettledWrite(
|
||||
ptyId: string,
|
||||
owner: InputOwner,
|
||||
pasteWasOpen: boolean,
|
||||
pasteWillBeOpen: boolean,
|
||||
accepted: boolean
|
||||
): boolean {
|
||||
if (this.ownerByPtyId.get(ptyId) !== owner) {
|
||||
return accepted
|
||||
}
|
||||
owner.pasteMutationPending = false
|
||||
owner.pasteOpen = accepted ? pasteWillBeOpen : pasteWasOpen
|
||||
if (!owner.pasteOpen) {
|
||||
flushQueryReplies(owner, (reply) => this.writeInput(ptyId, reply))
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
private interruptOwner(ptyId: string, owner: InputOwner): boolean {
|
||||
if (
|
||||
(owner.pasteOpen || owner.pasteMutationPending) &&
|
||||
!this.writeInput(ptyId, AGENT_PROMPT_BRACKETED_PASTE_END)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
owner.pasteOpen = false
|
||||
owner.pasteMutationPending = false
|
||||
if (!flushQueryReplies(owner, (reply) => this.writeInput(ptyId, reply))) {
|
||||
return false
|
||||
}
|
||||
invalidateOwner(this.ownerByPtyId, ptyId, owner, 'terminal_input_superseded', (token, reason) =>
|
||||
this.invalidateToken(token, reason)
|
||||
)
|
||||
return true
|
||||
}
|
||||
private releaseOwnerToken(ptyId: string, owner: InputOwner, token: InputToken): void {
|
||||
releaseOwnerToken(this.ownerByPtyId, ptyId, owner, token, (current) =>
|
||||
flushQueryReplies(current, (reply) => this.writeInput(ptyId, reply))
|
||||
)
|
||||
}
|
||||
private invalidateToken(token: InputToken, reason: PtyInputInvalidationReason): void {
|
||||
if (!token.active) {
|
||||
return
|
||||
}
|
||||
token.active = false
|
||||
token.reason = reason
|
||||
token.invalidate(reason)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AgentPromptDeliveryUnknownError,
|
||||
agentPromptDeliveryBecameUnknown
|
||||
agentPromptDeliveryBecameUnknown,
|
||||
isAgentPromptDeliveryUnknownError
|
||||
} from './agent-prompt-delivery-outcome'
|
||||
|
||||
describe('agent prompt delivery outcome', () => {
|
||||
@@ -19,5 +20,6 @@ describe('agent prompt delivery outcome', () => {
|
||||
const error = new AgentPromptDeliveryUnknownError('agent_prompt_not_ready')
|
||||
|
||||
expect(agentPromptDeliveryBecameUnknown(error)).toBe(error)
|
||||
expect(isAgentPromptDeliveryUnknownError(error)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,3 +15,9 @@ export function agentPromptDeliveryBecameUnknown(error: unknown): AgentPromptDel
|
||||
}
|
||||
return new AgentPromptDeliveryUnknownError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
|
||||
export function isAgentPromptDeliveryUnknownError(
|
||||
error: unknown
|
||||
): error is AgentPromptDeliveryUnknownError {
|
||||
return error instanceof AgentPromptDeliveryUnknownError
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
AGENT_PROMPT_SUBMIT_DELAY_MS
|
||||
} from '../../shared/agent-prompt-injection'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import { PtyInputTransactionOwner } from '../pty-input-transaction'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { makeStore } from './runtime-rpc-worktree-store-fixtures'
|
||||
|
||||
@@ -33,19 +34,31 @@ vi.mock('../git/worktree', () => ({
|
||||
async function createPromptRuntime(options: {
|
||||
launchAgent: TuiAgent
|
||||
foregroundAgent?: TuiAgent
|
||||
foregroundProcess?: string | (() => string | null | Promise<string | null>)
|
||||
inspectProcess?: () => {
|
||||
foregroundProcess: string | null
|
||||
hasChildProcesses: boolean
|
||||
unavailable?: true
|
||||
}
|
||||
onWrite?: (runtime: OrcaRuntimeService, data: string) => void
|
||||
}): Promise<{ runtime: OrcaRuntimeService; handle: string; writes: string[] }> {
|
||||
const runtime = new OrcaRuntimeService(makeStore() as never)
|
||||
const writes: string[] = []
|
||||
const inputOwner = new PtyInputTransactionOwner((_ptyId, data) => {
|
||||
writes.push(data)
|
||||
options.onWrite?.(runtime, data)
|
||||
return true
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }),
|
||||
write: (_ptyId, data) => {
|
||||
writes.push(data)
|
||||
options.onWrite?.(runtime, data)
|
||||
return true
|
||||
},
|
||||
write: (ptyId, data) => inputOwner.write(ptyId, data),
|
||||
beginInputTransaction: (ptyId, generation, kind) => inputOwner.begin(ptyId, generation, kind),
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => options.foregroundAgent ?? options.launchAgent
|
||||
inspectProcess: options.inspectProcess ? async () => options.inspectProcess!() : undefined,
|
||||
getForegroundProcess: async () =>
|
||||
typeof options.foregroundProcess === 'function'
|
||||
? options.foregroundProcess()
|
||||
: (options.foregroundProcess ?? options.foregroundAgent ?? options.launchAgent)
|
||||
})
|
||||
const terminal = await runtime.createTerminal(`path:${WORKTREE_PATH}`, {
|
||||
launchAgent: options.launchAgent
|
||||
@@ -193,7 +206,271 @@ describe('agent prompt delivery state machine', () => {
|
||||
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects direct input while an agent prompt owns PTY input', async () => {
|
||||
it('rejects a current shell instead of using stale OpenCode metadata', async () => {
|
||||
const { runtime, handle, writes } = await createPromptRuntime({
|
||||
launchAgent: 'opencode',
|
||||
foregroundProcess: 'zsh'
|
||||
})
|
||||
|
||||
await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow(
|
||||
'agent_prompt_target_changed'
|
||||
)
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects an authoritative missing foreground before writing', async () => {
|
||||
const { runtime, handle, writes } = await createPromptRuntime({
|
||||
launchAgent: 'opencode',
|
||||
inspectProcess: () => ({ foregroundProcess: null, hasChildProcesses: false })
|
||||
})
|
||||
|
||||
await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow(
|
||||
'agent_prompt_target_changed'
|
||||
)
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('does not submit after the agent returns to a shell', async () => {
|
||||
vi.useFakeTimers()
|
||||
let foregroundProcess = 'opencode'
|
||||
const { runtime, handle, writes } = await createPromptRuntime({
|
||||
launchAgent: 'opencode',
|
||||
foregroundProcess: () => foregroundProcess,
|
||||
onWrite: (runtime, data) => {
|
||||
if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
|
||||
foregroundProcess = 'zsh'
|
||||
runtime.onPtyData('pty-prompt', '\x1b[?25h', Date.now())
|
||||
}
|
||||
}
|
||||
})
|
||||
const outcome = runtime.sendTerminalAgentPrompt(handle, 'review this').catch((error) => error)
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await expect(outcome).resolves.toMatchObject({
|
||||
code: 'operation_unknown',
|
||||
data: { reason: 'agent_prompt_target_changed' }
|
||||
})
|
||||
expect(writes).not.toContain('\r')
|
||||
})
|
||||
|
||||
it('rechecks permission after the final foreground inspection', async () => {
|
||||
let finishInspection!: (process: string | null) => void
|
||||
let enterInspection!: () => void
|
||||
const inspectionEntered = new Promise<void>((resolve) => {
|
||||
enterInspection = resolve
|
||||
})
|
||||
const finalInspection = new Promise<string | null>((resolve) => {
|
||||
finishInspection = resolve
|
||||
})
|
||||
let blockInspection = false
|
||||
let reportedInspection = false
|
||||
const { runtime, handle, writes } = await createPromptRuntime({
|
||||
launchAgent: 'codex',
|
||||
foregroundProcess: () => {
|
||||
if (blockInspection) {
|
||||
if (!reportedInspection) {
|
||||
reportedInspection = true
|
||||
enterInspection()
|
||||
}
|
||||
return finalInspection
|
||||
}
|
||||
return 'codex'
|
||||
},
|
||||
onWrite: (runtime, data) => {
|
||||
if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
|
||||
blockInspection = true
|
||||
runtime.onPtyData('pty-prompt', '\x1b[?25h', Date.now())
|
||||
}
|
||||
}
|
||||
})
|
||||
const outcome = runtime.sendTerminalAgentPrompt(handle, 'review this').catch((error) => error)
|
||||
|
||||
await inspectionEntered
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now())
|
||||
finishInspection('codex')
|
||||
|
||||
await expect(outcome).resolves.toMatchObject({
|
||||
code: 'operation_unknown',
|
||||
data: { reason: 'agent_prompt_blocked' }
|
||||
})
|
||||
expect(writes).not.toContain('\r')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ nextProcess: 'zsh', label: 'a shell' },
|
||||
{ nextProcess: 'gemini', label: 'another agent' }
|
||||
])('does not submit after Aider changes to $label', async ({ nextProcess }) => {
|
||||
vi.useFakeTimers()
|
||||
let foregroundProcess = 'aider'
|
||||
const { runtime, handle, writes } = await createPromptRuntime({
|
||||
launchAgent: 'aider',
|
||||
foregroundProcess: () => foregroundProcess,
|
||||
onWrite: (_runtime, data) => {
|
||||
if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
|
||||
foregroundProcess = nextProcess
|
||||
}
|
||||
}
|
||||
})
|
||||
const outcome = runtime.sendTerminalAgentPrompt(handle, 'review this').catch((error) => error)
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await expect(outcome).resolves.toMatchObject({
|
||||
code: 'operation_unknown',
|
||||
data: { reason: 'agent_prompt_target_changed' }
|
||||
})
|
||||
expect(writes).not.toContain('\r')
|
||||
})
|
||||
|
||||
it('retains metadata fallback when foreground inspection is unavailable throughout', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { runtime, handle, writes } = await createPromptRuntime({
|
||||
launchAgent: 'opencode',
|
||||
inspectProcess: () => ({
|
||||
foregroundProcess: null,
|
||||
hasChildProcesses: true,
|
||||
unavailable: true
|
||||
}),
|
||||
onWrite: (runtime, data) => {
|
||||
if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
|
||||
runtime.onPtyData('pty-prompt', '\x1b[?25h', Date.now())
|
||||
}
|
||||
if (data === '\r') {
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;OpenCode working\x07', Date.now())
|
||||
}
|
||||
}
|
||||
})
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await expect(submission).resolves.toMatchObject({ accepted: true })
|
||||
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('routes a query reply after the prompt paste frame closes', async () => {
|
||||
vi.useFakeTimers()
|
||||
let queryReply: Promise<unknown> | undefined
|
||||
const { runtime, handle, writes } = await createPromptRuntime({
|
||||
launchAgent: 'opencode',
|
||||
onWrite: (runtime, data) => {
|
||||
if (data.includes('\x1b[200~') && !data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
|
||||
queueMicrotask(() => {
|
||||
queryReply = runtime.sendTerminal(handle, { text: '\x1b[?0u' })
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
const outcome = runtime
|
||||
.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000))
|
||||
.catch((error) => error)
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await expect(queryReply).resolves.toMatchObject({ accepted: true })
|
||||
await expect(outcome).resolves.toMatchObject({ code: 'operation_unknown' })
|
||||
const pasteEnd = writes.findIndex((data) => data.includes(AGENT_PROMPT_BRACKETED_PASTE_END))
|
||||
expect(writes.indexOf('\x1b[?0u')).toBeGreaterThan(pasteEnd)
|
||||
expect(writes).not.toContain('\r')
|
||||
})
|
||||
|
||||
it('does not run a queued prompt after an unknown delivery outcome', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { runtime, handle, writes } = await createPromptRuntime({ launchAgent: 'opencode' })
|
||||
const first = runtime.sendTerminalAgentPrompt(handle, 'first prompt').catch((error) => error)
|
||||
const second = runtime.sendTerminalAgentPrompt(handle, 'second prompt').catch((error) => error)
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await expect(first).resolves.toMatchObject({ code: 'operation_unknown' })
|
||||
await expect(second).resolves.toMatchObject({ code: 'operation_unknown' })
|
||||
expect(writes.join('')).toContain('first prompt')
|
||||
expect(writes.join('')).not.toContain('second prompt')
|
||||
})
|
||||
|
||||
it('reports a generation reset after paste as unknown', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { runtime, handle, writes } = await createPromptRuntime({
|
||||
launchAgent: 'opencode',
|
||||
onWrite: (runtime, data) => {
|
||||
if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
|
||||
runtime.onPtyData('pty-prompt', '\x1b[?25h', Date.now())
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-prompt',
|
||||
{ value: 0, generation: 'reset' },
|
||||
runtime.getPtyOutputSequence('pty-prompt')
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
const outcome = runtime.sendTerminalAgentPrompt(handle, 'review this').catch((error) => error)
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await expect(outcome).resolves.toMatchObject({
|
||||
code: 'operation_unknown',
|
||||
data: { reason: 'terminal_handle_stale' }
|
||||
})
|
||||
expect(writes).not.toContain('\r')
|
||||
})
|
||||
|
||||
it('reports a generation reset after a partial paste as unknown', async () => {
|
||||
let reset = false
|
||||
const { runtime, handle, writes } = await createPromptRuntime({
|
||||
launchAgent: 'opencode',
|
||||
onWrite: (runtime) => {
|
||||
if (reset) {
|
||||
return
|
||||
}
|
||||
reset = true
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-prompt',
|
||||
{ value: 0, generation: 'reset' },
|
||||
runtime.getPtyOutputSequence('pty-prompt')
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
await expect(runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000))).rejects.toMatchObject(
|
||||
{
|
||||
code: 'operation_unknown',
|
||||
data: { reason: 'terminal_handle_stale' }
|
||||
}
|
||||
)
|
||||
expect(writes).toHaveLength(1)
|
||||
expect(writes[0]).not.toContain(AGENT_PROMPT_BRACKETED_PASTE_END)
|
||||
})
|
||||
|
||||
it('reports a generation reset immediately after Enter as unknown', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { runtime, handle, writes } = await createPromptRuntime({
|
||||
launchAgent: 'opencode',
|
||||
onWrite: (runtime, data) => {
|
||||
if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
|
||||
runtime.onPtyData('pty-prompt', '\x1b[?25h', Date.now())
|
||||
}
|
||||
if (data === '\r') {
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-prompt',
|
||||
{ value: 0, generation: 'reset' },
|
||||
runtime.getPtyOutputSequence('pty-prompt')
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
const outcome = runtime.sendTerminalAgentPrompt(handle, 'review this').catch((error) => error)
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await expect(outcome).resolves.toMatchObject({
|
||||
code: 'operation_unknown',
|
||||
data: { reason: 'terminal_handle_stale' }
|
||||
})
|
||||
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('lets direct input cancel an unacknowledged agent prompt', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { runtime, handle, writes } = await createPromptRuntime({ launchAgent: 'opencode' })
|
||||
const promptOutcome = runtime
|
||||
@@ -201,16 +478,40 @@ describe('agent prompt delivery state machine', () => {
|
||||
.catch((error: unknown) => error)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
await expect(runtime.sendTerminal(handle, { text: 'manual input' })).rejects.toThrow(
|
||||
'terminal_input_busy'
|
||||
)
|
||||
await expect(runtime.sendTerminal(handle, { text: 'manual input' })).resolves.toMatchObject({
|
||||
accepted: true
|
||||
})
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await expect(promptOutcome).resolves.toMatchObject({ code: 'operation_unknown' })
|
||||
expect(writes.join('')).not.toContain('manual input')
|
||||
expect(writes.join('')).toContain('manual input')
|
||||
expect(writes.filter((data) => data === '\r')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('refuses a prompt before paste while direct input is in flight', async () => {
|
||||
it('does not attribute post-Enter user activity to the prompt', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { runtime, handle, writes } = await createPromptRuntime({
|
||||
launchAgent: 'aider',
|
||||
onWrite: (runtime, data) => {
|
||||
if (data === 'manual input') {
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Aider working\x07', Date.now())
|
||||
}
|
||||
}
|
||||
})
|
||||
const prompt = runtime.sendTerminalAgentPrompt(handle, 'review this').catch((error) => error)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(AGENT_PROMPT_SUBMIT_DELAY_MS)
|
||||
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
|
||||
await runtime.sendTerminal(handle, { text: 'manual input' })
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await expect(prompt).resolves.toMatchObject({
|
||||
code: 'operation_unknown',
|
||||
data: { reason: 'terminal_input_superseded' }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a prompt authoritative until guarded direct input is admitted', async () => {
|
||||
let releaseWrite!: () => void
|
||||
let enteredWrite!: () => void
|
||||
const entered = new Promise<void>((resolve) => {
|
||||
@@ -232,13 +533,16 @@ describe('agent prompt delivery state machine', () => {
|
||||
)
|
||||
await entered
|
||||
|
||||
await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow(
|
||||
'terminal_input_busy'
|
||||
)
|
||||
const prompt = runtime.sendTerminalAgentPrompt(handle, 'review this').catch((error) => error)
|
||||
for (let turn = 0; turn < 20; turn += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
expect(writes.join('')).toContain('review this')
|
||||
|
||||
releaseWrite()
|
||||
await direct
|
||||
await expect(prompt).resolves.toMatchObject({ code: 'operation_unknown' })
|
||||
|
||||
expect(writes.join('')).toContain('manual input')
|
||||
expect(writes.join('')).not.toContain('review this')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -442,8 +442,7 @@ describe('agent prompt submission runtime', () => {
|
||||
it('does not write Enter after the PTY generation changes during settlement', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
|
||||
const rejected = expect(submission).rejects.toThrow('terminal_handle_stale')
|
||||
const outcome = runtime.sendTerminalAgentPrompt(handle, 'review this').catch((error) => error)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(writes.some((data) => data.includes(AGENT_PROMPT_BRACKETED_PASTE_END))).toBe(true)
|
||||
@@ -454,7 +453,10 @@ describe('agent prompt submission runtime', () => {
|
||||
)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await rejected
|
||||
await expect(outcome).resolves.toMatchObject({
|
||||
code: 'operation_unknown',
|
||||
data: { reason: 'terminal_handle_stale' }
|
||||
})
|
||||
expect(writes).not.toContain('\r')
|
||||
})
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export type AgentPromptActivity = Readonly<{
|
||||
type AgentPromptVerificationOptions = {
|
||||
baseline: AgentPromptActivity
|
||||
readActivity: () => AgentPromptActivity
|
||||
assertActive?: () => void
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
@@ -18,11 +19,14 @@ export async function verifyAgentPromptSubmission(
|
||||
options: AgentPromptVerificationOptions
|
||||
): Promise<void> {
|
||||
throwIfAgentPromptAborted(options.signal)
|
||||
options.assertActive?.()
|
||||
assertPromptNotBlocked(options.baseline, options.baseline)
|
||||
|
||||
const deadline = Date.now() + AGENT_PROMPT_EFFECT_TIMEOUT_MS
|
||||
while (Date.now() < deadline) {
|
||||
options.assertActive?.()
|
||||
const current = options.readActivity()
|
||||
options.assertActive?.()
|
||||
assertSamePromptGeneration(options.baseline, current)
|
||||
assertPromptNotBlocked(options.baseline, current)
|
||||
if (agentPromptLifecycleChanged(options.baseline, current)) {
|
||||
@@ -32,6 +36,7 @@ export async function verifyAgentPromptSubmission(
|
||||
}
|
||||
|
||||
const current = options.readActivity()
|
||||
options.assertActive?.()
|
||||
assertSamePromptGeneration(options.baseline, current)
|
||||
assertPromptNotBlocked(options.baseline, current)
|
||||
if (agentPromptLifecycleChanged(options.baseline, current)) {
|
||||
|
||||
@@ -974,6 +974,24 @@ function syncSinglePty(
|
||||
})
|
||||
}
|
||||
|
||||
async function flushAgentPromptStart(): Promise<void> {
|
||||
for (let turn = 0; turn < 20; turn += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
function makePromptAwareWrite(runtime: OrcaRuntimeService) {
|
||||
return vi.fn((ptyId: string, data: string) => {
|
||||
if (data.includes('\x1b[201~')) {
|
||||
runtime.onPtyData(ptyId, '\x1b[?25h', Date.now())
|
||||
}
|
||||
if (data === '\r') {
|
||||
runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', Date.now())
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function makeDeferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>((next) => {
|
||||
@@ -20060,7 +20078,7 @@ describe('OrcaRuntimeService', () => {
|
||||
try {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const db = new InMemoryOrchestrationMessages()
|
||||
const write = vi.fn().mockReturnValue(true)
|
||||
const write = makePromptAwareWrite(runtime)
|
||||
setInMemoryOrchestrationMessages(runtime, db)
|
||||
runtime.setPtyController({
|
||||
write,
|
||||
@@ -20077,12 +20095,13 @@ describe('OrcaRuntimeService', () => {
|
||||
db.insertMessage({ from: 'term_sender', to: terminal.handle, subject: 'hello' })
|
||||
|
||||
runtime.deliverPendingMessagesForHandle(terminal.handle)
|
||||
await flushAgentPromptStart()
|
||||
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
expect.stringContaining('You have 1 orchestration message')
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
expect(write).toHaveBeenCalledWith('pty-1', '\r')
|
||||
|
||||
const unread = db.getUnreadMessages(mailbox)
|
||||
@@ -20100,7 +20119,7 @@ describe('OrcaRuntimeService', () => {
|
||||
try {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const db = new InMemoryOrchestrationMessages()
|
||||
const write = vi.fn().mockReturnValue(true)
|
||||
const write = makePromptAwareWrite(runtime)
|
||||
setInMemoryOrchestrationMessages(runtime, db)
|
||||
runtime.setPtyController({
|
||||
write,
|
||||
@@ -20121,12 +20140,13 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
runtime.deliverPendingMessagesForHandle(terminal.handle)
|
||||
await flushAgentPromptStart()
|
||||
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
expect.stringContaining('You have 1 orchestration message')
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
const submitWrites = write.mock.calls.filter(
|
||||
([ptyId, text]) => ptyId === 'pty-1' && text === '\r'
|
||||
)
|
||||
@@ -20147,7 +20167,7 @@ describe('OrcaRuntimeService', () => {
|
||||
try {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const db = new InMemoryOrchestrationMessages()
|
||||
const write = vi.fn().mockReturnValue(true)
|
||||
const write = makePromptAwareWrite(runtime)
|
||||
setInMemoryOrchestrationMessages(runtime, db)
|
||||
runtime.setPtyController({
|
||||
write,
|
||||
@@ -20163,6 +20183,7 @@ describe('OrcaRuntimeService', () => {
|
||||
db.insertMessage({ from: 'term_sender', to: terminal.handle, subject: 'hello cursor' })
|
||||
|
||||
runtime.deliverPendingMessagesForHandle(terminal.handle)
|
||||
await flushAgentPromptStart()
|
||||
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
@@ -20189,7 +20210,7 @@ describe('OrcaRuntimeService', () => {
|
||||
try {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const db = new InMemoryOrchestrationMessages()
|
||||
const write = vi.fn().mockReturnValue(true)
|
||||
const write = makePromptAwareWrite(runtime)
|
||||
setInMemoryOrchestrationMessages(runtime, db)
|
||||
runtime.setPtyController({
|
||||
write,
|
||||
@@ -35467,7 +35488,7 @@ describe('OrcaRuntimeService', () => {
|
||||
try {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const db = new InMemoryOrchestrationMessages()
|
||||
const write = vi.fn().mockReturnValue(true)
|
||||
const write = makePromptAwareWrite(runtime)
|
||||
setInMemoryOrchestrationMessages(runtime, db)
|
||||
runtime.setPtyController({
|
||||
write,
|
||||
@@ -35492,13 +35513,13 @@ describe('OrcaRuntimeService', () => {
|
||||
runtime.notifyMessageArrived(terminal.handle, 'status')
|
||||
|
||||
// The push is deferred one microtask so it lands behind any resolved check.
|
||||
await Promise.resolve()
|
||||
await flushAgentPromptStart()
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
expect.stringContaining('You have 1 orchestration message')
|
||||
)
|
||||
expect(write).not.toHaveBeenCalledWith('pty-1', expect.stringContaining('after wait'))
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
expect(write).toHaveBeenCalledWith('pty-1', '\r')
|
||||
expect(message.delivered_at).toEqual(expect.any(String))
|
||||
db.close()
|
||||
@@ -35512,7 +35533,7 @@ describe('OrcaRuntimeService', () => {
|
||||
try {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const db = new InMemoryOrchestrationMessages()
|
||||
const write = vi.fn().mockReturnValue(true)
|
||||
const write = makePromptAwareWrite(runtime)
|
||||
setInMemoryOrchestrationMessages(runtime, db)
|
||||
runtime.setPtyController({
|
||||
write,
|
||||
@@ -35541,15 +35562,18 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
await flushAgentPromptStart()
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
'\nYou have 1 orchestration message. Run `orca orchestration check --run run_mailbox`.\n'
|
||||
expect.stringContaining(
|
||||
'You have 1 orchestration message. Run `orca orchestration check --run run_mailbox`.'
|
||||
)
|
||||
)
|
||||
expect(write).not.toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
expect.stringContaining('private worker report')
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
expect(write).toHaveBeenCalledWith('pty-1', '\r')
|
||||
expect(message.delivered_at).toEqual(expect.any(String))
|
||||
|
||||
@@ -35573,7 +35597,7 @@ describe('OrcaRuntimeService', () => {
|
||||
try {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const db = new InMemoryOrchestrationMessages()
|
||||
const write = vi.fn().mockReturnValue(true)
|
||||
const write = makePromptAwareWrite(runtime)
|
||||
setInMemoryOrchestrationMessages(runtime, db)
|
||||
runtime.setPtyController({
|
||||
write,
|
||||
@@ -35614,7 +35638,7 @@ describe('OrcaRuntimeService', () => {
|
||||
type: 'status'
|
||||
})
|
||||
runtime.deliverPendingMessagesForHandle('run:run_stale_waiter', new Set(['worker_done']))
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await flushAgentPromptStart()
|
||||
|
||||
const pointers = () =>
|
||||
write.mock.calls.filter(
|
||||
@@ -35625,6 +35649,8 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(pointers()[0]?.[1]).toContain('You have 1 orchestration message')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 102)
|
||||
await flushAgentPromptStart()
|
||||
expect(pointers()).toHaveLength(2)
|
||||
expect(pointers()[1]?.[1]).toContain('You have 1 orchestration message')
|
||||
db.close()
|
||||
@@ -35716,6 +35742,7 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
expect(pendingReads).toHaveBeenCalledTimes(1)
|
||||
await flushAgentPromptStart()
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
expect.stringContaining('You have 1 orchestration message')
|
||||
@@ -35756,6 +35783,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
|
||||
syncSinglePty(runtime)
|
||||
await flushAgentPromptStart()
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
expect.stringContaining('You have 1 orchestration message')
|
||||
@@ -35771,7 +35799,7 @@ describe('OrcaRuntimeService', () => {
|
||||
try {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const db = new InMemoryOrchestrationMessages()
|
||||
const write = vi.fn().mockReturnValue(true)
|
||||
const write = makePromptAwareWrite(runtime)
|
||||
setInMemoryOrchestrationMessages(runtime, db)
|
||||
runtime.setPtyController({
|
||||
write,
|
||||
@@ -35908,7 +35936,9 @@ describe('OrcaRuntimeService', () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
'\nYou have 1 orchestration message. Run `orca orchestration check --run run_codex_native_title`.\n'
|
||||
expect.stringContaining(
|
||||
'You have 1 orchestration message. Run `orca orchestration check --run run_codex_native_title`.'
|
||||
)
|
||||
)
|
||||
})
|
||||
db.close()
|
||||
@@ -35976,6 +36006,7 @@ describe('OrcaRuntimeService', () => {
|
||||
// already-idle title first. The seed left lastAgentStatus 'idle', so there
|
||||
// is no transition — only the liveness edge can release the row (#12536).
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 100)
|
||||
await flushAgentPromptStart()
|
||||
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
@@ -36025,6 +36056,7 @@ describe('OrcaRuntimeService', () => {
|
||||
// The first live idle frame authorizes it and the row still delivers.
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
await flushAgentPromptStart()
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
expect.stringContaining('You have 1 orchestration message')
|
||||
@@ -36134,9 +36166,9 @@ describe('OrcaRuntimeService', () => {
|
||||
const payloads = write.mock.calls
|
||||
.map(([, data]) => data)
|
||||
.filter((data): data is string => typeof data === 'string')
|
||||
expect(payloads).toContain(
|
||||
'\nYou have 1 orchestration message. Run `orca orchestration check --run run_test`.\n'
|
||||
)
|
||||
expect(
|
||||
payloads.some((data) => data.includes('orca orchestration check --run run_test'))
|
||||
).toBe(true)
|
||||
expect(payloads.some((data) => data.includes('reserved completion'))).toBe(false)
|
||||
expect(status.delivered_at).toEqual(expect.any(String))
|
||||
expect(done.delivered_at).toBeNull()
|
||||
@@ -36272,6 +36304,8 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
runtime.notifyMessageArrived(republished.handle, 'status')
|
||||
await Promise.resolve()
|
||||
await flushAgentPromptStart()
|
||||
await flushAgentPromptStart()
|
||||
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(600)
|
||||
@@ -36287,7 +36321,7 @@ describe('OrcaRuntimeService', () => {
|
||||
try {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const db = new InMemoryOrchestrationMessages()
|
||||
const write = vi.fn().mockReturnValue(true)
|
||||
const write = makePromptAwareWrite(runtime)
|
||||
setInMemoryOrchestrationMessages(runtime, db)
|
||||
runtime.setPtyController({
|
||||
write,
|
||||
@@ -36317,6 +36351,7 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
runtime.notifyMessageArrived(republished.handle, 'status')
|
||||
await Promise.resolve()
|
||||
await flushAgentPromptStart()
|
||||
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
@@ -36373,6 +36408,7 @@ describe('OrcaRuntimeService', () => {
|
||||
// working frame, since exit keeps lastAgentStatus 'idle' for `ps` and the
|
||||
// replacement can come up straight at an idle prompt (no transition).
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 200)
|
||||
await flushAgentPromptStart()
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
expect.stringContaining('You have 1 orchestration message')
|
||||
@@ -36423,6 +36459,7 @@ describe('OrcaRuntimeService', () => {
|
||||
runtime.notifyMessageArrived(terminal.handle, 'status')
|
||||
|
||||
await Promise.resolve()
|
||||
await flushAgentPromptStart()
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
expect.stringContaining('You have 1 orchestration message')
|
||||
@@ -36502,6 +36539,7 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
runtime.notifyMessageArrived(terminal.handle, 'status')
|
||||
await Promise.resolve()
|
||||
await flushAgentPromptStart()
|
||||
runtime.notifyMessageArrived(terminal.handle, 'status')
|
||||
await Promise.resolve()
|
||||
|
||||
@@ -36532,7 +36570,7 @@ describe('OrcaRuntimeService', () => {
|
||||
try {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const db = new InMemoryOrchestrationMessages()
|
||||
const write = vi.fn().mockReturnValue(true)
|
||||
const write = makePromptAwareWrite(runtime)
|
||||
setInMemoryOrchestrationMessages(runtime, db)
|
||||
runtime.setPtyController({
|
||||
write,
|
||||
@@ -36550,7 +36588,7 @@ describe('OrcaRuntimeService', () => {
|
||||
runtime.notifyMessageArrived(terminal.handle, 'status')
|
||||
// Why the flush: the deferred push must actually arm its flight before the
|
||||
// second message arrives, or this exercises a plain batch instead.
|
||||
await Promise.resolve()
|
||||
await flushAgentPromptStart()
|
||||
|
||||
// Why: mid-flight notify parks the leaf; flight settle re-runs delivery
|
||||
// so the second row is not lost and is not double-injected with the first.
|
||||
@@ -36565,12 +36603,13 @@ describe('OrcaRuntimeService', () => {
|
||||
).toHaveLength(1)
|
||||
expect(second.delivered_at).toBeNull()
|
||||
|
||||
// Why: release must not require another agent-status OSC — only the
|
||||
// delayed-Enter flight timer. Advancing 3s with no status output covers
|
||||
// timer-only settle (CodeRabbit settling-timeout gap, #12584).
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
expect(write).toHaveBeenCalledWith('pty-1', '\r')
|
||||
expect(first.delivered_at).toEqual(expect.any(String))
|
||||
expect(second.delivered_at).toBeNull()
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 102)
|
||||
await flushAgentPromptStart()
|
||||
expect(
|
||||
write.mock.calls.filter(
|
||||
([, payload]) =>
|
||||
@@ -36581,6 +36620,7 @@ describe('OrcaRuntimeService', () => {
|
||||
'pty-1',
|
||||
expect.stringContaining('You have 1 orchestration message')
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
expect(second.delivered_at).toEqual(expect.any(String))
|
||||
db.close()
|
||||
} finally {
|
||||
@@ -36608,6 +36648,7 @@ describe('OrcaRuntimeService', () => {
|
||||
db.insertMessage({ from: 'sender', to: terminal.handle, subject: 'after wait' })
|
||||
|
||||
runtime.deliverPendingMessagesForHandle(terminal.handle)
|
||||
await flushAgentPromptStart()
|
||||
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
@@ -45351,9 +45392,9 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
runtime.onPtyData('pty-startup-draft', '›', Date.now())
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(write).toHaveBeenCalledWith('pty-startup-draft', `\x1b[200~${draftUrl}\x1b[201~`)
|
||||
await vi.waitFor(() => {
|
||||
expect(write).toHaveBeenCalledWith('pty-startup-draft', `\x1b[200~${draftUrl}\x1b[201~`)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the 8s main-runtime startup readiness budget for non-Codex agents', async () => {
|
||||
@@ -45538,7 +45579,16 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-cli-aider-startup' })
|
||||
const write = vi.fn().mockReturnValue(true)
|
||||
const write = vi.fn((ptyId: string, data: string) => {
|
||||
if (data === '\r') {
|
||||
runtime.onPtyData(
|
||||
ptyId,
|
||||
'\x1b]9999;{"state":"working","agentType":"aider"}\x07',
|
||||
Date.now()
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write,
|
||||
@@ -45588,7 +45638,10 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
)
|
||||
await vi.waitFor(() => {
|
||||
expect(write).toHaveBeenCalledWith('pty-cli-aider-startup', 'fix it\r')
|
||||
expect(write.mock.calls).toEqual([
|
||||
['pty-cli-aider-startup', '\x1b[200~fix it\x1b[201~'],
|
||||
['pty-cli-aider-startup', '\r']
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+310
-115
@@ -92,6 +92,7 @@ import {
|
||||
import { buildOrchestrationTaskDisplayMetadata } from '../../shared/orchestration-task-display'
|
||||
import {
|
||||
isTerminalInputTooLargeWithYield,
|
||||
TERMINAL_INPUT_CHUNK_MAX_BYTES,
|
||||
TERMINAL_INPUT_TOO_LARGE_ERROR,
|
||||
iterateTerminalInputChunks
|
||||
} from '../../shared/terminal-input'
|
||||
@@ -105,7 +106,17 @@ import {
|
||||
type AgentPromptActivity,
|
||||
verifyAgentPromptSubmission
|
||||
} from './agent-prompt-submission-verification'
|
||||
import { agentPromptDeliveryBecameUnknown } from './agent-prompt-delivery-outcome'
|
||||
import {
|
||||
type AgentPromptDeliveryUnknownError,
|
||||
agentPromptDeliveryBecameUnknown,
|
||||
isAgentPromptDeliveryUnknownError
|
||||
} from './agent-prompt-delivery-outcome'
|
||||
import {
|
||||
PtyInputTransactionOwner,
|
||||
isPtyInputTransactionQueryReply,
|
||||
type PtyInputTransaction,
|
||||
type PtyInputTransactionKind
|
||||
} from '../pty-input-transaction'
|
||||
import {
|
||||
awaitWindowsHostGitEnvironmentReady,
|
||||
gitExecFileAsync,
|
||||
@@ -1908,6 +1919,12 @@ type RuntimePtyController = {
|
||||
}>
|
||||
write(ptyId: string, data: string): boolean
|
||||
writeWithSettlement?(ptyId: string, data: string): Promise<boolean>
|
||||
beginInputTransaction?(
|
||||
ptyId: string,
|
||||
generation: number,
|
||||
kind: PtyInputTransactionKind
|
||||
): PtyInputTransaction | null
|
||||
invalidateInputTransactions?(ptyId: string, generation: number): void
|
||||
/** 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). */
|
||||
@@ -2037,9 +2054,21 @@ function assertAgentPromptRequestActive(signal?: AbortSignal): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForAgentPromptPromise<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
async function waitForAgentPromptPromise<T>(
|
||||
promise: Promise<T>,
|
||||
signal?: AbortSignal,
|
||||
invalidated?: Promise<'terminal_handle_stale' | 'terminal_input_superseded'>
|
||||
): Promise<T> {
|
||||
const awaited = invalidated
|
||||
? Promise.race([
|
||||
promise,
|
||||
invalidated.then((reason) => {
|
||||
throw new Error(reason)
|
||||
})
|
||||
])
|
||||
: promise
|
||||
if (!signal) {
|
||||
return await promise
|
||||
return await awaited
|
||||
}
|
||||
assertAgentPromptRequestActive(signal)
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
@@ -2062,7 +2091,7 @@ async function waitForAgentPromptPromise<T>(promise: Promise<T>, signal?: AbortS
|
||||
onAbort()
|
||||
return
|
||||
}
|
||||
promise.then(
|
||||
awaited.then(
|
||||
(value) => finish({ value }),
|
||||
(error: unknown) => finish({ error })
|
||||
)
|
||||
@@ -3208,11 +3237,21 @@ export class OrcaRuntimeService {
|
||||
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),
|
||||
getTerminalHandleForLeaf: (leaf) => this.issueHandle(leaf as RuntimeLeafRecord),
|
||||
redriveMailbox: (mailboxHandle, reservedTypes) =>
|
||||
this.deliverPendingMessagesForHandle(mailboxHandle, reservedTypes),
|
||||
writePty: (ptyId, data) => this.writeOrchestrationPointerPty(ptyId, data)
|
||||
sendPrompt: async (handle, prompt, options) => {
|
||||
try {
|
||||
await this.sendTerminalAgentPrompt(handle, prompt, {
|
||||
signal: options.signal,
|
||||
beforeWrite: options.beforeWrite,
|
||||
beforeAttempt: options.beforeAttempt
|
||||
})
|
||||
return 'delivered'
|
||||
} catch (error) {
|
||||
return isAgentPromptDeliveryUnknownError(error) ? 'unknown' : 'rejected'
|
||||
}
|
||||
}
|
||||
})
|
||||
private readonly orchestrationMailboxNotifications =
|
||||
new OrchestrationMailboxNotificationCoordinator<MessageWaiter>({
|
||||
@@ -3300,11 +3339,9 @@ export class OrcaRuntimeService {
|
||||
>()
|
||||
private agentPromptPermissionSequenceByPtyId = new Map<string, number>()
|
||||
private agentPromptExplicitStatusFloorByPtyId = new Map<string, number>()
|
||||
private agentPromptSubmissionTailByPtyId = new Map<string, Promise<void>>()
|
||||
private directTerminalWriteCountByPtyId = new Map<string, number>()
|
||||
private agentPromptInputReservationByPtyId = new Map<
|
||||
private agentPromptSubmissionTailByPtyId = new Map<
|
||||
string,
|
||||
{ generation: number; token: symbol }
|
||||
Promise<AgentPromptDeliveryUnknownError | null>
|
||||
>()
|
||||
private providerSequenceInitializedPtys = new Set<string>()
|
||||
private providerSequenceOffsetByPtyId = new Map<string, number>()
|
||||
@@ -6172,6 +6209,21 @@ export class OrcaRuntimeService {
|
||||
// Why: CLI terminal writes must go through the main-owned PTY registry
|
||||
// instead of tunneling back through renderer IPC, or live handles could
|
||||
// drift from the process they are supposed to control during reloads.
|
||||
if (controller && !controller.beginInputTransaction) {
|
||||
const write = controller.write.bind(controller)
|
||||
const writeWithSettlement = controller.writeWithSettlement?.bind(controller)
|
||||
const inputOwner = new PtyInputTransactionOwner(
|
||||
(ptyId, data) => write(ptyId, data),
|
||||
writeWithSettlement
|
||||
? (ptyId, data) => writeWithSettlement(ptyId, data)
|
||||
: (ptyId, data) => write(ptyId, data)
|
||||
)
|
||||
controller.write = (ptyId, data) => inputOwner.write(ptyId, data)
|
||||
controller.beginInputTransaction = (ptyId, generation, kind) =>
|
||||
inputOwner.begin(ptyId, generation, kind)
|
||||
controller.invalidateInputTransactions = (ptyId, generation) =>
|
||||
inputOwner.acknowledgeGeneration(ptyId, generation)
|
||||
}
|
||||
this.ptyController = controller
|
||||
}
|
||||
|
||||
@@ -12252,7 +12304,9 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
private advancePtyLifecycleGeneration(ptyId: string): void {
|
||||
this.ptyLifecycleGenerationById.set(ptyId, this.nextPtyLifecycleGeneration++)
|
||||
const generation = this.nextPtyLifecycleGeneration++
|
||||
this.ptyLifecycleGenerationById.set(ptyId, generation)
|
||||
this.ptyController?.invalidateInputTransactions?.(ptyId, generation)
|
||||
// Why: a stop whose exit never arrived would otherwise stay armed across a
|
||||
// same-id respawn and label the NEXT process's crash an operator close —
|
||||
// the exact lie this cause model exists to remove.
|
||||
@@ -18631,6 +18685,7 @@ export class OrcaRuntimeService {
|
||||
reserveWrite?: (ptyId: string) => void
|
||||
afterWrite?: (ptyId: string) => void | Promise<void>
|
||||
suffixFailureError?: string
|
||||
inputKind?: 'interactive' | 'automation'
|
||||
} = {}
|
||||
): Promise<RuntimeTerminalSend> {
|
||||
const pty = this.getLivePtyForHandle(handle)
|
||||
@@ -18643,9 +18698,25 @@ export class OrcaRuntimeService {
|
||||
throw new Error('invalid_terminal_send')
|
||||
}
|
||||
await assertTerminalInputWithinLimitWithYield(action.text)
|
||||
await this.withDirectTerminalInput(pty.pty.ptyId, () =>
|
||||
this.writeTerminalAction(pty.pty.ptyId, action, payload, options)
|
||||
)
|
||||
const writeDirectly =
|
||||
isPtyInputTransactionQueryReply(payload) ||
|
||||
(options.inputKind !== 'automation' &&
|
||||
!terminalActionNeedsInputTransaction(action, payload))
|
||||
await (writeDirectly
|
||||
? this.writeTerminalAction(pty.pty.ptyId, action, payload, options)
|
||||
: this.withPtyInputTransaction(
|
||||
pty.pty.ptyId,
|
||||
this.getPtyLifecycleGeneration(pty.pty.ptyId),
|
||||
options.inputKind ?? 'interactive',
|
||||
(transaction) =>
|
||||
this.writeTerminalAction(
|
||||
pty.pty.ptyId,
|
||||
action,
|
||||
payload,
|
||||
options,
|
||||
(data) => transaction.write(data) as boolean
|
||||
)
|
||||
))
|
||||
return {
|
||||
handle,
|
||||
accepted: true,
|
||||
@@ -18670,9 +18741,24 @@ export class OrcaRuntimeService {
|
||||
throw new Error('terminal_not_writable')
|
||||
}
|
||||
|
||||
await this.withDirectTerminalInput(leaf.ptyId, () =>
|
||||
this.writeTerminalAction(leaf.ptyId!, action, payload, options)
|
||||
)
|
||||
const writeDirectly =
|
||||
isPtyInputTransactionQueryReply(payload) ||
|
||||
(options.inputKind !== 'automation' && !terminalActionNeedsInputTransaction(action, payload))
|
||||
await (writeDirectly
|
||||
? this.writeTerminalAction(leaf.ptyId, action, payload, options)
|
||||
: this.withPtyInputTransaction(
|
||||
leaf.ptyId,
|
||||
this.getPtyLifecycleGeneration(leaf.ptyId),
|
||||
options.inputKind ?? 'interactive',
|
||||
(transaction) =>
|
||||
this.writeTerminalAction(
|
||||
leaf.ptyId!,
|
||||
action,
|
||||
payload,
|
||||
options,
|
||||
(data) => transaction.write(data) as boolean
|
||||
)
|
||||
))
|
||||
|
||||
return {
|
||||
handle,
|
||||
@@ -18686,6 +18772,8 @@ export class OrcaRuntimeService {
|
||||
prompt: string,
|
||||
options: {
|
||||
beforeWrite?: (ptyId: string) => void | Promise<void>
|
||||
beforeAttempt?: (ptyId: string) => void
|
||||
onMutated?: (ptyId: string) => void
|
||||
suffixFailureError?: string
|
||||
signal?: AbortSignal
|
||||
} = {}
|
||||
@@ -18704,8 +18792,19 @@ export class OrcaRuntimeService {
|
||||
async () => {
|
||||
this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId)
|
||||
this.assertAgentPromptGeneration(pty.pty.ptyId, generation)
|
||||
return await this.withAgentPromptInputReservation(pty.pty.ptyId, generation, () =>
|
||||
this.writeTerminalAgentPrompt(handle, pty.pty.ptyId, generation, payload, options)
|
||||
return await this.withPtyInputTransaction(
|
||||
pty.pty.ptyId,
|
||||
generation,
|
||||
'agent-prompt',
|
||||
(transaction) =>
|
||||
this.writeTerminalAgentPrompt(
|
||||
handle,
|
||||
pty.pty.ptyId,
|
||||
generation,
|
||||
payload,
|
||||
transaction,
|
||||
options
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -18727,8 +18826,19 @@ export class OrcaRuntimeService {
|
||||
const submits = await this.serializeAgentPromptSubmission(leaf.ptyId, generation, async () => {
|
||||
this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId!)
|
||||
this.assertAgentPromptGeneration(leaf.ptyId!, generation)
|
||||
return await this.withAgentPromptInputReservation(leaf.ptyId!, generation, () =>
|
||||
this.writeTerminalAgentPrompt(handle, leaf.ptyId!, generation, payload, options)
|
||||
return await this.withPtyInputTransaction(
|
||||
leaf.ptyId!,
|
||||
generation,
|
||||
'agent-prompt',
|
||||
(transaction) =>
|
||||
this.writeTerminalAgentPrompt(
|
||||
handle,
|
||||
leaf.ptyId!,
|
||||
generation,
|
||||
payload,
|
||||
transaction,
|
||||
options
|
||||
)
|
||||
)
|
||||
})
|
||||
const bytesWritten = Buffer.byteLength(payload, 'utf8') + submits
|
||||
@@ -19329,14 +19439,16 @@ export class OrcaRuntimeService {
|
||||
reserveWrite?: (ptyId: string) => void
|
||||
afterWrite?: (ptyId: string) => void | Promise<void>
|
||||
suffixFailureError?: string
|
||||
} = {}
|
||||
} = {},
|
||||
writeInput: (data: string) => boolean = (data) =>
|
||||
this.ptyController?.write(ptyId, data) ?? false
|
||||
): Promise<void> {
|
||||
// Why: direct terminal.send can carry paste-sized text from RPC/mobile
|
||||
// clients; chunk text before PTY/ConPTY while preserving suffix separation.
|
||||
const hasText = typeof action.text === 'string' && action.text.length > 0
|
||||
const hasSuffix = action.enter || action.interrupt
|
||||
if (hasText) {
|
||||
await this.writeTerminalInputChunks(ptyId, action.text!, options)
|
||||
await this.writeTerminalInputChunks(ptyId, action.text!, options, writeInput)
|
||||
}
|
||||
if (hasSuffix) {
|
||||
const suffix = (action.enter ? '\r' : '') + (action.interrupt ? '\x03' : '')
|
||||
@@ -19352,7 +19464,7 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const suffixWrote = this.ptyController?.write(ptyId, suffix) ?? false
|
||||
const suffixWrote = writeInput(suffix)
|
||||
if (!suffixWrote) {
|
||||
throw new Error(options.suffixFailureError ?? 'terminal_not_writable')
|
||||
}
|
||||
@@ -19365,53 +19477,37 @@ export class OrcaRuntimeService {
|
||||
|
||||
await options.beforeWrite?.(ptyId)
|
||||
options.reserveWrite?.(ptyId)
|
||||
const wrote = this.ptyController?.write(ptyId, payload) ?? false
|
||||
const wrote = writeInput(payload)
|
||||
if (!wrote) {
|
||||
throw new Error('terminal_not_writable')
|
||||
}
|
||||
await options.afterWrite?.(ptyId)
|
||||
}
|
||||
|
||||
private async withDirectTerminalInput<T>(ptyId: string, write: () => Promise<T>): Promise<T> {
|
||||
const reservation = this.agentPromptInputReservationByPtyId.get(ptyId)
|
||||
if (reservation?.generation === this.getPtyLifecycleGeneration(ptyId)) {
|
||||
throw new Error('terminal_input_busy')
|
||||
}
|
||||
if (reservation) {
|
||||
this.agentPromptInputReservationByPtyId.delete(ptyId)
|
||||
}
|
||||
this.directTerminalWriteCountByPtyId.set(
|
||||
ptyId,
|
||||
(this.directTerminalWriteCountByPtyId.get(ptyId) ?? 0) + 1
|
||||
)
|
||||
try {
|
||||
return await write()
|
||||
} finally {
|
||||
const remaining = (this.directTerminalWriteCountByPtyId.get(ptyId) ?? 1) - 1
|
||||
if (remaining === 0) {
|
||||
this.directTerminalWriteCountByPtyId.delete(ptyId)
|
||||
} else {
|
||||
this.directTerminalWriteCountByPtyId.set(ptyId, remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async withAgentPromptInputReservation<T>(
|
||||
private async withPtyInputTransaction<T>(
|
||||
ptyId: string,
|
||||
generation: number,
|
||||
submit: () => Promise<T>
|
||||
kind: PtyInputTransactionKind,
|
||||
run: (transaction: PtyInputTransaction) => Promise<T>
|
||||
): Promise<T> {
|
||||
if ((this.directTerminalWriteCountByPtyId.get(ptyId) ?? 0) > 0) {
|
||||
const controller = this.ptyController
|
||||
const transaction = controller?.beginInputTransaction?.(ptyId, generation, kind)
|
||||
if (controller?.beginInputTransaction && !transaction) {
|
||||
throw new Error('terminal_input_busy')
|
||||
}
|
||||
const token = Symbol('agent-prompt-input')
|
||||
this.agentPromptInputReservationByPtyId.set(ptyId, { generation, token })
|
||||
const fallback: PtyInputTransaction = {
|
||||
write: (data) => controller?.write(ptyId, data) ?? false,
|
||||
invalidated: new Promise<'terminal_handle_stale' | 'terminal_input_superseded'>(
|
||||
() => undefined
|
||||
),
|
||||
active: true,
|
||||
invalidationReason: null,
|
||||
release: () => undefined
|
||||
}
|
||||
try {
|
||||
return await submit()
|
||||
return await run(transaction ?? fallback)
|
||||
} finally {
|
||||
if (this.agentPromptInputReservationByPtyId.get(ptyId)?.token === token) {
|
||||
this.agentPromptInputReservationByPtyId.delete(ptyId)
|
||||
}
|
||||
transaction?.release()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19422,14 +19518,16 @@ export class OrcaRuntimeService {
|
||||
beforeWrite?: (ptyId: string) => void | Promise<void>
|
||||
reserveWrite?: (ptyId: string) => void
|
||||
afterWrite?: (ptyId: string) => void | Promise<void>
|
||||
} = {}
|
||||
} = {},
|
||||
writeInput: (data: string) => boolean = (data) =>
|
||||
this.ptyController?.write(ptyId, data) ?? false
|
||||
): Promise<void> {
|
||||
const chunks = iterateTerminalInputChunks(text)
|
||||
let chunk = chunks.next()
|
||||
while (!chunk.done) {
|
||||
await options.beforeWrite?.(ptyId)
|
||||
options.reserveWrite?.(ptyId)
|
||||
const wrote = this.ptyController?.write(ptyId, chunk.value) ?? false
|
||||
const wrote = writeInput(chunk.value)
|
||||
if (!wrote) {
|
||||
throw new Error('terminal_not_writable')
|
||||
}
|
||||
@@ -19446,8 +19544,11 @@ export class OrcaRuntimeService {
|
||||
ptyId: string,
|
||||
generation: number,
|
||||
pastePayload: string,
|
||||
transaction: PtyInputTransaction,
|
||||
options: {
|
||||
beforeWrite?: (ptyId: string) => void | Promise<void>
|
||||
beforeAttempt?: (ptyId: string) => void
|
||||
onMutated?: (ptyId: string) => void
|
||||
suffixFailureError?: string
|
||||
signal?: AbortSignal
|
||||
} = {}
|
||||
@@ -19456,9 +19557,10 @@ export class OrcaRuntimeService {
|
||||
this.assertAgentPromptGeneration(ptyId, generation)
|
||||
const permissionBaseline = this.getAgentPromptActivity(handle, ptyId)
|
||||
this.assertAgentPromptPermissionSafe(permissionBaseline, permissionBaseline)
|
||||
const settlementAgent = await this.resolveAgentPromptSettlementAgent(ptyId)
|
||||
const renderGate = this.createAgentPromptRenderGate(ptyId, settlementAgent)
|
||||
const settlementTarget = await this.resolveAgentPromptSettlementTarget(ptyId)
|
||||
const renderGate = this.createAgentPromptRenderGate(ptyId, settlementTarget.agent)
|
||||
let wrotePasteBytes = false
|
||||
let attemptedPaste = false
|
||||
let completedPaste = false
|
||||
try {
|
||||
const chunks = iterateTerminalInputChunks(pastePayload)
|
||||
@@ -19477,11 +19579,19 @@ export class OrcaRuntimeService {
|
||||
if (nextChunk.done) {
|
||||
renderGate?.arm()
|
||||
}
|
||||
const wrote = this.ptyController?.write(ptyId, chunk.value) ?? false
|
||||
if (!attemptedPaste) {
|
||||
options.beforeAttempt?.(ptyId)
|
||||
attemptedPaste = true
|
||||
}
|
||||
const wrote = await transaction.write(chunk.value)
|
||||
if (!wrote) {
|
||||
throw new Error('terminal_not_writable')
|
||||
}
|
||||
wrotePasteBytes = true
|
||||
if (!wrotePasteBytes) {
|
||||
wrotePasteBytes = true
|
||||
options.onMutated?.(ptyId)
|
||||
}
|
||||
this.assertPtyInputTransactionActive(transaction)
|
||||
chunk = nextChunk
|
||||
if (!chunk.done) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
@@ -19494,10 +19604,10 @@ export class OrcaRuntimeService {
|
||||
!completedPaste &&
|
||||
this.getPtyLifecycleGeneration(ptyId) === generation
|
||||
) {
|
||||
this.ptyController?.write(ptyId, AGENT_PROMPT_BRACKETED_PASTE_END)
|
||||
await transaction.write(AGENT_PROMPT_BRACKETED_PASTE_END)
|
||||
}
|
||||
renderGate?.dispose()
|
||||
if (wrotePasteBytes && !isAgentPromptTerminalReplacement(error)) {
|
||||
if (wrotePasteBytes) {
|
||||
throw agentPromptDeliveryBecameUnknown(error)
|
||||
}
|
||||
throw error
|
||||
@@ -19506,12 +19616,20 @@ export class OrcaRuntimeService {
|
||||
try {
|
||||
if (renderGate) {
|
||||
try {
|
||||
await waitForAgentPromptPromise(renderGate.wait(), options.signal)
|
||||
await waitForAgentPromptPromise(
|
||||
renderGate.wait(),
|
||||
options.signal,
|
||||
transaction.invalidated
|
||||
)
|
||||
} finally {
|
||||
renderGate.dispose()
|
||||
}
|
||||
} else {
|
||||
await waitForAgentPromptDelay(AGENT_PROMPT_SUBMIT_DELAY_MS, options.signal)
|
||||
await waitForAgentPromptPromise(
|
||||
waitForAgentPromptDelay(AGENT_PROMPT_SUBMIT_DELAY_MS),
|
||||
options.signal,
|
||||
transaction.invalidated
|
||||
)
|
||||
}
|
||||
assertAgentPromptRequestActive(options.signal)
|
||||
this.assertAgentPromptGeneration(ptyId, generation)
|
||||
@@ -19527,20 +19645,27 @@ export class OrcaRuntimeService {
|
||||
this.assertAgentPromptGeneration(ptyId, generation)
|
||||
const baseline = this.getAgentPromptActivity(handle, ptyId)
|
||||
this.assertAgentPromptPermissionSafe(permissionBaseline, baseline)
|
||||
const suffixWrote = this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT) ?? false
|
||||
await this.assertAgentPromptSettlementTarget(ptyId, settlementTarget)
|
||||
assertAgentPromptRequestActive(options.signal)
|
||||
this.assertAgentPromptGeneration(ptyId, generation)
|
||||
this.assertPtyInputTransactionActive(transaction)
|
||||
this.assertAgentPromptPermissionSafe(
|
||||
permissionBaseline,
|
||||
this.getAgentPromptActivity(handle, ptyId)
|
||||
)
|
||||
const suffixWrote = await transaction.write(AGENT_PROMPT_SUBMIT)
|
||||
if (!suffixWrote) {
|
||||
throw new Error(options.suffixFailureError ?? 'terminal_not_writable')
|
||||
}
|
||||
this.assertPtyInputTransactionActive(transaction)
|
||||
await verifyAgentPromptSubmission({
|
||||
baseline,
|
||||
readActivity: () => this.getAgentPromptActivity(handle, ptyId),
|
||||
assertActive: () => this.assertPtyInputTransactionActive(transaction),
|
||||
signal: options.signal
|
||||
})
|
||||
return 1
|
||||
} catch (error) {
|
||||
if (isAgentPromptTerminalReplacement(error)) {
|
||||
throw error
|
||||
}
|
||||
throw agentPromptDeliveryBecameUnknown(error)
|
||||
}
|
||||
}
|
||||
@@ -19551,11 +19676,16 @@ export class OrcaRuntimeService {
|
||||
submit: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const queueKey = `${ptyId}\u0000${generation}`
|
||||
const previous = this.agentPromptSubmissionTailByPtyId.get(queueKey) ?? Promise.resolve()
|
||||
const submission = previous.catch(() => undefined).then(submit)
|
||||
const previous = this.agentPromptSubmissionTailByPtyId.get(queueKey) ?? Promise.resolve(null)
|
||||
const submission = previous.then((priorUnknown) => {
|
||||
if (priorUnknown) {
|
||||
throw priorUnknown
|
||||
}
|
||||
return submit()
|
||||
})
|
||||
const tail = submission.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
() => null,
|
||||
(error: unknown) => (isAgentPromptDeliveryUnknownError(error) ? error : null)
|
||||
)
|
||||
this.agentPromptSubmissionTailByPtyId.set(queueKey, tail)
|
||||
try {
|
||||
@@ -19618,24 +19748,78 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveAgentPromptSettlementAgent(ptyId: string): Promise<TuiAgent | null> {
|
||||
private assertPtyInputTransactionActive(transaction: PtyInputTransaction): void {
|
||||
if (!transaction.active) {
|
||||
throw new Error(transaction.invalidationReason ?? 'terminal_input_superseded')
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveAgentPromptSettlementTarget(
|
||||
ptyId: string
|
||||
): Promise<AgentPromptSettlementTarget> {
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
if (!pty) {
|
||||
return null
|
||||
const inspection = await this.inspectAgentPromptForeground(ptyId)
|
||||
if (inspection.available) {
|
||||
const recognized = recognizeAgentProcess(inspection.process)?.agent
|
||||
if (!recognized) {
|
||||
throw new Error('agent_prompt_target_changed')
|
||||
}
|
||||
if (pty) {
|
||||
pty.foregroundAgent = recognized
|
||||
}
|
||||
return {
|
||||
agent: recognized,
|
||||
evidence: 'authoritative'
|
||||
}
|
||||
}
|
||||
const fallback = pty?.foregroundAgent ?? pty?.launchAgent ?? null
|
||||
return {
|
||||
agent: getAgentPromptSettlementPolicy(fallback) ? fallback : null,
|
||||
evidence: 'fallback'
|
||||
}
|
||||
}
|
||||
|
||||
private async assertAgentPromptSettlementTarget(
|
||||
ptyId: string,
|
||||
expected: AgentPromptSettlementTarget
|
||||
): Promise<void> {
|
||||
const inspection = await this.inspectAgentPromptForeground(ptyId)
|
||||
if (!inspection.available) {
|
||||
if (expected.evidence === 'fallback') {
|
||||
return
|
||||
}
|
||||
throw new Error('agent_prompt_target_unavailable')
|
||||
}
|
||||
const recognized = recognizeAgentProcess(inspection.process)?.agent
|
||||
if (!recognized || recognized !== expected.agent) {
|
||||
throw new Error('agent_prompt_target_changed')
|
||||
}
|
||||
}
|
||||
|
||||
private async inspectAgentPromptForeground(
|
||||
ptyId: string
|
||||
): Promise<{ available: boolean; process: string | null }> {
|
||||
const controller = this.ptyController
|
||||
if (!controller) {
|
||||
return { available: false, process: null }
|
||||
}
|
||||
if (controller.inspectProcess) {
|
||||
try {
|
||||
const inspection = await controller.inspectProcess(ptyId)
|
||||
return {
|
||||
available: inspection.unavailable !== true,
|
||||
process: inspection.foregroundProcess
|
||||
}
|
||||
} catch {
|
||||
return { available: false, process: null }
|
||||
}
|
||||
}
|
||||
try {
|
||||
const recognized = recognizeAgentProcess(
|
||||
(await this.ptyController?.getForegroundProcess(ptyId)) ?? null
|
||||
)?.agent
|
||||
if (recognized) {
|
||||
pty.foregroundAgent = recognized
|
||||
return getAgentPromptSettlementPolicy(recognized) ? recognized : null
|
||||
}
|
||||
const process = await controller.getForegroundProcess(ptyId)
|
||||
return { available: process !== null, process }
|
||||
} catch {
|
||||
// Current metadata remains the bounded fallback when foreground inspection is unavailable.
|
||||
return { available: false, process: null }
|
||||
}
|
||||
const fallback = pty.foregroundAgent ?? pty.launchAgent
|
||||
return getAgentPromptSettlementPolicy(fallback) ? fallback : null
|
||||
}
|
||||
|
||||
private createAgentPromptRenderGate(
|
||||
@@ -24054,14 +24238,15 @@ export class OrcaRuntimeService {
|
||||
|
||||
private pasteStartupDraftWhenReady(handle: string, draft: WorktreeStartupDraftPaste): void {
|
||||
void this.waitForStartupDraftReady(handle, draft.agent)
|
||||
.then((ptyId) => {
|
||||
.then(async (ptyId) => {
|
||||
if (!ptyId) {
|
||||
console.warn('[worktree-create] agent did not become ready for draft paste')
|
||||
return
|
||||
}
|
||||
this.ptyController?.write(
|
||||
ptyId,
|
||||
`${BRACKETED_PASTE_BEGIN}${draft.content}${BRACKETED_PASTE_END}`
|
||||
await this.sendTerminal(
|
||||
handle,
|
||||
{ text: `${BRACKETED_PASTE_BEGIN}${draft.content}${BRACKETED_PASTE_END}` },
|
||||
{ inputKind: 'automation' }
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -24071,12 +24256,12 @@ export class OrcaRuntimeService {
|
||||
|
||||
private sendStartupFollowupWhenReady(handle: string, followup: WorktreeStartupFollowup): void {
|
||||
void this.waitForStartupFollowupReady(handle, followup.expectedProcess)
|
||||
.then((ptyId) => {
|
||||
.then(async (ptyId) => {
|
||||
if (!ptyId) {
|
||||
console.warn('[worktree-create] agent did not become ready for follow-up prompt')
|
||||
return
|
||||
}
|
||||
this.ptyController?.write(ptyId, `${followup.prompt}\r`)
|
||||
await this.sendTerminalAgentPrompt(handle, followup.prompt)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[worktree-create] failed to send startup follow-up prompt:', error)
|
||||
@@ -29504,10 +29689,20 @@ export class OrcaRuntimeService {
|
||||
if (!pty || this.terminalSpawnCommandsByPtyId.has(pty.ptyId)) {
|
||||
return
|
||||
}
|
||||
if (this.ptyController?.write(pty.ptyId, command)) {
|
||||
// Why: Enter rides its own write so a long command cannot swallow it.
|
||||
this.ptyController.write(pty.ptyId, '\r')
|
||||
this.noteTerminalSpawnCommand(pty.ptyId, command)
|
||||
const transaction = this.ptyController?.beginInputTransaction?.(
|
||||
pty.ptyId,
|
||||
this.getPtyLifecycleGeneration(pty.ptyId),
|
||||
'automation'
|
||||
)
|
||||
if (!transaction) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (transaction.write(command) === true && transaction.write('\r') === true) {
|
||||
this.noteTerminalSpawnCommand(pty.ptyId, command)
|
||||
}
|
||||
} finally {
|
||||
transaction.release()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34918,17 +35113,6 @@ export class OrcaRuntimeService {
|
||||
this.orchestrationMailboxNotifications.deliverForHandle(handle, reservedTypes)
|
||||
}
|
||||
|
||||
private writeOrchestrationPointerPty(ptyId: string, data: string): boolean | Promise<boolean> {
|
||||
try {
|
||||
if (this.ptyController?.writeWithSettlement) {
|
||||
return this.ptyController.writeWithSettlement(ptyId, data).catch(() => false)
|
||||
}
|
||||
return this.ptyController?.write(ptyId, data) ?? false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private retireOrchestrationMailboxDeliveryForPty(ptyId: string): void {
|
||||
this.orchestrationMailboxNotifications.retirePty(ptyId)
|
||||
for (const leaf of this.getLeavesForPty(ptyId)) {
|
||||
@@ -40053,6 +40237,16 @@ function buildSendPayload(action: {
|
||||
return payload.length > 0 ? payload : null
|
||||
}
|
||||
|
||||
function terminalActionNeedsInputTransaction(
|
||||
action: { text?: string; enter?: boolean; interrupt?: boolean },
|
||||
payload: string
|
||||
): boolean {
|
||||
return (
|
||||
Boolean(action.text && (action.enter || action.interrupt)) ||
|
||||
Buffer.byteLength(payload, 'utf8') > TERMINAL_INPUT_CHUNK_MAX_BYTES
|
||||
)
|
||||
}
|
||||
|
||||
async function assertTerminalInputWithinLimitWithYield(text: string | undefined): Promise<void> {
|
||||
if (!text) {
|
||||
return
|
||||
@@ -40934,6 +41128,11 @@ type AgentPromptSettlementPolicy = Readonly<{
|
||||
renderTimeout: 'submit' | 'reject'
|
||||
}>
|
||||
|
||||
type AgentPromptSettlementTarget = Readonly<{
|
||||
agent: TuiAgent | null
|
||||
evidence: 'authoritative' | 'fallback'
|
||||
}>
|
||||
|
||||
const BOUNDED_FALLBACK_AGENT_PROMPT_SETTLEMENT: AgentPromptSettlementPolicy = {
|
||||
renderTimeout: 'submit'
|
||||
}
|
||||
@@ -40953,10 +41152,6 @@ function getAgentPromptSettlementPolicy(
|
||||
return null
|
||||
}
|
||||
|
||||
function isAgentPromptTerminalReplacement(error: unknown): boolean {
|
||||
return error instanceof Error && error.message === 'terminal_handle_stale'
|
||||
}
|
||||
|
||||
function findLastCompleteOscTitleRange(data: string): { start: number; end: number } | null {
|
||||
// Why: one forward cursor keeps hostile unterminated OSC output linear-time.
|
||||
let last: { start: number; end: number } | null = null
|
||||
|
||||
@@ -293,9 +293,9 @@ describe('orchestration notification mailbox consistency', () => {
|
||||
expect(pointerCount(first.write)).toBe(1)
|
||||
|
||||
const runB = createBoundRun(firstDb, 'Run B')
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
expect(first.write.mock.calls.filter(([, payload]) => payload === '\r')).toHaveLength(0)
|
||||
expect(firstDb.getMessageById(message.id)?.delivered_at).toBeNull()
|
||||
expect(firstDb.getMessageById(message.id)?.delivered_at).toEqual(expect.any(String))
|
||||
firstDb.close()
|
||||
|
||||
const restartedDb = new OrchestrationDb(dbPath)
|
||||
@@ -308,7 +308,7 @@ describe('orchestration notification mailbox consistency', () => {
|
||||
expect(checked).toMatchObject({ runId: runB.id, deliveryId: null, count: 0, messages: [] })
|
||||
expect(restartedDb.getMessageById(message.id)).toMatchObject({
|
||||
read: 0,
|
||||
delivered_at: null
|
||||
delivered_at: expect.any(String)
|
||||
})
|
||||
restartedDb.close()
|
||||
})
|
||||
@@ -348,7 +348,7 @@ describe('orchestration notification mailbox consistency', () => {
|
||||
insertDirectRunMessage(db, runA.id, 'Run A completion')
|
||||
|
||||
await driveToLiveIdle(harness.runtime)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
expect(harness.write).toHaveBeenCalledWith(
|
||||
PTY_ID,
|
||||
expect.stringContaining(`orchestration check --run ${runA.id}`)
|
||||
@@ -386,7 +386,7 @@ describe('orchestration notification mailbox consistency', () => {
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('releases a staged pointer when its Run moves to another live pane', async () => {
|
||||
it('does not transfer a pointer after its Run moves post-mutation', async () => {
|
||||
vi.useFakeTimers()
|
||||
const db = createDatabase('orca-mailbox-live-rebind-')
|
||||
const harness = createRuntime(db)
|
||||
@@ -404,19 +404,19 @@ describe('orchestration notification mailbox consistency', () => {
|
||||
})
|
||||
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)
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
|
||||
expect(
|
||||
harness.write.mock.calls.filter(
|
||||
([ptyId, payload]) =>
|
||||
ptyId === SECOND_PTY_ID && String(payload).includes('orca orchestration check')
|
||||
)
|
||||
).toHaveLength(1)
|
||||
).toHaveLength(0)
|
||||
expect(
|
||||
harness.write.mock.calls.filter(([ptyId, payload]) => ptyId === PTY_ID && payload === '\r')
|
||||
).toHaveLength(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
const checked = await checkBoundMailbox(harness.runtime, {
|
||||
terminal: SECOND_TERMINAL_HANDLE,
|
||||
paneKey: SECOND_PANE_KEY,
|
||||
@@ -453,14 +453,24 @@ describe('orchestration notification mailbox consistency', () => {
|
||||
)
|
||||
).toHaveLength(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
expect(
|
||||
harness.write.mock.calls.filter(
|
||||
([ptyId, payload]) =>
|
||||
ptyId === PTY_ID && String(payload).includes('orca orchestration check')
|
||||
)
|
||||
).toHaveLength(1)
|
||||
harness.runtime.onPtyData(PTY_ID, '\x1b]0;Codex done\x07', 3)
|
||||
for (let turn = 0; turn < 20; turn += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
expect(
|
||||
harness.write.mock.calls.filter(
|
||||
([ptyId, payload]) =>
|
||||
ptyId === PTY_ID && String(payload).includes('orca orchestration check')
|
||||
)
|
||||
).toHaveLength(2)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
|
||||
const checked = await checkBoundMailbox(harness.runtime)
|
||||
expect(checked).toMatchObject({ runId: run.id, count: 2 })
|
||||
@@ -480,7 +490,7 @@ describe('orchestration notification mailbox consistency', () => {
|
||||
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)
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
|
||||
expect(harness.write.mock.calls.filter(([, payload]) => payload === '\r')).toHaveLength(0)
|
||||
expect(db.getMessageById(message.id)?.delivered_at).toEqual(expect.any(String))
|
||||
@@ -502,7 +512,7 @@ describe('orchestration notification mailbox consistency', () => {
|
||||
const checked = await checkBoundMailbox(harness.runtime)
|
||||
expect(checked).toMatchObject({ runId: run.id, count: 1 })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
const redrive = vi.spyOn(harness.runtime, 'deliverPendingMessagesForHandle')
|
||||
harness.runtime.onPtyExit(PTY_ID, 0)
|
||||
await Promise.resolve()
|
||||
|
||||
@@ -88,12 +88,20 @@ export function createRuntime(db: OrchestrationDb): MailboxNotificationHarness {
|
||||
? { paneKey, source: 'current_hook' }
|
||||
: null
|
||||
})
|
||||
const write = vi.fn(() => true)
|
||||
const write = vi.fn((ptyId: string, data: string) => {
|
||||
if (data.includes('\x1b[201~')) {
|
||||
runtime.onPtyData(ptyId, '\x1b[?25h', Date.now())
|
||||
}
|
||||
if (data === '\r') {
|
||||
runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', Date.now())
|
||||
}
|
||||
return true
|
||||
})
|
||||
runtime.setOrchestrationDb(db)
|
||||
runtime.setPtyController({
|
||||
write,
|
||||
kill: vi.fn(),
|
||||
getForegroundProcess: async () => null
|
||||
getForegroundProcess: async () => 'codex'
|
||||
})
|
||||
runtime.registerPty(PTY_ID, WORKTREE_ID, null, {
|
||||
tabId: TAB_ID,
|
||||
@@ -186,7 +194,9 @@ 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()
|
||||
for (let turn = 0; turn < 20; turn += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
export function pointerCount(write: ReturnType<typeof vi.fn>): number {
|
||||
|
||||
@@ -18,7 +18,7 @@ vi.mock('electron', () => ({
|
||||
webContents: { fromId: vi.fn(() => null) }
|
||||
}))
|
||||
|
||||
describe('orchestration mailbox transport settlement', () => {
|
||||
describe('orchestration mailbox prompt settlement', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
@@ -26,41 +26,93 @@ describe('orchestration mailbox transport settlement', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not durably stage a pointer until transport settlement succeeds', async () => {
|
||||
it('does not replay a mutated pointer when lifecycle acknowledgement is missing', async () => {
|
||||
vi.useFakeTimers()
|
||||
const db = createDatabase('orca-mailbox-transport-settlement-')
|
||||
const db = createDatabase('orca-mailbox-prompt-settlement-')
|
||||
const first = createRuntime(db)
|
||||
const observedWrite = vi.fn((_ptyId: string, _data: string) => true)
|
||||
let settleWrite: ((accepted: boolean) => void) | undefined
|
||||
const observedWrite = vi.fn((ptyId: string, data: string) => {
|
||||
if (data.includes('\x1b[201~')) {
|
||||
first.runtime.onPtyData(ptyId, '\x1b[?25h', Date.now())
|
||||
}
|
||||
return true
|
||||
})
|
||||
first.runtime.setPtyController({
|
||||
write: observedWrite,
|
||||
writeWithSettlement: vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
settleWrite = resolve
|
||||
})
|
||||
),
|
||||
kill: vi.fn(),
|
||||
getForegroundProcess: async () => null
|
||||
getForegroundProcess: async () => 'codex'
|
||||
})
|
||||
const run = createBoundRun(db, 'SSH settlement Run')
|
||||
const message = insertDirectRunMessage(db, run.id, 'Rejected SSH pointer')
|
||||
const run = createBoundRun(db, 'Unknown prompt settlement Run')
|
||||
const message = insertDirectRunMessage(db, run.id, 'Unacknowledged pointer')
|
||||
|
||||
await driveToLiveIdle(first.runtime)
|
||||
expect(pointerCount(observedWrite)).toBe(0)
|
||||
expect(db.getMessageById(message.id)?.delivered_at).toBeNull()
|
||||
expect(pointerCount(observedWrite)).toBe(1)
|
||||
expect(db.getMessageById(message.id)?.delivered_at).toEqual(expect.any(String))
|
||||
|
||||
settleWrite?.(false)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(pointerCount(observedWrite)).toBe(0)
|
||||
expect(db.getMessageById(message.id)?.delivered_at).toBeNull()
|
||||
await vi.runAllTimersAsync()
|
||||
expect(observedWrite.mock.calls.filter(([, data]) => data === '\r')).toHaveLength(1)
|
||||
expect(db.getMessageById(message.id)?.delivered_at).toEqual(expect.any(String))
|
||||
|
||||
const restarted = createRuntime(db)
|
||||
await driveToLiveIdle(restarted.runtime)
|
||||
await Promise.resolve()
|
||||
expect(pointerCount(restarted.write)).toBe(1)
|
||||
expect(pointerCount(restarted.write)).toBe(0)
|
||||
expect(db.getMessageById(message.id)?.delivered_at).toEqual(expect.any(String))
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('durably suppresses replay before an ambiguous provider transport attempt', async () => {
|
||||
let settleWrite!: (accepted: boolean) => void
|
||||
const db = createDatabase('orca-mailbox-host-settlement-')
|
||||
const harness = createRuntime(db)
|
||||
const writeWithSettlement = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
settleWrite = resolve
|
||||
})
|
||||
)
|
||||
harness.runtime.setPtyController({
|
||||
write: vi.fn(() => true),
|
||||
writeWithSettlement,
|
||||
kill: vi.fn(),
|
||||
getForegroundProcess: async () => 'codex'
|
||||
})
|
||||
const run = createBoundRun(db, 'Provider settlement Run')
|
||||
const message = insertDirectRunMessage(db, run.id, 'Awaiting transport settlement')
|
||||
|
||||
await driveToLiveIdle(harness.runtime)
|
||||
expect(writeWithSettlement).toHaveBeenCalledOnce()
|
||||
expect(db.getMessageById(message.id)?.delivered_at).toEqual(expect.any(String))
|
||||
|
||||
settleWrite(false)
|
||||
for (let turn = 0; turn < 20; turn += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
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 attempt provider input when durable staging fails', async () => {
|
||||
const db = createDatabase('orca-mailbox-staging-failure-')
|
||||
const harness = createRuntime(db)
|
||||
const writeWithSettlement = vi.fn(async () => true)
|
||||
harness.runtime.setPtyController({
|
||||
write: vi.fn(() => true),
|
||||
writeWithSettlement,
|
||||
kill: vi.fn(),
|
||||
getForegroundProcess: async () => 'codex'
|
||||
})
|
||||
const run = createBoundRun(db, 'Staging failure Run')
|
||||
const message = insertDirectRunMessage(db, run.id, 'Must remain explicit')
|
||||
vi.spyOn(db, 'markAsDelivered').mockImplementation(() => {
|
||||
throw new Error('disk unavailable')
|
||||
})
|
||||
|
||||
await driveToLiveIdle(harness.runtime)
|
||||
expect(writeWithSettlement).not.toHaveBeenCalled()
|
||||
expect(db.getMessageById(message.id)?.delivered_at).toBeNull()
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { OrchestrationDb } from './db'
|
||||
import type { OrchestrationMailboxDeliveryTarget } from './mailbox-delivery-target'
|
||||
import type { OrchestrationMessageWaiter } from './mailbox-pointer-eligibility'
|
||||
import type { OrchestrationMailboxLeaf, OrchestrationMailboxOwner } from './mailbox-owner'
|
||||
|
||||
export 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
|
||||
getTerminalHandleForLeaf: (leaf: OrchestrationMailboxLeaf) => string
|
||||
redriveMailbox: (mailboxHandle: string, reservedTypes?: ReadonlySet<string>) => void
|
||||
sendPrompt: (
|
||||
handle: string,
|
||||
prompt: string,
|
||||
options: { signal: AbortSignal; beforeWrite: () => void; beforeAttempt: () => void }
|
||||
) => Promise<'delivered' | 'unknown' | 'rejected'>
|
||||
}
|
||||
@@ -1,41 +1,22 @@
|
||||
import { isCursorAgentTitle } from '../../../shared/agent-detection'
|
||||
import { ORCHESTRATION_DELIVERY_BATCH_LIMIT, type OrchestrationDb } from './db'
|
||||
import { ORCHESTRATION_DELIVERY_BATCH_LIMIT } from './db'
|
||||
import { formatMessagePointer } from './formatter'
|
||||
import type { OrchestrationMailboxDeliveryTarget } from './mailbox-delivery-target'
|
||||
import type { PointerDeliveryDependencies } from './mailbox-pointer-delivery-contract'
|
||||
import {
|
||||
hasUnfilteredOrchestrationWaiter,
|
||||
messageTypeHasOrchestrationWaiter,
|
||||
shouldReleaseOrchestrationPointer,
|
||||
type OrchestrationMessageWaiter
|
||||
} from './mailbox-pointer-eligibility'
|
||||
import type { OrchestrationMailboxLeaf, OrchestrationMailboxOwner } from './mailbox-owner'
|
||||
import type { OrchestrationMailboxLeaf } 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) {
|
||||
@@ -50,11 +31,8 @@ export class OrchestrationMailboxPointerDelivery<TWaiter extends OrchestrationMe
|
||||
if (mailboxHandle) {
|
||||
this.deliver(leaf, { mailboxHandle, reservedTypes })
|
||||
}
|
||||
} catch {
|
||||
// Persisted mail remains available to explicit check or a later idle edge.
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
deliver(
|
||||
leaf: OrchestrationMailboxLeaf,
|
||||
options: {
|
||||
@@ -63,14 +41,12 @@ export class OrchestrationMailboxPointerDelivery<TWaiter extends OrchestrationMe
|
||||
skipAbsenceProbe?: boolean
|
||||
}
|
||||
): void {
|
||||
const terminalHandle = this.deps.getTerminalHandleForLeaf(leaf)
|
||||
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
|
||||
}
|
||||
@@ -82,7 +58,6 @@ export class OrchestrationMailboxPointerDelivery<TWaiter extends OrchestrationMe
|
||||
this.parkRedelivery(mailboxHandle, options.reservedTypes)
|
||||
return
|
||||
}
|
||||
|
||||
const waiters = this.deps.getMessageWaiters(mailboxHandle)
|
||||
if (hasUnfilteredOrchestrationWaiter(waiters)) {
|
||||
return
|
||||
@@ -111,14 +86,7 @@ export class OrchestrationMailboxPointerDelivery<TWaiter extends OrchestrationMe
|
||||
if (newestSequence === undefined) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!this.state.releaseSupersededWatermark(
|
||||
mailboxHandle,
|
||||
newestSequence,
|
||||
leaf.ptyId,
|
||||
this.leafKey(leaf)
|
||||
)
|
||||
) {
|
||||
if (!this.state.releaseSupersededWatermark(mailboxHandle, newestSequence)) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
@@ -132,26 +100,18 @@ export class OrchestrationMailboxPointerDelivery<TWaiter extends OrchestrationMe
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.stagePointer(leaf, mailboxHandle, unread, newestSequence)
|
||||
this.stagePointer(leaf, terminalHandle, 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)
|
||||
}
|
||||
flight?.abortController.abort()
|
||||
for (const mailboxHandle of releasedMailboxes) {
|
||||
this.redrive(mailboxHandle, true)
|
||||
}
|
||||
}
|
||||
|
||||
private redeliverAfterProbe(
|
||||
leaf: OrchestrationMailboxLeaf,
|
||||
ptyId: string,
|
||||
@@ -166,9 +126,9 @@ export class OrchestrationMailboxPointerDelivery<TWaiter extends OrchestrationMe
|
||||
this.deliver(currentLeaf, { mailboxHandle, skipAbsenceProbe: true })
|
||||
}
|
||||
}
|
||||
|
||||
private stagePointer(
|
||||
leaf: OrchestrationMailboxLeaf,
|
||||
terminalHandle: string,
|
||||
mailboxHandle: string,
|
||||
unread: readonly { id: string; type: string; sequence: number }[],
|
||||
newestSequence: number
|
||||
@@ -178,70 +138,43 @@ export class OrchestrationMailboxPointerDelivery<TWaiter extends OrchestrationMe
|
||||
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(
|
||||
void this.deps
|
||||
.sendPrompt(terminalHandle, formatMessagePointer(unread.length, mailboxHandle), {
|
||||
signal: flight.abortController.signal,
|
||||
beforeWrite: () =>
|
||||
this.assertPromptDeliveryCurrent(leaf, mailboxHandle, unread, ptyId, flight),
|
||||
beforeAttempt: () =>
|
||||
this.stagePromptDeliveryAttempt(
|
||||
leaf,
|
||||
mailboxHandle,
|
||||
unread,
|
||||
newestSequence,
|
||||
ptyId,
|
||||
flight,
|
||||
accepted
|
||||
),
|
||||
() =>
|
||||
this.finishPointerWrite(leaf, mailboxHandle, unread, newestSequence, ptyId, flight, false)
|
||||
flight
|
||||
)
|
||||
})
|
||||
.then((outcome) =>
|
||||
this.finishPromptDelivery(leaf, mailboxHandle, newestSequence, ptyId, flight, outcome)
|
||||
)
|
||||
.catch(() =>
|
||||
this.finishPromptDelivery(leaf, mailboxHandle, newestSequence, ptyId, flight, 'rejected')
|
||||
)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
private finishPointerWrite(
|
||||
private finishPromptDelivery(
|
||||
leaf: OrchestrationMailboxLeaf,
|
||||
mailboxHandle: string,
|
||||
unread: readonly { id: string; type: string; sequence: number }[],
|
||||
newestSequence: number,
|
||||
ptyId: string,
|
||||
flight: OrchestrationMailboxDeliveryFlight,
|
||||
accepted: boolean
|
||||
outcome: 'delivered' | 'unknown' | 'rejected'
|
||||
): void {
|
||||
let delayedSettle = false
|
||||
try {
|
||||
if (!accepted || !this.state.isCurrentFlight(ptyId, flight)) {
|
||||
if (outcome === 'rejected' || !flight.mutated || !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 (
|
||||
outcome === 'delivered' &&
|
||||
[leaf.lastOscTitle, leaf.paneTitle, this.deps.getTabTitle(leaf.tabId)].some(
|
||||
isCursorAgentTitle
|
||||
)
|
||||
@@ -250,33 +183,76 @@ export class OrchestrationMailboxPointerDelivery<TWaiter extends OrchestrationMe
|
||||
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
|
||||
const released =
|
||||
outcome === 'delivered'
|
||||
? this.state.clearWatermark(mailboxHandle, newestSequence, ptyId)
|
||||
: this.state.deactivateWatermark(mailboxHandle, newestSequence, ptyId)
|
||||
if (released && outcome === 'delivered') {
|
||||
this.redrive(mailboxHandle)
|
||||
}
|
||||
} finally {
|
||||
if (!delayedSettle) {
|
||||
this.settle(ptyId, flight)
|
||||
this.settle(ptyId, flight)
|
||||
if (flight.redriveMailbox) {
|
||||
this.redrive(flight.redriveMailbox, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private assertPromptDeliveryCurrent(
|
||||
leaf: OrchestrationMailboxLeaf,
|
||||
mailboxHandle: string,
|
||||
unread: readonly { id: string; type: string; sequence: number }[],
|
||||
ptyId: string,
|
||||
flight: OrchestrationMailboxDeliveryFlight
|
||||
): void {
|
||||
const currentLeaf = this.deps.getLeaf(this.leafKey(leaf))
|
||||
const ownerChanged =
|
||||
!this.state.isCurrentFlight(ptyId, flight) ||
|
||||
currentLeaf?.ptyId !== ptyId ||
|
||||
!currentLeaf.writable ||
|
||||
this.deps.mailboxOwner.resolve(currentLeaf) !== mailboxHandle
|
||||
const agentNoLongerIdle =
|
||||
currentLeaf?.lastAgentStatus !== 'idle' || !currentLeaf.lastAgentStatusObservedLive
|
||||
const messagesReleased = shouldReleaseOrchestrationPointer(
|
||||
this.deps.getDb(),
|
||||
mailboxHandle,
|
||||
unread,
|
||||
this.deps.getMessageWaiters(mailboxHandle)
|
||||
)
|
||||
if (!ownerChanged && !agentNoLongerIdle && !messagesReleased) {
|
||||
return
|
||||
}
|
||||
if (!flight.mutated && ownerChanged) {
|
||||
flight.redriveMailbox = mailboxHandle
|
||||
}
|
||||
throw new Error('orchestration_pointer_released')
|
||||
}
|
||||
private stagePromptDeliveryAttempt(
|
||||
leaf: OrchestrationMailboxLeaf,
|
||||
mailboxHandle: string,
|
||||
unread: readonly { id: string; type: string; sequence: number }[],
|
||||
newestSequence: number,
|
||||
ptyId: string,
|
||||
flight: OrchestrationMailboxDeliveryFlight
|
||||
): void {
|
||||
const db = this.deps.getDb()
|
||||
if (!db) {
|
||||
throw new Error('orchestration_db_unavailable')
|
||||
}
|
||||
db.markAsDelivered(unread.map((message) => message.id))
|
||||
this.state.setWatermark(mailboxHandle, newestSequence, ptyId, this.leafKey(leaf))
|
||||
flight.mutated = true
|
||||
if (!this.state.isCurrentFlight(ptyId, flight)) {
|
||||
this.state.deactivateWatermark(mailboxHandle, newestSequence, ptyId)
|
||||
}
|
||||
this.assertPromptDeliveryCurrent(leaf, mailboxHandle, unread, ptyId, flight)
|
||||
if (
|
||||
[leaf.lastOscTitle, leaf.paneTitle, this.deps.getTabTitle(leaf.tabId)].some(
|
||||
isCursorAgentTitle
|
||||
)
|
||||
) {
|
||||
flight.abortController.abort()
|
||||
}
|
||||
}
|
||||
private settle(ptyId: string, flight: OrchestrationMailboxDeliveryFlight): void {
|
||||
const parked = this.state.settleFlight(ptyId, flight)
|
||||
if (!parked) {
|
||||
@@ -295,7 +271,6 @@ export class OrchestrationMailboxPointerDelivery<TWaiter extends OrchestrationMe
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private redrive(mailboxHandle: string, force = false): void {
|
||||
const parkedTypes = this.state.takeRedelivery(mailboxHandle, force)
|
||||
if (parkedTypes === undefined) {
|
||||
@@ -304,12 +279,9 @@ export class OrchestrationMailboxPointerDelivery<TWaiter extends OrchestrationMe
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
this.deps.redriveMailbox(mailboxHandle, parkedTypes ?? undefined)
|
||||
} catch {
|
||||
// Durable mail remains available to explicit check or a later idle edge.
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
}
|
||||
|
||||
private leafKey(leaf: OrchestrationMailboxLeaf): string {
|
||||
return this.deps.getLeafKey(leaf.tabId, leaf.leafId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { OrchestrationMailboxPointerState } from './mailbox-pointer-state'
|
||||
|
||||
describe('OrchestrationMailboxPointerState', () => {
|
||||
it('does not admit newer mail after an unknown prompt is deactivated', () => {
|
||||
const state = new OrchestrationMailboxPointerState()
|
||||
state.setWatermark('run:1', 1, 'pty-1', 'pane-1')
|
||||
expect(state.deactivateWatermark('run:1', 1, 'pty-1')).toBe(true)
|
||||
expect(state.releaseSupersededWatermark('run:1', 2)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { OrchestrationMailboxLeaf } from './mailbox-owner'
|
||||
|
||||
export type OrchestrationMailboxDeliveryFlight = {
|
||||
enterTimer: ReturnType<typeof setTimeout> | null
|
||||
stagedMessageIds: string[]
|
||||
abortController: AbortController
|
||||
mutated: boolean
|
||||
redriveMailbox: string | null
|
||||
}
|
||||
|
||||
export type ParkedOrchestrationMailboxDelivery = {
|
||||
@@ -28,7 +29,7 @@ export class OrchestrationMailboxPointerState {
|
||||
}
|
||||
|
||||
beginFlight(ptyId: string): OrchestrationMailboxDeliveryFlight {
|
||||
const flight = { enterTimer: null, stagedMessageIds: [] }
|
||||
const flight = { abortController: new AbortController(), mutated: false, redriveMailbox: null }
|
||||
this.flightsByPtyId.set(ptyId, flight)
|
||||
return flight
|
||||
}
|
||||
@@ -74,22 +75,15 @@ export class OrchestrationMailboxPointerState {
|
||||
return this.watermarkByMailbox.get(mailboxHandle)?.active === true
|
||||
}
|
||||
|
||||
releaseSupersededWatermark(
|
||||
mailboxHandle: string,
|
||||
newestSequence: number,
|
||||
ptyId: string | null,
|
||||
leafKey: string
|
||||
): boolean {
|
||||
releaseSupersededWatermark(mailboxHandle: string, newestSequence: number): boolean {
|
||||
const owner = this.watermarkByMailbox.get(mailboxHandle)
|
||||
if (owner && !owner.active) {
|
||||
return false
|
||||
}
|
||||
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)
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
setWatermark(mailbox: string, sequence: number, ptyId: string, leafKey: string): void {
|
||||
@@ -156,11 +150,20 @@ export class OrchestrationMailboxPointerState {
|
||||
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)) {
|
||||
if (!owner || owner.ptyId !== ptyId) {
|
||||
continue
|
||||
}
|
||||
if (flight?.mutated || !owner.active) {
|
||||
owner.active = false
|
||||
continue
|
||||
}
|
||||
if (this.clearWatermark(mailboxHandle, owner.sequence, ptyId)) {
|
||||
releasedMailboxes.push(mailboxHandle)
|
||||
}
|
||||
}
|
||||
this.watermarkMailboxesByPtyId.delete(ptyId)
|
||||
if (!flight?.mutated) {
|
||||
this.watermarkMailboxesByPtyId.delete(ptyId)
|
||||
}
|
||||
return { flight, releasedMailboxes }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { getDefaultWorkspaceSession } from '../../shared/constants'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import {
|
||||
AGENT_PROMPT_BRACKETED_PASTE_END,
|
||||
AGENT_PROMPT_SUBMIT_DELAY_MS
|
||||
} from '../../shared/agent-prompt-injection'
|
||||
|
||||
// STA repro (silent-send incident): `orca terminal send` to a leaf whose ptyId
|
||||
// no provider in this process owns was a silent no-op reported as success —
|
||||
@@ -41,18 +45,29 @@ async function makeRuntimeWithLeafHandle(options: {
|
||||
leafPtyId?: string
|
||||
probePtyLiveness?: (ptyId: string) => Promise<boolean | null>
|
||||
hasPty?: (ptyId: string) => boolean | null
|
||||
foregroundProcess?: string | null
|
||||
onWrite?: (runtime: OrcaRuntimeService, data: string) => void
|
||||
}): Promise<{
|
||||
runtime: OrcaRuntimeService
|
||||
handle: string
|
||||
write: ReturnType<typeof vi.fn>
|
||||
}> {
|
||||
const runtime = new OrcaRuntimeService(makeStore() as never)
|
||||
const write = vi.fn(() => true)
|
||||
const write = vi.fn((_ptyId: string, data: string) => {
|
||||
if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
|
||||
runtime.onPtyData(STALE_PTY_ID, '\x1b[?25h', Date.now())
|
||||
}
|
||||
if (data === '\r') {
|
||||
runtime.onPtyData(STALE_PTY_ID, '\x1b]0;Codex working\x07', Date.now())
|
||||
}
|
||||
options.onWrite?.(runtime, data)
|
||||
return true
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn(async () => ({ id: 'never' })),
|
||||
write,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
getForegroundProcess: async () => options.foregroundProcess ?? null,
|
||||
listProcesses: vi.fn(async () => []),
|
||||
...(options.hasPty ? { hasPty: options.hasPty } : {}),
|
||||
...(options.probePtyLiveness ? { probePtyLiveness: options.probePtyLiveness } : {})
|
||||
@@ -63,6 +78,12 @@ async function makeRuntimeWithLeafHandle(options: {
|
||||
return { runtime, handle: terminals[0].handle, write }
|
||||
}
|
||||
|
||||
async function flushAgentPromptStart(): Promise<void> {
|
||||
for (let turn = 0; turn < 20; turn += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
// Re-invocable: every graph resync replaces leaf records with fresh objects.
|
||||
function publishLeafGraph(runtime: OrcaRuntimeService, leafPtyId: string): void {
|
||||
runtime.syncWindowGraph(1, {
|
||||
@@ -152,6 +173,31 @@ describe('sendTerminal absence gate for leaf-branch writes', () => {
|
||||
expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping')
|
||||
})
|
||||
|
||||
it('uses fresh OpenCode readiness for a restored leaf-only session', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { runtime, handle, write } = await makeRuntimeWithLeafHandle({
|
||||
probePtyLiveness: async () => true,
|
||||
foregroundProcess: 'opencode',
|
||||
onWrite: (runtime, data) => {
|
||||
if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
|
||||
runtime.onPtyData(STALE_PTY_ID, '\x1b[?25h', Date.now())
|
||||
}
|
||||
if (data === '\r') {
|
||||
runtime.onPtyData(STALE_PTY_ID, '\x1b]0;OpenCode working\x07', Date.now())
|
||||
}
|
||||
}
|
||||
})
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(AGENT_PROMPT_SUBMIT_DELAY_MS)
|
||||
expect(write).not.toHaveBeenCalledWith(STALE_PTY_ID, '\r')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await expect(submission).resolves.toMatchObject({ accepted: true })
|
||||
expect(write.mock.calls.filter(([, data]) => data === '\r')).toHaveLength(1)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('proceeds unchanged when the controller exposes no probe', async () => {
|
||||
const { runtime, handle, write } = await makeRuntimeWithLeafHandle({})
|
||||
|
||||
@@ -350,6 +396,9 @@ describe('push-on-idle orchestration delivery absence gate', () => {
|
||||
resolveProbe(null)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
resolveProbe(null)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await flushAgentPromptStart()
|
||||
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
expect(stub.rows[0].delivered_at).toBeNull()
|
||||
@@ -360,6 +409,9 @@ describe('push-on-idle orchestration delivery absence gate', () => {
|
||||
resolveProbe(null)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
resolveProbe(null)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await flushAgentPromptStart()
|
||||
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
STALE_PTY_ID,
|
||||
@@ -446,6 +498,9 @@ describe('push-on-idle orchestration delivery absence gate', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
// Why twice: the probe continuation yields a turn before delivering.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
resolveProbe(null)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await flushAgentPromptStart()
|
||||
|
||||
const payloads = write.mock.calls
|
||||
.map(([, data]) => data)
|
||||
@@ -507,6 +562,9 @@ describe('push-on-idle orchestration delivery absence gate', () => {
|
||||
runtime.deliverPendingMessagesForHandle(handle)
|
||||
resolveProbe(null)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
resolveProbe(null)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await flushAgentPromptStart()
|
||||
|
||||
const pointerWrites = () =>
|
||||
write.mock.calls.filter(
|
||||
@@ -525,18 +583,18 @@ describe('push-on-idle orchestration delivery absence gate', () => {
|
||||
|
||||
// The submit-time liveness probe settles the flight, then the parked
|
||||
// trigger arms its own delivery probe for the newer sequence.
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
runtime.onPtyData(STALE_PTY_ID, '\x1b]0;Codex done\x07', 300)
|
||||
resolveProbe(null)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
resolveProbe(null)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await flushAgentPromptStart()
|
||||
|
||||
expect(pointerWrites()).toHaveLength(2)
|
||||
expect(pointerWrites()[1]?.[1]).toContain('You have 1 orchestration message')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
resolveProbe(null)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
expect(stub.markAsDelivered).toHaveBeenCalledTimes(2)
|
||||
expect(stub.rows.map((row) => row.delivered_at)).toEqual(
|
||||
stub.rows.map(() => expect.any(String))
|
||||
@@ -560,6 +618,7 @@ describe('push-on-idle orchestration delivery absence gate', () => {
|
||||
runtime.deliverPendingMessagesForHandle(handle)
|
||||
stub.insert('second')
|
||||
runtime.deliverPendingMessagesForHandle(handle)
|
||||
await flushAgentPromptStart()
|
||||
|
||||
const pointerWrites = () =>
|
||||
write.mock.calls.filter(
|
||||
@@ -571,11 +630,13 @@ describe('push-on-idle orchestration delivery absence gate', () => {
|
||||
|
||||
// Settle flushes the parked trigger; both still-pending rows are counted,
|
||||
// while the newer sequence authorizes exactly one fresh pointer.
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
runtime.onPtyData(STALE_PTY_ID, '\x1b]0;Codex done\x07', 300)
|
||||
await flushAgentPromptStart()
|
||||
expect(pointerWrites()).toHaveLength(2)
|
||||
expect(pointerWrites()[1]?.[1]).toContain('You have 1 orchestration message')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
expect(stub.markAsDelivered).toHaveBeenCalledTimes(2)
|
||||
expect(stub.rows.map((row) => row.delivered_at)).toEqual(
|
||||
stub.rows.map(() => expect.any(String))
|
||||
@@ -597,16 +658,17 @@ describe('push-on-idle orchestration delivery absence gate', () => {
|
||||
stub.insert('for the old session')
|
||||
|
||||
runtime.deliverPendingMessagesForHandle(handle)
|
||||
await flushAgentPromptStart()
|
||||
expect(write).toHaveBeenCalledTimes(1)
|
||||
|
||||
runtime.onPtyExit(STALE_PTY_ID, 0)
|
||||
runtime.onPtySpawned(STALE_PTY_ID)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
expect(write.mock.calls.filter(([, data]) => data === '\r')).toHaveLength(0)
|
||||
expect(stub.markAsDelivered).toHaveBeenCalledOnce()
|
||||
expect(stub.markAsUndelivered).toHaveBeenCalledOnce()
|
||||
expect(stub.rows[0].delivered_at).toBeNull()
|
||||
expect(stub.markAsUndelivered).not.toHaveBeenCalled()
|
||||
expect(stub.rows[0].delivered_at).toEqual(expect.any(String))
|
||||
|
||||
// The replacement's own delivery starts a fresh flight and completes —
|
||||
// but only once ITS live title proves idle; the dead session's live status
|
||||
@@ -622,10 +684,10 @@ describe('push-on-idle orchestration delivery absence gate', () => {
|
||||
const payloadWrites = write.mock.calls.filter(
|
||||
([, data]) => typeof data === 'string' && data.includes('orca orchestration check')
|
||||
)
|
||||
expect(payloadWrites).toHaveLength(2)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(write.mock.calls.filter(([, data]) => data === '\r')).toHaveLength(1)
|
||||
expect(stub.markAsDelivered).toHaveBeenCalledTimes(2)
|
||||
expect(payloadWrites).toHaveLength(1)
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
expect(write.mock.calls.filter(([, data]) => data === '\r')).toHaveLength(0)
|
||||
expect(stub.markAsDelivered).toHaveBeenCalledOnce()
|
||||
expect(stub.rows[0].delivered_at).toEqual(expect.any(String))
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
@@ -643,17 +705,18 @@ describe('push-on-idle orchestration delivery absence gate', () => {
|
||||
runtime.deliverPendingMessagesForHandle(handle)
|
||||
stub.insert('second')
|
||||
runtime.deliverPendingMessagesForHandle(handle)
|
||||
await Promise.resolve()
|
||||
await flushAgentPromptStart()
|
||||
|
||||
runtime.onPtyExit(STALE_PTY_ID, 0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
expect(write.mock.calls.filter(([, data]) => data === '\r')).toHaveLength(0)
|
||||
expect(stub.markAsDelivered).toHaveBeenCalledOnce()
|
||||
expect(stub.markAsUndelivered).toHaveBeenCalledOnce()
|
||||
expect(stub.markAsUndelivered).not.toHaveBeenCalled()
|
||||
// No stray settle flushed the parked trigger into the dead pty.
|
||||
expect(write).toHaveBeenCalledTimes(1)
|
||||
expect(stub.rows.every((row) => row.delivered_at === null)).toBe(true)
|
||||
expect(stub.rows[0].delivered_at).toEqual(expect.any(String))
|
||||
expect(stub.rows[1].delivered_at).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
@@ -672,17 +735,18 @@ describe('push-on-idle orchestration delivery absence gate', () => {
|
||||
stub.insert('orphaned snapshot')
|
||||
|
||||
runtime.deliverPendingMessagesForHandle(handle)
|
||||
await flushAgentPromptStart()
|
||||
expect(write).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Replace the leaf object the armed callback closed over, then exit.
|
||||
publishLeafGraph(runtime, STALE_PTY_ID)
|
||||
runtime.onPtyExit(STALE_PTY_ID, 0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
expect(write.mock.calls.filter(([, data]) => data === '\r')).toHaveLength(0)
|
||||
expect(stub.markAsDelivered).toHaveBeenCalledOnce()
|
||||
expect(stub.markAsUndelivered).toHaveBeenCalledOnce()
|
||||
expect(stub.rows[0].delivered_at).toBeNull()
|
||||
expect(stub.markAsUndelivered).not.toHaveBeenCalled()
|
||||
expect(stub.rows[0].delivered_at).toEqual(expect.any(String))
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user