mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 00:02:56 +00:00
Merge remote-tracking branch 'origin/main' into fix-19391
# Conflicts: # config/reliability-gates.jsonc
This commit is contained in:
@@ -168,6 +168,88 @@
|
||||
],
|
||||
"demotionRule": "Keep experimental until CI soak; investigate failures without relaxing fidelity, liveness or resource-count assertions."
|
||||
},
|
||||
{
|
||||
"id": "terminal-performance.osc-status-scan-budget",
|
||||
"title": "OSC 9999 status bursts reuse forward terminator searches",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime",
|
||||
"layer": "shared-unit-and-runtime-unit",
|
||||
"surfaces": ["terminal output ingestion", "terminal agent-status side effects"],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "daemon", "ssh", "remote-runtime"],
|
||||
"coverageNotes": "Shared parser tests cover provider-independent bytes; main and renderer contract tests cover status and terminal-output delivery. Live Linux, Windows, WSL, SSH and remote-runtime processes are not launched. Execution, liveness, paths, wire formats and mobile UI are unchanged.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/blob/main/src/shared/agent-status-osc.ts"
|
||||
],
|
||||
"invariant": "Terminal status parsing preserves ordinary UTF-16 output, every valid payload in order, the last valid payload's clean-output offset, earliest BEL/ST termination, and incomplete-frame caps while searching each complete burst only forward.",
|
||||
"oracle": "Two 5,000-frame bursts using exclusively BEL or ST produce every expected payload and ordinary output byte with at most twice the input length in native search ranges. Mixed terminators, every split through prefixes/JSON/ST, independent parser interleaving, malformed payloads, exact pending-cap boundaries and oversized complete frames retain their previous behavior. A one-character echo performs no terminator search. Parsed output chunks are not retained in legacy regular-expression state.",
|
||||
"commands": [
|
||||
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-status-osc.test.ts src/shared/agent-status-osc-scan-budget.test.ts src/shared/agent-status-types.test.ts src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/shared/agent-status-osc.test.ts",
|
||||
"src/shared/agent-status-osc-scan-budget.test.ts",
|
||||
"src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/shared/agent-status-osc-scan-budget.test.ts",
|
||||
"assertions": [
|
||||
"reads each burst only forward with terminator %j",
|
||||
"keeps a one-character input echo on the ordinary-output path",
|
||||
"does not retain the output chunk in legacy regular-expression state"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/shared/agent-status-osc.test.ts",
|
||||
"assertions": [
|
||||
"uses the earliest mixed terminator and counts only parsed payload offsets",
|
||||
"keeps a distant ST usable after many intervening BEL frames",
|
||||
"preserves every split of prefixes, JSON, and both terminators across independent streams",
|
||||
"applies the pending cap only to incomplete frames"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-09-07",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-status-osc.test.ts src/shared/agent-status-osc-scan-budget.test.ts src/shared/agent-status-types.test.ts src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 23.96,
|
||||
"summary": "153 tests passed across eight files. Independent baseline differential review also matched 3,704 streams and 45,141 chunk results."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 30,
|
||||
"scope": "Shared parser and main/renderer terminal contract tests; no launched app."
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "Initial deterministic local validation; CI soak has not started."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The unchanged parser failed both search budgets: 618,560,785 searched characters for the 246,390-character BEL burst and 631,068,285 for the 251,390-character ST burst. Reusing forward match positions reduces those totals to 492,770 and 502,770 characters respectively, within twice the input length, with identical complete results."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Warmed Node 24 macOS CPU medians: a 250 KB / 5,000-status burst fell from 100.240 ms to 1.903 ms; a 1 MB / 20,000-status burst fell from 1,588.492 ms to 7.369 ms. Wall-clock medians were 134.878 to 2.484 ms and 2,536.927 to 12.902 ms under concurrent machine load. These are adverse bursts, not typical callback sizes. The ordinary-output path is unchanged; one-character echo CPU was 2.173 versus 2.342 ms per 100,000 calls, and single-status BEL CPU was 10.835 versus 10.897 ms per 30,000 calls. Both native terminator searches advance monotonically within the current chunk; no regex state retains the input. No scheduling, polling, provider calls, pending limits, output filtering or payload parsing changed."
|
||||
},
|
||||
"knownGaps": [
|
||||
"Live Electron input latency and Linux/Windows/WSL/SSH execution have not been measured for this parser-only change.",
|
||||
"Fragmented unterminated payload accumulation and downstream processing of large status arrays remain outside this complete-burst search budget."
|
||||
],
|
||||
"promotionCriteria": [
|
||||
"Complete CI soak while preserving byte fidelity and deterministic search budgets."
|
||||
],
|
||||
"demotionRule": "Keep experimental until CI soak; investigate output, offset, carry or search-budget failures without relaxing the oracle."
|
||||
},
|
||||
{
|
||||
"id": "terminal-performance.padded-fullscreen-redraw",
|
||||
"title": "Fullscreen redraw padding does not stall terminal delivery",
|
||||
@@ -13743,6 +13825,7 @@
|
||||
"invariant": "Starting a worker in the coordinator's current workspace must materialize one inactive terminal tab before worker-start returns, preserve coordinator focus, and remain exactly once after workspace re-entry. After an app update or restart, an exact live legacy worker must fence automatic provider resume, adopt its original PTY into its original background pane, retain readable output, and clear the resume record without spawning, writing, signalling, interrupting, replacing, or focusing the worker. A current-contract worker whose renderer graph identity is temporarily absent must retain its Dispatch capability and settle exactly once from exact hook-attested handle, pane, and process evidence; otherwise only an exact attested coordinator may take over. A worker_done caller may report success only after the owning runtime returns an explicit lifecycle verdict or authoritative reads prove that the exact Task, Dispatch, and worker report receipt settled the expected outcome. Federated terminal settlement must remain replay-eligible until the worker durably acknowledges it, and identical same-outcome retries must converge idempotently. Independently updated clients and worker servers must preserve the negotiated protocol: current peers use Run-home lifecycle settlement, while protocol v1/v2 peers retain their legacy completion path without receiving newer-only fields. A federated worker may accept only the authority defined by its negotiated protocol. An exact existing target workspace must receive a discoverable tab without stealing coordinator focus; if renderer reveal fails, worker-start must expose that the live worker remains background-only. Run and Dispatch checks must resolve through the caller's stable pane identity when a terminal handle is reminted, while a live handle outranks mismatched pane metadata. A nested worker's creator edge requires the current creator pane, process incarnation, and owning Run generation; reminting and rebinding that pane to another Run must remove the stale edge. Explicit legacy terminal inspection remains handle-scoped, and remote or headless worker presentation remains background-only.",
|
||||
"oracle": "Drive Run create, Task create, and worker-start through production Electron runtimes with a deterministic Codex fixture. Require append-only ledgers with one still-live PID and no interruption, a visible inactive worker tab while the coordinator stays active, Run delivery through stable pane identity, and stable PTY/incarnation, tab, leaf, worktree, Task, and Dispatch across workspace re-entry. In a restart journey, retain the original daemon PTY and PID, remove renderer ownership, retain sleeping-session evidence, mark the Dispatch legacy, relaunch, and require exact inactive tab adoption, readable ACK output, cleared resume state, one spawn, and no resume argv or Conversation interrupted text after another workspace round trip. The service oracle removes renderer lookup identity from current-contract callers while retaining real restored-PTY and hook commitments, replays authenticated completion and takeover across fresh runtimes, and requires one Task, Dispatch, terminal authority, message, mutation, ordinary-mail delivery, remote process fencing, and unchanged fixture marker bytes while foreign pane evidence remains rejected. Unit tests separately remint a creator pane and process from Run A into Run B, require the nested Run A worker to fall back to its current coordinator, require indexed query plans, and bound 300 Task reads with 50,000 retained Runs. They also assert authority-specific legacy affordances, exact identity and owner matching, retained-output fallback, pane-stable routing, federated non-activation, and SSH fallback parity.",
|
||||
"commands": [
|
||||
"ORCA_BACKGROUND_LAUNCH=1 npx vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration/messaging/check-worker-federated-attachment.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts src/main/ssh/ssh-remote-orca-cli.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration-lifecycle-rejection.test.ts src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts src/cli/handlers/orchestration-migration.test.ts",
|
||||
@@ -13757,6 +13840,7 @@
|
||||
"pnpm run build:cli && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/orchestration-worker-settlement-release-cli.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/runtime/rpc/methods/orchestration/messaging/check-worker-federated-attachment.test.ts",
|
||||
"src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts",
|
||||
"src/main/runtime/orchestration/formatter.test.ts",
|
||||
"src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts",
|
||||
@@ -13780,6 +13864,13 @@
|
||||
"tests/e2e/orchestration-worker-settlement-release-cli.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/runtime/rpc/methods/orchestration/messaging/check-worker-federated-attachment.test.ts",
|
||||
"assertions": [
|
||||
"replays the coordinator instruction and takes its ack after the app restarts",
|
||||
"files loopback mail once under the local Dispatch Run without replacing its owner"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts",
|
||||
"assertions": [
|
||||
@@ -14278,7 +14369,7 @@
|
||||
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "ssh"],
|
||||
"coverageNotes": "Deterministic service tests cover release-versus-reuse ordering, transactional retain and takeover cancellation, exact host/pane/process identity, dead external/user-owned/transferred/stopped/abandoned reconciliation, host-partition persistence and legacy retirement replay with an absent web-terminal layout map, conservative unknown provider and legacy metadata handling, immutable transcript and bounded terminal archives, mutation restart, reset cleanup, replay idempotency, and 50-resource accounting. A macOS Electron journey invokes the freshly compiled worker-release CLI after the worker process disappears, then independently checks released SQLite state and coordinator liveness. Injected inventories cover local and SSH provider routing; live SSH, WSL, Windows, paired-runtime, and provider-close lost-ack journeys remain explicit gaps.",
|
||||
"coverageNotes": "New phones explicitly report terminal takeover on real user sends, throttled per owning client and handle. Host byte lanes perform zero orchestration SQL work; local and injected SSH report tests fence release. Phones predating this build do not fence release. Deterministic service tests cover release-versus-reuse ordering, transactional retain and takeover cancellation, exact host/pane/process identity, dead external/user-owned/transferred/stopped/abandoned reconciliation, host-partition persistence and legacy retirement replay with an absent web-terminal layout map, conservative unknown provider and legacy metadata handling, immutable transcript and bounded terminal archives, mutation restart, reset cleanup, replay idempotency, and 50-resource accounting. A macOS Electron journey invokes the freshly compiled worker-release CLI after the worker process disappears, then independently checks released SQLite state and coordinator liveness. Injected inventories cover local and SSH provider routing; live SSH, WSL, Windows, paired-runtime, and provider-close lost-ack journeys remain explicit gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/12355",
|
||||
"https://github.com/stablyai/orca/issues/13860",
|
||||
@@ -14289,6 +14380,8 @@
|
||||
"invariant": "A settled Dispatch may close only its one coordinator-created terminal lease. Explicit reuse, real user input, retain, identity or host change, ambiguity, and another resource for the same exact host/pane/process must fence closure. Once the authoritative owning provider positively excludes the resource's exact immutable process incarnation, even an external, user-owned, or transferred dead resource must converge to released without any process close. Unknown host scope, missing incarnation metadata, or unavailable inventory must remain retained. Exact terminal-close persistence must settle when a host partition omits renderer-owned layout state. Output preservation and the requested-to-releasing transition are atomic, archives remain readable without the provider file, retries resume idempotently, and orchestration reset removes archive and authority state.",
|
||||
"oracle": "Record release intent for a settled owner, attempt exact reuse before close, and require worker-start to fail with terminal_release_in_progress while the terminal stays open; then release the original owner exactly once. Race retain and real user input against a controlled archive promise and require no committed archive or close. Rebase a closed web-terminal host partition without terminalLayoutsByTabId and require the persistence write to complete while preserving host-authoritative membership; replay a valid legacy retirement under the same omission and require exact membership removal plus revision advancement. For retained external, user-owned, transferred, stopped, and abandoned resources, run one fresh inventory against the exact local/WSL or SSH provider: an exact live incarnation and every unknown inventory shape stay retained, while positive absence atomically sets ownership_state and release_state to released with processAction none and zero closeTerminal calls. Change host or process identity and inject duplicate resource evidence to require retention. Freeze a structured transcript, delete its source file, and require archived worker-read to return the same bounded redacted messages. Restart a pending mutation, reset orchestration state, and create 50 resources while asserting replay convergence, zero orphan rows, two-query worker listing, and no unrelated close.",
|
||||
"commands": [
|
||||
"ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts",
|
||||
"ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/mobile-worker-takeover-send-sites.test.ts mobile/src/terminal/worker-terminal-takeover-report.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/pty-inventory-liveness-verdict.test.ts src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
|
||||
@@ -14297,6 +14390,9 @@
|
||||
"pnpm run build:cli && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/orchestration-worker-settlement-release-cli.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts",
|
||||
"mobile/src/session/mobile-worker-takeover-send-sites.test.ts",
|
||||
"mobile/src/terminal/worker-terminal-takeover-report.test.ts",
|
||||
"src/main/runtime/pty-inventory-liveness-verdict.test.ts",
|
||||
"src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts",
|
||||
"src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts",
|
||||
@@ -14309,6 +14405,20 @@
|
||||
"tests/e2e/orchestration-worker-settlement-release-cli.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts",
|
||||
"assertions": [
|
||||
"a handle-addressed phone report fences %s worker release",
|
||||
"mobile %s bytes do no orchestration database work"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "mobile/src/session/mobile-worker-takeover-send-sites.test.ts",
|
||||
"assertions": [
|
||||
"%s reports on its send target once per handle per 30 seconds",
|
||||
"%s never reports takeover"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/pty-inventory-liveness-verdict.test.ts",
|
||||
"assertions": [
|
||||
|
||||
@@ -168,9 +168,10 @@ describe('orchestration kernel', () => {
|
||||
expect(kernel).toContain(
|
||||
'`projection.attention` categories, `projection.attention.requiresAction`, and literal `projection.nextAction` argv'
|
||||
)
|
||||
expect(kernel).toContain(
|
||||
'An `inspect` `nextAction` on a `live` row with `attention.requiresAction` false is informational, not a command to re-run: keep waiting with `check --wait`'
|
||||
)
|
||||
// Unverifiable workers can still owe release; the guide must explain the action itself.
|
||||
expect(kernel).toContain('A `none` `nextAction` has no argv to run')
|
||||
expect(kernel).toContain('read `liveness.reason` and keep waiting with `check --wait`')
|
||||
expect(kernel).toContain('Absence never earns an argv; settlement and pending work still do')
|
||||
expect(kernel).toContain('choose `worker-stop` or `worker-abandon`')
|
||||
})
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.
|
||||
src/main/runtime/orca-runtime-create-terminal.ts
|
||||
src/main/runtime/orca-runtime-deliver-pending-messages.ts
|
||||
src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts
|
||||
src/main/runtime/orca-runtime-fence-automation-owner.ts
|
||||
src/main/runtime/orca-runtime-automation-operations.ts
|
||||
src/main/runtime/orca-runtime-file-commands.ts
|
||||
src/main/runtime/orca-runtime-fit-override-listeners.ts
|
||||
src/main/runtime/orca-runtime-focus-terminal.ts
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 42m">
|
||||
<title>downloads: 42m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 44m">
|
||||
<title>downloads: 44m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">42m</text>
|
||||
<text x="90" y="14">42m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">44m</text>
|
||||
<text x="90" y="14">44m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
@@ -1,3 +1,8 @@
|
||||
// Takeover RPCs have their own send-site integration tests; these fixtures script PTY acknowledgements.
|
||||
vi.mock('../terminal/worker-terminal-takeover-report', () => ({
|
||||
reportWorkerTerminalUserInput: vi.fn()
|
||||
}))
|
||||
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -309,10 +309,13 @@ describe('typeMobileNativeChatCommandWithOutcome', () => {
|
||||
|
||||
await expect(result).resolves.toBe('accepted')
|
||||
expect(
|
||||
vi.mocked(client.sendRequest).mock.calls.map((call) => {
|
||||
const params = call[1] as { text: string; enter: boolean }
|
||||
return { text: params.text, enter: params.enter }
|
||||
})
|
||||
vi
|
||||
.mocked(client.sendRequest)
|
||||
.mock.calls.filter(([method]) => method === 'terminal.send')
|
||||
.map((call) => {
|
||||
const params = call[1] as { text: string; enter: boolean }
|
||||
return { text: params.text, enter: params.enter }
|
||||
})
|
||||
).toEqual(
|
||||
['\x15', '/', 'm', 'o', 'd', 'e', 'l', '\r'].map((text) => ({
|
||||
text,
|
||||
@@ -338,7 +341,12 @@ describe('typeMobileNativeChatCommandWithOutcome', () => {
|
||||
await vi.runAllTimersAsync()
|
||||
await result
|
||||
|
||||
const params = vi.mocked(client.sendRequest).mock.calls.map((call) => call[1]) as Array<{
|
||||
// Why the filter: an accepted send also fires the unawaited takeover report, which is not a
|
||||
// terminal.send and carries no draft.
|
||||
const params = vi
|
||||
.mocked(client.sendRequest)
|
||||
.mock.calls.filter((call) => call[0] === 'terminal.send')
|
||||
.map((call) => call[1]) as Array<{
|
||||
text: string
|
||||
resolvedLaunchDraft?: { text: string; createdAt: number }
|
||||
}>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { reportWorkerTerminalUserInput } from '../terminal/worker-terminal-takeover-report'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
@@ -64,7 +65,11 @@ export async function sendMobileNativeChatMessageWithOutcome(
|
||||
// pins the composer for twice as long.
|
||||
{ timeoutMs, budgetSpansConnect: true }
|
||||
)
|
||||
return isTerminalSendRpcAccepted(response) ? 'accepted' : 'rejected'
|
||||
if (!isTerminalSendRpcAccepted(response)) {
|
||||
return 'rejected'
|
||||
}
|
||||
reportWorkerTerminalUserInput(args.client, args.terminal)
|
||||
return 'accepted'
|
||||
} catch (error) {
|
||||
// Why: a logical relay↔direct cutover rejects the in-flight send without
|
||||
// knowing whether its frame reached the wire (the desktop may have delivered
|
||||
|
||||
@@ -66,11 +66,11 @@ const HEAD_MAIN_HOOK_SHA256 = '10071240ef9edafc2b9c8bed73be83dceaf7828e3b29f17da
|
||||
const HEAD_HOOK_BINDING_SHA256 = '1dadb8c3dc0573ea20659ce7251629669e618dd0effaeac3a4536b29c2e865a1'
|
||||
const HEAD_CALLBACK_IDENTITY_SHA256 =
|
||||
'2a9e4825df007f6ef53b81aa5004991d6318eee7507b44d625c07e630be432eb'
|
||||
const HEAD_CALLBACK_BODY_SHA256 = '22103ba85a86e3a3fcb80a7509c7a455d79863010cde3af02db6565b55e3ebe9'
|
||||
const HEAD_CALLBACK_BODY_SHA256 = 'af7f3c62954250d4be7ee432ecd10dc2689792aad8230fed2d1d68bbc892d776'
|
||||
const HEAD_EFFECT_SHA256 = 'd9ebfaabc1e79773cdada7ab370b20459ed972f1f8edce1652199f4d0391cd13'
|
||||
const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581'
|
||||
const HEAD_NESTED_FUNCTION_SHA256 =
|
||||
'536c72b233c813bb0cea164b090bdce5406ceb965bbc5b83c1f89b89b46f3821'
|
||||
'fde6679349ab2b8c30c7e627841ff99bd1dd24441ee95323d0aa70230422ae24'
|
||||
const HEAD_NATIVE_REGISTRATION_SHA256 =
|
||||
'cab85e4e4a3f43289ba93ddea9ccce57aea83e0bf14fd1620a965aad0c1cb49e'
|
||||
const HEAD_NATIVE_REMOVAL_SHA256 =
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { beforeEach, afterEach, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { resetWorkerTerminalTakeoverReportsForTest } from '../terminal/worker-terminal-takeover-report'
|
||||
import { useMobileSessionTerminalSendActions } from './use-mobile-session-terminal-send-actions'
|
||||
import { useMobileSessionTerminalInput } from './use-mobile-session-terminal-input'
|
||||
import { useMobileTerminalPaste } from './use-mobile-terminal-paste'
|
||||
import { useTerminalLiveInputCommit } from '../terminal/use-terminal-live-input-commit'
|
||||
import { routeDictationTranscript } from '../terminal/terminal-live-dictation-routing'
|
||||
import {
|
||||
sendMobileNativeChatMessageWithOutcome,
|
||||
clearMobileNativeChatInput
|
||||
} from './mobile-native-chat-send'
|
||||
import { sendMobileTerminalQueryReply } from '../terminal/mobile-terminal-query-reply'
|
||||
import { createTerminalAndSendPrompt } from './pr-ai-triage-launch'
|
||||
import { useMobileDiffReviewSendActions } from './use-mobile-diff-review-send-actions'
|
||||
import { pasteMobileNativeChatImagePaths } from './mobile-native-chat-image-send'
|
||||
|
||||
vi.mock('react-native', () => ({ Keyboard: { dismiss: vi.fn() } }))
|
||||
vi.mock('../platform/haptics', () => ({ triggerError: vi.fn(), triggerSuccess: vi.fn() }))
|
||||
vi.mock('expo-clipboard', () => ({ getStringAsync: async () => 'pasted text' }))
|
||||
vi.mock('expo-file-system', () => ({ File: class {}, Paths: { cache: '/tmp' } }))
|
||||
vi.mock('expo-image-manipulator', () => ({ ImageManipulator: {}, SaveFormat: {} }))
|
||||
|
||||
const REPORT = 'orchestration.workerTerminalUserInput'
|
||||
const ref = <T>(current: T) => ({ current })
|
||||
const renderers: ReactTestRenderer[] = []
|
||||
function clientFixture() {
|
||||
return {
|
||||
sendRequest: vi.fn(async (method: string) => ({
|
||||
id: 'rpc',
|
||||
ok: true as const,
|
||||
result:
|
||||
method === 'session.tabs.createTerminal'
|
||||
? { tab: { type: 'terminal', id: 'tab', terminal: 'term-1', title: 'test' } }
|
||||
: method === REPORT
|
||||
? { changed: 1 }
|
||||
: { send: { accepted: true } }
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function mountSendSites(client: ReturnType<typeof clientFixture>, handle = 'term-1') {
|
||||
const activeHandleRef = ref<string | null>(handle)
|
||||
const activeSessionTabTypeRef = ref<string | null>('terminal')
|
||||
const sendLiveTerminalInputRef = ref(async (_handle: string, _text: string) => false)
|
||||
const scope = {
|
||||
client,
|
||||
clientRef: ref(client),
|
||||
activeHandle: handle,
|
||||
activeHandleRef,
|
||||
activeSessionTabTypeRef,
|
||||
connState: 'connected',
|
||||
connStateRef: ref('connected'),
|
||||
activeSessionTab: { type: 'terminal', terminal: handle },
|
||||
sendingRef: ref(false),
|
||||
canSend: true,
|
||||
deviceTokenRef: ref('phone'),
|
||||
liveInputRef: ref(null),
|
||||
commandInputRef: ref(null),
|
||||
liveInputFocusTimerRef: ref(null),
|
||||
sendLiveTerminalInputRef,
|
||||
getSendCompletionGeneration: () => 0,
|
||||
showToast: vi.fn(),
|
||||
ptyModesRef: ref(new Map([[handle, { altScreen: true }]])),
|
||||
terminalGestureInputBucketsRef: ref(new Map()),
|
||||
terminalGestureInputQueuesRef: ref(new Map()),
|
||||
terminalGestureInputInFlightRef: ref(new Set()),
|
||||
bufferedTerminalDraftState: {
|
||||
input: 'command',
|
||||
beginBufferedTerminalDraftSend: vi.fn(),
|
||||
restoreRejectedDraft: vi.fn(),
|
||||
settleBufferedTerminalDraftSend: () => true
|
||||
}
|
||||
}
|
||||
let actions!: ReturnType<typeof useMobileSessionTerminalSendActions>
|
||||
let live!: ReturnType<typeof useTerminalLiveInputCommit>
|
||||
let gestures!: ReturnType<typeof useMobileSessionTerminalInput>
|
||||
let paste!: ReturnType<typeof useMobileTerminalPaste>
|
||||
let diff!: ReturnType<typeof useMobileDiffReviewSendActions>
|
||||
function Harness() {
|
||||
live = useTerminalLiveInputCommit({
|
||||
activeHandle: handle,
|
||||
activeHandleRef,
|
||||
activeSessionTabType: 'terminal',
|
||||
activeSessionTabTypeRef,
|
||||
connected: true,
|
||||
liveInputRef: ref(null),
|
||||
liveInputTerminalHandles: new Set([handle]),
|
||||
liveInputTerminalHandlesRef: ref(new Set([handle])),
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture: vi.fn()
|
||||
})
|
||||
actions = useMobileSessionTerminalSendActions({
|
||||
...scope,
|
||||
handleLiveInputAccessoryBytes: live.handleLiveInputAccessoryBytes
|
||||
} as never)
|
||||
gestures = useMobileSessionTerminalInput(scope as never)
|
||||
paste = useMobileTerminalPaste({
|
||||
...scope,
|
||||
flushPendingLiveInputBeforeExternalSend: live.flushPendingLiveInputBeforeExternalSend,
|
||||
getActiveWorktreeConnectionId: async () => null,
|
||||
onError: vi.fn(),
|
||||
onSuccess: vi.fn(),
|
||||
refreshCanPaste: vi.fn()
|
||||
} as never)
|
||||
diff = useMobileDiffReviewSendActions({
|
||||
client: client as unknown as RpcClient,
|
||||
connState: 'connected',
|
||||
worktreeId: 'workspace',
|
||||
screenState: { kind: 'loading' },
|
||||
setActionError: vi.fn(),
|
||||
setSendSheet: vi.fn(),
|
||||
saveCommentsAndReviewState: vi.fn()
|
||||
} as never)
|
||||
return null
|
||||
}
|
||||
act(() => {
|
||||
renderers.push(create(createElement(Harness)))
|
||||
})
|
||||
let text = ''
|
||||
return {
|
||||
'live field': async () => {
|
||||
text += 'x'
|
||||
live.handleLiveInputChange({ nativeEvent: { text, isComposing: false } })
|
||||
await live.flushPendingLiveInputBeforeExternalSend(handle)
|
||||
},
|
||||
'live submit': () => live.handleLiveInputSubmit(),
|
||||
'live accessory': async () => {
|
||||
live.handleLiveInputChange({ nativeEvent: { text: 'composing', isComposing: true } })
|
||||
await live.handleLiveInputAccessoryBytes({ bytes: '\x1b[A' })
|
||||
},
|
||||
'raw accessory': () => actions.handleAccessoryKey({ bytes: '\x1b[A' } as never),
|
||||
'buffered submit': () => actions.handleSend(),
|
||||
'gesture arrows': async () => {
|
||||
await gestures.handleTerminalInput(handle, '\x1b[A')
|
||||
await gestures.flushTerminalGestureInput(handle)
|
||||
},
|
||||
paste: () => paste(),
|
||||
dictation: async () => {
|
||||
const route = routeDictationTranscript('dictated text', true)
|
||||
expect(route.kind).toBe('live-insert')
|
||||
await actions.sendLiveTerminalInput(handle, route.text)
|
||||
},
|
||||
'native chat': () =>
|
||||
sendMobileNativeChatMessageWithOutcome({
|
||||
client: client as unknown as RpcClient,
|
||||
terminal: handle,
|
||||
text: 'hello'
|
||||
}),
|
||||
'query reply': () =>
|
||||
sendMobileTerminalQueryReply({
|
||||
bytes: '\x1b[0n',
|
||||
client,
|
||||
clientId: 'phone',
|
||||
connected: true,
|
||||
handle,
|
||||
hostSupportsQueryReplyInput: true,
|
||||
subscribedTerminals: new Set([handle])
|
||||
}),
|
||||
'image heal': () =>
|
||||
clearMobileNativeChatInput({
|
||||
client: client as unknown as RpcClient,
|
||||
terminal: handle,
|
||||
clearInput: '\x15'
|
||||
}),
|
||||
'image attachment': () =>
|
||||
pasteMobileNativeChatImagePaths({
|
||||
client,
|
||||
terminal: handle,
|
||||
deviceToken: 'phone',
|
||||
imagePaths: ['/tmp/picture.png'],
|
||||
followedByText: true
|
||||
}),
|
||||
'PR triage': () => createTerminalAndSendPrompt(client, 'workspace', 'fix checks'),
|
||||
'diff review': () => diff.sendPromptToTerminal(handle, []),
|
||||
programmatic: () => client.sendRequest('terminal.send')
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
resetWorkerTerminalTakeoverReportsForTest()
|
||||
})
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
for (const renderer of renderers.splice(0)) {
|
||||
renderer.unmount()
|
||||
}
|
||||
})
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const realSites = [
|
||||
'live field',
|
||||
'live submit',
|
||||
'live accessory',
|
||||
'raw accessory',
|
||||
'buffered submit',
|
||||
'gesture arrows',
|
||||
'paste',
|
||||
'dictation',
|
||||
'native chat'
|
||||
] as const
|
||||
it.each(realSites)('%s reports on its send target once per handle per 30 seconds', async (site) => {
|
||||
const client = clientFixture()
|
||||
const sites = mountSendSites(client)
|
||||
const invoke = async () => {
|
||||
await act(async () => {
|
||||
await sites[site]()
|
||||
})
|
||||
}
|
||||
const reports = () => client.sendRequest.mock.calls.filter(([method]) => method === REPORT)
|
||||
await invoke()
|
||||
await invoke()
|
||||
expect(
|
||||
client.sendRequest.mock.calls.filter(([method]) => method === 'terminal.send').length
|
||||
).toBeGreaterThanOrEqual(2)
|
||||
expect(reports()).toHaveLength(1)
|
||||
expect(reports()[0]).toEqual([REPORT, { terminal: 'term-1' }, expect.any(Object)])
|
||||
await vi.advanceTimersByTimeAsync(29_999)
|
||||
await invoke()
|
||||
expect(reports()).toHaveLength(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await invoke()
|
||||
expect(reports()).toHaveLength(2)
|
||||
const other = mountSendSites(client, 'term-2')
|
||||
await act(async () => {
|
||||
await other[site]()
|
||||
})
|
||||
expect(reports()).toHaveLength(3)
|
||||
expect(reports()[2][1]).toEqual({ terminal: 'term-2' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
'query reply',
|
||||
'image heal',
|
||||
'image attachment',
|
||||
'PR triage',
|
||||
'diff review',
|
||||
'programmatic'
|
||||
] as const)('%s never reports takeover', async (site) => {
|
||||
const client = clientFixture()
|
||||
const sites = mountSendSites(client)
|
||||
await act(async () => {
|
||||
await sites[site]()
|
||||
await sites[site]()
|
||||
})
|
||||
expect(client.sendRequest.mock.calls.some(([method]) => method === 'terminal.send')).toBe(true)
|
||||
expect(client.sendRequest.mock.calls.filter(([method]) => method === REPORT)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each(realSites)('%s does not report a rejected send', async (site) => {
|
||||
const client = clientFixture()
|
||||
client.sendRequest.mockResolvedValue({
|
||||
id: 'rpc',
|
||||
ok: true,
|
||||
result: { send: { accepted: false } }
|
||||
})
|
||||
const sites = mountSendSites(client)
|
||||
await act(async () => {
|
||||
await sites[site]()
|
||||
})
|
||||
expect(client.sendRequest.mock.calls.filter(([method]) => method === REPORT)).toHaveLength(0)
|
||||
})
|
||||
@@ -1,3 +1,8 @@
|
||||
// Takeover RPCs have their own send-site integration tests; these fixtures script PTY acknowledgements.
|
||||
vi.mock('../terminal/worker-terminal-takeover-report', () => ({
|
||||
reportWorkerTerminalUserInput: vi.fn()
|
||||
}))
|
||||
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -6,6 +6,12 @@ import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS } from './mobile-native-chat-send'
|
||||
import { useMobileNativeChatStop } from './use-mobile-native-chat-stop'
|
||||
|
||||
// Why mocked: the reporter is tested on its own; here Stop's escapes must be counted alone.
|
||||
const reportWorkerTerminalUserInput = vi.fn()
|
||||
vi.mock('../terminal/worker-terminal-takeover-report', () => ({
|
||||
reportWorkerTerminalUserInput: (...args: unknown[]) => reportWorkerTerminalUserInput(...args)
|
||||
}))
|
||||
|
||||
describe('useMobileNativeChatStop', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let stop: (() => void) | null = null
|
||||
@@ -19,6 +25,7 @@ describe('useMobileNativeChatStop', () => {
|
||||
result: { send: { accepted: true } }
|
||||
})
|
||||
onSendError.mockReset()
|
||||
reportWorkerTerminalUserInput.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -184,4 +191,26 @@ describe('useMobileNativeChatStop', () => {
|
||||
|
||||
expect(onSendError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports the takeover once an Escape is accepted', async () => {
|
||||
await render(true, 'stream-1')
|
||||
|
||||
act(() => stop?.())
|
||||
await act(async () => vi.runAllTimersAsync())
|
||||
|
||||
expect(reportWorkerTerminalUserInput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sendRequest }),
|
||||
'terminal-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not report a Stop the host rejected', async () => {
|
||||
sendRequest.mockResolvedValue({ ok: true, result: { send: { accepted: false } } })
|
||||
await render(true, 'stream-1')
|
||||
|
||||
act(() => stop?.())
|
||||
await act(async () => vi.runAllTimersAsync())
|
||||
|
||||
expect(reportWorkerTerminalUserInput).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RpcClient } from '../transport/rpc-client'
|
||||
import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response'
|
||||
import { reportWorkerTerminalUserInput } from '../terminal/worker-terminal-takeover-report'
|
||||
import { openMobileNativeChatSendBudget } from './mobile-native-chat-send'
|
||||
|
||||
export function useMobileNativeChatStop(args: {
|
||||
@@ -111,6 +112,8 @@ export function useMobileNativeChatStop(args: {
|
||||
.then((response) => {
|
||||
if (isTerminalSendRpcAccepted(response)) {
|
||||
sawAccepted = true
|
||||
// A deliberate Stop is human input; it takes the worker over like any other key.
|
||||
reportWorkerTerminalUserInput(client, handle)
|
||||
} else {
|
||||
sawRejected = true
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { reportWorkerTerminalUserInput } from '../terminal/worker-terminal-takeover-report'
|
||||
import { useCallback } from 'react'
|
||||
import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response'
|
||||
import {
|
||||
clearTerminalLiveInputFocusTimer,
|
||||
scheduleTerminalLiveInputFocus
|
||||
@@ -110,7 +112,7 @@ export function useMobileSessionTerminalInput(scope: MobileSessionFileActionsMod
|
||||
terminalGestureInputInFlightRef.current.add(handle)
|
||||
try {
|
||||
// Why: gesture arrows parked across a reconnect would move a TUI long after the swipe.
|
||||
await rpc.sendRequest(
|
||||
const response = await rpc.sendRequest(
|
||||
'terminal.send',
|
||||
buildTerminalSendParams({
|
||||
terminal: handle,
|
||||
@@ -120,6 +122,9 @@ export function useMobileSessionTerminalInput(scope: MobileSessionFileActionsMod
|
||||
}),
|
||||
TERMINAL_INPUT_SEND_OPTIONS
|
||||
)
|
||||
if (isTerminalSendRpcAccepted(response)) {
|
||||
reportWorkerTerminalUserInput(rpc, handle)
|
||||
}
|
||||
} catch {
|
||||
// Transient failure
|
||||
} finally {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { reportWorkerTerminalUserInput } from '../terminal/worker-terminal-takeover-report'
|
||||
import { useCallback } from 'react'
|
||||
import { Keyboard } from 'react-native'
|
||||
import { triggerError } from '../platform/haptics'
|
||||
@@ -98,6 +99,9 @@ export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminal
|
||||
TERMINAL_INPUT_SEND_OPTIONS
|
||||
)
|
||||
const accepted = isTerminalSendRpcAccepted(response)
|
||||
if (accepted) {
|
||||
reportWorkerTerminalUserInput(client, activeHandle)
|
||||
}
|
||||
if (!accepted) {
|
||||
restoreRejectedDraft()
|
||||
}
|
||||
@@ -166,7 +170,16 @@ export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminal
|
||||
}),
|
||||
TERMINAL_INPUT_SEND_OPTIONS
|
||||
)
|
||||
.then(isTerminalSendRpcAccepted, () => false)
|
||||
.then(
|
||||
(response) => {
|
||||
const accepted = isTerminalSendRpcAccepted(response)
|
||||
if (accepted) {
|
||||
reportWorkerTerminalUserInput(rpc, handle)
|
||||
}
|
||||
return accepted
|
||||
},
|
||||
() => false
|
||||
)
|
||||
},
|
||||
[showToast]
|
||||
)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { reportWorkerTerminalUserInput } from '../terminal/worker-terminal-takeover-report'
|
||||
import { useCallback, type RefObject } from 'react'
|
||||
import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import { File as FsFile, Paths } from 'expo-file-system'
|
||||
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator'
|
||||
@@ -155,7 +157,7 @@ export function useMobileTerminalPaste({
|
||||
) {
|
||||
return
|
||||
}
|
||||
await currentClient.sendRequest('terminal.send', {
|
||||
const response = await currentClient.sendRequest('terminal.send', {
|
||||
terminal: targetHandle,
|
||||
text: payload,
|
||||
enter: false,
|
||||
@@ -163,6 +165,9 @@ export function useMobileTerminalPaste({
|
||||
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
|
||||
: {})
|
||||
})
|
||||
if (isTerminalSendRpcAccepted(response)) {
|
||||
reportWorkerTerminalUserInput(currentClient, targetHandle)
|
||||
}
|
||||
onSuccess()
|
||||
refreshCanPaste()
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { reportWorkerTerminalUserInput } from './worker-terminal-takeover-report'
|
||||
import { getTerminalLiveAccessoryRawSendTarget } from './terminal-live-accessory-raw-send-target'
|
||||
import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response'
|
||||
import { buildTerminalSendParams, TERMINAL_INPUT_SEND_OPTIONS } from './terminal-send-request'
|
||||
@@ -37,5 +38,14 @@ export async function sendTerminalLiveAccessoryRawBytes(
|
||||
}),
|
||||
TERMINAL_INPUT_SEND_OPTIONS
|
||||
)
|
||||
.then(isTerminalSendRpcAccepted, () => false)
|
||||
.then(
|
||||
(response) => {
|
||||
const accepted = isTerminalSendRpcAccepted(response)
|
||||
if (accepted) {
|
||||
reportWorkerTerminalUserInput(args.client!, rawSendTarget)
|
||||
}
|
||||
return accepted
|
||||
},
|
||||
() => false
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import { XTERM_HTML } from './terminal-webview-html'
|
||||
// uncovered region ships silently. A diff here means the emitted WebView source changed —
|
||||
// update these values only when that change is deliberate, and only after checking the
|
||||
// document still runs. Refactors that merely move slice boundaries must leave them alone.
|
||||
const EXPECTED_SHA256 = '42cc000faddc3b58b8fd4855f848c7878f0cd6166c613f66d733645e8e1b9608'
|
||||
const EXPECTED_LENGTH = 729776
|
||||
const EXPECTED_SHA256 = '5c69dce3236662c381abbfb5d2d6b7163e0f4dd6841d72753733f9470326fee3'
|
||||
const EXPECTED_LENGTH = 730428
|
||||
|
||||
describe('terminal WebView payload', () => {
|
||||
it('composes the expected document', () => {
|
||||
|
||||
@@ -76,4 +76,43 @@ describe('mobile terminal-webview contrast floor gate', () => {
|
||||
context.applyTerminalTheme({ theme: { background: '#1e242a' } })
|
||||
expect(term.options.minimumContrastRatio).toBe(DARK_FLOOR)
|
||||
})
|
||||
|
||||
// #10754: the desktop user can lower or disable the floor. Mobile mirrors the desktop gate, so the
|
||||
// published value has to win here or the same session renders differently on the phone.
|
||||
describe('published desktop override', () => {
|
||||
function applyOn(term: { options: { minimumContrastRatio: number } }, input: unknown): void {
|
||||
const context = loadThemeInjected({
|
||||
term,
|
||||
document: {
|
||||
documentElement: { style: { background: '' } },
|
||||
body: { style: { background: '' } }
|
||||
}
|
||||
}) as Record<string, unknown> & { applyTerminalTheme: (input: unknown) => void }
|
||||
context.applyTerminalTheme(input)
|
||||
}
|
||||
|
||||
it('uses the published floor instead of the luminance gate', () => {
|
||||
const term = { options: { minimumContrastRatio: 0 } }
|
||||
applyOn(term, { theme: { background: '#1e242a' }, minimumContrastRatio: 1 })
|
||||
expect(term.options.minimumContrastRatio).toBe(1)
|
||||
})
|
||||
|
||||
it("clamps a published floor to xterm's 1-21 window", () => {
|
||||
const term = { options: { minimumContrastRatio: 0 } }
|
||||
applyOn(term, { theme: { background: '#1e242a' }, minimumContrastRatio: 99 })
|
||||
expect(term.options.minimumContrastRatio).toBe(21)
|
||||
applyOn(term, { theme: { background: '#1e242a' }, minimumContrastRatio: 0 })
|
||||
expect(term.options.minimumContrastRatio).toBe(1)
|
||||
})
|
||||
|
||||
it('falls back to the luminance gate for an older host that omits the field', () => {
|
||||
const term = { options: { minimumContrastRatio: 0 } }
|
||||
for (const published of [undefined, null, 'off', Number.NaN]) {
|
||||
applyOn(term, { theme: { background: '#1e242a' }, minimumContrastRatio: published })
|
||||
expect(term.options.minimumContrastRatio).toBe(DARK_FLOOR)
|
||||
applyOn(term, { theme: { background: '#ffffff' }, minimumContrastRatio: published })
|
||||
expect(term.options.minimumContrastRatio).toBe(LIGHT_FLOOR)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,8 @@ import { colors } from '../theme/mobile-theme'
|
||||
// #7934/#10104): a dark composed background gets a mild floor of 3 to rescue near-background body text
|
||||
// (e.g. Antigravity's #262b30 on #1e242a) without over-brightening vibrant ANSI colors; a light
|
||||
// background keeps the WCAG-AA 4.5 floor. Gate on the composed background luminance, not app mode,
|
||||
// because either theme slot can hold either kind of theme.
|
||||
// because either theme slot can hold either kind of theme. An explicit desktop override published on
|
||||
// the theme payload (#10754) wins over the luminance gate; older hosts simply omit it.
|
||||
export const TERMINAL_WEBVIEW_THEME_JS = `
|
||||
var DARK_BG_MIN_CONTRAST = 3;
|
||||
var LIGHT_BG_MIN_CONTRAST = 4.5;
|
||||
@@ -63,6 +64,12 @@ export const TERMINAL_WEBVIEW_THEME_JS = `
|
||||
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
||||
}
|
||||
|
||||
// Clamp an explicit desktop override to xterm's 1-21 range; null means "no usable override".
|
||||
function normalizeTerminalContrastOverride(value) {
|
||||
if (typeof value !== 'number' || !isFinite(value)) return null;
|
||||
return Math.min(21, Math.max(1, value));
|
||||
}
|
||||
|
||||
// Pick the xterm minimumContrastRatio floor from the composed terminal background.
|
||||
// Unparseable input defaults to the dark floor so agent output never stays invisible.
|
||||
function resolveTerminalContrastFloor(background) {
|
||||
@@ -100,7 +107,13 @@ export const TERMINAL_WEBVIEW_THEME_JS = `
|
||||
var background = terminalTheme.background || '${colors.terminalBg}';
|
||||
document.documentElement.style.background = background;
|
||||
document.body.style.background = background;
|
||||
terminalMinimumContrastRatio = resolveTerminalContrastFloor(background);
|
||||
// Why prefer the published value: the desktop user may have lowered or disabled the floor (#10754);
|
||||
// an older host omits the field and the luminance gate stays authoritative.
|
||||
var publishedFloor = normalizeTerminalContrastOverride(
|
||||
input && typeof input === 'object' ? input.minimumContrastRatio : undefined
|
||||
);
|
||||
terminalMinimumContrastRatio =
|
||||
publishedFloor === null ? resolveTerminalContrastFloor(background) : publishedFloor;
|
||||
if (term) {
|
||||
term.options.theme = terminalTheme;
|
||||
term.options.minimumContrastRatio = terminalMinimumContrastRatio;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { beforeEach, afterEach, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
reportWorkerTerminalUserInput,
|
||||
resetWorkerTerminalTakeoverReportsForTest
|
||||
} from './worker-terminal-takeover-report'
|
||||
|
||||
const success = { id: 'report', ok: true as const, result: { changed: 1 } }
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
resetWorkerTerminalTakeoverReportsForTest()
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('gates per handle and owning client for 30 seconds', () => {
|
||||
const relay = { sendRequest: vi.fn().mockResolvedValue(success) }
|
||||
const direct = { sendRequest: vi.fn().mockResolvedValue(success) }
|
||||
for (let i = 0; i < 100; i++) {
|
||||
reportWorkerTerminalUserInput(relay, 'term-1')
|
||||
}
|
||||
expect(relay.sendRequest).toHaveBeenCalledTimes(1)
|
||||
reportWorkerTerminalUserInput(relay, 'term-2')
|
||||
reportWorkerTerminalUserInput(direct, 'term-1')
|
||||
expect(relay.sendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(direct.sendRequest).toHaveBeenCalledTimes(1)
|
||||
vi.advanceTimersByTime(29_999)
|
||||
reportWorkerTerminalUserInput(relay, 'term-1')
|
||||
expect(relay.sendRequest).toHaveBeenCalledTimes(2)
|
||||
vi.advanceTimersByTime(1)
|
||||
reportWorkerTerminalUserInput(relay, 'term-1')
|
||||
expect(relay.sendRequest).toHaveBeenCalledTimes(3)
|
||||
expect(relay.sendRequest).toHaveBeenLastCalledWith(
|
||||
'orchestration.workerTerminalUserInput',
|
||||
{ terminal: 'term-1' },
|
||||
{ timeoutMs: 5_000, budgetSpansConnect: true, failWhenDisconnected: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('does not await a report and coalesces input while it is pending', () => {
|
||||
const client = { sendRequest: vi.fn(() => new Promise<never>(() => {})) }
|
||||
expect(reportWorkerTerminalUserInput(client, 'term-1')).toBeUndefined()
|
||||
reportWorkerTerminalUserInput(client, 'term-1')
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each(['throw', 'rpc refusal'])(
|
||||
'retries a %s once on the same target, then permits a later attempt',
|
||||
async (failure) => {
|
||||
const client = {
|
||||
sendRequest:
|
||||
failure === 'throw'
|
||||
? vi.fn().mockRejectedValue(new Error('offline'))
|
||||
: vi.fn().mockResolvedValue({ id: 'report', ok: false, error: { message: 'refused' } })
|
||||
}
|
||||
reportWorkerTerminalUserInput(client, 'term-1')
|
||||
await vi.advanceTimersByTimeAsync(249)
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(2)
|
||||
client.sendRequest.mockResolvedValue(success)
|
||||
reportWorkerTerminalUserInput(client, 'term-1')
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(3)
|
||||
}
|
||||
)
|
||||
|
||||
it('a report that changed nothing still arms the gate, so plain terminals pay once per window', async () => {
|
||||
// Why: the host answers `changed: 0` for every ordinary terminal; reopening on that turned
|
||||
// every accepted key into an RPC and a host write transaction (round 6 measurement: 100 for 100).
|
||||
const client = {
|
||||
sendRequest: vi.fn().mockResolvedValue({ id: 'report', ok: true, result: { changed: 0 } })
|
||||
}
|
||||
for (let i = 0; i < 100; i++) {
|
||||
reportWorkerTerminalUserInput(client, 'term-plain')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
}
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
reportWorkerTerminalUserInput(client, 'term-plain')
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
type ReportClient = Pick<RpcClient, 'sendRequest'>
|
||||
const REPORT_INTERVAL_MS = 30_000
|
||||
const REPORT_RETRY_DELAY_MS = 250
|
||||
let reportsByClient = new WeakMap<ReportClient, Map<string, number>>()
|
||||
|
||||
// The same logical client owns relay/direct cutover; never reroute a report via active UI state.
|
||||
export function reportWorkerTerminalUserInput(client: ReportClient, terminal: string): void {
|
||||
let reports = reportsByClient.get(client)
|
||||
if (!reports) {
|
||||
reports = new Map()
|
||||
reportsByClient.set(client, reports)
|
||||
}
|
||||
const now = Date.now()
|
||||
const last = reports.get(terminal)
|
||||
if (last !== undefined && now - last < REPORT_INTERVAL_MS) {
|
||||
return
|
||||
}
|
||||
if (reports.size >= 256) {
|
||||
for (const [handle, reportedAt] of reports) {
|
||||
if (now - reportedAt >= REPORT_INTERVAL_MS) {
|
||||
reports.delete(handle)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Why the gate ignores the answer: like desktop, one report per terminal per window is the
|
||||
// whole cost of typing into any terminal, worker or not. A result-aware gate that reopened on
|
||||
// "changed nothing" turned every key on an ordinary terminal into an RPC plus a host write.
|
||||
reports.set(terminal, now)
|
||||
void sendTakeoverReport(client, terminal).catch(() => {
|
||||
if (reports.get(terminal) === now) {
|
||||
reports.delete(terminal)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function sendTakeoverReport(client: ReportClient, terminal: string): Promise<void> {
|
||||
const report = async (): Promise<void> => {
|
||||
const response = await client.sendRequest(
|
||||
'orchestration.workerTerminalUserInput',
|
||||
{ terminal },
|
||||
{ timeoutMs: 5_000, budgetSpansConnect: true, failWhenDisconnected: true }
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error('Worker takeover report rejected')
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await report()
|
||||
} catch {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, REPORT_RETRY_DELAY_MS))
|
||||
return await report()
|
||||
}
|
||||
}
|
||||
|
||||
export function resetWorkerTerminalTakeoverReportsForTest(): void {
|
||||
reportsByClient = new WeakMap()
|
||||
}
|
||||
@@ -243,6 +243,7 @@
|
||||
"electron-vite": "^5.0.0",
|
||||
"emoji-picker-react": "^4.19.1",
|
||||
"emojibase-data": "17.0.0",
|
||||
"esbuild": "^0.25.12",
|
||||
"happy-dom": "^20.11.8",
|
||||
"html-to-image": "^1.11.13",
|
||||
"husky": "^9.1.7",
|
||||
|
||||
Generated
+3
@@ -366,6 +366,9 @@ importers:
|
||||
emojibase-data:
|
||||
specifier: 17.0.0
|
||||
version: 17.0.0(emojibase@17.0.0)
|
||||
esbuild:
|
||||
specifier: ^0.25.12
|
||||
version: 0.25.12
|
||||
happy-dom:
|
||||
specifier: ^20.11.8
|
||||
version: 20.11.8
|
||||
|
||||
@@ -137,8 +137,8 @@ After three consecutive empty waits, stop waiting blindly and enumerate with
|
||||
`ORCA orchestration worker-list --include-remote --json` (defaults to the bound
|
||||
Run; `--run <run_id>` overrides; the receipt's `scope` names which), acting on
|
||||
each row's `projection.attention` categories, `projection.attention.requiresAction`, and literal `projection.nextAction` argv.
|
||||
An `inspect` `nextAction` on a `live` row with `attention.requiresAction` false
|
||||
is informational, not a command to re-run: keep waiting with `check --wait`.
|
||||
A `none` `nextAction` has no argv to run: read `liveness.reason` and keep waiting
|
||||
with `check --wait`. Absence never earns an argv; settlement and pending work still do.
|
||||
Leave the wait only on positive proof the agent stopped: `exited` liveness, the
|
||||
worker's own observation of process exit, or a transcript whose final agent turn
|
||||
sent no `worker_done`. Then load `references/recovery-and-cleanup.md` and choose
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -74,21 +74,38 @@ export const WINDOWS_HOOK_STDIN_DRAIN_LABEL = 'orca_agent_hook_drain_stdin'
|
||||
export const WINDOWS_HOOK_STDIN_READER = '"%SystemRoot%\\System32\\more.com"'
|
||||
export const WINDOWS_HOOK_STDIN_DRAIN_COMMAND = `${WINDOWS_HOOK_STDIN_READER} >nul 2>nul`
|
||||
|
||||
// The Orca context a hook needs before it may own stdin; see the rule below.
|
||||
const WINDOWS_HOOK_ENVIRONMENT_VARS = [
|
||||
'ORCA_AGENT_HOOK_PORT',
|
||||
'ORCA_AGENT_HOOK_TOKEN',
|
||||
'ORCA_PANE_KEY'
|
||||
] as const
|
||||
|
||||
// Why (#11549): missing Orca context means the hook ran outside an Orca pane, where the caller
|
||||
// may abandon stdin rather than close it — a read-to-EOF then blocks forever and strands a
|
||||
// visible window per hook event. The Windows rule: a hook must check the Orca env before it
|
||||
// owns stdin, and exit without reading when the env is missing — the payload is discarded on
|
||||
// that path anyway. This applies to .cmd, the copilot .ps1, and the Git Bash kimi .sh alike.
|
||||
// that path anyway. This applies to .cmd, the copilot .ps1, and the Git Bash kimi .sh alike,
|
||||
// and to the launchers that own stdin themselves when the managed script is missing.
|
||||
// POSIX hooks keep capture-first: their callers close stdin, and exiting mid-write there
|
||||
// surfaces as EPIPE the agent can see (#8110).
|
||||
export function buildWindowsHookEnvironmentGuardLines(): string[] {
|
||||
return [
|
||||
'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0',
|
||||
'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0',
|
||||
'if "%ORCA_PANE_KEY%"=="" exit /b 0'
|
||||
]
|
||||
return WINDOWS_HOOK_ENVIRONMENT_VARS.map((name) => `if "%${name}%"=="" exit /b 0`)
|
||||
}
|
||||
|
||||
/** The same guard in sh, for the Git Bash hooks and launchers that run on Windows.
|
||||
* Default-formed because a static hook precheck (Grok) rejects a bare reference it
|
||||
* cannot resolve. POSIX hosts keep capture-first — this is the Windows rule only. */
|
||||
export const WINDOWS_GIT_BASH_HOOK_ENVIRONMENT_GUARD = `if ${WINDOWS_HOOK_ENVIRONMENT_VARS.map(
|
||||
(name) => `[ -z "\${${name}-}" ]`
|
||||
).join(' || ')}; then exit 0; fi`
|
||||
|
||||
/** The same guard for a PowerShell hook or launcher. Anything that reaches
|
||||
* `[Console]::In.ReadToEnd()` must run this first, or it inherits #11549. */
|
||||
export const WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD = `if (${WINDOWS_HOOK_ENVIRONMENT_VARS.map(
|
||||
(name) => `-not $env:${name}`
|
||||
).join(' -or ')}) { exit 0 }`
|
||||
|
||||
export function buildWindowsHookStdinDrainEpilogue(): string[] {
|
||||
return [`:${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`, WINDOWS_HOOK_STDIN_DRAIN_COMMAND, 'exit /b 0']
|
||||
}
|
||||
|
||||
@@ -31,7 +31,10 @@ import {
|
||||
type HooksConfig
|
||||
} from './installer-utils'
|
||||
import { buildPosixAgentHookPostCommand } from './hook-post-command'
|
||||
import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract'
|
||||
import {
|
||||
POSIX_HOOK_STDIN_DRAIN_COMMAND,
|
||||
WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD
|
||||
} from './hook-stdin-contract'
|
||||
import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command'
|
||||
|
||||
let tmpDir: string
|
||||
@@ -618,7 +621,10 @@ function expectedDecodedWindowsHookCommand(scriptPath: string): string {
|
||||
// Why: the execution-policy bypass rides in the payload, not on the command
|
||||
// line, so the launcher cannot spell the AV-blocked flag triple (#16003).
|
||||
// Why: PowerShell progress CLIXML corrupts consumers that merge stderr into JSON stdout.
|
||||
return `$ProgressPreference='SilentlyContinue'; try { Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force -ErrorAction SilentlyContinue } catch {}; if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0`
|
||||
// Why the guard is spelled by import: the launcher owns stdin on the missing-script path,
|
||||
// so it obeys the shared Windows rule (#11549), and re-typing it here would let the two
|
||||
// drift back apart.
|
||||
return `$ProgressPreference='SilentlyContinue'; try { Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force -ErrorAction SilentlyContinue } catch {}; if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; ${WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD}; [Console]::In.ReadToEnd() | Out-Null; exit 0`
|
||||
}
|
||||
|
||||
describe('wrapWindowsHookCommand', () => {
|
||||
@@ -640,15 +646,23 @@ describe('wrapWindowsHookCommand', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('emits fallback stdout when the managed script is missing', () => {
|
||||
const command = wrapWindowsHookCommand(
|
||||
'C:\\hooks\\cursor-hook.cmd',
|
||||
{},
|
||||
{ fallbackStdout: '{"permission":"allow"}' }
|
||||
)
|
||||
expect(decodeWindowsHookCommand(command)).toContain(
|
||||
'Write-Output \'{"permission":"allow"}\'; exit 0'
|
||||
// Why the ordering matters: a gate event reads silence as deny (#2426), and outside an
|
||||
// Orca pane the guard exits before the read — so an answer placed after the drain never
|
||||
// reaches the agent at all when the caller abandons the pipe (#11549).
|
||||
it('answers before it guards, and guards before it owns stdin', () => {
|
||||
const decoded = decodeWindowsHookCommand(
|
||||
wrapWindowsHookCommand(
|
||||
'C:\\hooks\\cursor-hook.cmd',
|
||||
{},
|
||||
{ fallbackStdout: '{"permission":"allow"}' }
|
||||
)
|
||||
)
|
||||
const answer = decoded.indexOf('Write-Output \'{"permission":"allow"}\'')
|
||||
const guard = decoded.indexOf(WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD)
|
||||
const ownsStdin = decoded.indexOf('[Console]::In.ReadToEnd()')
|
||||
expect(answer).toBeGreaterThan(-1)
|
||||
expect(guard).toBeGreaterThan(answer)
|
||||
expect(ownsStdin).toBeGreaterThan(guard)
|
||||
})
|
||||
|
||||
// Why: a user profile path like `C:\Users\Jane Doe` is the regression from
|
||||
|
||||
@@ -16,6 +16,7 @@ import { grantDirAcl, isPermissionError } from '../win32-utils'
|
||||
import { resolveHooksJsonWritePath } from './hook-config-write-path'
|
||||
import { writeRollingFileBackup } from '../rolling-file-backup'
|
||||
import { wrapWindowsPowerShellEncodedCommand } from './windows-powershell-hook-launcher'
|
||||
import { WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD } from './hook-stdin-contract'
|
||||
|
||||
export type HookCommandConfig = {
|
||||
type: 'command'
|
||||
@@ -131,7 +132,10 @@ export function wrapWindowsHookCommand(
|
||||
options.fallbackStdout === undefined
|
||||
? ''
|
||||
: `Write-Output ${quotePowerShellString(options.fallbackStdout)}; `
|
||||
const command = `${envPrefix}if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; ${fallback}exit 0`
|
||||
// Why the order: answer first (a gate event reads silence as deny), then the shared
|
||||
// env guard, and only then own stdin — outside an Orca pane the caller may abandon the
|
||||
// pipe, and ReadToEnd would strand the launcher there forever (#11549).
|
||||
const command = `${envPrefix}if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; ${fallback}${WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD}; [Console]::In.ReadToEnd() | Out-Null; exit 0`
|
||||
return wrapWindowsPowerShellEncodedCommand(command)
|
||||
}
|
||||
|
||||
|
||||
@@ -63,12 +63,26 @@ import { KimiHookService } from '../kimi/hook-service'
|
||||
|
||||
import { openClaudeHookService } from '../openclaude/hook-service'
|
||||
import { wrapPosixHookCommand, wrapWindowsHookCommand } from './installer-utils'
|
||||
import { POSIX_HOOK_STDIN_READER } from './hook-stdin-contract'
|
||||
import {
|
||||
POSIX_HOOK_STDIN_READER,
|
||||
WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD
|
||||
} from './hook-stdin-contract'
|
||||
import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command'
|
||||
import { createAgentHookMemorySftp } from './agent-hook-memory-sftp.test-fixture'
|
||||
import { findGitBash } from './windows-git-bash-path.test-fixture'
|
||||
|
||||
/** The launchers ship their command base64'd; assert the shape they actually run. */
|
||||
function decodeEncodedPowerShellCommand(command: string): string {
|
||||
const encoded = command.match(/-EncodedCommand\s+(\S+)/)
|
||||
expect(encoded, 'launcher carries an encoded command').not.toBeNull()
|
||||
return Buffer.from(encoded![1], 'base64').toString('utf16le')
|
||||
}
|
||||
|
||||
const REMOTE_HOME = '/home/dev'
|
||||
// Why all three: Windows reports a write to a pipe whose reader is gone as any of these,
|
||||
// depending on whether the read handle, the pipe, or the process went first. Enumerating
|
||||
// them keeps the guard-exit legs from failing on which race the host happened to run.
|
||||
const WRITER_BROKEN_BY_EARLY_EXIT = ['EPIPE', 'ECONNRESET', 'EOF']
|
||||
const LARGE_PAYLOAD = Buffer.alloc(1_000_000, 'x')
|
||||
|
||||
// Why: a developer box may set HKCU\...\Command Processor\AutoRun, which cmd.exe runs before any
|
||||
@@ -156,7 +170,10 @@ type HookRun = {
|
||||
function runHookProcess(
|
||||
executable: string,
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv
|
||||
env: NodeJS.ProcessEnv,
|
||||
// Why: `abandon` leaves the pipe open and unwritten — the shape a caller outside an Orca
|
||||
// pane produces, and the only one that can catch a read-to-EOF that never returns (#11549).
|
||||
stdin: 'close' | 'abandon' = 'close'
|
||||
): Promise<HookRun> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(executable, args, { env, stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
@@ -164,8 +181,9 @@ function runHookProcess(
|
||||
let stderr = ''
|
||||
let stdout = ''
|
||||
const timeout = setTimeout(() => {
|
||||
child.stdin.destroy()
|
||||
child.kill('SIGKILL')
|
||||
reject(new Error('hook did not finish after stdin closed'))
|
||||
reject(new Error(`hook did not finish with stdin ${stdin}d`))
|
||||
}, 10_000)
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timeout)
|
||||
@@ -182,7 +200,9 @@ function runHookProcess(
|
||||
clearTimeout(timeout)
|
||||
resolve({ exitCode, stdinErrors, stderr, stdout })
|
||||
})
|
||||
child.stdin.end(LARGE_PAYLOAD)
|
||||
if (stdin === 'close') {
|
||||
child.stdin.end(LARGE_PAYLOAD)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -303,6 +323,31 @@ describe('Windows managed hook stdin structure', () => {
|
||||
expect(copilot.indexOf('if (-not $env:ORCA_AGENT_HOOK_PORT')).toBeLessThan(
|
||||
copilot.indexOf('[Console]::In.ReadToEnd()')
|
||||
)
|
||||
// Why: the two encoded-PowerShell launchers own stdin themselves when the managed
|
||||
// script is missing, so the same guard has to precede their ReadToEnd — and the
|
||||
// fallback answer has to precede the guard, or a gate event outside a pane is
|
||||
// answered with silence, which reads as deny (#2426/#15462).
|
||||
for (const [name, command] of [
|
||||
[
|
||||
'wrapWindowsHookCommand',
|
||||
wrapWindowsHookCommand('C:\\missing\\orca-hook.cmd', {}, { fallbackStdout: '{}' })
|
||||
],
|
||||
[
|
||||
'wrapRuntimeHomeHookCommand',
|
||||
wrapRuntimeHomeHookCommand('missing-orca-hook', { neutralJsonWhenMissing: true })
|
||||
]
|
||||
] as const) {
|
||||
const decoded = decodeEncodedPowerShellCommand(command)
|
||||
expect(decoded, `${name} decoded`).toContain(WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD)
|
||||
expect(decoded.indexOf("Write-Output '{}'"), `${name} answers first`).toBeLessThan(
|
||||
decoded.indexOf(WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD)
|
||||
)
|
||||
expect(
|
||||
decoded.indexOf(WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD),
|
||||
`${name} guards before owning stdin`
|
||||
).toBeLessThan(decoded.indexOf('[Console]::In.ReadToEnd()'))
|
||||
}
|
||||
|
||||
const kimi = readFileSync(join(hooksDir, 'kimi-hook.sh'), 'utf8')
|
||||
expect(kimi.indexOf('if [ -z "$ORCA_AGENT_HOOK_PORT" ]')).toBeGreaterThan(-1)
|
||||
expect(kimi.indexOf('if [ -z "$ORCA_AGENT_HOOK_PORT" ]')).toBeLessThan(
|
||||
@@ -365,12 +410,11 @@ describe('Windows managed hook stdin structure', () => {
|
||||
const result = await runHookProcess(executable, args, hookEnvironment())
|
||||
expect(result.exitCode, `${fileName} exit code`).toBe(0)
|
||||
// Why (#11549 class): every Windows-local hook exits before owning stdin when the
|
||||
// Orca env is missing, so the writer may break — EPIPE, or ECONNRESET when Windows
|
||||
// tears the pipe down first. hookEnvironment() strips every ORCA_* var, so this
|
||||
// relaxation only ever covers the missing-env path — a happy-path case added to
|
||||
// this loop must not reuse it.
|
||||
// Orca env is missing, so the writer may break. hookEnvironment() strips every
|
||||
// ORCA_* var, so this relaxation only ever covers the missing-env path — a
|
||||
// happy-path case added to this loop must not reuse it.
|
||||
for (const error of result.stdinErrors) {
|
||||
expect(['EPIPE', 'ECONNRESET'], `${fileName} stdin error`).toContain(error.code)
|
||||
expect(WRITER_BROKEN_BY_EARLY_EXIT, `${fileName} stdin error`).toContain(error.code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,9 +439,42 @@ describe('Windows managed hook stdin structure', () => {
|
||||
}
|
||||
]
|
||||
for (const launcher of launcherCases) {
|
||||
const result = await runHookProcess(launcher.executable, launcher.args, hookEnvironment())
|
||||
expect(result.exitCode, `${launcher.name} exit code`).toBe(0)
|
||||
expect(result.stdinErrors, `${launcher.name} stdin errors`).toHaveLength(0)
|
||||
// Why (#11549 class): a launcher that reaches an interpreter owns stdin for a
|
||||
// missing script exactly like a managed script does, so it obeys the same rule —
|
||||
// drain inside a pane, exit before reading outside one. Its writer may therefore
|
||||
// break on the missing-env leg, and must not on the in-pane leg.
|
||||
const outside = await runHookProcess(
|
||||
launcher.executable,
|
||||
launcher.args,
|
||||
hookEnvironment()
|
||||
)
|
||||
expect(outside.exitCode, `${launcher.name} exit code`).toBe(0)
|
||||
for (const error of outside.stdinErrors) {
|
||||
expect(WRITER_BROKEN_BY_EARLY_EXIT, `${launcher.name} stdin error`).toContain(
|
||||
error.code
|
||||
)
|
||||
}
|
||||
const insideAPane = await runHookProcess(
|
||||
launcher.executable,
|
||||
launcher.args,
|
||||
hookEnvironment({
|
||||
ORCA_AGENT_HOOK_PORT: '59999',
|
||||
ORCA_AGENT_HOOK_TOKEN: 'token',
|
||||
ORCA_PANE_KEY: 'tab:leaf'
|
||||
})
|
||||
)
|
||||
expect(insideAPane.exitCode, `${launcher.name} in-pane exit code`).toBe(0)
|
||||
expect(insideAPane.stdinErrors, `${launcher.name} in-pane stdin errors`).toHaveLength(0)
|
||||
// Why this leg and not a shape assertion: an unguarded ReadToEnd exits fine when
|
||||
// the writer closes the pipe. Only a caller that abandons it strands the launcher,
|
||||
// which is what left a console per hook event on the reporting hosts.
|
||||
const abandoned = await runHookProcess(
|
||||
launcher.executable,
|
||||
launcher.args,
|
||||
hookEnvironment(),
|
||||
'abandon'
|
||||
)
|
||||
expect(abandoned.exitCode, `${launcher.name} abandoned-stdin exit code`).toBe(0)
|
||||
}
|
||||
} finally {
|
||||
homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir())
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract'
|
||||
import {
|
||||
POSIX_HOOK_STDIN_DRAIN_COMMAND,
|
||||
WINDOWS_GIT_BASH_HOOK_ENVIRONMENT_GUARD,
|
||||
WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD
|
||||
} from './hook-stdin-contract'
|
||||
import {
|
||||
encodeWindowsPowerShellHookCommand,
|
||||
WINDOWS_POWERSHELL_HOOK_SWITCHES
|
||||
@@ -19,16 +23,30 @@ export function wrapRuntimeHomeHookCommand(
|
||||
const windowsScript = `"\${HOME-}/.orca/agent-hooks/${scriptBaseName}.cmd"`
|
||||
const posixScript = `"\${HOME-}/.orca/agent-hooks/${scriptBaseName}.sh"`
|
||||
const drain = POSIX_HOOK_STDIN_DRAIN_COMMAND
|
||||
const missingScriptFallback = options.neutralJsonWhenMissing ? `${drain}; printf '{}\\n'` : drain
|
||||
const neutralJson = options.neutralJsonWhenMissing ? `printf '{}\\n'` : ''
|
||||
// Why two forms: the missing-script fallback owns stdin, so it follows the rule of the host
|
||||
// it lands on. POSIX callers close the pipe, so capture-first is safe there and a mid-write
|
||||
// exit stays visible as EPIPE (#8110). A Windows caller may abandon the pipe, so there the
|
||||
// answer comes first and the drain only runs with an Orca env behind it (#11549).
|
||||
const posixMissingScriptFallback = neutralJson ? `${drain}; ${neutralJson}` : drain
|
||||
const windowsMissingScriptFallback = [
|
||||
...(neutralJson ? [neutralJson] : []),
|
||||
WINDOWS_GIT_BASH_HOOK_ENVIRONMENT_GUARD,
|
||||
drain
|
||||
].join('; ')
|
||||
// Why platform-selected even when HOME is unset: which stdin rule applies follows the
|
||||
// caller, not the reason the script could not be found.
|
||||
const missingScriptFallback = `case "\${OSTYPE-}" in msys*|cygwin*|win32*) ${windowsMissingScriptFallback} ;; *) ${posixMissingScriptFallback} ;; esac`
|
||||
const powershell = '"${SYSTEMROOT-}/System32/WindowsPowerShell/v1.0/powershell.exe"'
|
||||
const powershellFallback = options.neutralJsonWhenMissing ? "; Write-Output '{}'" : ''
|
||||
const powershellCommand = `$homePath = $env:HOME -replace '^/([A-Za-z])/', '$1:/'; $scriptPath = Join-Path $homePath '.orca\\agent-hooks\\${scriptBaseName}.cmd'; if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null${powershellFallback}; exit 0`
|
||||
// Why the order: answer first, then the shared env guard, then own stdin — see wrapWindowsHookCommand.
|
||||
const powershellCommand = `$homePath = $env:HOME -replace '^/([A-Za-z])/', '$1:/'; $scriptPath = Join-Path $homePath '.orca\\agent-hooks\\${scriptBaseName}.cmd'; if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }${powershellFallback}; ${WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD}; [Console]::In.ReadToEnd() | Out-Null; exit 0`
|
||||
const encodedCommand = encodeWindowsPowerShellHookCommand(powershellCommand)
|
||||
// Why: the Git Bash and native Windows launchers must spell the same switches — window suppression (#14815) and an AV verdict on the shape (#16003) both hit either path.
|
||||
const powershellInvocation = `${powershell} ${WINDOWS_POWERSHELL_HOOK_SWITCHES} -EncodedCommand ${encodedCommand}`
|
||||
const encodedWindowsBranch = `if [ -f ${powershell} ]; then ${powershellInvocation}; else ${missingScriptFallback}; fi`
|
||||
const windowsBranch = `if [ -f ${windowsScript} ]; then case "\${HOME-}" in ${WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE}) ${encodedWindowsBranch} ;; *) ${windowsScript} ;; esac; else ${missingScriptFallback}; fi`
|
||||
const posixBranch = `if [ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then /bin/sh ${posixScript}; else ${missingScriptFallback}; fi`
|
||||
const encodedWindowsBranch = `if [ -f ${powershell} ]; then ${powershellInvocation}; else ${windowsMissingScriptFallback}; fi`
|
||||
const windowsBranch = `if [ -f ${windowsScript} ]; then case "\${HOME-}" in ${WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE}) ${encodedWindowsBranch} ;; *) ${windowsScript} ;; esac; else ${windowsMissingScriptFallback}; fi`
|
||||
const posixBranch = `if [ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then /bin/sh ${posixScript}; else ${posixMissingScriptFallback}; fi`
|
||||
// Why: OSTYPE is shell-owned, so platform selection adds no process to every hook invocation.
|
||||
return `if [ -z "\${HOME-}" ]; then ${missingScriptFallback}; else case "\${OSTYPE-}" in msys*|cygwin*|win32*) ${windowsBranch} ;; *) ${posixBranch} ;; esac; fi`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { consumeCompleteJsonlLines } from './session-scanner-jsonl-reader'
|
||||
|
||||
const source = vi.hoisted(() => ({ chunks: [] as Buffer[] }))
|
||||
vi.mock('../native-chat/wsl-transcript-fs-access', () => ({
|
||||
openTranscriptReadStream: async function* () {
|
||||
yield* source.chunks
|
||||
}
|
||||
}))
|
||||
|
||||
it('copies only the carried line when the next chunk contains many complete lines', async () => {
|
||||
source.chunks = Array.from({ length: 100 }, () => Buffer.from(`${'a\n'.repeat(1000)}x`))
|
||||
const original = Buffer.concat
|
||||
let copied = 0
|
||||
const concat = vi.spyOn(Buffer, 'concat').mockImplementation((chunks, total) => {
|
||||
copied += total ?? chunks.reduce((sum, chunk) => sum + chunk.length, 0)
|
||||
return original(chunks, total)
|
||||
})
|
||||
let lines = 0
|
||||
let result: Awaited<ReturnType<typeof consumeCompleteJsonlLines>>
|
||||
try {
|
||||
result = await consumeCompleteJsonlLines({
|
||||
path: '/log',
|
||||
start: 0,
|
||||
onLine: () => {
|
||||
lines += 1
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
concat.mockRestore()
|
||||
}
|
||||
expect(lines).toBe(100000)
|
||||
expect(result!).toEqual({ consumedThrough: 200099, trailingPartialLine: 'x', bytesRead: 200100 })
|
||||
expect(copied).toBeLessThan(1000)
|
||||
})
|
||||
|
||||
it('preserves UTF-8/CRLF carry, byte callbacks and stop offsets', async () => {
|
||||
source.chunks = [Buffer.from('ab\r'), Buffer.from('\ncd\npartial')]
|
||||
const lines: string[] = []
|
||||
expect(
|
||||
await consumeCompleteJsonlLines({
|
||||
path: '/log',
|
||||
start: 5,
|
||||
onLine: () => {},
|
||||
onLineBytes: (line) => lines.push(line.toString())
|
||||
})
|
||||
).toEqual({ consumedThrough: 12, trailingPartialLine: 'partial', bytesRead: 14 })
|
||||
expect(lines).toEqual(['ab', 'cd'])
|
||||
let stopped = false
|
||||
expect(
|
||||
await consumeCompleteJsonlLines({
|
||||
path: '/log',
|
||||
start: 5,
|
||||
onLine: () => {
|
||||
stopped = true
|
||||
},
|
||||
shouldStop: () => stopped
|
||||
})
|
||||
).toEqual({ consumedThrough: 9, trailingPartialLine: null, bytesRead: 14 })
|
||||
const unicode = Buffer.from('🦀\n')
|
||||
source.chunks = [unicode.subarray(0, 2), unicode.subarray(2)]
|
||||
const onLine = vi.fn()
|
||||
await consumeCompleteJsonlLines({ path: '/log', start: 0, onLine })
|
||||
expect(onLine).toHaveBeenCalledWith('🦀')
|
||||
})
|
||||
|
||||
// Why: a chunk boundary is not aligned to anything — it can land mid-record,
|
||||
// mid-UTF-8-sequence, between CR and LF, or on an empty line. A dropped or
|
||||
// merged line here silently corrupts an agent transcript, and a wrong
|
||||
// `consumedThrough` makes the next incremental scan resume mid-line.
|
||||
it('yields identical lines and resume offsets for every single-byte chunk split', async () => {
|
||||
const bigRecord = `{"d":${'"'.padEnd(2000, 'z')}"}`
|
||||
const expectedLines = [
|
||||
'{"a":1}', // plain LF record
|
||||
'{"b":"🦀 é 𝄞"}', // CRLF record whose content is 2/3/4-byte UTF-8
|
||||
'', // empty line
|
||||
'', // empty CRLF line
|
||||
'{"c":"x\ry"}', // lone CR inside a record
|
||||
bigRecord // single record larger than any carried prefix
|
||||
]
|
||||
const trailing = '{"partial":' // final line with no trailing newline
|
||||
const buffer = Buffer.from(
|
||||
`{"a":1}\n{"b":"🦀 é 𝄞"}\r\n\n\r\n{"c":"x\ry"}\n${bigRecord}\n${trailing}`,
|
||||
'utf-8'
|
||||
)
|
||||
const expectedConsumed = buffer.length - Buffer.byteLength(trailing)
|
||||
|
||||
for (let cut = 0; cut <= buffer.length; cut++) {
|
||||
source.chunks = [buffer.subarray(0, cut), buffer.subarray(cut)].filter((c) => c.length > 0)
|
||||
const lines: string[] = []
|
||||
const result = await consumeCompleteJsonlLines({
|
||||
path: '/log',
|
||||
start: 41,
|
||||
onLine: (line) => lines.push(line)
|
||||
})
|
||||
expect({ cut, lines, ...result }).toEqual({
|
||||
cut,
|
||||
lines: expectedLines,
|
||||
consumedThrough: 41 + expectedConsumed,
|
||||
trailingPartialLine: trailing,
|
||||
bytesRead: buffer.length
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -36,23 +36,24 @@ export async function consumeCompleteJsonlLines(args: {
|
||||
remainderLength += chunk.length
|
||||
continue
|
||||
}
|
||||
const data =
|
||||
remainderLength > 0
|
||||
? Buffer.concat([...remainderParts, chunk], remainderLength + chunk.length)
|
||||
: chunk
|
||||
remainderParts = []
|
||||
remainderLength = 0
|
||||
const data = chunk
|
||||
const carriedLength = remainderLength
|
||||
let lineStart = 0
|
||||
let newlineIndex = data.indexOf(NEWLINE_BYTE, lineStart)
|
||||
while (newlineIndex !== -1) {
|
||||
let lineEnd = newlineIndex
|
||||
if (lineEnd > lineStart && data[lineEnd - 1] === CARRIAGE_RETURN_BYTE) {
|
||||
lineEnd--
|
||||
let line = data.subarray(lineStart, newlineIndex)
|
||||
// Only the first line of a chunk can carry a prefix; resetting inside the
|
||||
// branch keeps the common per-line path allocation-free.
|
||||
if (remainderLength > 0) {
|
||||
line = Buffer.concat([...remainderParts, line], remainderLength + line.length)
|
||||
remainderParts = []
|
||||
remainderLength = 0
|
||||
}
|
||||
const lineEnd = line.at(-1) === CARRIAGE_RETURN_BYTE ? line.length - 1 : line.length
|
||||
if (args.onLineBytes) {
|
||||
args.onLineBytes(data.subarray(lineStart, lineEnd))
|
||||
args.onLineBytes(line.subarray(0, lineEnd))
|
||||
} else {
|
||||
args.onLine(data.toString('utf-8', lineStart, lineEnd))
|
||||
args.onLine(line.toString('utf-8', 0, lineEnd))
|
||||
}
|
||||
lineStart = newlineIndex + 1
|
||||
if (args.shouldStop?.()) {
|
||||
@@ -61,7 +62,7 @@ export async function consumeCompleteJsonlLines(args: {
|
||||
}
|
||||
newlineIndex = data.indexOf(NEWLINE_BYTE, lineStart)
|
||||
}
|
||||
consumedThrough += lineStart
|
||||
consumedThrough += carriedLength + lineStart
|
||||
if (stopped) {
|
||||
remainderParts = []
|
||||
remainderLength = 0
|
||||
|
||||
@@ -88,7 +88,10 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
export function getWindowsWrapperScript(eventName: string): string {
|
||||
return [
|
||||
'@echo off',
|
||||
'setlocal',
|
||||
// Why (#9358/#9941): `!` is legal in the hooks path, and inherited delayed expansion
|
||||
// eats it out of the percent-expanded `%~dp0` — the wrapper then misses the core and
|
||||
// silently falls back on every event. Same reason the core disables it.
|
||||
'setlocal DisableDelayedExpansion',
|
||||
`set "ORCA_ANTIGRAVITY_EVENT=${eventName}"`,
|
||||
'set "ORCA_ANTIGRAVITY_CORE=%~dp0antigravity-hook.cmd"',
|
||||
'if exist "%ORCA_ANTIGRAVITY_CORE%" (',
|
||||
@@ -102,8 +105,8 @@ export function getWindowsWrapperScript(eventName: string): string {
|
||||
') else (',
|
||||
' echo {}',
|
||||
')',
|
||||
// Why: when the shared core script is missing, this wrapper becomes the
|
||||
// stdin owner and must finish the agent's payload write before returning.
|
||||
// Missing-core fallbacks obey the same outside-Orca stdin guard as the core.
|
||||
...buildWindowsHookEnvironmentGuardLines(),
|
||||
WINDOWS_HOOK_STDIN_DRAIN_COMMAND,
|
||||
'exit /b 0',
|
||||
''
|
||||
|
||||
@@ -28,7 +28,8 @@ vi.mock('os', async (importOriginal) => {
|
||||
|
||||
import { AntigravityHookService } from './hook-service'
|
||||
import { ANTIGRAVITY_EVENTS, ANTIGRAVITY_PRE_TOOL_USE_DECISION } from './hook-events'
|
||||
import { getManagedScript } from './hook-script'
|
||||
import { getManagedScript, getWindowsWrapperScript } from './hook-script'
|
||||
import { WINDOWS_HOOK_STDIN_DRAIN_COMMAND } from '../agent-hooks/hook-stdin-contract'
|
||||
|
||||
// Why (#9358/#9941): `!` is legal in a Windows path and in a pane key. Under inherited
|
||||
// delayed expansion cmd eats it out of a percent-expanded curl argument, so bake one into
|
||||
@@ -91,17 +92,23 @@ async function startHookListener(): Promise<{
|
||||
|
||||
type HookRun = { exitCode: number | null; stdout: string; stderr: string; timedOut: boolean }
|
||||
|
||||
// Why spell `/v`: `cmd /d /c <bare .cmd path>` is the chain in the bug report's process trace,
|
||||
// and it inherits HKCU\...\Command Processor\DelayedExpansion. Naming the state makes the
|
||||
// hostile half reachable on any host — under `/v:on` cmd eats `!` out of every percent
|
||||
// expansion (#9358/#9941), and a harness pinned to `/v:off` could never fail on it.
|
||||
type DelayedExpansion = 'on' | 'off'
|
||||
const DELAYED_EXPANSION_STATES = ['off', 'on'] as const satisfies readonly DelayedExpansion[]
|
||||
|
||||
function runWrapper(
|
||||
wrapperPath: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
// Why: `null` abandons stdin instead of closing it — the shape a caller outside an Orca
|
||||
// pane produces, and the only way to prove the env guard exits before reading (#11549).
|
||||
stdinPayload: string | null = PAYLOAD
|
||||
stdinPayload: string | null = PAYLOAD,
|
||||
delayedExpansion: DelayedExpansion = 'off'
|
||||
): Promise<HookRun> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Why: mirror how Antigravity spawns the hook — `cmd /c <bare .cmd path>`, the exact
|
||||
// chain in the bug report's process trace.
|
||||
const child = spawn('cmd.exe', ['/d', '/c', wrapperPath], {
|
||||
const child = spawn('cmd.exe', [`/v:${delayedExpansion}`, '/d', '/c', wrapperPath], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
env
|
||||
@@ -111,6 +118,7 @@ function runWrapper(
|
||||
let timedOut = false
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
child.stdin.destroy()
|
||||
child.kill('SIGKILL')
|
||||
}, 15_000)
|
||||
child.on('error', (error) => {
|
||||
@@ -154,6 +162,24 @@ function expectedStdout(eventName: string): string {
|
||||
// Why: runs on every platform — the live delivery suite below is Windows-only, so this
|
||||
// keeps a POSIX-only CI leg from letting the interpreter back into the hot path.
|
||||
describe('Antigravity Windows hook post command', () => {
|
||||
it.each(ANTIGRAVITY_EVENTS)('guards missing-core stdin for $eventName', ({ eventName }) => {
|
||||
const script = getWindowsWrapperScript(eventName)
|
||||
const drain = script.indexOf(WINDOWS_HOOK_STDIN_DRAIN_COMMAND)
|
||||
const answer = script.lastIndexOf('echo {}')
|
||||
expect(drain).toBeGreaterThan(answer)
|
||||
for (const key of ['ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN', 'ORCA_PANE_KEY']) {
|
||||
const guard = script.indexOf(`if "%${key}%"=="" exit /b 0`)
|
||||
expect(guard, key).toBeGreaterThan(answer)
|
||||
expect(guard, key).toBeLessThan(drain)
|
||||
}
|
||||
})
|
||||
|
||||
// Why (#9358/#9941): `%~dp0` carries the hooks path, so an inherited delayed expansion eats
|
||||
// a `!` out of it and the wrapper silently misses the core on every event.
|
||||
it.each(ANTIGRAVITY_EVENTS)('disables delayed expansion for $eventName', ({ eventName }) => {
|
||||
expect(getWindowsWrapperScript(eventName)).toContain('setlocal DisableDelayedExpansion')
|
||||
})
|
||||
|
||||
it('posts through curl.exe rather than a PowerShell interpreter', () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
const script = getManagedScript('local')
|
||||
@@ -185,7 +211,10 @@ describe.skipIf(process.platform !== 'win32')('Antigravity Windows hook payload
|
||||
})
|
||||
|
||||
it('delivers every event wrapper payload to the listener without spawning PowerShell', async () => {
|
||||
home = mkdtempSync(join(tmpdir(), 'orca-antigravity-hook-'))
|
||||
// Why the `!` in the directory: it lands in the wrapper's `%~dp0`, which is what an
|
||||
// inherited delayed expansion eats (#9358/#9941). Without it the `/v:on` leg below
|
||||
// proves nothing about the core lookup.
|
||||
home = mkdtempSync(join(tmpdir(), 'orca-antigravity-hook!bang-'))
|
||||
homedirMock.mockReturnValue(home)
|
||||
expect(new AntigravityHookService().install().state).toBe('installed')
|
||||
|
||||
@@ -204,34 +233,42 @@ describe.skipIf(process.platform !== 'win32')('Antigravity Windows hook payload
|
||||
ORCA_WORKTREE_ID: WORKTREE_ID
|
||||
})
|
||||
|
||||
for (const event of ANTIGRAVITY_EVENTS) {
|
||||
const label = event.eventName
|
||||
const before = listener.posts.length
|
||||
const result = await runWrapper(join(hooksDir, event.windowsWrapperFileName), env)
|
||||
for (const delayedExpansion of DELAYED_EXPANSION_STATES) {
|
||||
for (const event of ANTIGRAVITY_EVENTS) {
|
||||
const label = `${event.eventName} (/v:${delayedExpansion})`
|
||||
const before = listener.posts.length
|
||||
const result = await runWrapper(
|
||||
join(hooksDir, event.windowsWrapperFileName),
|
||||
env,
|
||||
PAYLOAD,
|
||||
delayedExpansion
|
||||
)
|
||||
|
||||
expect(result.timedOut, `${label} timed out`).toBe(false)
|
||||
expect(result.exitCode, `${label} exit code`).toBe(0)
|
||||
expect(result.stderr, `${label} stderr`).toBe('')
|
||||
// Why: Antigravity reads silence on PreToolUse as deny (#2426), so the gate answer
|
||||
// must survive the transport change.
|
||||
expect(result.stdout.trim(), `${label} stdout`).toBe(expectedStdout(label))
|
||||
expect(result.timedOut, `${label} timed out`).toBe(false)
|
||||
expect(result.exitCode, `${label} exit code`).toBe(0)
|
||||
expect(result.stderr, `${label} stderr`).toBe('')
|
||||
// Why: Antigravity reads silence on PreToolUse as deny (#2426), so the gate answer
|
||||
// must survive the transport change.
|
||||
expect(result.stdout.trim(), `${label} stdout`).toBe(expectedStdout(event.eventName))
|
||||
|
||||
const posts = listener.posts.slice(before)
|
||||
expect(posts, `${label} posted exactly one hook`).toHaveLength(1)
|
||||
// Why: byte-exact, not "non-empty" — PowerShell recoded this body through the console
|
||||
// code page, and a silently corrupted payload still looks posted.
|
||||
expect(posts[0].payload, `${label} payload`).toBe(PAYLOAD)
|
||||
expect(posts[0].hookEventName, `${label} hook_event_name`).toBe(label)
|
||||
// Why: the `!` in both values is the delayed-expansion regression guard.
|
||||
expect(posts[0].paneKey, `${label} paneKey`).toBe(PANE_KEY)
|
||||
expect(posts[0].worktreeId, `${label} worktreeId`).toBe(WORKTREE_ID)
|
||||
expect(posts[0].token, `${label} token`).toBe(HOOK_TOKEN)
|
||||
expect(posts[0].contentType, `${label} content-type`).toContain(
|
||||
'application/x-www-form-urlencoded'
|
||||
)
|
||||
const posts = listener.posts.slice(before)
|
||||
expect(posts, `${label} posted exactly one hook`).toHaveLength(1)
|
||||
// Why: byte-exact, not "non-empty" — PowerShell recoded this body through the console
|
||||
// code page, and a silently corrupted payload still looks posted.
|
||||
expect(posts[0].payload, `${label} payload`).toBe(PAYLOAD)
|
||||
expect(posts[0].hookEventName, `${label} hook_event_name`).toBe(event.eventName)
|
||||
// Why: the `!` in both values is the delayed-expansion regression guard — it is the
|
||||
// `/v:on` leg that can actually fail on it.
|
||||
expect(posts[0].paneKey, `${label} paneKey`).toBe(PANE_KEY)
|
||||
expect(posts[0].worktreeId, `${label} worktreeId`).toBe(WORKTREE_ID)
|
||||
expect(posts[0].token, `${label} token`).toBe(HOOK_TOKEN)
|
||||
expect(posts[0].contentType, `${label} content-type`).toContain(
|
||||
'application/x-www-form-urlencoded'
|
||||
)
|
||||
}
|
||||
}
|
||||
// Why: five wrapper launches plus a real install can overrun the default under load.
|
||||
}, 60_000)
|
||||
// Why: ten wrapper launches plus a real install can overrun the default under load.
|
||||
}, 90_000)
|
||||
|
||||
// Why (#15117): Antigravity fires some events with no stdin at all. PowerShell substituted
|
||||
// `{}` before posting; curl forwards the empty body, so prove the post still happens — the
|
||||
@@ -264,6 +301,67 @@ describe.skipIf(process.platform !== 'win32')('Antigravity Windows hook payload
|
||||
expect(listener.posts[0].hookEventName).toBe('PreInvocation')
|
||||
}, 30_000)
|
||||
|
||||
// Why a helper: the missing-core cases all need a real install with the core removed, which
|
||||
// is the shape an AV quarantine or a half-finished uninstall leaves behind.
|
||||
async function installWithoutCore(): Promise<string> {
|
||||
home = mkdtempSync(join(tmpdir(), 'orca-antigravity-fallback-'))
|
||||
homedirMock.mockReturnValue(home)
|
||||
expect(new AntigravityHookService().install().state).toBe('installed')
|
||||
const hooksDir = join(home, '.orca', 'agent-hooks')
|
||||
rmSync(join(hooksDir, 'antigravity-hook.cmd'))
|
||||
return hooksDir
|
||||
}
|
||||
|
||||
it.each(['ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN', 'ORCA_PANE_KEY'])(
|
||||
'answers every missing-core event with abandoned stdin and no %s',
|
||||
async (missingKey) => {
|
||||
const hooksDir = await installWithoutCore()
|
||||
const listener = await startHookListener()
|
||||
server = listener.server
|
||||
const env = hookEnvironment({
|
||||
USERPROFILE: home,
|
||||
HOME: home,
|
||||
ORCA_AGENT_HOOK_PORT: String(listener.port),
|
||||
ORCA_AGENT_HOOK_TOKEN: HOOK_TOKEN,
|
||||
ORCA_PANE_KEY: PANE_KEY,
|
||||
[missingKey]: ''
|
||||
})
|
||||
for (const event of ANTIGRAVITY_EVENTS) {
|
||||
const result = await runWrapper(join(hooksDir, event.windowsWrapperFileName), env, null)
|
||||
expect(result.timedOut, event.eventName).toBe(false)
|
||||
expect(result.exitCode, event.eventName).toBe(0)
|
||||
expect(result.stdout.trim(), event.eventName).toBe(expectedStdout(event.eventName))
|
||||
expect(result.stderr, event.eventName).toBe('')
|
||||
}
|
||||
expect(listener.posts).toHaveLength(0)
|
||||
},
|
||||
90_000
|
||||
)
|
||||
|
||||
// Why: the guard must not cost the valid path its drain — with the Orca env present the
|
||||
// fallback still owns stdin, so the agent's payload write completes instead of breaking.
|
||||
it('still drains a closed payload for every missing-core event inside a pane', async () => {
|
||||
const hooksDir = await installWithoutCore()
|
||||
const listener = await startHookListener()
|
||||
server = listener.server
|
||||
const env = hookEnvironment({
|
||||
USERPROFILE: home,
|
||||
HOME: home,
|
||||
ORCA_AGENT_HOOK_PORT: String(listener.port),
|
||||
ORCA_AGENT_HOOK_TOKEN: HOOK_TOKEN,
|
||||
ORCA_PANE_KEY: PANE_KEY
|
||||
})
|
||||
for (const event of ANTIGRAVITY_EVENTS) {
|
||||
const result = await runWrapper(join(hooksDir, event.windowsWrapperFileName), env)
|
||||
expect(result.timedOut, event.eventName).toBe(false)
|
||||
expect(result.exitCode, event.eventName).toBe(0)
|
||||
expect(result.stdout.trim(), event.eventName).toBe(expectedStdout(event.eventName))
|
||||
expect(result.stderr, event.eventName).toBe('')
|
||||
}
|
||||
// Why: the fallback answers the agent but has no core to post through.
|
||||
expect(listener.posts).toHaveLength(0)
|
||||
}, 60_000)
|
||||
|
||||
it('exits without reading stdin when the pane env is missing', async () => {
|
||||
home = mkdtempSync(join(tmpdir(), 'orca-antigravity-hook-'))
|
||||
homedirMock.mockReturnValue(home)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { createServer, type Socket } from 'node:net'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RemoteBrowserSocksServer } from './remote-browser-socks-server'
|
||||
|
||||
vi.mock('node:net', () => ({
|
||||
createServer: vi.fn(() => ({ listening: false }))
|
||||
}))
|
||||
|
||||
function setup(requestTail: Buffer = Buffer.alloc(0)) {
|
||||
const upstream = new PassThrough()
|
||||
const write = vi.spyOn(upstream, 'write')
|
||||
const opened = Promise.withResolvers<PassThrough>()
|
||||
const open = vi.fn(() => opened.promise)
|
||||
const server = new RemoteBrowserSocksServer({ open })
|
||||
const socket = Object.assign(new EventEmitter(), {
|
||||
remoteAddress: '127.0.0.1',
|
||||
destroyed: false,
|
||||
write: vi.fn(() => true),
|
||||
pause: vi.fn(),
|
||||
end: vi.fn((_reply, callback) => callback()),
|
||||
pipe: vi.fn(),
|
||||
destroy: vi.fn(() => {
|
||||
socket.destroyed = true
|
||||
socket.emit('close')
|
||||
})
|
||||
})
|
||||
const accept = vi.mocked(createServer).mock.calls.at(-1)![0] as (socket: Socket) => void
|
||||
accept(socket as unknown as Socket)
|
||||
socket.emit('data', Buffer.from([5, 1, 0]))
|
||||
socket.emit('data', Buffer.concat([Buffer.from([5, 1, 0, 1, 127, 0, 0, 1, 1, 187]), requestTail]))
|
||||
return { server, socket, upstream, write, opened, open }
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('pending browser SOCKS route buffering', () => {
|
||||
it('copies fragmented pending bytes linearly and forwards every byte at the existing cap', async () => {
|
||||
const { server, socket, upstream, write, opened } = setup()
|
||||
const payload = Buffer.alloc(256 * 1024)
|
||||
for (let index = 0; index < payload.length; index += 1) {
|
||||
payload[index] = index % 251
|
||||
}
|
||||
let copiedBytes = 0
|
||||
const originalCopy = Buffer.prototype.copy
|
||||
const copy = vi.spyOn(Buffer.prototype, 'copy').mockImplementation(function (target, ...args) {
|
||||
const copied = originalCopy.call(this, target, ...args)
|
||||
copiedBytes += copied
|
||||
return copied
|
||||
})
|
||||
const concat = vi.spyOn(Buffer, 'concat')
|
||||
try {
|
||||
for (let index = 0; index < payload.length; index += 256) {
|
||||
socket.emit('data', payload.subarray(index, index + 256))
|
||||
}
|
||||
expect(concat.mock.calls.length).toBe(0)
|
||||
expect(copiedBytes).toBeLessThan(payload.length * 3)
|
||||
opened.resolve(upstream)
|
||||
await vi.waitFor(() => expect(write).toHaveBeenCalledTimes(1))
|
||||
expect(write.mock.calls[0][0]).toEqual(payload)
|
||||
} finally {
|
||||
copy.mockRestore()
|
||||
concat.mockRestore()
|
||||
await server.close()
|
||||
upstream.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps request-tail bytes ahead of later fragments in the pending payload', async () => {
|
||||
const tail = Buffer.from('GET / HTTP/1.1\r\n')
|
||||
const { server, socket, upstream, write, opened } = setup(tail)
|
||||
const rest = Buffer.from('Host: example.com\r\n\r\n')
|
||||
try {
|
||||
for (const byte of rest) {
|
||||
socket.emit('data', Buffer.from([byte]))
|
||||
}
|
||||
opened.resolve(upstream)
|
||||
await vi.waitFor(() => expect(write).toHaveBeenCalledTimes(1))
|
||||
expect(write.mock.calls[0][0]).toEqual(Buffer.concat([tail, rest]))
|
||||
} finally {
|
||||
await server.close()
|
||||
upstream.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects one byte beyond the cap and destroys a late upstream without forwarding', async () => {
|
||||
const { server, socket, upstream, write, opened, open } = setup()
|
||||
try {
|
||||
await vi.waitFor(() => expect(open).toHaveBeenCalledTimes(1))
|
||||
socket.emit('data', Buffer.alloc(256 * 1024))
|
||||
expect(socket.destroyed).toBe(false)
|
||||
socket.emit('data', Buffer.from([1]))
|
||||
expect(socket.destroyed).toBe(true)
|
||||
expect(socket.end.mock.calls[0][0][1]).toBe(1)
|
||||
opened.resolve(upstream)
|
||||
await vi.waitFor(() => expect(upstream.destroyed).toBe(true))
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('discards pending input on client close while the route is opening', async () => {
|
||||
const { server, socket, upstream, write, opened, open } = setup()
|
||||
try {
|
||||
await vi.waitFor(() => expect(open).toHaveBeenCalledTimes(1))
|
||||
socket.emit('data', Buffer.from('pending request'))
|
||||
socket.destroy()
|
||||
opened.resolve(upstream)
|
||||
await vi.waitFor(() => expect(upstream.destroyed).toBe(true))
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createServer, type Server, type Socket } from 'node:net'
|
||||
import type { Duplex } from 'node:stream'
|
||||
import { GrowingByteBuffer } from '../../shared/growing-byte-buffer'
|
||||
import { pipeUpstreamToClient } from './remote-browser-socks-upstream'
|
||||
|
||||
const SOCKS_VERSION = 5
|
||||
const SOCKS_NO_AUTH = 0
|
||||
@@ -106,10 +108,13 @@ export class RemoteBrowserSocksServer {
|
||||
this.clients.add(socket)
|
||||
let phase: 'greeting' | 'request' | 'opening' | 'connected' | 'closed' = 'greeting'
|
||||
let buffered = Buffer.alloc(0)
|
||||
const pendingUpstream = new GrowingByteBuffer()
|
||||
const timeout = setTimeout(() => socket.destroy(), HANDSHAKE_TIMEOUT_MS)
|
||||
const cleanup = (): void => {
|
||||
phase = 'closed'
|
||||
clearTimeout(timeout)
|
||||
buffered = Buffer.alloc(0)
|
||||
pendingUpstream.clear()
|
||||
this.clients.delete(socket)
|
||||
}
|
||||
const finishFailure = (reply: Uint8Array): void => {
|
||||
@@ -119,6 +124,7 @@ export class RemoteBrowserSocksServer {
|
||||
phase = 'closed'
|
||||
clearTimeout(timeout)
|
||||
buffered = Buffer.alloc(0)
|
||||
pendingUpstream.clear()
|
||||
socket.pause()
|
||||
socket.end(reply, () => socket.destroy())
|
||||
}
|
||||
@@ -127,13 +133,15 @@ export class RemoteBrowserSocksServer {
|
||||
if (phase === 'closed' || phase === 'connected') {
|
||||
return
|
||||
}
|
||||
buffered = Buffer.concat([buffered, chunk])
|
||||
if (phase === 'opening') {
|
||||
if (buffered.byteLength > MAX_PENDING_UPSTREAM_BYTES) {
|
||||
if (pendingUpstream.byteLength + chunk.byteLength > MAX_PENDING_UPSTREAM_BYTES) {
|
||||
fail(1)
|
||||
} else {
|
||||
pendingUpstream.append(chunk)
|
||||
}
|
||||
return
|
||||
}
|
||||
buffered = Buffer.concat([buffered, chunk])
|
||||
if (phase === 'greeting' && buffered.byteLength > MAX_HANDSHAKE_BYTES) {
|
||||
fail(1)
|
||||
return
|
||||
@@ -181,6 +189,8 @@ export class RemoteBrowserSocksServer {
|
||||
return
|
||||
}
|
||||
phase = 'opening'
|
||||
pendingUpstream.append(buffered)
|
||||
buffered = Buffer.alloc(0)
|
||||
void Promise.resolve()
|
||||
.then(() => this.open(normalizeListenerWildcard(parsed.target)))
|
||||
.then(
|
||||
@@ -193,9 +203,8 @@ export class RemoteBrowserSocksServer {
|
||||
clearTimeout(timeout)
|
||||
socket.off('data', onData)
|
||||
socket.write(SUCCESS_RESPONSE)
|
||||
if (buffered.byteLength > 0) {
|
||||
upstream.write(buffered)
|
||||
buffered = Buffer.alloc(0)
|
||||
if (pendingUpstream.byteLength > 0) {
|
||||
upstream.write(pendingUpstream.takeBuffer())
|
||||
}
|
||||
socket.pipe(upstream)
|
||||
pipeUpstreamToClient(upstream, socket)
|
||||
@@ -212,22 +221,6 @@ export class RemoteBrowserSocksServer {
|
||||
}
|
||||
}
|
||||
|
||||
function pipeUpstreamToClient(upstream: Duplex, socket: Socket): void {
|
||||
upstream.on('data', (chunk: Buffer) => {
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
const accepted = socket.write(bytes, (error) => {
|
||||
if (!error && 'settleRead' in upstream && typeof upstream.settleRead === 'function') {
|
||||
upstream.settleRead(bytes.byteLength)
|
||||
}
|
||||
})
|
||||
if (!accepted) {
|
||||
upstream.pause()
|
||||
}
|
||||
})
|
||||
socket.on('drain', () => upstream.resume())
|
||||
upstream.once('end', () => socket.end())
|
||||
}
|
||||
|
||||
function parseSocksRequest(buffer: Uint8Array): SocksRequest | null | undefined {
|
||||
if (buffer.byteLength < 4) {
|
||||
return undefined
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Socket } from 'node:net'
|
||||
import type { Duplex } from 'node:stream'
|
||||
|
||||
export function pipeUpstreamToClient(upstream: Duplex, socket: Socket): void {
|
||||
upstream.on('data', (chunk: Buffer) => {
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
const accepted = socket.write(bytes, (error) => {
|
||||
if (!error && 'settleRead' in upstream && typeof upstream.settleRead === 'function') {
|
||||
upstream.settleRead(bytes.byteLength)
|
||||
}
|
||||
})
|
||||
if (!accepted) {
|
||||
upstream.pause()
|
||||
}
|
||||
})
|
||||
socket.on('drain', () => upstream.resume())
|
||||
upstream.once('end', () => socket.end())
|
||||
}
|
||||
@@ -56,7 +56,10 @@ function upsertTrustBlocks(
|
||||
hash: string,
|
||||
explicitEnabled?: boolean
|
||||
): string {
|
||||
const ranges = getUniqueTrustBlockRanges(content, keys)
|
||||
const ranges = findHookTrustBlockRanges(
|
||||
content,
|
||||
new Set(keys.map(normalizeCodexHookTrustLookupKey))
|
||||
)
|
||||
if (ranges.length === 0) {
|
||||
return appendTrustBlocks(content, keys, hash, explicitEnabled ?? true)
|
||||
}
|
||||
@@ -74,21 +77,6 @@ function upsertTrustBlocks(
|
||||
return deduped + content.slice(cursor)
|
||||
}
|
||||
|
||||
function getUniqueTrustBlockRanges(
|
||||
content: string,
|
||||
keys: readonly string[]
|
||||
): HookTrustBlockRange[] {
|
||||
const normalizedKeys = new Set(keys.map(normalizeCodexHookTrustLookupKey))
|
||||
return findHookTrustBlockRanges(content, normalizedKeys)
|
||||
.filter(
|
||||
(range, index, ranges) =>
|
||||
ranges.findIndex(
|
||||
(candidate) => candidate.start === range.start && candidate.end === range.end
|
||||
) === index
|
||||
)
|
||||
.sort((left, right) => left.start - right.start)
|
||||
}
|
||||
|
||||
function isBlockDisabled(content: string, range: HookTrustBlockRange): boolean {
|
||||
const block = content.slice(range.headerLineEnd, range.end)
|
||||
const enabledMatch = /^[ \t]*enabled[ \t]*=[ \t]*(true|false)[ \t\r]*(?:#.*)?$/m.exec(block)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type * as HookTrustBlocks from './config-toml-hook-trust-blocks'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { upsertHookTrustContent } from './config-toml-hook-trust-edit'
|
||||
|
||||
const counts = vi.hoisted(() => ({ starts: 0 }))
|
||||
vi.mock('./config-toml-hook-trust-blocks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof HookTrustBlocks>()
|
||||
return {
|
||||
...actual,
|
||||
findHookTrustBlockRanges: (...args: Parameters<typeof actual.findHookTrustBlockRanges>) =>
|
||||
actual.findHookTrustBlockRanges(...args).map((range) => ({
|
||||
...range,
|
||||
get start() {
|
||||
counts.starts += 1
|
||||
return range.start
|
||||
}
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
it('consumes monotonically scanned trust ranges without pairwise deduplication', () => {
|
||||
const header = '[hooks.state."/foo/hooks.json:pre_tool_use:0:0"]'
|
||||
const content = `${header}\nenabled = true\ntrusted_hash = "old"\n`.repeat(1000)
|
||||
counts.starts = 0
|
||||
const result = upsertHookTrustContent(content, [
|
||||
{
|
||||
sourcePath: '/foo/hooks.json',
|
||||
eventLabel: 'pre_tool_use',
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: '/bin/echo hi',
|
||||
trustedHash: 'updated'
|
||||
}
|
||||
])
|
||||
expect(counts.starts).toBeLessThan(5000)
|
||||
expect(result.split(header)).toHaveLength(2)
|
||||
expect(result).toContain('trusted_hash = "updated"')
|
||||
})
|
||||
|
||||
// Dropping the old dedup+sort is only sound because the scanner advances its
|
||||
// cursor past each block it emits. Pin that precondition: if a future scanner
|
||||
// change lets ranges repeat or overlap, the upsert below would delete or widen
|
||||
// a neighbouring trust block instead of rewriting just the matched one.
|
||||
it('emits trust ranges with strictly ascending, non-overlapping spans', async () => {
|
||||
const { findHookTrustBlockRanges } = await vi.importActual<typeof HookTrustBlocks>(
|
||||
'./config-toml-hook-trust-blocks'
|
||||
)
|
||||
const key = '/foo/hooks.json:pre_tool_use:0:0'
|
||||
const header = `[hooks.state."${key}"]`
|
||||
const contents = [
|
||||
'',
|
||||
header,
|
||||
`${header}\n${header}\n`,
|
||||
`${header}\nenabled = true\n`.repeat(50),
|
||||
`${header}\r\nenabled = true\r\n`.repeat(3),
|
||||
`[x]\nv = """\n${header}\n"""\n${header}\nenabled = true\n`,
|
||||
`[x]\na = [\n${header}\n]\n${header}\nenabled = true\n`,
|
||||
`${header}\nenabled = true\n[[arr]]\nz = 1\n${header}\n`
|
||||
]
|
||||
for (const content of contents) {
|
||||
const ranges = findHookTrustBlockRanges(content, new Set([key]))
|
||||
for (const [index, range] of ranges.entries()) {
|
||||
expect(range.end).toBeGreaterThanOrEqual(range.start)
|
||||
expect(range.end).toBeLessThanOrEqual(content.length)
|
||||
if (index > 0) {
|
||||
expect(ranges[index - 1].start).toBeLessThan(range.start)
|
||||
expect(ranges[index - 1].end).toBeLessThanOrEqual(range.start)
|
||||
}
|
||||
}
|
||||
expect(new Set(ranges.map((range) => `${range.start}:${range.end}`)).size).toBe(ranges.length)
|
||||
}
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
import { getSharedManagedScriptPath } from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
buildPosixHookSpoolLines
|
||||
buildPosixHookSpoolLines,
|
||||
WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
|
||||
export function getManagedScriptFileName(): string {
|
||||
@@ -30,7 +31,7 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
// Why (#11549 class): missing Orca context means a user-wide hook fired outside an
|
||||
// Orca pane. ReadToEnd blocks forever if that caller abandons the pipe, so the guard
|
||||
// must run before the hook owns stdin; the payload would be discarded anyway.
|
||||
'if (-not $env:ORCA_AGENT_HOOK_PORT -or -not $env:ORCA_AGENT_HOOK_TOKEN -or -not $env:ORCA_PANE_KEY) { exit 0 }',
|
||||
WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD,
|
||||
'$inputData = [Console]::In.ReadToEnd()',
|
||||
'if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }',
|
||||
'try {',
|
||||
|
||||
@@ -25,19 +25,23 @@ export function countDiffLines(diff: string): { additions: number; deletions: nu
|
||||
// diff line `---<content>`, colliding with the `--- a/file` header — so it must
|
||||
// be counted once inside a hunk, not skipped.
|
||||
let inHunk = false
|
||||
for (const line of diff.split('\n')) {
|
||||
if (line.startsWith('@@')) {
|
||||
let cursor = 0
|
||||
while (cursor < diff.length) {
|
||||
if (diff.startsWith('@@', cursor)) {
|
||||
inHunk = true
|
||||
continue
|
||||
} else if (inHunk) {
|
||||
const prefix = diff.charCodeAt(cursor)
|
||||
if (prefix === 43) {
|
||||
additions += 1
|
||||
} else if (prefix === 45) {
|
||||
deletions += 1
|
||||
}
|
||||
}
|
||||
if (!inHunk) {
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('+')) {
|
||||
additions += 1
|
||||
} else if (line.startsWith('-')) {
|
||||
deletions += 1
|
||||
const newline = diff.indexOf('\n', cursor)
|
||||
if (newline === -1) {
|
||||
break
|
||||
}
|
||||
cursor = newline + 1
|
||||
}
|
||||
return { additions, deletions }
|
||||
}
|
||||
|
||||
@@ -458,4 +458,42 @@ describe('countDiffLines', () => {
|
||||
// Why: the `@@` hunk check runs first, so it must not swallow `+`/`-` content.
|
||||
expect(countDiffLines('@@ -1 +1 @@\n-@@ old\n+@@ new')).toEqual({ additions: 1, deletions: 1 })
|
||||
})
|
||||
|
||||
// Why: the scan now reads a prefix code unit at a byte cursor rather than a split
|
||||
// segment, so line-ending and non-ASCII shapes are the new regression surface.
|
||||
it('counts a CRLF hunk the same as an LF hunk', () => {
|
||||
expect(countDiffLines('@@ -1 +1,2 @@\r\n-old\r\n+a\r\n+b\r\n')).toEqual({
|
||||
additions: 2,
|
||||
deletions: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a lone CR as content, not a line break', () => {
|
||||
expect(countDiffLines('@@ -1 +1 @@\n-old\r+new')).toEqual({ additions: 0, deletions: 1 })
|
||||
})
|
||||
|
||||
it('counts lines whose content is multi-byte or a surrogate pair', () => {
|
||||
expect(countDiffLines('@@ -1 +1 @@\n-é ünïcode\n+🚀 rocket')).toEqual({
|
||||
additions: 1,
|
||||
deletions: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores non-ASCII context lines and blank lines inside a hunk', () => {
|
||||
expect(countDiffLines('@@ -1 +1 @@\n é leading accent\n 🚀 leading emoji\n\n')).toEqual({
|
||||
additions: 0,
|
||||
deletions: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('counts large diff prefixes without allocating a string array for every line', () => {
|
||||
const diff = `--- a/file\n+++ b/file\n@@ -1 +1 @@\n${'-old\n+new\n context\n'.repeat(10000)}`
|
||||
const split = vi.spyOn(String.prototype, 'split')
|
||||
try {
|
||||
expect(countDiffLines(diff)).toEqual({ additions: 10000, deletions: 10000 })
|
||||
expect(split.mock.calls.length).toBe(0)
|
||||
} finally {
|
||||
split.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
|
||||
import { registerSshPtyProvider, getLocalPtyProvider } from './pty'
|
||||
import { installPtyInspectIpcHandlers } from './pty/ipc/inspect'
|
||||
import { ptyOwnership } from './pty/provider/ownership-state'
|
||||
|
||||
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
|
||||
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
|
||||
vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock()))
|
||||
vi.mock('node:child_process', async (importOriginal) =>
|
||||
(await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal())
|
||||
)
|
||||
vi.mock('../opencode/hook-service', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock())
|
||||
)
|
||||
vi.mock('../mimo/hook-service', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock())
|
||||
)
|
||||
vi.mock('../agent-hooks/server', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock())
|
||||
)
|
||||
vi.mock('../pi/titlebar-extension-service', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock())
|
||||
)
|
||||
vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock()))
|
||||
vi.mock('../wsl', async (importOriginal) =>
|
||||
(await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal())
|
||||
)
|
||||
vi.mock('../telemetry/client', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock())
|
||||
)
|
||||
vi.mock('../telemetry/classify-error', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock())
|
||||
)
|
||||
vi.mock('../cli/linux-terminal-orca-cli-shim', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock())
|
||||
)
|
||||
vi.mock('../memory/pty-registry', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock())
|
||||
)
|
||||
vi.mock('../agent-hooks/migration-unsupported-pty-state', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock())
|
||||
)
|
||||
vi.mock('../codex/codex-pane-account-registry', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock())
|
||||
)
|
||||
vi.mock('../codex/codex-state-db-backfill-recovery', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock())
|
||||
)
|
||||
|
||||
describe('scoped activation PTY inventory', () => {
|
||||
const { handlers, installDaemonTestProvider } = setupPtyIpcSuite()
|
||||
|
||||
function install() {
|
||||
const localList = vi.fn(async () => [{ id: 'local', cwd: '/', title: 'shell' }])
|
||||
installDaemonTestProvider({ listProcesses: localList })
|
||||
const remoteLists = Array.from({ length: 50 }, (_, index) => {
|
||||
const list = vi.fn(async () => [
|
||||
{
|
||||
id: `ssh:host-${index}@@pty-1`,
|
||||
cwd: '/remote',
|
||||
title: 'agent',
|
||||
worktreeId: 'repo::/remote'
|
||||
}
|
||||
])
|
||||
registerSshPtyProvider(`host-${index}`, {
|
||||
...getLocalPtyProvider(),
|
||||
listProcesses: list,
|
||||
providesAgentSessionOwnerListings: () => true
|
||||
})
|
||||
return list
|
||||
})
|
||||
const startup = vi.fn(async () => {})
|
||||
installPtyInspectIpcHandlers({ getLocalPtyProviderStartupPromise: startup })
|
||||
const list = (scope?: unknown) => handlers.get('pty:listSessions')!(null, scope)
|
||||
return { localList, remoteLists, startup, list }
|
||||
}
|
||||
|
||||
it('queries only the chosen SSH provider and preserves workspace and ownership evidence', async () => {
|
||||
const { list, localList, remoteLists, startup } = install()
|
||||
expect(await list({ connectionId: 'host-17' })).toEqual([
|
||||
{
|
||||
id: 'ssh:host-17@@pty-1',
|
||||
cwd: '/remote',
|
||||
title: 'agent',
|
||||
worktreeId: 'repo::/remote',
|
||||
agentOwnership: 'absent'
|
||||
}
|
||||
])
|
||||
expect(remoteLists[17]).toHaveBeenCalledOnce()
|
||||
expect(remoteLists.reduce((count, mock) => count + mock.mock.calls.length, 0)).toBe(1)
|
||||
expect(localList).not.toHaveBeenCalled()
|
||||
expect(startup).not.toHaveBeenCalled()
|
||||
expect(ptyOwnership.get('ssh:host-17@@pty-1')).toBe('host-17')
|
||||
})
|
||||
|
||||
it('waits for local startup and never visits remote providers for a local scope', async () => {
|
||||
const { list, localList, remoteLists, startup } = install()
|
||||
let release!: () => void
|
||||
startup.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
)
|
||||
const pending = list({ connectionId: null })
|
||||
expect(localList).not.toHaveBeenCalled()
|
||||
release()
|
||||
await pending
|
||||
expect(localList).toHaveBeenCalledOnce()
|
||||
expect(remoteLists.every((mock) => mock.mock.calls.length === 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('propagates selected-host failure and never substitutes the local inventory', async () => {
|
||||
const { list, localList, remoteLists } = install()
|
||||
remoteLists[3].mockRejectedValue(new Error('relay unavailable'))
|
||||
await expect(list({ connectionId: 'host-3' })).rejects.toThrow('relay unavailable')
|
||||
await expect(list({ connectionId: 'missing' })).rejects.toThrow('No PTY provider')
|
||||
expect(localList).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([null, {}, { connectionId: '' }, { connectionId: 42 }])(
|
||||
'rejects malformed scope %j before inventory admission',
|
||||
async (scope) => {
|
||||
const { list, localList, remoteLists } = install()
|
||||
await expect(list(scope)).rejects.toThrow('invalid_pty_session_list_scope')
|
||||
expect(localList).not.toHaveBeenCalled()
|
||||
expect(remoteLists.every((mock) => mock.mock.calls.length === 0)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves unscoped diagnostic inventory and its remote-error fallback', async () => {
|
||||
const { list, localList, remoteLists } = install()
|
||||
remoteLists[3].mockRejectedValue(new Error('relay unavailable'))
|
||||
expect(await list()).toHaveLength(50)
|
||||
expect(localList).toHaveBeenCalledOnce()
|
||||
expect(remoteLists.every((mock) => mock.mock.calls.length === 1)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -6,10 +6,11 @@ import {
|
||||
PtyProcessListAdmission,
|
||||
visitPtyProcessListingsInBatches
|
||||
} from '../../../providers/pty-process-list-admission'
|
||||
import type { PtyListedSession } from '../../../../shared/pty-listed-session'
|
||||
import type { PtyListedSession, PtySessionListScope } from '../../../../shared/pty-listed-session'
|
||||
import { ptyOwnership } from '../provider/ownership-state'
|
||||
import {
|
||||
getProviderForPty,
|
||||
getProvider,
|
||||
hasPtyProviderForInspection,
|
||||
registeredPtyProviders,
|
||||
sshProviders,
|
||||
@@ -40,37 +41,58 @@ export function installPtyInspectIpcHandlers(deps: {
|
||||
)
|
||||
}
|
||||
|
||||
ipcMain.handle('pty:listSessions', async (): Promise<PtyListedSession[]> => {
|
||||
const deduped = new Map<string, PtyListedSession>()
|
||||
const admission = new PtyProcessListAdmission()
|
||||
await visitPtyProcessListingsInBatches(
|
||||
registeredPtyProviders(),
|
||||
({ provider, connectionId }) =>
|
||||
connectionId === null ? provider.listProcesses() : provider.listProcesses().catch(() => []),
|
||||
({ provider, connectionId }, sessions) => {
|
||||
for (const rawSession of sessions) {
|
||||
const session = admission.admit(rawSession)
|
||||
// Why: kill actions only send back the PTY id, so rebuild ownership while listing to keep reconnect-discovered remote sessions routed to their provider.
|
||||
ptyOwnership.set(session.id, connectionId)
|
||||
deduped.set(session.id, {
|
||||
id: session.id,
|
||||
cwd: session.cwd,
|
||||
title: session.title,
|
||||
// Why: the renderer's binding map is empty during restore, so ownership is the only
|
||||
// liveness evidence it has. Absence is authoritative only from a provider that
|
||||
// serializes claims — otherwise it is 'unknown', never 'absent' (#8459).
|
||||
agentOwnership:
|
||||
(session.agentSessionOwners?.length ?? 0) > 0
|
||||
? 'present'
|
||||
: provider.providesAgentSessionOwnerListings?.(session.id) === true
|
||||
? 'absent'
|
||||
: 'unknown'
|
||||
})
|
||||
ipcMain.handle(
|
||||
'pty:listSessions',
|
||||
async (_event, scope?: PtySessionListScope): Promise<PtyListedSession[]> => {
|
||||
if (scope !== undefined) {
|
||||
if (
|
||||
!scope ||
|
||||
(scope.connectionId !== null &&
|
||||
(typeof scope.connectionId !== 'string' || !scope.connectionId.trim()))
|
||||
) {
|
||||
throw new Error('invalid_pty_session_list_scope')
|
||||
}
|
||||
// Select the daemon only after startup has handed off ownership.
|
||||
if (scope.connectionId === null) {
|
||||
await getLocalPtyProviderStartupPromise()
|
||||
}
|
||||
}
|
||||
)
|
||||
return Array.from(deduped.values())
|
||||
})
|
||||
const deduped = new Map<string, PtyListedSession>()
|
||||
const admission = new PtyProcessListAdmission()
|
||||
await visitPtyProcessListingsInBatches(
|
||||
scope === undefined
|
||||
? registeredPtyProviders()
|
||||
: [{ provider: getProvider(scope.connectionId), connectionId: scope.connectionId }],
|
||||
({ provider, connectionId }) =>
|
||||
connectionId === null || scope !== undefined
|
||||
? provider.listProcesses()
|
||||
: provider.listProcesses().catch(() => []),
|
||||
({ provider, connectionId }, sessions) => {
|
||||
for (const rawSession of sessions) {
|
||||
const session = admission.admit(rawSession)
|
||||
// Why: kill actions only send back the PTY id, so rebuild ownership while listing to keep reconnect-discovered remote sessions routed to their provider.
|
||||
ptyOwnership.set(session.id, connectionId)
|
||||
deduped.set(session.id, {
|
||||
id: session.id,
|
||||
cwd: session.cwd,
|
||||
title: session.title,
|
||||
...(session.worktreeId !== undefined ? { worktreeId: session.worktreeId } : {}),
|
||||
// Why: the renderer's binding map is empty during restore, so ownership is the only
|
||||
// liveness evidence it has. Absence is authoritative only from a provider that
|
||||
// serializes claims — otherwise it is 'unknown', never 'absent' (#8459).
|
||||
agentOwnership:
|
||||
(session.agentSessionOwners?.length ?? 0) > 0
|
||||
? 'present'
|
||||
: provider.providesAgentSessionOwnerListings?.(session.id) === true
|
||||
? 'absent'
|
||||
: 'unknown'
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
return Array.from(deduped.values())
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'pty:getAuthoritativeBufferSnapshotCapabilities',
|
||||
|
||||
@@ -81,6 +81,49 @@ function sessionStore(leaves: string[]): { store: Store; read: () => WorkspaceSe
|
||||
}
|
||||
|
||||
describe('stable pane adoption after the relay reports the PTY absent', () => {
|
||||
it.each([false, true])(
|
||||
'reattaches a live pane without launching a provider process (settled worker: %s)',
|
||||
async (settledWorker) => {
|
||||
const { store, read } = sessionStore([LEAF])
|
||||
const paneKey = `${OWNER.tabId}:${LEAF}`
|
||||
const record = {
|
||||
paneKey,
|
||||
tabId: OWNER.tabId,
|
||||
worktreeId: WORKTREE,
|
||||
agent: 'claude' as const,
|
||||
providerSession: { key: 'session_id' as const, id: 'provider-session' },
|
||||
prompt: '',
|
||||
state: 'done' as const,
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
...(settledWorker ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' } : {})
|
||||
}
|
||||
store.setWorkspaceSession({
|
||||
...read(),
|
||||
sleepingAgentSessionsByPaneKey: { [paneKey]: record }
|
||||
})
|
||||
const spawn = vi.fn().mockResolvedValue({ id: OWNER.ptyId, isReattach: true })
|
||||
const onFreshSpawn = vi.fn()
|
||||
const result = await spawnForStablePane({
|
||||
runtime: undefined,
|
||||
store,
|
||||
worktreeId: WORKTREE,
|
||||
provider: { spawn } as unknown as IPtyProvider,
|
||||
spawnOptions: { cols: 80, rows: 24, command: 'claude --resume provider-session' },
|
||||
owner: OWNER,
|
||||
connectionId: 'conn-1',
|
||||
resolveOwner: () => OWNER,
|
||||
onFreshSpawn
|
||||
})
|
||||
expect(result.owner).toBe(OWNER)
|
||||
expect(spawn).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ sessionId: OWNER.ptyId, attachOnly: true, command: undefined })
|
||||
)
|
||||
expect(onFreshSpawn).not.toHaveBeenCalled()
|
||||
expect(read().tabsByWorktree[WORKTREE]).toHaveLength(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('spawns fresh once the relay has positively answered for that id', async () => {
|
||||
const { run, spawn } = spawnAfterAttachRejection(
|
||||
new SshPtyAbsentFromRelayError(`${SSH_SESSION_EXPIRED_ERROR}: pty-1`)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sortByUpdatedAtDescending } from '../../shared/updated-at-order'
|
||||
import type { JiraIssue, JiraIssueFilter, JiraSiteSelection } from '../../shared/jira-types'
|
||||
import { acquire, release } from './request-queue'
|
||||
import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request'
|
||||
@@ -18,9 +19,7 @@ function clampLimit(limit: number | undefined, fallback = 30): number {
|
||||
}
|
||||
|
||||
function sortAndLimitIssues(issues: JiraIssue[], limit: number): JiraIssue[] {
|
||||
return issues
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
||||
.slice(0, limit)
|
||||
return sortByUpdatedAtDescending(issues).slice(0, limit)
|
||||
}
|
||||
|
||||
function filterToJql(filter: JiraIssueFilter): string {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sortByUpdatedAtDescending } from '../../shared/updated-at-order'
|
||||
import type { LinearIssue } from '../../shared/linear/issue-types'
|
||||
import type { LinearWorkspaceSelection } from '../../shared/linear/workspace-types'
|
||||
import { LINEAR_ISSUE_API_PAGE_SIZE_MAX } from '../../shared/linear/issue-read-limits'
|
||||
@@ -30,18 +31,14 @@ export async function mapIssueForWorkspace(
|
||||
}
|
||||
|
||||
export function sortAndLimitIssues(issues: LinearIssue[], limit: number): LinearIssue[] {
|
||||
return issues
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
||||
.slice(0, limit)
|
||||
return sortByUpdatedAtDescending(issues).slice(0, limit)
|
||||
}
|
||||
|
||||
export function sortLimitAndDescribeIssues(
|
||||
issues: LinearIssue[],
|
||||
limit: number
|
||||
): { items: LinearIssue[]; clipped: boolean } {
|
||||
const sorted = issues.sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
)
|
||||
const sorted = sortByUpdatedAtDescending(issues)
|
||||
return {
|
||||
items: sorted.slice(0, limit),
|
||||
clipped: sorted.length > limit
|
||||
|
||||
@@ -66,8 +66,7 @@ export function boundHistoryItemsByBytes(
|
||||
submissionBytes: ReadonlyMap<string, number>,
|
||||
maxBytes: number
|
||||
): { items: AgentJournalRenderItem[]; dropped: number } {
|
||||
const groups = groupItemsBySequence(items)
|
||||
const ordered = keep === 'newest' ? groups.toReversed() : groups
|
||||
const ordered = groupItemsBySequence(items, keep)
|
||||
const kept: AgentJournalRenderItem[][] = []
|
||||
let total = 0
|
||||
for (const group of ordered) {
|
||||
@@ -88,29 +87,42 @@ export function boundHistoryItemsByBytes(
|
||||
}
|
||||
}
|
||||
|
||||
function groupItemsBySequence(
|
||||
items: readonly AgentJournalRenderItem[]
|
||||
): AgentJournalRenderItem[][] {
|
||||
const groups: AgentJournalRenderItem[][] = []
|
||||
for (const item of items) {
|
||||
const current = groups.at(-1)
|
||||
if (current?.[0]?.sequence === item.sequence) {
|
||||
current.push(item)
|
||||
/** Adjacent same-sequence runs, walked from the end (`newest`) or the start (`oldest`)
|
||||
* so a caller that stops at its window never groups the history it will not return.
|
||||
* Groups and their items keep the order the eager forward grouping produced. */
|
||||
function* groupItemsBySequence(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
keep: 'newest' | 'oldest'
|
||||
): Generator<AgentJournalRenderItem[]> {
|
||||
let cursor = keep === 'newest' ? items.length : 0
|
||||
while (keep === 'newest' ? cursor > 0 : cursor < items.length) {
|
||||
if (keep === 'newest') {
|
||||
let start = cursor - 1
|
||||
const sequence = items[start].sequence
|
||||
while (start > 0 && items[start - 1].sequence === sequence) {
|
||||
start -= 1
|
||||
}
|
||||
yield items.slice(start, cursor)
|
||||
cursor = start
|
||||
} else {
|
||||
groups.push([item])
|
||||
let end = cursor + 1
|
||||
const sequence = items[cursor].sequence
|
||||
while (end < items.length && items[end].sequence === sequence) {
|
||||
end += 1
|
||||
}
|
||||
yield items.slice(cursor, end)
|
||||
cursor = end
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
export function newestWholeSequenceGroups(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
limit: number
|
||||
): AgentJournalRenderItem[] {
|
||||
const groups = groupItemsBySequence(items)
|
||||
const selected: AgentJournalRenderItem[][] = []
|
||||
let count = 0
|
||||
for (const group of groups.toReversed()) {
|
||||
for (const group of groupItemsBySequence(items, 'newest')) {
|
||||
if (selected.length > 0 && count + group.length > limit) {
|
||||
break
|
||||
}
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
boundHistoryItemsByBytes,
|
||||
historyEntryBytes,
|
||||
newestWholeSequenceGroups,
|
||||
oversizedHistoryItem
|
||||
} from './agent-session-history-page-bounds'
|
||||
|
||||
/** The pre-generator eager grouping, verbatim, as the differential oracle. */
|
||||
function referenceGroups(items: readonly AgentJournalRenderItem[]): AgentJournalRenderItem[][] {
|
||||
const groups: AgentJournalRenderItem[][] = []
|
||||
for (const item of items) {
|
||||
const current = groups.at(-1)
|
||||
if (current?.[0]?.sequence === item.sequence) {
|
||||
current.push(item)
|
||||
} else {
|
||||
groups.push([item])
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
function referenceNewestWholeSequenceGroups(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
limit: number
|
||||
): AgentJournalRenderItem[] {
|
||||
const selected: AgentJournalRenderItem[][] = []
|
||||
let count = 0
|
||||
for (const group of referenceGroups(items).toReversed()) {
|
||||
if (selected.length > 0 && count + group.length > limit) {
|
||||
break
|
||||
}
|
||||
selected.push(group)
|
||||
count += group.length
|
||||
}
|
||||
return selected.toReversed().flat()
|
||||
}
|
||||
|
||||
function referenceBoundHistoryItemsByBytes(
|
||||
items: AgentJournalRenderItem[],
|
||||
keep: 'newest' | 'oldest',
|
||||
submissionBytes: ReadonlyMap<string, number>,
|
||||
maxBytes: number
|
||||
): { items: AgentJournalRenderItem[]; dropped: number } {
|
||||
const groups = referenceGroups(items)
|
||||
const ordered = keep === 'newest' ? groups.toReversed() : groups
|
||||
const kept: AgentJournalRenderItem[][] = []
|
||||
let total = 0
|
||||
for (const group of ordered) {
|
||||
const bytes = group.reduce((sum, item) => sum + historyEntryBytes(item, submissionBytes), 0)
|
||||
if (kept.length === 0 && bytes > maxBytes) {
|
||||
kept.push(group.map((item) => oversizedHistoryItem(item, bytes)))
|
||||
break
|
||||
}
|
||||
if (total + bytes > maxBytes) {
|
||||
break
|
||||
}
|
||||
kept.push(group)
|
||||
total += bytes
|
||||
}
|
||||
return {
|
||||
items: (keep === 'newest' ? kept.toReversed() : kept).flat(),
|
||||
dropped: items.length - kept.reduce((count, group) => count + group.length, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function item(index: number, sequence: number): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId: `i${index}`,
|
||||
revision: 1,
|
||||
sequence,
|
||||
observedAt: index,
|
||||
body: { kind: 'status', text: `s${index}` }
|
||||
}
|
||||
}
|
||||
|
||||
/** Every sequence-run shape of `length` items, as run-length compositions. */
|
||||
function* runShapes(length: number): Generator<number[]> {
|
||||
if (length === 0) {
|
||||
yield []
|
||||
return
|
||||
}
|
||||
for (let first = 1; first <= length; first += 1) {
|
||||
for (const rest of runShapes(length - first)) {
|
||||
yield [first, ...rest]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Build items from run lengths; `repeatSequence` reuses an earlier sequence value
|
||||
* in a later run so non-adjacent duplicates are exercised too. */
|
||||
function buildItems(runs: number[], repeatSequence: boolean): AgentJournalRenderItem[] {
|
||||
const items: AgentJournalRenderItem[] = []
|
||||
let index = 0
|
||||
runs.forEach((runLength, runIndex) => {
|
||||
const sequence = repeatSequence && runIndex > 0 && runIndex % 2 === 0 ? 0 : runIndex
|
||||
for (let i = 0; i < runLength; i += 1) {
|
||||
items.push(item(index++, sequence))
|
||||
}
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
it('matches eager grouping at every newest-window limit for every run shape', () => {
|
||||
let cases = 0
|
||||
for (let length = 0; length <= 7; length += 1) {
|
||||
for (const runs of runShapes(length)) {
|
||||
for (const repeatSequence of [false, true]) {
|
||||
const items = buildItems(runs, repeatSequence)
|
||||
// Every boundary, including 0, each exact group edge, and past the end.
|
||||
for (let limit = 0; limit <= length + 1; limit += 1) {
|
||||
expect(
|
||||
newestWholeSequenceGroups(items, limit),
|
||||
`runs ${runs.join(',')} repeat ${repeatSequence} limit ${limit}`
|
||||
).toEqual(referenceNewestWholeSequenceGroups(items, limit))
|
||||
cases += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(cases).toBeGreaterThan(1000)
|
||||
})
|
||||
|
||||
it('matches eager byte bounding at every budget boundary in both directions', () => {
|
||||
const submissionBytes = new Map<string, number>()
|
||||
let truncatedCases = 0
|
||||
let partialCases = 0
|
||||
for (let length = 1; length <= 6; length += 1) {
|
||||
for (const runs of runShapes(length)) {
|
||||
for (const repeatSequence of [false, true]) {
|
||||
const items = buildItems(runs, repeatSequence)
|
||||
const perItem = historyEntryBytes(items[0]!, submissionBytes)
|
||||
// Sweep exact group-boundary budgets plus one byte either side of each.
|
||||
const budgets = new Set<number>([0, 1])
|
||||
for (let n = 0; n <= length + 1; n += 1) {
|
||||
budgets.add(n * perItem - 1)
|
||||
budgets.add(n * perItem)
|
||||
budgets.add(n * perItem + 1)
|
||||
}
|
||||
for (const keep of ['newest', 'oldest'] as const) {
|
||||
for (const maxBytes of budgets) {
|
||||
const actual = boundHistoryItemsByBytes([...items], keep, submissionBytes, maxBytes)
|
||||
const expected = referenceBoundHistoryItemsByBytes(
|
||||
[...items],
|
||||
keep,
|
||||
submissionBytes,
|
||||
maxBytes
|
||||
)
|
||||
expect(
|
||||
actual,
|
||||
`runs ${runs.join(',')} repeat ${repeatSequence} keep ${keep} bytes ${maxBytes}`
|
||||
).toEqual(expected)
|
||||
if (
|
||||
actual.items.some(
|
||||
(entry) => entry.body.kind === 'status' && /truncated/.test(entry.body.text)
|
||||
)
|
||||
) {
|
||||
truncatedCases += 1
|
||||
} else if (actual.dropped > 0) {
|
||||
partialCases += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The oversized-first-group and partial-window paths must both be exercised.
|
||||
expect(truncatedCases).toBeGreaterThan(50)
|
||||
expect(partialCases).toBeGreaterThan(50)
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
boundHistoryItemsByBytes,
|
||||
newestWholeSequenceGroups
|
||||
} from './agent-session-history-page-bounds'
|
||||
|
||||
function item(index: number): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId: String(index),
|
||||
revision: 1,
|
||||
sequence: index,
|
||||
observedAt: index,
|
||||
body: { kind: 'status', text: 'ok' }
|
||||
}
|
||||
}
|
||||
|
||||
it('visits only the retained sequence window and its boundary', () => {
|
||||
let reads = 0
|
||||
const items = Array.from({ length: 10000 }, (_, index) => ({
|
||||
...item(index),
|
||||
get sequence() {
|
||||
reads += 1
|
||||
return index
|
||||
}
|
||||
}))
|
||||
expect(newestWholeSequenceGroups(items, 100).map((entry) => entry.itemId)).toEqual(
|
||||
Array.from({ length: 100 }, (_, index) => String(9900 + index))
|
||||
)
|
||||
expect(reads).toBeLessThan(300)
|
||||
reads = 0
|
||||
expect(boundHistoryItemsByBytes(items, 'newest', new Map(), 1000).items.length).toBeGreaterThan(0)
|
||||
expect(reads).toBeLessThan(100)
|
||||
})
|
||||
|
||||
it('retains entire boundary groups and preserves oversized first-group truncation', () => {
|
||||
const items = [item(1), { ...item(2), sequence: 1 }, item(3), { ...item(4), sequence: 3 }]
|
||||
expect(newestWholeSequenceGroups(items, 1)).toEqual(items.slice(2))
|
||||
expect(newestWholeSequenceGroups(items, 3)).toEqual(items.slice(2))
|
||||
expect(boundHistoryItemsByBytes(items, 'oldest', new Map(), 1)).toMatchObject({
|
||||
dropped: 2,
|
||||
items: [{ itemId: '1' }, { itemId: '2' }]
|
||||
})
|
||||
expect(boundHistoryItemsByBytes(items, 'newest', new Map(), 1)).toMatchObject({
|
||||
dropped: 2,
|
||||
items: [{ itemId: '3' }, { itemId: '4' }]
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createLocalFileSink, DROPPED_RECORD_TYPE, type LocalFileSink } from './local-file-sink'
|
||||
|
||||
function parseLine(raw: string): Record<string, unknown> {
|
||||
return JSON.parse(raw) as Record<string, unknown>
|
||||
}
|
||||
|
||||
let directory: string
|
||||
let sink: LocalFileSink | undefined
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(join(tmpdir(), 'orca-trace-memory-'))
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
sink?.close()
|
||||
sink = undefined
|
||||
vi.useRealTimers()
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function retainedHeap(): number {
|
||||
if (!globalThis.gc) {
|
||||
throw new Error('Memory regression requires --expose-gc')
|
||||
}
|
||||
globalThis.gc()
|
||||
globalThis.gc()
|
||||
return process.memoryUsage().heapUsed
|
||||
}
|
||||
|
||||
describe('trace sink rejected record retention', () => {
|
||||
it('keeps small-record byte scans deferred until the batch flush', () => {
|
||||
const filePath = join(directory, 'trace.ndjson')
|
||||
sink = createLocalFileSink({ filePath })
|
||||
const byteLength = vi.spyOn(Buffer, 'byteLength')
|
||||
let beforeFlush: number
|
||||
let afterFlush: number
|
||||
try {
|
||||
for (let index = 0; index < 20; index++) {
|
||||
sink.push({ index, text: '💡漢字' })
|
||||
}
|
||||
beforeFlush = byteLength.mock.calls.length
|
||||
sink.flush()
|
||||
afterFlush = byteLength.mock.calls.length
|
||||
} finally {
|
||||
byteLength.mockRestore()
|
||||
}
|
||||
expect(beforeFlush).toBe(0)
|
||||
expect(afterFlush).toBe(20)
|
||||
})
|
||||
|
||||
it('releases oversized serialized records before the pending batch flushes', () => {
|
||||
const filePath = join(directory, 'trace.ndjson')
|
||||
sink = createLocalFileSink({ filePath, maxBytes: 64 * 1024, batchWindowMs: 200 })
|
||||
const before = retainedHeap()
|
||||
for (let index = 0; index < 24; index++) {
|
||||
sink.push({ index, payload: 'x'.repeat(1024 * 1024) })
|
||||
}
|
||||
const retained = retainedHeap() - before
|
||||
expect(statSync(filePath).size).toBe(0)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
expect(retained).toBeLessThan(5 * 1024 * 1024)
|
||||
sink.push({ valid: true })
|
||||
vi.advanceTimersByTime(200)
|
||||
const written = readFileSync(filePath, 'utf8').split('\n').filter(Boolean).map(parseLine)
|
||||
// Each rejected record leaves a marker, so the gap is readable instead of silent.
|
||||
expect(written.filter((entry) => entry.type === DROPPED_RECORD_TYPE)).toHaveLength(24)
|
||||
expect(written.at(-1)).toEqual({ valid: true })
|
||||
})
|
||||
|
||||
it('names the dropped record in the marker instead of leaving a silent gap', () => {
|
||||
const filePath = join(directory, 'trace.ndjson')
|
||||
sink = createLocalFileSink({ filePath, maxBytes: 64 * 1024, flushBufferThreshold: 1 })
|
||||
sink.push({
|
||||
type: 'effect-span',
|
||||
name: 'worktree.create',
|
||||
traceId: 'a'.repeat(32),
|
||||
payload: 'x'.repeat(1024 * 1024)
|
||||
})
|
||||
const [marker] = readFileSync(filePath, 'utf8').split('\n').filter(Boolean).map(parseLine)
|
||||
expect(marker).toMatchObject({
|
||||
type: DROPPED_RECORD_TYPE,
|
||||
reason: 'oversize',
|
||||
name: 'worktree.create',
|
||||
traceId: 'a'.repeat(32)
|
||||
})
|
||||
expect(marker.droppedChars).toBeGreaterThan(1024 * 1024)
|
||||
// Timestamped so the bundle collector's lookback filter ages markers out like any other span.
|
||||
expect(BigInt(marker.endTimeUnixNano as string)).toBeGreaterThan(0n)
|
||||
})
|
||||
|
||||
it('omits the marker when even the marker would exceed the byte cap', () => {
|
||||
const filePath = join(directory, 'trace.ndjson')
|
||||
sink = createLocalFileSink({ filePath, maxBytes: 40, flushBufferThreshold: 1 })
|
||||
sink.push({ payload: 'x'.repeat(1_000) })
|
||||
sink.push({ ok: 1 })
|
||||
expect(readFileSync(filePath, 'utf8')).toBe('{"ok":1}\n')
|
||||
})
|
||||
|
||||
it('keeps the same count-triggered flush for valid records beside rejected ones', () => {
|
||||
const filePath = join(directory, 'trace.ndjson')
|
||||
sink = createLocalFileSink({ filePath, maxBytes: 32, flushBufferThreshold: 3 })
|
||||
sink.push({ valid: 1 })
|
||||
sink.push({ payload: '💡'.repeat(20) })
|
||||
expect(statSync(filePath).size).toBe(0)
|
||||
sink.push({ payload: 'x'.repeat(100) })
|
||||
expect(readFileSync(filePath, 'utf8')).toBe('{"valid":1}\n')
|
||||
sink.push({ valid: 2 })
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(readFileSync(filePath, 'utf8')).toBe('{"valid":1}\n{"valid":2}\n')
|
||||
})
|
||||
|
||||
it('accepts the exact UTF-8 byte cap and preserves rotation and close flushes', () => {
|
||||
const filePath = join(directory, 'trace.ndjson')
|
||||
const record = { text: '💡' }
|
||||
const line = `${JSON.stringify(record)}\n`
|
||||
sink = createLocalFileSink({ filePath, maxBytes: Buffer.byteLength(line), maxFiles: 2 })
|
||||
sink.push(record)
|
||||
sink.push({ text: '💡x' })
|
||||
sink.push(record)
|
||||
sink.close()
|
||||
expect(readFileSync(filePath, 'utf8')).toBe(line)
|
||||
expect(readFileSync(`${filePath}.1`, 'utf8')).toBe(line)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,9 @@ export const DEFAULT_MAX_FILES = 10
|
||||
export const DEFAULT_BATCH_WINDOW_MS = 200
|
||||
const PRIVATE_DIRECTORY_MODE = 0o700
|
||||
const PRIVATE_FILE_MODE = 0o600
|
||||
/** NDJSON `type` for the placeholder left behind when a record is too large to store. */
|
||||
export const DROPPED_RECORD_TYPE = 'trace-record-dropped'
|
||||
const MAX_MARKER_NAME_CHARS = 120
|
||||
|
||||
export type LocalFileSinkOptions = {
|
||||
readonly filePath: string
|
||||
@@ -77,7 +80,7 @@ export function createLocalFileSink(opts: LocalFileSinkOptions): LocalFileSink {
|
||||
let fd: number = openAppend(filePath)
|
||||
let currentBytes: number = safeFstatSize(fd)
|
||||
|
||||
let buffer: string[] = []
|
||||
let buffer: (string | null)[] = []
|
||||
let timer: NodeJS.Timeout | null = null
|
||||
let closed = false
|
||||
|
||||
@@ -171,11 +174,10 @@ export function createLocalFileSink(opts: LocalFileSinkOptions): LocalFileSink {
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const lineBytes = Buffer.byteLength(line, 'utf8')
|
||||
if (lineBytes > maxBytes) {
|
||||
// Oversized single span would blow the maxFiles × maxBytes envelope; drop just this record.
|
||||
if (line === null) {
|
||||
continue
|
||||
}
|
||||
const lineBytes = Buffer.byteLength(line, 'utf8')
|
||||
if (pendingChunkBytes > 0 && currentBytes + pendingChunkBytes + lineBytes > maxBytes) {
|
||||
flushPendingChunk()
|
||||
}
|
||||
@@ -189,6 +191,27 @@ export function createLocalFileSink(opts: LocalFileSinkOptions): LocalFileSink {
|
||||
flushPendingChunk()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand-in for a record too large to store. `droppedChars` is UTF-16 units, not bytes: measuring
|
||||
* bytes is the scan this path exists to skip. Returns null when even the marker exceeds maxBytes.
|
||||
*/
|
||||
function oversizeMarker(record: unknown, droppedChars: number): string | null {
|
||||
const span =
|
||||
typeof record === 'object' && record !== null ? (record as Record<string, unknown>) : {}
|
||||
const name = typeof span.name === 'string' ? span.name.slice(0, MAX_MARKER_NAME_CHARS) : null
|
||||
const traceId = typeof span.traceId === 'string' ? span.traceId.slice(0, 32) : null
|
||||
const marker = `${JSON.stringify({
|
||||
type: DROPPED_RECORD_TYPE,
|
||||
reason: 'oversize',
|
||||
droppedChars,
|
||||
// Lets the bundle collector's lookback filter age these out like any other span.
|
||||
endTimeUnixNano: `${Date.now()}000000`,
|
||||
...(name === null ? {} : { name }),
|
||||
...(traceId === null ? {} : { traceId })
|
||||
})}\n`
|
||||
return Buffer.byteLength(marker, 'utf8') > maxBytes ? null : marker
|
||||
}
|
||||
|
||||
function ensureTimer(): void {
|
||||
if (timer || closed) {
|
||||
return
|
||||
@@ -216,7 +239,13 @@ export function createLocalFileSink(opts: LocalFileSinkOptions): LocalFileSink {
|
||||
// Redactor handles cycles upstream; a throw here means pre-redact data slipped in — drop rather than crash (best-effort).
|
||||
return
|
||||
}
|
||||
buffer.push(line)
|
||||
// UTF-8 uses at most three bytes per UTF-16 unit; small records need no admission scan.
|
||||
const oversized =
|
||||
line.length > maxBytes ||
|
||||
(line.length * 3 > maxBytes && Buffer.byteLength(line, 'utf8') > maxBytes)
|
||||
// Rejected records still occupy a buffer slot (preserving flush timing) but carry a marker
|
||||
// instead of their payload, so the gap they leave is readable rather than silent.
|
||||
buffer.push(oversized ? oversizeMarker(record, line.length) : line)
|
||||
if (buffer.length >= flushThreshold) {
|
||||
flushBuffer()
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import { updateSettings, type SettingsMutationOperations } from './settings-update'
|
||||
|
||||
function makeOperations(): SettingsMutationOperations {
|
||||
return {
|
||||
// Only the fields updateSettings reads; the rest of GlobalSettings is irrelevant to the clamp.
|
||||
state: { settings: { terminalFontSize: 14 }, repos: [] } as unknown as PersistedState,
|
||||
bumpLocalWorktreeScanGeneration: vi.fn(),
|
||||
removeRetainedBlob: vi.fn(),
|
||||
scheduleSave: vi.fn(),
|
||||
notifySettingsChanged: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
// #10754: desktop IPC, the web RPC and the CLI all reach the store through this boundary, and xterm
|
||||
// throws on a non-finite minimumContrastRatio, so the clamp cannot live in the settings UI alone.
|
||||
describe('updateSettings terminalMinimumContrastRatio', () => {
|
||||
it('persists an in-range floor unchanged', () => {
|
||||
const operations = makeOperations()
|
||||
|
||||
expect(
|
||||
updateSettings(operations, { terminalMinimumContrastRatio: 1 }).terminalMinimumContrastRatio
|
||||
).toBe(1)
|
||||
expect(
|
||||
updateSettings(operations, { terminalMinimumContrastRatio: 4.5 }).terminalMinimumContrastRatio
|
||||
).toBe(4.5)
|
||||
})
|
||||
|
||||
it('clamps a hand-edited value into xterm range', () => {
|
||||
const operations = makeOperations()
|
||||
|
||||
expect(
|
||||
updateSettings(operations, { terminalMinimumContrastRatio: 0 }).terminalMinimumContrastRatio
|
||||
).toBe(1)
|
||||
expect(
|
||||
updateSettings(operations, { terminalMinimumContrastRatio: 500 }).terminalMinimumContrastRatio
|
||||
).toBe(21)
|
||||
})
|
||||
|
||||
it('drops an unusable value back to automatic rather than storing it', () => {
|
||||
const operations = makeOperations()
|
||||
|
||||
expect(
|
||||
updateSettings(operations, {
|
||||
terminalMinimumContrastRatio: Number.NaN
|
||||
}).terminalMinimumContrastRatio
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
updateSettings(operations, {
|
||||
terminalMinimumContrastRatio: 'off' as unknown as number
|
||||
}).terminalMinimumContrastRatio
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears the override so the automatic floor comes back', () => {
|
||||
const operations = makeOperations()
|
||||
|
||||
updateSettings(operations, { terminalMinimumContrastRatio: 1 })
|
||||
expect(
|
||||
updateSettings(operations, { terminalMinimumContrastRatio: undefined })
|
||||
.terminalMinimumContrastRatio
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves a stored floor alone when an unrelated setting is written', () => {
|
||||
const operations = makeOperations()
|
||||
|
||||
updateSettings(operations, { terminalMinimumContrastRatio: 1 })
|
||||
expect(updateSettings(operations, { terminalFontSize: 15 }).terminalMinimumContrastRatio).toBe(
|
||||
1
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-c
|
||||
import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes'
|
||||
import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings'
|
||||
import { normalizeDesktopTerminalScrollbackRows } from '../../../shared/terminal-scrollback-policy'
|
||||
import { normalizeTerminalMinimumContrastRatio } from '../../../shared/terminal-minimum-contrast-settings'
|
||||
import { normalizeTaskProviderSettings } from '../../../shared/task-providers'
|
||||
import { normalizeOpenInApplications } from '../../../shared/open-in-applications'
|
||||
import { normalizeTerminalShortcutPolicy } from '../../../shared/keybindings'
|
||||
@@ -123,6 +124,13 @@ export function updateSettings(
|
||||
updates.terminalScrollbackRows
|
||||
)
|
||||
}
|
||||
// Why here: every writer (desktop IPC, web RPC, CLI) crosses this boundary, so xterm can never be
|
||||
// handed an out-of-range floor, and undefined stays undefined to mean "automatic" (#10754).
|
||||
if ('terminalMinimumContrastRatio' in updates) {
|
||||
sanitizedUpdates.terminalMinimumContrastRatio = normalizeTerminalMinimumContrastRatio(
|
||||
updates.terminalMinimumContrastRatio
|
||||
)
|
||||
}
|
||||
if (
|
||||
'terminalTuiScrollSensitivity' in updates ||
|
||||
'terminalTuiScrollSensitivityDefaultedToOne' in updates
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants'
|
||||
import type { SshRemotePtyLease } from '../../../shared/ssh-types'
|
||||
import { toComparableRelaySshPtyId } from '../../../shared/ssh-pty-id'
|
||||
import { clearSshRemotePtyBindingsForLeases } from './ssh-pty-binding-cleanup'
|
||||
|
||||
function fixture(count: number) {
|
||||
const state = getDefaultPersistedState('/home/test')
|
||||
state.workspaceSession = getDefaultWorkspaceSession()
|
||||
state.workspaceSession.tabsByWorktree.wt = Array.from({ length: count }, (_, i) => ({
|
||||
id: `tab-${i}`,
|
||||
worktreeId: 'wt',
|
||||
ptyId: `pty-${i}`,
|
||||
title: '',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: i,
|
||||
createdAt: 1
|
||||
}))
|
||||
const leases: SshRemotePtyLease[] = Array.from({ length: count }, (_, i) => ({
|
||||
targetId: 'ssh-one',
|
||||
ptyId: `pty-${i}`,
|
||||
tabId: `tab-${i}`,
|
||||
worktreeId: 'wt',
|
||||
state: 'detached',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}))
|
||||
return {
|
||||
state,
|
||||
leases,
|
||||
toComparablePtyId: vi.fn((_target: string, ptyId: string) => ptyId),
|
||||
scheduleSave: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('SSH binding cleanup indexing', () => {
|
||||
it('normalizes each binding once across a large lease inventory', () => {
|
||||
const operations = fixture(1000)
|
||||
expect(clearSshRemotePtyBindingsForLeases(operations, 'ssh-one', operations.leases)).toBe(true)
|
||||
expect(operations.toComparablePtyId).toHaveBeenCalledTimes(1000)
|
||||
expect(
|
||||
operations.state.workspaceSession!.tabsByWorktree.wt.every((tab) => tab.ptyId === null)
|
||||
).toBe(true)
|
||||
expect(operations.scheduleSave).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retains foreign hosts and conflicting tab/workspace leases', () => {
|
||||
const operations = fixture(4)
|
||||
operations.leases[0].targetId = 'ssh-two'
|
||||
operations.leases[1].tabId = 'other-tab'
|
||||
operations.leases[2].worktreeId = 'other-workspace'
|
||||
delete operations.leases[3].tabId
|
||||
clearSshRemotePtyBindingsForLeases(operations, 'ssh-one', operations.leases)
|
||||
expect(operations.state.workspaceSession!.tabsByWorktree.wt.map((tab) => tab.ptyId)).toEqual([
|
||||
'pty-0',
|
||||
'pty-1',
|
||||
'pty-2',
|
||||
null
|
||||
])
|
||||
})
|
||||
|
||||
it('matches layout leaves against every lease for a PTY while preserving leaf conflicts', () => {
|
||||
const operations = fixture(1)
|
||||
const session = operations.state.workspaceSession!
|
||||
session.tabsByWorktree.wt[0].ptyId = null
|
||||
session.terminalLayoutsByTabId['tab-0'] = {
|
||||
root: null,
|
||||
activeLeafId: null,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: {
|
||||
matched: 'pty-0',
|
||||
protected: 'pty-0',
|
||||
wildcard: 'pty-1'
|
||||
}
|
||||
}
|
||||
const lease = operations.leases[0]
|
||||
operations.leases = [
|
||||
{ ...lease, leafId: 'wrong' },
|
||||
{ ...lease, leafId: 'matched' },
|
||||
{ ...lease, ptyId: 'pty-1' }
|
||||
]
|
||||
expect(clearSshRemotePtyBindingsForLeases(operations, 'ssh-one', operations.leases)).toBe(true)
|
||||
expect(session.terminalLayoutsByTabId['tab-0'].ptyIdsByLeafId).toEqual({
|
||||
protected: 'pty-0'
|
||||
})
|
||||
expect(operations.toComparablePtyId).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('normalizes app-form binding ids onto the relay-form lease key', () => {
|
||||
// Leases store the relay-local id; sessions may hold the app-wide "ssh:<target>@@<id>" form.
|
||||
// The index key is the normalized form, so both spellings still name the same PTY.
|
||||
const operations = fixture(1)
|
||||
operations.toComparablePtyId = vi.fn(toComparableRelaySshPtyId)
|
||||
operations.state.workspaceSession!.tabsByWorktree.wt[0].ptyId = 'ssh:ssh-one@@pty-0'
|
||||
|
||||
expect(clearSshRemotePtyBindingsForLeases(operations, 'ssh-one', operations.leases)).toBe(true)
|
||||
expect(operations.state.workspaceSession!.tabsByWorktree.wt[0].ptyId).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a binding whose app-form id names a different SSH target', () => {
|
||||
// Relay-local ids collide across targets ("pty-0" exists on every host). Clearing ssh-one must
|
||||
// never scrub a pane still bound to a live ssh-two shell.
|
||||
const operations = fixture(1)
|
||||
operations.toComparablePtyId = vi.fn(toComparableRelaySshPtyId)
|
||||
operations.state.workspaceSession!.tabsByWorktree.wt[0].ptyId = 'ssh:ssh-two@@pty-0'
|
||||
|
||||
expect(clearSshRemotePtyBindingsForLeases(operations, 'ssh-one', operations.leases)).toBe(false)
|
||||
expect(operations.state.workspaceSession!.tabsByWorktree.wt[0].ptyId).toBe('ssh:ssh-two@@pty-0')
|
||||
expect(operations.scheduleSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps every binding when no lease names its PTY', () => {
|
||||
// A bucket miss must fail closed: leak a stale id rather than unbind a live pane.
|
||||
const operations = fixture(2)
|
||||
for (const lease of operations.leases) {
|
||||
lease.ptyId = `unrelated-${lease.ptyId}`
|
||||
}
|
||||
|
||||
expect(clearSshRemotePtyBindingsForLeases(operations, 'ssh-one', operations.leases)).toBe(false)
|
||||
expect(operations.state.workspaceSession!.tabsByWorktree.wt.map((tab) => tab.ptyId)).toEqual([
|
||||
'pty-0',
|
||||
'pty-1'
|
||||
])
|
||||
expect(operations.scheduleSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not index leases when the session holds no bindings to check', () => {
|
||||
const operations = fixture(500)
|
||||
operations.state.workspaceSession!.tabsByWorktree = {}
|
||||
operations.state.workspaceSession!.terminalLayoutsByTabId = {}
|
||||
|
||||
expect(clearSshRemotePtyBindingsForLeases(operations, 'ssh-one', operations.leases)).toBe(false)
|
||||
expect(operations.toComparablePtyId).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -9,8 +9,8 @@ export type SshPtyBindingCleanupOperations = {
|
||||
scheduleSave: () => void
|
||||
}
|
||||
|
||||
/** `binding.ptyId` must already be in lease-comparable (relay) form; callers normalize it. */
|
||||
function sshRemotePtyLeaseMayReferenceBinding(
|
||||
operations: SshPtyBindingCleanupOperations,
|
||||
lease: SshRemotePtyLease,
|
||||
binding: {
|
||||
ptyId: string
|
||||
@@ -20,8 +20,7 @@ function sshRemotePtyLeaseMayReferenceBinding(
|
||||
leafId?: string
|
||||
}
|
||||
): boolean {
|
||||
const bindingPtyId = operations.toComparablePtyId(binding.targetId, binding.ptyId)
|
||||
if (lease.targetId !== binding.targetId || lease.ptyId !== bindingPtyId) {
|
||||
if (lease.targetId !== binding.targetId || lease.ptyId !== binding.ptyId) {
|
||||
return false
|
||||
}
|
||||
// Why: target removal is destructive; scrub matching bindings before deleting the lease, else removing the tombstone can revive stale PTY ids.
|
||||
@@ -50,6 +49,33 @@ export function clearSshRemotePtyBindingsForLeases(
|
||||
if (!leases?.length) {
|
||||
return false
|
||||
}
|
||||
// Keyed by the stored (relay) pty id, which is the only form a lease holds; every lookup below
|
||||
// normalizes the binding id to that form first, so a bucket miss means "no lease names this pty"
|
||||
// and the binding is KEPT. Failing closed here leaves a stale id to be retired on reattach,
|
||||
// where clearing on a bad match would strand a live remote shell behind a respawned pane.
|
||||
let leasesByPtyId: Map<string, SshRemotePtyLease[]> | undefined
|
||||
const referencesBinding = (
|
||||
binding: Parameters<typeof sshRemotePtyLeaseMayReferenceBinding>[1]
|
||||
): boolean => {
|
||||
if (!leasesByPtyId) {
|
||||
leasesByPtyId = new Map()
|
||||
for (const lease of leases) {
|
||||
if (lease.targetId !== targetId) {
|
||||
continue
|
||||
}
|
||||
const entries = leasesByPtyId.get(lease.ptyId)
|
||||
if (entries) {
|
||||
entries.push(lease)
|
||||
} else {
|
||||
leasesByPtyId.set(lease.ptyId, [lease])
|
||||
}
|
||||
}
|
||||
}
|
||||
const ptyId = operations.toComparablePtyId(binding.targetId, binding.ptyId)
|
||||
return (leasesByPtyId.get(ptyId) ?? []).some((lease) =>
|
||||
sshRemotePtyLeaseMayReferenceBinding(lease, { ...binding, ptyId })
|
||||
)
|
||||
}
|
||||
let changed = false
|
||||
const sessions = new Set(
|
||||
[
|
||||
@@ -62,14 +88,7 @@ export function clearSshRemotePtyBindingsForLeases(
|
||||
for (const tab of tabs) {
|
||||
if (
|
||||
tab.ptyId &&
|
||||
leases.some((lease) =>
|
||||
sshRemotePtyLeaseMayReferenceBinding(operations, lease, {
|
||||
ptyId: tab.ptyId!,
|
||||
worktreeId,
|
||||
targetId,
|
||||
tabId: tab.id
|
||||
})
|
||||
)
|
||||
referencesBinding({ ptyId: tab.ptyId, worktreeId, targetId, tabId: tab.id })
|
||||
) {
|
||||
tab.ptyId = null
|
||||
changed = true
|
||||
@@ -92,16 +111,7 @@ export function clearSshRemotePtyBindingsForLeases(
|
||||
const worktreeId = worktreeIdByTabId.get(tabId)
|
||||
const nextBindings = Object.fromEntries(
|
||||
Object.entries(bindings).filter(
|
||||
([leafId, ptyId]) =>
|
||||
!leases.some((lease) =>
|
||||
sshRemotePtyLeaseMayReferenceBinding(operations, lease, {
|
||||
ptyId,
|
||||
targetId,
|
||||
worktreeId,
|
||||
tabId,
|
||||
leafId
|
||||
})
|
||||
)
|
||||
([leafId, ptyId]) => !referencesBinding({ ptyId, targetId, worktreeId, tabId, leafId })
|
||||
)
|
||||
)
|
||||
if (Object.keys(nextBindings).length !== Object.keys(bindings).length) {
|
||||
|
||||
@@ -168,4 +168,45 @@ describe('workspace session terminal binding replay', () => {
|
||||
[LEAF_TWO]: 'pty-b'
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when one tab id is duplicated inside a single worktree list', () => {
|
||||
// Map indexing is last-wins where a linear find was first-wins; the ambiguity
|
||||
// fence must skip these ids so the two strategies can never disagree.
|
||||
const prior = session(null)
|
||||
prior.tabsByWorktree = {
|
||||
[WORKTREE_A]: [
|
||||
terminalTab(WORKTREE_A, 'duplicate-tab', 'pty-first'),
|
||||
terminalTab(WORKTREE_A, 'duplicate-tab', 'pty-last')
|
||||
]
|
||||
}
|
||||
const incoming = session(null)
|
||||
incoming.tabsByWorktree = {
|
||||
[WORKTREE_A]: [terminalTab(WORKTREE_A, 'duplicate-tab', null)]
|
||||
}
|
||||
|
||||
preserveMissingWorkspaceSessionTerminalBindings(incoming, prior, bindingRecovery as never)
|
||||
|
||||
expect(incoming.tabsByWorktree[WORKTREE_A]![0]!.ptyId).toBeNull()
|
||||
})
|
||||
|
||||
it('indexes prior tabs once when replaying a large workspace snapshot', () => {
|
||||
let reads = 0
|
||||
const prior = session(null)
|
||||
prior.tabsByWorktree.worktree = Array.from({ length: 1000 }, (_, i) => ({
|
||||
...terminalTab('worktree', `tab-${i}`, `pty-${i}`),
|
||||
get id() {
|
||||
reads++
|
||||
return `tab-${i}`
|
||||
}
|
||||
}))
|
||||
const incoming = session(null)
|
||||
incoming.tabsByWorktree.worktree = Array.from({ length: 1000 }, (_, i) =>
|
||||
terminalTab('worktree', `tab-${i}`, null)
|
||||
)
|
||||
preserveMissingWorkspaceSessionTerminalBindings(incoming, prior, bindingRecovery as never)
|
||||
expect(reads).toBeLessThan(10_000)
|
||||
expect(incoming.tabsByWorktree.worktree.map((tab) => tab.ptyId)).toEqual(
|
||||
Array.from({ length: 1000 }, (_, i) => `pty-${i}`)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,6 +93,7 @@ export function preserveMissingWorkspaceSessionTerminalBindings(
|
||||
if (!priorList) {
|
||||
continue
|
||||
}
|
||||
let priorById: Map<string, (typeof priorList)[number]> | undefined
|
||||
for (const tab of tabs) {
|
||||
if (ambiguousTabIds.has(tab.id)) {
|
||||
continue
|
||||
@@ -100,7 +101,8 @@ export function preserveMissingWorkspaceSessionTerminalBindings(
|
||||
if (tab.ptyId) {
|
||||
continue
|
||||
}
|
||||
const priorTab = priorList.find((candidate) => candidate.id === tab.id)
|
||||
priorById ??= new Map(priorList.map((candidate) => [candidate.id, candidate]))
|
||||
const priorTab = priorById.get(tab.id)
|
||||
const incomingLayout = nextLayouts[tab.id]
|
||||
const priorLayout = priorLayouts[tab.id]
|
||||
const priorPtyLeafId = priorLayout
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import type { MigrationUnsupportedPtyEntry } from '../../../shared/agent-status-types'
|
||||
import { legacyMigrationUnsupportedRowsToAliasEntries } from './pane-alias-normalization'
|
||||
|
||||
vi.mock('../../agent-hooks/server', () => ({ agentHookServer: {} }))
|
||||
|
||||
it('retains only the ambiguity verdict when many legacy rows name the same tab', () => {
|
||||
const row: MigrationUnsupportedPtyEntry = {
|
||||
ptyId: 'pty',
|
||||
tabId: 'tab',
|
||||
paneKey: 'tab:11111111-1111-4111-8111-111111111111',
|
||||
reason: 'legacy-numeric-pane-key',
|
||||
source: 'local',
|
||||
updatedAt: 1
|
||||
}
|
||||
const rows = Array.from({ length: 2000 }, () => row)
|
||||
const iterator = Array.prototype[Symbol.iterator]
|
||||
let copied = 0
|
||||
Array.prototype[Symbol.iterator] = function (this: unknown[]) {
|
||||
if (this[0] === row) {
|
||||
copied += this.length
|
||||
}
|
||||
return iterator.call(this)
|
||||
}
|
||||
let aliases: ReturnType<typeof legacyMigrationUnsupportedRowsToAliasEntries>
|
||||
try {
|
||||
aliases = legacyMigrationUnsupportedRowsToAliasEntries(rows)
|
||||
} finally {
|
||||
Array.prototype[Symbol.iterator] = iterator
|
||||
}
|
||||
expect(copied).toBeLessThan(10_000)
|
||||
expect(aliases).toEqual([])
|
||||
const unique = legacyMigrationUnsupportedRowsToAliasEntries([row])
|
||||
expect(unique.map((entry) => entry.legacyPaneKey)).toEqual(['tab:0', 'tab:1'])
|
||||
expect(unique.every((entry) => entry.stablePaneKey === row.paneKey)).toBe(true)
|
||||
})
|
||||
|
||||
function legacyRow(overrides: Partial<MigrationUnsupportedPtyEntry>): MigrationUnsupportedPtyEntry {
|
||||
return {
|
||||
ptyId: 'pty',
|
||||
tabId: 'tab',
|
||||
paneKey: 'tab:11111111-1111-4111-8111-111111111111',
|
||||
reason: 'legacy-numeric-pane-key',
|
||||
source: 'local',
|
||||
updatedAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
// Ambiguity must fail closed: only an exactly-one row per tab may mint an alias.
|
||||
it.each([
|
||||
['0 rows', 0],
|
||||
['2 rows', 2],
|
||||
['3 rows', 3],
|
||||
['4 rows', 4]
|
||||
])('mints no alias for a tab named by %s', (_label, count) => {
|
||||
const rows = Array.from({ length: count }, (_, i) =>
|
||||
legacyRow({
|
||||
ptyId: `pty-${i}`,
|
||||
paneKey: `tab:1111111${i}-1111-4111-8111-111111111111`
|
||||
})
|
||||
)
|
||||
expect(legacyMigrationUnsupportedRowsToAliasEntries(rows)).toEqual([])
|
||||
})
|
||||
|
||||
it('mints both numeric aliases for a tab named by exactly 1 row', () => {
|
||||
const row = legacyRow({ ptyId: 'pty-solo' })
|
||||
expect(legacyMigrationUnsupportedRowsToAliasEntries([row])).toEqual([
|
||||
{
|
||||
ptyId: 'pty-solo',
|
||||
legacyPaneKey: 'tab:0',
|
||||
stablePaneKey: row.paneKey,
|
||||
updatedAt: 1
|
||||
},
|
||||
{
|
||||
ptyId: 'pty-solo',
|
||||
legacyPaneKey: 'tab:1',
|
||||
stablePaneKey: row.paneKey,
|
||||
updatedAt: 1
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps unambiguous tabs in first-seen order while dropping ambiguous neighbours', () => {
|
||||
const solo = legacyRow({
|
||||
tabId: 'solo',
|
||||
ptyId: 'pty-solo',
|
||||
paneKey: 'solo:11111111-1111-4111-8111-111111111111'
|
||||
})
|
||||
const dupA = legacyRow({
|
||||
tabId: 'dup',
|
||||
ptyId: 'pty-a',
|
||||
paneKey: 'dup:22222222-2222-4222-8222-222222222222'
|
||||
})
|
||||
const dupB = legacyRow({
|
||||
tabId: 'dup',
|
||||
ptyId: 'pty-b',
|
||||
paneKey: 'dup:33333333-3333-4333-8333-333333333333'
|
||||
})
|
||||
const late = legacyRow({
|
||||
tabId: 'late',
|
||||
ptyId: 'pty-late',
|
||||
paneKey: 'late:44444444-4444-4444-8444-444444444444'
|
||||
})
|
||||
// A third row for 'dup' must not resurrect it: ambiguity is sticky, not a parity toggle.
|
||||
const aliases = legacyMigrationUnsupportedRowsToAliasEntries([solo, dupA, dupB, late, dupA])
|
||||
expect(aliases.map((entry) => entry.legacyPaneKey)).toEqual([
|
||||
'solo:0',
|
||||
'solo:1',
|
||||
'late:0',
|
||||
'late:1'
|
||||
])
|
||||
expect(aliases.every((entry) => entry.ptyId !== 'pty-a' && entry.ptyId !== 'pty-b')).toBe(true)
|
||||
})
|
||||
@@ -14,21 +14,17 @@ export function legacyMigrationUnsupportedRowsToAliasEntries(
|
||||
const normalizedEntries = normalizeMigrationUnsupportedPtyEntries(entries).filter(
|
||||
(entry) => entry.tabId && entry.paneKey && parsePaneKey(entry.paneKey)
|
||||
)
|
||||
const entriesByTabId = new Map<string, MigrationUnsupportedPtyEntry[]>()
|
||||
const entriesByTabId = new Map<string, MigrationUnsupportedPtyEntry | null>()
|
||||
for (const entry of normalizedEntries) {
|
||||
const tabId = entry.tabId
|
||||
if (!tabId) {
|
||||
continue
|
||||
}
|
||||
entriesByTabId.set(tabId, [...(entriesByTabId.get(tabId) ?? []), entry])
|
||||
entriesByTabId.set(tabId, entriesByTabId.has(tabId) ? null : entry)
|
||||
}
|
||||
const aliasEntries: LegacyPaneKeyAliasEntry[] = []
|
||||
for (const [tabId, tabEntries] of entriesByTabId) {
|
||||
if (tabEntries.length !== 1) {
|
||||
continue
|
||||
}
|
||||
const [entry] = tabEntries
|
||||
if (!entry.paneKey) {
|
||||
for (const [tabId, entry] of entriesByTabId) {
|
||||
if (!entry?.paneKey) {
|
||||
continue
|
||||
}
|
||||
// Why: pre-stable rows lack the old numeric key; only synthesize single-pane aliases when the row is unambiguous.
|
||||
|
||||
@@ -481,12 +481,14 @@ describe('getPiAgentStatusExtensionSource', () => {
|
||||
await handlerCall
|
||||
})
|
||||
|
||||
it('leaves runtime shutdown to PTY teardown instead of reporting turn completion', () => {
|
||||
it('leaves runtime shutdown to PTY teardown instead of reporting turn completion', async () => {
|
||||
const harness = createHarness({ kind: 'pi' })
|
||||
|
||||
// Why: Pi emits session_shutdown for reload/new/resume/fork while its PTY
|
||||
// stays alive. agent_end is the only extension event that proves done.
|
||||
expect(harness.handlers.session_shutdown).toBeUndefined()
|
||||
// Why: Pi emits session_shutdown for reload/new/resume/fork while its PTY stays
|
||||
// alive. agent_end is the only extension event that proves done, so the handler
|
||||
// exists solely to release a dialog Pi tore down without a close.
|
||||
await harness.callHook('session_shutdown')
|
||||
expect(harness.fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bounds stalled delivery to one active request and the latest pending status', async () => {
|
||||
|
||||
@@ -101,7 +101,7 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin
|
||||
'// Orca receiver from building an unbounded queue of obsolete snapshots.',
|
||||
'const HOOK_POST_TIMEOUT_MS = 1000',
|
||||
'let activePost = false',
|
||||
...(kind === 'pi' ? ['let piUiPromptActive = false'] : []),
|
||||
...(kind === 'pi' ? ['let piUiPromptDepth = 0', 'let piTurnInFlight = false'] : []),
|
||||
'let pendingPost: { hookEventName: string; extra: Record<string, unknown>; metadata: Record<string, unknown>; ompRuntime: boolean } | null = null',
|
||||
...sessionMetadataSourceLines,
|
||||
'',
|
||||
@@ -167,7 +167,7 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin
|
||||
' hookEventName,',
|
||||
// Why: every coalesced snapshot must retain an open modal, not just its start event.
|
||||
kind === 'pi'
|
||||
? ' extra: { ...extra, ...(!ompRuntime && piUiPromptActive ? { ui_prompt_active: true } : {}) },'
|
||||
? ' extra: { ...extra, ...(!ompRuntime && piUiPromptDepth > 0 ? { ui_prompt_active: true } : {}) },'
|
||||
: ' extra,',
|
||||
' metadata: getPostSessionMetadata(ompRuntime),',
|
||||
' ompRuntime,',
|
||||
|
||||
@@ -9,6 +9,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
|
||||
? [
|
||||
" pi.on('session_start', (event, ctx) => {",
|
||||
' updateSessionMetadata(ctx)',
|
||||
...(kind === 'pi' ? [' piUiPromptDepth = 0'] : []),
|
||||
' // Why: /reload re-registers the active session, but it is not a',
|
||||
' // turn boundary and must not clear the visible status or unread state.',
|
||||
" if (event.reason === 'reload') return",
|
||||
@@ -105,6 +106,9 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
|
||||
...captureSessionMetadata,
|
||||
' clearPendingAgentEndCheck()',
|
||||
' agentEndReported = false',
|
||||
// Why: a turn cannot begin under a dialog holding input focus, so this is the one
|
||||
// boundary that can recover a modal whose close never arrived.
|
||||
...(kind === 'pi' ? [' piUiPromptDepth = 0', ' piTurnInFlight = true'] : []),
|
||||
" post('agent_start')",
|
||||
' })',
|
||||
'',
|
||||
@@ -168,6 +172,9 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
|
||||
' function postAgentEndOnce(): void {',
|
||||
' if (agentEndReported) return',
|
||||
' agentEndReported = true',
|
||||
// Why: distinct from agentEndReported, which also dedupes the completion post and so
|
||||
// starts false on a pane that has not run a turn yet — that pane is idle, not busy.
|
||||
...(kind === 'pi' ? [' piTurnInFlight = false'] : []),
|
||||
" post('agent_end')",
|
||||
' }',
|
||||
'',
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
import type { PiAgentKind } from '../../shared/pi-agent-kind'
|
||||
|
||||
export function getPiAgentStatusRuntimeDetectionSourceLines(kind: PiAgentKind): string[] {
|
||||
if (kind === 'prime-agent') {
|
||||
return [
|
||||
`const CONFIGURED_HOOK_PATH = '/hook/${kind}'`,
|
||||
'',
|
||||
'function isOmpRuntime(): boolean {',
|
||||
' return false',
|
||||
'}',
|
||||
'',
|
||||
'function resolveHookPath(_ompRuntime: boolean): string {',
|
||||
' return CONFIGURED_HOOK_PATH',
|
||||
'}'
|
||||
]
|
||||
}
|
||||
|
||||
/** Why: a bare-shell OMP launch runs inside a pi-kind pane, so every extension that has to
|
||||
* defer to OMP's own approval events needs this check — not just the status extension it
|
||||
* was first written for. */
|
||||
export function getPiOmpRuntimeDetectionSourceLines(configuredHookPath: string): string[] {
|
||||
return [
|
||||
'function processName(value: unknown): string {',
|
||||
" return String(value || '').split(/[\\\\/]/).pop()?.toLowerCase() || ''",
|
||||
'}',
|
||||
'',
|
||||
`const CONFIGURED_HOOK_PATH = '/hook/${kind}'`,
|
||||
`const CONFIGURED_HOOK_PATH = '${configuredHookPath}'`,
|
||||
'let cachedOmpRuntime: boolean | null = null',
|
||||
'',
|
||||
'function isOmpRuntime(): boolean {',
|
||||
@@ -39,7 +28,27 @@ export function getPiAgentStatusRuntimeDetectionSourceLines(kind: PiAgentKind):
|
||||
" ['omp', 'omp.js', 'omp.sh', 'omp.cmd', 'omp.exe', 'omp.bat'].includes(name)",
|
||||
' )',
|
||||
' return cachedOmpRuntime',
|
||||
'}',
|
||||
'}'
|
||||
]
|
||||
}
|
||||
|
||||
export function getPiAgentStatusRuntimeDetectionSourceLines(kind: PiAgentKind): string[] {
|
||||
if (kind === 'prime-agent') {
|
||||
return [
|
||||
`const CONFIGURED_HOOK_PATH = '/hook/${kind}'`,
|
||||
'',
|
||||
'function isOmpRuntime(): boolean {',
|
||||
' return false',
|
||||
'}',
|
||||
'',
|
||||
'function resolveHookPath(_ompRuntime: boolean): string {',
|
||||
' return CONFIGURED_HOOK_PATH',
|
||||
'}'
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
...getPiOmpRuntimeDetectionSourceLines(`/hook/${kind}`),
|
||||
'',
|
||||
'function resolveHookPath(ompRuntime: boolean): string {',
|
||||
' // Why: runtime detection keeps a bare-shell OMP launch from reporting as Pi.',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PiAgentKind } from '../../shared/pi-agent-kind'
|
||||
|
||||
/** Pi owns nested prompt depth and emits one pair around select/confirm/input/editor/custom. */
|
||||
/** Mirrors the titlebar extension's dialog tracking so both agree on when the wait ends. */
|
||||
export function getPiAgentStatusUiPromptHandlerSourceLines(kind: PiAgentKind): string[] {
|
||||
if (kind !== 'pi') {
|
||||
return []
|
||||
@@ -9,14 +9,35 @@ export function getPiAgentStatusUiPromptHandlerSourceLines(kind: PiAgentKind): s
|
||||
return [
|
||||
" pi.on('ui_prompt_start', () => {",
|
||||
' if (isOmpRuntime()) return',
|
||||
' piUiPromptActive = true',
|
||||
' piUiPromptDepth++',
|
||||
' if (piUiPromptDepth > 1) return',
|
||||
" post('ui_prompt_start')",
|
||||
' })',
|
||||
'',
|
||||
" pi.on('ui_prompt_end', (_event, ctx) => {",
|
||||
' if (isOmpRuntime() || !piUiPromptActive) return',
|
||||
' piUiPromptActive = false',
|
||||
" post('ui_prompt_end', { is_idle: ctx?.isIdle?.() === true })",
|
||||
' if (isOmpRuntime() || piUiPromptDepth === 0) return',
|
||||
' piUiPromptDepth--',
|
||||
' if (piUiPromptDepth > 0) return',
|
||||
' // Why: ctx.isIdle throws outright once a session-switching modal invalidates the',
|
||||
' // runner (it calls assertActive), so local turn state is the floor, not a fallback:',
|
||||
' // with no turn in flight, no later event is coming to correct a working verdict, so',
|
||||
' // only consult ctx when this process believes work is running.',
|
||||
' let isIdle = !piTurnInFlight',
|
||||
' try {',
|
||||
" if (!isIdle && typeof ctx?.isIdle === 'function') isIdle = ctx.isIdle() === true",
|
||||
' } catch {',
|
||||
' // Why: a runner this very modal invalidated cannot answer; keep the local verdict.',
|
||||
' }',
|
||||
" post('ui_prompt_end', { is_idle: isIdle })",
|
||||
' })',
|
||||
'',
|
||||
" pi.on('session_shutdown', () => {",
|
||||
' if (isOmpRuntime()) return',
|
||||
' // Why: pi tears an open dialog down through resetExtensionUI without resolving its',
|
||||
' // promise, so a replaced session never emits the matching ui_prompt_end and the wait',
|
||||
' // would stick forever. Reset without posting: shutdown is not a turn boundary, and',
|
||||
' // the session_start that follows republishes the corrected state.',
|
||||
' piUiPromptDepth = 0',
|
||||
' })',
|
||||
''
|
||||
]
|
||||
|
||||
@@ -92,11 +92,21 @@ describe('Pi UI prompt status', () => {
|
||||
expect(harness.statuses.map((status) => status?.payload.state)).toEqual(['waiting', 'done'])
|
||||
})
|
||||
|
||||
it('does not infer done when the context cannot establish idleness', async () => {
|
||||
it('returns a pane that never ran a turn to done when idleness is unreadable', async () => {
|
||||
const harness = createHarness()
|
||||
await post(harness, 'ui_prompt_start')
|
||||
await post(harness, 'ui_prompt_end')
|
||||
expect(harness.statuses.at(-1)?.payload.state).toBe('working')
|
||||
// Why: no turn has started, so the pane is idle — reporting working would spin forever.
|
||||
expect(harness.statuses.at(-1)?.payload.state).toBe('done')
|
||||
})
|
||||
|
||||
it('trusts local turn state over a ctx that claims work on an idle pane', async () => {
|
||||
const harness = createHarness()
|
||||
await post(harness, 'ui_prompt_start')
|
||||
await harness.callHook('ui_prompt_end', {}, { isIdle: () => false })
|
||||
await flushPosts()
|
||||
// Why: no turn ever started, so nothing later would correct a working verdict.
|
||||
expect(harness.statuses.at(-1)?.payload.state).toBe('done')
|
||||
})
|
||||
|
||||
it('lets the normal settlement hook finish work after a modal closes', async () => {
|
||||
@@ -118,20 +128,139 @@ describe('Pi UI prompt status', () => {
|
||||
const harness = createHarness()
|
||||
await post(harness, 'ui_prompt_start')
|
||||
harness.reload()
|
||||
await post(harness, 'session_start', { reason: 'reload' })
|
||||
await post(harness, 'tool_execution_end', { toolName: 'bash' })
|
||||
// Why: re-registering handlers is not a session boundary and must not lose the wait.
|
||||
expect(harness.statuses.at(-1)?.payload.state).toBe('waiting')
|
||||
})
|
||||
|
||||
it('keeps a session-switching modal blocked until it actually closes', async () => {
|
||||
it('releases a modal that a session replacement tore down without a close', async () => {
|
||||
const harness = createHarness()
|
||||
await post(harness, 'before_agent_start', { prompt: 'Old session prompt' })
|
||||
await post(harness, 'ui_prompt_start')
|
||||
await post(harness, 'session_start', { reason: 'switch' })
|
||||
expect(harness.statuses.at(-1)?.payload.state).toBe('waiting')
|
||||
expect(harness.statuses.at(-1)?.payload.prompt).toBe('')
|
||||
// Why: pi hides the dialog through resetExtensionUI without resolving its promise,
|
||||
// so no ui_prompt_end is ever emitted — these two boundaries are the only release.
|
||||
await post(harness, 'session_shutdown')
|
||||
await post(harness, 'session_start', { reason: 'switch' })
|
||||
await post(harness, 'tool_execution_end', { toolName: 'bash' })
|
||||
expect(harness.statuses.at(-1)?.payload.state).not.toBe('waiting')
|
||||
})
|
||||
|
||||
it('releases a modal dropped by a reload that emits no shutdown', async () => {
|
||||
const harness = createHarness()
|
||||
await post(harness, 'ui_prompt_start')
|
||||
await post(harness, 'session_start', { reason: 'reload' })
|
||||
await post(harness, 'tool_execution_end', { toolName: 'bash' })
|
||||
expect(harness.statuses.at(-1)?.payload.state).not.toBe('waiting')
|
||||
})
|
||||
|
||||
it('still captures the assistant reply that lands while a modal is open', async () => {
|
||||
const harness = createHarness()
|
||||
await post(harness, 'agent_start')
|
||||
await post(harness, 'message_end', {
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'Before modal' }] }
|
||||
})
|
||||
await post(harness, 'ui_prompt_start')
|
||||
await post(harness, 'message_end', {
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'Final reply' }] }
|
||||
})
|
||||
await harness.callHook('ui_prompt_end', {}, { isIdle: () => true })
|
||||
await flushPosts()
|
||||
expect(harness.statuses.at(-1)?.payload).toMatchObject({
|
||||
state: 'done',
|
||||
lastAssistantMessage: 'Final reply'
|
||||
})
|
||||
expect(harness.statuses.at(-1)?.payload.toolName).toBeUndefined()
|
||||
expect(harness.statuses.at(-1)?.payload.interactivePrompt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('still reports the close when the modal invalidated its own runner', async () => {
|
||||
const harness = createHarness()
|
||||
await post(harness, 'ui_prompt_start')
|
||||
await harness.callHook(
|
||||
'ui_prompt_end',
|
||||
{},
|
||||
{
|
||||
isIdle: () => {
|
||||
throw new Error('extension runner is no longer active')
|
||||
}
|
||||
}
|
||||
)
|
||||
await flushPosts()
|
||||
// Why: a lost close would strand the pane on waiting; no turn is running, so done.
|
||||
expect(harness.statuses.at(-1)?.payload.state).toBe('done')
|
||||
})
|
||||
|
||||
it('keeps a mid-turn modal working when its runner throws on close', async () => {
|
||||
const harness = createHarness()
|
||||
await post(harness, 'agent_start')
|
||||
await post(harness, 'ui_prompt_start')
|
||||
await harness.callHook(
|
||||
'ui_prompt_end',
|
||||
{},
|
||||
{
|
||||
isIdle: () => {
|
||||
throw new Error('extension runner is no longer active')
|
||||
}
|
||||
}
|
||||
)
|
||||
await flushPosts()
|
||||
// Why: the turn is still in flight, so done would ring the completion bell early.
|
||||
expect(harness.statuses.at(-1)?.payload.state).toBe('working')
|
||||
await post(harness, 'agent_settled')
|
||||
expect(harness.statuses.at(-1)?.payload.state).toBe('done')
|
||||
})
|
||||
|
||||
it('recovers on a new turn when a modal close was lost', async () => {
|
||||
const harness = createHarness()
|
||||
await post(harness, 'ui_prompt_start')
|
||||
expect(harness.statuses.at(-1)?.payload.state).toBe('waiting')
|
||||
// Why: a turn cannot begin under a dialog holding input focus, so this is recovery.
|
||||
await post(harness, 'agent_start')
|
||||
await post(harness, 'tool_execution_end', { toolName: 'bash' })
|
||||
expect(harness.statuses.at(-1)?.payload.state).toBe('working')
|
||||
})
|
||||
|
||||
it('keeps the wait until the outermost of nested modals closes', async () => {
|
||||
const harness = createHarness()
|
||||
await post(harness, 'ui_prompt_start')
|
||||
await post(harness, 'ui_prompt_start')
|
||||
await harness.callHook('ui_prompt_end', {}, { isIdle: () => true })
|
||||
await flushPosts()
|
||||
expect(harness.statuses.at(-1)?.payload.state).toBe('waiting')
|
||||
await harness.callHook('ui_prompt_end', {}, { isIdle: () => true })
|
||||
await flushPosts()
|
||||
expect(harness.statuses.at(-1)?.payload.state).toBe('done')
|
||||
})
|
||||
|
||||
it('returns an idle pane to done when its modal lost the runner', async () => {
|
||||
const harness = createHarness()
|
||||
await post(harness, 'agent_start')
|
||||
await post(harness, 'agent_settled')
|
||||
expect(harness.statuses.at(-1)?.payload.state).toBe('done')
|
||||
await post(harness, 'ui_prompt_start')
|
||||
await harness.callHook(
|
||||
'ui_prompt_end',
|
||||
{},
|
||||
{
|
||||
isIdle: () => {
|
||||
throw new Error('extension runner is no longer active')
|
||||
}
|
||||
}
|
||||
)
|
||||
await flushPosts()
|
||||
// Why: the turn already reported its end, so no later event is coming to correct a
|
||||
// guess of working — fall back to what this process knows rather than strand it.
|
||||
expect(harness.statuses.at(-1)?.payload.state).toBe('done')
|
||||
})
|
||||
|
||||
it('keeps a mid-turn modal working when its close cannot read idleness', async () => {
|
||||
const harness = createHarness()
|
||||
await post(harness, 'agent_start')
|
||||
await post(harness, 'ui_prompt_start')
|
||||
await post(harness, 'ui_prompt_end')
|
||||
expect(harness.statuses.at(-1)?.payload.state).toBe('working')
|
||||
await post(harness, 'agent_settled')
|
||||
expect(harness.statuses.at(-1)?.payload.state).toBe('done')
|
||||
})
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ export class PiTitlebarExtensionService {
|
||||
if (kind !== 'prime-agent') {
|
||||
this.writeManagedExtension(
|
||||
join(extensionsDir, ORCA_PI_EXTENSION_FILE),
|
||||
withOrcaManagedExtensionMarker(getPiTitlebarExtensionSource())
|
||||
withOrcaManagedExtensionMarker(getPiTitlebarExtensionSource(kind))
|
||||
)
|
||||
this.writeManagedExtension(
|
||||
join(extensionsDir, ORCA_PI_PREFILL_EXTENSION_FILE),
|
||||
|
||||
@@ -3,6 +3,8 @@ import { runInNewContext } from 'node:vm'
|
||||
import ts from 'typescript-api'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { detectAgentStatusFromTitle } from '../../shared/agent-detection'
|
||||
import type { PiAgentKind } from '../../shared/pi-agent-kind'
|
||||
import { getPiTitlebarExtensionSource } from './titlebar-extension-source'
|
||||
|
||||
const BRAILLE_RE = /[⠀-⣿]/
|
||||
@@ -23,8 +25,19 @@ type Harness = {
|
||||
const CWD = '/repo/orca-app'
|
||||
const SESSION = 'omp-session'
|
||||
const IDLE_TITLE = `π - ${SESSION} - orca-app`
|
||||
const PROMPT_TITLE = `π ! ${SESSION} - orca-app`
|
||||
|
||||
function createHarness(options: { paneKey?: string; isIdle?: () => boolean } = {}): Harness {
|
||||
function createHarness(
|
||||
options: {
|
||||
paneKey?: string
|
||||
isIdle?: () => boolean
|
||||
kind?: PiAgentKind
|
||||
processTitle?: string
|
||||
cwdImpl?: () => string
|
||||
sessionNameImpl?: () => string
|
||||
env?: Record<string, string>
|
||||
} = {}
|
||||
): Harness {
|
||||
const titles: string[] = []
|
||||
const ctx: TitlebarContext = {
|
||||
ui: {
|
||||
@@ -48,8 +61,11 @@ function createHarness(options: { paneKey?: string; isIdle?: () => boolean } = {
|
||||
module,
|
||||
exports: module.exports,
|
||||
process: {
|
||||
env: { ORCA_PANE_KEY: options.paneKey ?? 'pane-1' },
|
||||
cwd: () => CWD
|
||||
env: { ORCA_PANE_KEY: options.paneKey ?? 'pane-1', ...options.env },
|
||||
pid: options.env?.ORCA_PI_TITLE_MARKER_OWNED === undefined ? 111 : 222,
|
||||
title: options.processTitle ?? 'pi',
|
||||
argv: ['node', 'pi'],
|
||||
cwd: options.cwdImpl ?? (() => CWD)
|
||||
},
|
||||
console: { warn: vi.fn(), error: vi.fn(), log: vi.fn() },
|
||||
Promise,
|
||||
@@ -61,7 +77,7 @@ function createHarness(options: { paneKey?: string; isIdle?: () => boolean } = {
|
||||
} as Record<string, unknown>
|
||||
context.globalThis = context
|
||||
|
||||
const output = ts.transpileModule(getPiTitlebarExtensionSource(), {
|
||||
const output = ts.transpileModule(getPiTitlebarExtensionSource(options.kind ?? 'pi'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }
|
||||
}).outputText
|
||||
runInNewContext(output, context)
|
||||
@@ -76,7 +92,7 @@ function createHarness(options: { paneKey?: string; isIdle?: () => boolean } = {
|
||||
on(name: string, handler: HookHandler) {
|
||||
handlers[name] = handler
|
||||
},
|
||||
getSessionName: () => SESSION
|
||||
getSessionName: options.sessionNameImpl ?? (() => SESSION)
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -247,4 +263,329 @@ describe('getPiTitlebarExtensionSource', () => {
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(harness.lastTitle()).toBe(IDLE_TITLE)
|
||||
})
|
||||
|
||||
it('marks a mid-turn dialog as needing input and holds it against the spinner', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('agent_start')
|
||||
await harness.callHook('ui_prompt_start')
|
||||
expect(harness.lastTitle()).toBe(PROMPT_TITLE)
|
||||
expect(detectAgentStatusFromTitle(PROMPT_TITLE)).toBe('permission')
|
||||
|
||||
// Why: the spinner interval keeps running, but must not repaint over the marker.
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
expect(harness.lastTitle()).toBe(PROMPT_TITLE)
|
||||
|
||||
await harness.callHook('ui_prompt_end')
|
||||
expect(harness.lastTitle()).toMatch(BRAILLE_RE)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('returns an idle pane to its plain title when the dialog closes', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('ui_prompt_start')
|
||||
expect(harness.lastTitle()).toBe(PROMPT_TITLE)
|
||||
|
||||
await harness.callHook('ui_prompt_end')
|
||||
expect(harness.lastTitle()).toBe(IDLE_TITLE)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('only the outermost of nested dialogs moves the title', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('agent_start')
|
||||
await harness.callHook('ui_prompt_start')
|
||||
await harness.callHook('ui_prompt_start')
|
||||
await harness.callHook('ui_prompt_end')
|
||||
// Why: the outer dialog still holds input focus.
|
||||
expect(harness.lastTitle()).toBe(PROMPT_TITLE)
|
||||
|
||||
await harness.callHook('ui_prompt_end')
|
||||
expect(harness.lastTitle()).toMatch(BRAILLE_RE)
|
||||
})
|
||||
|
||||
it('ignores an unmatched dialog close', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('agent_start')
|
||||
const titleCount = harness.titles.length
|
||||
await harness.callHook('ui_prompt_end')
|
||||
expect(harness.titles.length).toBe(titleCount)
|
||||
})
|
||||
|
||||
it.each(['agent_settled', 'session_shutdown'])(
|
||||
'keeps the marker when %s lands under an open dialog',
|
||||
async (name) => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('agent_start')
|
||||
await harness.callHook('ui_prompt_start')
|
||||
await harness.callHook(name)
|
||||
// Why: settling does not answer the dialog, so the pane still needs the user.
|
||||
const expected = name === 'session_shutdown' ? IDLE_TITLE : PROMPT_TITLE
|
||||
expect(harness.lastTitle()).toBe(expected)
|
||||
// Why: settling stops the spinner but must leave the marker re-assert running, or
|
||||
// pi's own next title write would silently retire a dialog that is still open.
|
||||
expect(vi.getTimerCount()).toBe(name === 'session_shutdown' ? 0 : 1)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the marker across an idle compaction that finishes under a dialog', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('ui_prompt_start')
|
||||
await harness.callHook('auto_compaction_start', { reason: 'idle' })
|
||||
await harness.callHook('auto_compaction_end')
|
||||
expect(harness.lastTitle()).toBe(PROMPT_TITLE)
|
||||
})
|
||||
|
||||
it('recovers the spinner on a new turn when a dialog close was lost', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('ui_prompt_start')
|
||||
expect(harness.lastTitle()).toBe(PROMPT_TITLE)
|
||||
|
||||
// Why: a turn cannot start under a dialog holding input focus, so this is recovery.
|
||||
await harness.callHook('agent_start')
|
||||
await vi.advanceTimersByTimeAsync(80)
|
||||
expect(harness.lastTitle()).toMatch(BRAILLE_RE)
|
||||
})
|
||||
|
||||
it('leaves the marker to OMP approval events instead of painting it', () => {
|
||||
expect(createHarness({ kind: 'omp' }).handlers.ui_prompt_start).toBeUndefined()
|
||||
})
|
||||
|
||||
it('still caps idle maintenance while a dialog holds the title', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('auto_compaction_start', { reason: 'idle' })
|
||||
await harness.callHook('ui_prompt_start')
|
||||
// Why: an open dialog must not suspend the cap that stops a stranded spinner.
|
||||
vi.advanceTimersByTime(301_000)
|
||||
|
||||
// Why: the spinner is capped, but the marker re-assert survives it — the dialog is
|
||||
// still open, so the pane must keep reporting that it needs input.
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
expect(harness.lastTitle()).toBe(PROMPT_TITLE)
|
||||
})
|
||||
|
||||
it('survives a dialog event that carries no ui context', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('agent_start')
|
||||
await expect(harness.handlers.ui_prompt_start?.({}, undefined)).resolves.toBeUndefined()
|
||||
await expect(harness.handlers.ui_prompt_end?.({}, undefined)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps spinning when the dialog event could not paint the marker', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('agent_start')
|
||||
await harness.handlers.ui_prompt_start?.({}, undefined)
|
||||
// Why: suppressing frames without a marker would freeze the title mid-spinner, which
|
||||
// still reads as working — the opposite of what the marker is for.
|
||||
await vi.advanceTimersByTimeAsync(160)
|
||||
expect(harness.lastTitle()).toMatch(BRAILLE_RE)
|
||||
})
|
||||
|
||||
it('marks a nested dialog when the outer one could not paint', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('agent_start')
|
||||
await harness.handlers.ui_prompt_start?.({}, undefined)
|
||||
await harness.callHook('ui_prompt_start')
|
||||
// Why: the outer ctx cannot decide that the whole stack stays unmarked.
|
||||
expect(harness.lastTitle()).toBe(PROMPT_TITLE)
|
||||
})
|
||||
|
||||
it('clears the marker through the opening ctx when the close carries none', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('ui_prompt_start')
|
||||
expect(harness.lastTitle()).toBe(PROMPT_TITLE)
|
||||
await harness.handlers.ui_prompt_end?.({}, undefined)
|
||||
// Why: otherwise the pane asks for attention until the next turn.
|
||||
expect(harness.lastTitle()).toBe(IDLE_TITLE)
|
||||
})
|
||||
|
||||
it('does not reject when the dialog ctx can no longer paint', async () => {
|
||||
const harness = createHarness()
|
||||
const throwing = {
|
||||
ui: {
|
||||
setTitle: () => {
|
||||
throw new Error('extension runner is no longer active')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await expect(harness.handlers.ui_prompt_start?.({}, throwing)).resolves.toBeUndefined()
|
||||
// Why: the marker never went up, so the spinner must not stay suppressed.
|
||||
await harness.callHook('agent_start')
|
||||
await vi.advanceTimersByTimeAsync(80)
|
||||
expect(harness.lastTitle()).toMatch(BRAILLE_RE)
|
||||
})
|
||||
|
||||
it('does not reject when the captured ctx dies before the dialog closes', async () => {
|
||||
const harness = createHarness()
|
||||
let live = true
|
||||
const dying = {
|
||||
ui: {
|
||||
setTitle: (title: string) => {
|
||||
if (!live) {
|
||||
throw new Error('extension runner is no longer active')
|
||||
}
|
||||
harness.titles.push(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await harness.handlers.ui_prompt_start?.({}, dying)
|
||||
expect(harness.lastTitle()).toBe(PROMPT_TITLE)
|
||||
live = false
|
||||
// Why: the close carries no ui, so it falls back to the ctx the modal invalidated.
|
||||
await expect(harness.handlers.ui_prompt_end?.({}, undefined)).resolves.toBeUndefined()
|
||||
// Why: a later turn still recovers a clean title through a live ctx.
|
||||
await harness.callHook('agent_start')
|
||||
await vi.advanceTimersByTimeAsync(80)
|
||||
expect(harness.lastTitle()).toMatch(BRAILLE_RE)
|
||||
})
|
||||
|
||||
it('does not strand the marker when the closing ctx throws on ui access', async () => {
|
||||
const harness = createHarness()
|
||||
// Why: pi's ctx.ui is a getter that calls assertActive(); a session-replacing dialog
|
||||
// invalidates the runner, so reading ctx.ui throws rather than yielding undefined.
|
||||
const stale = {
|
||||
get ui(): never {
|
||||
throw new Error('This extension ctx is stale')
|
||||
}
|
||||
}
|
||||
|
||||
await harness.callHook('ui_prompt_start')
|
||||
expect(harness.lastTitle()).toBe(PROMPT_TITLE)
|
||||
|
||||
await expect(harness.handlers.ui_prompt_end?.({}, stale as never)).resolves.toBeUndefined()
|
||||
// Why: the opening ctx still paints, so the pane stops asking for input.
|
||||
expect(harness.lastTitle()).toBe(IDLE_TITLE)
|
||||
|
||||
// Why: a stranded markerPainted would suppress every later working frame.
|
||||
await harness.callHook('agent_start')
|
||||
await vi.advanceTimersByTimeAsync(80)
|
||||
expect(harness.lastTitle()).toMatch(BRAILLE_RE)
|
||||
})
|
||||
|
||||
it('does not reject when the opening ctx throws on ui access', async () => {
|
||||
const harness = createHarness()
|
||||
const stale = {
|
||||
get ui(): never {
|
||||
throw new Error('This extension ctx is stale')
|
||||
}
|
||||
}
|
||||
|
||||
await harness.callHook('agent_start')
|
||||
await expect(harness.handlers.ui_prompt_start?.({}, stale as never)).resolves.toBeUndefined()
|
||||
// Why: no marker went up, so the spinner must keep running.
|
||||
await vi.advanceTimersByTimeAsync(80)
|
||||
expect(harness.lastTitle()).toMatch(BRAILLE_RE)
|
||||
})
|
||||
|
||||
it('re-asserts the marker when pi repaints the title under a dialog', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('agent_start')
|
||||
await harness.callHook('ui_prompt_start')
|
||||
expect(harness.lastTitle()).toBe(PROMPT_TITLE)
|
||||
|
||||
// Why: pi repaints on session_info_changed/rebindCurrentSession with no event we see,
|
||||
// so a marker that is merely "not overwritten by us" would be silently lost.
|
||||
harness.titles.push('π - other - orca-app')
|
||||
await vi.advanceTimersByTimeAsync(80)
|
||||
expect(harness.lastTitle()).toBe(PROMPT_TITLE)
|
||||
})
|
||||
|
||||
it('re-asserts the marker on an idle pane with no spinner running', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('ui_prompt_start')
|
||||
expect(harness.lastTitle()).toBe(PROMPT_TITLE)
|
||||
|
||||
// Why: no turn is running, so renderFrame never fires — only the slow re-assert can
|
||||
// undo a title pi writes from session_info_changed or its update-check restore.
|
||||
harness.titles.push('\u03c0 - other - orca-app')
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(harness.lastTitle()).toBe(PROMPT_TITLE)
|
||||
|
||||
await harness.callHook('ui_prompt_end')
|
||||
expect(harness.lastTitle()).toBe(IDLE_TITLE)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('releases the marker when a session replacement drops the dialog', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('ui_prompt_start')
|
||||
expect(harness.lastTitle()).toBe(PROMPT_TITLE)
|
||||
|
||||
// Why: pi hides the dialog without resolving it, so no close is coming.
|
||||
await harness.callHook('session_start', { reason: 'switch' })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await harness.callHook('agent_start')
|
||||
await vi.advanceTimersByTimeAsync(80)
|
||||
expect(harness.lastTitle()).toMatch(BRAILLE_RE)
|
||||
})
|
||||
|
||||
it('survives a deleted cwd instead of crashing the pi process', async () => {
|
||||
const harness = createHarness({
|
||||
cwdImpl: () => {
|
||||
throw new Error('ENOENT: uv_cwd')
|
||||
}
|
||||
})
|
||||
|
||||
// Why: these run inside setInterval callbacks, where an escape is an uncaught
|
||||
// exception and pi exits(1) through its own uncaughtException handler.
|
||||
await expect(harness.callHook('agent_start')).resolves.toBeUndefined()
|
||||
await expect(harness.callHook('ui_prompt_start')).resolves.toBeUndefined()
|
||||
// Why: an unguarded throw in the interval would surface here as an unhandled error.
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
await expect(harness.callHook('ui_prompt_end')).resolves.toBeUndefined()
|
||||
await expect(harness.callHook('agent_settled')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('survives a session name that throws on a stale runtime', async () => {
|
||||
let live = true
|
||||
const harness = createHarness({
|
||||
sessionNameImpl: () => {
|
||||
if (!live) {
|
||||
throw new Error('This extension API is stale')
|
||||
}
|
||||
return SESSION
|
||||
}
|
||||
})
|
||||
|
||||
await harness.callHook('agent_start')
|
||||
await harness.callHook('ui_prompt_start')
|
||||
live = false
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
await expect(harness.callHook('ui_prompt_end')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves the needs-input marker to the process that owns the pane', async () => {
|
||||
// Why: child agents inherit ORCA_PANE_KEY, and a second process asserting the marker
|
||||
// would report needs-input for a pane it does not speak for.
|
||||
const harness = createHarness({ env: { ORCA_PI_TITLE_MARKER_OWNED: '111' } })
|
||||
|
||||
await harness.callHook('agent_start')
|
||||
await harness.callHook('ui_prompt_start')
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(harness.titles).not.toContain(PROMPT_TITLE)
|
||||
expect(harness.lastTitle()).toMatch(BRAILLE_RE)
|
||||
})
|
||||
|
||||
it('leaves an OMP runtime to its own approval events', () => {
|
||||
const harness = createHarness({ processTitle: 'omp' })
|
||||
|
||||
expect(harness.handlers.ui_prompt_start).toBeDefined()
|
||||
expect(() => harness.handlers.ui_prompt_start?.({}, undefined)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,54 @@
|
||||
import type { PiAgentKind } from '../../shared/pi-agent-kind'
|
||||
import { getPiOmpRuntimeDetectionSourceLines } from './agent-status-runtime-detection-source'
|
||||
|
||||
export const ORCA_PI_EXTENSION_FILE = 'orca-titlebar-spinner.ts'
|
||||
|
||||
export function getPiTitlebarExtensionSource(): string {
|
||||
export function getPiTitlebarExtensionSource(kind: PiAgentKind = 'pi'): string {
|
||||
// Why: OMP reports input waits through its own approval events, which the status
|
||||
// extension already maps, and it writes this same marker natively. The runtime check
|
||||
// matters as well as the kind: a bare-shell OMP launch runs inside a pi-kind pane.
|
||||
const uiPromptHandlers =
|
||||
kind === 'pi'
|
||||
? [
|
||||
" pi.on('ui_prompt_start', async (_event, ctx) => {",
|
||||
' if (isOmpRuntime() || !ownsMarker) return',
|
||||
' promptDepth++',
|
||||
' // Why: retry on every open rather than only the outermost, so an outer ctx',
|
||||
' // that could not paint cannot decide the whole stack stays unmarked.',
|
||||
' if (markerPainted) return',
|
||||
' const painter = resolvePainter(ctx)',
|
||||
' // Why: only hold the spinner off once the marker is actually up, or a ctx',
|
||||
' // that cannot paint would freeze the title on its last working frame.',
|
||||
" if (!paintTitle(painter, () => getMarkedTitle(pi, '!'))) return",
|
||||
' markerPainted = true',
|
||||
' promptCtx = painter',
|
||||
' startMarkerReassert(painter)',
|
||||
' })',
|
||||
'',
|
||||
" pi.on('ui_prompt_end', async (_event, ctx) => {",
|
||||
' if (isOmpRuntime() || !ownsMarker || promptDepth === 0) return',
|
||||
' promptDepth--',
|
||||
' if (promptDepth > 0) return',
|
||||
' // Why: the opening ctx already painted once, so a close whose own ctx is stale',
|
||||
' // does not leave the needs-input marker up until the next turn.',
|
||||
' const painter = resolvePainter(ctx) ?? promptCtx',
|
||||
' markerPainted = false',
|
||||
' promptCtx = null',
|
||||
' stopMarkerReassert()',
|
||||
' // Why: a still-live turn resumes its spinner in place; otherwise the pane is idle',
|
||||
' // and must drop the needs-input marker rather than keep asking for attention.',
|
||||
' if (timer) {',
|
||||
' renderFrame(painter)',
|
||||
' return',
|
||||
' }',
|
||||
' paintTitle(painter, () => getBaseTitle(pi))',
|
||||
' })',
|
||||
''
|
||||
]
|
||||
: []
|
||||
|
||||
return [
|
||||
...(kind === 'pi' ? [...getPiOmpRuntimeDetectionSourceLines(`/hook/${kind}`), ''] : []),
|
||||
'const BRAILLE_FRAMES = [',
|
||||
" '\\u280b',",
|
||||
" '\\u2819',",
|
||||
@@ -16,36 +63,111 @@ export function getPiTitlebarExtensionSource(): string {
|
||||
']',
|
||||
'',
|
||||
'const FRAME_INTERVAL_MS = 80',
|
||||
'// Why: pi repaints the title from its own writers (session_info_changed, the win32',
|
||||
'// update-check restore) with no event we observe, so the marker has to be re-asserted',
|
||||
'// even when no spinner frame is due. Coarse on purpose: it only rewrites one string.',
|
||||
'const MARKER_REASSERT_MS = 1000',
|
||||
'const AGENT_END_IDLE_RECHECK_MS = 25',
|
||||
'const AGENT_END_IDLE_RECHECK_MAX_MS = 250',
|
||||
'// Why: a failed idle compaction can end without auto_compaction_end, and no agent turn will',
|
||||
'// close a maintenance spinner — cap it so idle maintenance cannot strand a working title.',
|
||||
'const IDLE_COMPACTION_MAX_FRAMES = Math.ceil(300000 / FRAME_INTERVAL_MS)',
|
||||
'',
|
||||
'function getBaseTitle(pi) {',
|
||||
'// Why: `-` is the plain separator; `!` is the state marker Orca reads as needs-input',
|
||||
'// (src/shared/pi-state-title-marker.ts), so mobile and the CLI see the wait too.',
|
||||
'function getMarkedTitle(pi, marker) {',
|
||||
' const cwd = process.cwd().split(/[\\\\/]/).filter(Boolean).at(-1) || process.cwd()',
|
||||
' const session = pi.getSessionName()',
|
||||
' return session ? `\\u03c0 - ${session} - ${cwd}` : `\\u03c0 - ${cwd}`',
|
||||
' return session',
|
||||
' ? `\\u03c0 ${marker} ${session} - ${cwd}`',
|
||||
' : `\\u03c0 ${marker} ${cwd}`',
|
||||
'}',
|
||||
'',
|
||||
'function getBaseTitle(pi) {',
|
||||
" return getMarkedTitle(pi, '-')",
|
||||
'}',
|
||||
'',
|
||||
'// Why: the ctx.ui pi passes is a getter that calls assertActive() and throws once a',
|
||||
'// session-replacing dialog invalidates the runner; optional chaining cannot screen',
|
||||
'// that out. Read it behind a try and never mutate state before a paint has succeeded.',
|
||||
'function resolvePainter(ctx) {',
|
||||
' try {',
|
||||
" return typeof ctx?.ui?.setTitle === 'function' ? ctx : null",
|
||||
' } catch {',
|
||||
' return null',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
'// Why: buildTitle runs inside the try because it is not safe either — getSessionName()',
|
||||
'// calls assertActive() and process.cwd() throws ENOENT once the worktree is deleted.',
|
||||
'// Most call sites are timer callbacks, where an escape is an uncaught exception and pi',
|
||||
'// exits(1) through its own uncaughtException handler.',
|
||||
'function paintTitle(ctx, buildTitle) {',
|
||||
' if (!ctx) return false',
|
||||
' try {',
|
||||
' ctx.ui.setTitle(buildTitle())',
|
||||
' return true',
|
||||
' } catch {',
|
||||
' return false',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
'export default function (pi) {',
|
||||
' if (!process.env.ORCA_PANE_KEY) return',
|
||||
...(kind === 'pi'
|
||||
? [
|
||||
' // Why: child agents inherit the pane env, and the spinner is harmlessly',
|
||||
' // per-process — but the needs-input marker is status the pane reports, so only',
|
||||
' // one process may assert it. Mirrors ORCA_PI_STATUS_OWNED in the status hook.',
|
||||
' const markerOwnerPid = process.env.ORCA_PI_TITLE_MARKER_OWNED',
|
||||
' const ownsMarker = !markerOwnerPid || markerOwnerPid === String(process.pid)',
|
||||
' if (ownsMarker) process.env.ORCA_PI_TITLE_MARKER_OWNED = String(process.pid)'
|
||||
]
|
||||
: []),
|
||||
|
||||
' let timer = null',
|
||||
' let frameIndex = 0',
|
||||
' // Why: only idle maintenance owns a spinner of its own. A threshold compaction runs',
|
||||
' // inside an agent turn, whose spinner must outlive it, and any newer start clears the',
|
||||
' // marker so a late idle completion cannot stop current work (#16470).',
|
||||
' let idleCompactionOwnsSpinner = false',
|
||||
' // Why: pi already collapses nested prompts into one start/end pair, so this counter',
|
||||
' // guards a close that never arrives, not nesting. A new turn cannot start under a',
|
||||
' // dialog holding input focus, so agent_start doubles as recovery.',
|
||||
' let promptDepth = 0',
|
||||
' let markerPainted = false',
|
||||
' let promptCtx = null',
|
||||
' // Why: a separate handle from `timer`, which clearAnimation() nulls — the marker must',
|
||||
' // survive a turn settling, a shutdown of the spinner, and the idle-maintenance cap.',
|
||||
' let markerTimer = null',
|
||||
' let pendingAgentEndCheck = null',
|
||||
' let pendingAgentEndContext = null',
|
||||
' let agentEndIdleRecheckMs = AGENT_END_IDLE_RECHECK_MS',
|
||||
'',
|
||||
' function resetPromptState() {',
|
||||
' stopMarkerReassert()',
|
||||
' promptDepth = 0',
|
||||
' markerPainted = false',
|
||||
' promptCtx = null',
|
||||
' }',
|
||||
'',
|
||||
' function clearPendingAgentEndCheck() {',
|
||||
' if (pendingAgentEndCheck !== null) clearTimeout(pendingAgentEndCheck)',
|
||||
' pendingAgentEndCheck = null',
|
||||
' pendingAgentEndContext = null',
|
||||
' }',
|
||||
'',
|
||||
' function stopMarkerReassert() {',
|
||||
' if (markerTimer) clearInterval(markerTimer)',
|
||||
' markerTimer = null',
|
||||
' }',
|
||||
'',
|
||||
' function startMarkerReassert(ctx) {',
|
||||
' stopMarkerReassert()',
|
||||
" markerTimer = setInterval(() => paintTitle(ctx, () => getMarkedTitle(pi, '!')), MARKER_REASSERT_MS)",
|
||||
" if (typeof markerTimer.unref === 'function') markerTimer.unref()",
|
||||
' }',
|
||||
'',
|
||||
' function clearAnimation() {',
|
||||
' if (timer) {',
|
||||
' clearInterval(timer)',
|
||||
@@ -58,19 +180,35 @@ export function getPiTitlebarExtensionSource(): string {
|
||||
' function stopAnimation(ctx) {',
|
||||
' clearPendingAgentEndCheck()',
|
||||
' clearAnimation()',
|
||||
' ctx.ui.setTitle(getBaseTitle(pi))',
|
||||
' // Why: settling under an open dialog still leaves the pane waiting on the user, so',
|
||||
' // the idle title must not retire the marker the dialog is holding.',
|
||||
" paintTitle(ctx, () => (markerPainted ? getMarkedTitle(pi, '!') : getBaseTitle(pi)))",
|
||||
' }',
|
||||
'',
|
||||
' function renderFrame(ctx) {',
|
||||
' // Why: the maintenance cap runs before the dialog guard so a dialog left open',
|
||||
' // cannot suspend it; stopAnimation keeps the marker while a dialog is open.',
|
||||
' if (idleCompactionOwnsSpinner && frameIndex >= IDLE_COMPACTION_MAX_FRAMES) {',
|
||||
' stopAnimation(ctx)',
|
||||
' return',
|
||||
' }',
|
||||
' const frame = BRAILLE_FRAMES[frameIndex % BRAILLE_FRAMES.length]',
|
||||
' const cwd = process.cwd().split(/[\\\\/]/).filter(Boolean).at(-1) || process.cwd()',
|
||||
' const session = pi.getSessionName()',
|
||||
' const title = session ? `${frame} \\u03c0 - ${session} - ${cwd}` : `${frame} \\u03c0 - ${cwd}`',
|
||||
' ctx.ui.setTitle(title)',
|
||||
' // Why: an 80ms working frame would repaint over the needs-input marker within one',
|
||||
' // tick, so a mid-turn dialog would still look busy everywhere the title is the',
|
||||
' // only evidence. Re-assert rather than skip: pi repaints the title on its own',
|
||||
' // (session_info_changed, resetExtensionUI, rebindCurrentSession) and would',
|
||||
' // otherwise wipe the marker with nothing to restore it. The frame still counts,',
|
||||
' // so the cap above keeps accruing in wall-clock.',
|
||||
' if (markerPainted) {',
|
||||
" paintTitle(ctx, () => getMarkedTitle(pi, '!'))",
|
||||
' frameIndex++',
|
||||
' return',
|
||||
' }',
|
||||
' paintTitle(ctx, () => {',
|
||||
' const frame = BRAILLE_FRAMES[frameIndex % BRAILLE_FRAMES.length]',
|
||||
' const cwd = process.cwd().split(/[\\\\/]/).filter(Boolean).at(-1) || process.cwd()',
|
||||
' const session = pi.getSessionName()',
|
||||
' return session ? `${frame} \\u03c0 - ${session} - ${cwd}` : `${frame} \\u03c0 - ${cwd}`',
|
||||
' })',
|
||||
' frameIndex++',
|
||||
' }',
|
||||
'',
|
||||
@@ -101,9 +239,17 @@ export function getPiTitlebarExtensionSource(): string {
|
||||
' }',
|
||||
'',
|
||||
" pi.on('agent_start', async (_event, ctx) => {",
|
||||
' resetPromptState()',
|
||||
' startAnimation(ctx)',
|
||||
' })',
|
||||
'',
|
||||
' // Why: pi drops an open dialog through resetExtensionUI without resolving its promise,',
|
||||
' // so a replaced or reloaded session never sends the matching close. Both boundaries',
|
||||
' // prove no dialog from the old session is still on screen.',
|
||||
" pi.on('session_start', async () => {",
|
||||
' resetPromptState()',
|
||||
' })',
|
||||
'',
|
||||
' // Why: modern Pi/OMP emit agent_end mid-run and only settle later, so settlement is the',
|
||||
' // authoritative completion boundary. Legacy runtimes never emit it, so agent_end stays.',
|
||||
" pi.on('agent_settled', async (_event, ctx) => {",
|
||||
@@ -126,6 +272,7 @@ export function getPiTitlebarExtensionSource(): string {
|
||||
" if (typeof pendingAgentEndCheck.unref === 'function') pendingAgentEndCheck.unref()",
|
||||
' })',
|
||||
'',
|
||||
...uiPromptHandlers,
|
||||
" pi.on('auto_compaction_start', async (event, ctx) => {",
|
||||
" if (event?.reason !== 'idle') return",
|
||||
' // Why: the idle worker can fire against a turn that just started, and reason alone does',
|
||||
@@ -142,6 +289,7 @@ export function getPiTitlebarExtensionSource(): string {
|
||||
' })',
|
||||
'',
|
||||
" pi.on('session_shutdown', async (_event, ctx) => {",
|
||||
' resetPromptState()',
|
||||
' stopAnimation(ctx)',
|
||||
' })',
|
||||
'}',
|
||||
|
||||
@@ -138,7 +138,9 @@ describe('createNestedProjectGroupResolver', () => {
|
||||
parentPath: '/workspace',
|
||||
groupName: 'workspace',
|
||||
mode: 'separate',
|
||||
repoPaths: ['/workspace/services/api', '/workspace/services/worker'],
|
||||
get repoPaths(): readonly string[] {
|
||||
throw new Error('separate imports must not build unused folder scopes')
|
||||
},
|
||||
createGroup: () => {
|
||||
throw new Error('should not create a group')
|
||||
}
|
||||
@@ -148,6 +150,30 @@ describe('createNestedProjectGroupResolver', () => {
|
||||
expect(resolver.getCreatedGroups()).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves every separate-import repo ungrouped even when repo paths are supplied', () => {
|
||||
const { groups, createGroup } = createGroupRecorder()
|
||||
const repoPaths = [
|
||||
'/workspace/services/api',
|
||||
'/workspace/services/worker',
|
||||
'/workspace/platform/packages/shared'
|
||||
]
|
||||
const resolver = createNestedProjectGroupResolver({
|
||||
parentPath: '/workspace',
|
||||
groupName: 'workspace',
|
||||
mode: 'separate',
|
||||
repoPaths,
|
||||
createGroup
|
||||
})
|
||||
|
||||
expect(repoPaths.map((repoPath) => resolver.getGroupForRepo(repoPath))).toEqual([
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
])
|
||||
expect(resolver.getRootGroup()).toBeUndefined()
|
||||
expect(groups).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves filesystem root parent paths when creating the root group', () => {
|
||||
const groups: ProjectGroup[] = []
|
||||
const resolver = createNestedProjectGroupResolver({
|
||||
|
||||
@@ -150,10 +150,12 @@ export function createNestedProjectGroupResolver(args: {
|
||||
createGroup: (input: CreateGroupInput) => ProjectGroup
|
||||
}): NestedProjectGroupResolver {
|
||||
const createdGroups: ProjectGroup[] = []
|
||||
const folderScopes = buildSparseFolderScopes({
|
||||
parentPath: args.parentPath,
|
||||
repoPaths: args.repoPaths ?? []
|
||||
})
|
||||
// Every folder-scope read sits behind ensureRootGroup, so outside group mode the scopes are
|
||||
// unreachable. One flag drives both so the skip can never drift from the guard that justifies it.
|
||||
const createsGroups = args.mode === 'group'
|
||||
const folderScopes = createsGroups
|
||||
? buildSparseFolderScopes({ parentPath: args.parentPath, repoPaths: args.repoPaths ?? [] })
|
||||
: []
|
||||
const folderScopesByRelativePath = new Map(
|
||||
folderScopes.map((scope) => [scope.relativePath, scope])
|
||||
)
|
||||
@@ -161,7 +163,7 @@ export function createNestedProjectGroupResolver(args: {
|
||||
let rootGroup: ProjectGroup | undefined
|
||||
|
||||
const ensureRootGroup = (): ProjectGroup | undefined => {
|
||||
if (args.mode !== 'group') {
|
||||
if (!createsGroups) {
|
||||
return undefined
|
||||
}
|
||||
if (rootGroup) {
|
||||
|
||||
+1
-5
@@ -23,7 +23,7 @@ import type { LegacyWorkerTerminalRecoveryResult } from './runtime-legacy-worker
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity'
|
||||
|
||||
export class OrcaRuntimeWithFenceAutomationOwner extends OrcaRuntimeWithPtyForegroundProcessReads {
|
||||
export class OrcaRuntimeWithAutomationOperations extends OrcaRuntimeWithPtyForegroundProcessReads {
|
||||
protected fenceAutomationOwner(
|
||||
id: string,
|
||||
expectedOwner: AutomationOwnerPrecondition | undefined,
|
||||
@@ -167,10 +167,6 @@ export class OrcaRuntimeWithFenceAutomationOwner extends OrcaRuntimeWithPtyForeg
|
||||
this.scheduleRestoredMessageRepoints()
|
||||
}
|
||||
|
||||
prepareLegacyWorkerTerminalRecovery(): LegacyWorkerTerminalRecoveryPlan {
|
||||
return this.legacyWorkerRecovery.prepare()
|
||||
}
|
||||
|
||||
protected async flushWorkspaceSessionOrThrowAsync(): Promise<void> {
|
||||
const store = this.store
|
||||
if (store?.flushPendingOrThrowAsync) {
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithFenceAutomationOwner } from './orca-runtime-fence-automation-owner'
|
||||
import { OrcaRuntimeWithAutomationOperations } from './orca-runtime-automation-operations'
|
||||
import {
|
||||
resolveTerminalSessionWorktreeId,
|
||||
runtimeWorktreeIdsEqual
|
||||
@@ -26,7 +26,7 @@ import type {
|
||||
ArtifactWriteRequest
|
||||
} from '../../shared/artifacts'
|
||||
|
||||
export class OrcaRuntimeWithHasExactPersistedTerminalSurfaceIdentity extends OrcaRuntimeWithFenceAutomationOwner {
|
||||
export class OrcaRuntimeWithHasExactPersistedTerminalSurfaceIdentity extends OrcaRuntimeWithAutomationOperations {
|
||||
protected hasExactPersistedTerminalSurfaceIdentity(expected: {
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
|
||||
@@ -136,8 +136,7 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin
|
||||
new RuntimeLegacyWorkerTerminalRecoveryPersistence(
|
||||
() => this.store,
|
||||
() => this.getOrchestrationDb(),
|
||||
(worktreeId) => this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId),
|
||||
(paneKey, blocked) => this.notifier?.setLegacyWorkerTerminalResumeFence?.(paneKey, blocked)
|
||||
(worktreeId) => this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId)
|
||||
)
|
||||
|
||||
protected readonly legacyWorkerRecovery = new RuntimeLegacyWorkerTerminalRecoveryController({
|
||||
|
||||
@@ -80,6 +80,15 @@ export class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalidateAllHand
|
||||
...(binding && paneKey ? { tabId: binding.tabId, paneKey } : {}),
|
||||
...(binding?.incarnationId ? { incarnationId: binding.incarnationId } : {})
|
||||
})
|
||||
const hostScope = this.getOrchestrationCompatibilityHostScope(pty)
|
||||
if (paneKey && binding?.incarnationId && hostScope) {
|
||||
this._orchestrationDb?.retainReplacedWorkerTerminalResources({
|
||||
paneKey,
|
||||
worktreeId,
|
||||
hostScope: JSON.stringify(hostScope),
|
||||
processIncarnation: `${ptyId}:${binding.incarnationId}`
|
||||
})
|
||||
}
|
||||
const agentLaunchAuthority = binding?.agentLaunchAuthority
|
||||
if (
|
||||
agentLaunchAuthority &&
|
||||
|
||||
@@ -109,6 +109,9 @@ export class OrcaRuntimeWithSerializeHeadlessTerminalBuffer extends OrcaRuntimeW
|
||||
// still awaiting their first PTY (ptyId null) may adopt it, which preserves
|
||||
// the mobile pre-spawn subscribe flow.
|
||||
resolveLiveLeafForHandle(handle: string): { ptyId: string | null } | null {
|
||||
// Why the discarded call: it re-links a runtime-owned handle whose `handles` record a renderer
|
||||
// reload cleared, so the lookup below sees it; without it a phone's held handle inspects nothing.
|
||||
this.getLivePtyForHandle(handle)
|
||||
const record = this.handles.get(handle)
|
||||
if (!record) {
|
||||
return null
|
||||
|
||||
@@ -54,16 +54,6 @@ export class OrcaRuntimeWithSubscribeToTerminalResize extends OrcaRuntimeWithApp
|
||||
// dispatch contexts immediately, rather than waiting for the coordinator's
|
||||
// next poll cycle. This catches agent crashes and unexpected exits within
|
||||
// milliseconds. The task is set back to 'pending' so it can be re-dispatched.
|
||||
/** A worker settled by its own process exit makes its pane fenceable now, not at the next app
|
||||
* start; a fence sweep must never fail the exit path behind it. */
|
||||
private sweepSettledWorkerResumeFencesAfterExit(): void {
|
||||
try {
|
||||
this.prepareLegacyWorkerTerminalRecovery()
|
||||
} catch (error) {
|
||||
console.warn('[orchestration] settled worker resume fence sweep failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
protected failActiveDispatchOnExit(
|
||||
handle: string,
|
||||
paneKey: string | null,
|
||||
@@ -90,7 +80,6 @@ export class OrcaRuntimeWithSubscribeToTerminalResize extends OrcaRuntimeWithApp
|
||||
const stopping = this._orchestrationDb.getWorkerDispatch?.(dispatch.id)
|
||||
if (stopping?.state === 'stopping' && stopping.runtime_epoch === this.getRuntimeId()) {
|
||||
this._orchestrationDb.settleWorkerStop(dispatch.id)
|
||||
this.sweepSettledWorkerResumeFencesAfterExit()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -99,7 +88,6 @@ export class OrcaRuntimeWithSubscribeToTerminalResize extends OrcaRuntimeWithApp
|
||||
workerProcessExited: true,
|
||||
terminationReason: cause.kind
|
||||
})
|
||||
this.sweepSettledWorkerResumeFencesAfterExit()
|
||||
if (isDeliberateTerminalExit(cause)) {
|
||||
return
|
||||
}
|
||||
@@ -145,7 +133,7 @@ export class OrcaRuntimeWithSubscribeToTerminalResize extends OrcaRuntimeWithApp
|
||||
exitCause: cause,
|
||||
handle
|
||||
}),
|
||||
...(recipient.runId ? { runId: recipient.runId } : {})
|
||||
runId: dispatch.run_id
|
||||
})
|
||||
this.notifyMessageArrived(escalation.to_handle, escalation.type)
|
||||
} catch (error) {
|
||||
|
||||
@@ -53,6 +53,28 @@ function register(runtime: OrcaRuntimeService, incarnationId: string): void {
|
||||
}
|
||||
|
||||
describe('runtime terminal handle incarnation fencing', () => {
|
||||
it('inspects the retained SSH PTY during renderer reload without an input write', async () => {
|
||||
const { runtime } = makeRuntime()
|
||||
const handle = runtime.preAllocateHandleForPty(PTY_ID)
|
||||
register(runtime, 'incarnation-1')
|
||||
syncGraph(runtime)
|
||||
const inspectProcess = vi.fn().mockResolvedValue({ foregroundProcess: 'codex' })
|
||||
runtime.setPtyController({
|
||||
write: vi.fn(() => true),
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
inspectProcess
|
||||
})
|
||||
expect(runtime.markRendererReloading(1)).not.toBeNull()
|
||||
expect((runtime as unknown as { handles: Map<string, unknown> }).handles.has(handle)).toBe(
|
||||
false
|
||||
)
|
||||
await expect(
|
||||
runtime.inspectTerminalProcess(handle, { expectedIncarnationId: 'incarnation-1' })
|
||||
).resolves.toEqual({ foregroundProcess: 'codex' })
|
||||
expect(inspectProcess).toHaveBeenCalledWith(PTY_ID, { expectedIncarnationId: 'incarnation-1' })
|
||||
})
|
||||
|
||||
it('preserves a direct handle while the PTY incarnation is unchanged', async () => {
|
||||
const { runtime } = makeRuntime()
|
||||
const handle = runtime.preAllocateHandleForPty(PTY_ID)
|
||||
|
||||
@@ -95,7 +95,10 @@ describe('OrcaRuntimeService', () => {
|
||||
return [name, createRootDispatch(db, task.id, handles[name], paneKey(name))]
|
||||
})
|
||||
)
|
||||
const legacyTask = db.createTask({ spec: 'legacy worker' })
|
||||
const legacyTask = db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'legacy worker'
|
||||
})
|
||||
const legacyDispatch = createRootDispatch(
|
||||
db,
|
||||
legacyTask.id,
|
||||
|
||||
+10
-2
@@ -216,7 +216,10 @@ describe('OrcaRuntimeService', () => {
|
||||
runtime as unknown as {
|
||||
leaves: Map<
|
||||
string,
|
||||
{ lastAgentStatus: string | null; lastAgentStatusObservedLive: boolean }
|
||||
{
|
||||
lastAgentStatus: string | null
|
||||
lastAgentStatusObservedLive: boolean
|
||||
}
|
||||
>
|
||||
}
|
||||
).leaves.values()
|
||||
@@ -415,7 +418,12 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
db.insertMessage({ from: 'term_worker', to: terminal.handle, subject: 'pending' })
|
||||
db.insertMessage({
|
||||
runId: 'run_legacy_local',
|
||||
from: 'term_worker',
|
||||
to: terminal.handle,
|
||||
subject: 'pending'
|
||||
})
|
||||
runtime.notifyMessageArrived(terminal.handle, 'status')
|
||||
db.close()
|
||||
|
||||
|
||||
+2
-5
@@ -390,7 +390,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(getSession().terminalTopologyRevisionByRepoId?.[TEST_REPO_ID]).toBe(1)
|
||||
})
|
||||
|
||||
it('fences provider resume and reveals one exact live legacy worker without stealing focus', async () => {
|
||||
it('reveals one exact live legacy worker without stealing focus', async () => {
|
||||
const workerLeafId = HEADLESS_LEAF_ID
|
||||
const coordinatorLeafId = HEADLESS_SECOND_LEAF_ID
|
||||
const workerPaneKey = `legacy-worker:${workerLeafId}`
|
||||
@@ -526,10 +526,7 @@ describe('OrcaRuntimeService', () => {
|
||||
resolveLegacyWorkerTerminalRecovery
|
||||
} as never)
|
||||
|
||||
runtime.prepareLegacyWorkerTerminalRecovery()
|
||||
expect(
|
||||
getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy
|
||||
).toBe('legacy-orchestration-worker')
|
||||
expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined()
|
||||
|
||||
const recovered = await runtime.reconcileLegacyWorkerTerminals({
|
||||
materializeRenderer: true
|
||||
|
||||
+5
-7
@@ -17,7 +17,7 @@ import {
|
||||
} from '../orca-runtime-test-scenario-builders.spec'
|
||||
|
||||
describe('OrcaRuntimeService', () => {
|
||||
it('retries renderer reveal before clearing an adopted legacy worker resume fence', async () => {
|
||||
it('retries renderer reveal before clearing an adopted legacy worker sleeping record', async () => {
|
||||
const workerPaneKey = `legacy-worker:${HEADLESS_LEAF_ID}`
|
||||
const incarnationId = '44444444-4444-4444-8444-444444444444'
|
||||
const session: WorkspaceSessionState = {
|
||||
@@ -106,7 +106,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(resolveLegacyWorkerTerminalRecovery).toHaveBeenCalledWith(workerPaneKey, 'adopted')
|
||||
})
|
||||
|
||||
it('keeps a revealed worker fenced until its exact renderer graph is published', async () => {
|
||||
it('defers a revealed worker until its exact renderer graph is published', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const harness = makePostRevealWorkerRecoveryHarness(() => true)
|
||||
@@ -288,7 +288,7 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps recovery fenced when the renderer omits the exact reveal identity', async () => {
|
||||
it('defers recovery when the renderer omits the exact reveal identity', async () => {
|
||||
const harness = makePostRevealWorkerRecoveryHarness(() => false)
|
||||
harness.revealTerminalSession.mockResolvedValue({ tabId: 'legacy-post-reveal' })
|
||||
|
||||
@@ -417,7 +417,7 @@ describe('OrcaRuntimeService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the legacy worker resume fence in memory when persistence fails', async () => {
|
||||
it('keeps the legacy worker sleeping record in memory when persistence fails', async () => {
|
||||
const workerPaneKey = `legacy-worker:${HEADLESS_LEAF_ID}`
|
||||
const incarnationId = '99999999-9999-4999-8999-999999999999'
|
||||
const session: WorkspaceSessionState = {
|
||||
@@ -526,9 +526,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(2)
|
||||
expect(revealTerminalSession).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy
|
||||
).toBe('legacy-orchestration-worker')
|
||||
expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined()
|
||||
expect(getSession().sleepingAgentSessionsByPaneKey?.[concurrentPaneKey]?.tabId).toBe(
|
||||
'concurrent-tab'
|
||||
)
|
||||
|
||||
+10
-10
@@ -56,7 +56,10 @@ describe('OrcaRuntimeService', () => {
|
||||
)
|
||||
const db = new OrchestrationDb(':memory:')
|
||||
try {
|
||||
const task = db.createTask({ spec: 'continue after missing worker recovery' })
|
||||
const task = db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'continue after missing worker recovery'
|
||||
})
|
||||
const started = db.createStartingWorkerDispatch({
|
||||
creator: { kind: 'system' },
|
||||
maxDepth: Number.MAX_SAFE_INTEGER,
|
||||
@@ -163,7 +166,10 @@ describe('OrcaRuntimeService', () => {
|
||||
)
|
||||
const db = new OrchestrationDb(':memory:')
|
||||
try {
|
||||
const task = db.createTask({ spec: 'retry missing worker recovery' })
|
||||
const task = db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'retry missing worker recovery'
|
||||
})
|
||||
const started = db.createStartingWorkerDispatch({
|
||||
creator: { kind: 'system' },
|
||||
maxDepth: Number.MAX_SAFE_INTEGER,
|
||||
@@ -328,10 +334,7 @@ describe('OrcaRuntimeService', () => {
|
||||
resolveLegacyWorkerTerminalRecovery
|
||||
} as never)
|
||||
|
||||
runtime.prepareLegacyWorkerTerminalRecovery()
|
||||
expect(
|
||||
getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy
|
||||
).toBe('legacy-orchestration-worker')
|
||||
expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined()
|
||||
|
||||
await expect(runtime.reconcileLegacyWorkerTerminals()).resolves.toMatchObject({
|
||||
adoptedDispatchIds: ['dispatch-exited-two'],
|
||||
@@ -445,9 +448,7 @@ describe('OrcaRuntimeService', () => {
|
||||
exitedDispatchIds: [],
|
||||
deferredDispatchIds: ['dispatch-inventory-unavailable']
|
||||
})
|
||||
expect(
|
||||
getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy
|
||||
).toBe('legacy-orchestration-worker')
|
||||
expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined()
|
||||
expect(resolveLegacyWorkerTerminalRecovery).not.toHaveBeenCalled()
|
||||
expect(listProcesses).toHaveBeenCalledOnce()
|
||||
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([])
|
||||
@@ -477,7 +478,6 @@ describe('OrcaRuntimeService', () => {
|
||||
try {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const reconcile = vi.spyOn(runtime, 'reconcileLegacyWorkerTerminals').mockResolvedValue({
|
||||
blockedPaneCount: 1,
|
||||
adoptedDispatchIds: [],
|
||||
exitedDispatchIds: [],
|
||||
deferredDispatchIds: []
|
||||
|
||||
+4
-81
@@ -18,13 +18,12 @@ import {
|
||||
TEST_WORKTREE_PATH,
|
||||
makeFolderProjectGroup,
|
||||
makeFolderWorkspace,
|
||||
makeRuntimeStoreWithWorkspaceSession,
|
||||
store
|
||||
makeRuntimeStoreWithWorkspaceSession
|
||||
} from '../orca-runtime-test-fixtures.spec'
|
||||
import { publishLegacyWorkerReveal } from '../orca-runtime-test-scenario-builders.spec'
|
||||
|
||||
describe('OrcaRuntimeService', () => {
|
||||
it('keeps live workers fenced without exact controller identity evidence', async () => {
|
||||
it('defers live workers without exact controller identity evidence', async () => {
|
||||
const incarnationId = '56565656-5656-4656-8656-565656565656'
|
||||
const cases = [
|
||||
{
|
||||
@@ -152,8 +151,7 @@ describe('OrcaRuntimeService', () => {
|
||||
for (const { name, leafId } of cases.slice(0, 2)) {
|
||||
expect(
|
||||
getSession().sleepingAgentSessionsByPaneKey?.[`legacy-${name}:${leafId}`]
|
||||
?.automaticResumeBlockedBy
|
||||
).toBe('legacy-orchestration-worker')
|
||||
).toBeDefined()
|
||||
}
|
||||
for (const { name, leafId } of cases.slice(2)) {
|
||||
expect(
|
||||
@@ -374,12 +372,7 @@ describe('OrcaRuntimeService', () => {
|
||||
} as never)
|
||||
|
||||
try {
|
||||
expect(runtime.prepareLegacyWorkerTerminalRecovery()).toMatchObject({
|
||||
blockedPanes: [expect.objectContaining({ paneKey: workerPaneKey })]
|
||||
})
|
||||
expect(
|
||||
sshSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy
|
||||
).toBe('legacy-orchestration-worker')
|
||||
expect(sshSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined()
|
||||
expect(localSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined()
|
||||
await expect(
|
||||
runtime.reconcileLegacyWorkerTerminals({
|
||||
@@ -422,74 +415,4 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('fences an unresolved folder legacy worker in its exact retained session partition', () => {
|
||||
const connectionId = 'ssh-unresolved-folder'
|
||||
const worktreeId = 'folder:missing-folder'
|
||||
const workerPaneKey = `legacy-unresolved-folder-worker:${HEADLESS_LEAF_ID}`
|
||||
const remoteInitialSession: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: { [worktreeId]: [] },
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[workerPaneKey]: {
|
||||
paneKey: workerPaneKey,
|
||||
tabId: 'legacy-unresolved-folder-worker',
|
||||
worktreeId,
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'legacy-unresolved-folder-session' },
|
||||
prompt: 'continue',
|
||||
state: 'working',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
origin: 'live',
|
||||
connectionId
|
||||
}
|
||||
}
|
||||
}
|
||||
const localSession = getDefaultWorkspaceSession()
|
||||
let remoteSession = remoteInitialSession
|
||||
const getWorkspaceSession = vi.fn((hostId?: string | null) =>
|
||||
hostId === `ssh:${connectionId}` ? remoteSession : localSession
|
||||
)
|
||||
const setWorkspaceSession = vi.fn((next: WorkspaceSessionState, hostId?: string | null) => {
|
||||
if (hostId !== `ssh:${connectionId}`) {
|
||||
throw new Error(`unexpected workspace-session host ${hostId ?? 'default'}`)
|
||||
}
|
||||
remoteSession = next
|
||||
})
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getFolderWorkspaces: () => [],
|
||||
getWorkspaceSession,
|
||||
getWorkspaceSessionHostIds: () => ['local', `ssh:${connectionId}`],
|
||||
setWorkspaceSession,
|
||||
flushOrThrow: vi.fn()
|
||||
} as never)
|
||||
runtime.setOrchestrationDb({
|
||||
listLegacyWorkerTerminalRecoveryRows: () => [
|
||||
{
|
||||
dispatch_id: 'dispatch-unresolved-folder',
|
||||
task_id: 'task-unresolved-folder',
|
||||
dispatch_status: 'completed',
|
||||
contract_version: 0,
|
||||
assignee_handle: 'term_unresolved_folder',
|
||||
assignee_pane_key: workerPaneKey,
|
||||
process_incarnation: 'pty-unresolved-folder:68686868-6868-4868-8868-686868686868',
|
||||
worker_state: 'ready',
|
||||
worktree_id: worktreeId,
|
||||
agent_terminal_handle: 'term_unresolved_folder'
|
||||
}
|
||||
]
|
||||
} as unknown as OrchestrationDb)
|
||||
|
||||
expect(runtime.prepareLegacyWorkerTerminalRecovery()).toMatchObject({
|
||||
blockedPanes: [expect.objectContaining({ paneKey: workerPaneKey, worktreeId })]
|
||||
})
|
||||
expect(
|
||||
remoteSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy
|
||||
).toBe('legacy-orchestration-worker')
|
||||
expect(localSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined()
|
||||
expect(setWorkspaceSession).toHaveBeenCalledOnce()
|
||||
expect(setWorkspaceSession).toHaveBeenCalledWith(expect.any(Object), `ssh:${connectionId}`)
|
||||
})
|
||||
})
|
||||
|
||||
+3
-7
@@ -153,11 +153,8 @@ describe('OrcaRuntimeService', () => {
|
||||
deferredDispatchIds: ['dispatch-ssh']
|
||||
})
|
||||
expect(listProcesses).not.toHaveBeenCalled()
|
||||
expect(
|
||||
getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy
|
||||
).toBe('legacy-orchestration-worker')
|
||||
expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined()
|
||||
expect(localSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined()
|
||||
expect(getWorkspaceSession).toHaveBeenCalledWith(`ssh:${connectionId}`)
|
||||
|
||||
await expect(
|
||||
runtime.reconcileLegacyWorkerTerminals({
|
||||
@@ -175,6 +172,7 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
|
||||
expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined()
|
||||
expect(getWorkspaceSession).toHaveBeenCalledWith(`ssh:${connectionId}`)
|
||||
expect(setWorkspaceSession).toHaveBeenCalledWith(expect.any(Object), `ssh:${connectionId}`)
|
||||
expect(listProcesses).toHaveBeenCalledTimes(3)
|
||||
expect(revealTerminalSession).toHaveBeenCalledWith(TEST_WORKTREE_ID, {
|
||||
@@ -297,9 +295,7 @@ describe('OrcaRuntimeService', () => {
|
||||
exitedDispatchIds: [],
|
||||
deferredDispatchIds: ['dispatch-wsl']
|
||||
})
|
||||
expect(
|
||||
getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy
|
||||
).toBe('legacy-orchestration-worker')
|
||||
expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined()
|
||||
expect(revealTerminalSession).not.toHaveBeenCalled()
|
||||
|
||||
observedDistro = 'Ubuntu'
|
||||
|
||||
@@ -7,9 +7,13 @@ type PointerTarget = { ptyId: string; processIncarnation: string }
|
||||
|
||||
// The slice of the mailbox store the pointer batch selector depends on.
|
||||
type PointerStore = {
|
||||
insertMessage(message: { from: string; to: string; subject: string; type?: MessageType }): {
|
||||
id: string
|
||||
}
|
||||
insertMessage(message: {
|
||||
runId: string
|
||||
from: string
|
||||
to: string
|
||||
subject: string
|
||||
type?: MessageType
|
||||
}): { id: string }
|
||||
stageMailboxPointerEnter(ids: string[], target: PointerTarget): boolean
|
||||
markMailboxPointerWriteAttempted(ids: string[], target: PointerTarget): boolean
|
||||
getUndeliveredUnreadMessages(
|
||||
@@ -32,7 +36,12 @@ describe.each(STORES)('mailbox pointer reservations (%s)', (_name, createStore)
|
||||
|
||||
it('refuses a claim another flight already holds', () => {
|
||||
const store = createStore()
|
||||
const message = store.insertMessage({ from: 'a', to: 'run:run-1', subject: 'contended' })
|
||||
const message = store.insertMessage({
|
||||
runId: 'run_legacy_local',
|
||||
from: 'a',
|
||||
to: 'run:run-1',
|
||||
subject: 'contended'
|
||||
})
|
||||
|
||||
expect(store.stageMailboxPointerEnter([message.id], rival)).toBe(true)
|
||||
expect(store.stageMailboxPointerEnter([message.id], mine)).toBe(false)
|
||||
@@ -41,8 +50,18 @@ describe.each(STORES)('mailbox pointer reservations (%s)', (_name, createStore)
|
||||
|
||||
it('rolls the whole batch back when one row is already claimed', () => {
|
||||
const store = createStore()
|
||||
const free = store.insertMessage({ from: 'a', to: 'run:run-1', subject: 'free' })
|
||||
const taken = store.insertMessage({ from: 'a', to: 'run:run-1', subject: 'taken' })
|
||||
const free = store.insertMessage({
|
||||
runId: 'run_legacy_local',
|
||||
from: 'a',
|
||||
to: 'run:run-1',
|
||||
subject: 'free'
|
||||
})
|
||||
const taken = store.insertMessage({
|
||||
runId: 'run_legacy_local',
|
||||
from: 'a',
|
||||
to: 'run:run-1',
|
||||
subject: 'taken'
|
||||
})
|
||||
expect(store.stageMailboxPointerEnter([taken.id], rival)).toBe(true)
|
||||
|
||||
expect(store.stageMailboxPointerEnter([free.id, taken.id], mine)).toBe(false)
|
||||
@@ -52,8 +71,19 @@ describe.each(STORES)('mailbox pointer reservations (%s)', (_name, createStore)
|
||||
|
||||
it('applies the exclusion and limit the pointer batch selector relies on', () => {
|
||||
const store = createStore()
|
||||
store.insertMessage({ from: 'a', to: 'run:run-1', subject: 'reserved', type: 'escalation' })
|
||||
const kept = store.insertMessage({ from: 'a', to: 'run:run-1', subject: 'kept' })
|
||||
store.insertMessage({
|
||||
runId: 'run_legacy_local',
|
||||
from: 'a',
|
||||
to: 'run:run-1',
|
||||
subject: 'reserved',
|
||||
type: 'escalation'
|
||||
})
|
||||
const kept = store.insertMessage({
|
||||
runId: 'run_legacy_local',
|
||||
from: 'a',
|
||||
to: 'run:run-1',
|
||||
subject: 'kept'
|
||||
})
|
||||
|
||||
expect(
|
||||
store
|
||||
|
||||
@@ -12,13 +12,14 @@ describe('coordinator decision-gate authority', () => {
|
||||
|
||||
it('opens a gate only for the sender-owned active Dispatch', () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
const task = db.createTask({ spec: 'owned gate target' })
|
||||
const task = db.createTask({ runId: 'run_legacy_local', spec: 'owned gate target' })
|
||||
const dispatch = createRootDispatch(db, task.id, 'term_owner', 'tab_owner:leaf_owner')
|
||||
const logs: string[] = []
|
||||
|
||||
openDecisionGateFromMessage(
|
||||
db,
|
||||
db.insertMessage({
|
||||
runId: 'run_legacy_local',
|
||||
from: 'term_owner',
|
||||
to: 'term_coordinator',
|
||||
subject: 'Need approval',
|
||||
@@ -41,20 +42,27 @@ describe('coordinator decision-gate authority', () => {
|
||||
|
||||
it('rejects a gate targeting another active Dispatch without mutating either Task', () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
const attackerTask = db.createTask({ spec: 'attacker assignment' })
|
||||
const attackerTask = db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'attacker assignment'
|
||||
})
|
||||
const attacker = createRootDispatch(
|
||||
db,
|
||||
attackerTask.id,
|
||||
'term_attacker',
|
||||
'tab_attacker:leaf_attacker'
|
||||
)
|
||||
const victimTask = db.createTask({ spec: 'victim assignment' })
|
||||
const victimTask = db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'victim assignment'
|
||||
})
|
||||
const victim = createRootDispatch(db, victimTask.id, 'term_victim', 'tab_victim:leaf_victim')
|
||||
const logs: string[] = []
|
||||
|
||||
openDecisionGateFromMessage(
|
||||
db,
|
||||
db.insertMessage({
|
||||
runId: 'run_legacy_local',
|
||||
from: 'term_attacker',
|
||||
to: 'term_coordinator',
|
||||
subject: 'Block the victim',
|
||||
@@ -79,12 +87,16 @@ describe('coordinator decision-gate authority', () => {
|
||||
|
||||
it('accepts the canonical sender of an imported federated Dispatch', () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
const task = db.createTask({ spec: 'remote gate target' })
|
||||
const task = db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'remote gate target'
|
||||
})
|
||||
const dispatch = createRootDispatch(db, task.id, 'remote-worker')
|
||||
|
||||
openDecisionGateFromMessage(
|
||||
db,
|
||||
db.insertMessage({
|
||||
runId: 'run_legacy_local',
|
||||
from: `dispatch:${dispatch.id}`,
|
||||
to: 'term_coordinator',
|
||||
subject: 'Remote approval required',
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('coordinator dispatch with an unobserved prompt', () => {
|
||||
|
||||
it('never re-pastes a preamble whose turn start was not observed', async () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
const task = db.createTask({ spec: 'do the work' })
|
||||
const task = db.createTask({ runId: 'run_legacy_local', spec: 'do the work' })
|
||||
const runtime = createRuntime(new Error('agent_prompt_stalled'))
|
||||
const logs: string[] = []
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('coordinator dispatch with an unobserved prompt', () => {
|
||||
|
||||
it('lets a late worker report settle a dispatch whose prompt was unobserved', async () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
const task = db.createTask({ spec: 'do the work' })
|
||||
const task = db.createTask({ runId: 'run_legacy_local', spec: 'do the work' })
|
||||
await dispatch(createRuntime(new Error('agent_prompt_stalled')), task.id, [])
|
||||
const dispatchId = db.getDispatchContext(task.id)!.id
|
||||
const minted = db.mintDispatchCapability({
|
||||
@@ -119,7 +119,7 @@ describe('coordinator dispatch with an unobserved prompt', () => {
|
||||
|
||||
it('still fails the dispatch when the prompt was never delivered', async () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
const task = db.createTask({ spec: 'do the work' })
|
||||
const task = db.createTask({ runId: 'run_legacy_local', spec: 'do the work' })
|
||||
const runtime = createRuntime(new Error('terminal_not_writable'))
|
||||
|
||||
await expect(dispatch(runtime, task.id, [])).rejects.toThrow('terminal_not_writable')
|
||||
|
||||
@@ -44,8 +44,8 @@ describe('Coordinator drift probe coalescing', () => {
|
||||
: { base: 'origin/main', behind: 0, recentSubjects: [] }
|
||||
}
|
||||
}
|
||||
const first = db.createTask({ spec: 'first task' })
|
||||
const second = db.createTask({ spec: 'second task' })
|
||||
const first = db.createTask({ runId: 'run_legacy_local', spec: 'first task' })
|
||||
const second = db.createTask({ runId: 'run_legacy_local', spec: 'second task' })
|
||||
const coordinator = new Coordinator(db, runtime, {
|
||||
spec: 'go',
|
||||
coordinatorHandle: 'coord',
|
||||
@@ -65,6 +65,7 @@ describe('Coordinator drift probe coalescing', () => {
|
||||
throw new Error(`missing dispatch for ${task.id}`)
|
||||
}
|
||||
db.insertMessage({
|
||||
runId: 'run_legacy_local',
|
||||
from: dispatch.assignee_handle,
|
||||
to: 'coord',
|
||||
subject: 'Done',
|
||||
@@ -105,8 +106,14 @@ describe('Coordinator drift probe coalescing', () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
const refused = db.createTask({ spec: 'requires a current base' })
|
||||
const allowed = db.createTask({ spec: 'can use stale base\nallow-stale-base: true' })
|
||||
const refused = db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'requires a current base'
|
||||
})
|
||||
const allowed = db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'can use stale base\nallow-stale-base: true'
|
||||
})
|
||||
const coordinator = new Coordinator(db, runtime, {
|
||||
spec: 'go',
|
||||
coordinatorHandle: 'coord',
|
||||
|
||||
@@ -12,20 +12,21 @@ describe('coordinator escalation authority', () => {
|
||||
|
||||
it('rejects an escalation targeting another active Dispatch', () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
const attackerTask = db.createTask({ spec: 'attacker assignment' })
|
||||
const attackerTask = db.createTask({ runId: 'run_legacy_local', spec: 'attacker assignment' })
|
||||
const attacker = createRootDispatch(
|
||||
db,
|
||||
attackerTask.id,
|
||||
'term_attacker',
|
||||
'tab_attacker:leaf_attacker'
|
||||
)
|
||||
const victimTask = db.createTask({ spec: 'victim assignment' })
|
||||
const victimTask = db.createTask({ runId: 'run_legacy_local', spec: 'victim assignment' })
|
||||
const victim = createRootDispatch(db, victimTask.id, 'term_victim')
|
||||
const logs: string[] = []
|
||||
|
||||
applyEscalationToDispatch(
|
||||
db,
|
||||
db.insertMessage({
|
||||
runId: 'run_legacy_local',
|
||||
from: 'term_attacker',
|
||||
to: 'term_coordinator',
|
||||
subject: 'Fail the victim',
|
||||
@@ -43,12 +44,13 @@ describe('coordinator escalation authority', () => {
|
||||
|
||||
it('accepts the canonical sender of an imported federated Dispatch', () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
const task = db.createTask({ spec: 'remote escalation target' })
|
||||
const task = db.createTask({ runId: 'run_legacy_local', spec: 'remote escalation target' })
|
||||
const dispatch = createRootDispatch(db, task.id, 'remote-worker')
|
||||
|
||||
applyEscalationToDispatch(
|
||||
db,
|
||||
db.insertMessage({
|
||||
runId: 'run_legacy_local',
|
||||
from: `dispatch:${dispatch.id}`,
|
||||
to: 'term_coordinator',
|
||||
subject: 'Remote worker failed',
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseAllowStaleBaseFromSpec } from './coordinator-stale-base-flag'
|
||||
|
||||
describe('parseAllowStaleBaseFromSpec', () => {
|
||||
it('matches canonical form on its own line and strips it', () => {
|
||||
const spec = `Do the work
|
||||
allow-stale-base: true`
|
||||
const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(spec)
|
||||
expect(allowStale).toBe(true)
|
||||
expect(strippedSpec).toBe('Do the work\n')
|
||||
expect(strippedSpec).not.toContain('allow-stale-base')
|
||||
})
|
||||
|
||||
it('matches case-insensitively', () => {
|
||||
const spec = `Do the work
|
||||
Allow-Stale-Base: TRUE`
|
||||
const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(spec)
|
||||
expect(allowStale).toBe(true)
|
||||
expect(strippedSpec).not.toMatch(/[Aa]llow-[Ss]tale-[Bb]ase/)
|
||||
})
|
||||
|
||||
it('does not match allow-stale-base: false', () => {
|
||||
const spec = `Do the work
|
||||
allow-stale-base: false`
|
||||
const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(spec)
|
||||
expect(allowStale).toBe(false)
|
||||
expect(strippedSpec).toBe(spec)
|
||||
})
|
||||
|
||||
it('does not match allow-stale-base: truthy', () => {
|
||||
const spec = `Do the work
|
||||
allow-stale-base: truthy`
|
||||
const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(spec)
|
||||
expect(allowStale).toBe(false)
|
||||
expect(strippedSpec).toBe(spec)
|
||||
})
|
||||
|
||||
it('does not match the flag embedded inside a sentence', () => {
|
||||
const spec = 'we allow-stale-base: true sometimes'
|
||||
const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(spec)
|
||||
expect(allowStale).toBe(false)
|
||||
expect(strippedSpec).toBe(spec)
|
||||
})
|
||||
|
||||
it('handles the flag as the last line with no trailing newline', () => {
|
||||
const spec = 'line 1\nallow-stale-base: true'
|
||||
const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(spec)
|
||||
expect(allowStale).toBe(true)
|
||||
expect(strippedSpec).toBe('line 1\n')
|
||||
expect(strippedSpec.endsWith('allow-stale-base: true')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -3,12 +3,11 @@ import { OrchestrationDb } from './db'
|
||||
import { reconcileLifecycleMessage } from './lifecycle-reconciliation'
|
||||
import { Coordinator } from './coordinator'
|
||||
import type { CoordinatorRuntime } from './coordinator-runtime-contract'
|
||||
import {
|
||||
DISPATCH_STALE_THRESHOLD,
|
||||
parseAllowStaleBaseFromSpec
|
||||
} from './coordinator-stale-base-flag'
|
||||
import { DISPATCH_STALE_THRESHOLD } from './coordinator-stale-base-flag'
|
||||
import { createRootDispatch } from './db/root-dispatch-test-fixture'
|
||||
|
||||
const runId = 'run_legacy_local'
|
||||
|
||||
type DriftResult = {
|
||||
base: string
|
||||
behind: number
|
||||
@@ -92,6 +91,7 @@ function insertWorkerDone(
|
||||
}
|
||||
const from = params.from ?? dispatch?.assignee_handle ?? 'term_unknown'
|
||||
db.insertMessage({
|
||||
runId,
|
||||
from,
|
||||
to: params.to ?? 'coord',
|
||||
subject: 'Done',
|
||||
@@ -131,7 +131,10 @@ describe('Coordinator', () => {
|
||||
runtime.cliCommand = 'orca-ide'
|
||||
runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }]
|
||||
|
||||
const task = db.createTask({ spec: 'implement feature' })
|
||||
const task = db.createTask({
|
||||
runId,
|
||||
spec: 'implement feature'
|
||||
})
|
||||
|
||||
// Simulate worker_done arriving after dispatch
|
||||
const coordinator = new Coordinator(db, runtime, {
|
||||
@@ -166,7 +169,10 @@ describe('Coordinator', () => {
|
||||
getTerminalPaneKey: (handle: string) => (handle === 'term_a' ? 'tab_a:leaf_a' : null)
|
||||
})
|
||||
|
||||
const task = db.createTask({ spec: 'implement feature' })
|
||||
const task = db.createTask({
|
||||
runId,
|
||||
spec: 'implement feature'
|
||||
})
|
||||
const coordinator = new Coordinator(db, withPaneLookup, {
|
||||
spec: 'build it',
|
||||
coordinatorHandle: 'coord',
|
||||
@@ -198,7 +204,10 @@ describe('Coordinator', () => {
|
||||
}
|
||||
: null
|
||||
})
|
||||
const task = db.createTask({ spec: 'implement feature' })
|
||||
const task = db.createTask({
|
||||
runId,
|
||||
spec: 'implement feature'
|
||||
})
|
||||
const coordinator = new Coordinator(db, withAuthority, {
|
||||
spec: 'build it',
|
||||
coordinatorHandle: 'coord',
|
||||
@@ -223,9 +232,13 @@ describe('Coordinator', () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
const runtime = createMockRuntime()
|
||||
|
||||
const task = db.createTask({ spec: 'send-driven completion' })
|
||||
const task = db.createTask({
|
||||
runId,
|
||||
spec: 'send-driven completion'
|
||||
})
|
||||
const dispatch = createRootDispatch(db, task.id, 'term_a')
|
||||
const msg = db.insertMessage({
|
||||
runId,
|
||||
from: 'term_a',
|
||||
to: 'coord',
|
||||
subject: 'Done',
|
||||
@@ -250,7 +263,10 @@ describe('Coordinator', () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
const runtime = createMockRuntime()
|
||||
|
||||
const task = db.createTask({ spec: 'duplicate completion' })
|
||||
const task = db.createTask({
|
||||
runId,
|
||||
spec: 'duplicate completion'
|
||||
})
|
||||
const dispatch = createRootDispatch(db, task.id, 'term_a')
|
||||
const payload = JSON.stringify({
|
||||
taskId: task.id,
|
||||
@@ -258,6 +274,7 @@ describe('Coordinator', () => {
|
||||
outcome: 'succeeded'
|
||||
})
|
||||
const first = db.insertMessage({
|
||||
runId,
|
||||
from: 'term_a',
|
||||
to: 'coord',
|
||||
subject: 'Done',
|
||||
@@ -265,6 +282,7 @@ describe('Coordinator', () => {
|
||||
payload
|
||||
})
|
||||
db.insertMessage({
|
||||
runId,
|
||||
from: 'term_a',
|
||||
to: 'coord',
|
||||
subject: 'Done again',
|
||||
@@ -289,7 +307,7 @@ describe('Coordinator', () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
const runtime = createMockRuntime()
|
||||
|
||||
const task = db.createTask({ spec: 'work' })
|
||||
const task = db.createTask({ runId, spec: 'work' })
|
||||
|
||||
const coordinator = new Coordinator(db, runtime, {
|
||||
spec: 'go',
|
||||
@@ -321,7 +339,10 @@ describe('Coordinator', () => {
|
||||
{ handle: 'term_b', worktreeId: 'wt1', connected: true, writable: true }
|
||||
]
|
||||
|
||||
const task = db.createTask({ spec: 'risky work' })
|
||||
const task = db.createTask({
|
||||
runId,
|
||||
spec: 'risky work'
|
||||
})
|
||||
|
||||
const coordinator = new Coordinator(db, runtime, {
|
||||
spec: 'go',
|
||||
@@ -339,6 +360,7 @@ describe('Coordinator', () => {
|
||||
const dispatch = db.getDispatchContext(task.id)
|
||||
expect(dispatch).toBeDefined()
|
||||
db.insertMessage({
|
||||
runId,
|
||||
from: dispatch?.assignee_handle ?? 'missing-worker',
|
||||
to: 'coord',
|
||||
subject: `Failed attempt ${i + 1}`,
|
||||
@@ -360,7 +382,10 @@ describe('Coordinator', () => {
|
||||
throw new Error('terminal_not_writable')
|
||||
}
|
||||
|
||||
const task = db.createTask({ spec: 'cannot dispatch' })
|
||||
const task = db.createTask({
|
||||
runId,
|
||||
spec: 'cannot dispatch'
|
||||
})
|
||||
const coordinator = new Coordinator(db, runtime, {
|
||||
spec: 'go',
|
||||
coordinatorHandle: 'coord',
|
||||
@@ -379,7 +404,10 @@ describe('Coordinator', () => {
|
||||
const runtime = createMockRuntime()
|
||||
runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }]
|
||||
|
||||
const task = db.createTask({ spec: 'needs approval' })
|
||||
const task = db.createTask({
|
||||
runId,
|
||||
spec: 'needs approval'
|
||||
})
|
||||
|
||||
const coordinator = new Coordinator(db, runtime, {
|
||||
spec: 'go',
|
||||
@@ -398,6 +426,7 @@ describe('Coordinator', () => {
|
||||
const dispatch = db.getDispatchContext(task.id)
|
||||
expect(dispatch).toBeDefined()
|
||||
db.insertMessage({
|
||||
runId,
|
||||
from: 'term_a',
|
||||
to: 'coord',
|
||||
subject: 'Need approval',
|
||||
@@ -441,8 +470,12 @@ describe('Coordinator', () => {
|
||||
const runtime = createMockRuntime()
|
||||
runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }]
|
||||
|
||||
const t1 = db.createTask({ spec: 'first' })
|
||||
const t2 = db.createTask({ spec: 'second', deps: [t1.id] })
|
||||
const t1 = db.createTask({ runId, spec: 'first' })
|
||||
const t2 = db.createTask({
|
||||
runId,
|
||||
spec: 'second',
|
||||
deps: [t1.id]
|
||||
})
|
||||
|
||||
expect(t2.status).toBe('pending')
|
||||
|
||||
@@ -492,9 +525,9 @@ describe('Coordinator', () => {
|
||||
{ handle: 'term_c', worktreeId: 'wt1', connected: true, writable: true }
|
||||
]
|
||||
|
||||
const t1 = db.createTask({ spec: 'one' })
|
||||
const t2 = db.createTask({ spec: 'two' })
|
||||
const t3 = db.createTask({ spec: 'three' })
|
||||
const t1 = db.createTask({ runId, spec: 'one' })
|
||||
const t2 = db.createTask({ runId, spec: 'two' })
|
||||
const t3 = db.createTask({ runId, spec: 'three' })
|
||||
|
||||
const coordinator = new Coordinator(db, runtime, {
|
||||
spec: 'go',
|
||||
@@ -530,7 +563,7 @@ describe('Coordinator', () => {
|
||||
const runtime = createMockRuntime()
|
||||
// No terminals available so dispatchReadyTasks creates one and we can
|
||||
// drive the stale-scan deterministically via SQL backdating.
|
||||
const task = db.createTask({ spec: 'work' })
|
||||
const task = db.createTask({ runId, spec: 'work' })
|
||||
const ctx = createRootDispatch(db, task.id, 'term_stale')
|
||||
|
||||
// Backdate dispatched_at and last_heartbeat_at beyond the 10-min threshold
|
||||
@@ -569,7 +602,7 @@ describe('Coordinator', () => {
|
||||
const runtime = createMockRuntime()
|
||||
runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }]
|
||||
|
||||
const task = db.createTask({ spec: 'work' })
|
||||
const task = db.createTask({ runId, spec: 'work' })
|
||||
const ctx = createRootDispatch(db, task.id, 'term_a')
|
||||
|
||||
const coordinator = new Coordinator(db, runtime, {
|
||||
@@ -581,6 +614,7 @@ describe('Coordinator', () => {
|
||||
const runPromise = coordinator.run()
|
||||
|
||||
db.insertMessage({
|
||||
runId,
|
||||
from: 'term_a',
|
||||
to: 'coord',
|
||||
subject: 'alive',
|
||||
@@ -606,12 +640,16 @@ describe('Coordinator', () => {
|
||||
const runtime = createMockRuntime()
|
||||
const logs: string[] = []
|
||||
|
||||
const task = db.createTask({ spec: 'retry-sensitive work' })
|
||||
const task = db.createTask({
|
||||
runId,
|
||||
spec: 'retry-sensitive work'
|
||||
})
|
||||
const staleCtx = createRootDispatch(db, task.id, 'term_old')
|
||||
db.failDispatch(staleCtx.id, 'retry elsewhere')
|
||||
const activeCtx = createRootDispatch(db, task.id, 'term_current')
|
||||
|
||||
db.insertMessage({
|
||||
runId,
|
||||
from: 'term_old',
|
||||
to: 'coord',
|
||||
subject: 'Late done',
|
||||
@@ -663,11 +701,15 @@ describe('Coordinator', () => {
|
||||
const runtime = createMockRuntime()
|
||||
const logs: string[] = []
|
||||
|
||||
const task = db.createTask({ spec: 'owned work' })
|
||||
const task = db.createTask({
|
||||
runId,
|
||||
spec: 'owned work'
|
||||
})
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
const ctx = createRootDispatch(db, task.id, 'term_owner', `tab_before:${leafId}`)
|
||||
|
||||
db.insertMessage({
|
||||
runId,
|
||||
from: 'term_reminted',
|
||||
to: 'coord',
|
||||
subject: 'Done after restart',
|
||||
@@ -693,7 +735,7 @@ describe('Coordinator', () => {
|
||||
it('can be stopped', async () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
const runtime = createMockRuntime()
|
||||
db.createTask({ spec: 'never finishes' })
|
||||
db.createTask({ runId, spec: 'never finishes' })
|
||||
|
||||
const coordinator = new Coordinator(db, runtime, {
|
||||
spec: 'go',
|
||||
@@ -723,7 +765,10 @@ describe('Coordinator', () => {
|
||||
recentSubjects: ['fix A', 'fix B', 'fix C']
|
||||
})
|
||||
|
||||
const task = db.createTask({ spec: 'do the work' })
|
||||
const task = db.createTask({
|
||||
runId,
|
||||
spec: 'do the work'
|
||||
})
|
||||
|
||||
const coordinator = new Coordinator(db, runtime, {
|
||||
spec: 'go',
|
||||
@@ -759,7 +804,10 @@ describe('Coordinator', () => {
|
||||
recentSubjects: ['fix A']
|
||||
})
|
||||
|
||||
const task = db.createTask({ spec: 'do the work' })
|
||||
const task = db.createTask({
|
||||
runId,
|
||||
spec: 'do the work'
|
||||
})
|
||||
|
||||
const coordinator = new Coordinator(db, runtime, {
|
||||
spec: 'go',
|
||||
@@ -799,7 +847,7 @@ describe('Coordinator', () => {
|
||||
|
||||
const spec = `Investigate issue #42
|
||||
allow-stale-base: true`
|
||||
const task = db.createTask({ spec })
|
||||
const task = db.createTask({ runId, spec })
|
||||
|
||||
const coordinator = new Coordinator(db, runtime, {
|
||||
spec: 'go',
|
||||
@@ -832,7 +880,10 @@ allow-stale-base: true`
|
||||
runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }]
|
||||
runtime.setProbeDrift(null)
|
||||
|
||||
const task = db.createTask({ spec: 'do the work' })
|
||||
const task = db.createTask({
|
||||
runId,
|
||||
spec: 'do the work'
|
||||
})
|
||||
|
||||
const coordinator = new Coordinator(db, runtime, {
|
||||
spec: 'go',
|
||||
@@ -861,7 +912,10 @@ allow-stale-base: true`
|
||||
runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }]
|
||||
const logs: string[] = []
|
||||
|
||||
const task = db.createTask({ spec: 'do the work' })
|
||||
const task = db.createTask({
|
||||
runId,
|
||||
spec: 'do the work'
|
||||
})
|
||||
|
||||
const coordinator = new Coordinator(db, runtime, {
|
||||
spec: 'go',
|
||||
@@ -892,7 +946,10 @@ allow-stale-base: true`
|
||||
runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }]
|
||||
runtime.throwProbeDrift = new Error('boom')
|
||||
|
||||
const task = db.createTask({ spec: 'do the work' })
|
||||
const task = db.createTask({
|
||||
runId,
|
||||
spec: 'do the work'
|
||||
})
|
||||
|
||||
const coordinator = new Coordinator(db, runtime, {
|
||||
spec: 'go',
|
||||
@@ -915,53 +972,3 @@ allow-stale-base: true`
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAllowStaleBaseFromSpec', () => {
|
||||
it('matches canonical form on its own line and strips it', () => {
|
||||
const spec = `Do the work
|
||||
allow-stale-base: true`
|
||||
const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(spec)
|
||||
expect(allowStale).toBe(true)
|
||||
expect(strippedSpec).toBe('Do the work\n')
|
||||
expect(strippedSpec).not.toContain('allow-stale-base')
|
||||
})
|
||||
|
||||
it('matches case-insensitively', () => {
|
||||
const spec = `Do the work
|
||||
Allow-Stale-Base: TRUE`
|
||||
const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(spec)
|
||||
expect(allowStale).toBe(true)
|
||||
expect(strippedSpec).not.toMatch(/[Aa]llow-[Ss]tale-[Bb]ase/)
|
||||
})
|
||||
|
||||
it('does not match allow-stale-base: false', () => {
|
||||
const spec = `Do the work
|
||||
allow-stale-base: false`
|
||||
const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(spec)
|
||||
expect(allowStale).toBe(false)
|
||||
expect(strippedSpec).toBe(spec)
|
||||
})
|
||||
|
||||
it('does not match allow-stale-base: truthy', () => {
|
||||
const spec = `Do the work
|
||||
allow-stale-base: truthy`
|
||||
const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(spec)
|
||||
expect(allowStale).toBe(false)
|
||||
expect(strippedSpec).toBe(spec)
|
||||
})
|
||||
|
||||
it('does not match the flag embedded inside a sentence', () => {
|
||||
const spec = 'we allow-stale-base: true sometimes'
|
||||
const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(spec)
|
||||
expect(allowStale).toBe(false)
|
||||
expect(strippedSpec).toBe(spec)
|
||||
})
|
||||
|
||||
it('handles the flag as the last line with no trailing newline', () => {
|
||||
const spec = 'line 1\nallow-stale-base: true'
|
||||
const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(spec)
|
||||
expect(allowStale).toBe(true)
|
||||
expect(strippedSpec).toBe('line 1\n')
|
||||
expect(strippedSpec.endsWith('allow-stale-base: true')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('orchestration empty-dispatch short-circuit (benchmark)', () => {
|
||||
|
||||
it('still runs the fan-out once a dispatch exists (correctness preserved)', () => {
|
||||
const db = new OrchestrationDb(':memory:')
|
||||
const task = db.createTask({ spec: 'work' })
|
||||
const task = db.createTask({ runId: 'run_legacy_local', spec: 'work' })
|
||||
createRootDispatch(db, task.id, 'term_5')
|
||||
const handles = Array.from({ length: 10 }, (_, i) => `term_${i}`)
|
||||
|
||||
@@ -76,7 +76,11 @@ describe('orchestration empty-dispatch short-circuit (benchmark)', () => {
|
||||
it('predicate lifecycle: false when empty, true after dispatch (even completed), false after reset', () => {
|
||||
const db = new OrchestrationDb(':memory:')
|
||||
expect(db.hasAnyDispatchContexts()).toBe(false)
|
||||
const ctx = createRootDispatch(db, db.createTask({ spec: 'work' }).id, 'term_worker')
|
||||
const ctx = createRootDispatch(
|
||||
db,
|
||||
db.createTask({ runId: 'run_legacy_local', spec: 'work' }).id,
|
||||
'term_worker'
|
||||
)
|
||||
expect(db.hasAnyDispatchContexts()).toBe(true)
|
||||
// Completed rows still count — recent-completed lookups must stay valid.
|
||||
db.completeDispatch(ctx.id)
|
||||
|
||||
@@ -13,7 +13,7 @@ afterEach(() => {
|
||||
function seedHeartbeatedDispatch(): { d: OrchestrationDb; dispatchId: string } {
|
||||
const d = new OrchestrationDb(':memory:')
|
||||
db = d
|
||||
const task = d.createTask({ spec: 'work' })
|
||||
const task = d.createTask({ runId: 'run_legacy_local', spec: 'work' })
|
||||
const dispatch = createRootDispatch(d, task.id, 'term_worker')
|
||||
d.recordHeartbeat(dispatch.id, '2026-05-03T00:00:00.000Z')
|
||||
return { d, dispatchId: dispatch.id }
|
||||
|
||||
@@ -8,7 +8,12 @@ describe('orchestration message timestamps', () => {
|
||||
|
||||
it('exposes SQLite timestamps with an explicit UTC designator', () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
const message = db.insertMessage({ from: 'a', to: 'b', subject: 'timestamped' })
|
||||
const message = db.insertMessage({
|
||||
runId: 'run_legacy_local',
|
||||
from: 'a',
|
||||
to: 'b',
|
||||
subject: 'timestamped'
|
||||
})
|
||||
|
||||
expect(message.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/)
|
||||
db.markAsDelivered([message.id])
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
import { OrchestrationDb, type MessageType } from './db'
|
||||
|
||||
const runId = 'run_legacy_local'
|
||||
|
||||
describe('OrchestrationDb', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
|
||||
afterEach(() => {
|
||||
db?.close()
|
||||
})
|
||||
|
||||
function createDb(): OrchestrationDb {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
return db
|
||||
}
|
||||
|
||||
describe('messages', () => {
|
||||
it('inserts and retrieves a message', () => {
|
||||
const d = createDb()
|
||||
const msg = d.insertMessage({
|
||||
runId,
|
||||
from: 'term_a',
|
||||
to: 'term_b',
|
||||
subject: 'hello',
|
||||
body: 'world'
|
||||
})
|
||||
expect(msg.id).toMatch(/^msg_/)
|
||||
expect(msg.from_handle).toBe('term_a')
|
||||
expect(msg.to_handle).toBe('term_b')
|
||||
expect(msg.subject).toBe('hello')
|
||||
expect(msg.body).toBe('world')
|
||||
expect(msg.type).toBe('status')
|
||||
expect(msg.priority).toBe('normal')
|
||||
expect(msg.read).toBe(0)
|
||||
expect(msg.sequence).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('returns unread messages in sequence order', () => {
|
||||
const d = createDb()
|
||||
d.insertMessage({ runId, from: 'a', to: 'b', subject: 'first' })
|
||||
d.insertMessage({ runId, from: 'a', to: 'b', subject: 'second' })
|
||||
d.insertMessage({ runId, from: 'a', to: 'c', subject: 'other' })
|
||||
|
||||
const unread = d.getUnreadMessages('b')
|
||||
expect(unread).toHaveLength(2)
|
||||
expect(unread[0].subject).toBe('first')
|
||||
expect(unread[1].subject).toBe('second')
|
||||
})
|
||||
|
||||
it('filters unread by type', () => {
|
||||
const d = createDb()
|
||||
d.insertMessage({
|
||||
runId,
|
||||
from: 'a',
|
||||
to: 'b',
|
||||
subject: 'status msg',
|
||||
type: 'status'
|
||||
})
|
||||
d.insertMessage({
|
||||
runId,
|
||||
from: 'a',
|
||||
to: 'b',
|
||||
subject: 'done msg',
|
||||
type: 'worker_done'
|
||||
})
|
||||
|
||||
const filtered = d.getUnreadMessages('b', ['worker_done'])
|
||||
expect(filtered).toHaveLength(1)
|
||||
expect(filtered[0].type).toBe('worker_done')
|
||||
})
|
||||
|
||||
it('excludes already-delivered rows from getUndeliveredUnreadMessages', () => {
|
||||
const d = createDb()
|
||||
const m1 = d.insertMessage({ runId, from: 'a', to: 'b', subject: 'one' })
|
||||
const m2 = d.insertMessage({ runId, from: 'a', to: 'b', subject: 'two' })
|
||||
|
||||
d.markAsDelivered([m1.id])
|
||||
|
||||
// Push delivery query: only undelivered, unread.
|
||||
const pending = d.getUndeliveredUnreadMessages('b')
|
||||
expect(pending).toHaveLength(1)
|
||||
expect(pending[0].id).toBe(m2.id)
|
||||
|
||||
// Explicit `check` still sees both (they are still unread).
|
||||
const unread = d.getUnreadMessages('b')
|
||||
expect(unread).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('creates the undelivered inbox index used by push delivery', () => {
|
||||
const d = createDb()
|
||||
const sqlite = (d as unknown as { db: Database.Database }).db
|
||||
|
||||
const indexes = sqlite
|
||||
.prepare(
|
||||
`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'messages' AND name = 'idx_messages_undelivered_inbox'`
|
||||
)
|
||||
.all()
|
||||
|
||||
expect(indexes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('filters getUndeliveredUnreadMessages by type', () => {
|
||||
const d = createDb()
|
||||
d.insertMessage({
|
||||
runId,
|
||||
from: 'a',
|
||||
to: 'b',
|
||||
subject: 's',
|
||||
type: 'status'
|
||||
})
|
||||
const wd = d.insertMessage({
|
||||
runId,
|
||||
from: 'a',
|
||||
to: 'b',
|
||||
subject: 'd',
|
||||
type: 'worker_done'
|
||||
})
|
||||
|
||||
const filtered = d.getUndeliveredUnreadMessages('b', ['worker_done'])
|
||||
expect(filtered).toHaveLength(1)
|
||||
expect(filtered[0].id).toBe(wd.id)
|
||||
})
|
||||
|
||||
it('marks messages as read', () => {
|
||||
const d = createDb()
|
||||
const m1 = d.insertMessage({ runId, from: 'a', to: 'b', subject: 'one' })
|
||||
const m2 = d.insertMessage({ runId, from: 'a', to: 'b', subject: 'two' })
|
||||
|
||||
d.markAsRead([m1.id])
|
||||
|
||||
const unread = d.getUnreadMessages('b')
|
||||
expect(unread).toHaveLength(1)
|
||||
expect(unread[0].id).toBe(m2.id)
|
||||
})
|
||||
|
||||
it('stores typed payload and thread_id', () => {
|
||||
const d = createDb()
|
||||
const payload = JSON.stringify({ taskId: 'task_abc', filesModified: ['src/a.ts'] })
|
||||
const msg = d.insertMessage({
|
||||
runId,
|
||||
from: 'a',
|
||||
to: 'b',
|
||||
subject: 'done',
|
||||
type: 'worker_done',
|
||||
priority: 'high',
|
||||
threadId: 'thread_1',
|
||||
payload
|
||||
})
|
||||
|
||||
expect(msg.type).toBe('worker_done')
|
||||
expect(msg.priority).toBe('high')
|
||||
expect(msg.thread_id).toBe('thread_1')
|
||||
expect(msg.payload).toBe(payload)
|
||||
})
|
||||
|
||||
it('rejects invalid message type', () => {
|
||||
const d = createDb()
|
||||
expect(() =>
|
||||
d.insertMessage({
|
||||
runId,
|
||||
from: 'a',
|
||||
to: 'b',
|
||||
subject: 'bad',
|
||||
type: 'invalid' as MessageType
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('getInbox returns all messages across recipients', () => {
|
||||
const d = createDb()
|
||||
d.insertMessage({ runId, from: 'a', to: 'b', subject: 'one' })
|
||||
d.insertMessage({ runId, from: 'a', to: 'c', subject: 'two' })
|
||||
d.insertMessage({ runId, from: 'b', to: 'a', subject: 'three' })
|
||||
|
||||
const inbox = d.getInbox(10)
|
||||
expect(inbox).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('getMessageById returns the correct message', () => {
|
||||
const d = createDb()
|
||||
const msg = d.insertMessage({
|
||||
runId,
|
||||
from: 'a',
|
||||
to: 'b',
|
||||
subject: 'test'
|
||||
})
|
||||
const found = d.getMessageById(msg.id)
|
||||
expect(found?.subject).toBe('test')
|
||||
expect(d.getMessageById('msg_nonexistent')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { OrchestrationDb } from './db'
|
||||
import { createRootDispatch } from './db/root-dispatch-test-fixture'
|
||||
|
||||
const PANE_W = 'tab_w:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
|
||||
describe('a Task whose supervised worker is stopping', () => {
|
||||
let db: OrchestrationDb
|
||||
beforeEach(() => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
})
|
||||
afterEach(() => db.close())
|
||||
|
||||
function localWorker() {
|
||||
const task = db.createTask({ runId: 'run_legacy_local', spec: 'local work' })
|
||||
const { dispatch } = db.createStartingWorkerDispatch({
|
||||
taskId: task.id,
|
||||
startOptions: {},
|
||||
creator: { kind: 'system' },
|
||||
maxDepth: 9
|
||||
})
|
||||
db.prepareStartingWorkerAuthority({
|
||||
dispatchId: dispatch.id,
|
||||
handle: 'term_w',
|
||||
paneKey: PANE_W,
|
||||
processIncarnation: 'inc1',
|
||||
worktreeId: 'wt',
|
||||
effects: [],
|
||||
setupState: 'not_configured'
|
||||
})
|
||||
db.markWorkerDispatchReady(dispatch.id)
|
||||
return { task, dispatch }
|
||||
}
|
||||
|
||||
describe('task-update', () => {
|
||||
it('refuses to re-open the Task while the worker is stopping', () => {
|
||||
const { task, dispatch } = localWorker()
|
||||
db.beginWorkerStop(dispatch.id, 'epoch_home')
|
||||
expect(db.getTask(task.id)?.status).toBe('blocked')
|
||||
|
||||
expect(() => db.updateTaskStatus(task.id, 'dispatched')).toThrowError(
|
||||
expect.objectContaining({
|
||||
code: 'task_not_startable',
|
||||
data: { taskId: task.id, dispatchId: dispatch.id }
|
||||
})
|
||||
)
|
||||
expect(db.getTask(task.id)?.status).toBe('blocked')
|
||||
})
|
||||
|
||||
it('refuses to re-open the Task while the stop outcome is unknown', () => {
|
||||
const { task, dispatch } = localWorker()
|
||||
db.beginWorkerStop(dispatch.id, 'epoch_home')
|
||||
db.markWorkerStopUnknown(dispatch.id, 'the execution host did not answer')
|
||||
|
||||
expect(() => db.updateTaskStatus(task.id, 'dispatched')).toThrowError(
|
||||
expect.objectContaining({ code: 'task_not_startable' })
|
||||
)
|
||||
expect(db.getTask(task.id)?.status).toBe('blocked')
|
||||
})
|
||||
|
||||
it('control: still accepts dispatched for an active Dispatch with no supervised worker', () => {
|
||||
const task = db.createTask({ runId: 'run_legacy_local', spec: 'unsupervised work' })
|
||||
createRootDispatch(db, task.id, 'term_worker')
|
||||
|
||||
expect(db.updateTaskStatus(task.id, 'dispatched')?.status).toBe('dispatched')
|
||||
})
|
||||
|
||||
it('control: still accepts dispatched while the supervised worker is ready', () => {
|
||||
const { task } = localWorker()
|
||||
|
||||
expect(db.updateTaskStatus(task.id, 'dispatched')?.status).toBe('dispatched')
|
||||
})
|
||||
|
||||
it('control: a no-op re-assert of dispatched under a stopping worker stays legal', () => {
|
||||
const { task, dispatch } = localWorker()
|
||||
expect(db.getTask(task.id)?.status).toBe('dispatched')
|
||||
db.beginWorkerStop(dispatch.id, 'epoch_home')
|
||||
// beginWorkerStop moved the Task to blocked; put it back the only way that is not a re-open.
|
||||
db.db.prepare("UPDATE tasks SET status = 'dispatched' WHERE id = ?").run(task.id)
|
||||
|
||||
expect(db.updateTaskStatus(task.id, 'dispatched')?.status).toBe('dispatched')
|
||||
})
|
||||
})
|
||||
|
||||
describe('operator escape', () => {
|
||||
it('accepts a re-issued worker-stop and reaches an honest stop_unknown outcome', () => {
|
||||
const { task, dispatch } = localWorker()
|
||||
db.beginWorkerStop(dispatch.id, 'epoch_dead_runtime')
|
||||
|
||||
// The runtime that owned the first stop died mid-flight; the re-issue is the way out.
|
||||
const reissued = db.beginWorkerStop(dispatch.id, 'epoch_new_runtime')
|
||||
expect(reissued).toMatchObject({ disposition: 'stopping' })
|
||||
expect(db.getWorkerDispatch(dispatch.id)?.runtime_epoch).toBe('epoch_new_runtime')
|
||||
|
||||
db.markWorkerStopUnknown(dispatch.id, 'the execution host did not answer')
|
||||
expect(db.abandonWorkerDispatch(dispatch.id)).toMatchObject({ disposition: 'abandoned' })
|
||||
expect(db.getTask(task.id)?.status).toBe('blocked')
|
||||
})
|
||||
|
||||
it('refuses a re-issue from the runtime whose own stop is still in flight', () => {
|
||||
const { dispatch } = localWorker()
|
||||
db.beginWorkerStop(dispatch.id, 'epoch_this_runtime')
|
||||
|
||||
// The terminal is closing and its exit event has not landed yet. Letting this second pass
|
||||
// record stop_unknown would make the exit read as a crash instead of this stop succeeding.
|
||||
expect(() => db.beginWorkerStop(dispatch.id, 'epoch_this_runtime')).toThrowError(
|
||||
/cannot stop from stopping/
|
||||
)
|
||||
|
||||
// The row is still the one the exit path claims a clean stop from: stopping, same epoch.
|
||||
expect(db.getWorkerDispatch(dispatch.id)).toMatchObject({
|
||||
state: 'stopping',
|
||||
runtime_epoch: 'epoch_this_runtime'
|
||||
})
|
||||
expect(db.settleWorkerStop(dispatch.id).state).toBe('stopped')
|
||||
expect(db.getDispatchContextById(dispatch.id)).toMatchObject({
|
||||
status: 'failed',
|
||||
last_failure: 'stopped'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -33,12 +33,16 @@ describe('task creation dependency readiness', () => {
|
||||
|
||||
it('creates a late dependent as ready when every dependency is completed', () => {
|
||||
const db = createDb()
|
||||
const first = db.createTask({ spec: 'first' })
|
||||
const second = db.createTask({ spec: 'second' })
|
||||
const first = db.createTask({ runId: 'run_legacy_local', spec: 'first' })
|
||||
const second = db.createTask({ runId: 'run_legacy_local', spec: 'second' })
|
||||
db.updateTaskStatus(first.id, 'completed')
|
||||
db.updateTaskStatus(second.id, 'completed')
|
||||
|
||||
const child = db.createTask({ spec: 'child', deps: [first.id, second.id] })
|
||||
const child = db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'child',
|
||||
deps: [first.id, second.id]
|
||||
})
|
||||
|
||||
expect(child.status).toBe('ready')
|
||||
})
|
||||
@@ -49,7 +53,7 @@ describe('task creation dependency readiness', () => {
|
||||
const path = join(directory, 'orchestration.db')
|
||||
const db = createDb(path)
|
||||
const concurrent = createDb(path)
|
||||
const dependency = db.createTask({ spec: 'dependency' })
|
||||
const dependency = db.createTask({ runId: 'run_legacy_local', spec: 'dependency' })
|
||||
const sqlite = (db as unknown as OrchestrationDbAccess).db
|
||||
const prepare = sqlite.prepare.bind(sqlite)
|
||||
let injected = false
|
||||
@@ -61,7 +65,7 @@ describe('task creation dependency readiness', () => {
|
||||
return prepare(sql)
|
||||
})
|
||||
|
||||
const child = db.createTask({ spec: 'child', deps: [dependency.id] })
|
||||
const child = db.createTask({ runId: 'run_legacy_local', spec: 'child', deps: [dependency.id] })
|
||||
|
||||
expect(injected).toBe(true)
|
||||
expect(child.status).toBe('ready')
|
||||
@@ -69,10 +73,14 @@ describe('task creation dependency readiness', () => {
|
||||
|
||||
it('promotes only after every dependency completes', () => {
|
||||
const db = createDb()
|
||||
const first = db.createTask({ spec: 'first' })
|
||||
const second = db.createTask({ spec: 'second' })
|
||||
const first = db.createTask({ runId: 'run_legacy_local', spec: 'first' })
|
||||
const second = db.createTask({ runId: 'run_legacy_local', spec: 'second' })
|
||||
db.updateTaskStatus(first.id, 'completed')
|
||||
const child = db.createTask({ spec: 'child', deps: [first.id, second.id] })
|
||||
const child = db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'child',
|
||||
deps: [first.id, second.id]
|
||||
})
|
||||
|
||||
expect(child.status).toBe('pending')
|
||||
db.updateTaskStatus(second.id, 'completed')
|
||||
@@ -83,11 +91,15 @@ describe('task creation dependency readiness', () => {
|
||||
'does not unlock a dependent whose dependency is %s',
|
||||
(status) => {
|
||||
const db = createDb()
|
||||
const terminal = db.createTask({ spec: 'terminal dependency' })
|
||||
const completing = db.createTask({ spec: 'completing dependency' })
|
||||
const terminal = db.createTask({ runId: 'run_legacy_local', spec: 'terminal dependency' })
|
||||
const completing = db.createTask({ runId: 'run_legacy_local', spec: 'completing dependency' })
|
||||
db.updateTaskStatus(terminal.id, status)
|
||||
|
||||
const child = db.createTask({ spec: 'child', deps: [terminal.id, completing.id] })
|
||||
const child = db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'child',
|
||||
deps: [terminal.id, completing.id]
|
||||
})
|
||||
|
||||
expect(child.status).toBe('pending')
|
||||
db.updateTaskStatus(completing.id, 'completed')
|
||||
@@ -98,9 +110,9 @@ describe('task creation dependency readiness', () => {
|
||||
it('rejects missing dependencies without inserting a task', () => {
|
||||
const db = createDb()
|
||||
|
||||
expect(() => db.createTask({ spec: 'child', deps: ['task_missing'] })).toThrow(
|
||||
'Dependency task task_missing must belong to run'
|
||||
)
|
||||
expect(() =>
|
||||
db.createTask({ runId: 'run_legacy_local', spec: 'child', deps: ['task_missing'] })
|
||||
).toThrow('Dependency task task_missing must belong to run')
|
||||
expect(db.listTasks()).toEqual([])
|
||||
})
|
||||
|
||||
@@ -109,7 +121,7 @@ describe('task creation dependency readiness', () => {
|
||||
const sqlite = (db as unknown as OrchestrationDbAccess).db
|
||||
sqlite.exec('BEGIN IMMEDIATE')
|
||||
|
||||
const task = db.createTask({ spec: 'transactional child' })
|
||||
const task = db.createTask({ runId: 'run_legacy_local', spec: 'transactional child' })
|
||||
sqlite.exec('ROLLBACK')
|
||||
|
||||
expect(db.getTask(task.id)).toBeUndefined()
|
||||
@@ -120,11 +132,19 @@ describe('task creation dependency readiness', () => {
|
||||
directories.push(directory)
|
||||
const path = join(directory, 'orchestration.db')
|
||||
const before = createDb(path)
|
||||
const completed = before.createTask({ spec: 'completed' })
|
||||
const open = before.createTask({ spec: 'open' })
|
||||
const completed = before.createTask({ runId: 'run_legacy_local', spec: 'completed' })
|
||||
const open = before.createTask({ runId: 'run_legacy_local', spec: 'open' })
|
||||
before.updateTaskStatus(completed.id, 'completed')
|
||||
const ready = before.createTask({ spec: 'ready', deps: [completed.id] })
|
||||
const pending = before.createTask({ spec: 'pending', deps: [completed.id, open.id] })
|
||||
const ready = before.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'ready',
|
||||
deps: [completed.id]
|
||||
})
|
||||
const pending = before.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'pending',
|
||||
deps: [completed.id, open.id]
|
||||
})
|
||||
before.close()
|
||||
databases.splice(databases.indexOf(before), 1)
|
||||
|
||||
|
||||
@@ -30,9 +30,17 @@ describe('Task/Dispatch invariant transactions', () => {
|
||||
'allows a dependency-blocked pending Task to become %s',
|
||||
(status) => {
|
||||
const { db } = createDatabase()
|
||||
const dependency = db.createTask({ spec: 'unresolved dependency' })
|
||||
const task = db.createTask({ spec: 'manual resolution', deps: [dependency.id] })
|
||||
const dependent = db.createTask({ spec: 'downstream work', deps: [task.id] })
|
||||
const dependency = db.createTask({ runId: 'run_legacy_local', spec: 'unresolved dependency' })
|
||||
const task = db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'manual resolution',
|
||||
deps: [dependency.id]
|
||||
})
|
||||
const dependent = db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'downstream work',
|
||||
deps: [task.id]
|
||||
})
|
||||
|
||||
expect(task.status).toBe('pending')
|
||||
const updated = db.updateTaskStatus(task.id, status, 'manual resolution')
|
||||
@@ -45,7 +53,7 @@ describe('Task/Dispatch invariant transactions', () => {
|
||||
|
||||
it('surfaces invalid Task lifecycle edges instead of returning the unchanged row', () => {
|
||||
const { db } = createDatabase()
|
||||
const task = db.createTask({ spec: 'invalid lifecycle edge' })
|
||||
const task = db.createTask({ runId: 'run_legacy_local', spec: 'invalid lifecycle edge' })
|
||||
db.updateTaskStatus(task.id, 'blocked')
|
||||
|
||||
expect(() =>
|
||||
@@ -67,8 +75,12 @@ describe('Task/Dispatch invariant transactions', () => {
|
||||
'rolls back a %s Task when Dispatch settlement fails',
|
||||
(status) => {
|
||||
const { db } = createDatabase()
|
||||
const task = db.createTask({ spec: 'atomic work' })
|
||||
const dependent = db.createTask({ spec: 'dependent work', deps: [task.id] })
|
||||
const task = db.createTask({ runId: 'run_legacy_local', spec: 'atomic work' })
|
||||
const dependent = db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'dependent work',
|
||||
deps: [task.id]
|
||||
})
|
||||
const dispatch = createRootDispatch(db, task.id, 'term_worker')
|
||||
const capability = db.mintDispatchCapability({
|
||||
dispatchId: dispatch.id,
|
||||
@@ -111,7 +123,10 @@ describe('Task/Dispatch invariant transactions', () => {
|
||||
|
||||
it('does not commit a caller-owned transaction', () => {
|
||||
const { db } = createDatabase()
|
||||
const task = db.createTask({ spec: 'outer transaction work' })
|
||||
const task = db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'outer transaction work'
|
||||
})
|
||||
const dispatch = createRootDispatch(db, task.id, 'term_worker')
|
||||
const sqlite = sqliteFor(db)
|
||||
|
||||
@@ -135,7 +150,10 @@ describe('Task/Dispatch invariant transactions', () => {
|
||||
|
||||
it('keeps Dispatch creation inside a caller-owned transaction', () => {
|
||||
const { db } = createDatabase()
|
||||
const task = db.createTask({ spec: 'outer transaction dispatch' })
|
||||
const task = db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'outer transaction dispatch'
|
||||
})
|
||||
const sqlite = sqliteFor(db)
|
||||
|
||||
sqlite.exec('BEGIN IMMEDIATE')
|
||||
@@ -152,7 +170,10 @@ describe('Task/Dispatch invariant transactions', () => {
|
||||
'settles every active Dispatch left by a pre-fix split when the Task becomes %s',
|
||||
(status) => {
|
||||
const { db } = createDatabase()
|
||||
const task = db.createTask({ spec: 'legacy split work' })
|
||||
const task = db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'legacy split work'
|
||||
})
|
||||
const first = createRootDispatch(db, task.id, 'term_first')
|
||||
sqliteFor(db).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id)
|
||||
const second = createRootDispatch(db, task.id, 'term_second')
|
||||
@@ -169,17 +190,31 @@ describe('Task/Dispatch invariant transactions', () => {
|
||||
expect(db.getActiveDispatchForTerminal('term_first')).toBeUndefined()
|
||||
expect(db.getActiveDispatchForTerminal('term_second')).toBeUndefined()
|
||||
expect(() =>
|
||||
createRootDispatch(db, db.createTask({ spec: 'first later work' }).id, 'term_first')
|
||||
createRootDispatch(
|
||||
db,
|
||||
db.createTask({ runId: 'run_legacy_local', spec: 'first later work' }).id,
|
||||
'term_first'
|
||||
)
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
createRootDispatch(db, db.createTask({ spec: 'second later work' }).id, 'term_second')
|
||||
createRootDispatch(
|
||||
db,
|
||||
db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'second later work'
|
||||
}).id,
|
||||
'term_second'
|
||||
)
|
||||
).not.toThrow()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not requeue a legacy split Task while another Dispatch remains active', () => {
|
||||
const { db } = createDatabase()
|
||||
const task = db.createTask({ spec: 'legacy split retry' })
|
||||
const task = db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'legacy split retry'
|
||||
})
|
||||
const first = createRootDispatch(db, task.id, 'term_first')
|
||||
sqliteFor(db).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id)
|
||||
const second = createRootDispatch(db, task.id, 'term_second')
|
||||
@@ -193,7 +228,10 @@ describe('Task/Dispatch invariant transactions', () => {
|
||||
|
||||
it('does not block a legacy split Task while another Dispatch remains active', () => {
|
||||
const { db } = createDatabase()
|
||||
const task = db.createTask({ spec: 'legacy split release' })
|
||||
const task = db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'legacy split release'
|
||||
})
|
||||
const first = createRootDispatch(db, task.id, 'term_first')
|
||||
sqliteFor(db).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id)
|
||||
const second = createRootDispatch(db, task.id, 'term_second')
|
||||
@@ -211,7 +249,10 @@ describe('Task/Dispatch invariant transactions', () => {
|
||||
'rejects moving a Task to %s while a Dispatch remains active',
|
||||
(status) => {
|
||||
const { db } = createDatabase()
|
||||
const task = db.createTask({ spec: 'guarded work' })
|
||||
const task = db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'guarded work'
|
||||
})
|
||||
const dispatch = createRootDispatch(db, task.id, 'term_worker')
|
||||
|
||||
expect(() => db.updateTaskStatus(task.id, status, 'must not persist')).toThrowError(
|
||||
@@ -227,7 +268,10 @@ describe('Task/Dispatch invariant transactions', () => {
|
||||
|
||||
it('rejects moving a Task to dispatched without an active Dispatch', () => {
|
||||
const { db } = createDatabase()
|
||||
const task = db.createTask({ spec: 'unassigned work' })
|
||||
const task = db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'unassigned work'
|
||||
})
|
||||
|
||||
expect(() => db.updateTaskStatus(task.id, 'dispatched')).toThrowError(
|
||||
expect.objectContaining({
|
||||
@@ -241,7 +285,10 @@ describe('Task/Dispatch invariant transactions', () => {
|
||||
it('rejects a Dispatch when failure wins after readiness was observed', () => {
|
||||
const first = createDatabase()
|
||||
const concurrent = createDatabase(first.path)
|
||||
const task = first.db.createTask({ spec: 'interleaved work' })
|
||||
const task = first.db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'interleaved work'
|
||||
})
|
||||
const sqlite = sqliteFor(first.db)
|
||||
const prepare = sqlite.prepare.bind(sqlite)
|
||||
let injected = false
|
||||
@@ -264,8 +311,14 @@ describe('Task/Dispatch invariant transactions', () => {
|
||||
it('atomically rejects a same-pane Dispatch that loses the occupancy race', () => {
|
||||
const first = createDatabase()
|
||||
const concurrent = createDatabase(first.path)
|
||||
const firstTask = first.db.createTask({ spec: 'first terminal claimant' })
|
||||
const secondTask = first.db.createTask({ spec: 'second terminal claimant' })
|
||||
const firstTask = first.db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'first terminal claimant'
|
||||
})
|
||||
const secondTask = first.db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'second terminal claimant'
|
||||
})
|
||||
const sqlite = sqliteFor(first.db)
|
||||
const prepare = sqlite.prepare.bind(sqlite)
|
||||
let winnerId: string | undefined
|
||||
@@ -303,14 +356,20 @@ describe('Task/Dispatch invariant transactions', () => {
|
||||
|
||||
it('rejects worker authority when another Dispatch owns the pane', () => {
|
||||
const { db } = createDatabase()
|
||||
const ownerTask = db.createTask({ spec: 'current pane owner' })
|
||||
const ownerTask = db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'current pane owner'
|
||||
})
|
||||
const owner = createRootDispatch(
|
||||
db,
|
||||
ownerTask.id,
|
||||
'term_owner',
|
||||
'tab_old:cccccccc-cccc-4ccc-8ccc-cccccccccccc'
|
||||
)
|
||||
const workerTask = db.createTask({ spec: 'competing supervised worker' })
|
||||
const workerTask = db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'competing supervised worker'
|
||||
})
|
||||
const started = db.createStartingWorkerDispatch({
|
||||
creator: { kind: 'system' },
|
||||
maxDepth: Number.MAX_SAFE_INTEGER,
|
||||
@@ -346,7 +405,10 @@ describe('Task/Dispatch invariant transactions', () => {
|
||||
'rejects a %s Task update while its supervised worker remains active',
|
||||
(status) => {
|
||||
const { db } = createDatabase()
|
||||
const task = db.createTask({ spec: 'supervised lifecycle' })
|
||||
const task = db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'supervised lifecycle'
|
||||
})
|
||||
const started = db.createStartingWorkerDispatch({
|
||||
creator: { kind: 'system' },
|
||||
maxDepth: Number.MAX_SAFE_INTEGER,
|
||||
@@ -394,7 +456,10 @@ describe('Task/Dispatch invariant transactions', () => {
|
||||
|
||||
it('keeps a federated late start authoritative after rejecting Task failure', () => {
|
||||
const { db } = createDatabase()
|
||||
const task = db.createTask({ spec: 'federated lifecycle' })
|
||||
const task = db.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'federated lifecycle'
|
||||
})
|
||||
const started = db.createStartingWorkerDispatch({
|
||||
creator: { kind: 'system' },
|
||||
maxDepth: Number.MAX_SAFE_INTEGER,
|
||||
|
||||
@@ -29,7 +29,7 @@ afterEach(() => {
|
||||
describe('Task/Dispatch lifecycle guards', () => {
|
||||
it('rejects a worker report while another supervised Dispatch is active', () => {
|
||||
const database = createDatabase()
|
||||
const task = database.createTask({ spec: 'legacy supervised split' })
|
||||
const task = database.createTask({ runId: 'run_legacy_local', spec: 'legacy supervised split' })
|
||||
const first = startWorker(database, task.id, 'first')
|
||||
sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id)
|
||||
const second = startWorker(database, task.id, 'second')
|
||||
@@ -55,7 +55,7 @@ describe('Task/Dispatch lifecycle guards', () => {
|
||||
'settles context-only legacy siblings after a %s worker report',
|
||||
(outcome) => {
|
||||
const database = createDatabase()
|
||||
const task = database.createTask({ spec: 'legacy mixed split' })
|
||||
const task = database.createTask({ runId: 'run_legacy_local', spec: 'legacy mixed split' })
|
||||
const contextOnly = createRootDispatch(database, task.id, 'term_context')
|
||||
sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id)
|
||||
const worker = startWorker(database, task.id, 'reporter')
|
||||
@@ -78,7 +78,10 @@ describe('Task/Dispatch lifecycle guards', () => {
|
||||
expect(() =>
|
||||
createRootDispatch(
|
||||
database,
|
||||
database.createTask({ spec: 'later context work' }).id,
|
||||
database.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'later context work'
|
||||
}).id,
|
||||
'term_context'
|
||||
)
|
||||
).not.toThrow()
|
||||
@@ -87,7 +90,10 @@ describe('Task/Dispatch lifecycle guards', () => {
|
||||
|
||||
it('settles a newer context-only legacy sibling after a worker report', () => {
|
||||
const database = createDatabase()
|
||||
const task = database.createTask({ spec: 'reversed legacy mixed split' })
|
||||
const task = database.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'reversed legacy mixed split'
|
||||
})
|
||||
const worker = startWorker(database, task.id, 'reversed_reporter')
|
||||
sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id)
|
||||
const contextOnly = createRootDispatch(database, task.id, 'term_reversed_context')
|
||||
@@ -112,7 +118,10 @@ describe('Task/Dispatch lifecycle guards', () => {
|
||||
'treats abandon of an already %s worker as stale without a lifecycle conflict',
|
||||
(state) => {
|
||||
const database = createDatabase()
|
||||
const task = database.createTask({ spec: `already ${state}` })
|
||||
const task = database.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: `already ${state}`
|
||||
})
|
||||
const worker = startWorker(database, task.id, `already_${state}`)
|
||||
if (state === 'failed') {
|
||||
database.failDispatch(worker.dispatchId, 'process exited', { workerProcessExited: true })
|
||||
@@ -130,7 +139,10 @@ describe('Task/Dispatch lifecycle guards', () => {
|
||||
|
||||
it('rejects generic failure while a supervised worker remains active', () => {
|
||||
const database = createDatabase()
|
||||
const task = database.createTask({ spec: 'supervised failure guard' })
|
||||
const task = database.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'supervised failure guard'
|
||||
})
|
||||
const worker = startWorker(database, task.id, 'guarded')
|
||||
|
||||
expect(() => database.failDispatch(worker.dispatchId, 'unsafe retry')).toThrowError(
|
||||
@@ -151,7 +163,10 @@ describe('Task/Dispatch lifecycle guards', () => {
|
||||
|
||||
it('atomically settles worker state when a proven process exit fails its Dispatch', () => {
|
||||
const database = createDatabase()
|
||||
const task = database.createTask({ spec: 'exited worker' })
|
||||
const task = database.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'exited worker'
|
||||
})
|
||||
const worker = startWorker(database, task.id, 'exited')
|
||||
|
||||
expect(
|
||||
@@ -168,7 +183,10 @@ describe('Task/Dispatch lifecycle guards', () => {
|
||||
|
||||
it('settles a stop-unknown worker when a positive PTY exit arrives', () => {
|
||||
const database = createDatabase()
|
||||
const task = database.createTask({ spec: 'stop-unknown exited worker' })
|
||||
const task = database.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'stop-unknown exited worker'
|
||||
})
|
||||
const worker = startWorker(database, task.id, 'stop_unknown_exited')
|
||||
|
||||
expect(database.beginWorkerStop(worker.dispatchId, 'runtime_test').disposition).toBe('stopping')
|
||||
@@ -198,7 +216,10 @@ describe('Task/Dispatch lifecycle guards', () => {
|
||||
|
||||
it('keeps a Task dispatched when missing-terminal recovery leaves another worker active', () => {
|
||||
const database = createDatabase()
|
||||
const task = database.createTask({ spec: 'legacy missing-terminal split' })
|
||||
const task = database.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'legacy missing-terminal split'
|
||||
})
|
||||
const missing = startWorker(database, task.id, 'missing')
|
||||
sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id)
|
||||
const live = startWorker(database, task.id, 'live')
|
||||
@@ -225,7 +246,10 @@ describe('Task/Dispatch lifecycle guards', () => {
|
||||
'keeps a Task dispatched when a %s worker start fails beside a live worker',
|
||||
(kind) => {
|
||||
const database = createDatabase()
|
||||
const task = database.createTask({ spec: `${kind} split start failure` })
|
||||
const task = database.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: `${kind} split start failure`
|
||||
})
|
||||
const failed = database.createStartingWorkerDispatch({
|
||||
creator: { kind: 'system' },
|
||||
maxDepth: Number.MAX_SAFE_INTEGER,
|
||||
@@ -342,7 +366,10 @@ describe('Task/Dispatch lifecycle guards', () => {
|
||||
|
||||
it('rolls back federated start uncertainty when the Task transition cannot commit', () => {
|
||||
const database = createDatabase()
|
||||
const task = database.createTask({ spec: 'atomic federated uncertainty' })
|
||||
const task = database.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'atomic federated uncertainty'
|
||||
})
|
||||
const started = database.createStartingWorkerDispatch({
|
||||
creator: { kind: 'system' },
|
||||
maxDepth: Number.MAX_SAFE_INTEGER,
|
||||
@@ -383,7 +410,10 @@ describe('Task/Dispatch lifecycle guards', () => {
|
||||
'%s releases the last context-only sibling after a newer worker start fails',
|
||||
(operation) => {
|
||||
const database = createDatabase()
|
||||
const task = database.createTask({ spec: `${operation} historical sibling` })
|
||||
const task = database.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: `${operation} historical sibling`
|
||||
})
|
||||
const contextOnly = createRootDispatch(database, task.id, `term_${operation}`)
|
||||
sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id)
|
||||
const failed = database.createStartingWorkerDispatch({
|
||||
@@ -411,7 +441,10 @@ describe('Task/Dispatch lifecycle guards', () => {
|
||||
expect(() =>
|
||||
createRootDispatch(
|
||||
database,
|
||||
database.createTask({ spec: `${operation} later work` }).id,
|
||||
database.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: `${operation} later work`
|
||||
}).id,
|
||||
`term_${operation}`
|
||||
)
|
||||
).not.toThrow()
|
||||
@@ -422,7 +455,10 @@ describe('Task/Dispatch lifecycle guards', () => {
|
||||
'%s records guarded receipts for context-only Dispatch and Task release',
|
||||
(operation) => {
|
||||
const database = createDatabase()
|
||||
const task = database.createTask({ spec: `${operation} receipt release` })
|
||||
const task = database.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: `${operation} receipt release`
|
||||
})
|
||||
const contextOnly = createRootDispatch(database, task.id, `term_${operation}`)
|
||||
|
||||
const released =
|
||||
@@ -445,7 +481,10 @@ describe('Task/Dispatch lifecycle guards', () => {
|
||||
|
||||
it('rolls back both context-only projections when the Task transition fails', () => {
|
||||
const database = createDatabase()
|
||||
const task = database.createTask({ spec: 'context-only atomic receipt' })
|
||||
const task = database.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'context-only atomic receipt'
|
||||
})
|
||||
const contextOnly = createRootDispatch(database, task.id, 'term_context')
|
||||
sqliteFor(database).exec(`
|
||||
CREATE TRIGGER reject_context_release_task_block
|
||||
@@ -470,7 +509,10 @@ describe('Task/Dispatch lifecycle guards', () => {
|
||||
'%s preserves a live worker sibling and lets it report',
|
||||
(operation) => {
|
||||
const database = createDatabase()
|
||||
const task = database.createTask({ spec: `${operation} legacy worker split` })
|
||||
const task = database.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: `${operation} legacy worker split`
|
||||
})
|
||||
const live = startWorker(database, task.id, `${operation}_live`)
|
||||
sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id)
|
||||
const released = startWorker(database, task.id, `${operation}_released`)
|
||||
@@ -502,7 +544,10 @@ describe('Task/Dispatch lifecycle guards', () => {
|
||||
|
||||
it('blocks a Task when an interleaved stop settles its final active Dispatch', () => {
|
||||
const database = createDatabase()
|
||||
const task = database.createTask({ spec: 'interleaved legacy worker release' })
|
||||
const task = database.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'interleaved legacy worker release'
|
||||
})
|
||||
const stopping = startWorker(database, task.id, 'interleaved_stopping')
|
||||
sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id)
|
||||
const abandoned = startWorker(database, task.id, 'interleaved_abandoned')
|
||||
@@ -521,7 +566,10 @@ describe('Task/Dispatch lifecycle guards', () => {
|
||||
|
||||
it('restores a live sibling after stopping an uncertain worker start', () => {
|
||||
const database = createDatabase()
|
||||
const task = database.createTask({ spec: 'uncertain legacy worker split' })
|
||||
const task = database.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'uncertain legacy worker split'
|
||||
})
|
||||
const live = startWorker(database, task.id, 'uncertain_live')
|
||||
sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id)
|
||||
const uncertain = database.createStartingWorkerDispatch({
|
||||
@@ -552,7 +600,10 @@ describe('Task/Dispatch lifecycle guards', () => {
|
||||
'restores a live sibling after an uncertain worker start fails through %s',
|
||||
(recovery) => {
|
||||
const database = createDatabase()
|
||||
const task = database.createTask({ spec: `${recovery} uncertain sibling` })
|
||||
const task = database.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: `${recovery} uncertain sibling`
|
||||
})
|
||||
const live = startWorker(database, task.id, `${recovery}_live`)
|
||||
sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id)
|
||||
const uncertain = database.createStartingWorkerDispatch({
|
||||
@@ -589,7 +640,10 @@ describe('Task/Dispatch lifecycle guards', () => {
|
||||
|
||||
it('rejects gate creation while a supervised worker remains active', () => {
|
||||
const database = createDatabase()
|
||||
const task = database.createTask({ spec: 'worker gate guard' })
|
||||
const task = database.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'worker gate guard'
|
||||
})
|
||||
const worker = startWorker(database, task.id, 'gate')
|
||||
|
||||
expect(() => database.createGate({ taskId: task.id, question: 'Proceed?' })).toThrowError(
|
||||
@@ -607,7 +661,10 @@ describe('Task/Dispatch lifecycle guards', () => {
|
||||
|
||||
it('rolls back gate resolution when an active Dispatch blocks readiness', () => {
|
||||
const database = createDatabase()
|
||||
const task = database.createTask({ spec: 'corrupt gated task' })
|
||||
const task = database.createTask({
|
||||
runId: 'run_legacy_local',
|
||||
spec: 'corrupt gated task'
|
||||
})
|
||||
const gate = database.createGate({ taskId: task.id, question: 'Proceed?' })
|
||||
sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id)
|
||||
const dispatch = createRootDispatch(database, task.id, 'term_worker')
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user