mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 16:02:42 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/codex-structured-mobile-write
# Conflicts: # mobile/src/session/MobileNativeChatMessage.tsx
This commit is contained in:
@@ -94,6 +94,7 @@ docs/**
|
||||
!docs/readme/
|
||||
!docs/readme/**
|
||||
!docs/STYLEGUIDE.md
|
||||
!docs/ai-vault-process-isolation-plan.md
|
||||
!docs/mobile-terminal-shortcut-bar.md
|
||||
!docs/reference/
|
||||
!docs/reference/git-compatibility.md
|
||||
|
||||
@@ -182,6 +182,7 @@ module.exports = {
|
||||
'out/main/grok/**',
|
||||
'out/main/hermes/**',
|
||||
'out/main/daemon-entry.js',
|
||||
'out/main/session-scanner-service-entry.js',
|
||||
'out/main/plugin-host-entry.js',
|
||||
'out/main/computer-sidecar.js',
|
||||
'out/main/parcel-watcher-process-entry.js',
|
||||
|
||||
+141
-42
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-08-09",
|
||||
"updatedAt": "2026-08-10",
|
||||
"policy": {
|
||||
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
|
||||
"blockingPromotion": {
|
||||
@@ -3656,9 +3656,7 @@
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts",
|
||||
"assertions": [
|
||||
"risky-output atlas recovery is bounded, coalesced, and survives a throwing manager"
|
||||
]
|
||||
"assertions": ["continues recovery when a manager throws after scheduling"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts",
|
||||
@@ -4315,14 +4313,16 @@
|
||||
"providers": ["paired-runtime", "ssh"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["paired-runtime"],
|
||||
"coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, prove sustained output while all six client xterms are warm-mounted but hidden causes zero renderer scheduler work, ordinary-park five xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore the bounded authoritative tail exactly once on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. A separate headed desktop-client journey relaunches the host main process while retaining the daemon and a daemon-backgrounded PTY, then requires first-observation foreground sync and continued input/output on the same PTY. Unit coverage keeps layout versions out of snapshot output high-waters when a restored snapshot has no output sequence and retries an existing subscriber only after authoritative provider inventory proves the retained PTY exists. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, 64 simultaneous synthetic-title sources with zero decorative client events after convergence, per-client late-subscribe recovery, semantic bell/title transitions, local animation-frame preservation, legacy no-output-pause delivery gating with exact snapshot restore, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, live SSH, and production-scale paired hosts remain gaps.",
|
||||
"coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, prove sustained output while all six client xterms are warm-mounted but hidden causes zero renderer scheduler work, then drive 180 timed real-PTY spinner frames through a second production `session.tabs.subscribeAll` transport and the real Zustand store. Each worktree must publish and apply only its exact initial-working and final-idle titles. Both topologies ordinary-park five xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore the bounded authoritative tail exactly once on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Production-parity React contracts reproduce and remove the independent cold-park watcher/title feedback loop, including memo-cache discard, StrictMode cleanup replay, detached split-PTY replacement, and the zero-park fast path. A deterministic cross-boundary contract drives 24 real PTY trackers in distinct worktrees through 2,880 decorative spinner and bare-native redraw chunks and counts host publications, serialized bytes, renderer apply calls, and store mutations while preserving exact per-PTY raw chunks and per-worktree semantic transitions. Fake-clock continuation beyond the 30-minute stale boundary requires one deduplicated refresh per worktree every 15 minutes, globally spaced by at least 50 ms, with every mirrored working status still fresh. Exact-identical Pi plus rotating normalized Pi/Grok frames renew coarse working/permission state; rich prompt/tool/question fields retain their original 30-minute lease, and `stateStartedAt`/acknowledgement identity remain stable across title/hook source swaps. A separate headed desktop-client journey relaunches the host main process while retaining the daemon and a daemon-backgrounded PTY, then requires first-observation foreground sync and continued input/output on the same PTY. Unit coverage keeps layout versions out of snapshot output high-waters when a restored snapshot has no output sequence and retries an existing subscriber only after authoritative provider inventory proves the retained PTY exists. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, 64 simultaneous synthetic-title sources with zero decorative client events after convergence, per-client late-subscribe recovery, semantic bell/title transitions, local animation-frame preservation, legacy no-output-pause delivery gating with exact snapshot restore, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, live SSH, and production-scale paired hosts remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/8652",
|
||||
"https://github.com/stablyai/orca/pull/10625"
|
||||
],
|
||||
"invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. After a host main-process relaunch, unknown local delivery-sync state must not be treated as proof that a surviving daemon PTY is already foregrounded. If subscription precedes provider readiness, authoritative inventory must reconsider that existing subscriber without spawning, resizing, or changing reconnect state. A snapshot without an output high-water must keep it unknown rather than borrowing a layout version that can suppress newer live output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After a paired client's first semantic title state, decorative spinner frequency must add zero encrypted client-event frames; local title animation and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.",
|
||||
"oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drive 64 host PTYs through ten 80 ms synthetic title frames, require zero paired client events after the first frame per PTY while all 640 local frames remain observable, attach a late client and require one current frame per PTY, then require semantic bell and idle transitions on both clients. Reconstruct a legacy subscribed stream with no outputPause capability, hide a chatty pane, require no hidden xterm writes, reveal it, and require one authoritative snapshot plus continued live output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.",
|
||||
"invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Parked watcher synchronization compares parking semantics rather than fresh object identity, owns setup and cleanup across StrictMode replay, reconciles real PTY/layout changes, and performs no terminal-state scan when nothing is parked. After a host main-process relaunch, unknown local delivery-sync state must not be treated as proof that a surviving daemon PTY is already foregrounded. If subscription precedes provider readiness, authoritative inventory must reconsider that existing subscriber without spawning, resizing, or changing reconnect state. A snapshot without an output high-water must keep it unknown rather than borrowing a layout version that can suppress newer live output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After the first semantic title state, decorative spinner frequency must add zero paired client-event frames and zero full session-tabs work between status-freshness leases regardless of hidden worktree count. Continuous working or permission evidence may refresh each affected worktree once per 15 minutes through one globally spaced FIFO so current and legacy viewers do not decay before 30 minutes; sibling PTYs share that worktree refresh, while local title animation, raw output, and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.",
|
||||
"oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, hide all six warm managers, emit an initial working title followed by 30 timed real-PTY spinner frames and a final idle title per worktree, and observe a second real session-tabs subscription plus the production renderer store. Require exactly two transport envelopes and two title mutations per worktree, no decorative title crossing either boundary, and positive decoded-envelope bytes. Enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drive 24 real PTY title trackers in distinct worktrees through 40 decorative spinner frames each, then 40 spinner frames alternating with Cursor's bare native identity redraw, using fake clocks. Require all 2,880 raw chunks exactly and zero host session-tabs publications, serialized bytes, renderer apply calls, and renderer store mutations. Continue decorative evidence beyond 30 minutes, require exactly one refresh per worktree per 15-minute lease through a FIFO spaced by at least 50 ms, and require all 24 viewer statuses to remain working and fresh; then require one exact semantic idle transition and one visible-output chunk per PTY. Require ordinary and interior-Braille title changes to publish. Drive 64 host PTYs through ten 80 ms synthetic title frames, require zero paired client events after the first frame per PTY while all 640 local frames remain observable, attach a late client and require one current frame per PTY, then require semantic bell and idle transitions on both clients. Reconstruct a legacy subscribed stream with no outputPause capability, hide a chatty pane, require no hidden xterm writes, reveal it, and require one authoritative snapshot plus continued live output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts --maxWorkers=1",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts --maxWorkers=1",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts",
|
||||
@@ -4335,11 +4335,20 @@
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-parked-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts",
|
||||
"src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts",
|
||||
"tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts",
|
||||
"tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx",
|
||||
"src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx",
|
||||
"src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx",
|
||||
"src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts",
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/renderer/src/web/web-preload-api.test.ts",
|
||||
"src/main/ipc/runtime-environments.test.ts",
|
||||
@@ -4371,6 +4380,59 @@
|
||||
"tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts",
|
||||
"assertions": [
|
||||
"one globally spaced FIFO refreshes each continuously active worktree before status expiry",
|
||||
"sibling PTYs deduplicate to one worktree refresh while semantic transitions bypass the queue",
|
||||
"sole-source PTY teardown removes queued work while sibling PTY sources remain deduplicated",
|
||||
"worktree, pending-queue, and timer state is removed on teardown"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts",
|
||||
"assertions": [
|
||||
"a live pending question remains visible under the current live shell title but cannot cross a later working interval, identity-only owner title, or provider generation",
|
||||
"inherited tool names, stale rows, resume-only rows, and unconfirmed restored rows cannot reopen a shell-reclaimed pane"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx",
|
||||
"assertions": [
|
||||
"parked terminal and unified-label title republication settles without a maximum-depth loop",
|
||||
"equivalent semantic inputs remain stable when React discards memoized identities"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx",
|
||||
"assertions": [
|
||||
"StrictMode cleanup replay restores watcher ownership",
|
||||
"detached split-PTY and layout replacement reconciles",
|
||||
"unparked unrelated writes perform zero PTY-map reads"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts",
|
||||
"assertions": [
|
||||
"2,880 decorative spinner and bare-native real-PTY OSC chunks publish zero session-tabs snapshots or serialized bytes after semantic convergence",
|
||||
"the viewer performs zero snapshot applies and zero store mutations during decorative activity",
|
||||
"after 30 minutes, one globally spaced refresh per worktree keeps every mirrored working status fresh",
|
||||
"exact Pi and rotating normalized Pi/Grok frames renew explicit and fallback status without changing stateStartedAt or acknowledgement semantics",
|
||||
"matching title evidence renews retained OSC hook status without resetting stateStartedAt, then drops unverifiable prompt metadata after the hook row expires",
|
||||
"renderer-authored rich status keeps its original lease, then renews only coarse title-confirmed state and provider identity",
|
||||
"ordinary-shell title transitions replace working stateStartedAt with the completion transition time",
|
||||
"decorative heartbeats remain blocked until unknown foreground ownership resolves, then resume at the bounded interval",
|
||||
"tracked terminal state reset clears delayed foreground ownership markers",
|
||||
"raw chunks, semantic idle transitions, and visible output remain exact"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts",
|
||||
"assertions": [
|
||||
"pending renderer, retained OSC, and live-hook questions survive the shell title that currently obscures them",
|
||||
"a new working interval, provider generation, or identity owner strips the prior question"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts",
|
||||
"assertions": ["retries an existing subscriber when provider inventory becomes ready"]
|
||||
@@ -4425,6 +4487,7 @@
|
||||
"assertions": [
|
||||
"ordinary-parks paired terminals and restores authoritative host scrollback",
|
||||
"warm-mounted hidden paired streams schedule zero renderer output work under sustained host output",
|
||||
"six hidden real PTYs publish and apply only exact working and idle title transitions across paired transport",
|
||||
"client recovery completes while every target terminal remains unmounted in the host renderer"
|
||||
]
|
||||
},
|
||||
@@ -4433,6 +4496,7 @@
|
||||
"assertions": [
|
||||
"ordinary-parks paired terminals against an isolated headless Orca host",
|
||||
"cold-activates only visible paired terminals against an isolated headless host",
|
||||
"the headless host bounds real-PTY decorative title transport and production store application",
|
||||
"the headless host shares the zero-hidden-renderer-work and exact-restoration contract"
|
||||
]
|
||||
},
|
||||
@@ -4527,6 +4591,42 @@
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts --maxWorkers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 1.56,
|
||||
"summary": "All 16 production-parity parking/watcher contracts passed on the combined candidate, covering the maximum-depth loop, semantic stability, StrictMode ownership, split-PTY reconciliation, and zero-park fast path."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 27.6,
|
||||
"summary": "The isolated headed desktop host and separate encrypted paired client drove 180 timed decorative real-PTY titles across six hidden worktrees and observed exactly 12 semantic transport envelopes and 12 production store title mutations, then reduced six managers to one, stayed within heap/cell/timer budgets, restored the bounded host tail exactly, and continued input/output while the host renderer retained zero target managers."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 33.9,
|
||||
"summary": "The isolated headless `orca serve` host passed the same six-worktree real-title transport/store bound, parking, and exact-restoration oracle; a second scenario proved cold activation mounted only the visible paired terminal."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts --maxWorkers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 5.62,
|
||||
"summary": "Forty-two deterministic contracts passed. The real-PTY cross-boundary contract delivered all 2,880 decorative spinner and bare-native redraw chunks with zero hot-path host publications, serialized bytes, renderer applies, or store mutations; continued past 30 minutes with one globally spaced freshness refresh per worktree every 15 minutes; kept all 24 mirrored statuses working; and preserved stable stateStartedAt. Exact-identical Pi and rotating normalized Pi/Grok frames renewed renderer-authored and retained-hook status without changing acknowledgement semantics. Rich status could not cross task completion, permission, provider-generation, or identity-owner boundaries, while neutral titles preserved same-task prompt and acknowledgement identity until authoritative shell evidence. Fresh renderer, retained OSC, and live-hook questions remained visible under the current shell title, but a later working interval, identity-only owner title, or provider generation stripped them; inherited tools and stale, resume-only, restored, or prior-interval questions stayed suppressed. After each rich source's original lease expired, title evidence kept only coarse working state and provider identity fresh while dropping unverifiable prompts. Unknown foreground ownership held publications at zero until resolution, then resumed the bounded heartbeat; tracked terminal state reset removed its delayed marker. The focused heartbeat contracts proved worktree deduplication, FIFO fairness, semantic bypass, PTY-source teardown, and timer cleanup. Causal-disable controls made ownership, teardown, retained source-switch, renderer rich-TTL, completion-transition, boundary-ordering, and live-question oracles fail; restoring the production corrections returned green."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-07",
|
||||
"runner": "local",
|
||||
@@ -4691,11 +4791,11 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. The 64-PTY decorative-title control delivered 576 client events after convergence on unmodified main; the candidate delivers zero while preserving all 640 local frames, one current frame to a late client, and bell/idle transitions to both clients. The legacy no-output-pause control failed to request a reveal snapshot and kept hidden bytes on the xterm path; the candidate gates those bytes locally and restores one authoritative snapshot before live output. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. The sequence-gap unit and headed paired oracle are red on current main and with only the new probe decision reverted: the authoritative host advances while the client stays stale after a successful probe. The candidate replaces the stream, repaints the marker, preserves PTY identity, and resumes I/O. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases. The host-relaunch oracle fails when unknown delivery state is coerced to foregrounded or a layout version becomes an output high-water; the candidate foregrounds the preserved daemon PTY once and resumes same-PTY input/output. When authoritative inventory overlaps a refused in-flight attach, removing only the post-settlement retry leaves the existing subscriber without daemon output; the candidate retains one waiter across repeated inventory publications, retries exactly once, and restores live output."
|
||||
"evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. The parked-title production-parity control throws Maximum update depth exceeded on the affected parking synchronization and settles with semantic dependencies; forced memo-cache discard, StrictMode cleanup replay, and split-PTY replacement are independently red before their corrections and green on the combined candidate. The real-PTY session-tabs oracle produces 96 full snapshots, 64,032 serialized bytes, 96 renderer applies, and 96 store mutations on unmodified main and on a one-line causal revert before reaching the alternating native-title phase; the candidate produces zero in all four dimensions while preserving all 2,880 redraw chunks and 24 exact semantic transitions. The 64-PTY decorative-title control delivered 576 client events after convergence on unmodified main; the candidate delivers zero while preserving all 640 local frames, one current frame to a late client, and bell/idle transitions to both clients. The legacy no-output-pause control failed to request a reveal snapshot and kept hidden bytes on the xterm path; the candidate gates those bytes locally and restores one authoritative snapshot before live output. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. The sequence-gap unit and headed paired oracle are red on current main and with only the new probe decision reverted: the authoritative host advances while the client stays stale after a successful probe. The candidate replaces the stream, repaints the marker, preserves PTY identity, and resumes I/O. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases. The host-relaunch oracle fails when unknown delivery state is coerced to foregrounded or a layout version becomes an output high-water; the candidate foregrounds the preserved daemon PTY once and resumes same-PTY input/output. When authoritative inventory overlaps a refused in-flight attach, removing only the post-settlement retry leaves the existing subscriber without daemon output; the candidate retains one waiter across repeated inventory publications, retries exactly once, and restores live output."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Before ordinary parking, six warm-mounted hidden paired xterms receive zero raw frames and schedule zero renderer output drains while host models continue ingesting sustained output. Ordinary parking then retains the host PTY and bounded 5,000-row provider model while destroying five client xterms. Decorative title ticks are collapsed per paired subscriber before JSON serialization and encryption; semantic facts remain on the singleton client-event stream. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. Legacy hosts retain the prior 12-worktree/45-minute lossy force-parking policy but now gate hidden raw output before xterm scheduling and restore from a bounded snapshot on reveal. Stream recovery adds no polling and is scoped to one stream. Provider-ready reconciliation runs only for PTYs returned by a successful controller inventory, requires an existing remote subscriber, and retains at most one post-settlement waiter per PTY while attach deduplication remains active."
|
||||
"evidence": "Before ordinary parking, six warm-mounted hidden paired xterms receive zero raw frames and schedule zero renderer output drains while host models continue ingesting sustained output. The headed and headless production transports each admit only the initial and final semantic title snapshots per worktree across 180 timed decorative frames, and the real store performs only those 12 exact title mutations. Ordinary parking then retains the host PTY and bounded 5,000-row provider model while destroying five client xterms. Parked watcher synchronization is keyed by bounded parking semantics rather than fresh object identities; unrelated store writes perform zero PTY-map reads when no tab is parked, while parked reconciliation remains bounded by the worktree's terminal tabs and assignments. Decorative title ticks are collapsed both on the paired client-event channel and at the authoritative real-PTY session-tabs classifier before snapshot construction, serialization, transport, recovery, or store application; semantic facts remain exact. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. The forced hidden-worktree retention policy keeps at most four otherwise un-parkable worktrees mounted and force-parks all after 15 minutes; the separate ordinary hot-retain policy keeps at most four recently visible worktrees warm for five minutes. Legacy hosts still gate hidden raw output before xterm scheduling and restore from a bounded snapshot on reveal. Stream recovery adds no polling and is scoped to one stream. Provider-ready reconciliation runs only for PTYs returned by a successful controller inventory, requires an existing remote subscriber, and retains at most one post-settlement waiter per PTY while attach deduplication remains active."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 consecutive CI passes or 14 days of soak history for the headed and headless parking commands.",
|
||||
@@ -4712,7 +4812,7 @@
|
||||
"The legacy protocol shape is deterministic unit coverage; a packaged pre-v1.4.163 host has not yet run the full headed memory oracle.",
|
||||
"The live windows-issues incident recorded two fast snapshot-probe successes while fresh output remained stale, but that release did not log the returned snapshot sequence; the deterministic sequence-gap oracle matches the observed false-health boundary without proving the field transport's lower-level drop mechanism."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote to protection none if decorative title frequency changes paired client-event volume after convergence, a semantic fact is dropped, paired first paint is blank, passive work reconnects after manual disconnect, a capable hidden paired manager survives ordinary parking, a legacy hidden pane schedules raw xterm output or bypasses its fallback, reveal loses requested bounded history or live PTY identity, retained buffer cells do not fall, or either headed/headless scenario flakes."
|
||||
"demotionRule": "Keep experimental or demote to protection none if decorative title frequency changes paired client-event or session-tabs publication volume after convergence, a semantic fact is dropped, paired first paint is blank, passive work reconnects after manual disconnect, a capable hidden paired manager survives ordinary parking, a legacy hidden pane schedules raw xterm output or bypasses its fallback, reveal loses requested bounded history or live PTY identity, retained buffer cells do not fall, or either headed/headless scenario flakes."
|
||||
},
|
||||
{
|
||||
"id": "terminal-performance.no-hot-list-sessions",
|
||||
@@ -8881,7 +8981,7 @@
|
||||
},
|
||||
{
|
||||
"id": "terminal-render.atlas-recovery-fanout",
|
||||
"title": "Terminal atlas recovery stays bounded to visible renderers",
|
||||
"title": "Ordinary terminal output avoids shared atlas recovery fanout",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-rendering",
|
||||
@@ -8890,31 +8990,33 @@
|
||||
"WebGL rendering",
|
||||
"document visibility",
|
||||
"hidden terminal output",
|
||||
"alternate-screen redraws",
|
||||
"paired terminal traffic"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local"],
|
||||
"coverageNotes": "A deterministic renderer contract models one visible manager plus 64 mounted hidden managers, hidden synchronized/TUI output, and five minutes of sustained streaming recovery requests. A macOS Electron run verifies that a document visibility cycle preserves real WebGL atlases and terminal pixels. Production v1.4.163 evidence linked the same 49-manager fanout to paired traffic, but the candidate has not been rerun against an isolated live paired server.",
|
||||
"coverageNotes": "Deterministic renderer contracts require foreground renderer-risk, synchronized, hidden, and alternate-screen PTY output to stay on pane-local repaint paths without a global atlas reset. Separate contracts preserve explicit paste, reveal, visibility-resume, system-resume, and confirmed-desync recovery. A macOS Electron run verifies that a document visibility cycle preserves real WebGL atlases and terminal pixels; the candidate has not been rerun against an isolated live paired server.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/7054",
|
||||
"https://github.com/stablyai/orca/pull/7604",
|
||||
"https://github.com/stablyai/orca/issues/12094"
|
||||
],
|
||||
"invariant": "Ordinary document visibility transitions and hidden terminal output must not clear the shared WebGL glyph atlas. Heavy reset-and-refresh recovery may touch only managers with visible terminal surfaces; hidden managers recover when revealed. Sustained streaming recovery requests must keep resets rate-bounded without starving repair. Genuine OS resume remains a heavy recovery trigger.",
|
||||
"oracle": "Dispatch a visible document visibilitychange and require atlas-preserving wake recovery; register one visible manager and 64 hidden managers and require exactly one reset and one refresh; parse hidden synchronized and high-confidence TUI output and require zero global atlas-recovery schedules. Across five minutes of 300ms streaming recovery requests, require more than one but at most 75 resets, no internal or trailing repair gap above 6.5 seconds, atlas-preserving presentation while a reset is suppressed, and one delayed wipe for a final suppressed settle. Separately, drive an Electron visibility cycle with two real WebGL panes, require zero atlas clears, and retain at least 85% of each pane's baseline ink pixels.",
|
||||
"invariant": "Ordinary PTY output must never clear the shared WebGL glyph atlas, including synchronized frames, hidden output, renderer-risk glyphs, and alternate-screen entry, redraw, or exit. Affected visible panes still receive pane-local refresh after parsing. Explicit paste, reveal, visibility-resume, system-resume, context-loss, and confirmed-desync recovery remain available and may reset visible renderers.",
|
||||
"oracle": "Parse foreground renderer-risk, synchronized, hidden, and alternate-screen PTY output and require pane-local refresh where needed with zero calls to shared reset-and-refresh recovery. Require the atlas-recovery module to expose only explicit lifecycle entry points, then verify paste and tab reveal still perform their recovery bursts. Dispatch a visible document visibilitychange and require atlas-preserving wake recovery; register one visible manager and 64 hidden managers and require heavy recovery to reset and refresh only the visible manager. Separately, drive an Electron visibility cycle with two real WebGL panes, require zero atlas clears, and retain at least 85% of each pane's baseline ink pixels.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery-rate.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery-rate.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts",
|
||||
"src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts",
|
||||
"src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts"
|
||||
],
|
||||
@@ -8930,22 +9032,28 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery-rate.test.ts",
|
||||
"assertions": [
|
||||
"sustained streaming requests keep atlas resets rate-bounded with no repair starvation",
|
||||
"suppressed resets present live buffers, cancel repair during resumed output, receive a trailing repair after settle, and clean up pending timers"
|
||||
]
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts",
|
||||
"assertions": ["limits atlas recovery to explicit renderer lifecycle events"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts",
|
||||
"assertions": ["bounds atlas recovery to visible managers"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts",
|
||||
"assertions": ["persists and recovers after the same cells stay missing twice"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts",
|
||||
"assertions": ["recovers a context-lost pane on the next rendering resume"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"assertions": [
|
||||
"defers hidden synchronized-output atlas recovery until reveal",
|
||||
"defers hidden high-confidence TUI redraw recovery until reveal",
|
||||
"advances hidden rewrite state without scheduling atlas recovery"
|
||||
"keeps hidden synchronized output off the global atlas recovery path",
|
||||
"refreshes renderer-risk output without clearing the shared glyph atlas",
|
||||
"refreshes alternate-screen redraws without clearing the shared glyph atlas",
|
||||
"avoids atlas clears when one chunk enters and exits alternate screen"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -8957,22 +9065,13 @@
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-04",
|
||||
"date": "2026-08-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery-rate.test.ts",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.5,
|
||||
"summary": "Seven deterministic tests passed for sustained reset cadence, bounded repair gaps, suppressed presentation with trailing repair, clock rollback, one-shot bypasses, and pending-timer cleanup."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-01",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 26,
|
||||
"summary": "All 577 focused renderer contracts passed, including atlas-preserving visibility, visible-only recovery fanout, and hidden-output rewrite-state coverage."
|
||||
"durationSeconds": 47,
|
||||
"summary": "All 625 focused renderer contracts passed on the final head for atlas-preserving visibility, visible-only heavy recovery fanout, confirmed-desync and context-loss recovery, explicit lifecycle recovery, pane-local foreground redraws, and zero ordinary-output atlas resets."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-01",
|
||||
@@ -9003,15 +9102,15 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "With the fix disabled, the byte-identical unit oracle observed clearGlyphAtlases=true, reset/refreshed all 65 managers, and scheduled hidden synchronized/TUI recovery one to three times. Restoring the fix made every assertion pass."
|
||||
"evidence": "Before the change, foreground renderer-risk and alternate-screen PTY writes requested terminal-output atlas recovery after parsing; hidden synchronized/TUI output also drove the same path. The updated oracle spies on that request before its removed 200ms debounce and on the eventual shared reset, while explicit lifecycle recovery remains required."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "A recovery with one visible and 64 hidden managers performs one reset and one refresh instead of 65 of each. Hidden synchronized/TUI output schedules zero global recovery work. Sustained 300ms recovery requests keep full atlas resets at or below 75 over five minutes while preserving a 6.5-second maximum repair gap. A final suppressed settle schedules one cancelable repair at the next budget boundary; resumed streaming cancels it. The rate budget adds no polling, recurring timer, provider call, or parking change."
|
||||
"evidence": "A 120-frame foreground redraw at 300ms cadence produced 13 terminal-output atlas reset cycles before the change and zero afterward over 37.470 seconds. Ordinary PTY output now adds no atlas debounce, retry timer, buffer-switch listener, global manager traversal, provider call, or recurring work; pane-local refresh remains for renderer-risk output. Explicit heavy recovery still touches visible managers only."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Accumulate stable macOS Electron runs with real BrowserWindow visibility transitions.",
|
||||
"Run an isolated headed paired-server terminal flood and verify bounded renderer CPU and atlas diagnostics.",
|
||||
"Run an isolated headed paired-server terminal flood and verify renderer CPU and atlas diagnostics remain flat under ordinary output.",
|
||||
"Add Linux and Windows WebGL visibility evidence before claiming cross-platform visual coverage."
|
||||
],
|
||||
"knownGaps": [
|
||||
@@ -9019,7 +9118,7 @@
|
||||
"No isolated live paired-server candidate run is recorded; remote-runtime coverage is a provider-agnostic renderer contract plus production incident evidence.",
|
||||
"The gate counts recovery fanout and pixel retention but does not impose an end-to-end renderer frame-latency threshold."
|
||||
],
|
||||
"demotionRule": "Demote if hidden managers re-enter reset/refresh recovery, ordinary visibility clears the atlas, real WebGL pixels regress, or the focused Electron test cannot remain deterministic."
|
||||
"demotionRule": "Demote if ordinary PTY output re-enters shared reset/refresh recovery, pane-local redraw coverage regresses, ordinary visibility clears the atlas, real WebGL pixels regress, or the focused Electron test cannot remain deterministic."
|
||||
},
|
||||
{
|
||||
"id": "terminal-render.atlas-identity-invalidation",
|
||||
|
||||
@@ -18,6 +18,7 @@ const __dirname = import.meta.dirname
|
||||
const ROOT = join(__dirname, '..', '..')
|
||||
const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts')
|
||||
const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts')
|
||||
const AI_VAULT_SERVICE_ENTRY = join(ROOT, 'src', 'relay', 'ai-vault-service-entry.ts')
|
||||
const MANAGED_HOOK_RUNTIME_ENTRY = join(
|
||||
ROOT,
|
||||
'src',
|
||||
@@ -88,6 +89,21 @@ for (const platform of PLATFORMS) {
|
||||
}
|
||||
})
|
||||
|
||||
await build({
|
||||
entryPoints: [AI_VAULT_SERVICE_ENTRY],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: join(outDir, 'relay-ai-vault-service.js'),
|
||||
external: ['electron'],
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"'
|
||||
}
|
||||
})
|
||||
|
||||
await build({
|
||||
entryPoints: [MANAGED_HOOK_RUNTIME_ENTRY],
|
||||
bundle: true,
|
||||
@@ -110,10 +126,12 @@ for (const platform of PLATFORMS) {
|
||||
// so a companion-only change always deploys beside the matching relay host.
|
||||
const relayContent = readFileSync(join(outDir, 'relay.js'))
|
||||
const watcherContent = readFileSync(join(outDir, 'relay-watcher.js'))
|
||||
const aiVaultServiceContent = readFileSync(join(outDir, 'relay-ai-vault-service.js'))
|
||||
const managedHookRuntimeContent = readFileSync(join(outDir, 'managed-hook-runtime.js'))
|
||||
const hash = createHash('sha256')
|
||||
.update(relayContent)
|
||||
.update(watcherContent)
|
||||
.update(aiVaultServiceContent)
|
||||
.update(managedHookRuntimeContent)
|
||||
// Why: changing the remote node-pty patch must select a fresh immutable Windows relay directory.
|
||||
if (platform.startsWith('win32-')) {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
// Run: node config/scripts/legacy-worker-recovery-persistence-benchmark.mjs
|
||||
import { closeSync, fsyncSync, openSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtemp, open, readFile, rename } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const runtimePath = join(repoRoot, 'src/main/runtime/orca-runtime.ts')
|
||||
const args = new Map(
|
||||
process.argv.slice(2).map((value, index, values) => [value, values[index + 1]])
|
||||
)
|
||||
const fixtureMiB = Number(args.get('--fixture-mib') ?? 24)
|
||||
const trials = Number(args.get('--trials') ?? 3)
|
||||
const jsonOutput = process.argv.includes('--json')
|
||||
|
||||
if (!Number.isInteger(fixtureMiB) || fixtureMiB < 1 || !Number.isInteger(trials) || trials < 1) {
|
||||
throw new Error('fixture-mib and trials must be positive integers')
|
||||
}
|
||||
|
||||
const runtimeSource = await readFile(runtimePath, 'utf8')
|
||||
const recoveryStart = runtimeSource.indexOf(
|
||||
'private async persistLegacyWorkerTerminalRecoveryBatch'
|
||||
)
|
||||
const recoveryEnd = runtimeSource.indexOf(
|
||||
'private reconcileMissingLegacyWorkerTerminal',
|
||||
recoveryStart
|
||||
)
|
||||
const recoverySource = runtimeSource.slice(recoveryStart, recoveryEnd)
|
||||
if (
|
||||
recoveryStart < 0 ||
|
||||
recoveryEnd < 0 ||
|
||||
!recoverySource.includes('await this.flushWorkspaceSessionOrThrowAsync()') ||
|
||||
recoverySource.includes('flushOrThrow()')
|
||||
) {
|
||||
throw new Error('recovery persistence implementation changed; update this benchmark')
|
||||
}
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-legacy-recovery-benchmark-'))
|
||||
const filler = 'x'.repeat(fixtureMiB * 1024 * 1024)
|
||||
|
||||
function payload(state) {
|
||||
return JSON.stringify({ state, filler })
|
||||
}
|
||||
|
||||
function writeDurableSync(path, body) {
|
||||
const tempPath = `${path}.sync.tmp`
|
||||
writeFileSync(tempPath, body)
|
||||
const fd = openSync(tempPath, 'r')
|
||||
try {
|
||||
fsyncSync(fd)
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
renameSync(tempPath, path)
|
||||
}
|
||||
|
||||
async function writeDurableAsync(path, body) {
|
||||
const tempPath = `${path}.async.tmp`
|
||||
const handle = await open(tempPath, 'w')
|
||||
try {
|
||||
await handle.writeFile(body)
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
await rename(tempPath, path)
|
||||
}
|
||||
|
||||
async function measure(run) {
|
||||
let maxEventLoopDelayMs = 0
|
||||
let expected = performance.now() + 1
|
||||
const timer = setInterval(() => {
|
||||
const now = performance.now()
|
||||
maxEventLoopDelayMs = Math.max(maxEventLoopDelayMs, now - expected)
|
||||
expected = now + 1
|
||||
}, 1)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
const startedAt = performance.now()
|
||||
await run()
|
||||
const durationMs = performance.now() - startedAt
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
clearInterval(timer)
|
||||
return { durationMs, maxEventLoopDelayMs }
|
||||
}
|
||||
|
||||
function median(values) {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return sorted[Math.floor(sorted.length / 2)]
|
||||
}
|
||||
|
||||
async function runLegacy(path) {
|
||||
const state = { fenced: false, surfacePresent: true, recoveryRecordPresent: true }
|
||||
state.fenced = true
|
||||
writeDurableSync(path, payload(state))
|
||||
state.surfacePresent = false
|
||||
writeDurableSync(path, payload(state))
|
||||
state.recoveryRecordPresent = false
|
||||
writeDurableSync(path, payload(state))
|
||||
}
|
||||
|
||||
async function runBatched(path) {
|
||||
const state = { fenced: false, surfacePresent: true, recoveryRecordPresent: true }
|
||||
state.fenced = true
|
||||
state.surfacePresent = false
|
||||
state.recoveryRecordPresent = false
|
||||
await writeDurableAsync(path, payload(state))
|
||||
}
|
||||
|
||||
try {
|
||||
const legacy = []
|
||||
const batched = []
|
||||
for (let index = 0; index < trials; index += 1) {
|
||||
legacy.push(await measure(() => runLegacy(join(root, `legacy-${index}.json`))))
|
||||
batched.push(await measure(() => runBatched(join(root, `batched-${index}.json`))))
|
||||
}
|
||||
const result = {
|
||||
benchmark: 'legacy-worker-recovery-persistence',
|
||||
fixtureMiB,
|
||||
trials,
|
||||
legacy: {
|
||||
durableWrites: 3,
|
||||
medianDurationMs: median(legacy.map((sample) => sample.durationMs)),
|
||||
medianMaxEventLoopDelayMs: median(legacy.map((sample) => sample.maxEventLoopDelayMs))
|
||||
},
|
||||
batchedAsync: {
|
||||
durableWrites: 1,
|
||||
medianDurationMs: median(batched.map((sample) => sample.durationMs)),
|
||||
medianMaxEventLoopDelayMs: median(batched.map((sample) => sample.maxEventLoopDelayMs))
|
||||
}
|
||||
}
|
||||
if (jsonOutput) {
|
||||
console.log(JSON.stringify(result))
|
||||
} else {
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
@@ -197,8 +197,13 @@ describe('Electron runtime package contract', () => {
|
||||
expect(relayBuild).toContain("'parcel-watcher-process-entry.ts'")
|
||||
expect(relayBuild).toContain("outfile: join(outDir, 'relay-watcher.js')")
|
||||
expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-watcher.js'))")
|
||||
expect(relayBuild).toContain("outfile: join(outDir, 'relay-ai-vault-service.js')")
|
||||
expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-ai-vault-service.js'))")
|
||||
expect(builderConfig).toContain("from: 'out/relay'")
|
||||
expect(remoteCommands).toContain("joinRemotePath(host, remoteRelayDir, 'relay-watcher.js')")
|
||||
expect(remoteCommands).toContain(
|
||||
"joinRemotePath(host, remoteRelayDir, 'relay-ai-vault-service.js')"
|
||||
)
|
||||
|
||||
const assertRelayGate = (steps, publishStepName) => {
|
||||
const names = steps.map((step) => step.name)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx'
|
||||
const knobByFlag = {
|
||||
'--iterations': 'ORCA_AI_VAULT_BENCH_ITERATIONS',
|
||||
'--sessions': 'ORCA_AI_VAULT_BENCH_SESSIONS',
|
||||
'--payload-kib': 'ORCA_AI_VAULT_BENCH_PAYLOAD_KIB',
|
||||
'--keys': 'ORCA_AI_VAULT_BENCH_KEYS',
|
||||
'--cadence-ms': 'ORCA_AI_VAULT_BENCH_CADENCE_MS',
|
||||
'--label': 'ORCA_AI_VAULT_BENCH_LABEL'
|
||||
}
|
||||
|
||||
const env = { ...process.env, ORCA_AI_VAULT_TYPING_BENCH: '1' }
|
||||
const passthroughArgs = []
|
||||
const argv = process.argv.slice(2)
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
if (argv[index] === '--') {
|
||||
continue
|
||||
}
|
||||
const knob = knobByFlag[argv[index]]
|
||||
if (knob) {
|
||||
env[knob] = argv[++index]
|
||||
} else {
|
||||
passthroughArgs.push(argv[index])
|
||||
}
|
||||
}
|
||||
|
||||
const child = spawn(
|
||||
npxCommand,
|
||||
[
|
||||
'playwright',
|
||||
'test',
|
||||
'tests/e2e/terminal-ai-vault-typing-latency.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--workers=1',
|
||||
...passthroughArgs
|
||||
],
|
||||
{ stdio: 'inherit', env }
|
||||
)
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
return
|
||||
}
|
||||
process.exit(code ?? 1)
|
||||
})
|
||||
@@ -153,6 +153,7 @@ function prepareRelayTree(runDir, nodePtyDir) {
|
||||
for (const filename of [
|
||||
'relay.js',
|
||||
'relay-watcher.js',
|
||||
'relay-ai-vault-service.js',
|
||||
'managed-hook-runtime.js',
|
||||
NODE_PTY_PATCH_FILENAME,
|
||||
'.version'
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env node
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { planWorktreeSortOrderUpdates } from '../../src/shared/worktree-sort-order-update.ts'
|
||||
import { reuseEqualCatalogRows } from '../../src/renderer/src/store/slices/worktree-catalog-reconciliation.ts'
|
||||
|
||||
const WORKTREE_COUNT = 655
|
||||
const TAB_COUNT = 1_895
|
||||
const REFRESH_COUNT = 10
|
||||
const ROUNDS = 15
|
||||
const BATCHES_PER_ROUND = 25
|
||||
|
||||
function makeFixture() {
|
||||
const worktrees = Array.from({ length: WORKTREE_COUNT }, (_, index) => ({
|
||||
id: `repo-${index % 15}::/workspace/${index}`,
|
||||
sortOrder: WORKTREE_COUNT - index,
|
||||
lastActivityAt: index % 19,
|
||||
isArchived: false
|
||||
}))
|
||||
const tabs = Array.from({ length: TAB_COUNT }, (_, index) => ({
|
||||
worktreeId: worktrees[index % worktrees.length].id,
|
||||
live: index % 3 === 0
|
||||
}))
|
||||
return { worktrees, tabs }
|
||||
}
|
||||
|
||||
function runSidebarProjection(worktrees, tabs) {
|
||||
const liveTabsByWorktree = new Map()
|
||||
for (const tab of tabs) {
|
||||
if (tab.live) {
|
||||
liveTabsByWorktree.set(tab.worktreeId, (liveTabsByWorktree.get(tab.worktreeId) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
const sorted = [...worktrees].sort(
|
||||
(left, right) =>
|
||||
(liveTabsByWorktree.get(right.id) ?? 0) - (liveTabsByWorktree.get(left.id) ?? 0) ||
|
||||
right.lastActivityAt - left.lastActivityAt ||
|
||||
right.sortOrder - left.sortOrder
|
||||
)
|
||||
return sorted.map((worktree) => worktree.id)
|
||||
}
|
||||
|
||||
function runBaseline() {
|
||||
const fixture = makeFixture()
|
||||
const metaById = new Map(fixture.worktrees.map((worktree) => [worktree.id, worktree]))
|
||||
const requestedIds = fixture.worktrees.map((worktree) => worktree.id)
|
||||
let catalog = fixture.worktrees
|
||||
let publications = 0
|
||||
let persistenceWrites = 0
|
||||
for (let refresh = 0; refresh < REFRESH_COUNT; refresh += 1) {
|
||||
const now = 1_000_000 + refresh
|
||||
requestedIds.forEach((worktreeId, index) => {
|
||||
metaById.set(worktreeId, {
|
||||
...metaById.get(worktreeId),
|
||||
sortOrder: now - index * 1000
|
||||
})
|
||||
persistenceWrites += 1
|
||||
})
|
||||
const incoming = structuredClone(requestedIds.map((worktreeId) => metaById.get(worktreeId)))
|
||||
const reconciled = reuseEqualCatalogRows(catalog, incoming)
|
||||
if (reconciled !== catalog) {
|
||||
catalog = reconciled
|
||||
publications += 1
|
||||
runSidebarProjection(catalog, fixture.tabs)
|
||||
}
|
||||
}
|
||||
return { publications, persistenceWrites }
|
||||
}
|
||||
|
||||
function runCandidate() {
|
||||
const fixture = makeFixture()
|
||||
const metaById = new Map(fixture.worktrees.map((worktree) => [worktree.id, worktree]))
|
||||
const requestedIds = fixture.worktrees.map((worktree) => worktree.id)
|
||||
let catalog = fixture.worktrees
|
||||
let publications = 0
|
||||
let persistenceWrites = 0
|
||||
for (let refresh = 0; refresh < REFRESH_COUNT; refresh += 1) {
|
||||
const updates = planWorktreeSortOrderUpdates(
|
||||
requestedIds,
|
||||
(worktreeId) => metaById.get(worktreeId),
|
||||
1_000_000 + refresh
|
||||
)
|
||||
for (const update of updates) {
|
||||
metaById.set(update.worktreeId, {
|
||||
...metaById.get(update.worktreeId),
|
||||
sortOrder: update.sortOrder
|
||||
})
|
||||
}
|
||||
persistenceWrites += updates.length
|
||||
const incoming = structuredClone(requestedIds.map((worktreeId) => metaById.get(worktreeId)))
|
||||
const reconciled = reuseEqualCatalogRows(catalog, incoming)
|
||||
if (reconciled !== catalog) {
|
||||
catalog = reconciled
|
||||
publications += 1
|
||||
runSidebarProjection(catalog, fixture.tabs)
|
||||
}
|
||||
}
|
||||
return { publications, persistenceWrites }
|
||||
}
|
||||
|
||||
function measureBatch(run) {
|
||||
let counters
|
||||
const startedAt = performance.now()
|
||||
for (let batch = 0; batch < BATCHES_PER_ROUND; batch += 1) {
|
||||
counters = run()
|
||||
}
|
||||
return { elapsedMs: (performance.now() - startedAt) / BATCHES_PER_ROUND, counters }
|
||||
}
|
||||
|
||||
function measureComparison() {
|
||||
runBaseline()
|
||||
runCandidate()
|
||||
const samples = Array.from({ length: ROUNDS }, (_, round) => {
|
||||
let baseline
|
||||
let candidate
|
||||
if (round % 2 === 0) {
|
||||
baseline = measureBatch(runBaseline)
|
||||
candidate = measureBatch(runCandidate)
|
||||
} else {
|
||||
candidate = measureBatch(runCandidate)
|
||||
baseline = measureBatch(runBaseline)
|
||||
}
|
||||
return { baseline, candidate, ratio: candidate.elapsedMs / baseline.elapsedMs }
|
||||
}).sort((left, right) => left.ratio - right.ratio)
|
||||
return samples[Math.floor(samples.length / 2)]
|
||||
}
|
||||
|
||||
const { baseline, candidate } = measureComparison()
|
||||
if (baseline.counters.publications !== REFRESH_COUNT || candidate.counters.publications !== 0) {
|
||||
throw new Error('Benchmark fixture no longer reproduces the refresh feedback loop')
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Worktree refresh churn: ${WORKTREE_COUNT} worktrees, ${TAB_COUNT} tabs, ${REFRESH_COUNT} refreshes`
|
||||
)
|
||||
console.log(
|
||||
`Before: ${baseline.elapsedMs.toFixed(3)} ms, ${baseline.counters.publications} catalog publications, ${baseline.counters.persistenceWrites} rank writes`
|
||||
)
|
||||
console.log(
|
||||
`After: ${candidate.elapsedMs.toFixed(3)} ms, ${candidate.counters.publications} catalog publications, ${candidate.counters.persistenceWrites} rank writes`
|
||||
)
|
||||
console.log(
|
||||
`Reduction: ${((1 - candidate.elapsedMs / baseline.elapsedMs) * 100).toFixed(1)}% CPU, 100% feedback publications`
|
||||
)
|
||||
@@ -0,0 +1,596 @@
|
||||
# AI Vault process isolation architecture and implementation plan
|
||||
|
||||
Status: implemented behind the documented desktop/runtime kill switch, with local build, parity, Electron, and A/B validation complete.
|
||||
|
||||
Last updated: 2026-08-09.
|
||||
|
||||
## Decision summary
|
||||
|
||||
AI Vault will remain part of the integrated Orca renderer, but its host-side work will move behind a persistent service-process boundary.
|
||||
|
||||
The target has three rules:
|
||||
|
||||
1. The desktop and Orca runtime route local Vault scans and title resolution to one lazy, supervised Vault service process per host process.
|
||||
2. The SSH relay routes Vault work to a relay-side Vault service process. The relay event loop that handles PTYs must not scan or parse Vault data.
|
||||
3. The renderer publishes completed Vault results at low priority after terminal input is quiet. xterm and the Vault panel remain in the same renderer.
|
||||
|
||||
This is deliberately not a separate `WebContentsView`, window, or embedded app. The measurements below show bounded CPU contention under an exaggerated scan, but no renderer long tasks and no evidence that a second Chromium renderer is justified.
|
||||
|
||||
The public Electron IPC, runtime RPC, and SSH relay method names and result meanings remain unchanged in the initial migration. The process boundary is host-internal.
|
||||
|
||||
## Goals
|
||||
|
||||
- A Vault scan, parser fault, cache fault, or memory spike cannot crash the terminal daemon, relay PTY loop, Electron main process, or runtime host.
|
||||
- Terminal input remains responsive while a local, runtime, or SSH Vault refresh is active.
|
||||
- Vault work has bounded concurrency, queue depth, memory, result size, cancellation latency, and wall time.
|
||||
- The service can be restarted without restarting the app, runtime, relay, or terminals.
|
||||
- Existing local, folder-workspace, WSL, runtime, and SSH behavior remains compatible.
|
||||
- Mixed-version remote peers continue to work.
|
||||
- The architecture is conventional and debuggable: integrated UI, thin routers, dedicated background services.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Treating the service process as a security sandbox. It runs trusted Orca code with the same user identity.
|
||||
- Rewriting the scanner or changing session discovery semantics during the process migration.
|
||||
- Moving xterm into another renderer.
|
||||
- Adding a process per window, worktree, repository, or SSH request.
|
||||
- Removing the old-relay compatibility fallback in the same change.
|
||||
- Making pagination a public wire dependency before mixed-version fallback behavior exists.
|
||||
|
||||
## Current architecture and coupling
|
||||
|
||||
| Path | Current execution | Remaining coupling |
|
||||
| --------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Desktop local scan and title resolution | Persistent `worker_threads.Worker` from `session-scanner-worker-spawn.ts` | Separate V8 isolate, but the same OS process, priority class, failure domain, and overall memory accounting as Electron main |
|
||||
| Orca runtime scan and title resolution | The same worker client used by runtime RPC methods | Same-process CPU, memory, and lifecycle coupling with the runtime host |
|
||||
| SSH relay scan and title resolution | `AiVaultHandler` calls the remote scanner and title reader inside `relay.ts` | Vault discovery, reads, parsing, cache work, and PTY routing share one event loop and process |
|
||||
| Old SSH relay fallback | Desktop main crawls the host through the SSH filesystem provider | Compatibility path can consume desktop and SSH multiplexer work; complete remote isolation is impossible without a new relay |
|
||||
| Renderer result publication | `setScanResult(result)` and `setSessions(result.sessions)` immediately | Deserialization, projection, filtering, grouping, and React rendering share the renderer thread with xterm |
|
||||
|
||||
The current local worker already provides meaningful isolation. It allows one active request, bounds the queue at 16, supports cancellation, and uses 130-second scan and 15-second title timeouts. The migration should preserve those properties instead of replacing them with an unbounded child-process API.
|
||||
|
||||
The terminal backend is already a detached daemon process. A Vault service beside it fits the existing process model; it does not require a terminal redesign.
|
||||
|
||||
## Feature-parity contract
|
||||
|
||||
The migration changes execution ownership only. Unless a separate product change is approved, the service-on and worker-fallback paths must be observably equivalent after normalizing `scannedAt` and process metrics.
|
||||
|
||||
| Capability | Required behavior after migration | Owner |
|
||||
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| Agent discovery | Preserve every `AI_VAULT_AGENTS` source: Claude, Codex, Hermes, Pi, OMP, Prime Agent, Cursor, Gemini, Antigravity, Rovo Dev, Copilot, OpenCode, Grok, OpenClaw, Devin, Droid, and Kimi | Service scanner, using existing source adapters |
|
||||
| Session result | Preserve every `AiVaultSession` field, including host/platform identity, paths, Codex home, timestamps, previews, token/message counts, queued/recoverable state, subagent count, resume command, and subagent metadata | Service result builder; router validates/restamps only where it does today |
|
||||
| Scope and depth | Preserve workspace/project/all scoping, guaranteed older in-scope sessions, 250/500/1000/unlimited depth, sorting, deduplication, and issue rows | Service per-host scan; router multi-host merge; renderer view projection |
|
||||
| Host routing | Preserve local, all-host, individual SSH, and individual runtime selection with the same per-host time budgets and partial-failure issue behavior | Router |
|
||||
| Dynamic roots | Preserve environment overrides, managed/per-account Codex homes, runtime Codex homes, WSL default and Orca-owned homes, and every platform-specific agent root | Router resolves dynamic homes per request; service discovers within them |
|
||||
| Refresh semantics | Preserve TTL reuse, force refresh, request-token cancellation, force preemption, coalescing, window-focus refresh, and new-agent-session refresh throttling | Router coordinator plus service cancellation |
|
||||
| Title synchronization | Preserve local, SSH, and runtime Claude/Codex title resolution and the existing input-quiet tab-title gate | Service title operation plus existing host routing |
|
||||
| Subagent expansion | Preserve local-only Claude and OMP child listing, path containment, status, issue rows, and retry behavior. Runtime/SSH/web remain empty until a separately negotiated feature exists | Service interactive operation; router retains current host gate |
|
||||
| First-prompt copy | Preserve local-only on-demand full prompt parsing, OpenCode row lookup, truncation safety, injected-turn suppression, and preview fallback. Runtime/SSH/web remain preview-only | Service interactive operation; router retains current host gate |
|
||||
| Resume | Preserve provider-specific resume commands, Codex-home preparation, account targeting, execution-host targeting, platform quoting, OMP path resume, drag/drop, and new-tab launch | Existing router/renderer paths; they consume an unchanged session result |
|
||||
| Logs and live state | Preserve View Log/Open Log, reveal/copy path, working-directory action, read-only editor behavior, live tail, original-pane jump, and worktree jump | Existing filesystem/editor/native-chat paths; outside the service migration |
|
||||
| Delete | Preserve local-only gating, liveness checks, OS trash/WSL deletion, companion artifacts, idempotency, and rejection reasons | Trusted router executor; invalidate router and service caches on success |
|
||||
| View behavior | Preserve search, agent toggles, sort/group, hide-empty/recoverable-empty logic, persisted view options, collapsed groups, and host/scope controls | Renderer; unchanged apart from publication scheduling |
|
||||
| Compatibility | Preserve current web/runtime limitations, old-relay fallback, mixed-version result meaning, and unavailable-host issue rows | Router and existing public transports |
|
||||
|
||||
Add a backend-equivalence suite that runs the same golden corpus through the current worker and the service process, normalizes only `scannedAt`, and deep-compares the complete results. The corpus must cover every agent, OpenCode SQLite and legacy formats, append-only incremental parsing, recoverable empty sessions, Claude/OMP subagents, managed Codex homes, duplicate Codex roots, WSL-shaped roots, scope inclusion, corrupt/partial files, and issue overflow.
|
||||
|
||||
No field may be dropped to reduce IPC size during the migration. Any later payload reduction requires an independently versioned compatibility plan because resume, drag/drop, log actions, and project ownership consume fields that may not be visibly rendered in the list.
|
||||
|
||||
## Target topology
|
||||
|
||||
```text
|
||||
Desktop / runtime host
|
||||
|
||||
Integrated renderer
|
||||
├─ xterm UI ─────────── terminal IPC/router ───────── terminal daemon
|
||||
└─ AI Vault panel ───── Vault IPC/RPC router ──────── Vault service process
|
||||
│
|
||||
└─ host merge + compatibility routing
|
||||
|
||||
SSH execution host
|
||||
|
||||
Desktop ── existing SSH Vault RPC ── relay router ───── Vault service sidecar
|
||||
└────────────────── PTY handler/event loop
|
||||
```
|
||||
|
||||
The two relay branches share only the relay's bounded request/response routing. Filesystem discovery, transcript reads, JSON parsing, SQLite access, parse caches, and title caches execute in the sidecar.
|
||||
|
||||
## Process ownership
|
||||
|
||||
- Desktop: one Vault service per Electron main process, shared by every window and workspace.
|
||||
- Orca runtime: one Vault service per runtime host process, shared by all connected clients.
|
||||
- SSH relay: one Vault service per live relay daemon, shared across relay reconnects and requests.
|
||||
- WSL: preserve current source ownership initially. The Windows local service invokes the existing WSL-aware adapters; do not add a process per distro in this migration.
|
||||
- Old relay: retain the bounded desktop fallback only when the relay method is unavailable. A new relay whose sidecar fails must return an issue instead of scanning inline on the relay loop.
|
||||
|
||||
## Service responsibilities
|
||||
|
||||
The service owns:
|
||||
|
||||
- Source discovery and filesystem metadata reads.
|
||||
- Transcript streaming and parsing for all supported agents.
|
||||
- OpenCode SQLite reads and its nested worker lifecycle.
|
||||
- In-memory and persisted session parse caches.
|
||||
- The bounded title index and transcript title resolution.
|
||||
- On-demand Claude/OMP subagent listing and full first-user-prompt extraction.
|
||||
- Scan parse concurrency and cooperative cancellation.
|
||||
- Result construction, deduplication, sorting, and internal paging.
|
||||
- Per-request metrics that contain counts and timings, not session content.
|
||||
|
||||
The router owns:
|
||||
|
||||
- Electron IPC, runtime RPC, or relay method registration.
|
||||
- Authentication and connection ownership already enforced by that transport.
|
||||
- Public argument validation and execution-host selection.
|
||||
- Resolution of dynamic host inputs such as managed Codex homes and WSL distro homes; resolved roots are passed with each scan so a long-lived service cannot retain stale account configuration.
|
||||
- Multi-host result merging.
|
||||
- Request coalescing and mapping caller cancellation to the service request.
|
||||
- Validation of service responses before publishing them across a public boundary.
|
||||
- Compatibility fallback selection.
|
||||
- Local deletion, liveness checks, OS trash integration, and cache-invalidation commands sent to the service after a successful mutation.
|
||||
|
||||
The renderer owns only view state, filtering/grouping of the returned page, user actions, and low-priority result publication.
|
||||
|
||||
## Internal process protocol
|
||||
|
||||
Add a versioned, private protocol in `src/main/ai-vault/session-scanner-service-protocol.ts`. It is not a remote wire protocol.
|
||||
|
||||
Parent to service:
|
||||
|
||||
```ts
|
||||
type ServiceRequest =
|
||||
| { type: 'init'; protocol: 1; host: HostDescriptor; cache: CacheOptions }
|
||||
| { type: 'request'; id: number; operation: 'scan'; options: ScanOptions }
|
||||
| { type: 'request'; id: number; operation: 'titles'; requests: TitleRequest[] }
|
||||
| { type: 'request'; id: number; operation: 'subagents'; request: SubagentRequest }
|
||||
| { type: 'request'; id: number; operation: 'firstPrompt'; request: FirstPromptRequest }
|
||||
| { type: 'invalidate'; paths: string[]; generation: number }
|
||||
| { type: 'cancel'; id: number }
|
||||
| { type: 'shutdown' }
|
||||
```
|
||||
|
||||
Service to parent:
|
||||
|
||||
```ts
|
||||
type ServiceResponse =
|
||||
| { type: 'ready'; protocol: 1; pid: number; capabilities: ServiceCapabilities }
|
||||
| { type: 'result'; id: number; operation: 'scan'; value: ScanPage; metrics: ScanMetrics }
|
||||
| { type: 'result'; id: number; operation: 'titles'; value: TitlesResult }
|
||||
| { type: 'result'; id: number; operation: 'subagents'; value: SubagentListResult }
|
||||
| { type: 'result'; id: number; operation: 'firstPrompt'; value: FirstPromptResult }
|
||||
| { type: 'invalidated'; generation: number }
|
||||
| { type: 'error'; id: number; code: ServiceErrorCode; message: string; retryable: boolean }
|
||||
| { type: 'fatal'; code: ServiceErrorCode; message: string }
|
||||
```
|
||||
|
||||
Protocol rules:
|
||||
|
||||
- Validate every incoming and outgoing message. Ignore an unknown response ID and terminate on a protocol-version mismatch.
|
||||
- Use monotonically increasing safe-integer request IDs scoped to the child lifetime.
|
||||
- Preserve the existing shared limits for request counts, scope-path count and length, title requests, and session depth.
|
||||
- Keep one cache-mutating lane for scans and title resolution, plus one independent interactive-read lane for subagent and first-prompt reads. Title resolution stays serialized with scans because both mutate the shared parse/title caches; the independent operations do not. Queue at most 16 calls across both lanes.
|
||||
- Coalesce equivalent scans. Replace an older queued background scan with the newest one.
|
||||
- Queue priority is first-prompt/subagent reads, queued title reads, forced foreground refresh, initial foreground load, then background refresh. Priority never preempts an active request; explicit cancellation does.
|
||||
- Keep the existing 130-second scan and 15-second title deadlines. A timeout terminates and replaces the service because a timed-out parser cannot be assumed healthy.
|
||||
- Cancellation rejects the caller immediately, sends `cancel`, and gives the service 2 seconds to acknowledge or finish. The service is terminated if it remains stuck.
|
||||
- Continue returning the last good renderer result when a refresh fails.
|
||||
- Invalidation is a control message, not queued scan work. The parent invalidates its result/host caches immediately and waits for the service acknowledgement before allowing a non-forced post-delete list result to reuse service cache state.
|
||||
- Instrument serialized response size. Warn at 8 MiB. Do not reject legacy unlimited results until internal paging and public fallback are implemented.
|
||||
|
||||
The first implementation may reuse the current plain-object Node IPC serialization. The payload does not require handles, sockets, or transferable buffers.
|
||||
|
||||
## Lifecycle and supervision
|
||||
|
||||
### Startup
|
||||
|
||||
- Start lazily on the first scan or title request.
|
||||
- Resolve an explicit entry path; packaged Electron must use `app.asar.unpacked` because `ELECTRON_RUN_AS_NODE=1` bypasses Electron's asar loader.
|
||||
- Fork with `stdio: ['ignore', 'ignore', 'pipe', 'ipc']`, `ELECTRON_RUN_AS_NODE=1`, and `windowsHide: true` on Windows.
|
||||
- Require a `ready` message within 5 seconds. A request does not enter its operation timeout until the service is ready.
|
||||
- Pipe a bounded stderr tail into the existing diagnostic log. Never allow child output backpressure to stall the child.
|
||||
|
||||
### Steady state
|
||||
|
||||
- Reuse the process and caches across calls.
|
||||
- Keep current parse concurrency at eight for the first A/B. Process isolation is not permission to increase concurrency.
|
||||
- Set best-effort below-normal priority with `os.setPriority(child.pid, PRIORITY_BELOW_NORMAL)`. Failure to lower priority is observable but not fatal.
|
||||
- Start with `--max-old-space-size=384`. Treat an out-of-memory exit as a service failure; keep terminals and the last Vault snapshot alive.
|
||||
- Exit after 10 minutes with no active or queued request. Persisted parse-cache state makes a later cold process cheaper.
|
||||
|
||||
### Faults and restart
|
||||
|
||||
- Reject the active request on exit, disconnect, malformed protocol, or timeout.
|
||||
- Retry queued work in a new process only when it is safe and has not been cancelled.
|
||||
- Back off at 250 ms, 1 second, then 5 seconds.
|
||||
- Open a 60-second circuit breaker after three unexpected exits in 60 seconds. Manual refresh may make one explicit restart attempt.
|
||||
- On idle or orderly app/runtime/relay shutdown, flush any debounced parse-cache persistence, send the final acknowledgement, and exit. The parent waits at most 2 seconds, then terminates. Never hold host shutdown indefinitely.
|
||||
|
||||
## Resource and quality-of-service policy
|
||||
|
||||
The process boundary supplies crash and heap isolation. Priority and bounds supply responsiveness.
|
||||
|
||||
- CPU: below-normal process priority, scan concurrency eight, one scan at a time.
|
||||
- Memory: 384 MiB V8 old-space starting cap, 4,096 title-cache entries, current parse-cache eviction, and response-size telemetry.
|
||||
- Queue: 16 calls, coalescing, background replacement, explicit priority.
|
||||
- I/O: stream transcripts; do not read an entire store into memory. Preserve current per-file streaming behavior.
|
||||
- UI: defer result publication until terminal input has been quiet for 100 ms, with a 1-second maximum deferral so the panel cannot starve.
|
||||
- React: publish inside `startTransition`; keep the previous snapshot visible while the transition is pending.
|
||||
- Unlimited history: retain behavior during migration. Add internal pages before enforcing a hard serialized-result cap.
|
||||
|
||||
Process priority is best effort and cross-platform. Correctness must not depend on a particular scheduler implementation.
|
||||
|
||||
## Renderer publication
|
||||
|
||||
`ai-vault-session-refresh.ts` should stop applying a completed result immediately.
|
||||
|
||||
Add `ai-vault-session-publication-gate.ts` with this behavior:
|
||||
|
||||
1. Cache the validated result immediately so another caller can reuse it.
|
||||
2. If no terminal has received input in the last 100 ms, publish now inside `startTransition`.
|
||||
3. Otherwise retain only the newest pending result and wait for quiet.
|
||||
4. Publish after 1 second even if input continues, using a transition and one bounded page.
|
||||
5. Cancel a pending publication when the scope, host, or component request token changes.
|
||||
|
||||
Reuse the existing terminal-input quiet signal used by AI Vault tab-title synchronization rather than adding global key listeners per panel.
|
||||
|
||||
A separate Vault renderer remains a contingency only if post-service measurements show repeated renderer tasks over 50 ms that cannot be removed with publication scheduling, paging, and list virtualization.
|
||||
|
||||
## Desktop and runtime implementation
|
||||
|
||||
Create these concrete modules:
|
||||
|
||||
- `src/main/ai-vault/session-scanner-service-entry.ts`: process entry, init/ready handshake, request dispatch, cancellation, cache initialization, and shutdown.
|
||||
- `src/main/ai-vault/session-scanner-service-client.ts`: queue, deadlines, cancellation, validation, and restart policy.
|
||||
- `src/main/ai-vault/session-scanner-service-spawn.ts`: lazy shared client and public scan/title functions.
|
||||
- `src/main/ai-vault/session-scanner-service-entry-path.ts`: dev, E2E, and packaged path resolution.
|
||||
- `src/main/ai-vault/session-scanner-service-protocol.ts`: message types, schemas, and error codes.
|
||||
- `src/main/ai-vault/session-scanner-service-priority.ts`: best-effort process priority policy.
|
||||
|
||||
Reuse the scanner and cache modules without moving their business logic. Replace imports in:
|
||||
|
||||
- `src/main/ai-vault/cached-session-list.ts`
|
||||
- `src/main/ai-vault/session-title-resolver.ts`
|
||||
- `src/main/ipc/ai-vault-subagent-list.ts`
|
||||
- `src/main/ai-vault/session-first-user-prompt-read.ts`
|
||||
- `src/main/ipc/ai-vault-delete.ts` for acknowledged service-cache invalidation after a successful delete
|
||||
- `src/main/ipc/ai-vault.ts`
|
||||
- `src/main/runtime/rpc/methods/ai-vault.ts`
|
||||
|
||||
Promote the parse-cache flush currently exposed only for tests into an internal production shutdown function. Idle and orderly service exits must await it within the shutdown grace period.
|
||||
|
||||
Keep the current worker implementation behind the rollout switch until the new process passes packaging and fault-injection coverage. Remove it in a later cleanup change, not in the initial migration.
|
||||
|
||||
## SSH relay sidecar implementation
|
||||
|
||||
Create:
|
||||
|
||||
- `src/relay/ai-vault-service-entry.ts`: bundled Node sidecar entry using the remote scanner and title reader.
|
||||
- `src/relay/ai-vault-service-client.ts`: relay-owned supervisor and internal protocol adapter.
|
||||
- `src/relay/ai-vault-service-priority.ts`: the same best-effort QoS policy without Electron imports.
|
||||
|
||||
Change `src/relay/ai-vault-handler.ts` so it validates public params and forwards them to the sidecar. It must no longer import or call `scanRemoteAiVaultSessions` or `readAiVaultSessionTitlesFromFiles` in the relay process.
|
||||
|
||||
`relay.ts` creates the client once, injects it into `AiVaultHandler`, and shuts it down with relay lifecycle. The PTY handler does not depend on sidecar readiness.
|
||||
|
||||
If the sidecar cannot start or crashes:
|
||||
|
||||
- Return a normal `AiVaultListResult` containing a host issue and no new sessions.
|
||||
- Do not scan inline in the relay.
|
||||
- Keep all PTYs, reconnect state, and other relay methods available.
|
||||
- Allow the next manual refresh to cross the restart circuit breaker once.
|
||||
|
||||
Build `relay-ai-vault-service.js` for every current relay platform in `config/scripts/build-relay.mjs`. Include its bytes in the immutable relay content hash so a sidecar-only change deploys a new relay directory. The bundle must target Node 18 and rely only on Node built-ins plus already supported optional externals.
|
||||
|
||||
## Remote compatibility
|
||||
|
||||
The first implementation adds no public method, stream opcode, required field, or changed result meaning.
|
||||
|
||||
- Current desktop with new relay: calls the existing Vault relay methods; the new relay handles them through its sidecar.
|
||||
- New desktop with current relay: calls the same methods and receives the same result shape.
|
||||
- New desktop with an older relay lacking the method: uses the existing budgeted SSH filesystem fallback.
|
||||
- Old desktop with new relay: unknown internal sidecar details never cross the wire.
|
||||
|
||||
Do not make a new client depend on internal paging fields from an older host. If public paging is added later, use optional request/result fields and fall back to the existing complete-result call when the host does not return paging capability.
|
||||
|
||||
The old-relay fallback is the explicit isolation exception. Keep its total budget and cancellation. Do not broaden when it is selected. Once relay adoption data shows the fallback is rare, it can be reduced or removed in a separate compatibility decision.
|
||||
|
||||
## Folder workspaces, WSL, and paths
|
||||
|
||||
- Scope paths remain opaque filesystem paths. Do not require a `.git` directory or worktree metadata.
|
||||
- Continue using `path.join` and the existing host-platform path adapters.
|
||||
- Keep Windows batch and shell selection unrelated to terminal-shell preference.
|
||||
- Preserve WSL host identity and cache scoping; capability and cache state must not leak between distros or native Windows.
|
||||
- An SSH service reads only the remote host's paths. A desktop service must not interpret remote paths as local paths.
|
||||
- Title requests must continue through the existing readable-path resolution and allowed-root validation before opening a transcript.
|
||||
- Subagent and first-prompt requests remain local-only and must validate renderer-provided paths against the same agent-source roots used for discovery, including managed and WSL roots. Tightening path validation must ship with parity fixtures so legitimate current roots are not rejected.
|
||||
|
||||
## Security boundary
|
||||
|
||||
The service is a performance and reliability boundary, not a privilege boundary.
|
||||
|
||||
- It has the same OS user and must access the same agent stores.
|
||||
- Do not expose its IPC channel to renderer code or the network.
|
||||
- The parent passes validated options; the service validates again before filesystem access.
|
||||
- Do not log prompts, titles, transcript paths, environment values, or raw service messages.
|
||||
- Preserve symlink and allowed-root checks for title reads.
|
||||
- Inherit only the environment needed for current agent-home discovery and process operation. Document any removed variables with cross-platform tests before tightening further.
|
||||
|
||||
## Packaging
|
||||
|
||||
Desktop packaging changes:
|
||||
|
||||
- Add `session-scanner-service-entry` to the main inputs in `electron.vite.config.ts`.
|
||||
- Add `out/main/session-scanner-service-entry.js` to `asarUnpack` in `config/electron-builder.config.cjs`.
|
||||
- Reuse the existing unpacked entry-path pattern used by other forked processes.
|
||||
- Add a packaged smoke test that resolves the real unpacked path, starts the service, scans one fixture, and shuts down.
|
||||
|
||||
Relay packaging changes:
|
||||
|
||||
- Bundle the sidecar beside `relay.js` and `relay-watcher.js` for every supported OS/architecture.
|
||||
- Include it in `.version` content hashing and deploy completeness assertions.
|
||||
- Verify reconnect and versioned-install cleanup keep the matching relay and sidecar together.
|
||||
- No new native module is required, so the Linux glibc floor should remain unchanged. Continue running the packaging verifier.
|
||||
|
||||
## Observability
|
||||
|
||||
Add spans and counters for:
|
||||
|
||||
- Service start, ready latency, PID, restart reason, circuit-breaker state, and idle exit.
|
||||
- Queue depth, queue wait, priority class, coalesced calls, and dropped background calls.
|
||||
- Discovery duration, parse duration, candidates, bytes read, cache reuse, incremental parses, full parses, and result count.
|
||||
- Parent/child serialization duration and serialized bytes.
|
||||
- Child RSS and heap-used snapshots at request completion.
|
||||
- Renderer result receipt, quiet-wait duration, transition publication duration, and displayed session count.
|
||||
- Relay sidecar failures separately from PTY and relay-loop health.
|
||||
|
||||
Metrics must use counts, byte sizes, and durations only. Paths and transcript-derived content stay out of logs.
|
||||
|
||||
## Verification matrix
|
||||
|
||||
The design is not ready for default-on until each layer below passes with both the worker fallback and process backend where applicable.
|
||||
|
||||
| Layer | Required coverage |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Pure parser | Existing fixtures for every agent and format; incremental append, corrupt/partial input, title precedence, usage, previews, queued prompts, recoverable empties, and subagent counts |
|
||||
| Backend equivalence | Worker versus process deep equality for complete list, title, subagent, and first-prompt results; only `scannedAt` and internal metrics may differ |
|
||||
| Cache | Cold/warm/incremental scans, persistence restart, atomic save failure, corrupt cache, app-version mismatch, delete generation guard, service invalidation acknowledgement, and idle-exit flush |
|
||||
| Queue/lifecycle | Both logical lanes, priority, coalescing, 16-call bound, cancellation before/while queued/active, deadlines, malformed IPC, crash, OOM, backoff, circuit breaker, and shutdown |
|
||||
| Desktop Electron | Visible list, scope/host/depth controls, refresh/cancel, first-prompt copy, Claude/OMP expansion, delete, View/Open Log, live tail, resume, drag/drop, and service-crash terminal typing through Electron/CDP |
|
||||
| Runtime/web | List/title/resume parity, host restamping, managed Codex homes, unchanged no-op cancellation limitation, and unchanged unavailable subagent/first-prompt/delete behavior |
|
||||
| SSH relay | All-agent remote list, title resolution, scope truncation, sidecar crash/timeout/OOM, PTY typing/output/reconnect continuity, old-relay fallback, and both mixed-version directions |
|
||||
| Windows/WSL | Native and per-distro roots, UNC paths, WSL deletion, priority failure fallback, hidden child windows, packaged entry path, and host-isolated cache/source state |
|
||||
| Linux/package | Ubuntu 20.04/glibc floor, Node 18 relay bundle, packaged entry smoke, sidecar deploy/hash completeness, and no undeclared native dependency |
|
||||
| Folder workspace | Workspace/project scope, ownership mapping, resume target, path actions, and deletion without assuming Git metadata |
|
||||
|
||||
The Electron feature-parity run must assert visible behavior rather than only inspecting the store. Service fault tests additionally assert PTY markers so a Vault failure cannot pass while silently disrupting terminals.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
| Failure | User-visible Vault behavior | Terminal behavior |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------- |
|
||||
| Service fails to start | Keep last result and show a retryable host issue | Unchanged |
|
||||
| Service crashes during scan | Reject refresh, restart under backoff, keep last result | Unchanged |
|
||||
| Service exceeds memory cap | Treat as crash and reduce confidence telemetry; do not remove the cap silently | Unchanged |
|
||||
| Scan or cancellation hangs | Kill and replace service after the deadline/grace | Unchanged |
|
||||
| Queue is full | Coalesce or drop background work; foreground call gets a clear retryable error | Unchanged |
|
||||
| Relay sidecar unavailable | Remote Vault host shows an issue | Relay PTYs and reconnect remain available |
|
||||
| Renderer publication is superseded | Drop the stale result and publish the newest scope only | Unchanged |
|
||||
| Old relay has no Vault method | Use the existing bounded desktop fallback | Existing compatibility limitation remains |
|
||||
|
||||
## Benchmark methodology
|
||||
|
||||
The new `bench:ai-vault-typing` command launches a fresh Electron app through Playwright's Electron/CDP path and uses an isolated home.
|
||||
|
||||
Each iteration:
|
||||
|
||||
1. Keeps the Vault panel mounted in both arms.
|
||||
2. Seeds 300 new, newer-mtime Codex JSONL transcripts in the isolated home.
|
||||
3. Alternates arm order to reduce warm-up bias.
|
||||
4. Runs paced raw-mode terminal echo typing as the control.
|
||||
5. Runs the same typing while programmatically starting a forced visible Vault refresh as treatment.
|
||||
6. Verifies the newest seeded title is visible, proving the full scan and UI path completed.
|
||||
|
||||
The renderer records keydown-to-xterm-parse, keydown-to-xterm-render, long tasks, timer drift, animation-frame gaps, refresh duration, and missing echoes without polling or serializing the terminal buffer for each key.
|
||||
|
||||
Raw reports are written to the ignored `tests/tools/benchmarks/results/` directory.
|
||||
|
||||
## Current measurements
|
||||
|
||||
Machine: Apple M5 Pro, 64 GiB RAM, macOS 26.5.1. These are local-host measurements on the current worker-thread architecture after the recently merged performance work.
|
||||
|
||||
Pre-implementation parity baseline:
|
||||
|
||||
- 90 Vault-focused unit/integration test files passed: 778 tests covering parsers, caches, routing, deletion, resume, renderer projection, subagents, first prompts, and runtime contracts.
|
||||
- Electron/CDP parity spot-check passed for long View Log tail stability, single-file session deletion, and Claude companion-directory deletion: 3 tests.
|
||||
- The representative, stress, and final benchmark smoke runs all completed with visible Vault results and zero missing terminal echoes.
|
||||
|
||||
### Representative pass
|
||||
|
||||
Configuration: 3 iterations, 300 new sessions per iteration, 128 KiB assistant payload per session, 100 keys per arm at 30 ms cadence, 118.4 MB total seeded data.
|
||||
|
||||
| Metric | Control | Refresh active |
|
||||
| ------------------------------ | -------------------: | ------------------: |
|
||||
| Parse p50 / p95 / max | 1.3 / 2.5 / 29.8 ms | 1.3 / 2.0 / 5.4 ms |
|
||||
| Render p50 / p95 / max | 5.9 / 10.0 / 35.4 ms | 5.8 / 9.5 / 10.6 ms |
|
||||
| Worst timer drift | 14.2 ms | 24.5 ms |
|
||||
| Worst frame gap | 9.4 ms | 24.9 ms |
|
||||
| Renderer long tasks over 50 ms | 0 | 0 |
|
||||
| Missing echoes | 0 / 300 | 0 / 300 |
|
||||
| Refresh duration p50 / max | — | 103.5 / 204.2 ms |
|
||||
|
||||
There is no treatment-side typing regression in this pass. The current worker is doing useful work and should remain the rollback path during migration.
|
||||
|
||||
### Stress pass
|
||||
|
||||
Configuration: 2 iterations, 300 new sessions per iteration, 512 KiB assistant payload per session, 120 keys per arm at 30 ms cadence, 314.9 MB total seeded data.
|
||||
|
||||
| Metric | Control | Refresh active |
|
||||
| ------------------------------ | ------------------: | -------------------: |
|
||||
| Parse p50 / p95 / max | 1.3 / 1.9 / 19.7 ms | 1.4 / 4.7 / 13.8 ms |
|
||||
| Render p50 / p95 / max | 5.6 / 9.4 / 23.8 ms | 6.0 / 11.2 / 16.3 ms |
|
||||
| Worst timer drift | 9.3 ms | 43.6 ms |
|
||||
| Worst frame gap | 9.4 ms | 50.0 ms |
|
||||
| Renderer long tasks over 50 ms | 0 | 0 |
|
||||
| Missing echoes | 0 / 240 | 0 / 240 |
|
||||
| Refresh duration p50 / max | — | 276.3 / 453.5 ms |
|
||||
|
||||
The exaggerated workload causes measurable but bounded whole-system contention. It does not show renderer result application becoming a long task. This is evidence for a lower-priority service process and against a second Chromium renderer.
|
||||
|
||||
### Measurement limits
|
||||
|
||||
- One high-end macOS machine does not establish Windows, Linux, low-core, battery, or memory-pressure behavior.
|
||||
- The benchmark covers the local service path, not the currently unisolated SSH relay path.
|
||||
- Raw shell echo is a stable latency probe, not a full-screen TUI workload.
|
||||
- A child process still competes for machine-wide CPU; the expected benefit comes from priority, heap/failure isolation, and supervision, not from eliminating CPU cost.
|
||||
|
||||
## Post-implementation A/B
|
||||
|
||||
Run the same build with the rollout switch off and on, alternating launch order:
|
||||
|
||||
```bash
|
||||
ORCA_AI_VAULT_SERVICE_PROCESS=0 pnpm bench:ai-vault-typing -- --label worker-control
|
||||
ORCA_AI_VAULT_SERVICE_PROCESS=1 pnpm bench:ai-vault-typing -- --label process-treatment
|
||||
```
|
||||
|
||||
Run at least five representative and five stress passes on:
|
||||
|
||||
- macOS Apple Silicon.
|
||||
- Windows x64 with a folder workspace and a WSL-backed source present.
|
||||
- Ubuntu 20.04-compatible x64 packaging/runtime.
|
||||
- A 4-core or smaller machine or constrained CI runner.
|
||||
|
||||
Add a Docker SSH variant that runs the same terminal probe while the remote sidecar scans. A fault arm kills the sidecar during typing and verifies the PTY marker stream and reconnect remain healthy.
|
||||
|
||||
## Implemented result
|
||||
|
||||
The implementation now matches the target topology:
|
||||
|
||||
- Desktop and runtime list scans, title resolution, subagent reads, and first-prompt reads use a lazy supervised child process by default outside unit tests. `ORCA_AI_VAULT_SERVICE_PROCESS=0` retains the worker fallback.
|
||||
- Successful local deletion remains in the trusted main process and acknowledges child parse-cache invalidation. In-flight parses cannot restore an invalidated entry.
|
||||
- The SSH relay handler no longer imports the remote scanner, transcript title reader, or filesystem provider. It forwards existing public methods to `relay-ai-vault-service.js`; sidecar failure returns a normal host issue.
|
||||
- All six relay platform bundles include the sidecar, hash its bytes into `.version`, and require it in remote-install completeness probes.
|
||||
- Renderer results cache immediately, retain only the newest pending publication, wait for 100 ms of input quiet, publish in a React transition, and cannot defer beyond one second.
|
||||
- Desktop and relay supervisors bound the queue at 16, cap old-space at 384 MiB, use below-normal priority where supported, enforce ready/operation/cancellation/shutdown deadlines, and restart with bounded backoff plus a three-fault circuit breaker.
|
||||
|
||||
Local validation on 2026-08-09:
|
||||
|
||||
- Electron E2E build emitted `out/main/session-scanner-service-entry.js`; every relay target built `relay-ai-vault-service.js` successfully.
|
||||
- Real built-child smoke checks passed for the desktop service title lane and relay sidecar list lane.
|
||||
- Vault, runtime, renderer, relay, and remote-install focused tests passed 900/901 in one loaded run. The single unrelated 150 ms map micro-benchmark measured 184 ms during that parallel run and passed at 30 ms alone.
|
||||
- Electron/CDP feature checks passed for long View Log stability, single-file deletion, and Claude companion-directory deletion with the process backend enabled.
|
||||
|
||||
Representative A/B configuration: 3 iterations, 300 sessions per iteration, 128 KiB payloads, and 100 keys at 30 ms cadence.
|
||||
|
||||
| Metric | Worker control | Worker refresh | Process control | Process refresh |
|
||||
| --------------------- | -------------: | -------------: | --------------: | --------------: |
|
||||
| Parse p95 | 2.0 ms | 1.9 ms | 6.2 ms | 6.4 ms |
|
||||
| Render p95 | 9.6 ms | 9.6 ms | 11.0 ms | 12.3 ms |
|
||||
| Worst timer drift | 9.4 ms | 17.4 ms | 10.4 ms | 12.0 ms |
|
||||
| Worst frame gap | 9.4 ms | 9.4 ms | 16.8 ms | 16.2 ms |
|
||||
| Long tasks over 50 ms | 0 | 0 | 0 | 0 |
|
||||
| Missing echoes | 0 / 300 | 0 / 300 | 0 / 300 | 0 / 300 |
|
||||
| Refresh p50 / max | — | 74.9 / 76.9 ms | — | 83.2 / 165.1 ms |
|
||||
|
||||
The process arm stayed well inside every acceptance budget. It did not outperform the already-optimized worker on raw parse latency; its value is the intended heap, crash, priority, relay-loop, and lifecycle isolation while preserving terminal responsiveness.
|
||||
|
||||
Windows/WSL, Ubuntu 20.04, constrained-core, packaged-app launch, and Docker SSH fault measurements remain release/CI matrix work because they cannot be truthfully produced on this macOS host. The implementation includes their path, platform, packaging, Node 18, and mixed-version compatibility requirements; default rollback remains one environment variable.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
Correctness:
|
||||
|
||||
- Zero missing terminal echoes.
|
||||
- The latest seeded Vault session is visibly published in every treatment arm.
|
||||
- Local, runtime, SSH, WSL, and folder-workspace session fixtures remain equivalent to the current scanner.
|
||||
- Cancellation, force-preemption, host merging, title resolution, and unlimited history preserve current semantics.
|
||||
|
||||
Performance:
|
||||
|
||||
- Treatment parse p95 is no more than 10 ms above its paired control.
|
||||
- Treatment render p95 is no more than 15 ms above its paired control.
|
||||
- Median active-key render latency remains at or below 75 ms; worst remains below 300 ms.
|
||||
- No renderer task attributable to Vault result publication exceeds 50 ms in the representative run.
|
||||
- No relay PTY input/output stall exceeds the terminal reliability budget while its sidecar scans or crashes.
|
||||
|
||||
Isolation:
|
||||
|
||||
- Killing or exhausting the desktop service does not crash Electron main, runtime RPC, renderer, or terminal daemon.
|
||||
- Killing or exhausting the relay sidecar does not interrupt PTYs, relay reconnect, or non-Vault methods.
|
||||
- The service respects queue, timeout, idle, and heap bounds.
|
||||
- Packaged app and relay artifacts contain and launch the correct entry for every supported platform.
|
||||
|
||||
## Phased implementation plan
|
||||
|
||||
### Phase 0 — measurement harness (completed in this change)
|
||||
|
||||
- Add `terminal-ai-vault-typing-latency.spec.ts` and its focused corpus/renderer probes.
|
||||
- Add `run-ai-vault-typing-bench.mjs` and `pnpm bench:ai-vault-typing`.
|
||||
- Capture representative and stress baselines.
|
||||
|
||||
Exit: reproducible JSON report, visible Vault completion assertion, zero missing echoes.
|
||||
|
||||
### Phase 1 — desktop and runtime service behind a switch
|
||||
|
||||
- Add the private service protocol, entry-path resolver, process entry, client, priority policy, and supervisor.
|
||||
- Reuse current scanner/cache implementations.
|
||||
- Route desktop IPC and runtime RPC through the service when `ORCA_AI_VAULT_SERVICE_PROCESS` is enabled.
|
||||
- Keep the worker path as the disabled-switch fallback.
|
||||
- Add unit tests for ready timeout, queue priority, coalescing, cancellation, operation timeout, crash, restart backoff, circuit breaker, idle exit, malformed messages, and packaged path resolution.
|
||||
- Add E2E fault injection that kills the service during active terminal typing.
|
||||
|
||||
Exit: desktop/runtime correctness parity, packaging smoke test, representative/stress A/B within budgets.
|
||||
|
||||
### Phase 2 — make the desktop/runtime process the default
|
||||
|
||||
- Enable the process by default in development, E2E, and canary builds.
|
||||
- Collect process RSS, restart, timeout, and latency telemetry without content.
|
||||
- Validate Windows, Linux, WSL, and constrained-runner results.
|
||||
- Keep `ORCA_AI_VAULT_SERVICE_PROCESS=0` as a release kill switch for one stable cycle.
|
||||
|
||||
Exit: no unexplained crash or timeout increase and all acceptance gates green.
|
||||
|
||||
### Phase 3 — SSH relay sidecar
|
||||
|
||||
- Add the relay sidecar entry and supervisor.
|
||||
- Remove scanner/parser/cache imports from the relay handler.
|
||||
- Add the sidecar to every relay bundle, content hash, deploy manifest, and completeness test.
|
||||
- Add Docker SSH scan, typing, sidecar-crash, reconnect, old-relay fallback, and mixed-version tests.
|
||||
- Make inline relay scanning impossible; sidecar failure returns a host issue.
|
||||
|
||||
Exit: remote typing and fault gates green; current public relay methods unchanged.
|
||||
|
||||
### Phase 4 — renderer quiet publication
|
||||
|
||||
- Add the shared quiet-publication gate and React transition.
|
||||
- Retain only the newest pending result per scope/host.
|
||||
- Add supersession, starvation timeout, unmount, and rapid-host-switch tests.
|
||||
- Re-run the Electron benchmark with large result counts and active filtering/grouping.
|
||||
|
||||
Exit: no Vault-attributable renderer long task over 50 ms in the representative gate.
|
||||
|
||||
### Phase 5 — hardening and cleanup
|
||||
|
||||
- Remove the worker implementation after one stable release if the kill switch was unused.
|
||||
- Decide whether public optional paging is necessary from serialized-size and renderer-publication telemetry.
|
||||
- Promote `bench:ai-vault-typing` from an experimental benchmark to the terminal reliability gate.
|
||||
- Document operational recovery and service diagnostics.
|
||||
|
||||
Exit: kill switch and dead worker code removed only after evidence supports it.
|
||||
|
||||
## Rollback
|
||||
|
||||
- Desktop/runtime: set `ORCA_AI_VAULT_SERVICE_PROCESS=0` to restore the current worker path without changing public APIs or stored data.
|
||||
- Renderer: disable quiet publication and apply the validated complete result directly.
|
||||
- Relay: deploy the prior immutable relay version. A new relay must never fall back to inline scanning when its sidecar fails.
|
||||
- Parse-cache format remains unchanged during migration, so rollback does not require cache conversion.
|
||||
|
||||
## Confidence and remaining questions
|
||||
|
||||
Confidence in the overall topology is about 90%. Confidence that a separate Vault renderer is unnecessary is higher because both baseline passes recorded zero renderer long tasks. Confidence that the local child process alone will materially improve normal typing latency is lower: the current worker already performs well, and the process migration is primarily buying priority, heap, crash, and lifecycle isolation.
|
||||
|
||||
The remaining implementation questions are deliberately narrow:
|
||||
|
||||
1. Is 384 MiB the right old-space cap on Windows/Linux and for unlimited histories? Resolve with packaged stress runs before default-on.
|
||||
2. Does the 10-minute idle exit improve retained memory without creating noticeable cold-refresh churn? Resolve with startup/RSS telemetry.
|
||||
3. Is public paging necessary? Add it only if response-size or renderer-publication evidence crosses the stated budgets.
|
||||
4. How large is the current SSH relay impact on low-core hosts? Resolve with the Phase 3 Docker and constrained-runner typing gate; it does not change the decision to remove scans from the relay loop.
|
||||
@@ -220,6 +220,9 @@ export const electronViteConfig: UserConfig = {
|
||||
'session-scanner-worker-entry': resolve(
|
||||
'src/main/ai-vault/session-scanner-worker-entry.ts'
|
||||
),
|
||||
'session-scanner-service-entry': resolve(
|
||||
'src/main/ai-vault/session-scanner-service-entry.ts'
|
||||
),
|
||||
// Why: libuv spawns processes inline on the calling loop, so the port
|
||||
// scan's probe commands run on a worker thread instead of the UI one.
|
||||
'port-scan-command-worker-entry': resolve(
|
||||
|
||||
@@ -332,17 +332,16 @@ function MobileNativeChatMessageImpl({
|
||||
|
||||
// Copy + scroll-to-top, shown inline with the first tool call (or after the
|
||||
// prose when there are no tools).
|
||||
const controls =
|
||||
isAgent && !queued ? (
|
||||
<AgentControls
|
||||
onCopy={handleCopy}
|
||||
onScrollToTop={
|
||||
onScrollToMessage && messageIndex !== undefined
|
||||
? () => onScrollToMessage(messageIndex)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
const controls = isAgent ? (
|
||||
<AgentControls
|
||||
onCopy={handleCopy}
|
||||
onScrollToTop={
|
||||
onScrollToMessage && messageIndex !== undefined
|
||||
? () => onScrollToMessage(messageIndex)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<View style={[styles.row, isUser && styles.rowUser]}>
|
||||
@@ -364,7 +363,6 @@ function MobileNativeChatMessageImpl({
|
||||
styles.content,
|
||||
isUser && styles.userBubble,
|
||||
isReasoning && styles.reasoning,
|
||||
queued && styles.queued,
|
||||
copied && styles.copied
|
||||
]}
|
||||
>
|
||||
|
||||
@@ -71,6 +71,7 @@ type Overrides = {
|
||||
onClearSendError?: () => void
|
||||
inputLockReason?: 'disconnected' | 'waiting' | null
|
||||
onSend?: (text: string) => Promise<boolean>
|
||||
pending?: Parameters<typeof MobileNativeChatView>[0]['pending']
|
||||
}
|
||||
|
||||
function assistantTurn(id: string, text: string): NativeChatMessage {
|
||||
@@ -117,6 +118,13 @@ describe('MobileNativeChatView', () => {
|
||||
return (list.props.data as { id: string }[]).map((row) => row.id)
|
||||
}
|
||||
|
||||
function renderedRow(id: string): ReturnType<typeof createElement> {
|
||||
const list = renderer!.root.find((node) => node.type === 'FlatList')
|
||||
const data = list.props.data as NativeChatMessage[]
|
||||
const index = data.findIndex((row) => row.id === id)
|
||||
return list.props.renderItem({ item: data[index], index })
|
||||
}
|
||||
|
||||
function banners(): ReactTestInstance[] {
|
||||
return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert')
|
||||
}
|
||||
@@ -187,6 +195,15 @@ describe('MobileNativeChatView', () => {
|
||||
expect(listIds()).toEqual(['a1', 'streaming'])
|
||||
})
|
||||
|
||||
it('renders an accepted optimistic image send without a queued state', async () => {
|
||||
await render({
|
||||
pending: [{ id: 'pending-1', text: 'look', images: ['file:///phone-photo.jpg'] }]
|
||||
})
|
||||
|
||||
expect(listIds()).toEqual(['pending-1'])
|
||||
expect(renderedRow('pending-1').props).not.toHaveProperty('queued')
|
||||
})
|
||||
|
||||
it('keeps a visible lock through a subscribed-end lease blip', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -58,8 +58,7 @@ type Props = {
|
||||
loadingEarlier?: boolean
|
||||
onLoadEarlier?: () => void
|
||||
onSend: (text: string) => Promise<boolean>
|
||||
/** Optimistic queued sends (owned by the route so they survive view switches). */
|
||||
/** Optimistic user echoes, including any ridden-along image preview URIs. */
|
||||
/** Accepted user echoes awaiting transcript replacement, including image previews. */
|
||||
pending: MobileNativeChatPendingItem[]
|
||||
/** Local photo URIs retained when the authoritative transcript replaces an
|
||||
* optimistic image bubble. */
|
||||
@@ -176,9 +175,8 @@ export function MobileNativeChatView({
|
||||
[]
|
||||
)
|
||||
|
||||
const pendingIds = useMemo(() => new Set(pending.map((p) => p.id)), [pending])
|
||||
// `data` is the list source: folded transcript + synthetic streaming bubble +
|
||||
// route-owned optimistic queued messages. Memoize on the same deps so the
|
||||
// route-owned accepted echoes. Memoize on the same deps so the
|
||||
// downstream autoscroll effects/`renderItem` keep referential stability.
|
||||
const { data } = useMemo(
|
||||
() =>
|
||||
@@ -248,7 +246,6 @@ export function MobileNativeChatView({
|
||||
({ item, index }: { item: NativeChatMessage; index: number }) => (
|
||||
<MobileNativeChatMessage
|
||||
message={item}
|
||||
queued={pendingIds.has(item.id)}
|
||||
toolsExpanded={toolsExpanded}
|
||||
fontScale={fontScale}
|
||||
messageIndex={index}
|
||||
@@ -256,7 +253,7 @@ export function MobileNativeChatView({
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
),
|
||||
[pendingIds, toolsExpanded, fontScale, onScrollToMessage, onOpenFile]
|
||||
[toolsExpanded, fontScale, onScrollToMessage, onOpenFile]
|
||||
)
|
||||
|
||||
const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error)
|
||||
|
||||
@@ -51,7 +51,7 @@ export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): Nat
|
||||
|
||||
/** Assemble the list data the chat renders: the folded transcript, then a
|
||||
* synthetic bubble for the streaming text the gate let through, then the
|
||||
* route-owned optimistic "queued" messages at the tail. */
|
||||
* route-owned accepted optimistic messages at the tail. */
|
||||
export function buildMobileNativeChatTransientData({
|
||||
folded,
|
||||
streaming,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// (src/renderer/src/components/native-chat/NativeChatComposer.tsx): slash/skill
|
||||
// sends are TUI control actions, not chat turns — they never echo as a user
|
||||
// bubble, because the transcript will never contain a matching user turn and
|
||||
// the optimistic echo would sit at "Queued" forever.
|
||||
// the optimistic echo would never reconcile.
|
||||
|
||||
import {
|
||||
getNativeChatAgentProfile,
|
||||
|
||||
@@ -181,7 +181,7 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
})
|
||||
// Why (desktop parity): a slash/skill send dispatches into the agent's own
|
||||
// TUI, not the conversation — the transcript never echoes it as a user
|
||||
// turn, so an optimistic bubble would sit at "Queued" forever and the
|
||||
// turn, so an optimistic bubble would never reconcile and the
|
||||
// unconfirmed hold could never observe a landing.
|
||||
const classification = classifyMobileNativeChatSend(agent, text)
|
||||
if (outcome === 'unknown') {
|
||||
|
||||
@@ -115,7 +115,9 @@
|
||||
"bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/main-thread-jank-bench.mjs",
|
||||
"bench:worktree-deletion": "node tests/tools/benchmarks/worktree-deletion-dev-bench.mjs",
|
||||
"bench:zustand-selector-fanout": "node config/scripts/zustand-selector-fanout-benchmark.mjs",
|
||||
"bench:worktree-refresh-churn": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/worktree-refresh-churn-benchmark.mjs",
|
||||
"bench:multi-workspace-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-multi-workspace-typing-bench.mjs",
|
||||
"bench:ai-vault-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-ai-vault-typing-bench.mjs",
|
||||
"bench:cold-park-reveal": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs",
|
||||
"bench:cold-park-resource": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs",
|
||||
"bench:compare": "node config/scripts/compare-benchmark-artifacts.mjs",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
resetAiVaultScannerWorkerForTests,
|
||||
scanAiVaultSessionsInWorker
|
||||
} from './session-scanner-worker-spawn'
|
||||
resetAiVaultScannerBackgroundForTests,
|
||||
scanAiVaultSessionsInBackground
|
||||
} from './session-scanner-background'
|
||||
import { getWslHomeAsync, listWslDistrosAsync } from '../wsl'
|
||||
import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
@@ -77,7 +77,7 @@ export async function listAiVaultSessions(
|
||||
start: async (scanSignal) => {
|
||||
const additionalCodexSessionsDirs =
|
||||
sources.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? []
|
||||
const result = await scanAiVaultSessionsInWorker(
|
||||
const result = await scanAiVaultSessionsInBackground(
|
||||
{
|
||||
limit: args?.limit,
|
||||
unlimited: args?.unlimited,
|
||||
@@ -142,5 +142,5 @@ export function resetAiVaultSessionListCacheForTests(): void {
|
||||
invalidateAiVaultSessionListCache()
|
||||
scanCoordinator = new AiVaultScanCoordinator()
|
||||
sources = {}
|
||||
resetAiVaultScannerWorkerForTests()
|
||||
resetAiVaultScannerBackgroundForTests()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type {
|
||||
AiVaultFirstUserPromptArgs,
|
||||
AiVaultFirstUserPromptResult
|
||||
} from '../../shared/ai-vault-types'
|
||||
import { readAiVaultFirstUserPromptInBackground } from './session-scanner-background'
|
||||
|
||||
export function handleAiVaultGetFirstUserPrompt(
|
||||
args?: AiVaultFirstUserPromptArgs
|
||||
): Promise<AiVaultFirstUserPromptResult> {
|
||||
if (!args || typeof args.filePath !== 'string' || typeof args.agent !== 'string') {
|
||||
return Promise.resolve({ prompt: null })
|
||||
}
|
||||
return readAiVaultFirstUserPromptInBackground({
|
||||
agent: args.agent,
|
||||
filePath: args.filePath,
|
||||
sessionId: typeof args.sessionId === 'string' ? args.sessionId : undefined,
|
||||
executionHostId: args.executionHostId,
|
||||
codexHome: args.codexHome
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { stat } from 'node:fs/promises'
|
||||
import type {
|
||||
AiVaultAgent,
|
||||
AiVaultFirstUserPromptArgs,
|
||||
AiVaultFirstUserPromptResult,
|
||||
AiVaultSession
|
||||
} from '../../shared/ai-vault-types'
|
||||
@@ -22,22 +21,6 @@ export type ReadAiVaultFirstUserPromptArgs = {
|
||||
|
||||
export type ReadAiVaultFirstUserPromptResult = AiVaultFirstUserPromptResult
|
||||
|
||||
/** IPC-safe entry: validates untyped payload then reads the full first prompt. */
|
||||
export async function handleAiVaultGetFirstUserPrompt(
|
||||
args?: AiVaultFirstUserPromptArgs
|
||||
): Promise<AiVaultFirstUserPromptResult> {
|
||||
if (!args || typeof args.filePath !== 'string' || typeof args.agent !== 'string') {
|
||||
return { prompt: null }
|
||||
}
|
||||
return readAiVaultFirstUserPrompt({
|
||||
agent: args.agent,
|
||||
filePath: args.filePath,
|
||||
sessionId: typeof args.sessionId === 'string' ? args.sessionId : undefined,
|
||||
executionHostId: args.executionHostId,
|
||||
codexHome: args.codexHome
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-parse one session transcript under full first-prompt capture and return
|
||||
* the untruncated first real user ask for copy/reuse.
|
||||
|
||||
@@ -86,8 +86,8 @@ export function scheduleSessionParseCachePersist(stats: SessionParseStats): void
|
||||
}
|
||||
}
|
||||
|
||||
/** Run any pending debounced save immediately and wait for it. Test-only. */
|
||||
export async function flushSessionParseCachePersistForTests(): Promise<void> {
|
||||
/** Run any pending debounced save immediately and wait for it before process exit. */
|
||||
export async function flushSessionParseCachePersist(): Promise<void> {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = null
|
||||
@@ -99,6 +99,8 @@ export async function flushSessionParseCachePersistForTests(): Promise<void> {
|
||||
await lastSave
|
||||
}
|
||||
|
||||
export const flushSessionParseCachePersistForTests = flushSessionParseCachePersist
|
||||
|
||||
async function loadPersistedEntries(current: SessionParseCachePersistenceOptions): Promise<void> {
|
||||
await sweepOrphanedTempFiles(current.filePath)
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { shouldUseAiVaultServiceProcess } from './session-scanner-background'
|
||||
|
||||
const originalBackend = process.env.ORCA_AI_VAULT_SERVICE_PROCESS
|
||||
const originalNodeEnv = process.env.NODE_ENV
|
||||
|
||||
afterEach(() => {
|
||||
if (originalBackend === undefined) {
|
||||
delete process.env.ORCA_AI_VAULT_SERVICE_PROCESS
|
||||
} else {
|
||||
process.env.ORCA_AI_VAULT_SERVICE_PROCESS = originalBackend
|
||||
}
|
||||
process.env.NODE_ENV = originalNodeEnv
|
||||
})
|
||||
|
||||
describe('shouldUseAiVaultServiceProcess', () => {
|
||||
it('keeps unit tests on the worker fallback by default', () => {
|
||||
delete process.env.ORCA_AI_VAULT_SERVICE_PROCESS
|
||||
process.env.NODE_ENV = 'test'
|
||||
expect(shouldUseAiVaultServiceProcess()).toBe(false)
|
||||
})
|
||||
|
||||
it('defaults non-test hosts to the service process', () => {
|
||||
delete process.env.ORCA_AI_VAULT_SERVICE_PROCESS
|
||||
process.env.NODE_ENV = 'production'
|
||||
expect(shouldUseAiVaultServiceProcess()).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['1', true],
|
||||
['0', false]
|
||||
] as const)('honors the explicit %s kill switch', (value, expected) => {
|
||||
process.env.ORCA_AI_VAULT_SERVICE_PROCESS = value
|
||||
expect(shouldUseAiVaultServiceProcess()).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { AiVaultListResult, AiVaultSubagentListResult } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSessionTitleRequest,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../../shared/ai-vault-session-title'
|
||||
import {
|
||||
readAiVaultFirstUserPrompt,
|
||||
type ReadAiVaultFirstUserPromptArgs,
|
||||
type ReadAiVaultFirstUserPromptResult
|
||||
} from './session-first-user-prompt-read'
|
||||
import {
|
||||
invalidateAiVaultServiceCache,
|
||||
listAiVaultSubagentSessionsInService,
|
||||
readAiVaultFirstUserPromptInService,
|
||||
resetAiVaultScannerServiceForTests,
|
||||
resolveAiVaultSessionTitlesInService,
|
||||
scanAiVaultSessionsInService
|
||||
} from './session-scanner-service-spawn'
|
||||
import type { AiVaultServiceSubagentRequest } from './session-scanner-service-protocol'
|
||||
import {
|
||||
resetAiVaultScannerWorkerForTests,
|
||||
resolveAiVaultSessionTitlesInWorker,
|
||||
scanAiVaultSessionsInWorker
|
||||
} from './session-scanner-worker-spawn'
|
||||
import type { AiVaultWorkerScanOptions } from './session-scanner-worker-protocol'
|
||||
import { listLocalAiVaultSubagentSessions } from './session-subagent-reader'
|
||||
|
||||
export function shouldUseAiVaultServiceProcess(): boolean {
|
||||
const configured = process.env.ORCA_AI_VAULT_SERVICE_PROCESS
|
||||
if (configured === '1') {
|
||||
return true
|
||||
}
|
||||
if (configured === '0') {
|
||||
return false
|
||||
}
|
||||
return process.env.NODE_ENV !== 'test'
|
||||
}
|
||||
|
||||
export function scanAiVaultSessionsInBackground(
|
||||
options: AiVaultWorkerScanOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultListResult> {
|
||||
return shouldUseAiVaultServiceProcess()
|
||||
? scanAiVaultSessionsInService(options, signal)
|
||||
: scanAiVaultSessionsInWorker(options, signal)
|
||||
}
|
||||
|
||||
export function resolveAiVaultSessionTitlesInBackground(
|
||||
requests: AiVaultSessionTitleRequest[],
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSessionTitlesResult> {
|
||||
return shouldUseAiVaultServiceProcess()
|
||||
? resolveAiVaultSessionTitlesInService(requests, signal)
|
||||
: resolveAiVaultSessionTitlesInWorker(requests, signal)
|
||||
}
|
||||
|
||||
export function listAiVaultSubagentSessionsInBackground(
|
||||
request: AiVaultServiceSubagentRequest
|
||||
): Promise<AiVaultSubagentListResult> {
|
||||
return shouldUseAiVaultServiceProcess()
|
||||
? listAiVaultSubagentSessionsInService(request)
|
||||
: listLocalAiVaultSubagentSessions(request)
|
||||
}
|
||||
|
||||
export function readAiVaultFirstUserPromptInBackground(
|
||||
request: ReadAiVaultFirstUserPromptArgs
|
||||
): Promise<ReadAiVaultFirstUserPromptResult> {
|
||||
return shouldUseAiVaultServiceProcess()
|
||||
? readAiVaultFirstUserPromptInService(request)
|
||||
: readAiVaultFirstUserPrompt(request)
|
||||
}
|
||||
|
||||
export function invalidateAiVaultBackgroundCache(paths: string[]): Promise<void> {
|
||||
return shouldUseAiVaultServiceProcess() ? invalidateAiVaultServiceCache(paths) : Promise.resolve()
|
||||
}
|
||||
|
||||
export function resetAiVaultScannerBackgroundForTests(): void {
|
||||
resetAiVaultScannerServiceForTests()
|
||||
resetAiVaultScannerWorkerForTests()
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import type {
|
||||
AiVaultServiceInit,
|
||||
AiVaultServiceLane,
|
||||
AiVaultServiceRequest
|
||||
} from './session-scanner-service-protocol'
|
||||
|
||||
export const AI_VAULT_SERVICE_READY_TIMEOUT_MS = 5_000
|
||||
export const AI_VAULT_SERVICE_SCAN_TIMEOUT_MS = 130_000
|
||||
export const AI_VAULT_SERVICE_INTERACTIVE_TIMEOUT_MS = 15_000
|
||||
export const AI_VAULT_SERVICE_MAX_CALLS = 16
|
||||
export const AI_VAULT_SERVICE_IDLE_TIMEOUT_MS = 10 * 60_000
|
||||
export const AI_VAULT_SERVICE_SHUTDOWN_TIMEOUT_MS = 2_000
|
||||
|
||||
export type AiVaultServiceProcessFactory = () => ChildProcess
|
||||
export type AiVaultServiceClientOptions = {
|
||||
processFactory: AiVaultServiceProcessFactory
|
||||
init: Omit<AiVaultServiceInit, 'type' | 'protocol'>
|
||||
idleTimeoutMs?: number
|
||||
onStderr?: (text: string) => void
|
||||
}
|
||||
|
||||
export type AiVaultServiceInvalidation = {
|
||||
resolve: () => void
|
||||
reject: (error: Error) => void
|
||||
timer: NodeJS.Timeout
|
||||
}
|
||||
|
||||
export class AiVaultServiceInvalidations {
|
||||
private readonly pending = new Map<number, AiVaultServiceInvalidation>()
|
||||
private generation = 0
|
||||
|
||||
get size(): number {
|
||||
return this.pending.size
|
||||
}
|
||||
|
||||
open(
|
||||
timeoutMs: number,
|
||||
onTimeout: (generation: number) => void,
|
||||
send: (generation: number) => void
|
||||
): Promise<void> {
|
||||
const generation = ++this.generation
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => onTimeout(generation), timeoutMs)
|
||||
timer.unref?.()
|
||||
this.pending.set(generation, { resolve, reject, timer })
|
||||
send(generation)
|
||||
})
|
||||
}
|
||||
|
||||
settle(generation: number): boolean {
|
||||
const entry = this.pending.get(generation)
|
||||
if (!entry) {
|
||||
return false
|
||||
}
|
||||
clearTimeout(entry.timer)
|
||||
this.pending.delete(generation)
|
||||
entry.resolve()
|
||||
return true
|
||||
}
|
||||
|
||||
rejectAll(error: Error): void {
|
||||
for (const entry of this.pending.values()) {
|
||||
clearTimeout(entry.timer)
|
||||
entry.reject(error)
|
||||
}
|
||||
this.pending.clear()
|
||||
}
|
||||
}
|
||||
|
||||
export function createAiVaultServiceReadyWaiter(
|
||||
timeoutMs: number,
|
||||
onTimeout: () => void
|
||||
): AiVaultServiceReadyWaiter {
|
||||
let resolve!: (child: ChildProcess) => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<ChildProcess>((resolveReady, rejectReady) => {
|
||||
resolve = resolveReady
|
||||
reject = rejectReady
|
||||
})
|
||||
const timer = setTimeout(onTimeout, timeoutMs)
|
||||
timer.unref?.()
|
||||
return { promise, resolve, reject, timer }
|
||||
}
|
||||
|
||||
export function retireAiVaultServiceChild(child: ChildProcess): void {
|
||||
child.removeAllListeners('message')
|
||||
child.removeAllListeners('disconnect')
|
||||
child.removeAllListeners('error')
|
||||
child.removeAllListeners('exit')
|
||||
const killTimer = setTimeout(() => child.kill(), AI_VAULT_SERVICE_SHUTDOWN_TIMEOUT_MS)
|
||||
killTimer.unref?.()
|
||||
child.once('exit', () => clearTimeout(killTimer))
|
||||
child.send({ type: 'shutdown' }, () => undefined)
|
||||
child.unref()
|
||||
}
|
||||
|
||||
export function armAiVaultServiceCancellationTimeout(
|
||||
call: AiVaultServicePendingCall,
|
||||
onExpired: () => void
|
||||
): void {
|
||||
if (call.timer) {
|
||||
clearTimeout(call.timer)
|
||||
}
|
||||
call.timer = setTimeout(onExpired, AI_VAULT_SERVICE_SHUTDOWN_TIMEOUT_MS)
|
||||
call.timer.unref?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* A cold start that faults before the request reached the child self-heals on
|
||||
* the scheduled respawn. Requeue once; the caller rejects when this returns false.
|
||||
*/
|
||||
export function requeueAiVaultServiceStart(
|
||||
call: AiVaultServicePendingCall,
|
||||
queue: AiVaultServicePendingCall[]
|
||||
): boolean {
|
||||
if (call.sent || call.cancelled || call.startRetried) {
|
||||
return false
|
||||
}
|
||||
call.startRetried = true
|
||||
queue.unshift(call)
|
||||
return true
|
||||
}
|
||||
|
||||
export function clearAiVaultServiceCall(call: AiVaultServicePendingCall): void {
|
||||
if (call.timer) {
|
||||
clearTimeout(call.timer)
|
||||
call.timer = null
|
||||
}
|
||||
if (call.signal && call.onAbort) {
|
||||
call.signal.removeEventListener('abort', call.onAbort)
|
||||
call.onAbort = null
|
||||
}
|
||||
}
|
||||
|
||||
export function rejectAiVaultServiceCall(call: AiVaultServicePendingCall, error: Error): void {
|
||||
clearAiVaultServiceCall(call)
|
||||
if (!call.cancelled) {
|
||||
call.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
export type AiVaultServicePendingCall = {
|
||||
request: AiVaultServiceRequest
|
||||
lane: AiVaultServiceLane
|
||||
signal?: AbortSignal
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
timer: NodeJS.Timeout | null
|
||||
onAbort: (() => void) | null
|
||||
cancelled: boolean
|
||||
/** Whether the child received the request; an unsent call gets no reply. */
|
||||
sent: boolean
|
||||
startRetried: boolean
|
||||
}
|
||||
|
||||
export type AiVaultServiceReadyWaiter = {
|
||||
promise: Promise<ChildProcess>
|
||||
resolve: (child: ChildProcess) => void
|
||||
reject: (error: Error) => void
|
||||
timer: NodeJS.Timeout
|
||||
}
|
||||
|
||||
export class AiVaultServiceIdleRetirement {
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
|
||||
clear(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
|
||||
schedule(busy: boolean, timeoutMs: number, retire: () => void): void {
|
||||
if (busy || this.timer) {
|
||||
return
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null
|
||||
retire()
|
||||
}, timeoutMs)
|
||||
this.timer.unref?.()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AiVaultScannerServiceClient } from './session-scanner-service-client'
|
||||
import { AI_VAULT_SERVICE_READY_TIMEOUT_MS } from './session-scanner-service-client-state'
|
||||
import {
|
||||
AiVaultServiceTestChild,
|
||||
aiVaultServiceRequestId,
|
||||
readyAiVaultServiceChild
|
||||
} from './session-scanner-service-test-child'
|
||||
|
||||
function setup(idleTimeoutMs?: number): {
|
||||
child: AiVaultServiceTestChild
|
||||
client: AiVaultScannerServiceClient
|
||||
} {
|
||||
const child = new AiVaultServiceTestChild()
|
||||
const client = new AiVaultScannerServiceClient({
|
||||
processFactory: () => child.asChildProcess(),
|
||||
init: { sessionParseCache: null },
|
||||
idleTimeoutMs
|
||||
})
|
||||
return { child, client }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('AiVaultScannerServiceClient', () => {
|
||||
it('waits for ready and runs cache and interactive lanes independently', async () => {
|
||||
const { child, client } = setup()
|
||||
const titles = client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
const subagents = client.request({
|
||||
type: 'request',
|
||||
operation: 'subagents',
|
||||
request: { agent: 'claude', parentFilePath: '/tmp/parent.jsonl' }
|
||||
})
|
||||
|
||||
expect(child.sent).toEqual([expect.objectContaining({ type: 'init', protocol: 1 })])
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
expect(child.sent).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ operation: 'titles' }),
|
||||
expect.objectContaining({ operation: 'subagents' })
|
||||
])
|
||||
)
|
||||
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: aiVaultServiceRequestId(child, 'titles'),
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: aiVaultServiceRequestId(child, 'subagents'),
|
||||
operation: 'subagents',
|
||||
value: { sessions: [], issues: [] }
|
||||
})
|
||||
await expect(titles).resolves.toEqual({ titles: [] })
|
||||
await expect(subagents).resolves.toEqual({ sessions: [], issues: [] })
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('bounds active and queued calls together at sixteen', async () => {
|
||||
const { child, client } = setup()
|
||||
const calls = Array.from({ length: 16 }, () =>
|
||||
client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
)
|
||||
|
||||
await expect(
|
||||
client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
).rejects.toThrow('queue is full')
|
||||
readyAiVaultServiceChild(child)
|
||||
client.dispose()
|
||||
await Promise.all(calls.map((call) => expect(call).rejects.toThrow('disposed')))
|
||||
})
|
||||
|
||||
it('cancels active work and kills a child that ignores cancellation', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { child, client } = setup()
|
||||
const controller = new AbortController()
|
||||
const request = client.request(
|
||||
{ type: 'request', operation: 'titles', requests: [] },
|
||||
controller.signal
|
||||
)
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
const id = aiVaultServiceRequestId(child, 'titles')
|
||||
|
||||
controller.abort()
|
||||
await expect(request).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(child.sent).toContainEqual({ type: 'cancel', id })
|
||||
vi.advanceTimersByTime(1_999)
|
||||
expect(child.killed).toBe(false)
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(child.killed).toBe(true)
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('drops a call cancelled before the child received it', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { child, client } = setup()
|
||||
const controller = new AbortController()
|
||||
const cancelled = client.request(
|
||||
{ type: 'request', operation: 'titles', requests: [] },
|
||||
controller.signal
|
||||
)
|
||||
|
||||
controller.abort()
|
||||
await expect(cancelled).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(child.sent).not.toContainEqual(expect.objectContaining({ type: 'cancel' }))
|
||||
|
||||
readyAiVaultServiceChild(child)
|
||||
const next = client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
await Promise.resolve()
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: aiVaultServiceRequestId(child, 'titles'),
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
await expect(next).resolves.toEqual({ titles: [] })
|
||||
vi.advanceTimersByTime(2_000)
|
||||
|
||||
expect(child.killed).toBe(false)
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('retries once when the first cold start misses the ready deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = new AiVaultScannerServiceClient({
|
||||
processFactory: () => {
|
||||
const child = new AiVaultServiceTestChild(12_345 + children.length)
|
||||
children.push(child)
|
||||
return child.asChildProcess()
|
||||
},
|
||||
init: { sessionParseCache: null }
|
||||
})
|
||||
const titles = client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
expect(children).toHaveLength(1)
|
||||
|
||||
vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS)
|
||||
expect(children[0]!.killed).toBe(true)
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(children).toHaveLength(2)
|
||||
readyAiVaultServiceChild(children[1]!)
|
||||
await Promise.resolve()
|
||||
children[1]!.emit('message', {
|
||||
type: 'result',
|
||||
id: aiVaultServiceRequestId(children[1]!, 'titles'),
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
|
||||
await expect(titles).resolves.toEqual({ titles: [] })
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('surfaces the startup error when the retried cold start also fails', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = new AiVaultScannerServiceClient({
|
||||
processFactory: () => {
|
||||
const child = new AiVaultServiceTestChild(12_345 + children.length)
|
||||
children.push(child)
|
||||
return child.asChildProcess()
|
||||
},
|
||||
init: { sessionParseCache: null }
|
||||
})
|
||||
const titles = client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
|
||||
vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS)
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(children).toHaveLength(2)
|
||||
vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS)
|
||||
|
||||
await expect(titles).rejects.toThrow('did not become ready')
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('acknowledges cache invalidation through the running child', async () => {
|
||||
const { child, client } = setup()
|
||||
const invalidation = client.invalidate(['/tmp/deleted.jsonl'])
|
||||
readyAiVaultServiceChild(child)
|
||||
await vi.waitFor(() =>
|
||||
expect(child.sent).toContainEqual({
|
||||
type: 'invalidate',
|
||||
generation: 1,
|
||||
paths: ['/tmp/deleted.jsonl']
|
||||
})
|
||||
)
|
||||
child.emit('message', { type: 'invalidated', generation: 1 })
|
||||
|
||||
await expect(invalidation).resolves.toBeUndefined()
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('leaves a scanning child alone when cache invalidation is slow to acknowledge', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { child, client } = setup()
|
||||
const scan = client.request({ type: 'request', operation: 'scan', options: {} })
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
|
||||
const invalidation = client.invalidate(['/tmp/deleted.jsonl'])
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS)
|
||||
|
||||
// The scan owns liveness through its own 130s deadline; killing the child
|
||||
// here would abort it and burn a slot toward the restart circuit.
|
||||
await expect(invalidation).resolves.toBeUndefined()
|
||||
expect(child.killed).toBe(false)
|
||||
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: aiVaultServiceRequestId(child, 'scan'),
|
||||
operation: 'scan',
|
||||
value: { result: { sessions: [], issues: [], scannedAt: '2026-08-10' }, durationMs: 1 }
|
||||
})
|
||||
await expect(scan).resolves.toMatchObject({ result: { sessions: [] } })
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('replaces a child that does not acknowledge cache invalidation', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = new AiVaultScannerServiceClient({
|
||||
processFactory: () => {
|
||||
const child = new AiVaultServiceTestChild(12_345 + children.length)
|
||||
children.push(child)
|
||||
return child.asChildProcess()
|
||||
},
|
||||
init: { sessionParseCache: null }
|
||||
})
|
||||
const invalidation = client.invalidate(['/tmp/deleted.jsonl'])
|
||||
readyAiVaultServiceChild(children[0]!)
|
||||
await Promise.resolve()
|
||||
|
||||
vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS)
|
||||
|
||||
await expect(invalidation).rejects.toThrow('cache invalidation timed out')
|
||||
expect(children[0]!.killed).toBe(true)
|
||||
const titles = client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
expect(children).toHaveLength(1)
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(children).toHaveLength(2)
|
||||
readyAiVaultServiceChild(children[1]!)
|
||||
await Promise.resolve()
|
||||
children[1]!.emit('message', {
|
||||
type: 'result',
|
||||
id: aiVaultServiceRequestId(children[1]!, 'titles'),
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
|
||||
await expect(titles).resolves.toEqual({ titles: [] })
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('keeps invalidation-only children alive through acknowledgement, then retires them', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = new AiVaultScannerServiceClient({
|
||||
processFactory: () => {
|
||||
const child = new AiVaultServiceTestChild(12_345 + children.length)
|
||||
children.push(child)
|
||||
return child.asChildProcess()
|
||||
},
|
||||
init: { sessionParseCache: null },
|
||||
idleTimeoutMs: 100
|
||||
})
|
||||
|
||||
const first = client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
readyAiVaultServiceChild(children[0]!)
|
||||
await Promise.resolve()
|
||||
children[0]!.emit('message', {
|
||||
type: 'result',
|
||||
id: aiVaultServiceRequestId(children[0]!, 'titles'),
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
await first
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
const invalidation = client.invalidate(['/tmp/deleted.jsonl'])
|
||||
readyAiVaultServiceChild(children[1]!)
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(children[1]!.sent).not.toContainEqual({ type: 'shutdown' })
|
||||
children[1]!.emit('message', { type: 'invalidated', generation: 1 })
|
||||
await invalidation
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
expect(children[1]!.sent).toContainEqual({ type: 'shutdown' })
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('faults the child on malformed output without affecting later processes', async () => {
|
||||
const { child, client } = setup()
|
||||
const request = client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
|
||||
child.emit('message', { nope: true })
|
||||
|
||||
await expect(request).rejects.toThrow('malformed')
|
||||
expect(child.killed).toBe(true)
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('retires an idle child gracefully, then kills it after the shutdown bound', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { child, client } = setup(100)
|
||||
const request = client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: aiVaultServiceRequestId(child, 'titles'),
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
await request
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(child.sent).toContainEqual({ type: 'shutdown' })
|
||||
vi.advanceTimersByTime(2_000)
|
||||
expect(child.killed).toBe(true)
|
||||
client.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,325 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { createAiVaultScanCancelledError } from './ai-vault-scan-cancellation'
|
||||
import {
|
||||
AI_VAULT_SERVICE_IDLE_TIMEOUT_MS,
|
||||
AI_VAULT_SERVICE_INTERACTIVE_TIMEOUT_MS,
|
||||
AI_VAULT_SERVICE_MAX_CALLS,
|
||||
AI_VAULT_SERVICE_READY_TIMEOUT_MS,
|
||||
AI_VAULT_SERVICE_SCAN_TIMEOUT_MS,
|
||||
AiVaultServiceIdleRetirement,
|
||||
AiVaultServiceInvalidations,
|
||||
armAiVaultServiceCancellationTimeout,
|
||||
clearAiVaultServiceCall,
|
||||
createAiVaultServiceReadyWaiter,
|
||||
rejectAiVaultServiceCall,
|
||||
requeueAiVaultServiceStart,
|
||||
retireAiVaultServiceChild,
|
||||
type AiVaultServiceClientOptions,
|
||||
type AiVaultServicePendingCall,
|
||||
type AiVaultServiceReadyWaiter
|
||||
} from './session-scanner-service-client-state'
|
||||
import { AiVaultServiceRestartPolicy } from './session-scanner-service-restart-policy'
|
||||
import {
|
||||
AI_VAULT_SERVICE_PROTOCOL_VERSION,
|
||||
aiVaultServiceLane,
|
||||
isAiVaultServiceChildMessage,
|
||||
type AiVaultServiceChildMessage,
|
||||
type AiVaultServiceInit,
|
||||
type AiVaultServiceRequest,
|
||||
type AiVaultServiceRequestBody,
|
||||
type AiVaultServiceResultValue
|
||||
} from './session-scanner-service-protocol'
|
||||
|
||||
export class AiVaultScannerServiceClient {
|
||||
private child: ChildProcess | null = null
|
||||
private readyWaiter: AiVaultServiceReadyWaiter | null = null
|
||||
private readonly active = new Map<AiVaultServicePendingCall['lane'], AiVaultServicePendingCall>()
|
||||
private readonly queue: AiVaultServicePendingCall[] = []
|
||||
private readonly invalidations = new AiVaultServiceInvalidations()
|
||||
private nextId = 1
|
||||
private readonly idleRetirement = new AiVaultServiceIdleRetirement()
|
||||
private readonly restartPolicy = new AiVaultServiceRestartPolicy()
|
||||
private disposed = false
|
||||
|
||||
constructor(private readonly options: AiVaultServiceClientOptions) {}
|
||||
|
||||
request<T>(body: AiVaultServiceRequestBody, signal?: AbortSignal): Promise<T> {
|
||||
if (this.disposed) {
|
||||
return Promise.reject(new Error('AI Vault service client was disposed.'))
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(createAiVaultScanCancelledError())
|
||||
}
|
||||
if (this.queue.length + this.active.size >= AI_VAULT_SERVICE_MAX_CALLS) {
|
||||
return Promise.reject(new Error('AI Vault service queue is full.'))
|
||||
}
|
||||
const request = { ...body, id: this.nextId++ } as AiVaultServiceRequest
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const call: AiVaultServicePendingCall = {
|
||||
request,
|
||||
lane: aiVaultServiceLane(request.operation),
|
||||
signal,
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject,
|
||||
timer: null,
|
||||
onAbort: null,
|
||||
cancelled: false,
|
||||
sent: false,
|
||||
startRetried: false
|
||||
}
|
||||
if (signal) {
|
||||
call.onAbort = () => this.cancel(call)
|
||||
signal.addEventListener('abort', call.onAbort, { once: true })
|
||||
}
|
||||
this.queue.push(call)
|
||||
this.idleRetirement.clear()
|
||||
this.pump()
|
||||
})
|
||||
}
|
||||
|
||||
async invalidate(paths: string[]): Promise<void> {
|
||||
if (paths.length === 0 || this.disposed) {
|
||||
return
|
||||
}
|
||||
this.idleRetirement.clear()
|
||||
const child = await this.ensureChild()
|
||||
return this.invalidations.open(
|
||||
AI_VAULT_SERVICE_READY_TIMEOUT_MS,
|
||||
(generation) => this.onInvalidationDeadline(generation),
|
||||
(generation) => child.send({ type: 'invalidate', generation, paths })
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The deadline is a startup-sized budget, but a child mid-scan can be slow to
|
||||
* turn the channel around. Fork IPC ordering already guarantees the child
|
||||
* applies the invalidation before any request sent after it, so a busy child
|
||||
* owes nothing here — only an idle one that misses the deadline is wedged.
|
||||
*/
|
||||
private onInvalidationDeadline(generation: number): void {
|
||||
if (this.active.size > 0) {
|
||||
this.invalidations.settle(generation)
|
||||
return
|
||||
}
|
||||
this.onFault(new Error('AI Vault service cache invalidation timed out.'))
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
this.disposed = true
|
||||
this.restartPolicy.dispose()
|
||||
this.idleRetirement.clear()
|
||||
const error = new Error('AI Vault service client was disposed.')
|
||||
for (const call of [...this.active.values(), ...this.queue]) {
|
||||
rejectAiVaultServiceCall(call, error)
|
||||
}
|
||||
this.active.clear()
|
||||
this.queue.length = 0
|
||||
this.invalidations.rejectAll(error)
|
||||
this.retireChild()
|
||||
}
|
||||
|
||||
private pump(): void {
|
||||
if (this.restartPolicy.restartScheduled) {
|
||||
return
|
||||
}
|
||||
for (const lane of ['cache', 'interactive'] as const) {
|
||||
if (this.active.has(lane)) {
|
||||
continue
|
||||
}
|
||||
const index = this.queue.findIndex((call) => call.lane === lane)
|
||||
if (index < 0) {
|
||||
continue
|
||||
}
|
||||
const call = this.queue.splice(index, 1)[0]!
|
||||
this.active.set(lane, call)
|
||||
void this.ensureChild().then(
|
||||
(child) => this.sendCall(child, call),
|
||||
(error: Error) => {
|
||||
if (this.active.get(lane) !== call) {
|
||||
return
|
||||
}
|
||||
this.active.delete(lane)
|
||||
this.retryStartOrReject(call, error)
|
||||
this.pump()
|
||||
}
|
||||
)
|
||||
}
|
||||
this.scheduleIdleIfNeeded()
|
||||
}
|
||||
|
||||
private sendCall(child: ChildProcess, call: AiVaultServicePendingCall): void {
|
||||
if (call.cancelled || this.active.get(call.lane) !== call) {
|
||||
return
|
||||
}
|
||||
const timeoutMs =
|
||||
call.request.operation === 'scan'
|
||||
? AI_VAULT_SERVICE_SCAN_TIMEOUT_MS
|
||||
: AI_VAULT_SERVICE_INTERACTIVE_TIMEOUT_MS
|
||||
call.timer = setTimeout(() => {
|
||||
this.onFault(new Error(`AI Vault service timed out after ${timeoutMs}ms.`))
|
||||
}, timeoutMs)
|
||||
call.timer.unref?.()
|
||||
call.sent = true
|
||||
child.send(call.request)
|
||||
}
|
||||
|
||||
private retryStartOrReject(call: AiVaultServicePendingCall, error: Error): void {
|
||||
if (
|
||||
this.disposed ||
|
||||
!this.restartPolicy.restartScheduled ||
|
||||
!requeueAiVaultServiceStart(call, this.queue)
|
||||
) {
|
||||
rejectAiVaultServiceCall(call, error)
|
||||
}
|
||||
}
|
||||
|
||||
private ensureChild(): Promise<ChildProcess> {
|
||||
if (this.child && !this.readyWaiter) {
|
||||
return Promise.resolve(this.child)
|
||||
}
|
||||
if (this.readyWaiter) {
|
||||
return this.readyWaiter.promise
|
||||
}
|
||||
const startError = this.restartPolicy.startError()
|
||||
if (startError) {
|
||||
return Promise.reject(startError)
|
||||
}
|
||||
let child: ChildProcess
|
||||
try {
|
||||
child = this.options.processFactory()
|
||||
} catch (error) {
|
||||
this.restartPolicy.recordFault(() => this.pump())
|
||||
return Promise.reject(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
this.child = child
|
||||
const waiter = createAiVaultServiceReadyWaiter(AI_VAULT_SERVICE_READY_TIMEOUT_MS, () =>
|
||||
this.onFault(new Error('AI Vault service did not become ready.'))
|
||||
)
|
||||
this.readyWaiter = waiter
|
||||
child.on('message', (message) => this.onMessage(message))
|
||||
child.on('error', (error) => this.onFault(error))
|
||||
child.on('disconnect', () => this.onFault(new Error('AI Vault service disconnected.')))
|
||||
child.on('exit', (code) => this.onFault(new Error(`AI Vault service exited (${code}).`)))
|
||||
child.stderr?.on('data', (chunk: Buffer) => this.options.onStderr?.(String(chunk)))
|
||||
child.send({
|
||||
type: 'init',
|
||||
protocol: AI_VAULT_SERVICE_PROTOCOL_VERSION,
|
||||
...this.options.init
|
||||
} satisfies AiVaultServiceInit)
|
||||
return waiter.promise
|
||||
}
|
||||
|
||||
private onMessage(raw: unknown): void {
|
||||
if (!isAiVaultServiceChildMessage(raw)) {
|
||||
this.onFault(new Error('AI Vault service sent a malformed message.'))
|
||||
return
|
||||
}
|
||||
const message = raw as AiVaultServiceChildMessage
|
||||
if (message.type === 'ready') {
|
||||
const waiter = this.readyWaiter
|
||||
if (!waiter || !this.child) {
|
||||
return
|
||||
}
|
||||
clearTimeout(waiter.timer)
|
||||
this.readyWaiter = null
|
||||
waiter.resolve(this.child)
|
||||
return
|
||||
}
|
||||
if (message.type === 'invalidated') {
|
||||
if (this.invalidations.settle(message.generation)) {
|
||||
this.scheduleIdleIfNeeded()
|
||||
}
|
||||
return
|
||||
}
|
||||
const call = [...this.active.values()].find((entry) => entry.request.id === message.id)
|
||||
if (!call) {
|
||||
return
|
||||
}
|
||||
this.active.delete(call.lane)
|
||||
clearAiVaultServiceCall(call)
|
||||
if (!call.cancelled) {
|
||||
if (message.type === 'error') {
|
||||
call.reject(new Error(message.message))
|
||||
} else {
|
||||
call.resolve((message as { value: AiVaultServiceResultValue['value'] }).value)
|
||||
}
|
||||
}
|
||||
this.pump()
|
||||
}
|
||||
|
||||
private cancel(call: AiVaultServicePendingCall): void {
|
||||
if (call.cancelled) {
|
||||
return
|
||||
}
|
||||
call.cancelled = true
|
||||
call.reject(createAiVaultScanCancelledError())
|
||||
const queuedIndex = this.queue.indexOf(call)
|
||||
if (queuedIndex >= 0) {
|
||||
this.queue.splice(queuedIndex, 1)
|
||||
clearAiVaultServiceCall(call)
|
||||
this.pump()
|
||||
return
|
||||
}
|
||||
if (this.active.get(call.lane) === call) {
|
||||
// Why: a call cancelled before it reached the child gets no acknowledgement,
|
||||
// so waiting on one would kill a healthy service and stall the lane.
|
||||
if (!call.sent) {
|
||||
this.active.delete(call.lane)
|
||||
clearAiVaultServiceCall(call)
|
||||
this.pump()
|
||||
return
|
||||
}
|
||||
this.child?.send({ type: 'cancel', id: call.request.id })
|
||||
armAiVaultServiceCancellationTimeout(call, () =>
|
||||
this.onFault(new Error('AI Vault service did not cancel within 2000ms.'))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private onFault(error: Error): void {
|
||||
const child = this.child
|
||||
if (!child) {
|
||||
return
|
||||
}
|
||||
this.child = null
|
||||
child.removeAllListeners()
|
||||
child.kill()
|
||||
if (this.readyWaiter) {
|
||||
clearTimeout(this.readyWaiter.timer)
|
||||
this.readyWaiter.reject(error)
|
||||
this.readyWaiter = null
|
||||
}
|
||||
// Recorded before the pending calls are settled so retryStartOrReject can see
|
||||
// whether a respawn is actually coming.
|
||||
this.restartPolicy.recordFault(() => this.pump())
|
||||
const active = [...this.active.values()]
|
||||
this.active.clear()
|
||||
for (const call of active) {
|
||||
this.retryStartOrReject(call, error)
|
||||
}
|
||||
this.invalidations.rejectAll(error)
|
||||
}
|
||||
|
||||
private scheduleIdleIfNeeded(): void {
|
||||
this.idleRetirement.schedule(
|
||||
this.active.size > 0 || this.queue.length > 0 || this.invalidations.size > 0 || !this.child,
|
||||
this.options.idleTimeoutMs ?? AI_VAULT_SERVICE_IDLE_TIMEOUT_MS,
|
||||
() => this.retireChild()
|
||||
)
|
||||
}
|
||||
|
||||
private retireChild(): void {
|
||||
this.idleRetirement.clear()
|
||||
const child = this.child
|
||||
this.child = null
|
||||
if (!child) {
|
||||
return
|
||||
}
|
||||
retireAiVaultServiceChild(child)
|
||||
}
|
||||
}
|
||||
|
||||
export type { AiVaultServiceProcessFactory } from './session-scanner-service-client-state'
|
||||
@@ -0,0 +1,58 @@
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
resolveAiVaultServiceEntryPath,
|
||||
resolveAiVaultServiceEntryPathWithoutApp
|
||||
} from './session-scanner-service-entry-path'
|
||||
|
||||
describe('resolveAiVaultServiceEntryPath', () => {
|
||||
it('uses the adjacent electron-vite output when present', () => {
|
||||
const outMain = join(process.cwd(), 'out', 'main')
|
||||
const adjacent = join(outMain, 'session-scanner-service-entry.js')
|
||||
|
||||
expect(resolveAiVaultServiceEntryPath(outMain, false, (path) => path === adjacent)).toBe(
|
||||
adjacent
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the nested output from a project root', () => {
|
||||
expect(resolveAiVaultServiceEntryPath(process.cwd(), false, () => false)).toBe(
|
||||
join(process.cwd(), 'out', 'main', 'session-scanner-service-entry.js')
|
||||
)
|
||||
})
|
||||
|
||||
it('uses app.asar.unpacked for packaged Electron', () => {
|
||||
const appPath = join('C:', 'Orca', 'resources', 'app.asar')
|
||||
|
||||
expect(resolveAiVaultServiceEntryPath(appPath, true)).toBe(
|
||||
join(
|
||||
'C:',
|
||||
'Orca',
|
||||
'resources',
|
||||
'app.asar.unpacked',
|
||||
'out',
|
||||
'main',
|
||||
'session-scanner-service-entry.js'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('uses resourcesPath from packaged Electron-as-Node runtimes', () => {
|
||||
const resourcesPath = join('Applications', 'Orca.app', 'Contents', 'Resources')
|
||||
const entry = join(
|
||||
resourcesPath,
|
||||
'app.asar.unpacked',
|
||||
'out',
|
||||
'main',
|
||||
'session-scanner-service-entry.js'
|
||||
)
|
||||
|
||||
expect(
|
||||
resolveAiVaultServiceEntryPathWithoutApp(
|
||||
'/unrelated/cwd',
|
||||
resourcesPath,
|
||||
(path) => path === entry
|
||||
)
|
||||
).toBe(entry)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
type ElectronAppPath = { getAppPath(): string; isPackaged: boolean }
|
||||
|
||||
function loadElectronApp(): ElectronAppPath | null {
|
||||
try {
|
||||
return require('electron').app ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAiVaultServiceEntryPath(
|
||||
appPath: string,
|
||||
isPackaged: boolean,
|
||||
pathExists: (candidate: string) => boolean = existsSync
|
||||
): string {
|
||||
const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath
|
||||
const adjacentEntry = join(basePath, 'session-scanner-service-entry.js')
|
||||
if (!isPackaged && pathExists(adjacentEntry)) {
|
||||
return adjacentEntry
|
||||
}
|
||||
return join(basePath, 'out', 'main', 'session-scanner-service-entry.js')
|
||||
}
|
||||
|
||||
export function resolveAiVaultServiceEntryPathWithoutApp(
|
||||
cwd: string,
|
||||
resourcesPath: string | undefined,
|
||||
pathExists: (candidate: string) => boolean = existsSync
|
||||
): string {
|
||||
if (resourcesPath) {
|
||||
const packagedEntry = join(
|
||||
resourcesPath,
|
||||
'app.asar.unpacked',
|
||||
'out',
|
||||
'main',
|
||||
'session-scanner-service-entry.js'
|
||||
)
|
||||
if (pathExists(packagedEntry)) {
|
||||
return packagedEntry
|
||||
}
|
||||
}
|
||||
return resolveAiVaultServiceEntryPath(cwd, false, pathExists)
|
||||
}
|
||||
|
||||
export function getAiVaultServiceEntryPath(): string {
|
||||
const app = loadElectronApp()
|
||||
return app
|
||||
? resolveAiVaultServiceEntryPath(app.getAppPath(), app.isPackaged)
|
||||
: resolveAiVaultServiceEntryPathWithoutApp(process.cwd(), process.resourcesPath)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AI_VAULT_SERVICE_PROTOCOL_VERSION } from './session-scanner-service-protocol'
|
||||
|
||||
const invalidateSessionParseCacheEntry = vi.hoisted(() => vi.fn())
|
||||
const scanAiVaultSessions = vi.hoisted(() => vi.fn())
|
||||
|
||||
// Only the invalidation hook is replaced; the title reader shares this module.
|
||||
vi.mock('./session-scanner-parse-cache', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
invalidateSessionParseCacheEntry
|
||||
}))
|
||||
vi.mock('./session-scanner', () => ({ scanAiVaultSessions }))
|
||||
vi.mock('./session-parse-cache-persistence', () => ({
|
||||
flushSessionParseCachePersist: vi.fn(() => Promise.resolve()),
|
||||
initSessionParseCachePersistence: vi.fn()
|
||||
}))
|
||||
vi.mock('./session-subagent-reader', () => ({
|
||||
listLocalAiVaultSubagentSessions: vi.fn(() => Promise.resolve({ sessions: [], issues: [] }))
|
||||
}))
|
||||
|
||||
const sent: { type: string; id?: number }[] = []
|
||||
|
||||
function emit(message: unknown): void {
|
||||
process.emit('message', message as never, undefined as never)
|
||||
}
|
||||
|
||||
async function runScan(id: number): Promise<void> {
|
||||
emit({ type: 'request', id, operation: 'scan', options: {} })
|
||||
await vi.waitFor(() => expect(sent.some((message) => message.id === id)).toBe(true))
|
||||
}
|
||||
|
||||
function invalidationsFor(path: string): number {
|
||||
return invalidateSessionParseCacheEntry.mock.calls.filter((call) => call[0] === path).length
|
||||
}
|
||||
|
||||
describe('AI Vault service entry cache invalidation', () => {
|
||||
beforeAll(async () => {
|
||||
process.send = ((message: { type: string; id?: number }) => {
|
||||
sent.push(message)
|
||||
return true
|
||||
}) as typeof process.send
|
||||
await import('./session-scanner-service-entry')
|
||||
emit({ type: 'init', protocol: AI_VAULT_SERVICE_PROTOCOL_VERSION })
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
sent.length = 0
|
||||
invalidateSessionParseCacheEntry.mockClear()
|
||||
scanAiVaultSessions.mockResolvedValue({ sessions: [], issues: [], scannedAt: '2026-08-10' })
|
||||
})
|
||||
|
||||
it('re-applies an invalidation once and then stops re-evicting the path', async () => {
|
||||
emit({ type: 'invalidate', generation: 1, paths: ['/transcripts/a.jsonl'] })
|
||||
expect(invalidationsFor('/transcripts/a.jsonl')).toBe(1)
|
||||
|
||||
// The request that overlapped the invalidation still gets the re-apply, so a
|
||||
// read that started before it cannot leave pre-edit content cached.
|
||||
await runScan(1)
|
||||
expect(invalidationsFor('/transcripts/a.jsonl')).toBe(2)
|
||||
|
||||
// Every later request must not keep paying for a consumed invalidation.
|
||||
await runScan(2)
|
||||
expect(invalidationsFor('/transcripts/a.jsonl')).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps re-applying while another request is still executing', async () => {
|
||||
let releaseSlowScan: (() => void) | undefined
|
||||
scanAiVaultSessions.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseSlowScan = () =>
|
||||
resolve({ sessions: [], issues: [], scannedAt: '2026-08-10' } as never)
|
||||
})
|
||||
)
|
||||
// 'subagents' runs on the interactive lane, so it overlaps the cache-lane
|
||||
// scan; 'titles' would not, it shares the cache lane with scans.
|
||||
emit({ type: 'request', id: 10, operation: 'scan', options: {} })
|
||||
await vi.waitFor(() => expect(releaseSlowScan).toBeDefined())
|
||||
|
||||
emit({ type: 'invalidate', generation: 2, paths: ['/transcripts/b.jsonl'] })
|
||||
emit({ type: 'request', id: 11, operation: 'subagents', request: {} })
|
||||
await vi.waitFor(() => expect(sent.some((message) => message.id === 11)).toBe(true))
|
||||
|
||||
// The scan is still reading, so the path stays armed rather than draining.
|
||||
expect(invalidationsFor('/transcripts/b.jsonl')).toBe(2)
|
||||
|
||||
releaseSlowScan?.()
|
||||
await vi.waitFor(() => expect(sent.some((message) => message.id === 10)).toBe(true))
|
||||
expect(invalidationsFor('/transcripts/b.jsonl')).toBe(3)
|
||||
|
||||
await runScan(12)
|
||||
expect(invalidationsFor('/transcripts/b.jsonl')).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { AiVaultSessionTitle } from '../../shared/ai-vault-session-title'
|
||||
import { readAiVaultFirstUserPrompt } from './session-first-user-prompt-read'
|
||||
import {
|
||||
flushSessionParseCachePersist,
|
||||
initSessionParseCachePersistence
|
||||
} from './session-parse-cache-persistence'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import { invalidateSessionParseCacheEntry } from './session-scanner-parse-cache'
|
||||
import {
|
||||
AI_VAULT_SERVICE_PROTOCOL_VERSION,
|
||||
aiVaultServiceLane,
|
||||
cacheServiceTitle,
|
||||
isAiVaultServiceRequest,
|
||||
type AiVaultServiceChildMessage,
|
||||
type AiVaultServiceParentMessage,
|
||||
type AiVaultServiceRequest,
|
||||
type AiVaultServiceResultValue
|
||||
} from './session-scanner-service-protocol'
|
||||
import { readAiVaultSessionTitlesFromFiles } from './session-title-file-reader'
|
||||
import { resolveHostReadableAiVaultTitleRequests } from './session-title-request-paths'
|
||||
import { listLocalAiVaultSubagentSessions } from './session-subagent-reader'
|
||||
|
||||
if (!process.send) {
|
||||
throw new Error('AI Vault service requires a parent IPC channel.')
|
||||
}
|
||||
|
||||
const controllers = new Map<number, AbortController>()
|
||||
const cancelled = new Set<number>()
|
||||
const pending = new Set<number>()
|
||||
const titleIndex = new Map<string, AiVaultSessionTitle>()
|
||||
const invalidatedPaths = new Set<string>()
|
||||
let initialized = false
|
||||
let shuttingDown = false
|
||||
let cacheLane = Promise.resolve()
|
||||
let interactiveLane = Promise.resolve()
|
||||
|
||||
function send(message: AiVaultServiceChildMessage): void {
|
||||
process.send?.(message)
|
||||
}
|
||||
|
||||
function titleKey(request: { agent: string; sessionId: string }): string {
|
||||
return `${request.agent}\0${request.sessionId}`
|
||||
}
|
||||
|
||||
async function executeRequest(request: AiVaultServiceRequest): Promise<AiVaultServiceResultValue> {
|
||||
const controller = new AbortController()
|
||||
controllers.set(request.id, controller)
|
||||
try {
|
||||
if (cancelled.delete(request.id)) {
|
||||
controller.abort()
|
||||
}
|
||||
if (request.operation === 'titles') {
|
||||
const requests = await resolveHostReadableAiVaultTitleRequests(
|
||||
request.requests,
|
||||
controller.signal
|
||||
)
|
||||
return {
|
||||
operation: 'titles',
|
||||
value: await readAiVaultSessionTitlesFromFiles(requests, {
|
||||
signal: controller.signal,
|
||||
cache: {
|
||||
get: (entry) => titleIndex.get(titleKey(entry)) ?? null,
|
||||
set: (title) => cacheServiceTitle(titleIndex, title)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
if (request.operation === 'subagents') {
|
||||
return {
|
||||
operation: 'subagents',
|
||||
value: await listLocalAiVaultSubagentSessions(request.request)
|
||||
}
|
||||
}
|
||||
if (request.operation === 'firstPrompt') {
|
||||
return {
|
||||
operation: 'firstPrompt',
|
||||
value: await readAiVaultFirstUserPrompt(request.request)
|
||||
}
|
||||
}
|
||||
const startedAt = performance.now()
|
||||
const result = await scanAiVaultSessions({ ...request.options, signal: controller.signal })
|
||||
for (const session of result.sessions) {
|
||||
if ((session.agent === 'claude' || session.agent === 'codex') && session.title.trim()) {
|
||||
cacheServiceTitle(titleIndex, {
|
||||
agent: session.agent,
|
||||
sessionId: session.sessionId,
|
||||
title: session.title.trim()
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
operation: 'scan',
|
||||
value: { result, durationMs: performance.now() - startedAt }
|
||||
}
|
||||
} finally {
|
||||
controllers.delete(request.id)
|
||||
cancelled.delete(request.id)
|
||||
for (const path of invalidatedPaths) {
|
||||
invalidateSessionParseCacheEntry(path)
|
||||
}
|
||||
// Why: the re-apply only protects reads that overlapped the invalidation.
|
||||
// Once nothing else is executing it has done its job, and holding the paths
|
||||
// would re-evict them on every later request for the life of the process.
|
||||
if (controllers.size === 0) {
|
||||
invalidatedPaths.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRequest(request: AiVaultServiceRequest): Promise<void> {
|
||||
try {
|
||||
const value = await executeRequest(request)
|
||||
send({ type: 'result', id: request.id, ...value })
|
||||
} catch (error) {
|
||||
send({
|
||||
type: 'error',
|
||||
id: request.id,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
retryable: true
|
||||
})
|
||||
} finally {
|
||||
pending.delete(request.id)
|
||||
}
|
||||
}
|
||||
|
||||
function queueRequest(request: AiVaultServiceRequest): void {
|
||||
if (pending.size >= 16) {
|
||||
send({
|
||||
type: 'error',
|
||||
id: request.id,
|
||||
message: 'AI Vault service queue is full.',
|
||||
retryable: true
|
||||
})
|
||||
return
|
||||
}
|
||||
pending.add(request.id)
|
||||
if (aiVaultServiceLane(request.operation) === 'interactive') {
|
||||
interactiveLane = interactiveLane.then(() => handleRequest(request))
|
||||
return
|
||||
}
|
||||
cacheLane = cacheLane.then(() => handleRequest(request))
|
||||
}
|
||||
|
||||
async function shutdown(): Promise<void> {
|
||||
if (shuttingDown) {
|
||||
return
|
||||
}
|
||||
shuttingDown = true
|
||||
for (const controller of controllers.values()) {
|
||||
controller.abort()
|
||||
}
|
||||
await Promise.allSettled([cacheLane, interactiveLane])
|
||||
await flushSessionParseCachePersist()
|
||||
process.disconnect?.()
|
||||
}
|
||||
|
||||
process.on('message', (raw: AiVaultServiceParentMessage) => {
|
||||
if (raw?.type === 'init') {
|
||||
if (initialized || raw.protocol !== AI_VAULT_SERVICE_PROTOCOL_VERSION) {
|
||||
void shutdown()
|
||||
return
|
||||
}
|
||||
initialized = true
|
||||
if (raw.sessionParseCache) {
|
||||
initSessionParseCachePersistence(raw.sessionParseCache)
|
||||
}
|
||||
send({ type: 'ready', protocol: AI_VAULT_SERVICE_PROTOCOL_VERSION, pid: process.pid })
|
||||
return
|
||||
}
|
||||
if (!initialized || shuttingDown) {
|
||||
return
|
||||
}
|
||||
if (raw?.type === 'cancel') {
|
||||
cancelled.add(raw.id)
|
||||
controllers.get(raw.id)?.abort()
|
||||
return
|
||||
}
|
||||
if (raw?.type === 'invalidate') {
|
||||
for (const path of raw.paths) {
|
||||
invalidatedPaths.delete(path)
|
||||
invalidatedPaths.add(path)
|
||||
invalidateSessionParseCacheEntry(path)
|
||||
}
|
||||
while (invalidatedPaths.size > 4_096) {
|
||||
const oldest = invalidatedPaths.values().next().value
|
||||
if (oldest === undefined) {
|
||||
break
|
||||
}
|
||||
invalidatedPaths.delete(oldest)
|
||||
}
|
||||
titleIndex.clear()
|
||||
send({ type: 'invalidated', generation: raw.generation })
|
||||
return
|
||||
}
|
||||
if (raw?.type === 'shutdown') {
|
||||
void shutdown()
|
||||
return
|
||||
}
|
||||
if (isAiVaultServiceRequest(raw)) {
|
||||
queueRequest(raw)
|
||||
}
|
||||
})
|
||||
|
||||
process.on('disconnect', () => void shutdown())
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildAiVaultServiceEnv, buildRelayAiVaultServiceEnv } from './session-scanner-service-env'
|
||||
|
||||
describe('buildAiVaultServiceEnv', () => {
|
||||
it('drops Node flag injection variables so the forked heap cap and loader stand', () => {
|
||||
const env = buildAiVaultServiceEnv(
|
||||
{
|
||||
NODE_OPTIONS: '--max-old-space-size=8192 --require=/tmp/evil.js',
|
||||
NODE_REPL_EXTERNAL_MODULE: '/tmp/evil.js',
|
||||
NODE_PATH: '/tmp/evil-modules',
|
||||
PATH: '/usr/bin'
|
||||
},
|
||||
'linux'
|
||||
)
|
||||
|
||||
expect(env.NODE_OPTIONS).toBeUndefined()
|
||||
expect(env.NODE_REPL_EXTERNAL_MODULE).toBeUndefined()
|
||||
expect(env.NODE_PATH).toBeUndefined()
|
||||
expect(env.PATH).toBe('/usr/bin')
|
||||
})
|
||||
|
||||
it('drops an unrecognised variable rather than carrying a shell-exported secret', () => {
|
||||
const env = buildAiVaultServiceEnv(
|
||||
{ AWS_SECRET_ACCESS_KEY: 'shhh', HOME: '/home/dev' },
|
||||
'linux'
|
||||
)
|
||||
|
||||
expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined()
|
||||
expect(env.HOME).toBe('/home/dev')
|
||||
})
|
||||
|
||||
it('keeps the agent-home variables the scanner discovers sessions through', () => {
|
||||
const env = buildAiVaultServiceEnv(
|
||||
{
|
||||
CODEX_HOME: '/home/dev/.codex',
|
||||
COPILOT_HOME: '/home/dev/.copilot',
|
||||
DEVIN_HOME: '/home/dev/.devin',
|
||||
GROK_HOME: '/home/dev/.grok',
|
||||
KIMI_CODE_HOME: '/home/dev/.kimi-code',
|
||||
OMP_CODING_AGENT_DIR: '/home/dev/.omp/agent/sessions',
|
||||
OPENCLAW_STATE_DIR: '/home/dev/.openclaw',
|
||||
PI_CODING_AGENT_DIR: '/home/dev/.pi/agent/sessions',
|
||||
PRIME_AGENT_CODING_AGENT_DIR: '/home/dev/.prime/agent',
|
||||
PRIME_AGENT_CODING_AGENT_SESSION_DIR: '/home/dev/.prime/legacy-sessions',
|
||||
PRIME_AGENT_SESSION_DIR: '/home/dev/.prime/sessions'
|
||||
},
|
||||
'linux'
|
||||
)
|
||||
|
||||
expect(env).toEqual({
|
||||
CODEX_HOME: '/home/dev/.codex',
|
||||
COPILOT_HOME: '/home/dev/.copilot',
|
||||
DEVIN_HOME: '/home/dev/.devin',
|
||||
GROK_HOME: '/home/dev/.grok',
|
||||
KIMI_CODE_HOME: '/home/dev/.kimi-code',
|
||||
OMP_CODING_AGENT_DIR: '/home/dev/.omp/agent/sessions',
|
||||
OPENCLAW_STATE_DIR: '/home/dev/.openclaw',
|
||||
PI_CODING_AGENT_DIR: '/home/dev/.pi/agent/sessions',
|
||||
PRIME_AGENT_CODING_AGENT_DIR: '/home/dev/.prime/agent',
|
||||
PRIME_AGENT_CODING_AGENT_SESSION_DIR: '/home/dev/.prime/legacy-sessions',
|
||||
PRIME_AGENT_SESSION_DIR: '/home/dev/.prime/sessions',
|
||||
ELECTRON_RUN_AS_NODE: '1'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the OpenCode data-directory and database overrides', () => {
|
||||
const env = buildAiVaultServiceEnv(
|
||||
{ XDG_DATA_HOME: '/home/dev/data', OPENCODE_DB: 'opencode-alt.db' },
|
||||
'linux'
|
||||
)
|
||||
|
||||
expect(env.XDG_DATA_HOME).toBe('/home/dev/data')
|
||||
expect(env.OPENCODE_DB).toBe('opencode-alt.db')
|
||||
})
|
||||
|
||||
it('runs the forked Electron binary as plain Node', () => {
|
||||
expect(buildAiVaultServiceEnv({}, 'linux').ELECTRON_RUN_AS_NODE).toBe('1')
|
||||
})
|
||||
|
||||
it('ignores a POSIX variable that only matches an allowed name by case', () => {
|
||||
expect(buildAiVaultServiceEnv({ codex_home: '/tmp/spoof' }, 'linux')).toEqual({
|
||||
ELECTRON_RUN_AS_NODE: '1'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves a lowercased Windows variable the OS would still honour', () => {
|
||||
const env = buildAiVaultServiceEnv({ codex_home: 'C:\\codex', Path: 'C:\\bin' }, 'win32')
|
||||
|
||||
expect(env.CODEX_HOME).toBe('C:\\codex')
|
||||
expect(env.PATH).toBe('C:\\bin')
|
||||
})
|
||||
|
||||
it('spells SystemRoot the way Windows Node expects', () => {
|
||||
expect(buildAiVaultServiceEnv({ SystemRoot: 'C:\\Windows' }, 'win32').SystemRoot).toBe(
|
||||
'C:\\Windows'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not mutate the caller environment', () => {
|
||||
const baseEnv = { NODE_OPTIONS: '--inspect', HOME: '/home/dev' }
|
||||
buildAiVaultServiceEnv(baseEnv, 'linux')
|
||||
|
||||
expect(baseEnv.NODE_OPTIONS).toBe('--inspect')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRelayAiVaultServiceEnv', () => {
|
||||
it('drops Node flag injection variables', () => {
|
||||
const env = buildRelayAiVaultServiceEnv(
|
||||
{ NODE_OPTIONS: '--max-old-space-size=8192', NODE_PATH: '/tmp/evil', HOME: '/home/ada' },
|
||||
'linux'
|
||||
)
|
||||
|
||||
expect(env.NODE_OPTIONS).toBeUndefined()
|
||||
expect(env.NODE_PATH).toBeUndefined()
|
||||
expect(env.HOME).toBe('/home/ada')
|
||||
})
|
||||
|
||||
// The sidecar takes remoteHome and hostPlatform from its init message, so an
|
||||
// agent-home override on the remote host is not part of how it finds roots.
|
||||
it('withholds the agent-home variables the desktop child needs', () => {
|
||||
const env = buildRelayAiVaultServiceEnv(
|
||||
{ CODEX_HOME: '/remote/.codex', PATH: '/usr/bin' },
|
||||
'linux'
|
||||
)
|
||||
|
||||
expect(env.CODEX_HOME).toBeUndefined()
|
||||
expect(env.PATH).toBe('/usr/bin')
|
||||
})
|
||||
|
||||
it('stays plain Node rather than an Electron child', () => {
|
||||
expect(buildRelayAiVaultServiceEnv({}, 'linux').ELECTRON_RUN_AS_NODE).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Environments for the AI Vault service children.
|
||||
*
|
||||
* Deliberately allowlists, never `...process.env` (the plugin worker takes the
|
||||
* same stance): the children are forked with a heap cap and no loader, and an
|
||||
* ambient NODE_OPTIONS would raise the cap or `--require` code straight into
|
||||
* them. Shell-exported secrets have no business in a transcript reader either.
|
||||
*/
|
||||
|
||||
// What Node and libuv need to start and resolve a home, temp dir and locale.
|
||||
const RUNTIME_ENV_ALLOWLIST = [
|
||||
'PATH',
|
||||
'HOME',
|
||||
'USERPROFILE',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'LC_CTYPE',
|
||||
'TZ',
|
||||
'TMPDIR',
|
||||
'TEMP',
|
||||
'TMP',
|
||||
// Why: Windows Node/libuv need these to resolve DLLs and the machine root.
|
||||
'SYSTEMROOT',
|
||||
'SYSTEMDRIVE',
|
||||
'WINDIR',
|
||||
'COMSPEC',
|
||||
'PATHEXT',
|
||||
'PROCESSOR_ARCHITECTURE',
|
||||
'NUMBER_OF_PROCESSORS'
|
||||
] as const
|
||||
|
||||
// Why: the desktop child resolves agent roots from its own environment, so
|
||||
// dropping one hides every session of a user who relocated that agent's home.
|
||||
const AGENT_ROOT_ENV_ALLOWLIST = [
|
||||
'CODEX_HOME',
|
||||
'COPILOT_HOME',
|
||||
'DEVIN_HOME',
|
||||
'GROK_HOME',
|
||||
'KIMI_CODE_HOME',
|
||||
'OMP_CODING_AGENT_DIR',
|
||||
'OPENCLAW_STATE_DIR',
|
||||
'OPENCODE_DB',
|
||||
'PI_CODING_AGENT_DIR',
|
||||
'PRIME_AGENT_CODING_AGENT_DIR',
|
||||
'PRIME_AGENT_CODING_AGENT_SESSION_DIR',
|
||||
'PRIME_AGENT_SESSION_DIR',
|
||||
// Why: OpenCode has no home variable — its store hangs off the XDG data dir,
|
||||
// so this one is an agent root here rather than generic runtime state.
|
||||
'XDG_DATA_HOME'
|
||||
] as const
|
||||
|
||||
function pickAllowedEnv(
|
||||
keys: readonly string[],
|
||||
baseEnv: NodeJS.ProcessEnv,
|
||||
platform: NodeJS.Platform
|
||||
): NodeJS.ProcessEnv {
|
||||
const windowsLookup = new Map<string, string>()
|
||||
if (platform === 'win32') {
|
||||
// Why: Windows resolves env names case-insensitively, so a lowercased
|
||||
// `codex_home` still reaches the child; folding on POSIX instead would
|
||||
// promote an attacker-set `path` over the real one.
|
||||
for (const [key, value] of Object.entries(baseEnv)) {
|
||||
if (typeof value === 'string') {
|
||||
windowsLookup.set(key.toUpperCase(), value)
|
||||
}
|
||||
}
|
||||
}
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
for (const key of keys) {
|
||||
const value = platform === 'win32' ? windowsLookup.get(key) : baseEnv[key]
|
||||
if (value !== undefined) {
|
||||
env[key === 'SYSTEMROOT' ? 'SystemRoot' : key] = value
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
/** Desktop: forked from the Electron binary, so it also needs run-as-node. */
|
||||
export function buildAiVaultServiceEnv(
|
||||
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): NodeJS.ProcessEnv {
|
||||
const env = pickAllowedEnv(
|
||||
[...RUNTIME_ENV_ALLOWLIST, ...AGENT_ROOT_ENV_ALLOWLIST],
|
||||
baseEnv,
|
||||
platform
|
||||
)
|
||||
env.ELECTRON_RUN_AS_NODE = '1'
|
||||
return env
|
||||
}
|
||||
|
||||
/** Relay: the sidecar takes every root from its init message, not the environment. */
|
||||
export function buildRelayAiVaultServiceEnv(
|
||||
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): NodeJS.ProcessEnv {
|
||||
return pickAllowedEnv(RUNTIME_ENV_ALLOWLIST, baseEnv, platform)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { constants, setPriority } from 'node:os'
|
||||
|
||||
export function lowerAiVaultServicePriority(pid: number | undefined): boolean {
|
||||
if (pid === undefined || !Number.isSafeInteger(pid) || pid <= 0) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
setPriority(pid, constants.priority.PRIORITY_BELOW_NORMAL)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { AiVaultListResult, AiVaultSubagentListResult } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSessionTitle,
|
||||
AiVaultSessionTitleRequest,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../../shared/ai-vault-session-title'
|
||||
import type { ReadAiVaultFirstUserPromptArgs } from './session-first-user-prompt-read'
|
||||
import type { SessionParseCachePersistenceOptions } from './session-parse-cache-persistence'
|
||||
import type { AiVaultWorkerScanOptions } from './session-scanner-worker-protocol'
|
||||
|
||||
export const AI_VAULT_SERVICE_PROTOCOL_VERSION = 1
|
||||
|
||||
export type AiVaultServiceLane = 'cache' | 'interactive'
|
||||
export type AiVaultServiceOperation = 'scan' | 'titles' | 'subagents' | 'firstPrompt'
|
||||
|
||||
export type AiVaultServiceSubagentRequest = {
|
||||
agent: 'claude' | 'omp'
|
||||
parentFilePath: string
|
||||
}
|
||||
|
||||
export type AiVaultServiceInit = {
|
||||
type: 'init'
|
||||
protocol: typeof AI_VAULT_SERVICE_PROTOCOL_VERSION
|
||||
sessionParseCache: SessionParseCachePersistenceOptions | null
|
||||
}
|
||||
|
||||
export type AiVaultServiceRequestBody =
|
||||
| { type: 'request'; operation: 'scan'; options: AiVaultWorkerScanOptions }
|
||||
| {
|
||||
type: 'request'
|
||||
operation: 'titles'
|
||||
requests: AiVaultSessionTitleRequest[]
|
||||
}
|
||||
| {
|
||||
type: 'request'
|
||||
operation: 'subagents'
|
||||
request: AiVaultServiceSubagentRequest
|
||||
}
|
||||
| {
|
||||
type: 'request'
|
||||
operation: 'firstPrompt'
|
||||
request: ReadAiVaultFirstUserPromptArgs
|
||||
}
|
||||
|
||||
export type AiVaultServiceRequest = AiVaultServiceRequestBody & { id: number }
|
||||
|
||||
export type AiVaultServiceParentMessage =
|
||||
| AiVaultServiceInit
|
||||
| AiVaultServiceRequest
|
||||
| { type: 'cancel'; id: number }
|
||||
| { type: 'invalidate'; generation: number; paths: string[] }
|
||||
| { type: 'shutdown' }
|
||||
|
||||
export type AiVaultServiceResultValue =
|
||||
| { operation: 'scan'; value: { result: AiVaultListResult; durationMs: number } }
|
||||
| { operation: 'titles'; value: AiVaultSessionTitlesResult }
|
||||
| { operation: 'subagents'; value: AiVaultSubagentListResult }
|
||||
| { operation: 'firstPrompt'; value: { prompt: string | null } }
|
||||
|
||||
export type AiVaultServiceChildMessage =
|
||||
| {
|
||||
type: 'ready'
|
||||
protocol: typeof AI_VAULT_SERVICE_PROTOCOL_VERSION
|
||||
pid: number
|
||||
}
|
||||
| ({ type: 'result'; id: number } & AiVaultServiceResultValue)
|
||||
| { type: 'error'; id: number; message: string; retryable: boolean }
|
||||
| { type: 'invalidated'; generation: number }
|
||||
|
||||
export function aiVaultServiceLane(operation: AiVaultServiceOperation): AiVaultServiceLane {
|
||||
return operation === 'subagents' || operation === 'firstPrompt' ? 'interactive' : 'cache'
|
||||
}
|
||||
|
||||
export function isAiVaultServiceRequest(value: unknown): value is AiVaultServiceRequest {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const message = value as Record<string, unknown>
|
||||
return (
|
||||
message.type === 'request' &&
|
||||
Number.isSafeInteger(message.id) &&
|
||||
(message.operation === 'scan' ||
|
||||
message.operation === 'titles' ||
|
||||
message.operation === 'subagents' ||
|
||||
message.operation === 'firstPrompt')
|
||||
)
|
||||
}
|
||||
|
||||
export function isAiVaultServiceChildMessage(value: unknown): value is AiVaultServiceChildMessage {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const message = value as Record<string, unknown>
|
||||
if (message.type === 'ready') {
|
||||
return message.protocol === AI_VAULT_SERVICE_PROTOCOL_VERSION && Number.isInteger(message.pid)
|
||||
}
|
||||
if (message.type === 'invalidated') {
|
||||
return Number.isSafeInteger(message.generation)
|
||||
}
|
||||
return (message.type === 'result' || message.type === 'error') && Number.isSafeInteger(message.id)
|
||||
}
|
||||
|
||||
export function cacheServiceTitle(
|
||||
titleIndex: Map<string, AiVaultSessionTitle>,
|
||||
title: AiVaultSessionTitle,
|
||||
maxEntries = 4_096
|
||||
): void {
|
||||
const key = `${title.agent}\0${title.sessionId}`
|
||||
titleIndex.delete(key)
|
||||
titleIndex.set(key, title)
|
||||
while (titleIndex.size > maxEntries) {
|
||||
const oldest = titleIndex.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
break
|
||||
}
|
||||
titleIndex.delete(oldest)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AiVaultServiceRestartPolicy } from './session-scanner-service-restart-policy'
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('AiVaultServiceRestartPolicy', () => {
|
||||
it('keeps one pending restart when faults overlap', () => {
|
||||
vi.useFakeTimers()
|
||||
const policy = new AiVaultServiceRestartPolicy()
|
||||
const restart = vi.fn()
|
||||
|
||||
policy.recordFault(restart)
|
||||
policy.recordFault(restart)
|
||||
vi.advanceTimersByTime(10_000)
|
||||
|
||||
expect(restart).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('cancels the pending restart on dispose after overlapping faults', () => {
|
||||
vi.useFakeTimers()
|
||||
const policy = new AiVaultServiceRestartPolicy()
|
||||
const restart = vi.fn()
|
||||
|
||||
policy.recordFault(restart)
|
||||
policy.recordFault(restart)
|
||||
policy.dispose()
|
||||
vi.advanceTimersByTime(10_000)
|
||||
|
||||
expect(restart).not.toHaveBeenCalled()
|
||||
expect(policy.restartScheduled).toBe(false)
|
||||
})
|
||||
|
||||
it('opens the circuit after three faults inside the window', () => {
|
||||
let now = 0
|
||||
const policy = new AiVaultServiceRestartPolicy(() => now)
|
||||
|
||||
expect(policy.startError()).toBeNull()
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
policy.recordFault(() => undefined)
|
||||
now += 1_000
|
||||
}
|
||||
|
||||
expect(policy.startError()?.message).toContain('circuit is open')
|
||||
now += 60_000
|
||||
expect(policy.startError()).toBeNull()
|
||||
policy.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
const AI_VAULT_SERVICE_FAULT_WINDOW_MS = 60_000
|
||||
const AI_VAULT_SERVICE_RESTART_DELAYS_MS = [250, 1_000, 5_000] as const
|
||||
|
||||
export class AiVaultServiceRestartPolicy {
|
||||
private faults: number[] = []
|
||||
private circuitUntil = 0
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
|
||||
constructor(private readonly now: () => number = Date.now) {}
|
||||
|
||||
get restartScheduled(): boolean {
|
||||
return this.timer !== null
|
||||
}
|
||||
|
||||
startError(): Error | null {
|
||||
return this.now() < this.circuitUntil
|
||||
? new Error('AI Vault service restart circuit is open.')
|
||||
: null
|
||||
}
|
||||
|
||||
recordFault(restart: () => void): void {
|
||||
const now = this.now()
|
||||
this.faults = [
|
||||
...this.faults.filter((time) => now - time < AI_VAULT_SERVICE_FAULT_WINDOW_MS),
|
||||
now
|
||||
]
|
||||
if (this.faults.length >= 3) {
|
||||
this.circuitUntil = now + AI_VAULT_SERVICE_FAULT_WINDOW_MS
|
||||
}
|
||||
const delay = AI_VAULT_SERVICE_RESTART_DELAYS_MS[Math.min(this.faults.length - 1, 2)]
|
||||
// Why: a second fault before the pending restart fires would otherwise strand
|
||||
// the old timer, leaving a restart that dispose() can no longer cancel.
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null
|
||||
restart()
|
||||
}, delay)
|
||||
this.timer.unref?.()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const forkMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('node:child_process', () => ({ fork: forkMock }))
|
||||
vi.mock('node:fs', () => ({ existsSync: () => true }))
|
||||
|
||||
const { spawnAiVaultServiceProcess } = await import('./session-scanner-service-spawn')
|
||||
|
||||
function forkOptions(): { env?: NodeJS.ProcessEnv; execArgv?: string[] } {
|
||||
return forkMock.mock.calls.at(-1)?.[2] ?? {}
|
||||
}
|
||||
|
||||
describe('spawnAiVaultServiceProcess', () => {
|
||||
beforeEach(() => {
|
||||
forkMock.mockReset()
|
||||
forkMock.mockReturnValue({ pid: undefined, unref: vi.fn() } as unknown as ChildProcess)
|
||||
})
|
||||
|
||||
it('keeps NODE_OPTIONS out of the child so the heap cap and loader stand', () => {
|
||||
vi.stubEnv('NODE_OPTIONS', '--max-old-space-size=8192 --require=/tmp/evil.js')
|
||||
spawnAiVaultServiceProcess()
|
||||
const options = forkOptions()
|
||||
|
||||
// Asserted first: omitting `env` entirely inherits everything, and would
|
||||
// leave the NODE_OPTIONS assertion below passing for the wrong reason.
|
||||
expect(options.env).toBeDefined()
|
||||
expect(options.env?.NODE_OPTIONS).toBeUndefined()
|
||||
expect(options.execArgv).toEqual(['--max-old-space-size=384'])
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('still passes through a relocated agent home', () => {
|
||||
vi.stubEnv('CODEX_HOME', '/home/dev/elsewhere/.codex')
|
||||
spawnAiVaultServiceProcess()
|
||||
|
||||
expect(forkOptions().env?.CODEX_HOME).toBe('/home/dev/elsewhere/.codex')
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('runs the forked Electron binary as plain Node', () => {
|
||||
spawnAiVaultServiceProcess()
|
||||
|
||||
expect(forkOptions().env?.ELECTRON_RUN_AS_NODE).toBe('1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { fork, type ChildProcess } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import type { AiVaultListResult, AiVaultSubagentListResult } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSessionTitleRequest,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../../shared/ai-vault-session-title'
|
||||
import { withSpan } from '../observability/tracer'
|
||||
import type {
|
||||
ReadAiVaultFirstUserPromptArgs,
|
||||
ReadAiVaultFirstUserPromptResult
|
||||
} from './session-first-user-prompt-read'
|
||||
import { getSessionParseCachePersistenceOptions } from './session-parse-cache-persistence'
|
||||
import { buildAiVaultServiceEnv } from './session-scanner-service-env'
|
||||
import { AiVaultScannerServiceClient } from './session-scanner-service-client'
|
||||
import { getAiVaultServiceEntryPath } from './session-scanner-service-entry-path'
|
||||
import { lowerAiVaultServicePriority } from './session-scanner-service-priority'
|
||||
import type { AiVaultServiceSubagentRequest } from './session-scanner-service-protocol'
|
||||
import type { AiVaultWorkerScanOptions } from './session-scanner-worker-protocol'
|
||||
|
||||
export function spawnAiVaultServiceProcess(): ChildProcess {
|
||||
const entryPath = getAiVaultServiceEntryPath()
|
||||
if (!existsSync(entryPath)) {
|
||||
throw new Error(`AI Vault service entry not found: ${entryPath}`)
|
||||
}
|
||||
const child = fork(entryPath, [], {
|
||||
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
||||
execArgv: ['--max-old-space-size=384'],
|
||||
env: buildAiVaultServiceEnv(),
|
||||
...(process.platform === 'win32' ? { windowsHide: true } : {})
|
||||
})
|
||||
lowerAiVaultServicePriority(child.pid)
|
||||
child.unref()
|
||||
return child
|
||||
}
|
||||
|
||||
let sharedClient: AiVaultScannerServiceClient | null = null
|
||||
|
||||
function getSharedClient(): AiVaultScannerServiceClient {
|
||||
sharedClient ??= new AiVaultScannerServiceClient({
|
||||
processFactory: spawnAiVaultServiceProcess,
|
||||
init: { sessionParseCache: getSessionParseCachePersistenceOptions() },
|
||||
onStderr: (text) => console.error('[ai-vault-service]', text.trimEnd())
|
||||
})
|
||||
return sharedClient
|
||||
}
|
||||
|
||||
export function scanAiVaultSessionsInService(
|
||||
options: AiVaultWorkerScanOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultListResult> {
|
||||
return withSpan('aiVault.scan.service', async (span) => {
|
||||
const value = await getSharedClient().request<{
|
||||
result: AiVaultListResult
|
||||
durationMs: number
|
||||
}>({ type: 'request', operation: 'scan', options }, signal)
|
||||
span.setAttribute('serviceDurationMs', value.durationMs)
|
||||
span.setAttribute('sessions', value.result.sessions.length)
|
||||
return value.result
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveAiVaultSessionTitlesInService(
|
||||
requests: AiVaultSessionTitleRequest[],
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSessionTitlesResult> {
|
||||
return getSharedClient().request({ type: 'request', operation: 'titles', requests }, signal)
|
||||
}
|
||||
|
||||
export function listAiVaultSubagentSessionsInService(
|
||||
request: AiVaultServiceSubagentRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSubagentListResult> {
|
||||
return getSharedClient().request({ type: 'request', operation: 'subagents', request }, signal)
|
||||
}
|
||||
|
||||
export function readAiVaultFirstUserPromptInService(
|
||||
request: ReadAiVaultFirstUserPromptArgs,
|
||||
signal?: AbortSignal
|
||||
): Promise<ReadAiVaultFirstUserPromptResult> {
|
||||
return getSharedClient().request({ type: 'request', operation: 'firstPrompt', request }, signal)
|
||||
}
|
||||
|
||||
export function invalidateAiVaultServiceCache(paths: string[]): Promise<void> {
|
||||
return sharedClient?.invalidate(paths) ?? Promise.resolve()
|
||||
}
|
||||
|
||||
export function resetAiVaultScannerServiceForTests(): void {
|
||||
sharedClient?.dispose()
|
||||
sharedClient = null
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
|
||||
export class AiVaultServiceTestChild extends EventEmitter {
|
||||
readonly sent: unknown[] = []
|
||||
readonly stderr = new EventEmitter()
|
||||
readonly pid: number
|
||||
killed = false
|
||||
unrefed = false
|
||||
|
||||
constructor(pid = 12_345) {
|
||||
super()
|
||||
this.pid = pid
|
||||
}
|
||||
|
||||
send(message: unknown, callback?: (error: Error | null) => void): boolean {
|
||||
this.sent.push(message)
|
||||
callback?.(null)
|
||||
return true
|
||||
}
|
||||
|
||||
kill(): boolean {
|
||||
this.killed = true
|
||||
return true
|
||||
}
|
||||
|
||||
unref(): this {
|
||||
this.unrefed = true
|
||||
return this
|
||||
}
|
||||
|
||||
asChildProcess(): ChildProcess {
|
||||
return this as unknown as ChildProcess
|
||||
}
|
||||
}
|
||||
|
||||
export function readyAiVaultServiceChild(child: AiVaultServiceTestChild): void {
|
||||
child.emit('message', { type: 'ready', protocol: 1, pid: child.pid })
|
||||
}
|
||||
|
||||
export function aiVaultServiceRequestId(child: AiVaultServiceTestChild, operation: string): number {
|
||||
const request = child.sent.find(
|
||||
(message) =>
|
||||
typeof message === 'object' &&
|
||||
message !== null &&
|
||||
(message as { operation?: string }).operation === operation
|
||||
) as { id?: number } | undefined
|
||||
if (request?.id === undefined) {
|
||||
throw new Error(`No ${operation} request was sent.`)
|
||||
}
|
||||
return request.id
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { AiVaultSubagentListResult } from '../../shared/ai-vault-types'
|
||||
import { listClaudeSubagentSessions } from './session-scanner-claude-subagents'
|
||||
import { listOmpSubagentSessions } from './session-scanner-omp-subagent-listing'
|
||||
import type { AiVaultServiceSubagentRequest } from './session-scanner-service-protocol'
|
||||
|
||||
export function listLocalAiVaultSubagentSessions(
|
||||
request: AiVaultServiceSubagentRequest
|
||||
): Promise<AiVaultSubagentListResult> {
|
||||
return request.agent === 'claude'
|
||||
? listClaudeSubagentSessions({ parentFilePath: request.parentFilePath })
|
||||
: listOmpSubagentSessions({ parentFilePath: request.parentFilePath })
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type AiVaultSessionTitleRequest,
|
||||
type AiVaultSessionTitlesResult
|
||||
} from '../../shared/ai-vault-session-title'
|
||||
import { resolveAiVaultSessionTitlesInWorker } from './session-scanner-worker-spawn'
|
||||
import { resolveAiVaultSessionTitlesInBackground } from './session-scanner-background'
|
||||
|
||||
const TRANSCRIPT_PATH_MAX_LENGTH = 32_768
|
||||
|
||||
@@ -42,5 +42,5 @@ export async function resolveLocalAiVaultSessionTitles(
|
||||
deduped.set(key, normalized)
|
||||
}
|
||||
}
|
||||
return resolveAiVaultSessionTitlesInWorker([...deduped.values()], signal)
|
||||
return resolveAiVaultSessionTitlesInBackground([...deduped.values()], signal)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '../ai-vault/cached-session-list'
|
||||
import { deleteAiVaultSessionFile } from '../ai-vault/session-delete'
|
||||
import { invalidateSessionParseCacheEntry } from '../ai-vault/session-scanner-parse-cache'
|
||||
import { invalidateAiVaultBackgroundCache } from '../ai-vault/session-scanner-background'
|
||||
import type { AiVaultAgent } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultDeleteSessionArgs,
|
||||
@@ -15,6 +16,7 @@ import type {
|
||||
// invalidation is injected rather than reached into from here.
|
||||
type AiVaultDeleteDeps = {
|
||||
invalidateMultiHostListCache: () => void
|
||||
invalidateBackgroundCache?: (paths: string[]) => Promise<void>
|
||||
}
|
||||
|
||||
// Binds the delete orchestration to the caller's cache-invalidation seam.
|
||||
@@ -52,6 +54,11 @@ export async function deleteAiVaultSession(
|
||||
// what the renderer echoes back as filePath), so invalidate with that exact
|
||||
// key — resolve() could normalise it away from the stored key and miss.
|
||||
invalidateSessionParseCacheEntry(args?.filePath ?? '')
|
||||
await (deps.invalidateBackgroundCache ?? invalidateAiVaultBackgroundCache)([
|
||||
args?.filePath ?? ''
|
||||
]).catch((error) => {
|
||||
console.warn('[ai-vault] background cache invalidation failed:', error)
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@@ -169,14 +169,22 @@ describe('Agent Session History scan coalescing', () => {
|
||||
expect(result).not.toHaveProperty('cancelled')
|
||||
})
|
||||
|
||||
it('still rejects the handler when a scan fails for a non-cancellation reason', async () => {
|
||||
it('reports a failed local scan as a host issue rather than rejecting', async () => {
|
||||
mocks.scanAiVaultSessionsInWorker.mockRejectedValue(new Error('transcript root is unreadable'))
|
||||
registerAiVaultHandlers()
|
||||
const list = ipcHandler('aiVault:listSessions')
|
||||
|
||||
await expect(
|
||||
list({ sender: { id: 1 } }, { executionHostScope: 'local', requestToken: 'scan' })
|
||||
).rejects.toThrow('transcript root is unreadable')
|
||||
// The local leg degrades like the SSH legs above: a rejection reaches the
|
||||
// renderer as a raw string painted over the list instead of an issue row.
|
||||
const result = await list(
|
||||
{ sender: { id: 1 } },
|
||||
{ executionHostScope: 'local', requestToken: 'scan' }
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
sessions: [],
|
||||
issues: [expect.objectContaining({ message: 'transcript root is unreadable', kind: 'host' })]
|
||||
})
|
||||
expect(result).not.toHaveProperty('cancelled')
|
||||
})
|
||||
|
||||
it('re-joins a preempted same-scope caller onto the forced refresh', async () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { getAiVaultWslHomeDirs } from '../ai-vault/cached-session-list'
|
||||
import { listClaudeSubagentSessions } from '../ai-vault/session-scanner-claude-subagents'
|
||||
import { listOmpSubagentSessions } from '../ai-vault/session-scanner-omp-subagent-listing'
|
||||
import { listAiVaultSubagentSessionsInBackground } from '../ai-vault/session-scanner-background'
|
||||
import { claudeProjectsRootDirs, ompSessionsRootDirs } from '../ai-vault/session-scanner-roots'
|
||||
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
@@ -45,7 +44,5 @@ export async function listAiVaultSubagentSessions(
|
||||
if (!roots.some((root) => isPathInsideOrEqual(resolve(root), parentFilePath))) {
|
||||
return { sessions: [], issues: [] }
|
||||
}
|
||||
return args.agent === 'claude'
|
||||
? listClaudeSubagentSessions({ parentFilePath })
|
||||
: listOmpSubagentSessions({ parentFilePath })
|
||||
return listAiVaultSubagentSessionsInBackground({ agent: args.agent, parentFilePath })
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ vi.mock('./ssh', () => ({
|
||||
|
||||
const { OMP_SESSIONS_DIR } = await import('../ai-vault/session-scanner-roots')
|
||||
const { _internals, registerAiVaultHandlers } = await import('./ai-vault')
|
||||
const { deleteAiVaultSession: deleteAiVaultSessionWithDeps } = await import('./ai-vault-delete')
|
||||
|
||||
const provider = {} as IFilesystemProvider
|
||||
|
||||
@@ -789,9 +790,14 @@ describe('deleteAiVaultSession', () => {
|
||||
}
|
||||
|
||||
it('invalidates every AI Vault cache after a real delete', async () => {
|
||||
const invalidateMultiHostListCache = vi.fn()
|
||||
const invalidateBackgroundCache = vi.fn().mockResolvedValue(undefined)
|
||||
mocks.deleteAiVaultSessionFile.mockResolvedValue({ outcome: 'deleted' })
|
||||
|
||||
const result = await _internals.deleteAiVaultSession(args)
|
||||
const result = await deleteAiVaultSessionWithDeps(args, {
|
||||
invalidateMultiHostListCache,
|
||||
invalidateBackgroundCache
|
||||
})
|
||||
|
||||
expect(result).toEqual({ outcome: 'deleted' })
|
||||
expect(mocks.deleteAiVaultSessionFile).toHaveBeenCalledWith(
|
||||
@@ -802,8 +808,10 @@ describe('deleteAiVaultSession', () => {
|
||||
executionHostId: 'local'
|
||||
})
|
||||
)
|
||||
expect(invalidateMultiHostListCache).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.invalidateAiVaultSessionListCache).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.invalidateSessionParseCacheEntry).toHaveBeenCalledWith(args.filePath)
|
||||
expect(invalidateBackgroundCache).toHaveBeenCalledWith([args.filePath])
|
||||
})
|
||||
|
||||
it('does not invalidate any cache when the executor rejects (e.g. non-local host)', async () => {
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
type AiVaultSubagentListArgs,
|
||||
type AiVaultSubagentListResult
|
||||
} from '../../shared/ai-vault-types'
|
||||
import { handleAiVaultGetFirstUserPrompt } from '../ai-vault/session-first-user-prompt-read'
|
||||
import { handleAiVaultGetFirstUserPrompt } from '../ai-vault/session-first-user-prompt-handler'
|
||||
import { registerAiVaultResumeHandler, type AiVaultResumeHandlerOptions } from './ai-vault-resume'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
@@ -139,7 +139,7 @@ async function scanAiVaultSessionsByHostScope(
|
||||
const depth = requestedAiVaultSessionDepth(args)
|
||||
const scopePaths = args?.scopePaths ?? []
|
||||
if (executionHostScope === LOCAL_EXECUTION_HOST_ID) {
|
||||
return scanLocalAiVaultSessions(args, signal)
|
||||
return scanLocalAiVaultSessionsAsIssue(args, signal)
|
||||
}
|
||||
if (executionHostScope === 'all') {
|
||||
const runtimeHosts = getActiveRuntimeAiVaultHostInfosResult()
|
||||
@@ -149,7 +149,7 @@ async function scanAiVaultSessionsByHostScope(
|
||||
...(sshHosts.issue ? [sshHosts.issue] : [])
|
||||
]
|
||||
const scannedResults = await Promise.all([
|
||||
scanLocalAiVaultSessionsForAllScope(args, signal),
|
||||
scanLocalAiVaultSessionsAsIssue(args, signal),
|
||||
...sshHosts.hostInfos.map((hostInfo) =>
|
||||
scanHostLegWithCache({
|
||||
cacheKey: `${cacheKey}|${toSshExecutionHostId(hostInfo.targetId)}`,
|
||||
@@ -226,8 +226,10 @@ function getActiveSshAiVaultHostInfosResult(): AiVaultHostDiscoveryResult<{ targ
|
||||
|
||||
// Why: the SSH legs already degrade to an issue row so one bad host can't take
|
||||
// the shared Promise.all down; the local leg can throw too (parse-cache load,
|
||||
// WSL home resolution) and would otherwise discard every host's sessions.
|
||||
async function scanLocalAiVaultSessionsForAllScope(
|
||||
// WSL home resolution, scanner service supervision) and would otherwise discard
|
||||
// every host's sessions under 'all', or replace the list with a raw error string
|
||||
// under single-host scope.
|
||||
async function scanLocalAiVaultSessionsAsIssue(
|
||||
args: AiVaultListArgs | undefined,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<AiVaultListResult> {
|
||||
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
notificationCtorMock,
|
||||
notificationIsSupportedMock,
|
||||
getAllWindowsMock,
|
||||
getTrustedUIRendererWindowMock,
|
||||
shellOpenExternalMock
|
||||
} = vi.hoisted(() => {
|
||||
const removeHandlerMock = vi.fn()
|
||||
@@ -35,6 +36,7 @@ const {
|
||||
})
|
||||
const notificationIsSupportedMock = vi.fn(() => true)
|
||||
const getAllWindowsMock = vi.fn(() => [])
|
||||
const getTrustedUIRendererWindowMock = vi.fn()
|
||||
const shellOpenExternalMock = vi.fn()
|
||||
return {
|
||||
removeHandlerMock,
|
||||
@@ -47,6 +49,7 @@ const {
|
||||
notificationCtorMock,
|
||||
notificationIsSupportedMock,
|
||||
getAllWindowsMock,
|
||||
getTrustedUIRendererWindowMock,
|
||||
shellOpenExternalMock
|
||||
}
|
||||
})
|
||||
@@ -81,6 +84,10 @@ vi.mock('./notification-authorization-status', () => ({
|
||||
readNotificationAuthorizationStatus: readAuthorizationStatusMock
|
||||
}))
|
||||
|
||||
vi.mock('./ui', () => ({
|
||||
getTrustedUIRendererWindow: getTrustedUIRendererWindowMock
|
||||
}))
|
||||
|
||||
// Why: notifications.ts pulls in the tray module (for the minimized attention
|
||||
// dot), which transitively loads app-icon/electron-toolkit; stub it so this
|
||||
// suite stays focused on notification dispatch and avoids that import chain.
|
||||
@@ -121,6 +128,8 @@ describe('registerNotificationHandlers', () => {
|
||||
readAuthorizationStatusMock.mockResolvedValue(null)
|
||||
getAllWindowsMock.mockReset()
|
||||
getAllWindowsMock.mockReturnValue([])
|
||||
getTrustedUIRendererWindowMock.mockReset()
|
||||
getTrustedUIRendererWindowMock.mockReturnValue(null)
|
||||
shellOpenExternalMock.mockClear()
|
||||
setTrayAttentionMock.mockClear()
|
||||
})
|
||||
@@ -452,20 +461,32 @@ describe('registerNotificationHandlers', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('focuses the originating terminal pane when a notification with paneKey is clicked', async () => {
|
||||
it('focuses the originating terminal pane in the main window when a dashboard popout is open', async () => {
|
||||
const popoutSend = vi.fn()
|
||||
const popoutFocus = vi.fn()
|
||||
const webContentsSend = vi.fn()
|
||||
const restore = vi.fn()
|
||||
const show = vi.fn()
|
||||
const focus = vi.fn()
|
||||
getAllWindowsMock.mockReturnValue([
|
||||
{
|
||||
isDestroyed: () => false,
|
||||
isFocused: () => false,
|
||||
isMinimized: () => true,
|
||||
restore,
|
||||
focus,
|
||||
webContents: { send: webContentsSend }
|
||||
} as never
|
||||
])
|
||||
const popoutWindow = {
|
||||
isDestroyed: () => false,
|
||||
isFocused: () => true,
|
||||
isMinimized: () => false,
|
||||
restore: vi.fn(),
|
||||
focus: popoutFocus,
|
||||
webContents: { send: popoutSend }
|
||||
}
|
||||
const mainWindow = {
|
||||
isDestroyed: () => false,
|
||||
isFocused: () => false,
|
||||
isMinimized: () => true,
|
||||
restore,
|
||||
show,
|
||||
focus,
|
||||
webContents: { send: webContentsSend }
|
||||
}
|
||||
getAllWindowsMock.mockReturnValue([popoutWindow, mainWindow] as never)
|
||||
getTrustedUIRendererWindowMock.mockReturnValue(mainWindow)
|
||||
registerNotificationHandlers({
|
||||
getSettings: () => ({
|
||||
notifications: {
|
||||
@@ -486,8 +507,12 @@ describe('registerNotificationHandlers', () => {
|
||||
|
||||
getNotificationEventHandler('click')()
|
||||
|
||||
expect(getTrustedUIRendererWindowMock).toHaveBeenCalledTimes(1)
|
||||
expect(restore).toHaveBeenCalledTimes(1)
|
||||
expect(show).toHaveBeenCalledTimes(1)
|
||||
expect(focus).toHaveBeenCalledTimes(1)
|
||||
expect(popoutFocus).not.toHaveBeenCalled()
|
||||
expect(popoutSend).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(notificationRemoveListenerMock).toHaveBeenCalledWith('click', expect.any(Function))
|
||||
expect(webContentsSend).toHaveBeenCalledWith('ui:activateWorktree', {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { readNotificationAuthorizationStatus } from './notification-authorizatio
|
||||
import { parsePaneKey } from '../../shared/stable-pane-id'
|
||||
import { setTrayAttention } from '../tray/system-tray'
|
||||
import { isMainWindowVisible } from '../window/main-window-visibility'
|
||||
import { getTrustedUIRendererWindow } from './ui'
|
||||
|
||||
const NOTIFICATION_COOLDOWN_MS = 5000
|
||||
const MAX_RECENT_NOTIFICATION_KEYS = 50
|
||||
@@ -512,8 +513,8 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
|
||||
const repoId = getRepoIdFromWorktreeId(args.worktreeId)
|
||||
clickHandler = () => {
|
||||
release()
|
||||
const win = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed())
|
||||
if (!win) {
|
||||
const win = getTrustedUIRendererWindow()
|
||||
if (!win || win.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
@@ -522,6 +523,7 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
|
||||
if (win.isMinimized()) {
|
||||
win.restore()
|
||||
}
|
||||
win.show()
|
||||
win.focus()
|
||||
win.webContents.send('ui:activateWorktree', {
|
||||
repoId,
|
||||
|
||||
@@ -603,6 +603,18 @@ describe('registerWorktreeHandlers', () => {
|
||||
expect(orderedTargets).not.toContain(staleId)
|
||||
})
|
||||
|
||||
it('persistSortOrder skips ranks that already represent the requested order', () => {
|
||||
const firstId = 'repo-1::/workspace/first'
|
||||
const secondId = 'repo-1::/workspace/second'
|
||||
store.getWorktreeMeta.mockImplementation((id: string) => ({
|
||||
sortOrder: id === firstId ? 200 : 100
|
||||
}))
|
||||
|
||||
handlers['worktrees:persistSortOrder'](null, { orderedIds: [firstId, secondId] })
|
||||
|
||||
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prefetches the local default create base through the runtime refresh cache', async () => {
|
||||
const repo = {
|
||||
id: 'repo-1',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import { readBranchRenameFailureOutputForDisplay } from '../agent-hooks/branch-rename-failure-output'
|
||||
import { parseWorkspaceKey } from '../../shared/workspace-scope'
|
||||
import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports'
|
||||
import { planWorktreeSortOrderUpdates } from '../../shared/worktree-sort-order-update'
|
||||
import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection'
|
||||
import { TaskSourceContextSchema } from '../../shared/task-source-context-schema'
|
||||
import { WorkspaceLinkedItemSchema } from '../../shared/workspace-linked-item-schema'
|
||||
@@ -3142,18 +3143,13 @@ export function registerWorktreeHandlers(
|
||||
if (!Array.isArray(args?.orderedIds) || args.orderedIds.length === 0) {
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
for (let i = 0; i < args.orderedIds.length; i++) {
|
||||
// Why: a sidebar-order snapshot must only reorder worktrees that already
|
||||
// exist — it must never create one. Without this guard a stale id the
|
||||
// renderer still lists (e.g. a removed repo's `${repoId}::${path}`) gets a
|
||||
// fresh worktreeMeta entry minted here, resurrecting an orphan/duplicate
|
||||
// workspace on the next launch. setWorktreeMeta has no repo-existence check.
|
||||
if (!store.getWorktreeMeta(args.orderedIds[i])) {
|
||||
continue
|
||||
}
|
||||
// Descending timestamps: first item gets highest sortOrder so b - a sorts first-wins on cold start.
|
||||
store.setWorktreeMeta(args.orderedIds[i], { sortOrder: now - i * 1000 })
|
||||
const updates = planWorktreeSortOrderUpdates(
|
||||
args.orderedIds,
|
||||
(worktreeId) => store.getWorktreeMeta(worktreeId),
|
||||
Date.now()
|
||||
)
|
||||
for (const update of updates) {
|
||||
store.setWorktreeMeta(update.worktreeId, { sortOrder: update.sortOrder })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { normalizeFeatureInteractions } from '../shared/feature-interactions'
|
||||
import type { PersistedState } from '../shared/types'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => tmpdir() },
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
encryptString: (value: string) => Buffer.from(value),
|
||||
decryptString: (value: Buffer) => value.toString('utf8')
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) }))
|
||||
|
||||
import { Store } from './persistence'
|
||||
|
||||
const INTERACTIONS = 200
|
||||
const tempDirs: string[] = []
|
||||
|
||||
function createStore(name: string): { dataFile: string; store: Store } {
|
||||
const dir = mkdtempSync(join(tmpdir(), `orca-ui-broadcast-${name}-`))
|
||||
tempDirs.push(dir)
|
||||
const dataFile = join(dir, 'orca-data.json')
|
||||
return { dataFile, store: new Store({ dataFile }) }
|
||||
}
|
||||
|
||||
describe('feature interaction UI broadcast benchmark', () => {
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('reduces repeated interaction recording to one material UI broadcast', () => {
|
||||
const { store: legacyStore } = createStore('legacy')
|
||||
const { dataFile, store: optimizedStore } = createStore('optimized')
|
||||
let legacyBroadcasts = 0
|
||||
let optimizedBroadcasts = 0
|
||||
legacyStore.onUIChanged(() => legacyBroadcasts++)
|
||||
optimizedStore.onUIChanged(() => optimizedBroadcasts++)
|
||||
|
||||
const legacyStart = performance.now()
|
||||
for (let index = 0; index < INTERACTIONS; index++) {
|
||||
const featureInteractions = normalizeFeatureInteractions(
|
||||
legacyStore.getUI().featureInteractions
|
||||
)
|
||||
const existing = featureInteractions['agent-orchestration']
|
||||
legacyStore.updateUI({
|
||||
featureInteractions: {
|
||||
...featureInteractions,
|
||||
'agent-orchestration': {
|
||||
firstInteractedAt: existing?.firstInteractedAt ?? 1,
|
||||
interactionCount: (existing?.interactionCount ?? 0) + 1
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
const legacyMs = performance.now() - legacyStart
|
||||
|
||||
const optimizedStart = performance.now()
|
||||
for (let index = 0; index < INTERACTIONS; index++) {
|
||||
optimizedStore.recordFeatureInteraction('agent-orchestration')
|
||||
}
|
||||
const optimizedMs = performance.now() - optimizedStart
|
||||
|
||||
expect(legacyBroadcasts).toBe(INTERACTIONS)
|
||||
expect(optimizedBroadcasts).toBe(1)
|
||||
expect(
|
||||
optimizedStore.getUI().featureInteractions?.['agent-orchestration']?.interactionCount
|
||||
).toBe(INTERACTIONS)
|
||||
optimizedStore.flush()
|
||||
expect(
|
||||
new Store({ dataFile }).getUI().featureInteractions?.['agent-orchestration']?.interactionCount
|
||||
).toBe(INTERACTIONS)
|
||||
const persisted = JSON.parse(readFileSync(dataFile, 'utf8')) as PersistedState
|
||||
expect(persisted.featureInteractionTelemetryBuckets?.['agent-orchestration']).toBe(
|
||||
'count_200_499'
|
||||
)
|
||||
|
||||
console.log(
|
||||
`[bench] ${INTERACTIONS} orchestration interactions: ` +
|
||||
`full-state broadcasts ${legacyBroadcasts} -> ${optimizedBroadcasts}; ` +
|
||||
`main-thread mutation ${legacyMs.toFixed(2)}ms -> ${optimizedMs.toFixed(2)}ms`
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -5778,7 +5778,7 @@ export class Store {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: UI view-state is written from both desktop and mobile (ui.set RPC), so notify to keep bi-directional sync (desktop hydrates UI only once).
|
||||
// Why: renderer-visible UI state is written from desktop and mobile, so notify to keep bi-directional sync.
|
||||
onUIChanged(listener: (ui: PersistedState['ui']) => void): () => void {
|
||||
this.uiChangeListeners.add(listener)
|
||||
return () => {
|
||||
@@ -6189,7 +6189,8 @@ export class Store {
|
||||
(lastEmittedBucket === null ||
|
||||
compareFeatureInteractionUsageBuckets(nextBucket, lastEmittedBucket) > 0)
|
||||
|
||||
this.updateUI({
|
||||
this.state.ui = {
|
||||
...this.state.ui,
|
||||
featureInteractions: {
|
||||
...featureInteractions,
|
||||
[id]: {
|
||||
@@ -6197,11 +6198,15 @@ export class Store {
|
||||
interactionCount: nextCount
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
this.state.featureInteractionTelemetryBuckets = shouldEmit
|
||||
? { ...telemetryBuckets, [id]: nextBucket }
|
||||
: telemetryBuckets
|
||||
this.scheduleSave()
|
||||
// Why: live UI only consumes the seen transition; count-only telemetry must not re-hydrate the renderer.
|
||||
if (!existing) {
|
||||
this.notifyUIChanged()
|
||||
}
|
||||
|
||||
if (shouldEmit) {
|
||||
track('feature_interaction_usage_bucket_reached', {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createMobileSessionTabsAgentStatusHeartbeat,
|
||||
SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS,
|
||||
SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS
|
||||
} from './mobile-session-tabs-agent-status-heartbeat'
|
||||
|
||||
describe('mobile session-tabs agent-status heartbeat', () => {
|
||||
const worktreesByPtyId = new Map<string, string[]>()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
worktreesByPtyId.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('refreshes continuously active PTYs through one globally capped FIFO', () => {
|
||||
const emitted: { worktreeId: string; at: number }[] = []
|
||||
const heartbeat = createMobileSessionTabsAgentStatusHeartbeat(
|
||||
(ptyId) => worktreesByPtyId.get(ptyId) ?? [],
|
||||
(worktreeId) => emitted.push({ worktreeId, at: Date.now() })
|
||||
)
|
||||
const ptyIds = Array.from({ length: 24 }, (_, index) => `pty-${index}`)
|
||||
for (const ptyId of ptyIds) {
|
||||
worktreesByPtyId.set(ptyId, [ptyId.replace('pty', 'worktree')])
|
||||
heartbeat.observeSemanticTitle(ptyId)
|
||||
}
|
||||
|
||||
vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS - 1)
|
||||
for (const ptyId of ptyIds) {
|
||||
heartbeat.scheduleDecorativeHeartbeat(ptyId)
|
||||
}
|
||||
vi.runOnlyPendingTimers()
|
||||
expect(emitted).toEqual([])
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
for (const ptyId of ptyIds) {
|
||||
heartbeat.scheduleDecorativeHeartbeat(ptyId)
|
||||
}
|
||||
vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS * (ptyIds.length - 1))
|
||||
|
||||
expect(emitted.map(({ worktreeId }) => worktreeId)).toEqual(
|
||||
ptyIds.map((ptyId) => ptyId.replace('pty', 'worktree'))
|
||||
)
|
||||
for (let index = 1; index < emitted.length; index += 1) {
|
||||
expect(emitted[index]!.at - emitted[index - 1]!.at).toBeGreaterThanOrEqual(
|
||||
SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS
|
||||
)
|
||||
}
|
||||
for (const ptyId of ptyIds) {
|
||||
heartbeat.scheduleDecorativeHeartbeat(ptyId)
|
||||
}
|
||||
vi.runOnlyPendingTimers()
|
||||
expect(emitted).toHaveLength(ptyIds.length)
|
||||
heartbeat.dispose()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('lets semantic transitions bypass and cancel queued heartbeats', () => {
|
||||
const emitted: string[] = []
|
||||
worktreesByPtyId.set('pty-1', ['worktree-1'])
|
||||
worktreesByPtyId.set('pty-2', ['worktree-2'])
|
||||
const heartbeat = createMobileSessionTabsAgentStatusHeartbeat(
|
||||
(ptyId) => worktreesByPtyId.get(ptyId) ?? [],
|
||||
(worktreeId) => emitted.push(worktreeId)
|
||||
)
|
||||
heartbeat.observeSemanticTitle('pty-1')
|
||||
heartbeat.observeSemanticTitle('pty-2')
|
||||
vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS)
|
||||
|
||||
heartbeat.scheduleDecorativeHeartbeat('pty-1')
|
||||
heartbeat.scheduleDecorativeHeartbeat('pty-2')
|
||||
heartbeat.observeSemanticTitle('pty-2')
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(emitted).toEqual(['worktree-1'])
|
||||
heartbeat.removePty('pty-1')
|
||||
heartbeat.removePty('pty-2')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('deduplicates sibling PTYs because one snapshot refreshes their worktree', () => {
|
||||
const emitted: string[] = []
|
||||
worktreesByPtyId.set('pty-1', ['worktree-1'])
|
||||
worktreesByPtyId.set('pty-2', ['worktree-1'])
|
||||
const heartbeat = createMobileSessionTabsAgentStatusHeartbeat(
|
||||
(ptyId) => worktreesByPtyId.get(ptyId) ?? [],
|
||||
(worktreeId) => emitted.push(worktreeId)
|
||||
)
|
||||
heartbeat.observeSemanticTitle('pty-1')
|
||||
heartbeat.observeSemanticTitle('pty-2')
|
||||
vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS)
|
||||
|
||||
heartbeat.scheduleDecorativeHeartbeat('pty-1')
|
||||
heartbeat.scheduleDecorativeHeartbeat('pty-2')
|
||||
heartbeat.removePty('pty-1')
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(emitted).toEqual(['worktree-1'])
|
||||
})
|
||||
|
||||
it('does not slip spacing-delayed worktrees to the next stale boundary', () => {
|
||||
const emitted: { worktreeId: string; at: number }[] = []
|
||||
worktreesByPtyId.set('pty-1', ['worktree-1'])
|
||||
worktreesByPtyId.set('pty-2', ['worktree-2'])
|
||||
const heartbeat = createMobileSessionTabsAgentStatusHeartbeat(
|
||||
(ptyId) => worktreesByPtyId.get(ptyId) ?? [],
|
||||
(worktreeId) => emitted.push({ worktreeId, at: Date.now() })
|
||||
)
|
||||
heartbeat.observeSemanticTitle('pty-1')
|
||||
heartbeat.observeSemanticTitle('pty-2')
|
||||
|
||||
vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS)
|
||||
heartbeat.scheduleDecorativeHeartbeat('pty-1')
|
||||
heartbeat.scheduleDecorativeHeartbeat('pty-2')
|
||||
vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS)
|
||||
expect(emitted).toEqual([
|
||||
{ worktreeId: 'worktree-1', at: SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS },
|
||||
{
|
||||
worktreeId: 'worktree-2',
|
||||
at:
|
||||
SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS +
|
||||
SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS
|
||||
}
|
||||
])
|
||||
|
||||
vi.advanceTimersByTime(
|
||||
SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS -
|
||||
SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS
|
||||
)
|
||||
heartbeat.scheduleDecorativeHeartbeat('pty-1')
|
||||
heartbeat.scheduleDecorativeHeartbeat('pty-2')
|
||||
vi.runOnlyPendingTimers()
|
||||
expect(emitted.at(-1)).toEqual({
|
||||
worktreeId: 'worktree-1',
|
||||
at: SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS * 2
|
||||
})
|
||||
|
||||
vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS - 1)
|
||||
heartbeat.scheduleDecorativeHeartbeat('pty-2')
|
||||
vi.runOnlyPendingTimers()
|
||||
expect(emitted).toHaveLength(3)
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
heartbeat.scheduleDecorativeHeartbeat('pty-2')
|
||||
vi.runOnlyPendingTimers()
|
||||
expect(emitted.at(-1)).toEqual({
|
||||
worktreeId: 'worktree-2',
|
||||
at:
|
||||
SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS * 2 +
|
||||
SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS
|
||||
})
|
||||
})
|
||||
|
||||
it('drops removed worktrees and pending work during teardown', () => {
|
||||
const emitted: string[] = []
|
||||
worktreesByPtyId.set('pty-1', ['worktree-1'])
|
||||
worktreesByPtyId.set('pty-2', ['worktree-2'])
|
||||
const heartbeat = createMobileSessionTabsAgentStatusHeartbeat(
|
||||
(ptyId) => worktreesByPtyId.get(ptyId) ?? [],
|
||||
(worktreeId) => emitted.push(worktreeId)
|
||||
)
|
||||
heartbeat.observeSemanticTitle('pty-1')
|
||||
heartbeat.observeSemanticTitle('pty-2')
|
||||
vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS)
|
||||
heartbeat.scheduleDecorativeHeartbeat('pty-1')
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
heartbeat.removePty('pty-1')
|
||||
vi.runAllTimers()
|
||||
expect(emitted).toEqual([])
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
heartbeat.scheduleDecorativeHeartbeat('pty-2')
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
heartbeat.removeWorktree('worktree-2')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
heartbeat.scheduleDecorativeHeartbeat('pty-1')
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
heartbeat.cancelPending()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
heartbeat.scheduleDecorativeHeartbeat('pty-1')
|
||||
heartbeat.dispose()
|
||||
vi.runAllTimers()
|
||||
expect(emitted).toEqual([])
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types'
|
||||
|
||||
// Refresh halfway through the stale lease so a bounded global queue has ample drain time.
|
||||
export const SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS = AGENT_STATUS_STALE_AFTER_MS / 2
|
||||
export const SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS = 50
|
||||
|
||||
export type MobileSessionTabsAgentStatusHeartbeat = {
|
||||
observeSemanticTitle: (ptyId: string) => void
|
||||
scheduleDecorativeHeartbeat: (ptyId: string) => void
|
||||
removePty: (ptyId: string) => void
|
||||
removeWorktree: (worktreeId: string) => void
|
||||
cancelPending: () => void
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
export function createMobileSessionTabsAgentStatusHeartbeat(
|
||||
resolveWorktreeIds: (ptyId: string) => Iterable<string>,
|
||||
emit: (worktreeId: string) => void
|
||||
): MobileSessionTabsAgentStatusHeartbeat {
|
||||
const lastEligibilityCheckAtByPtyId = new Map<string, number>()
|
||||
const lastRefreshAtByWorktreeId = new Map<string, number>()
|
||||
const pendingPtyIdsByWorktreeId = new Map<string, Set<string>>()
|
||||
let lastGlobalHeartbeatAt: number | null = null
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const clearTimer = (): void => {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
const arm = (): void => {
|
||||
if (timer !== null || pendingPtyIdsByWorktreeId.size === 0) {
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
const delay =
|
||||
lastGlobalHeartbeatAt === null
|
||||
? 0
|
||||
: Math.max(
|
||||
0,
|
||||
SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS - (now - lastGlobalHeartbeatAt)
|
||||
)
|
||||
timer = setTimeout(() => {
|
||||
timer = null
|
||||
const worktreeId = pendingPtyIdsByWorktreeId.keys().next().value
|
||||
if (typeof worktreeId !== 'string') {
|
||||
return
|
||||
}
|
||||
const pendingPtyIds = pendingPtyIdsByWorktreeId.get(worktreeId)
|
||||
pendingPtyIdsByWorktreeId.delete(worktreeId)
|
||||
const emittedAt = Date.now()
|
||||
lastRefreshAtByWorktreeId.set(worktreeId, emittedAt)
|
||||
for (const ptyId of pendingPtyIds ?? []) {
|
||||
lastEligibilityCheckAtByPtyId.set(ptyId, emittedAt)
|
||||
}
|
||||
lastGlobalHeartbeatAt = emittedAt
|
||||
emit(worktreeId)
|
||||
arm()
|
||||
}, delay)
|
||||
if (typeof timer.unref === 'function') {
|
||||
timer.unref()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
observeSemanticTitle(ptyId: string): void {
|
||||
const observedAt = Date.now()
|
||||
lastEligibilityCheckAtByPtyId.set(ptyId, observedAt)
|
||||
for (const worktreeId of resolveWorktreeIds(ptyId)) {
|
||||
lastRefreshAtByWorktreeId.set(worktreeId, observedAt)
|
||||
pendingPtyIdsByWorktreeId.delete(worktreeId)
|
||||
}
|
||||
if (pendingPtyIdsByWorktreeId.size === 0) {
|
||||
clearTimer()
|
||||
}
|
||||
},
|
||||
scheduleDecorativeHeartbeat(ptyId: string): void {
|
||||
const now = Date.now()
|
||||
const lastEligibilityCheckAt = lastEligibilityCheckAtByPtyId.get(ptyId)
|
||||
if (
|
||||
lastEligibilityCheckAt !== undefined &&
|
||||
now - lastEligibilityCheckAt < SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS
|
||||
) {
|
||||
return
|
||||
}
|
||||
lastEligibilityCheckAtByPtyId.set(ptyId, now)
|
||||
for (const worktreeId of resolveWorktreeIds(ptyId)) {
|
||||
const lastRefreshAt = lastRefreshAtByWorktreeId.get(worktreeId)
|
||||
if (
|
||||
lastRefreshAt === undefined ||
|
||||
now - lastRefreshAt >= SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS
|
||||
) {
|
||||
const pendingPtyIds = pendingPtyIdsByWorktreeId.get(worktreeId) ?? new Set<string>()
|
||||
pendingPtyIds.add(ptyId)
|
||||
pendingPtyIdsByWorktreeId.set(worktreeId, pendingPtyIds)
|
||||
}
|
||||
}
|
||||
arm()
|
||||
},
|
||||
removePty(ptyId: string): void {
|
||||
lastEligibilityCheckAtByPtyId.delete(ptyId)
|
||||
for (const [worktreeId, pendingPtyIds] of pendingPtyIdsByWorktreeId) {
|
||||
pendingPtyIds.delete(ptyId)
|
||||
if (pendingPtyIds.size === 0) {
|
||||
pendingPtyIdsByWorktreeId.delete(worktreeId)
|
||||
}
|
||||
}
|
||||
if (pendingPtyIdsByWorktreeId.size === 0) {
|
||||
clearTimer()
|
||||
}
|
||||
},
|
||||
removeWorktree(worktreeId: string): void {
|
||||
lastRefreshAtByWorktreeId.delete(worktreeId)
|
||||
pendingPtyIdsByWorktreeId.delete(worktreeId)
|
||||
if (pendingPtyIdsByWorktreeId.size === 0) {
|
||||
clearTimer()
|
||||
}
|
||||
},
|
||||
cancelPending(): void {
|
||||
clearTimer()
|
||||
pendingPtyIdsByWorktreeId.clear()
|
||||
},
|
||||
dispose(): void {
|
||||
clearTimer()
|
||||
pendingPtyIdsByWorktreeId.clear()
|
||||
lastEligibilityCheckAtByPtyId.clear()
|
||||
lastRefreshAtByWorktreeId.clear()
|
||||
lastGlobalHeartbeatAt = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -230,6 +230,65 @@ describe('headless hook agent-status projection (#11761)', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not carry a shell-obscured question into the next working interval', async () => {
|
||||
const rows = [hookRow()]
|
||||
const runtime = await createRuntimeWithHookRows(rows)
|
||||
const questionAt = rows[0]!.receivedAt
|
||||
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(questionAt + 1)
|
||||
try {
|
||||
const initial = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
|
||||
const initialTab = initial.tabs[0]
|
||||
if (initialTab?.type !== 'terminal' || !initialTab.terminal) {
|
||||
throw new Error('expected a live terminal handle')
|
||||
}
|
||||
await runtime.renameTerminal(initialTab.terminal, 'bash')
|
||||
observePaneTitle(runtime, 'bash')
|
||||
let result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
|
||||
let tab = result.tabs[0]
|
||||
expect(tab?.type === 'terminal' && tab.agentStatus).toEqual(
|
||||
expect.objectContaining({ state: 'waiting', interactivePrompt: ASK_PROMPT })
|
||||
)
|
||||
|
||||
dateNow.mockReturnValue(questionAt + 2)
|
||||
observePaneTitle(runtime, '⠋ Claude')
|
||||
result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
|
||||
tab = result.tabs[0]
|
||||
expect(tab?.type === 'terminal' && tab.agentStatus).toEqual(
|
||||
expect.objectContaining({ state: 'working', prompt: '' })
|
||||
)
|
||||
expect(tab?.type === 'terminal' && tab.agentStatus).not.toHaveProperty('interactivePrompt')
|
||||
} finally {
|
||||
dateNow.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not carry a hook question across a provider generation reset', async () => {
|
||||
const runtime = await createRuntimeWithHookRows([hookRow()])
|
||||
const internals = runtime as unknown as {
|
||||
ptysById: Map<string, { title: string | null }>
|
||||
resetTrackedTerminalStateForProviderGeneration: (ptyId: string) => void
|
||||
}
|
||||
internals.ptysById.get(PTY_ID)!.title = 'bash'
|
||||
internals.resetTrackedTerminalStateForProviderGeneration(PTY_ID)
|
||||
|
||||
const result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
|
||||
const tab = result.tabs[0]
|
||||
expect(tab?.type === 'terminal' && tab.agentStatus).not.toHaveProperty('interactivePrompt')
|
||||
})
|
||||
|
||||
it('does not carry a hook question across an identity-only owner title', async () => {
|
||||
const runtime = await createRuntimeWithHookRows([hookRow()])
|
||||
const internals = runtime as unknown as {
|
||||
ptysById: Map<string, { title: string | null }>
|
||||
}
|
||||
internals.ptysById.get(PTY_ID)!.title = 'bash'
|
||||
observePaneTitle(runtime, 'Cursor Agent')
|
||||
|
||||
const result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
|
||||
const tab = result.tabs[0]
|
||||
expect(tab?.type === 'terminal' && tab.agentStatus).not.toHaveProperty('interactivePrompt')
|
||||
})
|
||||
|
||||
// The title path is refreshed live; an older hook `done` must not erase it.
|
||||
it('keeps the title-derived working state when the hook row predates the title', async () => {
|
||||
const now = Date.now()
|
||||
|
||||
@@ -19079,6 +19079,71 @@ describe('OrcaRuntimeService', () => {
|
||||
).rejects.toThrow('terminal_orphan_competing_owner')
|
||||
})
|
||||
|
||||
it('preserves concurrent workspace-session changes when async orphan persistence fails', async () => {
|
||||
const session = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeRepoId: TEST_REPO_ID,
|
||||
activeWorktreeId: TEST_WORKTREE_ID,
|
||||
tabsByWorktree: { [TEST_WORKTREE_ID]: [] }
|
||||
}
|
||||
const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession(session)
|
||||
const durableWrite = deferred<void>()
|
||||
const durableWriteStarted = deferred<void>()
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...runtimeStore,
|
||||
flushPendingOrThrowAsync: vi.fn(() => {
|
||||
durableWriteStarted.resolve()
|
||||
return durableWrite.promise
|
||||
})
|
||||
} as never)
|
||||
runtime.setPtyController({
|
||||
write: vi.fn(() => true),
|
||||
kill: vi.fn(() => true),
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [
|
||||
{
|
||||
id: 'pty-async-rollback',
|
||||
incarnationId: 'inc-async-rollback',
|
||||
terminalHandle: 'term_async_rollback',
|
||||
title: 'Async rollback',
|
||||
cwd: TEST_WORKTREE_PATH,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
wslDistro: null
|
||||
}
|
||||
]
|
||||
})
|
||||
const before = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
const adoption = runtime.adoptTerminalOrphans({
|
||||
worktree: `id:${TEST_WORKTREE_ID}`,
|
||||
expectedTopologyRevision: before.topologyRevisions?.[TEST_WORKTREE_ID] ?? 0,
|
||||
claims: [
|
||||
{
|
||||
terminal: 'term_async_rollback',
|
||||
ptyId: 'pty-async-rollback',
|
||||
incarnationId: 'inc-async-rollback',
|
||||
tabId: 'tab-async-rollback',
|
||||
leafId: HEADLESS_LEAF_ID
|
||||
}
|
||||
]
|
||||
})
|
||||
await durableWriteStarted.promise
|
||||
setSession({
|
||||
...getSession(),
|
||||
activeTabIdByWorktree: {
|
||||
...getSession().activeTabIdByWorktree,
|
||||
'concurrent-worktree': 'concurrent-tab'
|
||||
}
|
||||
})
|
||||
durableWrite.reject(new Error('disk unavailable'))
|
||||
|
||||
await expect(adoption).rejects.toThrow('disk unavailable')
|
||||
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([])
|
||||
expect(getSession().terminalLayoutsByTabId['tab-async-rollback']).toBeUndefined()
|
||||
expect(getSession().activeTabIdByWorktree?.['concurrent-worktree']).toBe('concurrent-tab')
|
||||
expect(getSession().terminalTopologyRevisionByRepoId?.[TEST_REPO_ID] ?? 0).toBe(0)
|
||||
})
|
||||
|
||||
function publishLegacyWorkerReveal(
|
||||
runtime: OrcaRuntimeService,
|
||||
identity: { worktreeId: string; tabId: string; leafId: string; ptyId: string },
|
||||
@@ -19845,17 +19910,23 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session)
|
||||
const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession(session)
|
||||
const durableWrite = deferred<void>()
|
||||
const durableWriteStarted = deferred<void>()
|
||||
let flushCount = 0
|
||||
const flushOrThrow = vi.fn(() => {
|
||||
const flushPendingOrThrowAsync = vi.fn(() => {
|
||||
flushCount += 1
|
||||
if (flushCount === 1 || flushCount === 3) {
|
||||
throw new Error('disk unavailable')
|
||||
if (flushCount === 1) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
durableWriteStarted.resolve()
|
||||
return durableWrite.promise
|
||||
})
|
||||
const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow } as never, undefined, {
|
||||
canRecoverPersistentLocalPtys: () => true
|
||||
})
|
||||
const runtime = new OrcaRuntimeService(
|
||||
{ ...runtimeStore, flushPendingOrThrowAsync } as never,
|
||||
undefined,
|
||||
{ canRecoverPersistentLocalPtys: () => true }
|
||||
)
|
||||
runtime.setOrchestrationDb({
|
||||
listLegacyWorkerTerminalRecoveryRows: () => [
|
||||
{
|
||||
@@ -19904,18 +19975,35 @@ describe('OrcaRuntimeService', () => {
|
||||
} as never)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
await expect(
|
||||
runtime.reconcileLegacyWorkerTerminals({ materializeRenderer: true })
|
||||
).resolves.toMatchObject({
|
||||
const recovery = runtime.reconcileLegacyWorkerTerminals({ materializeRenderer: true })
|
||||
await durableWriteStarted.promise
|
||||
const concurrentPaneKey = `concurrent:${HEADLESS_SECOND_LEAF_ID}`
|
||||
setSession({
|
||||
...getSession(),
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
...getSession().sleepingAgentSessionsByPaneKey,
|
||||
[concurrentPaneKey]: {
|
||||
...session.sleepingAgentSessionsByPaneKey![workerPaneKey]!,
|
||||
paneKey: concurrentPaneKey,
|
||||
tabId: 'concurrent-tab'
|
||||
}
|
||||
}
|
||||
})
|
||||
durableWrite.reject(new Error('disk unavailable'))
|
||||
|
||||
await expect(recovery).resolves.toMatchObject({
|
||||
adoptedDispatchIds: [],
|
||||
exitedDispatchIds: [],
|
||||
deferredDispatchIds: ['dispatch-persistence-failure']
|
||||
})
|
||||
expect(flushOrThrow).toHaveBeenCalledTimes(3)
|
||||
expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(2)
|
||||
expect(revealTerminalSession).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy
|
||||
).toBe('legacy-orchestration-worker')
|
||||
expect(getSession().sleepingAgentSessionsByPaneKey?.[concurrentPaneKey]?.tabId).toBe(
|
||||
'concurrent-tab'
|
||||
)
|
||||
expect(resolveLegacyWorkerTerminalRecovery).not.toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
})
|
||||
@@ -19942,8 +20030,17 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
}
|
||||
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session)
|
||||
const durableWrite = deferred<void>()
|
||||
const durableWriteStarted = deferred<void>()
|
||||
const flushPendingOrThrowAsync = vi.fn(() => {
|
||||
durableWriteStarted.resolve()
|
||||
return durableWrite.promise
|
||||
})
|
||||
const flushOrThrow = vi.fn(() => {
|
||||
throw new Error('synchronous persistence must not run')
|
||||
})
|
||||
const runtime = new OrcaRuntimeService(
|
||||
{ ...runtimeStore, flushOrThrow: vi.fn() } as never,
|
||||
{ ...runtimeStore, flushOrThrow, flushPendingOrThrowAsync } as never,
|
||||
undefined,
|
||||
{ canRecoverPersistentLocalPtys: () => true }
|
||||
)
|
||||
@@ -19975,12 +20072,24 @@ describe('OrcaRuntimeService', () => {
|
||||
const resolveLegacyWorkerTerminalRecovery = vi.fn()
|
||||
runtime.setNotifier({ resolveLegacyWorkerTerminalRecovery } as never)
|
||||
|
||||
await expect(runtime.reconcileLegacyWorkerTerminals()).resolves.toMatchObject({
|
||||
const recovery = runtime.reconcileLegacyWorkerTerminals()
|
||||
await durableWriteStarted.promise
|
||||
|
||||
expect(resolveLegacyWorkerTerminalRecovery).not.toHaveBeenCalled()
|
||||
expect(db.getDispatchContextById(started.dispatch.id)?.status).toBe('dispatched')
|
||||
durableWrite.resolve()
|
||||
|
||||
await expect(recovery).resolves.toMatchObject({
|
||||
adoptedDispatchIds: [],
|
||||
exitedDispatchIds: [started.dispatch.id],
|
||||
deferredDispatchIds: []
|
||||
})
|
||||
|
||||
expect(flushPendingOrThrowAsync).toHaveBeenCalledOnce()
|
||||
expect(flushPendingOrThrowAsync).toHaveBeenCalledWith({
|
||||
drainToStableGeneration: false
|
||||
})
|
||||
expect(flushOrThrow).not.toHaveBeenCalled()
|
||||
expect(db.getDispatchContextById(started.dispatch.id)).toMatchObject({
|
||||
status: 'failed',
|
||||
failure_count: 1
|
||||
@@ -19999,7 +20108,7 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('retries missing-worker recovery when clearing its persisted resume fence fails', async () => {
|
||||
it('waits for durability before retry settles a resolution already present in memory', async () => {
|
||||
const workerPaneKey = `legacy-missing-retry:${HEADLESS_LEAF_ID}`
|
||||
const incarnationId = '34343434-3434-4434-8434-343434343434'
|
||||
const session: WorkspaceSessionState = {
|
||||
@@ -20020,17 +20129,26 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session)
|
||||
const flushOrThrow = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => undefined)
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('disk unavailable')
|
||||
})
|
||||
.mockImplementation(() => undefined)
|
||||
const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow } as never, undefined, {
|
||||
canRecoverPersistentLocalPtys: () => true
|
||||
const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession(session)
|
||||
const firstDurableWrite = deferred<void>()
|
||||
const firstDurableWriteStarted = deferred<void>()
|
||||
const retryDurableWrite = deferred<void>()
|
||||
const retryDurableWriteStarted = deferred<void>()
|
||||
let flushCount = 0
|
||||
const flushPendingOrThrowAsync = vi.fn(() => {
|
||||
flushCount += 1
|
||||
if (flushCount === 1) {
|
||||
firstDurableWriteStarted.resolve()
|
||||
return firstDurableWrite.promise
|
||||
}
|
||||
retryDurableWriteStarted.resolve()
|
||||
return retryDurableWrite.promise
|
||||
})
|
||||
const runtime = new OrcaRuntimeService(
|
||||
{ ...runtimeStore, flushPendingOrThrowAsync } as never,
|
||||
undefined,
|
||||
{ canRecoverPersistentLocalPtys: () => true }
|
||||
)
|
||||
const db = new OrchestrationDb(':memory:')
|
||||
try {
|
||||
const task = db.createTask({ spec: 'retry missing worker recovery' })
|
||||
@@ -20060,20 +20178,30 @@ describe('OrcaRuntimeService', () => {
|
||||
runtime.setNotifier({ resolveLegacyWorkerTerminalRecovery } as never)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
await expect(runtime.reconcileLegacyWorkerTerminals()).resolves.toMatchObject({
|
||||
const firstRecovery = runtime.reconcileLegacyWorkerTerminals()
|
||||
await firstDurableWriteStarted.promise
|
||||
setSession({ ...getSession(), sleepingAgentSessionsByPaneKey: undefined })
|
||||
firstDurableWrite.reject(new Error('disk unavailable'))
|
||||
|
||||
await expect(firstRecovery).resolves.toMatchObject({
|
||||
exitedDispatchIds: [],
|
||||
deferredDispatchIds: [started.dispatch.id]
|
||||
})
|
||||
expect(db.getDispatchContextById(started.dispatch.id)?.status).toBe('dispatched')
|
||||
expect(db.getWorkerDispatch(started.dispatch.id)?.state).toBe('ready')
|
||||
expect(
|
||||
getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy
|
||||
).toBe('legacy-orchestration-worker')
|
||||
expect(getSession().sleepingAgentSessionsByPaneKey).toBeUndefined()
|
||||
|
||||
await expect(runtime.reconcileLegacyWorkerTerminals()).resolves.toMatchObject({
|
||||
const retry = runtime.reconcileLegacyWorkerTerminals()
|
||||
await retryDurableWriteStarted.promise
|
||||
expect(db.getDispatchContextById(started.dispatch.id)?.status).toBe('dispatched')
|
||||
expect(db.getWorkerDispatch(started.dispatch.id)?.state).toBe('ready')
|
||||
retryDurableWrite.resolve()
|
||||
|
||||
await expect(retry).resolves.toMatchObject({
|
||||
exitedDispatchIds: [started.dispatch.id],
|
||||
deferredDispatchIds: []
|
||||
})
|
||||
expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(2)
|
||||
expect(db.getWorkerDispatch(started.dispatch.id)?.state).toBe('abandoned')
|
||||
expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined()
|
||||
expect(resolveLegacyWorkerTerminalRecovery).toHaveBeenCalledWith(
|
||||
@@ -24549,7 +24677,7 @@ describe('OrcaRuntimeService', () => {
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('keeps same-status Pi-compatible title changes queued behind the foreground owner probe', async () => {
|
||||
it('keeps decorative Pi frames queued behind the foreground owner probe', async () => {
|
||||
const foregroundProcess = deferred<string | null>()
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' })
|
||||
const getForegroundProcess = vi.fn(() => foregroundProcess.promise)
|
||||
@@ -24568,9 +24696,9 @@ describe('OrcaRuntimeService', () => {
|
||||
const events: RuntimeMobileSessionTabsResult[] = []
|
||||
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
|
||||
|
||||
runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123)
|
||||
runtime.onPtyData('pty-typed-omp', '\x1b]0;⠋ Pi\x07', 123)
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi idle\x07', 124)
|
||||
runtime.onPtyData('pty-typed-omp', '\x1b]0;⠙ Pi\x07', 124)
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
|
||||
@@ -24585,18 +24713,22 @@ describe('OrcaRuntimeService', () => {
|
||||
tabs: [
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
title: 'OMP ready',
|
||||
title: '⠋ OMP',
|
||||
agentStatus: expect.objectContaining({
|
||||
state: 'done',
|
||||
state: 'working',
|
||||
agentType: 'omp',
|
||||
terminalHandle: terminal.handle,
|
||||
terminalTitle: 'OMP ready'
|
||||
terminalTitle: '⠋ OMP'
|
||||
})
|
||||
})
|
||||
]
|
||||
})
|
||||
])
|
||||
|
||||
runtime.onPtyData('pty-typed-omp', '\x1b]0;⠹ Pi\x07', 125)
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
expect(events).toHaveLength(1)
|
||||
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
@@ -39320,6 +39452,35 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(reposChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('persists changed worktree order once and emits targeted invalidations', () => {
|
||||
const firstId = `${TEST_REPO_ID}::/tmp/first`
|
||||
const secondId = `${TEST_REPO_ID}::/tmp/second`
|
||||
const metaById: Record<string, Partial<WorktreeMeta>> = {
|
||||
[firstId]: { sortOrder: 200 },
|
||||
[secondId]: { sortOrder: 100 }
|
||||
}
|
||||
const setWorktreeMeta = vi.fn((id: string, updates: Partial<WorktreeMeta>) => {
|
||||
metaById[id] = { ...metaById[id], ...updates }
|
||||
})
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getWorktreeMeta: (id: string) => metaById[id],
|
||||
setWorktreeMeta
|
||||
} as never)
|
||||
const events: { type: string; repoId?: string }[] = []
|
||||
const unsubscribe = runtime.onClientEvent((event) => events.push(event))
|
||||
|
||||
expect(runtime.persistManagedWorktreeSortOrder([firstId, secondId])).toEqual({ updated: 0 })
|
||||
expect(setWorktreeMeta).not.toHaveBeenCalled()
|
||||
expect(events).toEqual([])
|
||||
|
||||
expect(runtime.persistManagedWorktreeSortOrder([secondId, firstId])).toEqual({ updated: 2 })
|
||||
unsubscribe()
|
||||
|
||||
expect(setWorktreeMeta).toHaveBeenCalledTimes(2)
|
||||
expect(events).toEqual([{ type: 'worktreesChanged', repoId: TEST_REPO_ID }])
|
||||
})
|
||||
|
||||
it('worktree scan cache: folder metadata invalidation preserves raw scans', async () => {
|
||||
vi.mocked(listWorktrees).mockClear()
|
||||
const runtime = createRuntime()
|
||||
|
||||
+359
-175
@@ -10,6 +10,7 @@ import {
|
||||
normalizeTerminalTitle
|
||||
} from '../../shared/agent-detection'
|
||||
import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail'
|
||||
import { planWorktreeSortOrderUpdates } from '../../shared/worktree-sort-order-update'
|
||||
import { isArtifactSharingEnabled } from '../../shared/artifact-sharing-gate'
|
||||
import { sortDirEntries } from '../../shared/file-name-sort'
|
||||
import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing'
|
||||
@@ -27,10 +28,10 @@ import type {
|
||||
} from './remote-terminal-source-range-consumer'
|
||||
import {
|
||||
createTerminalTitleTracker,
|
||||
stripBrailleSpinnerGlyphs,
|
||||
type TerminalTitleFactMeta,
|
||||
type TerminalTitleTracker
|
||||
} from '../../shared/terminal-output-side-effects'
|
||||
import { getDecorativeAgentTitleSignature } from '../../shared/agent-decorative-title-signature'
|
||||
import { createCommandCodeOutputStatusDetector } from '../../shared/command-code-output-status'
|
||||
import type {
|
||||
TerminalSideEffectBatch,
|
||||
@@ -120,6 +121,7 @@ import { resolveWorktreeCreateBase } from '../worktree-create-base'
|
||||
import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref'
|
||||
import { OrchestrationDb } from './orchestration/db'
|
||||
import { reconcileRequestedWorkerTerminalReleases } from './orchestration/worker-terminal-release-reconciliation'
|
||||
import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback'
|
||||
import { OrchestrationError } from './orchestration/orchestration-error'
|
||||
import {
|
||||
planLegacyWorkerTerminalRecovery,
|
||||
@@ -991,6 +993,10 @@ import {
|
||||
createMobileSessionTabsNotifyCoalescer,
|
||||
type MobileSessionTabsNotifyCoalescer
|
||||
} from './mobile-session-tabs-notify-coalescer'
|
||||
import {
|
||||
createMobileSessionTabsAgentStatusHeartbeat,
|
||||
type MobileSessionTabsAgentStatusHeartbeat
|
||||
} from './mobile-session-tabs-agent-status-heartbeat'
|
||||
import { TerminalFocusNavigationCoalescer } from './terminal-focus-navigation-coalescer'
|
||||
import {
|
||||
appendRecentPtyPathCandidates,
|
||||
@@ -1126,6 +1132,7 @@ type RuntimeStore = {
|
||||
getWorkspaceSessionHostIds?: Store['getWorkspaceSessionHostIds']
|
||||
setWorkspaceSession?: Store['setWorkspaceSession']
|
||||
flushOrThrow?: Store['flushOrThrow']
|
||||
flushPendingOrThrowAsync?: Store['flushPendingOrThrowAsync']
|
||||
persistPtyBinding?: Store['persistPtyBinding']
|
||||
getSshRemotePtyLeases?: Store['getSshRemotePtyLeases']
|
||||
getUI?: Store['getUI']
|
||||
@@ -1310,6 +1317,9 @@ type RuntimePtyWorktreeRecord = {
|
||||
lastAgentStatus: AgentStatus | null
|
||||
/** False until a live OSC frame sets the status; restore seeds never set it. */
|
||||
lastAgentStatusObservedLive: boolean
|
||||
lastAgentStatusStartedAtEpochMs: number | null
|
||||
// A later semantic title interval cannot inherit rich fields from an earlier task.
|
||||
lastAgentStatusRichInvalidatedAtEpochMs: number | null
|
||||
lastOscTitle: string | null
|
||||
lastOscTitleAt: number | null
|
||||
// Why a second stamp: `lastOscTitleAt` is a title-observation sequence number,
|
||||
@@ -1519,12 +1529,8 @@ type RuntimePtyTitleTrackerEntry = {
|
||||
tracker: TerminalTitleTracker
|
||||
// Why: onPtyData batches the mobile session-tab touch to once per chunk;
|
||||
// the stale-working-title timer fires between chunks and must touch
|
||||
// immediately. These flags route the tracker callback to the right mode.
|
||||
// immediately. This flag routes the tracker callback to the right mode.
|
||||
applyingChunk: boolean
|
||||
// Why: synthetic spinner ticks arrive ~12.5x/sec per working pane; the
|
||||
// synthetic path gates mobile snapshot fan-out on a non-decorative title
|
||||
// change (spinner glyph + status comparison key kept below).
|
||||
applyingSyntheticFrame: boolean
|
||||
lastMobileTitleGateKey: string | null
|
||||
chunkTouchedSessionTabs: boolean
|
||||
// Why: facts observed while applying a chunk are batched into one
|
||||
@@ -1988,6 +1994,11 @@ export type LegacyWorkerTerminalRecoveryResult = {
|
||||
deferredDispatchIds: string[]
|
||||
}
|
||||
|
||||
type LegacyWorkerTerminalRecoveryResolution = {
|
||||
candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number]
|
||||
resolution: 'adopted' | 'exited'
|
||||
}
|
||||
|
||||
export type OrchestrationCompatibilityCallerAuthority = Readonly<{
|
||||
hostScope: OrchestrationCompatibilityTerminalAuthority['hostScope']
|
||||
paneKey: string
|
||||
@@ -2756,6 +2767,11 @@ export class OrcaRuntimeService {
|
||||
createMobileSessionTabsNotifyCoalescer((worktreeId) =>
|
||||
this.notifyMobileSessionTabsChangedNow(worktreeId)
|
||||
)
|
||||
private readonly mobileSessionTabsAgentStatusHeartbeat: MobileSessionTabsAgentStatusHeartbeat =
|
||||
createMobileSessionTabsAgentStatusHeartbeat(
|
||||
(ptyId) => this.getMobileSessionWorktreeIdsForPty(ptyId),
|
||||
(worktreeId) => this.touchMobileSessionTabsForWorktree(worktreeId)
|
||||
)
|
||||
// Why: concurrent host terminal.focus storms (CLI switch fan-out / bulk open)
|
||||
// each await a full host reveal; only one terminal can be focused, so latest-wins
|
||||
// single-flight bounds host work. Does not replace cheaper activation or
|
||||
@@ -3821,7 +3837,11 @@ export class OrcaRuntimeService {
|
||||
prepareLegacyWorkerTerminalRecovery(): LegacyWorkerTerminalRecoveryPlan {
|
||||
const plan = this.getLegacyWorkerTerminalRecoveryPlan()
|
||||
const store = this.store
|
||||
if (!store?.getWorkspaceSession || !store.setWorkspaceSession || !store.flushOrThrow) {
|
||||
if (
|
||||
!store?.getWorkspaceSession ||
|
||||
!store.setWorkspaceSession ||
|
||||
(!store.flushPendingOrThrowAsync && !store.flushOrThrow)
|
||||
) {
|
||||
return plan
|
||||
}
|
||||
const sessions = new Map<
|
||||
@@ -3876,13 +3896,25 @@ export class OrcaRuntimeService {
|
||||
for (const [hostId, state] of changed) {
|
||||
store.setWorkspaceSession(state.next, hostId)
|
||||
}
|
||||
store.flushOrThrow()
|
||||
} catch (error) {
|
||||
console.warn('[orchestration] failed to persist legacy worker resume fence', error)
|
||||
console.warn('[orchestration] failed to stage legacy worker resume fence', error)
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
private async flushWorkspaceSessionOrThrowAsync(): Promise<void> {
|
||||
const store = this.store
|
||||
if (store?.flushPendingOrThrowAsync) {
|
||||
await store.flushPendingOrThrowAsync({ drainToStableGeneration: false })
|
||||
return
|
||||
}
|
||||
if (store?.flushOrThrow) {
|
||||
store.flushOrThrow()
|
||||
return
|
||||
}
|
||||
throw new Error('workspace_session_persistence_unavailable')
|
||||
}
|
||||
|
||||
async reconcileLegacyWorkerTerminals(
|
||||
options: { connectionId?: string; materializeRenderer?: boolean } = {}
|
||||
): Promise<LegacyWorkerTerminalRecoveryResult> {
|
||||
@@ -3982,33 +4014,71 @@ export class OrcaRuntimeService {
|
||||
)
|
||||
}
|
||||
|
||||
private persistLegacyWorkerTerminalRecoveryResolution(
|
||||
candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number],
|
||||
resolution: 'adopted' | 'exited'
|
||||
): boolean {
|
||||
private async persistLegacyWorkerTerminalRecoveryBatch(
|
||||
resolutions: readonly LegacyWorkerTerminalRecoveryResolution[]
|
||||
): Promise<ReadonlySet<string>> {
|
||||
const store = this.store
|
||||
const session = this.getWorkspaceSessionForWorktree(candidate.worktreeId)
|
||||
if (!store?.setWorkspaceSession || !store.flushOrThrow || !session) {
|
||||
return false
|
||||
if (
|
||||
!store?.getWorkspaceSession ||
|
||||
!store.setWorkspaceSession ||
|
||||
(!store.flushPendingOrThrowAsync && !store.flushOrThrow)
|
||||
) {
|
||||
return new Set()
|
||||
}
|
||||
const record = session.sleepingAgentSessionsByPaneKey?.[candidate.paneKey]
|
||||
if (!record || !runtimeWorktreeIdsEqual(record.worktreeId, candidate.worktreeId)) {
|
||||
return true
|
||||
}
|
||||
const next = structuredClone(session)
|
||||
delete next.sleepingAgentSessionsByPaneKey?.[candidate.paneKey]
|
||||
const originalSessions = new Map<ExecutionHostId, WorkspaceSessionState>()
|
||||
const stagedSessions = new Map<ExecutionHostId, WorkspaceSessionState>()
|
||||
const stagedDispatchIds = new Set<string>()
|
||||
try {
|
||||
this.setWorkspaceSessionForWorktree(candidate.worktreeId, next)
|
||||
store.flushOrThrow()
|
||||
return true
|
||||
for (const { candidate, resolution } of resolutions) {
|
||||
const hostId = this.tryGetWorkspaceSessionHostIdForWorktree(candidate.worktreeId)
|
||||
const session = hostId ? store.getWorkspaceSession(hostId) : null
|
||||
if (!hostId || !session) {
|
||||
continue
|
||||
}
|
||||
originalSessions.set(hostId, originalSessions.get(hostId) ?? session)
|
||||
let next =
|
||||
resolution === 'exited'
|
||||
? retireTerminalSurfaceFromPersistence(session, {
|
||||
worktreeId: candidate.worktreeId,
|
||||
parentTabId: candidate.tabId,
|
||||
leafId: candidate.leafId,
|
||||
ptyId: candidate.ptyId,
|
||||
incarnationId: candidate.incarnationId
|
||||
})
|
||||
: session
|
||||
const record = next.sleepingAgentSessionsByPaneKey?.[candidate.paneKey]
|
||||
if (record && runtimeWorktreeIdsEqual(record.worktreeId, candidate.worktreeId)) {
|
||||
const sleepingAgentSessionsByPaneKey = { ...next.sleepingAgentSessionsByPaneKey }
|
||||
delete sleepingAgentSessionsByPaneKey[candidate.paneKey]
|
||||
next = { ...next, sleepingAgentSessionsByPaneKey }
|
||||
}
|
||||
if (next !== session) {
|
||||
store.setWorkspaceSession(next, hostId)
|
||||
}
|
||||
stagedSessions.set(hostId, store.getWorkspaceSession(hostId))
|
||||
stagedDispatchIds.add(candidate.dispatchId)
|
||||
}
|
||||
if (stagedDispatchIds.size > 0) {
|
||||
await this.flushWorkspaceSessionOrThrowAsync()
|
||||
}
|
||||
return stagedDispatchIds
|
||||
} catch (error) {
|
||||
this.setWorkspaceSessionForWorktree(candidate.worktreeId, session)
|
||||
console.warn('[orchestration] failed to persist legacy worker recovery resolution', {
|
||||
dispatchId: candidate.dispatchId,
|
||||
resolution,
|
||||
for (const [hostId, original] of originalSessions) {
|
||||
const staged = stagedSessions.get(hostId)
|
||||
const current = store.getWorkspaceSession(hostId)
|
||||
if (!staged || !current) {
|
||||
continue
|
||||
}
|
||||
const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(original, staged, current)
|
||||
if (rolledBack !== current) {
|
||||
store.setWorkspaceSession(rolledBack, hostId)
|
||||
}
|
||||
}
|
||||
console.warn('[orchestration] failed to persist legacy worker recovery batch', {
|
||||
dispatchIds: [...stagedDispatchIds],
|
||||
error
|
||||
})
|
||||
return false
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4033,48 +4103,9 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
private resolveExitedLegacyWorkerTerminal(
|
||||
candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number]
|
||||
): boolean {
|
||||
if (
|
||||
!this.rollbackLegacyWorkerTerminalSurface(candidate) ||
|
||||
!this.persistLegacyWorkerTerminalRecoveryResolution(candidate, 'exited') ||
|
||||
!this.reconcileMissingLegacyWorkerTerminal(candidate)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
this.notifier?.resolveLegacyWorkerTerminalRecovery?.(candidate.paneKey, 'exited')
|
||||
return true
|
||||
}
|
||||
|
||||
private rollbackLegacyWorkerTerminalSurface(
|
||||
candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number]
|
||||
): boolean {
|
||||
const store = this.store
|
||||
const session = this.getWorkspaceSessionForWorktree(candidate.worktreeId)
|
||||
if (store?.setWorkspaceSession && store.flushOrThrow && session) {
|
||||
const retired = retireTerminalSurfaceFromPersistence(session, {
|
||||
worktreeId: candidate.worktreeId,
|
||||
parentTabId: candidate.tabId,
|
||||
leafId: candidate.leafId,
|
||||
ptyId: candidate.ptyId,
|
||||
incarnationId: candidate.incarnationId
|
||||
})
|
||||
if (retired !== session) {
|
||||
try {
|
||||
this.setWorkspaceSessionForWorktree(candidate.worktreeId, retired)
|
||||
store.flushOrThrow()
|
||||
} catch (error) {
|
||||
this.setWorkspaceSessionForWorktree(candidate.worktreeId, session)
|
||||
console.warn('[orchestration] failed to persist legacy worker surface rollback', {
|
||||
dispatchId: candidate.dispatchId,
|
||||
error
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
): void {
|
||||
const snapshot = this.mobileSessionTabsByWorktree.get(candidate.worktreeId)
|
||||
if (snapshot) {
|
||||
const retired = retireTerminalSurfacesFromSnapshot({
|
||||
@@ -4122,13 +4153,6 @@ export class OrcaRuntimeService {
|
||||
'rolled_back',
|
||||
candidate.ptyId
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private resolveReplacedLegacyWorkerTerminal(
|
||||
candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number]
|
||||
): boolean {
|
||||
return this.resolveExitedLegacyWorkerTerminal(candidate)
|
||||
}
|
||||
|
||||
private updateLegacyWorkerTerminalRecoveryRetry(
|
||||
@@ -4208,6 +4232,7 @@ export class OrcaRuntimeService {
|
||||
const adoptedDispatchIds: string[] = []
|
||||
const exitedDispatchIds: string[] = []
|
||||
const deferredDispatchIds = new Set(plan.ambiguousDispatchIds)
|
||||
const pendingResolutions: LegacyWorkerTerminalRecoveryResolution[] = []
|
||||
const recoveryCandidatesByProvider = new Map<
|
||||
string,
|
||||
{
|
||||
@@ -4274,11 +4299,7 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
for (const { candidate, workspace } of provider.entries) {
|
||||
if (!inventory.livePtyIds.has(candidate.ptyId)) {
|
||||
if (this.resolveExitedLegacyWorkerTerminal(candidate)) {
|
||||
exitedDispatchIds.push(candidate.dispatchId)
|
||||
} else {
|
||||
deferredDispatchIds.add(candidate.dispatchId)
|
||||
}
|
||||
pendingResolutions.push({ candidate, resolution: 'exited' })
|
||||
continue
|
||||
}
|
||||
const controllerIdentity = inventory.terminalIdentityByPtyId.get(candidate.ptyId)
|
||||
@@ -4290,11 +4311,7 @@ export class OrcaRuntimeService {
|
||||
controllerIdentity.handle !== candidate.terminalHandle ||
|
||||
controllerIdentity.incarnationId !== candidate.incarnationId
|
||||
) {
|
||||
if (this.resolveReplacedLegacyWorkerTerminal(candidate)) {
|
||||
exitedDispatchIds.push(candidate.dispatchId)
|
||||
} else {
|
||||
deferredDispatchIds.add(candidate.dispatchId)
|
||||
}
|
||||
pendingResolutions.push({ candidate, resolution: 'exited' })
|
||||
continue
|
||||
}
|
||||
const preAdoptionInventory = await this.refreshPtyWorktreeRecordsWithControllerInventory(
|
||||
@@ -4308,11 +4325,7 @@ export class OrcaRuntimeService {
|
||||
continue
|
||||
}
|
||||
if (!preAdoptionInventory.livePtyIds.has(candidate.ptyId)) {
|
||||
if (this.resolveExitedLegacyWorkerTerminal(candidate)) {
|
||||
exitedDispatchIds.push(candidate.dispatchId)
|
||||
} else {
|
||||
deferredDispatchIds.add(candidate.dispatchId)
|
||||
}
|
||||
pendingResolutions.push({ candidate, resolution: 'exited' })
|
||||
continue
|
||||
}
|
||||
const preAdoptionIdentity = preAdoptionInventory.terminalIdentityByPtyId.get(
|
||||
@@ -4326,11 +4339,7 @@ export class OrcaRuntimeService {
|
||||
preAdoptionIdentity.handle !== candidate.terminalHandle ||
|
||||
preAdoptionIdentity.incarnationId !== candidate.incarnationId
|
||||
) {
|
||||
if (this.resolveReplacedLegacyWorkerTerminal(candidate)) {
|
||||
exitedDispatchIds.push(candidate.dispatchId)
|
||||
} else {
|
||||
deferredDispatchIds.add(candidate.dispatchId)
|
||||
}
|
||||
pendingResolutions.push({ candidate, resolution: 'exited' })
|
||||
continue
|
||||
}
|
||||
const session = this.getWorkspaceSessionForWorktree(candidate.worktreeId)
|
||||
@@ -4461,11 +4470,7 @@ export class OrcaRuntimeService {
|
||||
if (!finalInventory.livePtyIds.has(candidate.ptyId)) {
|
||||
this.legacyWorkerTerminalReceiptEpochByPane.delete(candidate.paneKey)
|
||||
this.onPtyExit(candidate.ptyId, 0, candidate.incarnationId)
|
||||
if (this.resolveExitedLegacyWorkerTerminal(candidate)) {
|
||||
exitedDispatchIds.push(candidate.dispatchId)
|
||||
} else {
|
||||
deferredDispatchIds.add(candidate.dispatchId)
|
||||
}
|
||||
pendingResolutions.push({ candidate, resolution: 'exited' })
|
||||
continue
|
||||
}
|
||||
const finalIdentity = finalInventory.terminalIdentityByPtyId.get(candidate.ptyId)
|
||||
@@ -4479,21 +4484,32 @@ export class OrcaRuntimeService {
|
||||
finalIdentity.incarnationId !== candidate.incarnationId
|
||||
) {
|
||||
this.legacyWorkerTerminalReceiptEpochByPane.delete(candidate.paneKey)
|
||||
if (this.resolveReplacedLegacyWorkerTerminal(candidate)) {
|
||||
exitedDispatchIds.push(candidate.dispatchId)
|
||||
} else {
|
||||
deferredDispatchIds.add(candidate.dispatchId)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!this.persistLegacyWorkerTerminalRecoveryResolution(candidate, 'adopted')) {
|
||||
deferredDispatchIds.add(candidate.dispatchId)
|
||||
pendingResolutions.push({ candidate, resolution: 'exited' })
|
||||
continue
|
||||
}
|
||||
pendingResolutions.push({ candidate, resolution: 'adopted' })
|
||||
}
|
||||
}
|
||||
const persistedDispatchIds =
|
||||
await this.persistLegacyWorkerTerminalRecoveryBatch(pendingResolutions)
|
||||
for (const { candidate, resolution } of pendingResolutions) {
|
||||
if (!persistedDispatchIds.has(candidate.dispatchId)) {
|
||||
deferredDispatchIds.add(candidate.dispatchId)
|
||||
continue
|
||||
}
|
||||
if (resolution === 'adopted') {
|
||||
this.legacyWorkerRecoveredPtys.add(candidate.ptyId)
|
||||
this.notifier?.resolveLegacyWorkerTerminalRecovery?.(candidate.paneKey, 'adopted')
|
||||
adoptedDispatchIds.push(candidate.dispatchId)
|
||||
continue
|
||||
}
|
||||
this.rollbackLegacyWorkerTerminalSurface(candidate)
|
||||
if (!this.reconcileMissingLegacyWorkerTerminal(candidate)) {
|
||||
deferredDispatchIds.add(candidate.dispatchId)
|
||||
continue
|
||||
}
|
||||
this.notifier?.resolveLegacyWorkerTerminalRecovery?.(candidate.paneKey, 'exited')
|
||||
exitedDispatchIds.push(candidate.dispatchId)
|
||||
}
|
||||
const result = {
|
||||
blockedPaneCount: plan.blockedPanes.length,
|
||||
@@ -6552,6 +6568,21 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
private getMobileSessionWorktreeIdsForPty(ptyId: string): string[] {
|
||||
const worktreeIds: string[] = []
|
||||
for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) {
|
||||
const hasPtyBackedTab = snapshot.tabs.some(
|
||||
(tab) =>
|
||||
tab.type === 'terminal' &&
|
||||
(tab.ptyId === ptyId || tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] === ptyId)
|
||||
)
|
||||
if (hasPtyBackedTab) {
|
||||
worktreeIds.push(worktreeId)
|
||||
}
|
||||
}
|
||||
return worktreeIds
|
||||
}
|
||||
|
||||
/** Bump the snapshot version and emit, coalesced unless `immediate`.
|
||||
* Why the bump: clients gate mirrored snapshots on a strictly increasing
|
||||
* `snapshotVersion`, so a re-emit at the same version is silently dropped. */
|
||||
@@ -9288,6 +9319,9 @@ export class OrcaRuntimeService {
|
||||
// subscriber closing mid-window still receives the latest settled state.
|
||||
this.mobileSessionTabsNotifyCoalescer.flushAll()
|
||||
this.mobileSessionTabListeners.delete(subscription)
|
||||
if (this.mobileSessionTabListeners.size === 0) {
|
||||
this.mobileSessionTabsAgentStatusHeartbeat.cancelPending()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10074,13 +10108,11 @@ export class OrcaRuntimeService {
|
||||
ingestSyntheticTitleFrame(ptyId: string, data: string): void {
|
||||
const entry = this.getOrCreatePtyTitleTrackerEntry(ptyId)
|
||||
entry.applyingChunk = true
|
||||
entry.applyingSyntheticFrame = true
|
||||
entry.chunkTouchedSessionTabs = false
|
||||
try {
|
||||
entry.tracker.applySyntheticTitleFrame(data)
|
||||
} finally {
|
||||
entry.applyingChunk = false
|
||||
entry.applyingSyntheticFrame = false
|
||||
this.flushPendingTerminalSideEffectFacts(ptyId, entry)
|
||||
}
|
||||
if (entry.chunkTouchedSessionTabs) {
|
||||
@@ -10318,13 +10350,13 @@ export class OrcaRuntimeService {
|
||||
return { ...snapshot, lastTitle: tracked }
|
||||
}
|
||||
|
||||
/** Decorative comparison key: spinner frame glyphs stripped, derived agent
|
||||
* status kept so a working→idle flip with an otherwise-equal label still
|
||||
* counts as a change. */
|
||||
/** Decorative comparison key: only recognized agent titles fold leading spinner frames. */
|
||||
private makeDecorativeTitleGateKey(rawTitle: string, normalizedTitle: string): string {
|
||||
return `${detectAgentStatusFromTitle(rawTitle) ?? ''}\u0000${stripBrailleSpinnerGlyphs(
|
||||
normalizedTitle
|
||||
)}`
|
||||
// Stable Pi/Gemini/Grok display normalization also defines their semantic gate.
|
||||
const normalizedSignature =
|
||||
rawTitle === normalizedTitle ? null : getDecorativeAgentTitleSignature(normalizedTitle)
|
||||
const signature = normalizedSignature ?? getDecorativeAgentTitleSignature(rawTitle)
|
||||
return signature === null ? `literal\u0000${normalizedTitle}` : `agent\u0000${signature}`
|
||||
}
|
||||
|
||||
private getOrCreatePtyTitleTrackerEntry(ptyId: string): RuntimePtyTitleTrackerEntry {
|
||||
@@ -10355,28 +10387,42 @@ export class OrcaRuntimeService {
|
||||
...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {})
|
||||
})
|
||||
const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle, meta)
|
||||
// Why: an identity-only cursor title records nothing, so on a fresh pane `changed` is
|
||||
// false — but the tracker title is that pane's only Cursor identity and still has to
|
||||
// fan out to the sidebar and mobile (#10258).
|
||||
if (!changed && !this.isLiveCursorNativeTitle(rawTitle, meta)) {
|
||||
return
|
||||
}
|
||||
const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta)
|
||||
const live = this.ptyTitleTrackersByPtyId.get(ptyId)
|
||||
const gateKey = this.makeDecorativeTitleGateKey(rawTitle, normalizedTitle)
|
||||
const decorativeOnly = live?.lastMobileTitleGateKey === gateKey
|
||||
if (live) {
|
||||
live.lastMobileTitleGateKey = gateKey
|
||||
}
|
||||
const tracksReplicatedStatus =
|
||||
live?.applyingChunk === true && this.mobileSessionTabListeners.size > 0
|
||||
const titleStatus = tracksReplicatedStatus ? detectAgentStatusFromTitle(rawTitle) : null
|
||||
if (
|
||||
tracksReplicatedStatus &&
|
||||
decorativeOnly &&
|
||||
!this.ptyDelayedForegroundSnapshotTitleObservations.has(ptyId) &&
|
||||
(titleStatus === 'working' || titleStatus === 'permission')
|
||||
) {
|
||||
// Normalized Pi/Gemini/Grok frames still renew the replicated status lease.
|
||||
this.mobileSessionTabsAgentStatusHeartbeat.scheduleDecorativeHeartbeat(ptyId)
|
||||
}
|
||||
// Why: an identity-only cursor title records nothing, but the tracker
|
||||
// title is that pane's only Cursor identity and must still fan out (#10258).
|
||||
if (!changed && !identityOnlyTitle) {
|
||||
return
|
||||
}
|
||||
if (live?.applyingChunk) {
|
||||
// Why: synthetic spinner ticks change only the braille glyph
|
||||
// ~12.5x/sec; fanning out full mobile session snapshots per frame
|
||||
// is pure churn. Raw lastOscTitle updates above stay cheap.
|
||||
if (!(live.applyingSyntheticFrame && decorativeOnly)) {
|
||||
if (!decorativeOnly) {
|
||||
this.mobileSessionTabsAgentStatusHeartbeat.observeSemanticTitle(ptyId)
|
||||
live.chunkTouchedSessionTabs = true
|
||||
}
|
||||
} else {
|
||||
// Stale-working-title timer path — fires between chunks, so the
|
||||
// per-chunk batching in onPtyData cannot pick it up.
|
||||
this.mobileSessionTabsAgentStatusHeartbeat.observeSemanticTitle(ptyId)
|
||||
this.touchMobileSessionSnapshotsForPty(ptyId)
|
||||
}
|
||||
},
|
||||
@@ -10420,7 +10466,6 @@ export class OrcaRuntimeService {
|
||||
const entry: RuntimePtyTitleTrackerEntry = {
|
||||
tracker,
|
||||
applyingChunk: false,
|
||||
applyingSyntheticFrame: false,
|
||||
lastMobileTitleGateKey: null,
|
||||
chunkTouchedSessionTabs: false,
|
||||
pendingFacts: [],
|
||||
@@ -10462,11 +10507,22 @@ export class OrcaRuntimeService {
|
||||
const prevStatus = pty.lastAgentStatus
|
||||
const prevTitle = pty.lastOscTitle
|
||||
const observedAt = this.nextTitleObservationSequence()
|
||||
const observedAtEpochMs = identityOnlyTitle ? null : Date.now()
|
||||
pty.lastOscTitle = recordedTitle
|
||||
pty.lastOscTitleAt = identityOnlyTitle ? null : observedAt
|
||||
pty.lastOscTitleEpochMs = identityOnlyTitle ? null : Date.now()
|
||||
pty.lastOscTitleEpochMs = observedAtEpochMs
|
||||
pty.lastAgentStatus = agentStatus
|
||||
pty.lastAgentStatusObservedLive = true
|
||||
if (prevStatus !== agentStatus) {
|
||||
pty.lastAgentStatusStartedAtEpochMs = observedAtEpochMs
|
||||
}
|
||||
if (
|
||||
identityOnlyTitle ||
|
||||
terminalTitleBlocksExplicitAgentStatus(recordedTitle) ||
|
||||
(prevStatus !== null && agentStatus !== null && prevStatus !== agentStatus)
|
||||
) {
|
||||
pty.lastAgentStatusRichInvalidatedAtEpochMs = observedAtEpochMs ?? Date.now()
|
||||
}
|
||||
if (identityOnlyTitle) {
|
||||
pty.managementTitle = null
|
||||
pty.managementTitleAt = null
|
||||
@@ -10546,6 +10602,8 @@ export class OrcaRuntimeService {
|
||||
private disposePtyTitleTracker(ptyId: string): void {
|
||||
this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.dispose()
|
||||
this.ptyTitleTrackersByPtyId.delete(ptyId)
|
||||
this.ptyDelayedForegroundSnapshotTitleObservations.delete(ptyId)
|
||||
this.mobileSessionTabsAgentStatusHeartbeat.removePty(ptyId)
|
||||
for (const titleGateKeys of this.terminalSideEffectTitleGateKeysByClientEventListener.values()) {
|
||||
titleGateKeys.delete(ptyId)
|
||||
}
|
||||
@@ -10567,6 +10625,8 @@ export class OrcaRuntimeService {
|
||||
// Why: the prior process's live frames say nothing about the replacement,
|
||||
// so the seed a same-id restore applies must not inherit its authority.
|
||||
pty.lastAgentStatusObservedLive = false
|
||||
pty.lastAgentStatusStartedAtEpochMs = null
|
||||
pty.lastAgentStatusRichInvalidatedAtEpochMs = Date.now()
|
||||
pty.managementTitle = null
|
||||
pty.managementTitleAt = null
|
||||
}
|
||||
@@ -15660,7 +15720,11 @@ export class OrcaRuntimeService {
|
||||
const { livePtyIds, terminalIdentityByPtyId } = inventory
|
||||
const store = this.store
|
||||
const session = this.getWorkspaceSessionForWorktree(workspace.id)
|
||||
if (!store?.setWorkspaceSession || !store.flushOrThrow || !session) {
|
||||
if (
|
||||
!store?.setWorkspaceSession ||
|
||||
(!store.flushPendingOrThrowAsync && !store.flushOrThrow) ||
|
||||
!session
|
||||
) {
|
||||
throw new Error('workspace_session_unavailable')
|
||||
}
|
||||
const sessionWorktreeId = resolveTerminalSessionWorktreeId(session, workspace.id)
|
||||
@@ -16080,11 +16144,19 @@ export class OrcaRuntimeService {
|
||||
[workspace.id]: activeGroup.id
|
||||
}
|
||||
const persisted = advanceTerminalTopologyRevision(next, workspace.id)
|
||||
let staged: WorkspaceSessionState | null = null
|
||||
try {
|
||||
this.setWorkspaceSessionForWorktree(workspace.id, persisted)
|
||||
store.flushOrThrow()
|
||||
staged = this.getWorkspaceSessionForWorktree(workspace.id)
|
||||
await this.flushWorkspaceSessionOrThrowAsync()
|
||||
} catch (error) {
|
||||
this.setWorkspaceSessionForWorktree(workspace.id, session)
|
||||
const current = this.getWorkspaceSessionForWorktree(workspace.id)
|
||||
if (staged && current) {
|
||||
const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current)
|
||||
if (rolledBack !== current) {
|
||||
this.setWorkspaceSessionForWorktree(workspace.id, rolledBack)
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
for (const { claim, pty, paneKey } of validated) {
|
||||
@@ -17129,6 +17201,9 @@ export class OrcaRuntimeService {
|
||||
return
|
||||
}
|
||||
this.ptyDelayedForegroundSnapshotTitleObservations.delete(ptyId)
|
||||
if (this.mobileSessionTabListeners.size > 0) {
|
||||
this.mobileSessionTabsAgentStatusHeartbeat.observeSemanticTitle(ptyId)
|
||||
}
|
||||
if (!foregroundAgentChanged) {
|
||||
this.touchMobileSessionSnapshotsForPty(ptyId)
|
||||
}
|
||||
@@ -23813,21 +23888,29 @@ export class OrcaRuntimeService {
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const now = Date.now()
|
||||
let updated = 0
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
// Why: a sort-order snapshot must only reorder existing worktrees, never
|
||||
// mint new meta — a stale id would otherwise resurrect an orphan workspace
|
||||
// (setWorktreeMeta has no repo-existence check).
|
||||
if (!this.store.getWorktreeMeta(orderedIds[i])) {
|
||||
continue
|
||||
}
|
||||
this.store.setWorktreeMeta(orderedIds[i], { sortOrder: now - i * 1000 })
|
||||
updated++
|
||||
const store = this.store
|
||||
const updates = planWorktreeSortOrderUpdates(
|
||||
orderedIds,
|
||||
(worktreeId) => store.getWorktreeMeta(worktreeId),
|
||||
Date.now()
|
||||
)
|
||||
for (const update of updates) {
|
||||
store.setWorktreeMeta(update.worktreeId, { sortOrder: update.sortOrder })
|
||||
}
|
||||
if (updates.length === 0) {
|
||||
return { updated: 0 }
|
||||
}
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.notifyReposChanged()
|
||||
return { updated }
|
||||
const changedRepoIds = new Set(
|
||||
updates.flatMap((update) => {
|
||||
const parsed = splitWorktreeId(update.worktreeId)
|
||||
return parsed ? [parsed.repoId] : []
|
||||
})
|
||||
)
|
||||
for (const repoId of changedRepoIds) {
|
||||
this.notifyWorktreesChanged(repoId)
|
||||
}
|
||||
return { updated: updates.length }
|
||||
}
|
||||
|
||||
async resolveManagedPrBase(args: {
|
||||
@@ -24222,6 +24305,7 @@ export class OrcaRuntimeService {
|
||||
store.removeWorktreeMeta(worktreeId)
|
||||
}
|
||||
this.mobileSessionTabsByWorktree.delete(worktreeId)
|
||||
this.mobileSessionTabsAgentStatusHeartbeat.removeWorktree(worktreeId)
|
||||
this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId)
|
||||
advertisedUrlWatcher.forgetWorktree(worktreeId)
|
||||
deleteWorktreeHistoryDir(worktreeId)
|
||||
@@ -29259,6 +29343,8 @@ export class OrcaRuntimeService {
|
||||
lastExitCode: null,
|
||||
lastAgentStatus: null,
|
||||
lastAgentStatusObservedLive: false,
|
||||
lastAgentStatusStartedAtEpochMs: null,
|
||||
lastAgentStatusRichInvalidatedAtEpochMs: null,
|
||||
lastOscTitle: null,
|
||||
lastOscTitleAt: null,
|
||||
lastOscTitleEpochMs: null,
|
||||
@@ -30010,6 +30096,7 @@ export class OrcaRuntimeService {
|
||||
nextWorktrees.add(worktreeId)
|
||||
} else {
|
||||
this.mobileSessionTabsByWorktree.delete(worktreeId)
|
||||
this.mobileSessionTabsAgentStatusHeartbeat.removeWorktree(worktreeId)
|
||||
this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId)
|
||||
// Why: drop any pending coalesced notify so a stale snapshot can't land after the removed frame.
|
||||
this.mobileSessionTabsNotifyCoalescer.cancel(worktreeId)
|
||||
@@ -30579,16 +30666,21 @@ export class OrcaRuntimeService {
|
||||
(hookAgentStatus.providerSessionReceivedAt ?? -1) >= tab.agentStatus.updatedAt)
|
||||
? hookAgentStatus.providerSession
|
||||
: tab.agentStatus?.providerSession
|
||||
const normalizedTabAgentStatus = tab.agentStatus
|
||||
? normalizeCompatibleAgentStatusEntryForOwner(
|
||||
{
|
||||
...tab.agentStatus,
|
||||
...(hookProviderSession ? { providerSession: hookProviderSession } : {})
|
||||
},
|
||||
ownerAgent
|
||||
)
|
||||
: null
|
||||
// Why: keep rich hook status on a live prompt/tool (authoritative even under a non-agent title), else interactivePrompt is lost.
|
||||
const statusPty = liveLeafPty ?? mobileStatusPty
|
||||
const normalizedTabAgentStatus = this.renewMobileAgentStatusFromPtyTitle(
|
||||
tab.agentStatus
|
||||
? normalizeCompatibleAgentStatusEntryForOwner(
|
||||
{
|
||||
...tab.agentStatus,
|
||||
...(hookProviderSession ? { providerSession: hookProviderSession } : {})
|
||||
},
|
||||
ownerAgent
|
||||
)
|
||||
: null,
|
||||
statusPty,
|
||||
{ preserveQuestionUnderShellTitle: true }
|
||||
)
|
||||
// Why: keep rich status on a live prompt/tool, or interactivePrompt is lost under a non-agent title.
|
||||
const hasLiveAgentSignal =
|
||||
normalizedTabAgentStatus?.interactivePrompt != null ||
|
||||
normalizedTabAgentStatus?.toolName != null
|
||||
@@ -30608,8 +30700,10 @@ export class OrcaRuntimeService {
|
||||
agentStatus: {
|
||||
state: 'done' as const,
|
||||
prompt: '',
|
||||
updatedAt: normalizedTabAgentStatus.updatedAt,
|
||||
stateStartedAt: normalizedTabAgentStatus.stateStartedAt,
|
||||
updatedAt: statusPty?.lastOscTitleEpochMs ?? normalizedTabAgentStatus.updatedAt,
|
||||
stateStartedAt:
|
||||
statusPty?.lastAgentStatusStartedAtEpochMs ??
|
||||
normalizedTabAgentStatus.stateStartedAt,
|
||||
paneKey: normalizedTabAgentStatus.paneKey,
|
||||
stateHistory: [],
|
||||
agentType: normalizedTabAgentStatus.agentType,
|
||||
@@ -30702,6 +30796,91 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
private renewMobileAgentStatusFromPtyTitle(
|
||||
status: AgentStatusEntry | null,
|
||||
pty: RuntimePtyWorktreeRecord | null,
|
||||
options: { preserveQuestionUnderShellTitle?: boolean } = {}
|
||||
): AgentStatusEntry | null {
|
||||
if (!status || !pty) {
|
||||
return status
|
||||
}
|
||||
if (
|
||||
options.preserveQuestionUnderShellTitle &&
|
||||
status.interactivePrompt != null &&
|
||||
terminalTitleBlocksExplicitAgentStatus(pty.lastOscTitle)
|
||||
) {
|
||||
return status
|
||||
}
|
||||
const richStatusCanOwnTitleInterval =
|
||||
pty.lastAgentStatusRichInvalidatedAtEpochMs === null ||
|
||||
status.updatedAt > pty.lastAgentStatusRichInvalidatedAtEpochMs
|
||||
const titleEvidenceAt = pty.lastOscTitleEpochMs
|
||||
if (titleEvidenceAt === null) {
|
||||
return richStatusCanOwnTitleInterval ? status : null
|
||||
}
|
||||
const buildTitleOnlyStatus = (
|
||||
state: AgentStatusEntry['state'],
|
||||
updatedAt: number,
|
||||
stateStartedAt: number
|
||||
): AgentStatusEntry => ({
|
||||
state,
|
||||
prompt: '',
|
||||
updatedAt,
|
||||
stateStartedAt,
|
||||
paneKey: status.paneKey,
|
||||
stateHistory: [],
|
||||
...(status.agentType ? { agentType: status.agentType } : {}),
|
||||
...(status.terminalHandle ? { terminalHandle: status.terminalHandle } : {}),
|
||||
...(status.worktreeId ? { worktreeId: status.worktreeId } : {}),
|
||||
...(status.tabId ? { tabId: status.tabId } : {}),
|
||||
...(status.terminalTitle ? { terminalTitle: status.terminalTitle } : {}),
|
||||
...(status.providerSession ? { providerSession: status.providerSession } : {})
|
||||
})
|
||||
const titleConfirmsState =
|
||||
(pty.lastAgentStatus === 'working' && status.state === 'working') ||
|
||||
(pty.lastAgentStatus === 'permission' &&
|
||||
(status.state === 'blocked' || status.state === 'waiting'))
|
||||
if (!titleConfirmsState) {
|
||||
if (richStatusCanOwnTitleInterval && status.updatedAt >= titleEvidenceAt) {
|
||||
return status
|
||||
}
|
||||
if (
|
||||
pty.lastAgentStatus === null &&
|
||||
!terminalTitleBlocksExplicitAgentStatus(pty.lastOscTitle)
|
||||
) {
|
||||
return status
|
||||
}
|
||||
const titleState =
|
||||
pty.lastAgentStatus === 'working'
|
||||
? 'working'
|
||||
: pty.lastAgentStatus === 'permission'
|
||||
? 'blocked'
|
||||
: 'done'
|
||||
return buildTitleOnlyStatus(
|
||||
titleState,
|
||||
titleEvidenceAt,
|
||||
pty.lastAgentStatusStartedAtEpochMs ?? titleEvidenceAt
|
||||
)
|
||||
}
|
||||
const richStatusIsFresh = Date.now() - status.updatedAt <= AGENT_STATUS_STALE_AFTER_MS
|
||||
const richStatusOwnsCurrentState = richStatusIsFresh && richStatusCanOwnTitleInterval
|
||||
// Fresh explicit evidence from this title interval owns acknowledgement identity.
|
||||
const stateStartedAt = richStatusOwnsCurrentState
|
||||
? status.stateStartedAt
|
||||
: (pty.lastAgentStatusStartedAtEpochMs ?? status.stateStartedAt)
|
||||
if (richStatusOwnsCurrentState) {
|
||||
pty.lastAgentStatusStartedAtEpochMs = stateStartedAt
|
||||
}
|
||||
const updatedAt = Math.max(status.updatedAt, titleEvidenceAt)
|
||||
if (!richStatusOwnsCurrentState) {
|
||||
return buildTitleOnlyStatus(status.state, updatedAt, stateStartedAt)
|
||||
}
|
||||
if (updatedAt === status.updatedAt && stateStartedAt === status.stateStartedAt) {
|
||||
return status
|
||||
}
|
||||
return { ...status, updatedAt, stateStartedAt }
|
||||
}
|
||||
|
||||
/** Mobile-friendly status entry for a PTY, aligning agentType and titles with the active owner. */
|
||||
private buildPtyMobileAgentStatus(
|
||||
pty: RuntimePtyWorktreeRecord | null,
|
||||
@@ -30776,24 +30955,29 @@ export class OrcaRuntimeService {
|
||||
// Why: OSC 9999 hook payload carries real state/prompt/agent; without preferring it, hook-only transitions never surfaced (#7970).
|
||||
const liveRow = retained ?? this.resolveHookLiveAgentRow(hookRow.live, pty, nonAgentTitle)
|
||||
if (liveRow) {
|
||||
return {
|
||||
agentStatus: normalizeCompatibleAgentStatusEntryForOwner(
|
||||
{
|
||||
...liveRow.payload,
|
||||
paneKey,
|
||||
updatedAt: liveRow.updatedAt,
|
||||
stateStartedAt: liveRow.stateStartedAt,
|
||||
stateHistory: [],
|
||||
...(terminalHandle ? { terminalHandle } : {}),
|
||||
...((pty?.worktreeId ?? liveRow.worktreeId)
|
||||
? { worktreeId: pty?.worktreeId ?? liveRow.worktreeId }
|
||||
: {}),
|
||||
tabId: tab.parentTabId,
|
||||
terminalTitle,
|
||||
...providerSession
|
||||
},
|
||||
ownerAgent
|
||||
)
|
||||
const liveStatus = normalizeCompatibleAgentStatusEntryForOwner(
|
||||
{
|
||||
...liveRow.payload,
|
||||
paneKey,
|
||||
updatedAt: liveRow.updatedAt,
|
||||
stateStartedAt: liveRow.stateStartedAt,
|
||||
stateHistory: [],
|
||||
...(terminalHandle ? { terminalHandle } : {}),
|
||||
...((pty?.worktreeId ?? liveRow.worktreeId)
|
||||
? { worktreeId: pty?.worktreeId ?? liveRow.worktreeId }
|
||||
: {}),
|
||||
tabId: tab.parentTabId,
|
||||
terminalTitle,
|
||||
...providerSession
|
||||
},
|
||||
ownerAgent
|
||||
)
|
||||
// A live question outranks only the shell title that currently obscures it.
|
||||
const renewedStatus = this.renewMobileAgentStatusFromPtyTitle(liveStatus, pty, {
|
||||
preserveQuestionUnderShellTitle: true
|
||||
})
|
||||
if (renewedStatus) {
|
||||
return { agentStatus: renewedStatus }
|
||||
}
|
||||
}
|
||||
// Last resort: the pane's hook evidence is identity only (resume rows, stale
|
||||
@@ -30814,7 +30998,7 @@ export class OrcaRuntimeService {
|
||||
: 'done',
|
||||
prompt: '',
|
||||
updatedAt: evidenceAt,
|
||||
stateStartedAt: evidenceAt,
|
||||
stateStartedAt: pty?.lastAgentStatusStartedAtEpochMs ?? evidenceAt,
|
||||
paneKey,
|
||||
...(terminalHandle ? { terminalHandle } : {}),
|
||||
...(agentType ? { agentType } : {}),
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type { WorkspaceSessionState } from '../../shared/types'
|
||||
|
||||
const MISSING = Symbol('missing')
|
||||
type RollbackValue = unknown | typeof MISSING
|
||||
|
||||
function isRecord(value: RollbackValue): value is Record<string, unknown> {
|
||||
return (
|
||||
value !== MISSING &&
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.getPrototypeOf(value) === Object.prototype
|
||||
)
|
||||
}
|
||||
|
||||
function rollbackValue(
|
||||
original: RollbackValue,
|
||||
staged: RollbackValue,
|
||||
current: RollbackValue
|
||||
): RollbackValue {
|
||||
if (isDeepStrictEqual(original, staged)) {
|
||||
return current
|
||||
}
|
||||
if (isDeepStrictEqual(current, staged)) {
|
||||
return original
|
||||
}
|
||||
if (!isRecord(original) || !isRecord(staged) || !isRecord(current)) {
|
||||
return current
|
||||
}
|
||||
let changed = false
|
||||
const next: Record<string, unknown> = { ...current }
|
||||
for (const key of new Set([
|
||||
...Object.keys(original),
|
||||
...Object.keys(staged),
|
||||
...Object.keys(current)
|
||||
])) {
|
||||
const value = rollbackValue(
|
||||
Object.hasOwn(original, key) ? original[key] : MISSING,
|
||||
Object.hasOwn(staged, key) ? staged[key] : MISSING,
|
||||
Object.hasOwn(current, key) ? current[key] : MISSING
|
||||
)
|
||||
if (value === MISSING) {
|
||||
if (Object.hasOwn(next, key)) {
|
||||
delete next[key]
|
||||
changed = true
|
||||
}
|
||||
} else if (!Object.hasOwn(current, key) || !isDeepStrictEqual(current[key], value)) {
|
||||
next[key] = value
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : current
|
||||
}
|
||||
|
||||
export function rollbackWorkspaceSessionAfterFailedAsyncWrite(
|
||||
original: WorkspaceSessionState,
|
||||
staged: WorkspaceSessionState,
|
||||
current: WorkspaceSessionState
|
||||
): WorkspaceSessionState {
|
||||
return rollbackValue(original, staged, current) as WorkspaceSessionState
|
||||
}
|
||||
@@ -134,6 +134,7 @@ describe('isRelayAlreadyInstalled', () => {
|
||||
const cmd = mockExec.mock.calls.at(-1)?.[1] ?? ''
|
||||
expect(cmd).toContain('relay.js')
|
||||
expect(cmd).toContain('relay-watcher.js')
|
||||
expect(cmd).toContain('relay-ai-vault-service.js')
|
||||
expect(cmd).toContain('managed-hook-runtime.js')
|
||||
expect(cmd).toContain('.install-complete')
|
||||
})
|
||||
|
||||
@@ -117,6 +117,7 @@ describe('ssh remote command builders', () => {
|
||||
const probe = probeRelayInstalledCommand(posix, '/home/me/relay')
|
||||
expect(probe).toContain('test -d')
|
||||
expect(probe).toContain('managed-hook-runtime.js')
|
||||
expect(probe).toContain('relay-ai-vault-service.js')
|
||||
})
|
||||
|
||||
it('uses encoded PowerShell for Windows deploy commands', () => {
|
||||
@@ -127,6 +128,7 @@ describe('ssh remote command builders', () => {
|
||||
const probe = probeRelayInstalledCommand(windows, 'C:/Users/me/relay')
|
||||
expect(probe).toContain('-EncodedCommand')
|
||||
expect(decodePowerShellCommand(probe)).toContain('managed-hook-runtime.js')
|
||||
expect(decodePowerShellCommand(probe)).toContain('relay-ai-vault-service.js')
|
||||
})
|
||||
|
||||
it('uses a legacy-visible Windows lock directory with an exclusive owner file', () => {
|
||||
|
||||
@@ -91,6 +91,7 @@ export function probeRelayInstalledCommand(
|
||||
): string {
|
||||
const relayJs = joinRemotePath(host, remoteRelayDir, 'relay.js')
|
||||
const relayWatcherJs = joinRemotePath(host, remoteRelayDir, 'relay-watcher.js')
|
||||
const relayAiVaultServiceJs = joinRemotePath(host, remoteRelayDir, 'relay-ai-vault-service.js')
|
||||
const managedHookRuntimeJs = joinRemotePath(host, remoteRelayDir, 'managed-hook-runtime.js')
|
||||
const installComplete = joinRemotePath(host, remoteRelayDir, '.install-complete')
|
||||
if (!isWindowsRemoteHost(host)) {
|
||||
@@ -98,6 +99,7 @@ export function probeRelayInstalledCommand(
|
||||
`test -d ${shellEscape(remoteRelayDir)} ` +
|
||||
`&& test -f ${shellEscape(relayJs)} ` +
|
||||
`&& test -f ${shellEscape(relayWatcherJs)} ` +
|
||||
`&& test -f ${shellEscape(relayAiVaultServiceJs)} ` +
|
||||
`&& test -f ${shellEscape(managedHookRuntimeJs)} ` +
|
||||
`&& test -f ${shellEscape(installComplete)} ` +
|
||||
`&& echo OK || echo MISSING`
|
||||
@@ -108,9 +110,10 @@ export function probeRelayInstalledCommand(
|
||||
`$dir = ${powerShellLiteral(remoteRelayDir)}`,
|
||||
`$relay = ${powerShellLiteral(relayJs)}`,
|
||||
`$watcher = ${powerShellLiteral(relayWatcherJs)}`,
|
||||
`$aiVaultService = ${powerShellLiteral(relayAiVaultServiceJs)}`,
|
||||
`$managedHooks = ${powerShellLiteral(managedHookRuntimeJs)}`,
|
||||
`$complete = ${powerShellLiteral(installComplete)}`,
|
||||
"if ((Test-Path -LiteralPath $dir -PathType Container) -and (Test-Path -LiteralPath $relay -PathType Leaf) -and (Test-Path -LiteralPath $watcher -PathType Leaf) -and (Test-Path -LiteralPath $managedHooks -PathType Leaf) -and (Test-Path -LiteralPath $complete -PathType Leaf)) { 'OK' } else { 'MISSING' }"
|
||||
"if ((Test-Path -LiteralPath $dir -PathType Container) -and (Test-Path -LiteralPath $relay -PathType Leaf) -and (Test-Path -LiteralPath $watcher -PathType Leaf) -and (Test-Path -LiteralPath $aiVaultService -PathType Leaf) -and (Test-Path -LiteralPath $managedHooks -PathType Leaf) -and (Test-Path -LiteralPath $complete -PathType Leaf)) { 'OK' } else { 'MISSING' }"
|
||||
].join('; ')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ exec /bin/sh -c "$cmd"
|
||||
|
||||
function writeFakeRelay(dir: string): void {
|
||||
writeFileSync(join(dir, 'relay-watcher.js'), '')
|
||||
writeFileSync(join(dir, 'relay-ai-vault-service.js'), '')
|
||||
writeFileSync(join(dir, 'managed-hook-runtime.js'), '')
|
||||
writeFileSync(
|
||||
join(dir, 'relay.js'),
|
||||
|
||||
@@ -8,8 +8,13 @@ import {
|
||||
SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD
|
||||
} from '../shared/ssh-ai-vault-relay'
|
||||
import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner'
|
||||
import { readAiVaultSessionTitlesFromFiles } from '../main/ai-vault/session-title-file-reader'
|
||||
import type { RelayDispatcher, RequestContext } from './dispatcher'
|
||||
import { AiVaultHandler } from './ai-vault-handler'
|
||||
import { createRelayAiVaultFilesystemProvider } from './ai-vault-service-filesystem'
|
||||
import type { RelayAiVaultServiceApi } from './ai-vault-service-client-state'
|
||||
|
||||
type RequestHandler = (params: Record<string, unknown>, context: RequestContext) => Promise<unknown>
|
||||
|
||||
@@ -49,7 +54,7 @@ describe('AiVaultHandler', () => {
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome,
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
scanRemoteSessions
|
||||
service: createTestService(remoteHome, getRemoteHostPlatform('linux-x64'), scanRemoteSessions)
|
||||
})
|
||||
|
||||
await expect(
|
||||
@@ -99,7 +104,8 @@ describe('AiVaultHandler', () => {
|
||||
const dispatcher = createMockDispatcher()
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome,
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
service: createTestService(remoteHome, getRemoteHostPlatform('linux-x64'))
|
||||
})
|
||||
|
||||
const result = (await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {
|
||||
@@ -123,7 +129,11 @@ describe('AiVaultHandler', () => {
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
scanRemoteSessions
|
||||
service: createTestService(
|
||||
'/home/ada',
|
||||
getRemoteHostPlatform('linux-x64'),
|
||||
scanRemoteSessions
|
||||
)
|
||||
})
|
||||
const scopePaths = Array.from({ length: 80 }, (_, index) => `/repo/${index}`)
|
||||
|
||||
@@ -156,7 +166,11 @@ describe('AiVaultHandler', () => {
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
scanRemoteSessions
|
||||
service: createTestService(
|
||||
'/home/ada',
|
||||
getRemoteHostPlatform('linux-x64'),
|
||||
scanRemoteSessions
|
||||
)
|
||||
})
|
||||
|
||||
await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {
|
||||
@@ -183,7 +197,11 @@ describe('AiVaultHandler', () => {
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
scanRemoteSessions: scanRemoteSessions as never
|
||||
service: createTestService(
|
||||
'/home/ada',
|
||||
getRemoteHostPlatform('linux-x64'),
|
||||
scanRemoteSessions as never
|
||||
)
|
||||
})
|
||||
const firstController = new AbortController()
|
||||
const first = dispatcher.call(
|
||||
@@ -219,7 +237,11 @@ describe('AiVaultHandler', () => {
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
scanRemoteSessions: scanRemoteSessions as never
|
||||
service: createTestService(
|
||||
'/home/ada',
|
||||
getRemoteHostPlatform('linux-x64'),
|
||||
scanRemoteSessions as never
|
||||
)
|
||||
})
|
||||
const first = dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { limit: 20 })
|
||||
await vi.waitFor(() => expect(signals).toHaveLength(1))
|
||||
@@ -254,11 +276,31 @@ describe('AiVaultHandler', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('soft-disables the method instead of aborting relay startup when the service is missing', () => {
|
||||
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
|
||||
try {
|
||||
const dispatcher = createMockDispatcher()
|
||||
|
||||
expect(
|
||||
() =>
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
).not.toThrow()
|
||||
|
||||
expect(() => dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {})).toThrow(/No handler/)
|
||||
} finally {
|
||||
stderr.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('stops a relay-local scan when the owning request is cancelled', async () => {
|
||||
const dispatcher = createMockDispatcher()
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
service: createTestService('/home/ada', getRemoteHostPlatform('linux-x64'))
|
||||
})
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
@@ -267,6 +309,70 @@ describe('AiVaultHandler', () => {
|
||||
dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {}, controller.signal)
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
|
||||
it('returns a host issue when the sidecar is unavailable', async () => {
|
||||
const dispatcher = createMockDispatcher()
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
service: {
|
||||
listSessions: () => Promise.reject(new Error('sidecar crashed')),
|
||||
resolveSessionTitles: () => Promise.resolve({ titles: [] })
|
||||
}
|
||||
})
|
||||
|
||||
await expect(dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {})).resolves.toMatchObject({
|
||||
sessions: [],
|
||||
issues: [
|
||||
expect.objectContaining({ kind: 'host', message: expect.stringContaining('sidecar') })
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('returns no titles instead of an RPC error when the sidecar is unavailable', async () => {
|
||||
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
|
||||
try {
|
||||
const dispatcher = createMockDispatcher()
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
service: {
|
||||
listSessions: () => Promise.resolve(emptyResult()),
|
||||
resolveSessionTitles: () => Promise.reject(new Error('sidecar crashed'))
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
dispatcher.call(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, {
|
||||
requests: [
|
||||
{ agent: 'codex', sessionId: 'ssh-session', transcriptPath: '/home/ada/s.jsonl' }
|
||||
]
|
||||
})
|
||||
).resolves.toEqual({ titles: [] })
|
||||
} finally {
|
||||
stderr.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('propagates title cancellation instead of degrading it', async () => {
|
||||
const dispatcher = createMockDispatcher()
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
service: {
|
||||
listSessions: () => Promise.resolve(emptyResult()),
|
||||
resolveSessionTitles: () => {
|
||||
const error = new Error('The operation was aborted.')
|
||||
error.name = 'AbortError'
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
dispatcher.call(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, { requests: [] })
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
})
|
||||
|
||||
async function makeTemporaryHome(): Promise<string> {
|
||||
@@ -279,6 +385,28 @@ function emptyResult(): AiVaultListResult {
|
||||
return { sessions: [], issues: [], scannedAt: '2026-07-26T00:00:00.000Z' }
|
||||
}
|
||||
|
||||
function createTestService(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform,
|
||||
scan: typeof scanRemoteAiVaultSessions = scanRemoteAiVaultSessions
|
||||
): RelayAiVaultServiceApi {
|
||||
return {
|
||||
listSessions: (params, signal) =>
|
||||
scan({
|
||||
provider: createRelayAiVaultFilesystemProvider(),
|
||||
executionHostId: 'local',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
limit: params.limit,
|
||||
unlimited: params.unlimited,
|
||||
scopePaths: params.scopePaths,
|
||||
signal
|
||||
}),
|
||||
resolveSessionTitles: (requests, signal) =>
|
||||
readAiVaultSessionTitlesFromFiles(requests, { signal })
|
||||
}
|
||||
}
|
||||
|
||||
function createMockDispatcher(): {
|
||||
value: RelayDispatcher
|
||||
call: (method: string, params: Record<string, unknown>, signal?: AbortSignal) => Promise<unknown>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { lstat, readdir } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { AI_VAULT_SCOPE_PATHS_MAX_COUNT, type AiVaultListResult } from '../shared/ai-vault-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host'
|
||||
@@ -14,34 +13,25 @@ import {
|
||||
SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH,
|
||||
type SshAiVaultRelayListParams
|
||||
} from '../shared/ssh-ai-vault-relay'
|
||||
import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner'
|
||||
import type { RemoteSessionFilesystemProvider } from '../main/ai-vault/remote-session-scanner-types'
|
||||
import { getRemoteHostPlatform, type RemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
import { parseUnameToRelayPlatform } from '../main/ssh/relay-protocol'
|
||||
import { readRelayFileContent } from './fs-handler-file-read'
|
||||
import { relayLogLine } from './relay-diagnostic-log'
|
||||
import type { RelayDispatcher } from './dispatcher'
|
||||
import { AiVaultScanCoordinator } from '../main/ai-vault/ai-vault-scan-coordinator'
|
||||
import { readAiVaultSessionTitlesFromFiles } from '../main/ai-vault/session-title-file-reader'
|
||||
|
||||
type ScanRemoteSessions = typeof scanRemoteAiVaultSessions
|
||||
import type { RelayAiVaultServiceApi } from './ai-vault-service-client-state'
|
||||
|
||||
type AiVaultHandlerOptions = {
|
||||
remoteHome?: string
|
||||
hostPlatform?: RemoteHostPlatform
|
||||
scanRemoteSessions?: ScanRemoteSessions
|
||||
service?: RelayAiVaultServiceApi
|
||||
}
|
||||
|
||||
export class AiVaultHandler {
|
||||
private readonly remoteHome: string
|
||||
private readonly scanRemoteSessions: ScanRemoteSessions
|
||||
private readonly provider: RemoteSessionFilesystemProvider
|
||||
private readonly scanCoordinator = new AiVaultScanCoordinator()
|
||||
|
||||
constructor(dispatcher: RelayDispatcher, options: AiVaultHandlerOptions = {}) {
|
||||
this.remoteHome = options.remoteHome ?? homedir()
|
||||
this.scanRemoteSessions = options.scanRemoteSessions ?? scanRemoteAiVaultSessions
|
||||
this.provider = createRelayAiVaultFilesystemProvider()
|
||||
const hostPlatform = options.hostPlatform ?? currentRelayHostPlatform()
|
||||
// Why: an OS/arch this build has no path flavor for must not abort relay
|
||||
// startup — leaving the method unregistered soft-disables the feature and
|
||||
@@ -52,48 +42,78 @@ export class AiVaultHandler {
|
||||
)
|
||||
return
|
||||
}
|
||||
// Why: same reasoning as an unsupported platform. Throwing here would take
|
||||
// relay startup — and every PTY on the host — down over a Vault wiring bug.
|
||||
const service = options.service
|
||||
if (!service) {
|
||||
relayLogLine('[relay] Agent Session History disabled: service unavailable')
|
||||
return
|
||||
}
|
||||
dispatcher.onRequest(SSH_AI_VAULT_LIST_SESSIONS_METHOD, (params, context) =>
|
||||
this.listSessions(hostPlatform, params, context.signal)
|
||||
this.listSessions(service, params, context.signal)
|
||||
)
|
||||
dispatcher.onRequest(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, (params, context) =>
|
||||
this.resolveSessionTitles(params, context.signal)
|
||||
this.resolveSessionTitles(service, params, context.signal)
|
||||
)
|
||||
}
|
||||
|
||||
private resolveSessionTitles(
|
||||
private async resolveSessionTitles(
|
||||
service: RelayAiVaultServiceApi,
|
||||
rawParams: Record<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSessionTitlesResult> {
|
||||
return readAiVaultSessionTitlesFromFiles(normalizeTitleRequests(rawParams.requests), { signal })
|
||||
try {
|
||||
return await service.resolveSessionTitles(normalizeTitleRequests(rawParams.requests), signal)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw error
|
||||
}
|
||||
// Why: titles are decoration. Degrade like an unresolvable title instead of
|
||||
// failing the RPC, which would surface a raw error on every list row.
|
||||
relayLogLine(
|
||||
`[relay-ai-vault-service] title resolution unavailable: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
return { titles: [] }
|
||||
}
|
||||
}
|
||||
|
||||
private async listSessions(
|
||||
hostPlatform: RemoteHostPlatform,
|
||||
service: RelayAiVaultServiceApi,
|
||||
rawParams: Record<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultListResult> {
|
||||
const params = normalizeSshAiVaultRelayListParams(rawParams)
|
||||
const result = await this.scanCoordinator.run({
|
||||
key: JSON.stringify({
|
||||
limit: params.limit,
|
||||
unlimited: params.unlimited,
|
||||
scopePaths: params.scopePaths,
|
||||
scopePathsTruncated: params.scopePathsTruncated
|
||||
}),
|
||||
force: params.force,
|
||||
signal,
|
||||
start: (scanSignal) =>
|
||||
this.scanRemoteSessions({
|
||||
provider: this.provider,
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID,
|
||||
remoteHome: this.remoteHome,
|
||||
hostPlatform,
|
||||
let result: AiVaultListResult
|
||||
try {
|
||||
result = await this.scanCoordinator.run({
|
||||
key: JSON.stringify({
|
||||
limit: params.limit,
|
||||
unlimited: params.unlimited,
|
||||
scopePaths: params.scopePaths,
|
||||
signal: scanSignal
|
||||
})
|
||||
})
|
||||
scopePathsTruncated: params.scopePathsTruncated
|
||||
}),
|
||||
force: params.force,
|
||||
signal,
|
||||
start: (scanSignal) => service.listSessions(params, scanSignal)
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw error
|
||||
}
|
||||
result = {
|
||||
sessions: [],
|
||||
scannedAt: new Date().toISOString(),
|
||||
issues: [
|
||||
{
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID,
|
||||
agent: 'codex',
|
||||
kind: 'host',
|
||||
path: this.remoteHome,
|
||||
message: `Agent Session History service unavailable: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
if (!params.scopePathsTruncated) {
|
||||
return result
|
||||
}
|
||||
@@ -176,29 +196,3 @@ function currentRelayHostPlatform(): RemoteHostPlatform | null {
|
||||
const relayPlatform = parseUnameToRelayPlatform(process.platform, process.arch)
|
||||
return relayPlatform ? getRemoteHostPlatform(relayPlatform) : null
|
||||
}
|
||||
|
||||
function createRelayAiVaultFilesystemProvider(): RemoteSessionFilesystemProvider {
|
||||
return {
|
||||
async readDir(dirPath) {
|
||||
const entries = await readdir(dirPath, { withFileTypes: true })
|
||||
return entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
isDirectory: entry.isDirectory(),
|
||||
isSymlink: entry.isSymbolicLink()
|
||||
}))
|
||||
},
|
||||
readFile: readRelayFileContent,
|
||||
async stat(filePath) {
|
||||
const stats = await lstat(filePath)
|
||||
return {
|
||||
size: stats.size,
|
||||
type: stats.isDirectory() ? 'directory' : stats.isSymbolicLink() ? 'symlink' : 'file',
|
||||
mtime: stats.mtimeMs,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
dev: stats.dev,
|
||||
ino: stats.ino,
|
||||
nlink: stats.nlink
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import type { AiVaultListResult } from '../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSessionTitleRequest,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../shared/ai-vault-session-title'
|
||||
import type { SshAiVaultRelayListParams } from '../shared/ssh-ai-vault-relay'
|
||||
import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
import {
|
||||
relayAiVaultServiceLane,
|
||||
type RelayAiVaultServiceLane,
|
||||
type RelayAiVaultServiceRequest
|
||||
} from './ai-vault-service-protocol'
|
||||
|
||||
export const RELAY_AI_VAULT_READY_TIMEOUT_MS = 5_000
|
||||
export const RELAY_AI_VAULT_SCAN_TIMEOUT_MS = 130_000
|
||||
export const RELAY_AI_VAULT_TITLE_TIMEOUT_MS = 15_000
|
||||
export const RELAY_AI_VAULT_MAX_CALLS = 16
|
||||
export const RELAY_AI_VAULT_IDLE_TIMEOUT_MS = 10 * 60_000
|
||||
|
||||
export function relayAiVaultAbortError(): Error {
|
||||
const error = new Error('The operation was aborted.')
|
||||
error.name = 'AbortError'
|
||||
return error
|
||||
}
|
||||
|
||||
export function relayAiVaultError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
|
||||
export function armRelayAiVaultCancellationTimeout(
|
||||
call: RelayAiVaultServiceCall,
|
||||
onExpired: () => void
|
||||
): void {
|
||||
call.timer = setTimeout(onExpired, 2_000)
|
||||
call.timer.unref?.()
|
||||
}
|
||||
|
||||
export function createRelayAiVaultServiceCall(args: {
|
||||
request: RelayAiVaultServiceRequest
|
||||
signal?: AbortSignal
|
||||
resolve: RelayAiVaultServiceCall['resolve']
|
||||
reject: RelayAiVaultServiceCall['reject']
|
||||
}): RelayAiVaultServiceCall {
|
||||
return {
|
||||
request: args.request,
|
||||
lane: relayAiVaultServiceLane(args.request.operation),
|
||||
signal: args.signal,
|
||||
forceStart: args.request.operation === 'list' && args.request.params.force === true,
|
||||
resolve: args.resolve,
|
||||
reject: args.reject,
|
||||
timer: null,
|
||||
onAbort: null,
|
||||
settled: false,
|
||||
sent: false,
|
||||
startRetried: false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A cold start that faults before the request reached the sidecar self-heals on
|
||||
* the scheduled respawn. Requeue once; the caller settles when this returns false.
|
||||
*/
|
||||
export function requeueRelayAiVaultServiceStart(
|
||||
call: RelayAiVaultServiceCall,
|
||||
queue: RelayAiVaultServiceCall[]
|
||||
): boolean {
|
||||
if (call.sent || call.settled || call.startRetried) {
|
||||
return false
|
||||
}
|
||||
call.startRetried = true
|
||||
queue.unshift(call)
|
||||
return true
|
||||
}
|
||||
|
||||
export function settleRelayAiVaultServiceCall(
|
||||
call: RelayAiVaultServiceCall,
|
||||
value: Error | AiVaultListResult | AiVaultSessionTitlesResult
|
||||
): void {
|
||||
// A cancelled call is settled before its cancel watchdog is armed, so the
|
||||
// timer has to be cleared even when the reject/resolve is already done.
|
||||
if (call.timer) {
|
||||
clearTimeout(call.timer)
|
||||
call.timer = null
|
||||
}
|
||||
if (call.settled) {
|
||||
return
|
||||
}
|
||||
call.settled = true
|
||||
if (call.signal && call.onAbort) {
|
||||
call.signal.removeEventListener('abort', call.onAbort)
|
||||
}
|
||||
if (value instanceof Error) {
|
||||
call.reject(value)
|
||||
} else {
|
||||
call.resolve(value)
|
||||
}
|
||||
}
|
||||
|
||||
export type RelayAiVaultServiceCall = {
|
||||
request: RelayAiVaultServiceRequest
|
||||
lane: RelayAiVaultServiceLane
|
||||
signal?: AbortSignal
|
||||
forceStart: boolean
|
||||
resolve: (value: AiVaultListResult | AiVaultSessionTitlesResult) => void
|
||||
reject: (error: Error) => void
|
||||
timer: NodeJS.Timeout | null
|
||||
onAbort: (() => void) | null
|
||||
settled: boolean
|
||||
/** Whether the sidecar received the request; an unsent call gets no reply. */
|
||||
sent: boolean
|
||||
startRetried: boolean
|
||||
}
|
||||
|
||||
export type RelayAiVaultServiceApi = {
|
||||
listSessions(params: SshAiVaultRelayListParams, signal?: AbortSignal): Promise<AiVaultListResult>
|
||||
resolveSessionTitles(
|
||||
requests: AiVaultSessionTitleRequest[],
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSessionTitlesResult>
|
||||
}
|
||||
|
||||
export type RelayAiVaultServiceClientOptions = {
|
||||
processFactory: () => ChildProcess
|
||||
init: {
|
||||
remoteHome: string
|
||||
hostPlatform: RemoteHostPlatform
|
||||
}
|
||||
now?: () => number
|
||||
idleTimeoutMs?: number
|
||||
}
|
||||
|
||||
export class RelayAiVaultIdleRetirement {
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
|
||||
clear(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
|
||||
schedule(busy: boolean, timeoutMs: number, retire: () => void): void {
|
||||
if (busy || this.timer) {
|
||||
return
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null
|
||||
retire()
|
||||
}, timeoutMs)
|
||||
this.timer.unref?.()
|
||||
}
|
||||
}
|
||||
|
||||
export function retireRelayAiVaultServiceChild(child: ChildProcess): void {
|
||||
const timer = setTimeout(() => child.kill(), 2_000)
|
||||
timer.unref?.()
|
||||
child.once('exit', () => clearTimeout(timer))
|
||||
child.send({ type: 'shutdown' }, () => undefined)
|
||||
}
|
||||
|
||||
/** Orderly shutdown that never holds relay teardown past the kill deadline. */
|
||||
export function shutdownRelayAiVaultServiceChild(child: ChildProcess): Promise<void> {
|
||||
child.send({ type: 'shutdown' }, () => undefined)
|
||||
return new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
child.kill()
|
||||
resolve()
|
||||
}, 2_000)
|
||||
timer.unref?.()
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
import {
|
||||
AiVaultServiceTestChild,
|
||||
readyAiVaultServiceChild
|
||||
} from '../main/ai-vault/session-scanner-service-test-child'
|
||||
import { RelayAiVaultServiceClient } from './ai-vault-service-client'
|
||||
import { RELAY_AI_VAULT_READY_TIMEOUT_MS } from './ai-vault-service-client-state'
|
||||
import { relayAiVaultServiceEntryPath } from './ai-vault-service-spawn'
|
||||
|
||||
function createClient(
|
||||
children: AiVaultServiceTestChild[],
|
||||
idleTimeoutMs?: number
|
||||
): RelayAiVaultServiceClient {
|
||||
return new RelayAiVaultServiceClient({
|
||||
init: {
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
},
|
||||
processFactory: () => {
|
||||
const child = new AiVaultServiceTestChild(20_000 + children.length)
|
||||
children.push(child)
|
||||
return child.asChildProcess()
|
||||
},
|
||||
idleTimeoutMs
|
||||
})
|
||||
}
|
||||
|
||||
function relayRequests(child: AiVaultServiceTestChild, operation: string): { id: number }[] {
|
||||
return child.sent.filter(
|
||||
(message) => (message as { operation?: string }).operation === operation
|
||||
) as { id: number }[]
|
||||
}
|
||||
|
||||
function relayRequestCount(child: AiVaultServiceTestChild, operation: string): number {
|
||||
return relayRequests(child, operation).length
|
||||
}
|
||||
|
||||
function relayRequestId(child: AiVaultServiceTestChild, operation: string): number {
|
||||
const request = relayRequests(child, operation).at(-1)
|
||||
if (!request) {
|
||||
throw new Error(`No ${operation} request was sent.`)
|
||||
}
|
||||
return request.id
|
||||
}
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('RelayAiVaultServiceClient', () => {
|
||||
it('holds list and title calls behind the ready handshake', async () => {
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = createClient(children)
|
||||
const list = client.listSessions({ limit: 20 })
|
||||
const titles = client.resolveSessionTitles([])
|
||||
const child = children[0]!
|
||||
|
||||
expect(child.sent).toEqual([expect.objectContaining({ type: 'init', protocol: 1 })])
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
const listRequest = child.sent.find(
|
||||
(message) => (message as { operation?: string }).operation === 'list'
|
||||
) as { id: number }
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: listRequest.id,
|
||||
operation: 'list',
|
||||
value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' }
|
||||
})
|
||||
await expect(list).resolves.toMatchObject({ sessions: [] })
|
||||
const titleRequest = child.sent.find(
|
||||
(message) => (message as { operation?: string }).operation === 'titles'
|
||||
) as { id: number }
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: titleRequest.id,
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
await expect(titles).resolves.toEqual({ titles: [] })
|
||||
const disposing = client.dispose()
|
||||
child.emit('exit', 0)
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('resolves titles while a scan still occupies the cache lane', async () => {
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = createClient(children)
|
||||
const list = client.listSessions({})
|
||||
const child = children[0]!
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
|
||||
const titles = client.resolveSessionTitles([
|
||||
{ agent: 'claude', sessionId: 'session-1', transcriptPath: '/home/ada/session-1.jsonl' }
|
||||
])
|
||||
await Promise.resolve()
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: relayRequestId(child, 'titles'),
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
|
||||
await expect(titles).resolves.toEqual({ titles: [] })
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: relayRequestId(child, 'list'),
|
||||
operation: 'list',
|
||||
value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' }
|
||||
})
|
||||
await expect(list).resolves.toMatchObject({ sessions: [] })
|
||||
const disposing = client.dispose()
|
||||
child.emit('exit', 0)
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('does not start queued cache work until cancelled work acknowledges', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = createClient(children)
|
||||
const controller = new AbortController()
|
||||
const first = client.listSessions({}, controller.signal)
|
||||
const second = client.listSessions({ limit: 5 })
|
||||
const child = children[0]!
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
const firstRequest = relayRequestId(child, 'list')
|
||||
|
||||
controller.abort()
|
||||
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(relayRequestCount(child, 'list')).toBe(1)
|
||||
child.emit('message', {
|
||||
type: 'error',
|
||||
id: firstRequest,
|
||||
message: 'aborted'
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(relayRequestCount(child, 'list')).toBe(2)
|
||||
void second.catch(() => undefined)
|
||||
const disposing = client.dispose()
|
||||
child.emit('exit', 0)
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('drops a call cancelled before the sidecar received it', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = createClient(children)
|
||||
const controller = new AbortController()
|
||||
const cancelled = client.listSessions({}, controller.signal)
|
||||
const child = children[0]!
|
||||
|
||||
controller.abort()
|
||||
await expect(cancelled).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(child.sent).not.toContainEqual(expect.objectContaining({ type: 'cancel' }))
|
||||
|
||||
readyAiVaultServiceChild(child)
|
||||
const next = client.listSessions({ limit: 5 })
|
||||
await Promise.resolve()
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: relayRequestId(child, 'list'),
|
||||
operation: 'list',
|
||||
value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' }
|
||||
})
|
||||
await expect(next).resolves.toMatchObject({ sessions: [] })
|
||||
vi.advanceTimersByTime(2_000)
|
||||
|
||||
expect(child.killed).toBe(false)
|
||||
const disposing = client.dispose()
|
||||
child.emit('exit', 0)
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('retries a cold-start crash once without faulting the replacement sidecar', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = createClient(children)
|
||||
const list = client.listSessions({})
|
||||
|
||||
children[0]!.emit('exit', 1)
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(children).toHaveLength(2)
|
||||
readyAiVaultServiceChild(children[1]!)
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(RELAY_AI_VAULT_READY_TIMEOUT_MS)
|
||||
|
||||
expect(children[1]!.killed).toBe(false)
|
||||
children[1]!.emit('message', {
|
||||
type: 'result',
|
||||
id: relayRequestId(children[1]!, 'list'),
|
||||
operation: 'list',
|
||||
value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' }
|
||||
})
|
||||
await expect(list).resolves.toMatchObject({ sessions: [] })
|
||||
const disposing = client.dispose()
|
||||
children[1]!.emit('exit', 0)
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('keeps the sidecar alive once a cancelled call acknowledges', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = createClient(children)
|
||||
const controller = new AbortController()
|
||||
const first = client.listSessions({}, controller.signal)
|
||||
const second = client.resolveSessionTitles([])
|
||||
const child = children[0]!
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
const firstRequest = child.sent.find(
|
||||
(message) => (message as { operation?: string }).operation === 'list'
|
||||
) as { id: number }
|
||||
|
||||
controller.abort()
|
||||
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
||||
child.emit('message', { type: 'error', id: firstRequest.id, message: 'aborted' })
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(2_000)
|
||||
|
||||
expect(child.killed).toBe(false)
|
||||
const titleRequest = child.sent.find(
|
||||
(message) => (message as { operation?: string }).operation === 'titles'
|
||||
) as { id: number }
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: titleRequest.id,
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
await expect(second).resolves.toEqual({ titles: [] })
|
||||
const disposing = client.dispose()
|
||||
child.emit('exit', 0)
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('restarts queued work after a sidecar crash with bounded backoff', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = createClient(children)
|
||||
const first = client.listSessions({})
|
||||
const second = client.listSessions({ limit: 5 })
|
||||
readyAiVaultServiceChild(children[0]!)
|
||||
await Promise.resolve()
|
||||
|
||||
children[0]!.emit('exit', 1)
|
||||
await expect(first).rejects.toThrow('exited')
|
||||
expect(children).toHaveLength(1)
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(children).toHaveLength(2)
|
||||
readyAiVaultServiceChild(children[1]!)
|
||||
await Promise.resolve()
|
||||
children[1]!.emit('message', {
|
||||
type: 'result',
|
||||
id: relayRequestId(children[1]!, 'list'),
|
||||
operation: 'list',
|
||||
value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' }
|
||||
})
|
||||
await expect(second).resolves.toMatchObject({ sessions: [] })
|
||||
const disposing = client.dispose()
|
||||
children[1]!.emit('exit', 0)
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('retires the sidecar after the idle bound', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = createClient(children, 100)
|
||||
const list = client.listSessions({})
|
||||
const child = children[0]!
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
const request = child.sent.find(
|
||||
(message) => (message as { operation?: string }).operation === 'list'
|
||||
) as { id: number }
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: request.id,
|
||||
operation: 'list',
|
||||
value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' }
|
||||
})
|
||||
await list
|
||||
|
||||
vi.advanceTimersByTime(99)
|
||||
expect(child.sent).not.toContainEqual({ type: 'shutdown' })
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(child.sent).toContainEqual({ type: 'shutdown' })
|
||||
vi.advanceTimersByTime(2_000)
|
||||
expect(child.killed).toBe(true)
|
||||
await client.dispose()
|
||||
})
|
||||
|
||||
it('resolves the sidecar beside each bundled relay', () => {
|
||||
expect(relayAiVaultServiceEntryPath('/opt/orca/relay')).toBe(
|
||||
'/opt/orca/relay/relay-ai-vault-service.js'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,314 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import type { AiVaultListResult } from '../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSessionTitleRequest,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../shared/ai-vault-session-title'
|
||||
import type { SshAiVaultRelayListParams } from '../shared/ssh-ai-vault-relay'
|
||||
import {
|
||||
RELAY_AI_VAULT_MAX_CALLS,
|
||||
RELAY_AI_VAULT_IDLE_TIMEOUT_MS,
|
||||
RELAY_AI_VAULT_READY_TIMEOUT_MS,
|
||||
RELAY_AI_VAULT_SCAN_TIMEOUT_MS,
|
||||
RELAY_AI_VAULT_TITLE_TIMEOUT_MS,
|
||||
armRelayAiVaultCancellationTimeout,
|
||||
createRelayAiVaultServiceCall,
|
||||
relayAiVaultAbortError,
|
||||
relayAiVaultError,
|
||||
RelayAiVaultIdleRetirement,
|
||||
requeueRelayAiVaultServiceStart,
|
||||
retireRelayAiVaultServiceChild,
|
||||
settleRelayAiVaultServiceCall,
|
||||
shutdownRelayAiVaultServiceChild,
|
||||
type RelayAiVaultServiceApi,
|
||||
type RelayAiVaultServiceCall,
|
||||
type RelayAiVaultServiceClientOptions
|
||||
} from './ai-vault-service-client-state'
|
||||
import {
|
||||
RELAY_AI_VAULT_SERVICE_PROTOCOL,
|
||||
isRelayAiVaultServiceChildMessage,
|
||||
type RelayAiVaultServiceChildMessage,
|
||||
type RelayAiVaultServiceInit,
|
||||
type RelayAiVaultServiceLane,
|
||||
type RelayAiVaultServiceRequest
|
||||
} from './ai-vault-service-protocol'
|
||||
import { RelayAiVaultRestartPolicy } from './ai-vault-service-restart-policy'
|
||||
import { relayLogLine } from './relay-diagnostic-log'
|
||||
|
||||
export class RelayAiVaultServiceClient implements RelayAiVaultServiceApi {
|
||||
private child: ChildProcess | null = null
|
||||
private ready: Promise<ChildProcess> | null = null
|
||||
private readyReject: ((error: Error) => void) | null = null
|
||||
private readyTimer: NodeJS.Timeout | null = null
|
||||
private readonly active = new Map<RelayAiVaultServiceLane, RelayAiVaultServiceCall>()
|
||||
private readonly queue: RelayAiVaultServiceCall[] = []
|
||||
private nextId = 1
|
||||
private readonly restartPolicy: RelayAiVaultRestartPolicy
|
||||
private readonly idleRetirement = new RelayAiVaultIdleRetirement()
|
||||
private disposed = false
|
||||
|
||||
constructor(private readonly options: RelayAiVaultServiceClientOptions) {
|
||||
this.restartPolicy = new RelayAiVaultRestartPolicy(options.now)
|
||||
}
|
||||
|
||||
listSessions(
|
||||
params: SshAiVaultRelayListParams,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultListResult> {
|
||||
return this.request({ type: 'request', id: this.nextId++, operation: 'list', params }, signal)
|
||||
}
|
||||
|
||||
resolveSessionTitles(
|
||||
requests: AiVaultSessionTitleRequest[],
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSessionTitlesResult> {
|
||||
return this.request(
|
||||
{ type: 'request', id: this.nextId++, operation: 'titles', requests },
|
||||
signal
|
||||
)
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
this.idleRetirement.clear()
|
||||
this.clearReadyTimer()
|
||||
this.restartPolicy.dispose()
|
||||
const error = new Error('Relay AI Vault service was disposed.')
|
||||
for (const call of [...this.active.values(), ...this.queue.splice(0)]) {
|
||||
settleRelayAiVaultServiceCall(call, error)
|
||||
}
|
||||
this.active.clear()
|
||||
const child = this.detachChild()
|
||||
if (child) {
|
||||
await shutdownRelayAiVaultServiceChild(child)
|
||||
}
|
||||
}
|
||||
|
||||
private request<T extends AiVaultListResult | AiVaultSessionTitlesResult>(
|
||||
request: RelayAiVaultServiceRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
if (this.disposed) {
|
||||
return Promise.reject(new Error('Relay AI Vault service was disposed.'))
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(relayAiVaultAbortError())
|
||||
}
|
||||
if (this.queue.length + this.active.size >= RELAY_AI_VAULT_MAX_CALLS) {
|
||||
return Promise.reject(new Error('Relay AI Vault service queue is full.'))
|
||||
}
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const call = createRelayAiVaultServiceCall({
|
||||
request,
|
||||
signal,
|
||||
resolve: resolve as RelayAiVaultServiceCall['resolve'],
|
||||
reject
|
||||
})
|
||||
if (signal) {
|
||||
call.onAbort = () => this.cancel(call)
|
||||
signal.addEventListener('abort', call.onAbort, { once: true })
|
||||
}
|
||||
this.queue.push(call)
|
||||
this.idleRetirement.clear()
|
||||
this.pump()
|
||||
})
|
||||
}
|
||||
|
||||
private pump(): void {
|
||||
if (this.disposed || this.restartPolicy.restartScheduled) {
|
||||
return
|
||||
}
|
||||
for (const lane of ['cache', 'interactive'] as const) {
|
||||
if (this.active.has(lane)) {
|
||||
continue
|
||||
}
|
||||
const index = this.queue.findIndex((call) => call.lane === lane)
|
||||
if (index < 0) {
|
||||
continue
|
||||
}
|
||||
const call = this.queue.splice(index, 1)[0]!
|
||||
this.active.set(lane, call)
|
||||
void this.ensureChild(call.forceStart).then(
|
||||
(child) => this.sendCall(child, call),
|
||||
(error: Error) => {
|
||||
if (this.active.get(lane) !== call) {
|
||||
return
|
||||
}
|
||||
this.active.delete(lane)
|
||||
this.retryStartOrSettle(call, error)
|
||||
this.pump()
|
||||
}
|
||||
)
|
||||
}
|
||||
this.scheduleIdleIfNeeded()
|
||||
}
|
||||
|
||||
private sendCall(child: ChildProcess, call: RelayAiVaultServiceCall): void {
|
||||
if (this.active.get(call.lane) !== call || call.settled) {
|
||||
return
|
||||
}
|
||||
const timeout =
|
||||
call.request.operation === 'list'
|
||||
? RELAY_AI_VAULT_SCAN_TIMEOUT_MS
|
||||
: RELAY_AI_VAULT_TITLE_TIMEOUT_MS
|
||||
call.timer = setTimeout(
|
||||
() => this.onFault(new Error(`Relay AI Vault service timed out after ${timeout}ms.`)),
|
||||
timeout
|
||||
)
|
||||
call.timer.unref?.()
|
||||
call.sent = true
|
||||
child.send(call.request)
|
||||
}
|
||||
|
||||
private retryStartOrSettle(call: RelayAiVaultServiceCall, error: Error): void {
|
||||
if (this.disposed || !requeueRelayAiVaultServiceStart(call, this.queue)) {
|
||||
settleRelayAiVaultServiceCall(call, error)
|
||||
}
|
||||
}
|
||||
|
||||
private ensureChild(forceStart: boolean): Promise<ChildProcess> {
|
||||
if (this.child && !this.ready) {
|
||||
return Promise.resolve(this.child)
|
||||
}
|
||||
if (this.ready) {
|
||||
return this.ready
|
||||
}
|
||||
const startError = this.restartPolicy.startError(forceStart)
|
||||
if (startError) {
|
||||
return Promise.reject(startError)
|
||||
}
|
||||
let child: ChildProcess
|
||||
try {
|
||||
child = this.options.processFactory()
|
||||
} catch (error) {
|
||||
return Promise.reject(relayAiVaultError(error))
|
||||
}
|
||||
this.child = child
|
||||
this.ready = new Promise<ChildProcess>((resolve, reject) => {
|
||||
this.readyReject = reject
|
||||
// Why: held on the instance so a crash before ready cannot leave the deadline
|
||||
// armed, where it would later fault the healthy replacement sidecar.
|
||||
this.readyTimer = setTimeout(
|
||||
() => this.onFault(new Error('Relay AI Vault service did not become ready.')),
|
||||
RELAY_AI_VAULT_READY_TIMEOUT_MS
|
||||
)
|
||||
this.readyTimer.unref?.()
|
||||
child.on('message', (message) => {
|
||||
if (isRelayAiVaultServiceChildMessage(message) && message.type === 'ready') {
|
||||
this.clearReadyTimer()
|
||||
this.ready = null
|
||||
this.readyReject = null
|
||||
resolve(child)
|
||||
return
|
||||
}
|
||||
this.onMessage(message)
|
||||
})
|
||||
})
|
||||
child.on('error', (error) => this.onFault(error))
|
||||
child.on('disconnect', () => this.onFault(new Error('Relay AI Vault service disconnected.')))
|
||||
child.on('exit', (code) => this.onFault(new Error(`Relay AI Vault service exited (${code}).`)))
|
||||
child.stderr?.on('data', (chunk: Buffer) =>
|
||||
relayLogLine(`[relay-ai-vault-service] ${String(chunk).trimEnd()}`)
|
||||
)
|
||||
child.send({
|
||||
type: 'init',
|
||||
protocol: RELAY_AI_VAULT_SERVICE_PROTOCOL,
|
||||
...this.options.init
|
||||
} satisfies RelayAiVaultServiceInit)
|
||||
return this.ready
|
||||
}
|
||||
|
||||
private onMessage(raw: unknown): void {
|
||||
if (!isRelayAiVaultServiceChildMessage(raw)) {
|
||||
this.onFault(new Error('Relay AI Vault service sent a malformed message.'))
|
||||
return
|
||||
}
|
||||
const message = raw as RelayAiVaultServiceChildMessage
|
||||
if (message.type === 'ready') {
|
||||
return
|
||||
}
|
||||
const call = [...this.active.values()].find((entry) => entry.request.id === message.id)
|
||||
if (!call) {
|
||||
return
|
||||
}
|
||||
this.active.delete(call.lane)
|
||||
settleRelayAiVaultServiceCall(
|
||||
call,
|
||||
message.type === 'error' ? new Error(message.message) : message.value
|
||||
)
|
||||
this.pump()
|
||||
}
|
||||
|
||||
private cancel(call: RelayAiVaultServiceCall): void {
|
||||
const index = this.queue.indexOf(call)
|
||||
if (index >= 0) {
|
||||
this.queue.splice(index, 1)
|
||||
settleRelayAiVaultServiceCall(call, relayAiVaultAbortError())
|
||||
this.pump()
|
||||
return
|
||||
}
|
||||
if (this.active.get(call.lane) === call) {
|
||||
// Why: a call cancelled before it reached the sidecar gets no acknowledgement,
|
||||
// so waiting on one would kill a healthy sidecar and stall the lane.
|
||||
if (!call.sent) {
|
||||
this.active.delete(call.lane)
|
||||
settleRelayAiVaultServiceCall(call, relayAiVaultAbortError())
|
||||
this.pump()
|
||||
return
|
||||
}
|
||||
this.child?.send({ type: 'cancel', id: call.request.id })
|
||||
settleRelayAiVaultServiceCall(call, relayAiVaultAbortError())
|
||||
armRelayAiVaultCancellationTimeout(call, () =>
|
||||
this.onFault(new Error('Relay AI Vault service did not cancel within 2000ms.'))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private onFault(error: Error): void {
|
||||
if (!this.child) {
|
||||
return
|
||||
}
|
||||
this.idleRetirement.clear()
|
||||
this.detachChild()?.kill()
|
||||
this.clearReadyTimer()
|
||||
this.readyReject?.(error)
|
||||
this.readyReject = null
|
||||
this.ready = null
|
||||
const active = [...this.active.values()]
|
||||
this.active.clear()
|
||||
for (const call of active) {
|
||||
this.retryStartOrSettle(call, error)
|
||||
}
|
||||
this.restartPolicy.recordFault()
|
||||
if (this.queue.length > 0 && !this.disposed) {
|
||||
this.restartPolicy.scheduleRestart(() => this.pump())
|
||||
}
|
||||
}
|
||||
|
||||
private clearReadyTimer(): void {
|
||||
if (this.readyTimer) {
|
||||
clearTimeout(this.readyTimer)
|
||||
this.readyTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private detachChild(): ChildProcess | null {
|
||||
const child = this.child
|
||||
this.child = null
|
||||
child?.removeAllListeners()
|
||||
return child
|
||||
}
|
||||
|
||||
private scheduleIdleIfNeeded(): void {
|
||||
this.idleRetirement.schedule(
|
||||
this.active.size > 0 || this.queue.length > 0 || !this.child,
|
||||
this.options.idleTimeoutMs ?? RELAY_AI_VAULT_IDLE_TIMEOUT_MS,
|
||||
() => {
|
||||
const child = this.detachChild()
|
||||
if (child) {
|
||||
retireRelayAiVaultServiceChild(child)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host'
|
||||
import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner'
|
||||
import { readAiVaultSessionTitlesFromFiles } from '../main/ai-vault/session-title-file-reader'
|
||||
import { createRelayAiVaultFilesystemProvider } from './ai-vault-service-filesystem'
|
||||
import {
|
||||
RELAY_AI_VAULT_SERVICE_PROTOCOL,
|
||||
isRelayAiVaultServiceRequest,
|
||||
relayAiVaultServiceLane,
|
||||
type RelayAiVaultServiceChildMessage,
|
||||
type RelayAiVaultServiceInit,
|
||||
type RelayAiVaultServiceParentMessage,
|
||||
type RelayAiVaultServiceRequest
|
||||
} from './ai-vault-service-protocol'
|
||||
|
||||
if (!process.send) {
|
||||
throw new Error('Relay AI Vault service requires a parent IPC channel.')
|
||||
}
|
||||
|
||||
const controllers = new Map<number, AbortController>()
|
||||
const cancelled = new Set<number>()
|
||||
const pending = new Set<number>()
|
||||
const provider = createRelayAiVaultFilesystemProvider()
|
||||
let init: RelayAiVaultServiceInit | null = null
|
||||
let cacheLane = Promise.resolve()
|
||||
let interactiveLane = Promise.resolve()
|
||||
let shuttingDown = false
|
||||
|
||||
function send(message: RelayAiVaultServiceChildMessage): void {
|
||||
process.send?.(message)
|
||||
}
|
||||
|
||||
async function execute(request: RelayAiVaultServiceRequest): Promise<void> {
|
||||
const controller = new AbortController()
|
||||
controllers.set(request.id, controller)
|
||||
if (cancelled.delete(request.id)) {
|
||||
controller.abort()
|
||||
}
|
||||
try {
|
||||
if (!init) {
|
||||
throw new Error('Relay AI Vault service is not initialized.')
|
||||
}
|
||||
if (request.operation === 'titles') {
|
||||
const value = await readAiVaultSessionTitlesFromFiles(request.requests, {
|
||||
signal: controller.signal
|
||||
})
|
||||
send({ type: 'result', id: request.id, operation: 'titles', value })
|
||||
return
|
||||
}
|
||||
const value = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID,
|
||||
remoteHome: init.remoteHome,
|
||||
hostPlatform: init.hostPlatform,
|
||||
limit: request.params.limit,
|
||||
unlimited: request.params.unlimited,
|
||||
scopePaths: request.params.scopePaths,
|
||||
signal: controller.signal
|
||||
})
|
||||
send({ type: 'result', id: request.id, operation: 'list', value })
|
||||
} catch (error) {
|
||||
send({
|
||||
type: 'error',
|
||||
id: request.id,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
} finally {
|
||||
controllers.delete(request.id)
|
||||
cancelled.delete(request.id)
|
||||
pending.delete(request.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function shutdown(): Promise<void> {
|
||||
if (shuttingDown) {
|
||||
return
|
||||
}
|
||||
shuttingDown = true
|
||||
for (const controller of controllers.values()) {
|
||||
controller.abort()
|
||||
}
|
||||
await Promise.allSettled([cacheLane, interactiveLane])
|
||||
process.disconnect?.()
|
||||
}
|
||||
|
||||
process.on('message', (raw: RelayAiVaultServiceParentMessage) => {
|
||||
if (raw?.type === 'init') {
|
||||
if (init || raw.protocol !== RELAY_AI_VAULT_SERVICE_PROTOCOL) {
|
||||
void shutdown()
|
||||
return
|
||||
}
|
||||
init = raw
|
||||
send({ type: 'ready', protocol: RELAY_AI_VAULT_SERVICE_PROTOCOL, pid: process.pid })
|
||||
return
|
||||
}
|
||||
if (!init || shuttingDown) {
|
||||
return
|
||||
}
|
||||
if (raw?.type === 'cancel') {
|
||||
cancelled.add(raw.id)
|
||||
controllers.get(raw.id)?.abort()
|
||||
return
|
||||
}
|
||||
if (raw?.type === 'shutdown') {
|
||||
void shutdown()
|
||||
return
|
||||
}
|
||||
if (!isRelayAiVaultServiceRequest(raw)) {
|
||||
return
|
||||
}
|
||||
if (pending.size >= 16) {
|
||||
send({ type: 'error', id: raw.id, message: 'Relay AI Vault service queue is full.' })
|
||||
return
|
||||
}
|
||||
pending.add(raw.id)
|
||||
if (relayAiVaultServiceLane(raw.operation) === 'interactive') {
|
||||
interactiveLane = interactiveLane.then(() => execute(raw))
|
||||
return
|
||||
}
|
||||
cacheLane = cacheLane.then(() => execute(raw))
|
||||
})
|
||||
|
||||
process.on('disconnect', () => void shutdown())
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
import { RelayAiVaultServiceClient } from './ai-vault-service-client'
|
||||
import { spawnRelayAiVaultService } from './ai-vault-service-spawn'
|
||||
|
||||
export function createRelayAiVaultService(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform
|
||||
): RelayAiVaultServiceClient {
|
||||
return new RelayAiVaultServiceClient({
|
||||
init: { remoteHome, hostPlatform },
|
||||
processFactory: spawnRelayAiVaultService
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { lstat, readdir } from 'node:fs/promises'
|
||||
import type { RemoteSessionFilesystemProvider } from '../main/ai-vault/remote-session-scanner-types'
|
||||
import { readRelayFileContent } from './fs-handler-file-read'
|
||||
|
||||
export function createRelayAiVaultFilesystemProvider(): RemoteSessionFilesystemProvider {
|
||||
return {
|
||||
async readDir(dirPath) {
|
||||
const entries = await readdir(dirPath, { withFileTypes: true })
|
||||
return entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
isDirectory: entry.isDirectory(),
|
||||
isSymlink: entry.isSymbolicLink()
|
||||
}))
|
||||
},
|
||||
readFile: readRelayFileContent,
|
||||
async stat(filePath) {
|
||||
const stats = await lstat(filePath)
|
||||
return {
|
||||
size: stats.size,
|
||||
type: stats.isDirectory() ? 'directory' : stats.isSymbolicLink() ? 'symlink' : 'file',
|
||||
mtime: stats.mtimeMs,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
dev: stats.dev,
|
||||
ino: stats.ino,
|
||||
nlink: stats.nlink
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { AiVaultListResult } from '../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSessionTitleRequest,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../shared/ai-vault-session-title'
|
||||
import type { SshAiVaultRelayListParams } from '../shared/ssh-ai-vault-relay'
|
||||
import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
|
||||
export const RELAY_AI_VAULT_SERVICE_PROTOCOL = 1
|
||||
|
||||
export type RelayAiVaultServiceInit = {
|
||||
type: 'init'
|
||||
protocol: typeof RELAY_AI_VAULT_SERVICE_PROTOCOL
|
||||
remoteHome: string
|
||||
hostPlatform: RemoteHostPlatform
|
||||
}
|
||||
|
||||
export type RelayAiVaultServiceRequest =
|
||||
| {
|
||||
type: 'request'
|
||||
id: number
|
||||
operation: 'list'
|
||||
params: SshAiVaultRelayListParams
|
||||
}
|
||||
| {
|
||||
type: 'request'
|
||||
id: number
|
||||
operation: 'titles'
|
||||
requests: AiVaultSessionTitleRequest[]
|
||||
}
|
||||
|
||||
export type RelayAiVaultServiceLane = 'cache' | 'interactive'
|
||||
export type RelayAiVaultServiceOperation = RelayAiVaultServiceRequest['operation']
|
||||
|
||||
/** Title reads must not queue behind a full scan; they back interactive UI. */
|
||||
export function relayAiVaultServiceLane(
|
||||
operation: RelayAiVaultServiceOperation
|
||||
): RelayAiVaultServiceLane {
|
||||
return operation === 'titles' ? 'interactive' : 'cache'
|
||||
}
|
||||
|
||||
export type RelayAiVaultServiceParentMessage =
|
||||
| RelayAiVaultServiceInit
|
||||
| RelayAiVaultServiceRequest
|
||||
| { type: 'cancel'; id: number }
|
||||
| { type: 'shutdown' }
|
||||
|
||||
export type RelayAiVaultServiceChildMessage =
|
||||
| {
|
||||
type: 'ready'
|
||||
protocol: typeof RELAY_AI_VAULT_SERVICE_PROTOCOL
|
||||
pid: number
|
||||
}
|
||||
| { type: 'result'; id: number; operation: 'list'; value: AiVaultListResult }
|
||||
| {
|
||||
type: 'result'
|
||||
id: number
|
||||
operation: 'titles'
|
||||
value: AiVaultSessionTitlesResult
|
||||
}
|
||||
| { type: 'error'; id: number; message: string }
|
||||
|
||||
export function isRelayAiVaultServiceRequest(value: unknown): value is RelayAiVaultServiceRequest {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const message = value as Record<string, unknown>
|
||||
return (
|
||||
message.type === 'request' &&
|
||||
Number.isSafeInteger(message.id) &&
|
||||
(message.operation === 'list' || message.operation === 'titles')
|
||||
)
|
||||
}
|
||||
|
||||
export function isRelayAiVaultServiceChildMessage(
|
||||
value: unknown
|
||||
): value is RelayAiVaultServiceChildMessage {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const message = value as Record<string, unknown>
|
||||
if (message.type === 'ready') {
|
||||
return message.protocol === RELAY_AI_VAULT_SERVICE_PROTOCOL && Number.isSafeInteger(message.pid)
|
||||
}
|
||||
return (message.type === 'result' || message.type === 'error') && Number.isSafeInteger(message.id)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RelayAiVaultRestartPolicy } from './ai-vault-service-restart-policy'
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('RelayAiVaultRestartPolicy', () => {
|
||||
it('opens the circuit after three faults inside the window and closes it after', () => {
|
||||
let now = 0
|
||||
const policy = new RelayAiVaultRestartPolicy(() => now)
|
||||
|
||||
expect(policy.startError(false)).toBeNull()
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
policy.recordFault()
|
||||
now += 1_000
|
||||
}
|
||||
|
||||
expect(policy.startError(false)?.message).toContain('circuit is open')
|
||||
now += 60_000
|
||||
expect(policy.startError(false)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the circuit closed when the faults age out of the window', () => {
|
||||
let now = 0
|
||||
const policy = new RelayAiVaultRestartPolicy(() => now)
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
policy.recordFault()
|
||||
now += 30_000
|
||||
}
|
||||
|
||||
expect(policy.startError(false)).toBeNull()
|
||||
})
|
||||
|
||||
it('lets a forced refresh reopen an open circuit', () => {
|
||||
let now = 0
|
||||
const policy = new RelayAiVaultRestartPolicy(() => now)
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
policy.recordFault()
|
||||
}
|
||||
expect(policy.startError(false)?.message).toContain('circuit is open')
|
||||
|
||||
expect(policy.startError(true)).toBeNull()
|
||||
// The forced start clears the circuit outright, so the next background
|
||||
// start is no longer refused either.
|
||||
expect(policy.startError(false)).toBeNull()
|
||||
})
|
||||
|
||||
it('backs off further with each fault and keeps one pending restart', () => {
|
||||
vi.useFakeTimers()
|
||||
const policy = new RelayAiVaultRestartPolicy()
|
||||
const restart = vi.fn()
|
||||
|
||||
policy.recordFault()
|
||||
policy.scheduleRestart(restart)
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(restart).toHaveBeenCalledTimes(1)
|
||||
|
||||
policy.recordFault()
|
||||
policy.scheduleRestart(restart)
|
||||
policy.recordFault()
|
||||
policy.scheduleRestart(restart)
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(restart).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.advanceTimersByTime(5_000)
|
||||
expect(restart).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('cancels the pending restart on dispose', () => {
|
||||
vi.useFakeTimers()
|
||||
const policy = new RelayAiVaultRestartPolicy()
|
||||
const restart = vi.fn()
|
||||
|
||||
policy.recordFault()
|
||||
policy.scheduleRestart(restart)
|
||||
policy.dispose()
|
||||
vi.advanceTimersByTime(10_000)
|
||||
|
||||
expect(restart).not.toHaveBeenCalled()
|
||||
expect(policy.restartScheduled).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
const RELAY_AI_VAULT_FAULT_WINDOW_MS = 60_000
|
||||
const RELAY_AI_VAULT_RESTART_DELAYS_MS = [250, 1_000, 5_000] as const
|
||||
|
||||
export class RelayAiVaultRestartPolicy {
|
||||
private faults: number[] = []
|
||||
private circuitUntil = 0
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
|
||||
constructor(private readonly now: () => number = Date.now) {}
|
||||
|
||||
get restartScheduled(): boolean {
|
||||
return this.timer !== null
|
||||
}
|
||||
|
||||
/** A forced refresh is a deliberate user action, so it reopens the circuit. */
|
||||
startError(forceStart: boolean): Error | null {
|
||||
if (forceStart) {
|
||||
this.circuitUntil = 0
|
||||
return null
|
||||
}
|
||||
return this.now() < this.circuitUntil
|
||||
? new Error('Relay AI Vault service restart circuit is open.')
|
||||
: null
|
||||
}
|
||||
|
||||
recordFault(): void {
|
||||
const now = this.now()
|
||||
this.faults = [
|
||||
...this.faults.filter((time) => now - time < RELAY_AI_VAULT_FAULT_WINDOW_MS),
|
||||
now
|
||||
]
|
||||
if (this.faults.length >= 3) {
|
||||
this.circuitUntil = now + RELAY_AI_VAULT_FAULT_WINDOW_MS
|
||||
}
|
||||
}
|
||||
|
||||
scheduleRestart(restart: () => void): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
}
|
||||
const delay = RELAY_AI_VAULT_RESTART_DELAYS_MS[Math.min(this.faults.length - 1, 2)]
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null
|
||||
restart()
|
||||
}, delay)
|
||||
this.timer.unref?.()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const forkMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('node:child_process', () => ({ fork: forkMock }))
|
||||
|
||||
const { spawnRelayAiVaultService } = await import('./ai-vault-service-spawn')
|
||||
|
||||
function forkOptions(): { env?: NodeJS.ProcessEnv; execArgv?: string[] } {
|
||||
return forkMock.mock.calls.at(-1)?.[2] ?? {}
|
||||
}
|
||||
|
||||
describe('spawnRelayAiVaultService', () => {
|
||||
beforeEach(() => {
|
||||
forkMock.mockReset()
|
||||
forkMock.mockReturnValue({ pid: undefined, unref: vi.fn() } as unknown as ChildProcess)
|
||||
})
|
||||
|
||||
it('keeps NODE_OPTIONS out of the sidecar so the heap cap and loader stand', () => {
|
||||
vi.stubEnv('NODE_OPTIONS', '--max-old-space-size=8192 --require=/tmp/evil.js')
|
||||
spawnRelayAiVaultService()
|
||||
const options = forkOptions()
|
||||
|
||||
// Asserted first: omitting `env` entirely inherits everything, and would
|
||||
// leave the NODE_OPTIONS assertion below passing for the wrong reason.
|
||||
expect(options.env).toBeDefined()
|
||||
expect(options.env?.NODE_OPTIONS).toBeUndefined()
|
||||
expect(options.execArgv).toEqual(['--max-old-space-size=384'])
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('does not hand the sidecar the rest of the remote login environment', () => {
|
||||
vi.stubEnv('AWS_SECRET_ACCESS_KEY', 'shhh')
|
||||
spawnRelayAiVaultService()
|
||||
const options = forkOptions()
|
||||
|
||||
expect(options.env).toBeDefined()
|
||||
expect(options.env?.AWS_SECRET_ACCESS_KEY).toBeUndefined()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { fork, type ChildProcess } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
import { buildRelayAiVaultServiceEnv } from '../main/ai-vault/session-scanner-service-env'
|
||||
import { lowerAiVaultServicePriority } from '../main/ai-vault/session-scanner-service-priority'
|
||||
|
||||
export function relayAiVaultServiceEntryPath(baseDir = __dirname): string {
|
||||
return join(baseDir, 'relay-ai-vault-service.js')
|
||||
}
|
||||
|
||||
export function spawnRelayAiVaultService(): ChildProcess {
|
||||
const child = fork(relayAiVaultServiceEntryPath(), [], {
|
||||
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
||||
execArgv: ['--max-old-space-size=384'],
|
||||
env: buildRelayAiVaultServiceEnv(),
|
||||
...(process.platform === 'win32' ? { windowsHide: true } : {})
|
||||
})
|
||||
lowerAiVaultServicePriority(child.pid)
|
||||
child.unref()
|
||||
return child
|
||||
}
|
||||
+21
-2
@@ -9,6 +9,7 @@
|
||||
// reconnects via `relay.js --connect`, bridging the new SSH channel's stdio to the existing relay's socket.
|
||||
|
||||
import { createServer, createConnection, type Socket, type Server } from 'node:net'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
unlinkSync,
|
||||
@@ -41,6 +42,9 @@ import { PortScanHandler } from './port-scan-handler'
|
||||
import { AgentExecHandler } from './agent-exec-handler'
|
||||
import { WorkspaceSessionHandler } from './workspace-session-handler'
|
||||
import { AiVaultHandler } from './ai-vault-handler'
|
||||
import { createRelayAiVaultService } from './ai-vault-service-factory'
|
||||
import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
import { parseUnameToRelayPlatform } from '../main/ssh/relay-protocol'
|
||||
import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server'
|
||||
import { PluginOverlayManager } from './plugin-overlay'
|
||||
import {
|
||||
@@ -723,7 +727,17 @@ async function main(): Promise<void> {
|
||||
const _workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher)
|
||||
void _workspaceSessionHandler
|
||||
|
||||
const _aiVaultHandler = new AiVaultHandler(dispatcher)
|
||||
const aiVaultRelayPlatform = parseUnameToRelayPlatform(process.platform, process.arch)
|
||||
const aiVaultHostPlatform = aiVaultRelayPlatform
|
||||
? getRemoteHostPlatform(aiVaultRelayPlatform)
|
||||
: undefined
|
||||
const aiVaultService = aiVaultHostPlatform
|
||||
? createRelayAiVaultService(homedir(), aiVaultHostPlatform)
|
||||
: null
|
||||
const _aiVaultHandler = new AiVaultHandler(dispatcher, {
|
||||
hostPlatform: aiVaultHostPlatform,
|
||||
service: aiVaultService ?? undefined
|
||||
})
|
||||
void _aiVaultHandler
|
||||
|
||||
// Why: relay-hosted plugin provisioning is a later phase. Register the
|
||||
@@ -1285,7 +1299,12 @@ async function main(): Promise<void> {
|
||||
graceBranch = null
|
||||
void ptyHandler
|
||||
.dispose()
|
||||
.then(() => {
|
||||
.then(async () => {
|
||||
await aiVaultService?.dispose().catch((error) => {
|
||||
relayLogLine(
|
||||
`[relay] AI Vault sidecar shutdown failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
})
|
||||
stopPoolWatch()
|
||||
stopPoolActiveWatch()
|
||||
dispatcher.dispose()
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('BrowserPagePane render IPC boundary', () => {
|
||||
it('derives toolbar URLs without querying the webview', () => {
|
||||
const source = readFileSync(
|
||||
fileURLToPath(new URL('./BrowserPane.tsx', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const start = source.indexOf('const isBlankTab =', source.indexOf('function BrowserPagePane'))
|
||||
const end = source.indexOf('useEffect(() => {', start)
|
||||
const renderUrlDerivation = source.slice(start, end)
|
||||
|
||||
expect(renderUrlDerivation).toContain('getLiveBrowserUrl(browserTab.id)')
|
||||
expect(renderUrlDerivation).not.toContain('webviewRef')
|
||||
expect(renderUrlDerivation).not.toContain('.getURL(')
|
||||
})
|
||||
})
|
||||
@@ -82,7 +82,7 @@ import {
|
||||
browserViewportPresetToOverride,
|
||||
getBrowserViewportPreset
|
||||
} from '../../../../shared/browser-viewport-presets'
|
||||
import { rememberLiveBrowserUrl } from './browser-runtime'
|
||||
import { getLiveBrowserUrl, rememberLiveBrowserUrl, seedLiveBrowserUrl } from './browser-runtime'
|
||||
import { ensureBrowserPageWebview } from './browser-page-webview'
|
||||
import { RemoteBrowserStreamLifecycle } from './remote-browser-stream-lifecycle'
|
||||
import { isRemoteBrowserPageMissingError } from './remote-browser-stream-errors'
|
||||
@@ -688,35 +688,10 @@ function getRemoteBrowserDeviceScaleFactor(): number {
|
||||
return Math.min(2, Math.max(1, Number(scale.toFixed(2))))
|
||||
}
|
||||
|
||||
function getOpenableExternalUrl(
|
||||
webview: Electron.WebviewTag | null,
|
||||
fallbackUrl: string
|
||||
): string | null {
|
||||
let currentUrl = fallbackUrl
|
||||
if (webview) {
|
||||
try {
|
||||
currentUrl = webview.getURL() || fallbackUrl
|
||||
} catch {
|
||||
// Why: querying nav state before dom-ready throws and blanks the whole IDE on launch; fall back to the persisted URL.
|
||||
currentUrl = fallbackUrl
|
||||
}
|
||||
}
|
||||
function getOpenableExternalUrl(currentUrl: string): string | null {
|
||||
return normalizeExternalBrowserUrl(redactKagiSessionToken(currentUrl))
|
||||
}
|
||||
|
||||
function getCurrentBrowserUrl(webview: Electron.WebviewTag | null, fallbackUrl: string): string {
|
||||
let currentUrl = fallbackUrl
|
||||
if (webview) {
|
||||
try {
|
||||
currentUrl = webview.getURL() || fallbackUrl
|
||||
} catch {
|
||||
// Why: toolbar actions need a stable URL during early guest attach/restore; fall back to the persisted URL instead of throwing.
|
||||
currentUrl = fallbackUrl
|
||||
}
|
||||
}
|
||||
return toDisplayUrl(currentUrl)
|
||||
}
|
||||
|
||||
function retryBrowserTabLoad(
|
||||
webview: Electron.WebviewTag | null,
|
||||
browserTab: BrowserPageState,
|
||||
@@ -3430,6 +3405,7 @@ function BrowserPagePane({
|
||||
container = ensuredWebview.container
|
||||
const webview = ensuredWebview.webview
|
||||
const needsInitialNavigation = ensuredWebview.created
|
||||
seedLiveBrowserUrl(browserTab.id, redactKagiSessionToken(browserTabUrlRef.current))
|
||||
|
||||
if (!ensuredWebview.created) {
|
||||
// pointerEvents already applied inside ensureBrowserPageWebview for the reused-webview path.
|
||||
@@ -4556,8 +4532,10 @@ function BrowserPagePane({
|
||||
|
||||
// Why: a blank tab reads as 'about:blank' or the resolved data: URL, so match both to keep the "New Browser Tab" overlay visible.
|
||||
const isBlankTab = browserTab.url === 'about:blank' || browserTab.url === ORCA_BROWSER_BLANK_URL
|
||||
const externalUrl = getOpenableExternalUrl(webviewRef.current, browserTab.url)
|
||||
const currentBrowserUrl = getCurrentBrowserUrl(webviewRef.current, browserTab.url)
|
||||
// Why: synchronous webview URL access blocks render; navigation handlers update this cache before their store writes can re-render the pane.
|
||||
const liveBrowserUrl = getLiveBrowserUrl(browserTab.id) ?? browserTab.url
|
||||
const externalUrl = getOpenableExternalUrl(liveBrowserUrl)
|
||||
const currentBrowserUrl = toDisplayUrl(liveBrowserUrl)
|
||||
const shareableArtifactFile =
|
||||
workspaceConnectionId === null ? getShareableBrowserArtifactFile(currentBrowserUrl) : null
|
||||
const failedNavigationUrl = browserTab.loadError?.validatedUrl ?? currentBrowserUrl
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { clearLiveBrowserUrl, getLiveBrowserUrl, rememberLiveBrowserUrl } from './browser-runtime'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
clearLiveBrowserUrl,
|
||||
getLiveBrowserUrl,
|
||||
rememberLiveBrowserUrl,
|
||||
seedLiveBrowserUrl
|
||||
} from './browser-runtime'
|
||||
|
||||
describe('browser runtime live URL cache', () => {
|
||||
beforeEach(() => {
|
||||
afterEach(() => {
|
||||
clearLiveBrowserUrl('page-1')
|
||||
clearLiveBrowserUrl('popup-1')
|
||||
})
|
||||
|
||||
it('remembers and clears the last live URL for a browser page', () => {
|
||||
@@ -15,4 +21,30 @@ describe('browser runtime live URL cache', () => {
|
||||
|
||||
expect(getLiveBrowserUrl('page-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('seeds an initial URL without replacing a committed navigation', () => {
|
||||
seedLiveBrowserUrl('page-1', 'https://initial.example/')
|
||||
rememberLiveBrowserUrl('page-1', 'https://committed.example/')
|
||||
seedLiveBrowserUrl('page-1', 'https://stale-persisted.example/')
|
||||
|
||||
expect(getLiveBrowserUrl('page-1')).toBe('https://committed.example/')
|
||||
})
|
||||
|
||||
it('retains the last committed URL when a later load fails', () => {
|
||||
seedLiveBrowserUrl('page-1', 'https://initial.example/')
|
||||
rememberLiveBrowserUrl('page-1', 'https://committed.example/')
|
||||
// A failure event records its validated URL in loadError, not the live URL cache.
|
||||
|
||||
expect(getLiveBrowserUrl('page-1')).toBe('https://committed.example/')
|
||||
})
|
||||
|
||||
it('keeps popup page URLs independent and clears only the destroyed page', () => {
|
||||
seedLiveBrowserUrl('page-1', 'https://opener.example/')
|
||||
seedLiveBrowserUrl('popup-1', 'https://popup.example/')
|
||||
|
||||
clearLiveBrowserUrl('popup-1')
|
||||
|
||||
expect(getLiveBrowserUrl('page-1')).toBe('https://opener.example/')
|
||||
expect(getLiveBrowserUrl('popup-1')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
const liveBrowserUrlByTabId = new Map<string, string>()
|
||||
|
||||
export function seedLiveBrowserUrl(browserTabId: string, initialUrl: string): void {
|
||||
if (!liveBrowserUrlByTabId.has(browserTabId)) {
|
||||
liveBrowserUrlByTabId.set(browserTabId, initialUrl)
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberLiveBrowserUrl(browserTabId: string, url: string): void {
|
||||
liveBrowserUrlByTabId.set(browserTabId, url)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AiVaultListResult } from '../../../../shared/ai-vault-types'
|
||||
import {
|
||||
markInputQuietSchedulerInput,
|
||||
resetInputQuietSchedulerForTest
|
||||
} from '@/lib/input-quiet-scheduler'
|
||||
import { AiVaultSessionPublicationGate } from './ai-vault-session-publication-gate'
|
||||
|
||||
const FIRST: AiVaultListResult = {
|
||||
sessions: [],
|
||||
issues: [],
|
||||
scannedAt: '2026-08-09T00:00:00.000Z'
|
||||
}
|
||||
const SECOND: AiVaultListResult = {
|
||||
...FIRST,
|
||||
scannedAt: '2026-08-09T00:00:01.000Z'
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
resetInputQuietSchedulerForTest()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('AiVaultSessionPublicationGate', () => {
|
||||
it('publishes immediately when terminal input is already quiet', () => {
|
||||
const apply = vi.fn()
|
||||
|
||||
new AiVaultSessionPublicationGate().publish(FIRST, apply)
|
||||
|
||||
expect(apply).toHaveBeenCalledWith(FIRST)
|
||||
})
|
||||
|
||||
it('retains only the newest result while input is active', () => {
|
||||
const apply = vi.fn()
|
||||
const gate = new AiVaultSessionPublicationGate()
|
||||
markInputQuietSchedulerInput()
|
||||
|
||||
gate.publish(FIRST, apply)
|
||||
gate.publish(SECOND, apply)
|
||||
vi.advanceTimersByTime(101)
|
||||
|
||||
expect(apply).toHaveBeenCalledOnce()
|
||||
expect(apply).toHaveBeenCalledWith(SECOND)
|
||||
})
|
||||
|
||||
it('publishes by one second even when input never becomes quiet', () => {
|
||||
const apply = vi.fn()
|
||||
const gate = new AiVaultSessionPublicationGate()
|
||||
markInputQuietSchedulerInput()
|
||||
gate.publish(FIRST, apply)
|
||||
|
||||
for (let elapsed = 90; elapsed < 1_000; elapsed += 90) {
|
||||
vi.advanceTimersByTime(90)
|
||||
markInputQuietSchedulerInput()
|
||||
}
|
||||
vi.advanceTimersByTime(10)
|
||||
|
||||
expect(apply).toHaveBeenCalledWith(FIRST)
|
||||
})
|
||||
|
||||
it('cancels a pending publication on scope change or unmount', () => {
|
||||
const apply = vi.fn()
|
||||
const gate = new AiVaultSessionPublicationGate()
|
||||
markInputQuietSchedulerInput()
|
||||
gate.publish(FIRST, apply)
|
||||
|
||||
gate.cancel()
|
||||
vi.advanceTimersByTime(1_000)
|
||||
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { startTransition } from 'react'
|
||||
import type { AiVaultListResult } from '../../../../shared/ai-vault-types'
|
||||
import { hasInputBeenQuietFor, scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler'
|
||||
|
||||
const AI_VAULT_PUBLICATION_QUIET_MS = 100
|
||||
const AI_VAULT_PUBLICATION_MAX_WAIT_MS = 1_000
|
||||
|
||||
export class AiVaultSessionPublicationGate {
|
||||
private generation = 0
|
||||
private cancelPending: (() => void) | null = null
|
||||
|
||||
publish(result: AiVaultListResult, apply: (result: AiVaultListResult) => void): void {
|
||||
this.cancel()
|
||||
const generation = this.generation
|
||||
if (hasInputBeenQuietFor(AI_VAULT_PUBLICATION_QUIET_MS)) {
|
||||
startTransition(() => apply(result))
|
||||
return
|
||||
}
|
||||
this.cancelPending = scheduleAfterInputQuiet(
|
||||
() => {
|
||||
if (generation !== this.generation) {
|
||||
return
|
||||
}
|
||||
this.cancelPending = null
|
||||
startTransition(() => apply(result))
|
||||
},
|
||||
{
|
||||
delayMs: 0,
|
||||
quietMs: AI_VAULT_PUBLICATION_QUIET_MS,
|
||||
idleTimeoutMs: AI_VAULT_PUBLICATION_QUIET_MS,
|
||||
maxWaitMs: AI_VAULT_PUBLICATION_MAX_WAIT_MS
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.generation += 1
|
||||
this.cancelPending?.()
|
||||
this.cancelPending = null
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import type { ExecutionHostScope } from '../../../../shared/execution-host'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AiVaultSessionLimit } from './ai-vault-session-limit'
|
||||
import { AiVaultSessionPublicationGate } from './ai-vault-session-publication-gate'
|
||||
import {
|
||||
aiVaultSessionResultCacheKey,
|
||||
cacheAiVaultSessionResult,
|
||||
@@ -53,6 +54,7 @@ export function useAiVaultSessionRefresh(
|
||||
const pendingBackgroundRef = useRef(true)
|
||||
const lastAppliedScanRef = useRef<{ scopeKey: string; scannedAt: string } | null>(null)
|
||||
const mountedRef = useRef(true)
|
||||
const publicationGateRef = useRef(new AiVaultSessionPublicationGate())
|
||||
const scanScopeKey = `${aiVaultSessionResultCacheKey(executionHostScope, scopePaths)}\n${sessionLimit}`
|
||||
const scopePathsRef = useRef<readonly string[]>(scopePaths)
|
||||
scopePathsRef.current = scopePaths
|
||||
@@ -89,8 +91,10 @@ export function useAiVaultSessionRefresh(
|
||||
const scanKey = `${baseKey}\n${selectedLimit}`
|
||||
lastAppliedScanRef.current = { scopeKey: scanKey, scannedAt: cachedResult.scannedAt }
|
||||
setError(null)
|
||||
setScanResult(cachedResult)
|
||||
setSessions(cachedResult.sessions)
|
||||
publicationGateRef.current.publish(cachedResult, (published) => {
|
||||
setScanResult(published)
|
||||
setSessions(published.sessions)
|
||||
})
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
@@ -155,8 +159,12 @@ export function useAiVaultSessionRefresh(
|
||||
result,
|
||||
replaceHostEntries: args.force === true
|
||||
})
|
||||
setScanResult(result)
|
||||
setSessions(result.sessions)
|
||||
publicationGateRef.current.publish(result, (published) => {
|
||||
if (mountedRef.current && scanKey === currentScanScopeKey()) {
|
||||
setScanResult(published)
|
||||
setSessions(published.sessions)
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
// A cancelled scan is not a failure: another caller's forced refresh
|
||||
// preempts the shared scan, and painting its abort would replace the
|
||||
@@ -214,8 +222,10 @@ export function useAiVaultSessionRefresh(
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
const requestToken = requestTokenRef.current
|
||||
const publicationGate = publicationGateRef.current
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
publicationGate.cancel()
|
||||
refreshIdRef.current += 1
|
||||
refreshInFlightRef.current = false
|
||||
void window.api.aiVault.cancelListSessions({
|
||||
@@ -230,6 +240,7 @@ export function useAiVaultSessionRefresh(
|
||||
|
||||
// Panel entry reuses the renderer result first, then the host scan cache.
|
||||
useEffect(() => {
|
||||
publicationGateRef.current.cancel()
|
||||
if (refreshInFlightRef.current) {
|
||||
void window.api.aiVault.cancelListSessions({
|
||||
requestToken: requestTokenRef.current
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { TerminalFontSizeSetting } from './TerminalFontSizeSetting'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, defaultValue: string) => defaultValue
|
||||
}))
|
||||
|
||||
describe('TerminalFontSizeSetting', () => {
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
function renderSetting(terminalFontSize: number, updateSettings = vi.fn()): void {
|
||||
act(() => {
|
||||
root.render(
|
||||
<TerminalFontSizeSetting
|
||||
settings={{ terminalFontSize } as GlobalSettings}
|
||||
updateSettings={updateSettings}
|
||||
forceVisible
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function getInput(): HTMLInputElement {
|
||||
const input = container.querySelector<HTMLInputElement>('input[type="number"]')
|
||||
if (!input) {
|
||||
throw new Error('font size input not found')
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
function getStepperButtons(): { decrement: HTMLButtonElement; increment: HTMLButtonElement } {
|
||||
const [decrement, increment] = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>('button')
|
||||
)
|
||||
if (!decrement || !increment) {
|
||||
throw new Error('stepper buttons not found')
|
||||
}
|
||||
return { decrement, increment }
|
||||
}
|
||||
|
||||
it('keeps the two-digit input compact without native spin buttons', () => {
|
||||
renderSetting(15)
|
||||
|
||||
expect(Array.from(getInput().classList)).toEqual(
|
||||
expect.arrayContaining(['number-input-clean', 'w-14'])
|
||||
)
|
||||
})
|
||||
|
||||
it('steps the font size within the supported range', () => {
|
||||
const updateSettings = vi.fn()
|
||||
renderSetting(15, updateSettings)
|
||||
|
||||
const { decrement, increment } = getStepperButtons()
|
||||
|
||||
act(() => increment.click())
|
||||
expect(updateSettings).toHaveBeenCalledWith({ terminalFontSize: 16 })
|
||||
|
||||
act(() => decrement.click())
|
||||
expect(updateSettings).toHaveBeenCalledWith({ terminalFontSize: 14 })
|
||||
})
|
||||
|
||||
it('disables the steppers at the range bounds', () => {
|
||||
renderSetting(10)
|
||||
expect(getStepperButtons().decrement.disabled).toBe(true)
|
||||
expect(getStepperButtons().increment.disabled).toBe(false)
|
||||
|
||||
renderSetting(24)
|
||||
expect(getStepperButtons().decrement.disabled).toBe(false)
|
||||
expect(getStepperButtons().increment.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -45,6 +45,7 @@ export function TerminalFontSizeSetting({
|
||||
>
|
||||
<Minus className="size-3" />
|
||||
</Button>
|
||||
{/* Why: native spin buttons overlap the value and duplicate the −/+ steppers. */}
|
||||
<Input
|
||||
type="number"
|
||||
min={10}
|
||||
@@ -56,7 +57,7 @@ export function TerminalFontSizeSetting({
|
||||
updateSettings({ terminalFontSize: value })
|
||||
}
|
||||
}}
|
||||
className="w-14 text-center tabular-nums"
|
||||
className="number-input-clean w-14 text-center tabular-nums"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
+14
-8
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const SOURCE = readFileSync(join(__dirname, 'DeleteWorktreeDialog.tsx'), 'utf8')
|
||||
const SOURCE = readFileSync(join(__dirname, 'use-delete-worktree-status-hydration.ts'), 'utf8')
|
||||
|
||||
function sourceBetween(source: string, startPattern: string, endPattern: string): string {
|
||||
const start = source.indexOf(startPattern)
|
||||
@@ -14,15 +14,21 @@ function sourceBetween(source: string, startPattern: string, endPattern: string)
|
||||
|
||||
describe('DeleteWorktreeDialog host-context boundaries', () => {
|
||||
it('preloads git status from the selected worktree owner instead of the focused host', () => {
|
||||
const effect = sourceBetween(
|
||||
SOURCE,
|
||||
'const statusTargets = deleteTargets.filter(',
|
||||
'return () => {'
|
||||
)
|
||||
const effect = sourceBetween(SOURCE, 'const targets = deleteTargets.filter(', 'return () => {')
|
||||
|
||||
expect(effect).toContain('getSettingsForWorktreeRuntimeOwner')
|
||||
expect(effect).toContain('worktreesByRepo: useAppStore.getState().worktreesByRepo')
|
||||
expect(effect).toContain('item.id')
|
||||
expect(effect).not.toContain('settings,\n worktreeId: item.id')
|
||||
expect(effect).toContain('target.id')
|
||||
expect(effect).not.toContain('settings,\n worktreeId: target.id')
|
||||
})
|
||||
|
||||
it('does not restart pending status requests when one target hydrates', () => {
|
||||
const effect = sourceBetween(SOURCE, 'useEffect(() => {', '}, [')
|
||||
|
||||
expect(SOURCE).not.toContain('useAppStore((state) => state.gitStatusByWorktree)')
|
||||
expect(effect).toContain('useAppStore.getState().gitStatusByWorktree')
|
||||
expect(effect).toContain('const controller = new AbortController()')
|
||||
expect(effect).toContain('{ signal: controller.signal }')
|
||||
expect(effect).toContain('controller.abort()')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,7 +43,9 @@ vi.mock('@/store', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/store/selectors', () => ({
|
||||
useAllWorktrees: () => mocks.state.allWorktrees()
|
||||
useAllWorktrees: () => mocks.state.allWorktrees(),
|
||||
getWorktreeMapFromState: () =>
|
||||
new Map(mocks.state.allWorktrees().map((worktree) => [worktree.id, worktree]))
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
@@ -91,7 +93,12 @@ vi.mock('./active-worktree-focus-after-delete', () => ({
|
||||
prepareActiveWorktreeFocusAfterDelete: () => vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./stale-workspace-list-toast', () => ({
|
||||
showWorkspaceListChangedToast: vi.fn()
|
||||
}))
|
||||
|
||||
import { runWorktreeDeletesInParallel } from './delete-worktree-flow'
|
||||
import { showWorkspaceListChangedToast } from './stale-workspace-list-toast'
|
||||
|
||||
function makeWorktree(id: string, path: string): Worktree {
|
||||
return {
|
||||
@@ -153,7 +160,11 @@ describe('DeleteWorktreeDialog lineage copy', () => {
|
||||
it('shows child-delete copy and only a delete-all action when the workspace has children', async () => {
|
||||
const parent = makeWorktree('Parent workspace', '/workspaces/parent')
|
||||
const child = makeWorktree('Child workspace', '/workspaces/child')
|
||||
mocks.state.modalData = { worktreeId: parent.id }
|
||||
mocks.state.modalData = {
|
||||
worktreeId: parent.id,
|
||||
worktreeDeleteIdentities: [{ id: parent.id, instanceId: parent.instanceId }],
|
||||
lineageDeleteIdentities: [child, parent].map(({ id, instanceId }) => ({ id, instanceId }))
|
||||
}
|
||||
mocks.state.allWorktrees.mockReturnValue([parent, child])
|
||||
mocks.state.worktreeLineageById = {
|
||||
[child.id]: makeLineage(child, parent)
|
||||
@@ -295,7 +306,11 @@ describe('DeleteWorktreeDialog lineage copy', () => {
|
||||
it('notifies the dialog caller after a toast force delete succeeds', async () => {
|
||||
const workspace = makeWorktree('Workspace', '/workspaces/workspace')
|
||||
const onDeleted = vi.fn()
|
||||
mocks.state.modalData = { worktreeId: workspace.id, onDeleted }
|
||||
mocks.state.modalData = {
|
||||
worktreeId: workspace.id,
|
||||
worktreeDeleteIdentities: [{ id: workspace.id, instanceId: workspace.instanceId }],
|
||||
onDeleted
|
||||
}
|
||||
mocks.state.allWorktrees.mockReturnValue([workspace])
|
||||
|
||||
const { default: DeleteWorktreeDialog } = await import('./DeleteWorktreeDialog')
|
||||
@@ -318,4 +333,57 @@ describe('DeleteWorktreeDialog lineage copy', () => {
|
||||
|
||||
expect(onDeleted).toHaveBeenCalledWith([workspace.id])
|
||||
})
|
||||
|
||||
it('rejects confirmation when the workspace instance changed after the dialog opened', async () => {
|
||||
const original = makeWorktree('Workspace', '/workspaces/original')
|
||||
const replacement = { ...original, instanceId: 'replacement-instance' }
|
||||
mocks.state.modalData = {
|
||||
worktreeId: original.id,
|
||||
worktreeDeleteIdentities: [{ id: original.id, instanceId: original.instanceId }]
|
||||
}
|
||||
mocks.state.allWorktrees.mockReturnValue([original])
|
||||
|
||||
const { default: DeleteWorktreeDialog } = await import('./DeleteWorktreeDialog')
|
||||
renderToStaticMarkup(<DeleteWorktreeDialog />)
|
||||
mocks.state.allWorktrees.mockReturnValue([replacement])
|
||||
|
||||
const deleteButton = mocks.buttonProps.find((props) => props.variant === 'destructive') as
|
||||
| { onClick?: (event: never) => void }
|
||||
| undefined
|
||||
deleteButton?.onClick?.(undefined as never)
|
||||
|
||||
expect(showWorkspaceListChangedToast).toHaveBeenCalledOnce()
|
||||
expect(mocks.state.closeModal).toHaveBeenCalledOnce()
|
||||
expect(runWorktreeDeletesInParallel).not.toHaveBeenCalled()
|
||||
expect(mocks.state.removeWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects lineage confirmation when a descendant instance changed', async () => {
|
||||
const parent = makeWorktree('Parent workspace', '/workspaces/parent')
|
||||
const child = makeWorktree('Child workspace', '/workspaces/child')
|
||||
const replacement = { ...child, instanceId: 'replacement-instance' }
|
||||
mocks.state.modalData = {
|
||||
worktreeId: parent.id,
|
||||
worktreeDeleteIdentities: [{ id: parent.id, instanceId: parent.instanceId }],
|
||||
lineageDeleteIdentities: [child, parent].map(({ id, instanceId }) => ({ id, instanceId }))
|
||||
}
|
||||
mocks.state.allWorktrees.mockReturnValue([parent, child])
|
||||
mocks.state.worktreeLineageById = {
|
||||
[child.id]: makeLineage(child, parent)
|
||||
}
|
||||
|
||||
const { default: DeleteWorktreeDialog } = await import('./DeleteWorktreeDialog')
|
||||
renderToStaticMarkup(<DeleteWorktreeDialog />)
|
||||
mocks.state.allWorktrees.mockReturnValue([parent, replacement])
|
||||
|
||||
const deleteButton = mocks.buttonProps.find((props) => props.variant === 'destructive') as
|
||||
| { onClick?: () => void }
|
||||
| undefined
|
||||
deleteButton?.onClick?.()
|
||||
|
||||
expect(showWorkspaceListChangedToast).toHaveBeenCalledOnce()
|
||||
expect(mocks.state.closeModal).toHaveBeenCalledOnce()
|
||||
expect(runWorktreeDeletesInParallel).not.toHaveBeenCalled()
|
||||
expect(mocks.state.removeWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,9 +9,6 @@ import {
|
||||
import { useAppStore } from '@/store'
|
||||
import { useAllWorktrees } from '@/store/selectors'
|
||||
import { toast } from 'sonner'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { getRuntimeGitStatus } from '@/runtime/runtime-git-client'
|
||||
import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner'
|
||||
import { runWorktreeDeletesInParallel } from './delete-worktree-flow'
|
||||
import { prepareActiveWorktreeFocusAfterDelete } from './active-worktree-focus-after-delete'
|
||||
import { getWorkspaceDeleteLineage } from './workspace-delete-lineage'
|
||||
@@ -30,6 +27,8 @@ import {
|
||||
isFolderWorkspaceDelete as getIsFolderWorkspaceDelete
|
||||
} from './delete-worktree-dialog-copy'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useDeleteWorktreeStatusHydration } from './use-delete-worktree-status-hydration'
|
||||
import { useConfirmedWorktreeDeleteTargets } from './use-confirmed-worktree-delete-targets'
|
||||
|
||||
const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
|
||||
const activeModal = useAppStore((s) => s.activeModal)
|
||||
@@ -43,9 +42,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
|
||||
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree)
|
||||
const setGitStatus = useAppStore((s) => s.setGitStatus)
|
||||
|
||||
const isOpen = activeModal === 'delete-worktree'
|
||||
const worktreeId = typeof modalData.worktreeId === 'string' ? modalData.worktreeId : ''
|
||||
@@ -58,6 +55,12 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
|
||||
: [],
|
||||
[modalData.worktreeIds, worktreeId]
|
||||
)
|
||||
const { worktreeDeleteIdentities, lineageDeleteIdentities, resolveConfirmedTargets } =
|
||||
useConfirmedWorktreeDeleteTargets({
|
||||
worktreeIdentityData: modalData.worktreeDeleteIdentities,
|
||||
lineageIdentityData: modalData.lineageDeleteIdentities,
|
||||
closeModal
|
||||
})
|
||||
const onDeleted =
|
||||
typeof modalData.onDeleted === 'function'
|
||||
? (modalData.onDeleted as (worktreeIds: string[]) => void)
|
||||
@@ -146,6 +149,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
|
||||
repoMap
|
||||
})
|
||||
}, [deleteStateByWorktreeId, deleteTargets, gitStatusByWorktree, repoMap])
|
||||
useDeleteWorktreeStatusHydration({ isOpen, deleteTargets, repoMap })
|
||||
|
||||
if (!isOpen && dontAskAgain) {
|
||||
// Why: this checkbox is a one-shot dialog intent; reset it as soon as the
|
||||
@@ -170,47 +174,6 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
|
||||
worktrees.length
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return
|
||||
}
|
||||
const statusTargets = deleteTargets.filter(
|
||||
(item) =>
|
||||
!item.isMainWorktree &&
|
||||
!getIsFolderWorkspaceDelete(repoMap, item) &&
|
||||
gitStatusByWorktree[item.id] === undefined
|
||||
)
|
||||
if (statusTargets.length === 0) {
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
for (const item of statusTargets) {
|
||||
void getRuntimeGitStatus({
|
||||
// Why: delete warnings inspect git state for the selected workspace;
|
||||
// a later focused-host switch must not make this preload query another host.
|
||||
settings: getSettingsForWorktreeRuntimeOwner(
|
||||
{ repos, settings, worktreesByRepo: useAppStore.getState().worktreesByRepo },
|
||||
item.id
|
||||
),
|
||||
worktreeId: item.id,
|
||||
worktreePath: item.path,
|
||||
connectionId: getConnectionId(item.id) ?? undefined
|
||||
})
|
||||
.then((status) => {
|
||||
if (!cancelled) {
|
||||
setGitStatus(item.id, status)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort only: delete itself still performs the authoritative
|
||||
// backend check and will surface failures through the normal toast.
|
||||
})
|
||||
}
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [deleteTargets, gitStatusByWorktree, isOpen, repoMap, repos, setGitStatus, settings])
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (open) {
|
||||
@@ -254,6 +217,10 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
|
||||
if (worktreeIds.length === 0) {
|
||||
return
|
||||
}
|
||||
const currentWorktrees = resolveConfirmedTargets(worktreeDeleteIdentities, worktreeIds.length)
|
||||
if (!currentWorktrees) {
|
||||
return
|
||||
}
|
||||
// Why: force-delete is a recovery path taken after a failed first delete.
|
||||
// Saving "don't ask again" from that state would conflate the recovery
|
||||
// action with a broader preference. Only persist the preference on the
|
||||
@@ -303,7 +270,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
|
||||
// Why: this modal is the destructive confirmation for the workspace
|
||||
// folder. Running a non-force remove here just turns dirty files into
|
||||
// a redundant Force Delete toast after the user already confirmed.
|
||||
const deletePromise = runWorktreeDeletesInParallel(worktrees, {
|
||||
const deletePromise = runWorktreeDeletesInParallel(currentWorktrees, {
|
||||
force: true,
|
||||
onForceDeleted: handleForceDeletedFromToast
|
||||
})
|
||||
@@ -326,8 +293,9 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
|
||||
persistDontAskAgainPreference,
|
||||
removeWorktree,
|
||||
worktreeIds.length,
|
||||
worktreeDeleteIdentities,
|
||||
worktreeId,
|
||||
worktrees
|
||||
resolveConfirmedTargets
|
||||
]
|
||||
)
|
||||
|
||||
@@ -335,9 +303,16 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
|
||||
if (lineageDelete.deleteAllTargets.length <= 1) {
|
||||
return
|
||||
}
|
||||
const currentTargets = resolveConfirmedTargets(
|
||||
lineageDeleteIdentities,
|
||||
lineageDelete.deleteAllTargets.length
|
||||
)
|
||||
if (!currentTargets) {
|
||||
return
|
||||
}
|
||||
// Why: the lineage modal confirms every affected workspace up front, so
|
||||
// dirty child workspaces should not create per-workspace force prompts.
|
||||
const deletePromise = runWorktreeDeletesInParallel(lineageDelete.deleteAllTargets, {
|
||||
const deletePromise = runWorktreeDeletesInParallel(currentTargets, {
|
||||
force: true,
|
||||
onForceDeleted: handleForceDeletedFromToast
|
||||
})
|
||||
@@ -349,7 +324,14 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
|
||||
onDeleted?.(deletedIds)
|
||||
}
|
||||
})
|
||||
}, [closeModal, handleForceDeletedFromToast, lineageDelete.deleteAllTargets, onDeleted])
|
||||
}, [
|
||||
closeModal,
|
||||
handleForceDeletedFromToast,
|
||||
lineageDelete.deleteAllTargets.length,
|
||||
lineageDeleteIdentities,
|
||||
onDeleted,
|
||||
resolveConfirmedTargets
|
||||
])
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
|
||||
@@ -40,7 +40,10 @@ import type {
|
||||
WorkspaceStatus,
|
||||
WorkspaceStatusDefinition
|
||||
} from '../../../../shared/types'
|
||||
import { runWorktreeBatchDelete, runWorktreeDelete } from './delete-worktree-flow'
|
||||
import {
|
||||
deferWorktreeContextMenuDeleteIntent,
|
||||
type WorktreeContextMenuDeleteIntent
|
||||
} from './worktree-context-menu-delete-intent'
|
||||
import { runSleepWorktrees } from './sleep-worktree-flow'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor'
|
||||
@@ -62,11 +65,7 @@ import {
|
||||
import { WorkspaceSleepMenuItems } from './WorkspaceSleepMenuItems'
|
||||
import { isEventTargetInsideCurrentTarget } from './worktree-card-dom-events'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
folderWorkspaceKey,
|
||||
parseWorkspaceKey,
|
||||
worktreeWorkspaceKey
|
||||
} from '../../../../shared/workspace-scope'
|
||||
import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
|
||||
type Props = {
|
||||
worktree: Worktree
|
||||
@@ -332,8 +331,6 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
||||
const projectGroups = useAppStore((s) => s.projectGroups)
|
||||
const createProjectGroup = useAppStore((s) => s.createProjectGroup)
|
||||
const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup)
|
||||
const deleteFolderWorkspace = useAppStore((s) => s.deleteFolderWorkspace)
|
||||
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
|
||||
const repo = useRepoById(worktree.repoId)
|
||||
const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id])
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
@@ -674,47 +671,30 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
||||
}, [sleepWorktreesAfterMenuClose, subtreeSleepableWorktrees])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
// Folder mode handled inline because it routes to a different modal;
|
||||
// standard delete delegates to the shared runWorktreeDelete helper.
|
||||
const restoreSidebarPosition = preserveDeleteSiblingPosition(scopeRef.current)
|
||||
scopeRef.current
|
||||
?.closest('[data-worktree-sidebar]')
|
||||
?.dispatchEvent(new Event(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT))
|
||||
setMenuOpenState(false)
|
||||
// Why: Delete can remove the active row and remount the sidebar. Run it
|
||||
// after menu close for the same reason as Sleep above.
|
||||
window.setTimeout(() => {
|
||||
if (isMultiContext) {
|
||||
runWorktreeBatchDelete(batchDeleteWorktrees.map((item) => item.id))
|
||||
restoreSidebarPosition()
|
||||
return
|
||||
}
|
||||
if (folderWorkspaceId) {
|
||||
void deleteFolderWorkspace(folderWorkspaceId).then((deleted) => {
|
||||
if (
|
||||
deleted &&
|
||||
useAppStore.getState().activeWorktreeId === folderWorkspaceKey(folderWorkspaceId)
|
||||
) {
|
||||
setActiveWorktree(null)
|
||||
const intent: WorktreeContextMenuDeleteIntent = isMultiContext
|
||||
? {
|
||||
kind: 'batch',
|
||||
worktrees: batchDeleteWorktrees.map(({ id, instanceId }) => ({ id, instanceId }))
|
||||
}
|
||||
: folderWorkspaceId
|
||||
? { kind: 'folder', folderWorkspaceId }
|
||||
: {
|
||||
kind: 'worktree',
|
||||
worktree: { id: worktree.id, instanceId: worktree.instanceId }
|
||||
}
|
||||
})
|
||||
restoreSidebarPosition()
|
||||
return
|
||||
}
|
||||
// Why delegate to runWorktreeDelete: keeps the delete-vs-project-removal
|
||||
// decision tree (and its rationale) in one place shared with command
|
||||
// surfaces and the memory popover's inline Delete action.
|
||||
runWorktreeDelete(worktree.id)
|
||||
restoreSidebarPosition()
|
||||
}, 50)
|
||||
deferWorktreeContextMenuDeleteIntent(intent, restoreSidebarPosition)
|
||||
setMenuOpenState(false)
|
||||
}, [
|
||||
batchDeleteWorktrees,
|
||||
deleteFolderWorkspace,
|
||||
folderWorkspaceId,
|
||||
isMultiContext,
|
||||
setActiveWorktree,
|
||||
setMenuOpenState,
|
||||
worktree.id
|
||||
worktree.id,
|
||||
worktree.instanceId
|
||||
])
|
||||
|
||||
const handleOpenParent = useCallback(() => {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import type { Repo, Worktree, WorktreeCardProperty } from '../../../../shared/types'
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
@@ -17,6 +18,7 @@ const mockStore = vi.hoisted(() => ({
|
||||
// (React.memo shallow-equal props) does NOT invoke it — which is the claim
|
||||
// under test: order-preserving epoch bumps must not re-render cards.
|
||||
const cardRenderSpy = vi.hoisted(() => vi.fn())
|
||||
const trackSpy = vi.hoisted(() => vi.fn())
|
||||
|
||||
type WorktreeListComponent = React.ComponentType<{
|
||||
scrollOffsetRef: React.RefObject<number>
|
||||
@@ -96,6 +98,8 @@ vi.mock('@/lib/sidebar-worktree-activation', () => ({
|
||||
activateWorktreeFromSidebar: mockStore.activateWorktreeFromSidebar
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/telemetry', () => ({ track: trackSpy }))
|
||||
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: vi.fn()
|
||||
}))
|
||||
@@ -320,4 +324,57 @@ describe('WorktreeCard memo bail-out across epoch bumps', () => {
|
||||
expect(cardRenderSpy.mock.calls.length).toBeGreaterThan(countAfterMount)
|
||||
expect(cardRenderSpy).toHaveBeenCalledWith('wt-a')
|
||||
})
|
||||
|
||||
it('tracks Smart attention changes that preserve the displayed order', async () => {
|
||||
mockStore.state = { ...mockStore.state, sortBy: 'smart' }
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
mountedRoots.push(root)
|
||||
|
||||
await renderList(root)
|
||||
const renderedOrder = (): (string | null)[] =>
|
||||
[...container.querySelectorAll('[data-mock-worktree-card]')].map((card) =>
|
||||
card.getAttribute('data-mock-worktree-card')
|
||||
)
|
||||
expect(renderedOrder()).toEqual(['wt-a', 'wt-b'])
|
||||
expect(trackSpy).not.toHaveBeenCalledWith('smart_sort_class_distribution', expect.anything())
|
||||
|
||||
const now = Date.now()
|
||||
const paneKey = 'tab-a:11111111-1111-4111-8111-111111111111'
|
||||
const working: AgentStatusEntry = {
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
updatedAt: now,
|
||||
stateStartedAt: now,
|
||||
agentType: 'codex',
|
||||
paneKey,
|
||||
worktreeId: 'wt-a',
|
||||
stateHistory: []
|
||||
}
|
||||
mockStore.state = {
|
||||
...mockStore.state,
|
||||
agentStatusByPaneKey: { [paneKey]: working },
|
||||
repos: [...(mockStore.state.repos as Repo[])]
|
||||
}
|
||||
await renderList(root)
|
||||
|
||||
expect(renderedOrder()).toEqual(['wt-a', 'wt-b'])
|
||||
expect(trackSpy).toHaveBeenCalledWith(
|
||||
'smart_sort_class_distribution',
|
||||
expect.objectContaining({ class_3: 1, total_worktrees: 2 })
|
||||
)
|
||||
|
||||
mockStore.state = {
|
||||
...mockStore.state,
|
||||
agentStatusByPaneKey: {
|
||||
[paneKey]: { ...working, state: 'blocked', stateStartedAt: now + 1, updatedAt: now + 1 }
|
||||
},
|
||||
repos: [...(mockStore.state.repos as Repo[])]
|
||||
}
|
||||
await renderList(root)
|
||||
|
||||
expect(renderedOrder()).toEqual(['wt-a', 'wt-b'])
|
||||
expect(trackSpy).toHaveBeenCalledWith('smart_sort_class_1_promotion', { cause: 'blocked' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5355,7 +5355,7 @@ const WorktreeList = React.memo(function WorktreeList({
|
||||
// Why a ref alongside the memo: telemetry effects need the last attention map without re-reading store state.
|
||||
const lastAttentionByWorktreeRef = useRef<Map<string, WorktreeAttention> | null>(null)
|
||||
|
||||
const sortedIds = useMemo(() => {
|
||||
const recomputedSortedIds = useMemo(() => {
|
||||
const state = useAppStore.getState()
|
||||
const nonArchivedWorktrees = getAllWorktreesFromState(state).filter(
|
||||
(worktree) => !worktree.isArchived
|
||||
@@ -5403,6 +5403,8 @@ const WorktreeList = React.memo(function WorktreeList({
|
||||
// debouncedSortEpoch is an intentional trigger not read in the memo; its change (debounced) signals a recompute.
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSortEpoch, repoMap, sortBy])
|
||||
// Why: stable ID order prevents rank-only refreshes from echoing an unchanged snapshot.
|
||||
const sortedIds = useReusedArrayIdentity(recomputedSortedIds)
|
||||
|
||||
// Why a ref of prior class: fire class_1_promotion only on transitions into Class 1, not every recompute that stays there.
|
||||
const prevClassByWorktreeIdRef = useRef<Map<string, SmartClass>>(new Map())
|
||||
@@ -5428,9 +5430,9 @@ const WorktreeList = React.memo(function WorktreeList({
|
||||
}
|
||||
prevClassByWorktreeIdRef.current = next
|
||||
hasObservedSmartOnceRef.current = true
|
||||
}, [sortBy, sortedIds])
|
||||
}, [sortBy, recomputedSortedIds])
|
||||
|
||||
// Why retry on sortedIds: Smart may activate before attention hydrates; fire once, then stay quiet until the user leaves Smart.
|
||||
// Why retry on recomputation: Smart may activate before attention hydrates; fire once, then stay quiet until the user leaves Smart.
|
||||
const hasTrackedSmartDistributionRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (sortBy !== 'smart') {
|
||||
@@ -5467,7 +5469,7 @@ const WorktreeList = React.memo(function WorktreeList({
|
||||
total_worktrees: attention.size
|
||||
})
|
||||
hasTrackedSmartDistributionRef.current = true
|
||||
}, [sortBy, sortedIds])
|
||||
}, [sortBy, recomputedSortedIds])
|
||||
|
||||
// Why fire on the transition: switching away from Smart is the signal; compare via ref so a round-trip doesn't double-fire.
|
||||
const prevSortByRef = useRef(sortBy)
|
||||
|
||||
@@ -74,7 +74,11 @@ vi.mock('./delete-worktree-failure-toast', () => ({
|
||||
|
||||
import { toast } from 'sonner'
|
||||
import { showDeleteWorktreeFailureToast } from './delete-worktree-failure-toast'
|
||||
import { runWorktreeBatchDelete, runWorktreeDelete } from './delete-worktree-flow'
|
||||
import {
|
||||
runWorktreeBatchDelete,
|
||||
runWorktreeDelete,
|
||||
runWorktreeDeletesInParallel
|
||||
} from './delete-worktree-flow'
|
||||
|
||||
function setWorktrees(
|
||||
worktrees: {
|
||||
@@ -101,7 +105,7 @@ function setWorktrees(
|
||||
)
|
||||
}
|
||||
|
||||
describe('runWorktreeBatchDelete', () => {
|
||||
describe('delete worktree flow', () => {
|
||||
beforeEach(() => {
|
||||
mocks.state.settings = { skipDeleteWorktreeConfirm: false }
|
||||
mocks.state.clearWorktreeDeleteState.mockClear()
|
||||
@@ -129,6 +133,10 @@ describe('runWorktreeBatchDelete', () => {
|
||||
expect(mocks.state.clearWorktreeDeleteState).not.toHaveBeenCalledWith('main')
|
||||
expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', {
|
||||
worktreeIds: ['wt-1', 'wt-2'],
|
||||
worktreeDeleteIdentities: [
|
||||
{ id: 'wt-1', instanceId: 'wt-1-instance' },
|
||||
{ id: 'wt-2', instanceId: 'wt-2-instance' }
|
||||
],
|
||||
allowSkipConfirm: false
|
||||
})
|
||||
})
|
||||
@@ -139,7 +147,10 @@ describe('runWorktreeBatchDelete', () => {
|
||||
const started = runWorktreeBatchDelete(['main', 'wt-1'])
|
||||
|
||||
expect(started).toBe(true)
|
||||
expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { worktreeId: 'wt-1' })
|
||||
expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', {
|
||||
worktreeId: 'wt-1',
|
||||
worktreeDeleteIdentities: [{ id: 'wt-1', instanceId: 'wt-1-instance' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('treats duplicate selected ids as one delete target', () => {
|
||||
@@ -149,7 +160,85 @@ describe('runWorktreeBatchDelete', () => {
|
||||
|
||||
expect(started).toBe(true)
|
||||
expect(mocks.state.clearWorktreeDeleteState).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { worktreeId: 'wt-1' })
|
||||
expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', {
|
||||
worktreeId: 'wt-1',
|
||||
worktreeDeleteIdentities: [{ id: 'wt-1', instanceId: 'wt-1-instance' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects the whole batch when a selected path belongs to a different instance', () => {
|
||||
setWorktrees([
|
||||
{ id: 'wt-1', instanceId: 'instance-1' },
|
||||
{ id: 'wt-2', instanceId: 'instance-2' }
|
||||
])
|
||||
|
||||
const started = runWorktreeBatchDelete([
|
||||
{ id: 'wt-1', instanceId: 'instance-1' },
|
||||
{ id: 'wt-2', instanceId: 'replaced-instance' }
|
||||
])
|
||||
|
||||
expect(started).toBe(false)
|
||||
expect(mocks.state.clearWorktreeDeleteState).not.toHaveBeenCalled()
|
||||
expect(mocks.state.openModal).not.toHaveBeenCalled()
|
||||
expect(mocks.state.removeWorktree).not.toHaveBeenCalled()
|
||||
expect(toast.info).toHaveBeenCalledWith(
|
||||
'Workspace list changed',
|
||||
expect.objectContaining({
|
||||
description: 'Refresh Space and try again if the workspace list looks stale.'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('opens batch confirmation when every selected instance is still current', () => {
|
||||
setWorktrees([
|
||||
{ id: 'wt-1', instanceId: 'instance-1' },
|
||||
{ id: 'wt-2', instanceId: 'instance-2' }
|
||||
])
|
||||
|
||||
const started = runWorktreeBatchDelete([
|
||||
{ id: 'wt-1', instanceId: 'instance-1' },
|
||||
{ id: 'wt-2', instanceId: 'instance-2' }
|
||||
])
|
||||
|
||||
expect(started).toBe(true)
|
||||
expect(toast.info).not.toHaveBeenCalled()
|
||||
expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', {
|
||||
worktreeIds: ['wt-1', 'wt-2'],
|
||||
worktreeDeleteIdentities: [
|
||||
{ id: 'wt-1', instanceId: 'instance-1' },
|
||||
{ id: 'wt-2', instanceId: 'instance-2' }
|
||||
],
|
||||
allowSkipConfirm: false
|
||||
})
|
||||
})
|
||||
|
||||
it('revalidates each queued instance immediately before execution', async () => {
|
||||
setWorktrees([
|
||||
{ id: 'wt-1', instanceId: 'instance-1', path: '/workspaces/first-longer' },
|
||||
{ id: 'wt-2', instanceId: 'instance-2', path: '/workspaces/second' }
|
||||
])
|
||||
const targets = Array.from(mocks.state.worktreeMap.values())
|
||||
let finishFirst!: (result: { ok: true }) => void
|
||||
mocks.state.removeWorktree.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (finishFirst = resolve))
|
||||
)
|
||||
|
||||
const deletion = runWorktreeDeletesInParallel(targets)
|
||||
await vi.waitFor(() => expect(mocks.state.removeWorktree).toHaveBeenCalledWith('wt-1', false))
|
||||
setWorktrees([
|
||||
{ id: 'wt-1', instanceId: 'instance-1', path: '/workspaces/first-longer' },
|
||||
{ id: 'wt-2', instanceId: 'replacement-instance', path: '/workspaces/second' }
|
||||
])
|
||||
finishFirst({ ok: true })
|
||||
|
||||
await expect(deletion).resolves.toEqual(['wt-1'])
|
||||
expect(mocks.state.removeWorktree).not.toHaveBeenCalledWith('wt-2', false)
|
||||
expect(toast.info).toHaveBeenCalledWith(
|
||||
'Workspace list changed',
|
||||
expect.objectContaining({
|
||||
description: 'Refresh Space and try again if the workspace list looks stale.'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps batch deletes behind confirmation when confirmation is skipped', () => {
|
||||
@@ -166,6 +255,10 @@ describe('runWorktreeBatchDelete', () => {
|
||||
expect(mocks.state.removeWorktree).not.toHaveBeenCalled()
|
||||
expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', {
|
||||
worktreeIds: ['wt-1', 'wt-2'],
|
||||
worktreeDeleteIdentities: [
|
||||
{ id: 'wt-1', instanceId: 'wt-1-instance' },
|
||||
{ id: 'wt-2', instanceId: 'wt-2-instance' }
|
||||
],
|
||||
allowSkipConfirm: false,
|
||||
onDeleted
|
||||
})
|
||||
@@ -271,6 +364,11 @@ describe('runWorktreeBatchDelete', () => {
|
||||
expect(mocks.state.removeWorktree).not.toHaveBeenCalled()
|
||||
expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', {
|
||||
worktreeId: 'parent',
|
||||
worktreeDeleteIdentities: [{ id: 'parent', instanceId: 'parent-instance' }],
|
||||
lineageDeleteIdentities: [
|
||||
{ id: 'child', instanceId: 'child-instance' },
|
||||
{ id: 'parent', instanceId: 'parent-instance' }
|
||||
],
|
||||
allowSkipConfirm: false
|
||||
})
|
||||
})
|
||||
@@ -297,10 +395,80 @@ describe('runWorktreeBatchDelete', () => {
|
||||
expect(mocks.state.removeWorktree).not.toHaveBeenCalled()
|
||||
expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', {
|
||||
worktreeId: 'parent',
|
||||
worktreeDeleteIdentities: [{ id: 'parent', instanceId: 'parent-instance' }],
|
||||
lineageDeleteIdentities: [
|
||||
{ id: 'child', instanceId: 'child-instance' },
|
||||
{ id: 'parent', instanceId: 'parent-instance' }
|
||||
],
|
||||
allowSkipConfirm: false
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a stale list instead of silently dropping a delete whose row vanished', () => {
|
||||
mocks.state.settings = { skipDeleteWorktreeConfirm: true }
|
||||
setWorktrees([])
|
||||
|
||||
runWorktreeDelete('wt-1')
|
||||
|
||||
expect(mocks.state.removeWorktree).not.toHaveBeenCalled()
|
||||
expect(mocks.state.openModal).not.toHaveBeenCalled()
|
||||
expect(mocks.state.clearWorktreeDeleteState).not.toHaveBeenCalled()
|
||||
expect(toast.info).toHaveBeenCalledWith(
|
||||
'Workspace list changed',
|
||||
expect.objectContaining({
|
||||
description: 'Refresh Space and try again if the workspace list looks stale.'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a delayed delete when the path now belongs to a different instance', () => {
|
||||
mocks.state.settings = { skipDeleteWorktreeConfirm: true }
|
||||
setWorktrees([{ id: 'wt-1', instanceId: 'instance-2' }])
|
||||
|
||||
runWorktreeDelete('wt-1', { expectedInstanceId: 'instance-1' })
|
||||
|
||||
expect(mocks.state.removeWorktree).not.toHaveBeenCalled()
|
||||
expect(mocks.state.openModal).not.toHaveBeenCalled()
|
||||
expect(toast.info).toHaveBeenCalledWith(
|
||||
'Workspace list changed',
|
||||
expect.objectContaining({
|
||||
description: 'Refresh Space and try again if the workspace list looks stale.'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('runs a delayed delete when the captured instance is still current', () => {
|
||||
mocks.state.settings = { skipDeleteWorktreeConfirm: true }
|
||||
setWorktrees([{ id: 'wt-1', instanceId: 'instance-1', displayName: 'one' }])
|
||||
|
||||
runWorktreeDelete('wt-1', { expectedInstanceId: 'instance-1' })
|
||||
|
||||
expect(toast.info).not.toHaveBeenCalled()
|
||||
expect(mocks.state.removeWorktree).toHaveBeenCalledWith('wt-1', false)
|
||||
})
|
||||
|
||||
// Why: the delete-current-workspace shortcut (useIpcEvents) forwards whatever workspace is
|
||||
// active, and a folder workspace is never in the worktree map — claiming it vanished would lie.
|
||||
it('stays silent for a folder workspace, which this funnel does not route', () => {
|
||||
mocks.state.settings = { skipDeleteWorktreeConfirm: true }
|
||||
setWorktrees([])
|
||||
|
||||
runWorktreeDelete('folder:11111111-2222-3333-4444-555555555555')
|
||||
|
||||
expect(mocks.state.removeWorktree).not.toHaveBeenCalled()
|
||||
expect(toast.info).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not report a stale list when the workspace is still present', () => {
|
||||
mocks.state.settings = { skipDeleteWorktreeConfirm: true }
|
||||
setWorktrees([{ id: 'wt-1', displayName: 'one' }])
|
||||
|
||||
runWorktreeDelete('wt-1')
|
||||
|
||||
expect(toast.info).not.toHaveBeenCalled()
|
||||
expect(mocks.state.removeWorktree).toHaveBeenCalledWith('wt-1', false)
|
||||
})
|
||||
|
||||
it('opens project removal confirmation for a primary workspace', () => {
|
||||
mocks.state.settings = { skipDeleteWorktreeConfirm: true }
|
||||
setWorktrees([
|
||||
@@ -334,6 +502,7 @@ describe('runWorktreeBatchDelete', () => {
|
||||
expect(mocks.state.removeWorktree).not.toHaveBeenCalled()
|
||||
expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', {
|
||||
worktreeId: 'wt-1',
|
||||
worktreeDeleteIdentities: [{ id: 'wt-1', instanceId: 'wt-1-instance' }],
|
||||
allowSkipConfirm: false,
|
||||
onDeleted
|
||||
})
|
||||
|
||||
@@ -1,221 +1,46 @@
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getAllWorktreesFromState, getWorktreeMapFromState } from '@/store/selectors'
|
||||
import { findRepoForHost } from '@/store/slices/repo-host-identity'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { prepareActiveWorktreeFocusAfterDelete } from './active-worktree-focus-after-delete'
|
||||
import { showDeleteWorktreeFailureToast } from './delete-worktree-failure-toast'
|
||||
import {
|
||||
showNoDeletableWorkspacesToast,
|
||||
showWorkspaceListChangedToast
|
||||
} from './stale-workspace-list-toast'
|
||||
import { getWorkspaceDeleteLineage } from './workspace-delete-lineage'
|
||||
import { resolveSshWorkspaceForget } from './ssh-workspace-forget-resolution'
|
||||
import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome'
|
||||
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import {
|
||||
isPathInsideOrEqual,
|
||||
normalizeRuntimePathForComparison
|
||||
} from '../../../../shared/cross-platform-path'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
resolveWorktreeBatchDeleteTargets,
|
||||
toWorktreeDeleteIdentities,
|
||||
type WorktreeBatchDeleteOptions,
|
||||
type WorktreeDeleteIdentity,
|
||||
type WorktreeDeleteOptions
|
||||
} from './worktree-delete-request'
|
||||
import {
|
||||
runWorktreeDeletesInParallel,
|
||||
runWorktreeDeleteWithToast
|
||||
} from './worktree-delete-execution'
|
||||
|
||||
type WorktreeBatchDeleteOptions = {
|
||||
forceConfirm?: boolean
|
||||
onDeleted?: (worktreeIds: string[]) => void
|
||||
}
|
||||
|
||||
type WorktreeDeleteWithToastOptions = {
|
||||
force?: boolean
|
||||
onForceDeleted?: (worktreeId: string) => void
|
||||
// Why: batch deletes suppress the per-delete focus handoff to focus one survivor after the batch (see runWorktreeDeletesInParallel).
|
||||
focusSuccessorOnDelete?: boolean
|
||||
}
|
||||
|
||||
// Why: a failed delete usually means unresolved changes, so land on the diff panel, not just focus the worktree.
|
||||
function viewWorktreeDiff(worktreeId: string): void {
|
||||
activateAndRevealWorktree(worktreeId)
|
||||
const state = useAppStore.getState()
|
||||
state.setRightSidebarTab('source-control')
|
||||
state.setRightSidebarOpen(true)
|
||||
}
|
||||
|
||||
function isStrictDescendantPath(parentPath: string, childPath: string): boolean {
|
||||
return (
|
||||
normalizeRuntimePathForComparison(parentPath) !==
|
||||
normalizeRuntimePathForComparison(childPath) && isPathInsideOrEqual(parentPath, childPath)
|
||||
)
|
||||
}
|
||||
|
||||
export async function runWorktreeDeletesInParallel(
|
||||
targets: readonly Pick<Worktree, 'id' | 'displayName' | 'repoId' | 'path'>[],
|
||||
options: WorktreeDeleteWithToastOptions = {}
|
||||
): Promise<string[]> {
|
||||
// Why: refresh races can leave duplicate rows, but a destructive command must run once per identity.
|
||||
const uniqueTargets = Array.from(new Map(targets.map((target) => [target.id, target])).values())
|
||||
// Why: capture the viewed workspace before any delete so we can focus one survivor after the batch settles, not per delete.
|
||||
const activeWorktreeIdBefore = useAppStore.getState().activeWorktreeId
|
||||
const commitBatchFocus = activeWorktreeIdBefore
|
||||
? prepareActiveWorktreeFocusAfterDelete(activeWorktreeIdBefore)
|
||||
: null
|
||||
// Why: mark every target deleting up front for immediate in-flight feedback, even though deletes serialize per repo.
|
||||
useAppStore.getState().markWorktreesDeleting(uniqueTargets.map((target) => target.id))
|
||||
// Why: worktree remove/prune/branch -D race on shared ref locks; group by repoId to serialize per repo (cross-repo stays parallel).
|
||||
const groups = new Map<string, (typeof uniqueTargets)[number][]>()
|
||||
for (const target of uniqueTargets) {
|
||||
const group = groups.get(target.repoId)
|
||||
if (group) {
|
||||
group.push(target)
|
||||
} else {
|
||||
groups.set(target.repoId, [target])
|
||||
}
|
||||
}
|
||||
for (const group of groups.values()) {
|
||||
// Why: delete nested children first — else the parent delete is rejected while it still contains a registered worktree.
|
||||
group.sort((a, b) => b.path.length - a.path.length)
|
||||
}
|
||||
const groupResults = await Promise.all(
|
||||
Array.from(groups.values()).map(async (group) => {
|
||||
const deletedInGroup: string[] = []
|
||||
const failedInGroup: (typeof group)[number][] = []
|
||||
for (const target of group) {
|
||||
if (failedInGroup.some((failed) => isStrictDescendantPath(target.path, failed.path))) {
|
||||
useAppStore.getState().clearWorktreeDeleteState(target.id)
|
||||
continue
|
||||
}
|
||||
const deleted = await runWorktreeDeleteWithToast(target.id, target.displayName, {
|
||||
...options,
|
||||
focusSuccessorOnDelete: false
|
||||
})
|
||||
if (deleted) {
|
||||
deletedInGroup.push(target.id)
|
||||
} else {
|
||||
// Why: after a descendant delete fails, deleting an ancestor can still remove that child from disk (it lives under the parent).
|
||||
failedInGroup.push(target)
|
||||
}
|
||||
}
|
||||
return deletedInGroup
|
||||
})
|
||||
)
|
||||
const deletedSet = new Set(groupResults.flat())
|
||||
// Why: focus a survivor once after the batch settles — an intermediate focus could spawn a terminal in a to-be-deleted workspace.
|
||||
if (activeWorktreeIdBefore && deletedSet.has(activeWorktreeIdBefore)) {
|
||||
commitBatchFocus?.()
|
||||
}
|
||||
return uniqueTargets.filter((target) => deletedSet.has(target.id)).map((target) => target.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared delete-with-toast flow for both DeleteWorktreeDialog (confirm) and
|
||||
* WorktreeContextMenu (skip-confirm), so both entry points behave identically.
|
||||
*
|
||||
* A renderer-layer helper (not a store action) to keep UI concerns out of the store slice.
|
||||
*/
|
||||
export function runWorktreeDeleteWithToast(
|
||||
worktreeId: string,
|
||||
worktreeName: string,
|
||||
options: WorktreeDeleteWithToastOptions = {}
|
||||
): Promise<boolean> {
|
||||
const removeWorktree = useAppStore.getState().removeWorktree
|
||||
const commitFocus = prepareActiveWorktreeFocusAfterDelete(worktreeId)
|
||||
const focusSuccessor = options.focusSuccessorOnDelete !== false
|
||||
|
||||
return removeWorktree(worktreeId, options.force === true)
|
||||
.then((result) => {
|
||||
if (result.ok) {
|
||||
// Why: keep the user on a live workspace instead of the Landing screen when they delete the one they were viewing.
|
||||
if (focusSuccessor) {
|
||||
commitFocus()
|
||||
}
|
||||
return true
|
||||
}
|
||||
const state = useAppStore.getState().deleteStateByWorktreeId[worktreeId]
|
||||
const canForceDelete = state?.canForceDelete ?? false
|
||||
const hasKnownChanges =
|
||||
(useAppStore.getState().gitStatusByWorktree[worktreeId]?.length ?? 0) > 0
|
||||
showDeleteWorktreeFailureToast({
|
||||
error: result.error,
|
||||
canForceDelete,
|
||||
forceDeleteReason: state?.forceDeleteReason ?? null,
|
||||
lockReason: state?.lockReason ?? null,
|
||||
hasKnownChanges,
|
||||
onViewChanges: () => viewWorktreeDiff(worktreeId),
|
||||
onForceDelete: () => {
|
||||
// Why: recapture at click time — the user may have navigated away while the toast was open, so focus only hands off if still viewed.
|
||||
const commitForceFocus = prepareActiveWorktreeFocusAfterDelete(worktreeId)
|
||||
// Why (#11960): the user clicked Force Delete on a failure toast, so this
|
||||
// retry may waive the PTY-stop proof the first attempt could not satisfy.
|
||||
const forceRemoval = useAppStore
|
||||
.getState()
|
||||
.removeWorktree(worktreeId, true, { allowUnverifiedPtyStop: true })
|
||||
forceRemoval
|
||||
.then((forceResult) => {
|
||||
if (!forceResult.ok) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.sidebar.delete.worktree.flow.4f3876c0f5',
|
||||
'Force delete failed'
|
||||
),
|
||||
{
|
||||
description: forceResult.error,
|
||||
action: {
|
||||
label: translate(
|
||||
'auto.components.sidebar.delete.worktree.flow.7488ed8711',
|
||||
'View'
|
||||
),
|
||||
onClick: () => viewWorktreeDiff(worktreeId)
|
||||
}
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
commitForceFocus()
|
||||
options.onForceDeleted?.(worktreeId)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.sidebar.delete.worktree.flow.ae57cbf6e4',
|
||||
'Failed to delete workspace'
|
||||
),
|
||||
{
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
action: {
|
||||
label: translate(
|
||||
'auto.components.sidebar.delete.worktree.flow.7488ed8711',
|
||||
'View'
|
||||
),
|
||||
onClick: () => viewWorktreeDiff(worktreeId)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
},
|
||||
worktreeId,
|
||||
worktreeName
|
||||
})
|
||||
return false
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.sidebar.delete.worktree.flow.ae57cbf6e4',
|
||||
'Failed to delete workspace'
|
||||
),
|
||||
{
|
||||
description: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
)
|
||||
return false
|
||||
})
|
||||
}
|
||||
export { runWorktreeDeletesInParallel, runWorktreeDeleteWithToast }
|
||||
|
||||
/**
|
||||
* Shared funnel for the standard (non-folder) delete decision tree (WorktreeContextMenu,
|
||||
* MemoryStatusSegment); branches on the `skipDeleteWorktreeConfirm` preference.
|
||||
*
|
||||
* The missing-record guard is defense-in-depth: refuse to act if the record vanished
|
||||
* between render and click (concurrent delete or state reset).
|
||||
* The missing-record and instance guards reject stale actions after concurrent state changes.
|
||||
*/
|
||||
export function runWorktreeDelete(worktreeId: string): void {
|
||||
export function runWorktreeDelete(worktreeId: string, options: WorktreeDeleteOptions = {}): void {
|
||||
const state = useAppStore.getState()
|
||||
const target = getWorktreeMapFromState(state).get(worktreeId) ?? null
|
||||
if (!target) {
|
||||
const instanceChanged =
|
||||
Object.hasOwn(options, 'expectedInstanceId') &&
|
||||
target?.instanceId !== options.expectedInstanceId
|
||||
if (!target || instanceChanged) {
|
||||
// Why: folder workspaces are never in the worktree map — their callers own that route, so a
|
||||
// miss there is a routing gap, not a stale list, and must not claim the workspace is gone.
|
||||
if (parseWorkspaceKey(worktreeId)?.type !== 'folder') {
|
||||
showWorkspaceListChangedToast()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (target.isMainWorktree) {
|
||||
@@ -254,9 +79,12 @@ export function runWorktreeDelete(worktreeId: string): void {
|
||||
return
|
||||
}
|
||||
|
||||
const hasLineageChildren =
|
||||
getWorkspaceDeleteLineage(target, getAllWorktreesFromState(state), state.worktreeLineageById)
|
||||
.descendants.length > 0
|
||||
const deleteLineage = getWorkspaceDeleteLineage(
|
||||
target,
|
||||
getAllWorktreesFromState(state),
|
||||
state.worktreeLineageById
|
||||
)
|
||||
const hasLineageChildren = deleteLineage.descendants.length > 0
|
||||
const skipConfirm = state.settings?.skipDeleteWorktreeConfirm ?? false
|
||||
if (skipConfirm && !hasLineageChildren) {
|
||||
void runWorktreeDeleteWithToast(worktreeId, target.displayName)
|
||||
@@ -264,33 +92,32 @@ export function runWorktreeDelete(worktreeId: string): void {
|
||||
}
|
||||
state.openModal('delete-worktree', {
|
||||
worktreeId,
|
||||
worktreeDeleteIdentities: toWorktreeDeleteIdentities([target]),
|
||||
...(hasLineageChildren
|
||||
? {
|
||||
lineageDeleteIdentities: toWorktreeDeleteIdentities(deleteLineage.deleteAllTargets)
|
||||
}
|
||||
: {}),
|
||||
...(hasLineageChildren ? { allowSkipConfirm: false } : {})
|
||||
})
|
||||
}
|
||||
|
||||
export function runWorktreeBatchDelete(
|
||||
worktreeIds: readonly string[],
|
||||
requestedWorktrees: readonly string[] | readonly WorktreeDeleteIdentity[],
|
||||
options: WorktreeBatchDeleteOptions = {}
|
||||
): boolean {
|
||||
const state = useAppStore.getState()
|
||||
const worktreeMap = getWorktreeMapFromState(state)
|
||||
const targets = Array.from(new Set(worktreeIds))
|
||||
.map((id) => worktreeMap.get(id) ?? null)
|
||||
.filter((worktree): worktree is Worktree => worktree != null && !worktree.isMainWorktree)
|
||||
const targets = resolveWorktreeBatchDeleteTargets(
|
||||
requestedWorktrees,
|
||||
getWorktreeMapFromState(state)
|
||||
)
|
||||
if (!targets) {
|
||||
showWorkspaceListChangedToast()
|
||||
return false
|
||||
}
|
||||
|
||||
if (targets.length === 0) {
|
||||
toast.info(
|
||||
translate(
|
||||
'auto.components.sidebar.delete.worktree.flow.7243145cd6',
|
||||
'No deletable workspaces selected'
|
||||
),
|
||||
{
|
||||
description: translate(
|
||||
'auto.components.sidebar.delete.worktree.flow.b81b4e40ca',
|
||||
'Refresh Space and try again if the workspace list looks stale.'
|
||||
)
|
||||
}
|
||||
)
|
||||
showNoDeletableWorkspacesToast()
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -299,13 +126,15 @@ export function runWorktreeBatchDelete(
|
||||
}
|
||||
|
||||
// Why: bulk cleanup can destroy many directories at once, so batch/Space deletes keep an explicit confirmation step.
|
||||
const singleTargetHasLineageChildren =
|
||||
targets.length === 1 &&
|
||||
getWorkspaceDeleteLineage(
|
||||
targets[0],
|
||||
getAllWorktreesFromState(state),
|
||||
state.worktreeLineageById
|
||||
).descendants.length > 0
|
||||
const singleTargetLineage =
|
||||
targets.length === 1
|
||||
? getWorkspaceDeleteLineage(
|
||||
targets[0],
|
||||
getAllWorktreesFromState(state),
|
||||
state.worktreeLineageById
|
||||
)
|
||||
: null
|
||||
const singleTargetHasLineageChildren = (singleTargetLineage?.descendants.length ?? 0) > 0
|
||||
const skipConfirm =
|
||||
!options.forceConfirm &&
|
||||
targets.length === 1 &&
|
||||
@@ -325,6 +154,14 @@ export function runWorktreeBatchDelete(
|
||||
if (targets.length === 1) {
|
||||
state.openModal('delete-worktree', {
|
||||
worktreeId: targets[0].id,
|
||||
worktreeDeleteIdentities: toWorktreeDeleteIdentities(targets),
|
||||
...(singleTargetHasLineageChildren && singleTargetLineage
|
||||
? {
|
||||
lineageDeleteIdentities: toWorktreeDeleteIdentities(
|
||||
singleTargetLineage.deleteAllTargets
|
||||
)
|
||||
}
|
||||
: {}),
|
||||
...(options.forceConfirm || singleTargetHasLineageChildren
|
||||
? { allowSkipConfirm: false }
|
||||
: {}),
|
||||
@@ -335,6 +172,7 @@ export function runWorktreeBatchDelete(
|
||||
|
||||
state.openModal('delete-worktree', {
|
||||
worktreeIds: targets.map((target) => target.id),
|
||||
worktreeDeleteIdentities: toWorktreeDeleteIdentities(targets),
|
||||
allowSkipConfirm: false,
|
||||
...(options.onDeleted ? { onDeleted: options.onDeleted } : {})
|
||||
})
|
||||
|
||||
@@ -49,6 +49,14 @@ vi.mock('sonner', () => ({
|
||||
import { toast } from 'sonner'
|
||||
import { runWorktreeDeletesInParallel } from './delete-worktree-flow'
|
||||
|
||||
function runDeletesForCurrentWorktrees(
|
||||
targets: Parameters<typeof runWorktreeDeletesInParallel>[0],
|
||||
options?: Parameters<typeof runWorktreeDeletesInParallel>[1]
|
||||
) {
|
||||
mocks.state.worktreeMap = new Map(targets.map((target) => [target.id, target]))
|
||||
return runWorktreeDeletesInParallel(targets, options)
|
||||
}
|
||||
|
||||
function deferredDeleteResult(): {
|
||||
promise: Promise<{ ok: true }>
|
||||
resolve: (value: { ok: true }) => void
|
||||
@@ -65,6 +73,7 @@ describe('runWorktreeDeletesInParallel', () => {
|
||||
mocks.state.removeWorktree.mockClear().mockResolvedValue({ ok: true })
|
||||
mocks.state.clearWorktreeDeleteState.mockClear()
|
||||
mocks.state.markWorktreesDeleting.mockClear()
|
||||
mocks.state.worktreeMap = new Map()
|
||||
mocks.state.deleteStateByWorktreeId = {}
|
||||
vi.mocked(toast.error).mockClear()
|
||||
vi.mocked(toast.info).mockClear()
|
||||
@@ -77,7 +86,7 @@ describe('runWorktreeDeletesInParallel', () => {
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise)
|
||||
|
||||
const deleted = runWorktreeDeletesInParallel([
|
||||
const deleted = runDeletesForCurrentWorktrees([
|
||||
{ id: 'wt-1', displayName: 'one', repoId: 'repo-a', path: '/workspaces/one' },
|
||||
{ id: 'wt-2', displayName: 'two', repoId: 'repo-b', path: '/workspaces/two' }
|
||||
])
|
||||
@@ -98,7 +107,7 @@ describe('runWorktreeDeletesInParallel', () => {
|
||||
const childDelete = deferredDeleteResult()
|
||||
mocks.state.removeWorktree.mockReturnValueOnce(childDelete.promise)
|
||||
|
||||
const deleted = runWorktreeDeletesInParallel([
|
||||
const deleted = runDeletesForCurrentWorktrees([
|
||||
{ id: 'parent', displayName: 'parent', repoId: 'repo-a', path: '/workspaces/parent' },
|
||||
{ id: 'child', displayName: 'child', repoId: 'repo-a', path: '/workspaces/parent/child' }
|
||||
])
|
||||
@@ -124,7 +133,7 @@ describe('runWorktreeDeletesInParallel', () => {
|
||||
})
|
||||
|
||||
it('deletes nested workspaces before their parent within the same repo', async () => {
|
||||
await runWorktreeDeletesInParallel([
|
||||
await runDeletesForCurrentWorktrees([
|
||||
{ id: 'parent', displayName: 'parent', repoId: 'repo-a', path: '/workspaces/parent' },
|
||||
{ id: 'child', displayName: 'child', repoId: 'repo-a', path: '/workspaces/parent/child' }
|
||||
])
|
||||
@@ -134,7 +143,7 @@ describe('runWorktreeDeletesInParallel', () => {
|
||||
})
|
||||
|
||||
it('passes confirmed force to each delete', async () => {
|
||||
await runWorktreeDeletesInParallel(
|
||||
await runDeletesForCurrentWorktrees(
|
||||
[
|
||||
{ id: 'wt-1', displayName: 'one', repoId: 'repo-a', path: '/workspaces/one' },
|
||||
{ id: 'wt-2', displayName: 'two', repoId: 'repo-b', path: '/workspaces/two' }
|
||||
@@ -157,7 +166,7 @@ describe('runWorktreeDeletesInParallel', () => {
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
.mockResolvedValueOnce({ ok: false, error: 'selector_not_found' })
|
||||
|
||||
await expect(runWorktreeDeletesInParallel([target, target])).resolves.toEqual(['wt-1'])
|
||||
await expect(runDeletesForCurrentWorktrees([target, target])).resolves.toEqual(['wt-1'])
|
||||
|
||||
expect(mocks.state.markWorktreesDeleting).toHaveBeenCalledWith(['wt-1'])
|
||||
expect(mocks.state.removeWorktree).toHaveBeenCalledTimes(1)
|
||||
@@ -176,7 +185,7 @@ describe('runWorktreeDeletesInParallel', () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
runWorktreeDeletesInParallel([
|
||||
runDeletesForCurrentWorktrees([
|
||||
{ id: 'parent', displayName: 'parent', repoId: 'repo-a', path: '/workspaces/parent' },
|
||||
{ id: 'child', displayName: 'child', repoId: 'repo-a', path: '/workspaces/parent/child' }
|
||||
])
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
// Why: keys keep their original `delete.worktree.flow` namespace so the existing
|
||||
// translations are not orphaned by the move out of that module.
|
||||
function staleWorkspaceListToast(title: string): void {
|
||||
toast.info(title, {
|
||||
description: translate(
|
||||
'auto.components.sidebar.delete.worktree.flow.b81b4e40ca',
|
||||
'Refresh Space and try again if the workspace list looks stale.'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** A delete target changed or vanished after the action was selected. */
|
||||
export function showWorkspaceListChangedToast(): void {
|
||||
staleWorkspaceListToast(
|
||||
translate(
|
||||
'auto.components.sidebar.delete.worktree.flow.workspaceListChanged',
|
||||
'Workspace list changed'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** A multi-select delete whose selection resolved to nothing deletable. */
|
||||
export function showNoDeletableWorkspacesToast(): void {
|
||||
staleWorkspaceListToast(
|
||||
translate(
|
||||
'auto.components.sidebar.delete.worktree.flow.7243145cd6',
|
||||
'No deletable workspaces selected'
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getWorktreeMapFromState } from '@/store/selectors'
|
||||
import {
|
||||
readWorktreeDeleteIdentities,
|
||||
resolveWorktreeBatchDeleteTargets,
|
||||
type WorktreeDeleteIdentity
|
||||
} from './worktree-delete-request'
|
||||
import { showWorkspaceListChangedToast } from './stale-workspace-list-toast'
|
||||
|
||||
export function useConfirmedWorktreeDeleteTargets({
|
||||
worktreeIdentityData,
|
||||
lineageIdentityData,
|
||||
closeModal
|
||||
}: {
|
||||
worktreeIdentityData: unknown
|
||||
lineageIdentityData: unknown
|
||||
closeModal: () => void
|
||||
}) {
|
||||
const worktreeDeleteIdentities = useMemo(
|
||||
() => readWorktreeDeleteIdentities(worktreeIdentityData),
|
||||
[worktreeIdentityData]
|
||||
)
|
||||
const lineageDeleteIdentities = useMemo(
|
||||
() => readWorktreeDeleteIdentities(lineageIdentityData),
|
||||
[lineageIdentityData]
|
||||
)
|
||||
const resolveConfirmedTargets = useCallback(
|
||||
(identities: readonly WorktreeDeleteIdentity[], expectedCount: number) => {
|
||||
const targets = resolveWorktreeBatchDeleteTargets(
|
||||
identities,
|
||||
getWorktreeMapFromState(useAppStore.getState())
|
||||
)
|
||||
if (!targets || targets.length !== expectedCount) {
|
||||
showWorkspaceListChangedToast()
|
||||
closeModal()
|
||||
return null
|
||||
}
|
||||
return targets
|
||||
},
|
||||
[closeModal]
|
||||
)
|
||||
|
||||
return { worktreeDeleteIdentities, lineageDeleteIdentities, resolveConfirmedTargets }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner'
|
||||
import { getRuntimeGitStatus } from '@/runtime/runtime-git-client'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import { isFolderWorkspaceDelete } from './delete-worktree-dialog-copy'
|
||||
|
||||
export function useDeleteWorktreeStatusHydration({
|
||||
isOpen,
|
||||
deleteTargets,
|
||||
repoMap
|
||||
}: {
|
||||
isOpen: boolean
|
||||
deleteTargets: readonly Worktree[]
|
||||
repoMap: ReadonlyMap<string, Repo>
|
||||
}): void {
|
||||
const repos = useAppStore((state) => state.repos)
|
||||
const settings = useAppStore((state) => state.settings)
|
||||
const setGitStatus = useAppStore((state) => state.setGitStatus)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return
|
||||
}
|
||||
const gitStatusByWorktree = useAppStore.getState().gitStatusByWorktree
|
||||
const targets = deleteTargets.filter(
|
||||
(target) =>
|
||||
!target.isMainWorktree &&
|
||||
!isFolderWorkspaceDelete(repoMap, target) &&
|
||||
gitStatusByWorktree[target.id] === undefined
|
||||
)
|
||||
const controller = new AbortController()
|
||||
for (const target of targets) {
|
||||
void getRuntimeGitStatus(
|
||||
{
|
||||
settings: getSettingsForWorktreeRuntimeOwner(
|
||||
{ repos, settings, worktreesByRepo: useAppStore.getState().worktreesByRepo },
|
||||
target.id
|
||||
),
|
||||
worktreeId: target.id,
|
||||
worktreePath: target.path,
|
||||
connectionId: getConnectionId(target.id) ?? undefined
|
||||
},
|
||||
{ signal: controller.signal }
|
||||
)
|
||||
.then((status) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setGitStatus(target.id, status)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Best effort only; deletion performs the authoritative backend check.
|
||||
})
|
||||
}
|
||||
return () => {
|
||||
controller.abort()
|
||||
}
|
||||
}, [deleteTargets, isOpen, repoMap, repos, setGitStatus, settings])
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ runDelete: vi.fn(), runBatchDelete: vi.fn() }))
|
||||
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: vi.fn() } }))
|
||||
vi.mock('./delete-worktree-flow', () => ({
|
||||
runWorktreeDelete: mocks.runDelete,
|
||||
runWorktreeBatchDelete: mocks.runBatchDelete
|
||||
}))
|
||||
|
||||
import { deferWorktreeContextMenuDeleteIntent } from './worktree-context-menu-delete-intent'
|
||||
|
||||
describe('deferWorktreeContextMenuDeleteIntent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('dispatches the selected workspace identity after the menu event completes', () => {
|
||||
const defer = vi.fn<(callback: () => void) => void>()
|
||||
const intent = {
|
||||
kind: 'worktree' as const,
|
||||
worktree: { id: 'repo::/work/wt', instanceId: 'instance-1' }
|
||||
}
|
||||
const onDispatched = vi.fn()
|
||||
|
||||
deferWorktreeContextMenuDeleteIntent(intent, onDispatched, defer)
|
||||
|
||||
expect(mocks.runDelete).not.toHaveBeenCalled()
|
||||
expect(onDispatched).not.toHaveBeenCalled()
|
||||
expect(defer).toHaveBeenCalledOnce()
|
||||
|
||||
const [deferred] = defer.mock.calls[0]
|
||||
deferred()
|
||||
|
||||
expect(mocks.runDelete).toHaveBeenCalledWith('repo::/work/wt', {
|
||||
expectedInstanceId: 'instance-1'
|
||||
})
|
||||
expect(onDispatched).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('preserves every selected workspace identity for batch validation', () => {
|
||||
const intent = {
|
||||
kind: 'batch' as const,
|
||||
worktrees: [
|
||||
{ id: 'wt-1', instanceId: 'instance-1' },
|
||||
{ id: 'wt-2', instanceId: 'instance-2' }
|
||||
]
|
||||
}
|
||||
|
||||
deferWorktreeContextMenuDeleteIntent(intent, undefined, (callback) => callback())
|
||||
|
||||
expect(mocks.runBatchDelete).toHaveBeenCalledWith(intent.worktrees)
|
||||
})
|
||||
|
||||
it('dispatches on the next macrotask by default', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('window', { setTimeout })
|
||||
const intent = {
|
||||
kind: 'worktree' as const,
|
||||
worktree: { id: 'wt-1', instanceId: 'instance-1' }
|
||||
}
|
||||
|
||||
deferWorktreeContextMenuDeleteIntent(intent)
|
||||
|
||||
expect(mocks.runDelete).not.toHaveBeenCalled()
|
||||
vi.runAllTimers()
|
||||
expect(mocks.runDelete).toHaveBeenCalledWith('wt-1', {
|
||||
expectedInstanceId: 'instance-1'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { runWorktreeBatchDelete, runWorktreeDelete } from './delete-worktree-flow'
|
||||
import type { WorktreeDeleteIdentity } from './worktree-delete-request'
|
||||
|
||||
export type WorktreeContextMenuDeleteIntent =
|
||||
| { kind: 'worktree'; worktree: WorktreeDeleteIdentity }
|
||||
| { kind: 'batch'; worktrees: readonly WorktreeDeleteIdentity[] }
|
||||
| { kind: 'folder'; folderWorkspaceId: string }
|
||||
|
||||
export function runWorktreeContextMenuDeleteIntent(intent: WorktreeContextMenuDeleteIntent): void {
|
||||
if (intent.kind === 'batch') {
|
||||
runWorktreeBatchDelete(intent.worktrees)
|
||||
return
|
||||
}
|
||||
if (intent.kind === 'worktree') {
|
||||
runWorktreeDelete(intent.worktree.id, { expectedInstanceId: intent.worktree.instanceId })
|
||||
return
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
void state.deleteFolderWorkspace(intent.folderWorkspaceId).then((deleted) => {
|
||||
const current = useAppStore.getState()
|
||||
if (deleted && current.activeWorktreeId === folderWorkspaceKey(intent.folderWorkspaceId)) {
|
||||
current.setActiveWorktree(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function deferWorktreeContextMenuDeleteIntent(
|
||||
intent: WorktreeContextMenuDeleteIntent,
|
||||
onDispatched?: () => void,
|
||||
defer: (callback: () => void) => void = (callback) => window.setTimeout(callback, 0)
|
||||
): void {
|
||||
defer(() => {
|
||||
runWorktreeContextMenuDeleteIntent(intent)
|
||||
onDispatched?.()
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user