Merge origin/main into brennanb2025/sta-3331-chat-file-links

This commit is contained in:
Brennan Benson
2026-08-03 23:30:28 -07:00
556 changed files with 41073 additions and 3879 deletions
+84 -9
View File
@@ -33,6 +33,13 @@ name: Adhoc macOS Dev Build
# Contents: Read and write. The secret names below are historical — one App, one
# private key, both dev-channel repos — and rotating it stays a single operation.
# Provision with `bash config/scripts/setup-hourly-release-token.sh`.
#
# The requested ref is vetted before checkout: it must be a branch or tag of this
# repo, or a commit reachable from one. PR refs are refused outright — this
# workflow runs the checked-out code next to MAC_CERTS and the notary password,
# so "just build that community PR" must not become a way to hand fork code the
# release identity. A branch here always belongs to someone with write access,
# which is the same trust the dispatch button itself already requires.
on:
workflow_dispatch:
@@ -45,7 +52,7 @@ on:
# than the one the workflow file itself is read from — normally leave the
# picker on main and name your branch here, so a stale copy of this
# workflow on an old branch is not what runs.
description: 'Branch, tag, or SHA to build (default: the branch selected above)'
description: 'Branch, tag, or SHA to build — must live in stablyai/orca; PR refs are refused (default: the branch selected above)'
required: false
default: ''
type: string
@@ -77,6 +84,14 @@ env:
jobs:
build-adhoc-mac:
if: github.repository == 'stablyai/orca'
# Why an environment: it gives the signing/notary/App secrets somewhere to
# live that a stale copy of this workflow on an old branch cannot reach.
# Referencing it is a no-op until repo settings give it teeth; the intended
# follow-up is to move MAC_CERTS, MAC_CERTS_PASSWORD, APPLE_ID,
# APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID, HOURLY_RELEASE_APP_ID, and
# HOURLY_RELEASE_APP_PRIVATE_KEY into it, then pin its deployment branch
# policy to main so only main's copy of this file can read them.
environment: adhoc-mac-build
runs-on: blacksmith-6vcpu-macos-15
# Why 150: it must exceed the worst case the retry budgets below can produce
# (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or
@@ -85,6 +100,57 @@ jobs:
env:
NODE_OPTIONS: --max-old-space-size=4096
steps:
# Why vet before checkout: everything after this step runs the checked-out
# code with release signing credentials in reach. Branches and tags of this
# repo are the intended audience; refs/pull/* would smuggle in fork code,
# and a raw SHA is only accepted when some branch or tag of this repo can
# actually reach it. Resolving to a pinned SHA here also means the commit
# that was vetted is the commit that gets checked out — a push to the
# branch between the two steps cannot swap it.
- name: Vet the requested ref
id: vetted
shell: bash
env:
REQUESTED_REF: ${{ inputs.ref || github.ref_name }}
REPO_URL: https://github.com/${{ github.repository }}
run: |
set -euo pipefail
case "$REQUESTED_REF" in
refs/pull/*|pull/*)
echo "::error::Refusing to build PR ref '$REQUESTED_REF': this workflow signs with release credentials, so it only builds branches, tags, or commits of stablyai/orca. Push the code to a branch of this repo instead."
exit 1
;;
esac
# Bare: a work-tree repo refuses to fetch over its own checked-out
# branch. tree:0 keeps the fetch to the commit graph — no trees, no
# blobs — so this stays cheap next to the build it fronts.
scratch="$RUNNER_TEMP/vet-requested-ref"
git init -q --bare "$scratch"
git -C "$scratch" fetch -q --filter=tree:0 "$REPO_URL" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
# Branch first to keep actions/checkout's old tie-break: bare
# rev-parse would prefer the tag when a branch shares its name.
sha=""
for cand in "refs/heads/$REQUESTED_REF" "refs/tags/$REQUESTED_REF" "$REQUESTED_REF"; do
if sha="$(git -C "$scratch" rev-parse --verify --quiet "$cand^{commit}")"; then
break
fi
sha=""
done
if [[ -z "$sha" ]]; then
echo "::error::'$REQUESTED_REF' does not resolve to a branch, tag, or commit of stablyai/orca."
exit 1
fi
# The object resolving locally is not proof a branch or tag reaches
# it: a partial clone can lazily fetch a bare SHA on demand, and
# GitHub serves PR-only commits by SHA. Reachability is the actual
# trust test.
if [[ -z "$(git -C "$scratch" for-each-ref --contains "$sha" refs/heads refs/tags | head -1)" ]]; then
echo "::error::Commit $REQUESTED_REF is not reachable from any branch or tag of stablyai/orca; refusing to build it with release credentials."
exit 1
fi
echo "Vetted $REQUESTED_REF -> $sha"
echo "sha=$sha" >>"$GITHUB_OUTPUT"
- name: Checkout the requested ref
uses: actions/checkout@v6
with:
@@ -92,7 +158,7 @@ jobs:
# build code that has not landed, and the workflow definition itself
# always comes from the dispatch ref — naming the branch here instead
# applies main's current copy of this file to an arbitrary branch.
ref: ${{ inputs.ref || github.ref_name }}
ref: ${{ steps.vetted.outputs.sha }}
fetch-depth: 0
# This job only reads stablyai/orca and never pushes; every write goes
# to the adhoc repo through a minted App token passed by env. Not
@@ -146,17 +212,26 @@ jobs:
env:
REF: ${{ inputs.ref || github.ref_name }}
LABEL: ${{ inputs.label }}
MAIN_REPO_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Why this check: the version script is read from the branch being built,
# Why this check: the version scripts are read from the branch being built,
# not from main, so a branch cut before the adhoc channel landed has no
# copy of it. Say that plainly instead of failing with a module-not-found.
if [[ ! -f config/scripts/adhoc-build-version.mjs ]]; then
echo "::error::$REF has no config/scripts/adhoc-build-version.mjs; rebase it onto a main that has the adhoc channel."
exit 1
fi
# copy of them. Say that plainly instead of failing with a module-not-found.
for script in adhoc-build-version dev-channel-base-version; do
if [[ ! -f "config/scripts/$script.mjs" ]]; then
echo "::error::$REF has no config/scripts/$script.mjs; rebase it onto a main that has the adhoc channel."
exit 1
fi
done
echo "head_sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT"
ORCA_ADHOC_LABEL="${LABEL:-$REF}" node config/scripts/adhoc-build-version.mjs \
# Why the main repo's tags: package.json on a branch is as stale as the
# main it forked from, and stable patches never merge back into it.
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \
--repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \
--json tagName --jq '.[].tagName' || true)"
ORCA_PUBLISHED_VERSIONS="$published" ORCA_ADHOC_LABEL="${LABEL:-$REF}" \
node config/scripts/adhoc-build-version.mjs \
>"$RUNNER_TEMP/adhoc-identity.txt"
if ! grep -q '^name=' "$RUNNER_TEMP/adhoc-identity.txt"; then
echo "::error::adhoc-build-version.mjs emitted no release name; $REF's copy of the script is out of sync with this workflow."
+14 -1
View File
@@ -179,6 +179,7 @@ jobs:
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
MAIN_REPO_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Why read the highest number off existing titles rather than counting
@@ -195,7 +196,19 @@ jobs:
--jq '[.[] | (.name // "") | capture(" • (?<n>[0-9]+) • ")? | .n | tonumber] | max // 0')"
build_number=$(( last_number + 1 ))
echo "Hourly build number $build_number (previous high: $last_number)"
ORCA_HOURLY_BUILD_NUMBER="$build_number" node config/scripts/hourly-build-version.mjs \
# Why the main repo's tags decide the base version rather than
# package.json: main's version only moves on `release:` commits, and
# stable patches are cut from release branches that never merge back, so
# package.json can sit several patches behind what users are running. A
# separate token because GH_TOKEN above is the App's, scoped to the
# hourly repo. Empty on failure — the script then falls back to
# package.json, which is stale but never wrong enough to fail a build.
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \
--repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \
--json tagName --jq '.[].tagName' || true)"
echo "Highest published tag seen: $(head -1 <<<"$published")"
ORCA_PUBLISHED_VERSIONS="$published" \
ORCA_HOURLY_BUILD_NUMBER="$build_number" node config/scripts/hourly-build-version.mjs \
>"$RUNNER_TEMP/hourly-identity.txt"
# Why check rather than trust: the checkout above pins `ref: main`, but a
# workflow_dispatch runs this file from whatever branch was dispatched. A
+1
View File
@@ -100,6 +100,7 @@ docs/**
!docs/reference/headless-linux-server.md
!docs/reference/linux-glibc-compatibility.md
!docs/reference/relay-grace-time-reconfiguration.md
!docs/reference/windows-setup-shell.md
# Stably CLI (only docs/ are tracked)
.stably/*
+1
View File
@@ -29,6 +29,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
- **Keyboard shortcuts**: Never hardcode `e.metaKey`. Use a platform check (`navigator.userAgent.includes('Mac')`) to pick `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`.
- **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms.
- **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`.
- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md).
- **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc.
## SSH Use Case
File diff suppressed because one or more lines are too long
+342 -5
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-08-01",
"updatedAt": "2026-08-03",
"policy": {
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
"blockingPromotion": {
@@ -10,6 +10,101 @@
}
},
"gates": [
{
"id": "editor.restored-sibling-owner-reparent",
"title": "Restored sibling tabs migrate filesystem authority before becoming editable",
"maturity": "experimental",
"protection": "partial",
"owner": "editor-runtime",
"layer": "renderer-store-controller-contract",
"surfaces": [
"restored editor tabs",
"editor save and autosave",
"filesystem watches and hot-exit persistence"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "ssh", "paired-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "ssh", "paired-runtime"],
"coverageNotes": "Deterministic store/controller tests cover local, direct-SSH, paired-runtime identity, folder workspaces, queue quiescence, stale route and connection-generation rejection, authoritative active-workspace projection, owner-derived IDs, drafts and editor maps, tab/group placement, exact watch replacement, change/delete/rename correlation, explicit save, autosave, collision refusal without activation reconciliation, provenance, and restart hot-exit persistence. Live headed/headless paired-runtime and post-establish Electron IPC remain uncollected.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/11304",
"https://github.com/stablyai/orca/pull/11369"
],
"invariant": "A restored absolute path owned by a same-host sibling workspace cannot read, save, watch, or persist until old saves drain, the exact route and host generations are revalidated, and one authoritative activation transaction installs the sibling owner, target projection, editor state, reconciliation, and first-activation terminal preparation; collisions and stale routes fail before activation preparation or reconciliation.",
"oracle": "Hold an old-owner save in flight, start migration, then remove the sibling, change a folder root, or reconnect SSH and require owner migration to fail closed after quiescence. On a stable route require one coherent target-workspace projection, first-activation terminal generation preparation, destination authority for explicit save and autosave, exact watch replacement, destination-only change/delete/rename handling, owner-derived state rekeys, and hot-exit restart ownership. Seed an invalid destination tab/group model before a collision and require the target projection to remain byte-identical.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts src/renderer/src/lib/runtime-workspace-file-route.test.ts"
],
"testFiles": [
"src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts",
"src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts",
"src/renderer/src/lib/runtime-workspace-file-route.test.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts",
"assertions": [
"one atomic commit rekeys owner-derived editor, preview, draft, cursor, view, tab, group, active, reveal, focus, provenance, and persistence state",
"active reparenting uses the centralized workspace activation transaction for terminal, browser, pending-creation, explorer, first-activation, and post-commit state",
"source watch unsubscribes once, destination watch subscribes once, and only destination change/delete/rename events reach the file",
"dirty and clean destination collisions fail closed without changing either open session or reconciling destination tabs and groups",
"folder and direct-SSH owners retain exact root, host, target, and connection-generation authority"
]
},
{
"file": "src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts",
"assertions": [
"migration waits for the old save queue and routes later explicit and automatic saves through the destination worktree",
"a removed sibling, changed folder root, or changed direct-SSH generation after quiescence rejects migration without changing ownership"
]
},
{
"file": "src/renderer/src/lib/runtime-workspace-file-route.test.ts",
"assertions": [
"same-host runtime, folder, local, and direct-SSH roots resolve without cross-host fallback"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-03",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts src/renderer/src/lib/runtime-workspace-file-route.test.ts",
"result": "passed",
"durationSeconds": 3,
"summary": "Three deterministic files passed 20 lifecycle, stale-route, activation, ownership, host, collision, persistence, watch, and save tests."
}
],
"runtimeBudget": {
"p95Seconds": 5,
"scope": "focused renderer store/controller contract on a local development runner"
},
"flakeHistory": {
"status": "unknown",
"evidence": "One local deterministic run is recorded; CI and soak history have not started."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The final2 verifier's byte-identical post-quiescence lifecycle scope passed 21/21 on 7890271160 and failed 7 tests on f92db196a1; base e08eba674c, published 1bcfe3bcb0, and revert b3627461ab were structurally red. The permanent collision oracle is in test blob ac62a89c671a0c8f081ebf06736715bd2150ce9f (SHA-256 a61970ace80be1cb56f781aee6f878a8446c6c4527f9eb0cd0c151d8df96bf50). The same byte-identical blob is structurally red on base, published, and revert, behaviorally red on rejected final2 candidate 7890271160 because pre-commit reconciliation removes the stale destination tab, and green on implementation b72a838cdc; the full permanent gate passes 20/20."
},
"performanceBudget": {
"required": true,
"evidence": "Route lookup scans only the already-indexed renderer workspace catalog, while migration is O(open editor files + source/destination tabs/groups) and performs no provider call, Git scan, polling, retry, subprocess, or global fanout beyond the required old/new watch delta."
},
"promotionCriteria": [
"Collect two fresh child-verifier decisions against the correction candidate.",
"Collect headed and headless paired-runtime coverage after the post-establish Electron IPC gap is fixed.",
"Collect physical Windows or WSL path/host evidence and CI soak history."
],
"knownGaps": [
"Headed Electron post-establish IPC, paired headless, reconnect, and physical Windows/WSL journeys are uncollected.",
"Cold many-repo main-process sibling discovery remains a separately measured follow-up.",
"Registered-workspace symlink policy remains inherited and unchanged."
],
"demotionRule": "Keep experimental or demote if migration can race a save, retain source authority, fan out watches, lose dirty/editor state, accept an owner-generation change, merge a collision, or the focused contract flakes without a product or harness bug."
},
{
"id": "terminal-provider.login-session-retirement",
"title": "macOS login-session retirement preserves live daemons through transient rejection bursts",
@@ -1031,6 +1126,126 @@
],
"demotionRule": "Demote if any probe can run before lifecycle ownership, if more than one shared heartbeat timer is armed, if first-socket cleanup exceeds one interval, if later-socket cleanup exceeds two intervals, or if close/error cleanup retains listeners or timers."
},
{
"id": "runtime.streaming-subscription-close-delivery",
"title": "Retiring a runtime transport always tells the renderer its streams closed",
"maturity": "experimental",
"protection": "partial",
"owner": "runtime-platform",
"layer": "runtime-subscription-ipc-contract",
"surfaces": [
"paired remote server",
"runtime environment disconnect and re-pair",
"terminal.multiplex streaming",
"browser screencast streaming",
"parked remote terminal reveal"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["remote-runtime"],
"coverageNotes": "Deterministic main-IPC contract tests cover disconnect-driven close delivery, exactly-once close, per-subscription teardown isolation against a failing socket close and a throwing liveness probe, containment of a throwing renderer send on the unguarded host-close path, and continued suppression of stale payloads from a retired transport. A headed paired-server journey (real Orca host plus a separate paired Orca desktop client) covers hidden-but-mounted reveal, cold-parked reveal, and cold-parked reveal across a disconnect/reconnect. Live Linux and Windows paired-server evidence and real sleep/wake transport loss remain uncollected.",
"motivatingLinks": [
"tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts",
"docs/reference/headless-linux-server.md"
],
"invariant": "Every renderer-held runtime subscription receives exactly one terminal close event when its transport is retired, including when the retirement advanced the transport generation first, and a single failing teardown never abandons that environment's remaining subscriptions nor escapes into the transport that reported the close. Payload frames from a retired transport stay suppressed. A revealed remote terminal therefore reattaches over a live multiplex connection: its buffer restores, typed input reaches the host PTY, the echo paints without a tab flip, and the PTY converges on the revealed pane grid.",
"oracle": "The main IPC contract test subscribes terminal.multiplex through the real handler, disconnects the environment, and asserts the renderer received exactly one {type: close} subscription event. Two isolation tests subscribe a second stream to the same environment and make the first one fail -- in its socket close, and in the liveness probe inside notifyClosed -- then assert the disconnect does not throw, both transports closed, and every close the renderer could still receive was delivered. A third drives a host-initiated close through the transport callback, which is the one notifyClosed call site with no surrounding guard, with a renderer send that throws, and asserts it cannot escape into the WebSocket close handler. A fourth test asserts that after retirement a late response frame is not forwarded and a late transport close does not re-send. The paired-server journey runs three reveal scenarios against one real host and one real paired desktop client, and for each records buffer restore, host-side receipt of the typed marker through an out-of-band host sink file, live paint without a tab flip, and PTY-versus-pane grid convergence.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/runtime-environments.test.ts",
"pnpm exec playwright test tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
],
"testFiles": [
"src/main/ipc/runtime-environments.test.ts",
"tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts"
],
"assertionRefs": [
{
"file": "src/main/ipc/runtime-environments.test.ts",
"assertions": [
"tells the renderer when retiring the transport closes its streaming subscription (the load-bearing repro; red with the fix reverted)",
"retires an environment's remaining subscriptions when one teardown throws (load-bearing; red without per-subscription isolation)",
"contains a throwing renderer send on a host-initiated close (load-bearing; red without the guarded send, and the only coverage of the unguarded notifyClosed call site)",
"retires remaining subscriptions when a liveness probe inside notifyClosed throws (load-bearing; keeps the isolation structural rather than comment-asserted)",
"suppresses stale payloads from a retired transport but never re-sends its close (forward guard on the retained generation gate, not a repro)"
]
},
{
"file": "tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts",
"assertions": [
"a revealed hidden-but-mounted remote terminal restores, accepts input, and paints live",
"a revealed cold-parked remote terminal restores, accepts input, and paints live",
"a cold-parked remote terminal revealed after a runtime disconnect and reconnect restores, accepts input, and paints live",
"the host PTY grid converges on the revealed pane grid in every scenario"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-03",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/runtime-environments.test.ts",
"result": "passed",
"durationSeconds": 0.81,
"summary": "53 runtime environment IPC tests passed, including the new close-delivery, both teardown-isolation, transport-path containment, and stale-payload-suppression contracts."
},
{
"date": "2026-08-03",
"runner": "local",
"platform": "macos",
"command": "pnpm exec playwright test tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"result": "passed",
"durationSeconds": 15.5,
"summary": "All three reveal scenarios restored their buffer, delivered typed input to the host PTY, painted live without a tab flip, and converged the PTY on the 135x60 pane grid. Before the fix the reconnect-parked scenario stayed blank at recoveryState connecting with the PTY stranded at the host 128x60 grid."
},
{
"date": "2026-08-03",
"runner": "local",
"platform": "macos",
"command": "pnpm exec playwright test tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"result": "passed",
"durationSeconds": 15.5,
"summary": "Repeat run on a clean uninstrumented build; all three scenarios green with identical grids."
},
{
"date": "2026-08-03",
"runner": "local",
"platform": "macos",
"command": "pnpm exec playwright test tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"result": "passed",
"durationSeconds": 14.7,
"summary": "Third consecutive pass after the review follow-ups (env save/restore and the hidden-mounted stayed-mounted assertion)."
}
],
"runtimeBudget": {
"p95Seconds": 60,
"scope": "focused runtime environment IPC tests plus one headed paired-server reveal journey"
},
"flakeHistory": {
"status": "unknown",
"evidence": "Deterministic IPC coverage plus the three recorded paired-server journey runs, which passed consecutively on macOS at 14.7-15.5s; the spec also passed once on a shared CI runner. Longer soak history is not yet available."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "On main the paired-server journey reproduced a blank, non-interactive pane after revealing a cold-parked remote terminal across a reconnect: recoveryState stayed connecting, the xterm buffer stayed empty, typed input never reached the host sink, a tab flip did not recover it, and the PTY stayed at the host 128x60 grid. Renderer instrumentation showed the multiplexer reusing a dead subscription (ensureConnected reuse-ready) and never receiving a subscribed event. Reverting the main-side fix also turns the close-delivery IPC test red; with the fix the multiplexer observes handleClose, reconnects fresh, and every scenario passes."
},
"performanceBudget": {
"required": true,
"evidence": "The fix adds one boolean latch and at most one extra IPC send per retired subscription, on a path that already tears the subscription down. It introduces no timer, poll, retry, subprocess, or per-frame work, and the close is deduplicated so a transport-driven close after an environment-wide retirement sends nothing."
},
"promotionCriteria": [
"Collect at least 100 consecutive CI or soak passes or 14 days without an unexplained flake.",
"Collect live Linux and Windows paired-server reveal evidence.",
"Add real sleep/wake and network-loss transport drops alongside the explicit disconnect trigger."
],
"knownGaps": [
"Live paired-server validation currently covers macOS host and client only.",
"The transport drop is an explicit runtime disconnect; real sleep/wake and network partitions are not yet exercised by this gate.",
"Browser screencast subscribers share the fixed contract but have no dedicated reveal journey; the web and mobile clients run parallel transports and are unaffected."
],
"demotionRule": "Demote if a retired runtime transport can leave a renderer subscription without a close event, if a close is delivered more than once, if one failing teardown strands its sibling subscriptions, if payload frames from a retired transport reach the renderer, or if a revealed remote terminal can stay blank or reject input after a reconnect."
},
{
"id": "editor.live-log-append-stability",
"title": "Long live session logs retain their Monaco viewport while appending",
@@ -1695,13 +1910,15 @@
"invariant": "Reconnect, replay, or lifecycle observations from a viewer that negotiated explicit close intent must never kill a live PTY. A capable reasonless close must keep and republish; a legacy paired viewer must retain current-main behavior because its intentional close and cleanup echo are wire-identical. Lifecycle close requires the exact observed publication, terminal, environment, and authoritative liveness, never signals a process, and leaves renderer-owned or partial-split retirement to its owner. Legacy daemon hello and warm reattachment remain non-destructive.",
"oracle": "Start isolated v25 and v26 daemon generations with one capable-viewer PTY and one legacy-viewer PTY per generation in four target worktrees plus an unaddressed control PTY in a fifth worktree. Route them through the production desktop scanner, runtime, RPC dispatcher, renderer-close relay, and daemon router. First issue sequential reasonless closes from an authenticated capable connection and require refusal, snapshot republish, zero shutdown calls, exact process survival, and post-close I/O. Then issue the byte-identical requests without the negotiated capability and require current-main behavior: two ordered immediate shutdowns, session-killed events, and exact root/descendant death, while capable and control PTYs survive. An observer lists all targets before, between, and after while issuing zero closes. Unit contracts also require in-process reasonless refusal, legacy runtime/mobile compatibility, explicit user closes, encrypted client-auth advertisement, and old-server lifecycle calls never to fall back.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/remote-runtime-request-connection.integration.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/main/runtime/rpc/methods/session-tabs-schemas.test.ts src/main/runtime/rpc/e2ee-channel.test.ts src/main/runtime/rpc/e2ee-channel-v2.test.ts src/main/runtime/rpc/mobile-socket-wiring.test.ts src/main/runtime/rpc/runtime-client-capabilities.test.ts src/shared/remote-runtime-client.test.ts src/shared/remote-runtime-request-connection.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/cli/runtime/websocket-transport.test.ts src/renderer/src/web/web-runtime-client.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-close-intent.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/components/terminal/terminal-tab-actions.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-server-kill-attribution.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/remote-runtime-request-connection.integration.test.ts src/main/runtime/rpc/runtime-close-attribution-topology.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/main/runtime/rpc/methods/session-tabs-schemas.test.ts src/main/runtime/rpc/e2ee-channel.test.ts src/main/runtime/rpc/e2ee-channel-v2.test.ts src/main/runtime/rpc/mobile-socket-wiring.test.ts src/main/runtime/rpc/runtime-client-capabilities.test.ts src/shared/remote-runtime-client.test.ts src/shared/remote-runtime-request-connection.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/cli/runtime/websocket-transport.test.ts src/renderer/src/web/web-runtime-client.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-close-intent.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/components/terminal/terminal-tab-actions.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
"pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"pnpm exec playwright test tests/e2e/daemon-generation-legacy-close-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
],
"testFiles": [
"src/main/daemon/daemon-server-kill-attribution.test.ts",
"src/main/runtime/orca-runtime.test.ts",
"src/main/runtime/remote-runtime-request-connection.integration.test.ts",
"src/main/runtime/rpc/runtime-close-attribution-topology.test.ts",
"src/main/runtime/rpc/methods/session-tabs.test.ts",
"src/main/runtime/rpc/methods/session-tabs-schemas.test.ts",
"src/main/runtime/rpc/e2ee-channel.test.ts",
@@ -1723,6 +1940,19 @@
"tests/e2e/daemon-generation-legacy-close-safety.spec.ts"
],
"assertionRefs": [
{
"file": "src/main/daemon/daemon-server-kill-attribution.test.ts",
"assertions": [
"successful and failed daemon kill requests retain the authenticated control-client identity without claiming a failed kill succeeded"
]
},
{
"file": "src/main/runtime/rpc/runtime-close-attribution-topology.test.ts",
"assertions": [
"remote, legacy, reconnect, stale, concurrent, cross-worktree, and unowned close spans retain authoritative runtime, target, requester, decision, and outcome identities without serializing the bearer credential",
"close attribution adds no terminal inventory scan or provider fanout"
]
},
{
"file": "src/main/runtime/remote-runtime-request-connection.integration.test.ts",
"assertions": [
@@ -6511,15 +6741,16 @@
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "daemon", "ssh"],
"coverageNotes": "A deterministic service-state-machine oracle now models a current-contract worker and coordinator whose renderer graph identities disappear across an app/runtime update. It exercises the production verifier with restored PTY and hydrated hook commitments, proves authenticated completion replay across a fresh runtime, explicit takeover, ordinary mail routing, remote-attachment process fencing, retained Task/Dispatch/terminal identity, and unchanged fixture marker bytes, and rejects foreign pane evidence. Other deterministic units cover authority-aware legacy formatting, exact legacy worker identity planning, local worker presentation, retained-output reads after adoption, reveal-failure warnings, stable-pane Run/Dispatch routing, the SSH in-process CLI fallback, and federated non-reveal. Two isolated macOS Electron journeys launch fake Codex workers through the real RPC path and record append-only spawn/interruption ledgers. They assert immediate inactive presentation, one live agent PID, stable PTY/incarnation/tab/leaf/worktree/Task/Dispatch identity, and no interruption after workspace re-entry; the restart journey additionally removes renderer ownership, marks the Dispatch legacy, retains the daemon process across an app restart, and proves exact background adoption with readable ACK output and no resume replay. Distinct A/B artifacts plus live SSH, WSL, folder, remote-runtime, Linux, and Windows cutover journeys remain explicit gaps.",
"coverageNotes": "A deterministic service-state-machine oracle now models a current-contract worker and coordinator whose renderer graph identities disappear across an app/runtime update. It exercises the production verifier with restored PTY and hydrated hook commitments, proves authenticated completion replay across a fresh runtime, explicit takeover, ordinary mail routing, remote-attachment process fencing, retained Task/Dispatch/terminal identity, and unchanged fixture marker bytes, and rejects foreign pane evidence. Other deterministic units cover authority-aware legacy formatting, exact legacy worker identity planning, local worker presentation, retained-output reads after adoption, reveal-failure warnings, stable-pane Run/Dispatch routing, creator pane/process/Run-generation fencing, indexed retained-Run lookup scaling, the SSH in-process CLI fallback, and federated non-reveal. Two isolated macOS Electron journeys launch fake Codex workers through the real RPC path and record append-only spawn/interruption ledgers. They assert immediate inactive presentation, one live agent PID, stable PTY/incarnation/tab/leaf/worktree/Task/Dispatch identity, and no interruption after workspace re-entry; the restart journey additionally removes renderer ownership, marks the Dispatch legacy, retains the daemon process across an app restart, and proves exact background adoption with readable ACK output and no resume replay. Distinct A/B artifacts plus live SSH, WSL, folder, remote-runtime, Linux, and Windows cutover journeys remain explicit gaps.",
"motivatingLinks": ["https://github.com/stablyai/orca/pull/11107#discussion_r3663321387"],
"invariant": "Starting a worker in the coordinator's current workspace must materialize one inactive terminal tab before worker-start returns, preserve coordinator focus, and remain exactly once after workspace re-entry. After an app update or restart, an exact live legacy worker must fence automatic provider resume, adopt its original PTY into its original background pane, retain readable output, and clear the resume record without spawning, writing, signalling, interrupting, replacing, or focusing the worker. A current-contract worker whose renderer graph identity is temporarily absent must retain its Dispatch capability and settle exactly once from exact hook-attested handle, pane, and process evidence; otherwise only an exact attested coordinator may take over. An exact existing target workspace must receive a discoverable tab without stealing coordinator focus; if renderer reveal fails, worker-start must expose that the live worker remains background-only. Run and Dispatch checks must resolve through the caller's stable pane identity when a terminal handle is reminted, while a live handle outranks mismatched pane metadata, explicit legacy terminal inspection remains handle-scoped, and remote or headless worker presentation remains background-only.",
"oracle": "Drive Run create, Task create, and worker-start through production Electron runtimes with a deterministic Codex fixture. Require append-only ledgers with one still-live PID and no interruption, a visible inactive worker tab while the coordinator stays active, Run delivery through stable pane identity, and stable PTY/incarnation, tab, leaf, worktree, Task, and Dispatch across workspace re-entry. In a restart journey, retain the original daemon PTY and PID, remove renderer ownership, retain sleeping-session evidence, mark the Dispatch legacy, relaunch, and require exact inactive tab adoption, readable ACK output, cleared resume state, one spawn, and no resume argv or Conversation interrupted text after another workspace round trip. The service oracle removes renderer lookup identity from current-contract callers while retaining real restored-PTY and hook commitments, replays authenticated completion and takeover across fresh runtimes, and requires one Task, Dispatch, terminal authority, message, mutation, ordinary-mail delivery, remote process fencing, and unchanged fixture marker bytes while foreign pane evidence remains rejected. Unit tests separately assert authority-specific legacy affordances, exact identity and owner matching, retained-output fallback, pane-stable routing, federated non-activation, and SSH fallback parity.",
"invariant": "Starting a worker in the coordinator's current workspace must materialize one inactive terminal tab before worker-start returns, preserve coordinator focus, and remain exactly once after workspace re-entry. After an app update or restart, an exact live legacy worker must fence automatic provider resume, adopt its original PTY into its original background pane, retain readable output, and clear the resume record without spawning, writing, signalling, interrupting, replacing, or focusing the worker. A current-contract worker whose renderer graph identity is temporarily absent must retain its Dispatch capability and settle exactly once from exact hook-attested handle, pane, and process evidence; otherwise only an exact attested coordinator may take over. An exact existing target workspace must receive a discoverable tab without stealing coordinator focus; if renderer reveal fails, worker-start must expose that the live worker remains background-only. Run and Dispatch checks must resolve through the caller's stable pane identity when a terminal handle is reminted, while a live handle outranks mismatched pane metadata. A nested worker's creator edge requires the current creator pane, process incarnation, and owning Run generation; reminting and rebinding that pane to another Run must remove the stale edge. Explicit legacy terminal inspection remains handle-scoped, and remote or headless worker presentation remains background-only.",
"oracle": "Drive Run create, Task create, and worker-start through production Electron runtimes with a deterministic Codex fixture. Require append-only ledgers with one still-live PID and no interruption, a visible inactive worker tab while the coordinator stays active, Run delivery through stable pane identity, and stable PTY/incarnation, tab, leaf, worktree, Task, and Dispatch across workspace re-entry. In a restart journey, retain the original daemon PTY and PID, remove renderer ownership, retain sleeping-session evidence, mark the Dispatch legacy, relaunch, and require exact inactive tab adoption, readable ACK output, cleared resume state, one spawn, and no resume argv or Conversation interrupted text after another workspace round trip. The service oracle removes renderer lookup identity from current-contract callers while retaining real restored-PTY and hook commitments, replays authenticated completion and takeover across fresh runtimes, and requires one Task, Dispatch, terminal authority, message, mutation, ordinary-mail delivery, remote process fencing, and unchanged fixture marker bytes while foreign pane evidence remains rejected. Unit tests separately remint a creator pane and process from Run A into Run B, require the nested Run A worker to fall back to its current coordinator, require indexed query plans, and bound 300 Task reads with 50,000 retained Runs. They also assert authority-specific legacy affordances, exact identity and owner matching, retained-output fallback, pane-stable routing, federated non-activation, and SSH fallback parity.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/ssh/ssh-remote-orca-cli.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/formatter.test.ts src/main/runtime/rpc/methods/orchestration-federation.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts",
"pnpm run test:e2e -- tests/e2e/orchestration-worker-terminal-visibility.spec.ts --workers=1",
"pnpm run test:e2e -- tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts --workers=1"
@@ -6528,6 +6759,7 @@
"src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts",
"src/main/runtime/orchestration/formatter.test.ts",
"src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts",
"src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts",
"src/main/runtime/orca-runtime.test.ts",
"src/cli/handlers/orchestration.test.ts",
"src/cli/handlers/orchestration-check-identity.test.ts",
@@ -6549,6 +6781,13 @@
"ordinary mail and remote attachments use the same attested pane and process authority"
]
},
{
"file": "src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts",
"assertions": [
"creator lookup uses the assignee-handle and pane-leaf indexes without a retained-Run scan",
"300 Task reads remain bounded with 50,000 unrelated retained Runs"
]
},
{
"file": "src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts",
"assertions": [
@@ -6733,6 +6972,104 @@
],
"demotionRule": "Keep experimental or demote if either Electron journey flakes without a product or harness defect, if local worker-start can return before tab materialization without an explicit reveal warning, if focus moves to the worker, if restart or workspace re-entry spawns/resumes/duplicates/interferes with the worker, if retained output becomes unreadable, or if pane-stable delivery reads the wrong mailbox."
},
{
"id": "orchestration.settled-worker-terminal-release",
"title": "Settled worker cleanup preserves one exact terminal lease and immutable output",
"maturity": "experimental",
"protection": "partial",
"owner": "orchestration",
"layer": "runtime-sqlite-terminal-lifecycle",
"surfaces": [
"worker-release",
"worker-retain",
"explicit terminal reuse",
"restart reconciliation",
"worker-read archives",
"orchestration reset"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local"],
"coverageNotes": "Deterministic service tests cover release-versus-reuse ordering, transactional retain and takeover cancellation, exact host/pane/process identity, conservative schema-v23 backfill, immutable transcript and bounded terminal archives, mutation restart, reset cleanup, replay idempotency, and 50-resource accounting. Live SSH, WSL, Windows, paired-runtime, and provider-close lost-ack journeys remain explicit gaps.",
"motivatingLinks": ["https://github.com/stablyai/orca/pull/12355", "STA-905"],
"invariant": "A settled Dispatch may close only its one coordinator-created terminal lease. Explicit reuse, real user input, retain, identity or host change, ambiguity, and another resource for the same exact host/pane/process must fence closure. Output preservation and the requested-to-releasing transition are atomic, archives remain readable without the provider file, retries resume idempotently, and orchestration reset removes archive and authority state.",
"oracle": "Record release intent for a settled owner, attempt exact reuse before close, and require worker-start to fail with terminal_release_in_progress while the terminal stays open; then release the original owner exactly once. Race retain and real user input against a controlled archive promise and require no committed archive or close. Change host or process identity and inject duplicate resource evidence to require retention. Freeze a structured transcript, delete its source file, and require archived worker-read to return the same bounded redacted messages. Restart a pending mutation, reset orchestration state, and create 50 resources while asserting replay convergence, zero orphan rows, two-query worker listing, and no unrelated close.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot"
],
"testFiles": [
"src/main/runtime/rpc/methods/orchestration-worker-release.test.ts",
"src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts",
"src/main/runtime/rpc/orchestration-mutation-ledger.test.ts",
"src/main/runtime/orchestration/worker-transcript-read.test.ts",
"src/renderer/src/lib/worker-terminal-takeover-report.test.ts"
],
"assertionRefs": [
{
"file": "src/main/runtime/rpc/methods/orchestration-worker-release.test.ts",
"assertions": [
"rejects exact reuse after release intent instead of closing the new worker",
"lets an explicit retain cancel a release while output capture is pending",
"retains when the terminal host scope changed instead of closing",
"reads an immutable transcript snapshot after the provider file disappears",
"backfills a legacy creator plus explicit reuser as ambiguous",
"removes terminal authority and archived output on orchestration reset"
]
},
{
"file": "src/main/runtime/rpc/orchestration-mutation-ledger.test.ts",
"assertions": ["resumes a pending idempotent worker release after restart"]
},
{
"file": "src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts",
"assertions": [
"finishes a requested release after restart-style interruption",
"coalesces overlapping reconciliation passes and closes each resource once",
"keeps live terminals bounded across 50 settled workers while controls survive"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-03",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 3.48,
"summary": "Five focused files passed 56 tests covering lease serialization, reminted-handle transfer, duplicate-identity fencing, retain and takeover races, immutable archives, conservative legacy migration, mutation restart, reset cleanup, bounded accounting, and renderer input reporting."
}
],
"runtimeBudget": {
"p95Seconds": 8,
"scope": "deterministic runtime, SQLite, transcript, renderer-input, and mutation contracts"
},
"flakeHistory": {
"status": "unknown",
"evidence": "First local deterministic run only; focused CI and soak history are not yet available."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The byte-identical 56-test oracle produced 12 deterministic failures and 44 passes with production restored to exact PR head 4aa66b62e3, including ready reuse during release, unsafe migration, stale archives, mutable transcript output, host/identity close gaps, and mutation replay loss. Candidate 457bc4edd1 passed all 56; restoring the same five production files to 4aa66b62e3 reproduced the same red state."
},
"performanceBudget": {
"required": true,
"evidence": "Release performs constant-count indexed resource and identity queries plus one bounded archive capture. Worker-list uses two set queries rather than one resource lookup per worker. Reconciliation remains serial and adds no polling, timers, subprocesses, renderer subscriptions, or provider-wide listing."
},
"promotionCriteria": [
"Collect 100 consecutive focused CI passes or 14 days of soak history.",
"Add live headed and headless paired-runtime release/reuse journeys.",
"Add Docker SSH reconnect and physical Windows WSL host-scope evidence.",
"Prove provider-close lost acknowledgements converge with an exact durable close receipt."
],
"knownGaps": [
"Provider close has no durable cross-process operation receipt, so a crash after host mutation but before SQLite settlement remains release_pending until exact inventory returns.",
"Federated release remains explicitly unsupported and retained.",
"Mobile/direct remote input takeover and live Linux, Windows, WSL, SSH, headed, and headless paired-runtime release are not exercised."
],
"demotionRule": "Keep experimental or demote if release can overlap exact reuse, close a conflicting lease, lose immutable output, retain reset archives, fan out per worker, or any focused ordering test flakes without a product or harness defect."
},
{
"id": "terminal-render.windows-cjk-repaint",
"title": "Windows ConPTY wide glyphs and cursor rewrites repaint without stale cells",
+12 -3
View File
@@ -2,6 +2,10 @@ import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs'
import {
readPublishedVersionsFromEnv,
resolveDevChannelBaseVersion
} from './dev-channel-base-version.mjs'
/** Long enough to name a feature, short enough that a picker row stays readable. */
export const ADHOC_LABEL_MAX_LENGTH = 32
@@ -83,12 +87,13 @@ export function formatAdhocReleaseName(version, label, commit, date) {
].join(' • ')
}
export function getAdhocBuildIdentity(now = new Date(), label = '') {
export function getAdhocBuildIdentity(now = new Date(), label = '', publishedVersions = []) {
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
encoding: 'utf8'
}).trim()
const version = createAdhocBuildVersion(packageJson.version, now)
const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions)
const version = createAdhocBuildVersion(base, now)
return {
commit,
version,
@@ -98,7 +103,11 @@ export function getAdhocBuildIdentity(now = new Date(), label = '') {
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
const identity = getAdhocBuildIdentity(new Date(), process.env.ORCA_ADHOC_LABEL ?? '')
const identity = getAdhocBuildIdentity(
new Date(),
process.env.ORCA_ADHOC_LABEL ?? '',
readPublishedVersionsFromEnv()
)
// Consumed by the workflow via $GITHUB_OUTPUT.
process.stdout.write(
`version=${identity.version}\ncommit=${identity.commit}\nlabel=${identity.label}\nname=${identity.name}\n`
@@ -1,4 +1,12 @@
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
@@ -31,6 +39,20 @@ describe('Windows CLI launcher', () => {
}
})
itCrossHost('never materializes the child environment block from ProcessStartInfo', () => {
// Why: both ProcessStartInfo env properties copy the process block into a case-insensitive
// dictionary that throws when the inherited block holds PATH and Path (stablyai/orca#12046).
const source = readFileSync(
join(projectRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs'),
'utf8'
)
const code = source.replace(/^\s*\/\/.*$/gm, '')
expect(code).not.toContain('EnvironmentVariables')
expect(code).not.toContain('startInfo.Environment')
expect(code).toContain('Environment.SetEnvironmentVariable')
})
itWindows('preserves a multiline argument from PowerShell through the native launcher', () => {
const appRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher '))
try {
@@ -89,4 +111,70 @@ describe('Windows CLI launcher', () => {
rmSync(appRoot, { recursive: true, force: true })
}
})
itWindows('survives an inherited environment block containing PATH and Path', () => {
const appRoot = mkdtempSync(join(tmpdir(), 'orca duplicate path launcher '))
try {
const resourcesPath = join(appRoot, 'resources')
const launcherPath = join(resourcesPath, 'bin', 'orca.exe')
const cliPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js')
const outputPath = join(appRoot, 'child-result.json')
const harnessSourcePath = join(
projectRoot,
'config',
'scripts',
'fixtures',
'DuplicatePathProcessLauncher.cs'
)
const harnessPath = join(appRoot, 'DuplicatePathLauncher.exe')
mkdirSync(dirname(launcherPath), { recursive: true })
mkdirSync(dirname(cliPath), { recursive: true })
copyFileSync(process.execPath, join(appRoot, 'Orca.exe'))
writeFileSync(
cliPath,
`require('node:fs').writeFileSync(process.env.ORCA_TEST_OUTPUT, JSON.stringify({
electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE,
pathKeys: Object.keys(process.env).filter((key) => key.toLowerCase() === 'path')
}))\n`,
'utf8'
)
const build = spawnSync(
process.execPath,
['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath],
{ cwd: projectRoot, encoding: 'utf8' }
)
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0)
const compiler = findFrameworkCompiler()
expect(compiler).not.toBeNull()
const compileHarness = spawnSync(
compiler,
['/nologo', '/target:exe', `/out:${harnessPath}`, harnessSourcePath],
{ encoding: 'utf8' }
)
expect(compileHarness.status, `${compileHarness.stdout}\n${compileHarness.stderr}`).toBe(0)
const launch = spawnSync(harnessPath, [launcherPath, outputPath], { encoding: 'utf8' })
expect(launch.status, `${launch.stdout}\n${launch.stderr}`).toBe(0)
expect(JSON.parse(readFileSync(outputPath, 'utf8'))).toEqual({
electronRunAsNode: '1',
pathKeys: ['PATH', 'Path']
})
} finally {
rmSync(appRoot, { recursive: true, force: true })
}
})
})
function findFrameworkCompiler() {
const windowsDirectory = process.env.WINDIR ?? process.env.SystemRoot
if (!windowsDirectory) {
return null
}
return (
[
join(windowsDirectory, 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe'),
join(windowsDirectory, 'Microsoft.NET', 'Framework', 'v4.0.30319', 'csc.exe')
].find((candidate) => existsSync(candidate)) ?? null
)
}
@@ -0,0 +1,66 @@
const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
function parseVersionTriple(value) {
const match = SEMVER.exec(String(value ?? '').trim())
if (!match) {
return null
}
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
prerelease: match[4] ?? null
}
}
function compareTriples(a, b) {
return a.major - b.major || a.minor - b.minor || a.patch - b.patch
}
/**
* The base `X.Y.Z` an hourly or adhoc build should carry.
*
* Why not package.json alone: main's version only moves on `release:` commits, and
* stable patches are cut from release branches that never merge back. On
* 2026-08-03 main read `1.4.165-rc.0` for twenty hours while 1.4.165, 1.4.166 and
* 1.4.167 all shipped — so hourlies built from that main claimed 1.4.165 while
* carrying code newer than 1.4.167, and sorted *below* the stable their user was
* already running. Published tags are the only honest answer to "what number is
* taken"; package.json is a floor, not a source of truth.
*/
export function resolveDevChannelBaseVersion(packageVersion, publishedVersions = []) {
const fromPackage = parseVersionTriple(packageVersion)
if (!fromPackage) {
throw new Error(`Package version is not valid semver: ${packageVersion}`)
}
// Unparseable tags are skipped rather than fatal: the main repo carries old tags
// that predate the current scheme, and one of them must not fail every build.
const published = publishedVersions.map(parseVersionTriple).filter(Boolean)
let base = fromPackage
if (published.length > 0) {
const highest = published.reduce((best, entry) =>
compareTriples(entry, best) > 0 ? entry : best
)
// A shipped stable owns its number, so the next dev build belongs on the patch
// above it. A bare prerelease does not — rc.1 of 1.4.168 means 1.4.168 is still
// the version being worked toward, which is exactly what main is building.
const shipped = published.some(
(entry) => !entry.prerelease && compareTriples(entry, highest) === 0
)
const next = shipped ? { ...highest, patch: highest.patch + 1 } : highest
if (compareTriples(next, base) > 0) {
base = next
}
}
return `${base.major}.${base.minor}.${base.patch}`
}
/** Tag list the workflow reads out of the main repo, newline separated. */
export function readPublishedVersionsFromEnv(value = process.env.ORCA_PUBLISHED_VERSIONS) {
return String(value ?? '')
.split(/\s+/)
.filter(Boolean)
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import {
readPublishedVersionsFromEnv,
resolveDevChannelBaseVersion
} from './dev-channel-base-version.mjs'
describe('dev channel base version', () => {
it('falls back to package.json when no tags are supplied', () => {
expect(resolveDevChannelBaseVersion('1.4.168-rc.1')).toBe('1.4.168')
expect(resolveDevChannelBaseVersion('1.4.168', [])).toBe('1.4.168')
})
// The bug this exists for: main sat at 1.4.165-rc.0 for twenty hours while three
// stables shipped, so hourlies claimed a version their users had already passed.
it('climbs past stables that shipped while main stood still', () => {
expect(
resolveDevChannelBaseVersion('1.4.165-rc.0', [
'v1.4.165',
'v1.4.166',
'v1.4.167',
'v1.4.165-rc.0'
])
).toBe('1.4.168')
})
// Why +1 on a stable but not on a prerelease: 1.4.167 is spent, so the next dev
// build is 1.4.168. But 1.4.168-rc.1 means 1.4.168 is still being built toward,
// which is what main holds — claiming 1.4.169 would jump a release nobody cut.
it('takes the patch above a shipped stable and holds at an open prerelease', () => {
expect(resolveDevChannelBaseVersion('1.4.167', ['v1.4.167'])).toBe('1.4.168')
expect(resolveDevChannelBaseVersion('1.4.168-rc.1', ['v1.4.168-rc.1'])).toBe('1.4.168')
})
// Why max and not most-recent: a hotfix on an old line published today would
// otherwise drag every subsequent hourly backwards.
it('reads the highest tag, not the last one listed', () => {
expect(resolveDevChannelBaseVersion('1.4.160', ['v1.4.167', 'v1.3.99', 'v1.4.120'])).toBe(
'1.4.168'
)
})
it('treats package.json as a floor when it leads the tags', () => {
expect(resolveDevChannelBaseVersion('1.5.0-rc.0', ['v1.4.167'])).toBe('1.5.0')
})
// Why skipped rather than fatal: the main repo carries legacy tags, and one
// unparseable entry must not fail every hourly build.
it('ignores tags it cannot parse', () => {
expect(resolveDevChannelBaseVersion('1.4.160', ['nightly', '', 'v1.4.167', 'latest'])).toBe(
'1.4.168'
)
})
it('rejects a package version that is not semver', () => {
expect(() => resolveDevChannelBaseVersion('not-a-version')).toThrow(/not valid semver/)
})
it('carries major and minor rollovers through the bump', () => {
expect(resolveDevChannelBaseVersion('1.4.0', ['v2.0.0'])).toBe('2.0.1')
})
it('splits an env tag list on any whitespace', () => {
expect(readPublishedVersionsFromEnv('v1.4.167\nv1.4.166\n')).toEqual(['v1.4.167', 'v1.4.166'])
expect(readPublishedVersionsFromEnv('')).toEqual([])
expect(readPublishedVersionsFromEnv(undefined)).toEqual([])
})
})
@@ -0,0 +1,123 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
internal static class DuplicatePathProcessLauncher
{
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct StartupInfo
{
public int cb;
public string lpReserved;
public string lpDesktop;
public string lpTitle;
public int dwX;
public int dwY;
public int dwXSize;
public int dwYSize;
public int dwXCountChars;
public int dwYCountChars;
public int dwFillAttribute;
public int dwFlags;
public short wShowWindow;
public short cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[StructLayout(LayoutKind.Sequential)]
private struct ProcessInformation
{
public IntPtr hProcess;
public IntPtr hThread;
public int dwProcessId;
public int dwThreadId;
}
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool CreateProcess(
string applicationName,
StringBuilder commandLine,
IntPtr processAttributes,
IntPtr threadAttributes,
bool inheritHandles,
uint creationFlags,
IntPtr environment,
string currentDirectory,
ref StartupInfo startupInfo,
out ProcessInformation processInformation
);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode);
[DllImport("kernel32.dll")]
private static extern bool CloseHandle(IntPtr handle);
private static int Main(string[] args)
{
List<string> entries = new List<string>();
foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables())
{
if (!String.Equals((string)entry.Key, "PATH", StringComparison.OrdinalIgnoreCase))
{
entries.Add((string)entry.Key + "=" + (string)entry.Value);
}
}
entries.Add("PATH=C:\\live");
entries.Add("Path=C:\\shadowed");
entries.Add("ORCA_TEST_OUTPUT=" + args[1]);
IntPtr environment = Marshal.StringToHGlobalUni(String.Join("\0", entries.ToArray()) + "\0\0");
StartupInfo startupInfo = new StartupInfo();
startupInfo.cb = Marshal.SizeOf(startupInfo);
ProcessInformation processInformation;
try
{
bool started = CreateProcess(
args[0],
new StringBuilder("\"" + args[0] + "\""),
IntPtr.Zero,
IntPtr.Zero,
false,
0x00000400,
environment,
null,
ref startupInfo,
out processInformation
);
if (!started)
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
}
finally
{
Marshal.FreeHGlobal(environment);
}
try
{
WaitForSingleObject(processInformation.hProcess, 0xffffffff);
uint exitCode;
if (!GetExitCodeProcess(processInformation.hProcess, out exitCode))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
return (int)exitCode;
}
finally
{
CloseHandle(processInformation.hThread);
CloseHandle(processInformation.hProcess);
}
}
}
@@ -0,0 +1,72 @@
/** @vitest-environment happy-dom */
import { describe, expect, it } from 'vitest'
import {
installHappyDomMutationObserverRetention,
retainedMutationCallbackCount
} from './happy-dom-mutation-observer-retention'
function readListenerCallbacks(target: Node): unknown[] {
const listenersSymbol = Object.getOwnPropertySymbols(target).find(
(candidate) => candidate.description === 'mutationListeners'
)
const listeners = listenersSymbol
? (target as unknown as Record<symbol, unknown>)[listenersSymbol]
: []
if (!Array.isArray(listeners)) {
return []
}
return listeners.map((listener: { callback?: { deref: () => unknown } }) =>
listener.callback?.deref()
)
}
describe('happy-dom MutationObserver retention', () => {
it('pins the internal callback that happy-dom only holds weakly', () => {
expect(installHappyDomMutationObserverRetention()).toBe(true)
const target = document.createElement('div')
document.body.append(target)
const observer = new MutationObserver(() => {})
observer.observe(target, { childList: true, subtree: true })
const callbacks = readListenerCallbacks(target)
expect(callbacks.length).toBe(1)
expect(callbacks[0]).toBeTypeOf('function')
expect(retainedMutationCallbackCount(observer)).toBe(1)
observer.disconnect()
expect(retainedMutationCallbackCount(observer)).toBe(0)
target.remove()
})
it('keeps delivering records after the weak callback would have been collected', async () => {
installHappyDomMutationObserverRetention()
const target = document.createElement('div')
document.body.append(target)
let deliveries = 0
const observer = new MutationObserver(() => {
deliveries += 1
})
observer.observe(target, { childList: true, subtree: true })
target.replaceChildren(document.createElement('div'))
await Promise.resolve()
expect(deliveries).toBe(1)
const collectGarbage = (globalThis as { gc?: () => void }).gc
if (collectGarbage) {
for (let round = 0; round < 5; round += 1) {
await new Promise((resolve) => setTimeout(resolve, 1))
collectGarbage()
}
await new Promise((resolve) => setTimeout(resolve, 1))
}
target.replaceChildren(document.createElement('span'))
await Promise.resolve()
expect(deliveries).toBe(2)
observer.disconnect()
target.remove()
})
})
@@ -0,0 +1,81 @@
// Why: happy-dom stores each MutationObserver's internal callback in a WeakRef, so any GC pause
// under parallel test load silently and permanently stops a still-connected observer. Browsers
// keep that callback reachable for as long as the observer observes; mirror that lifetime here so
// DOM-driven tests never lose mutation records mid-run.
type HappyDomMutationListener = {
callback?: { deref: () => unknown }
}
type PatchableMutationObserver = {
observe: (target: Node, options?: MutationObserverInit) => void
disconnect: () => void
}
const MUTATION_LISTENERS_SYMBOL_DESCRIPTION = 'mutationListeners'
const RETENTION_INSTALLED = Symbol.for('orca.happyDomMutationObserverRetention')
const retainedCallbacks = new WeakMap<object, Set<unknown>>()
function readMutationListeners(target: Node): HappyDomMutationListener[] {
const listenersSymbol = Object.getOwnPropertySymbols(target).find(
(candidate) => candidate.description === MUTATION_LISTENERS_SYMBOL_DESCRIPTION
)
if (!listenersSymbol) {
return []
}
const listeners = (target as unknown as Record<symbol, unknown>)[listenersSymbol]
return Array.isArray(listeners) ? (listeners as HappyDomMutationListener[]) : []
}
/** Number of internal callbacks pinned for `observer`; drops to 0 once it disconnects. */
export function retainedMutationCallbackCount(observer: MutationObserver): number {
return retainedCallbacks.get(observer)?.size ?? 0
}
export function installHappyDomMutationObserverRetention(): boolean {
const observerClass = (globalThis as { MutationObserver?: typeof MutationObserver })
.MutationObserver
if (!observerClass) {
return false
}
const prototype = observerClass.prototype as unknown as PatchableMutationObserver &
Record<symbol, unknown>
if (prototype[RETENTION_INSTALLED] === true) {
return true
}
const observe = prototype.observe
const disconnect = prototype.disconnect
prototype.observe = function patchedObserve(
this: object,
target: Node,
options?: MutationObserverInit
): void {
const existing = new Set(readMutationListeners(target))
observe.call(this as unknown as PatchableMutationObserver, target, options)
const pinned = retainedCallbacks.get(this) ?? new Set<unknown>()
for (const listener of readMutationListeners(target)) {
if (existing.has(listener)) {
continue
}
const callback = listener.callback?.deref()
if (callback) {
pinned.add(callback)
}
}
if (pinned.size > 0) {
retainedCallbacks.set(this, pinned)
}
}
prototype.disconnect = function patchedDisconnect(this: object): void {
disconnect.call(this as unknown as PatchableMutationObserver)
retainedCallbacks.delete(this)
}
prototype[RETENTION_INSTALLED] = true
return true
}
installHappyDomMutationObserverRetention()
+8 -3
View File
@@ -2,6 +2,10 @@ import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs'
import {
readPublishedVersionsFromEnv,
resolveDevChannelBaseVersion
} from './dev-channel-base-version.mjs'
/** `1.4.160-hourly.202607281400` — UTC to the minute, so tags sort chronologically
* by semver and every build is uniquely versioned. */
@@ -45,18 +49,19 @@ export function formatHourlyReleaseName(version, buildNumber, commit, date) {
].join(' • ')
}
export function getHourlyBuildIdentity(now = new Date(), buildNumber = 1) {
export function getHourlyBuildIdentity(now = new Date(), buildNumber = 1, publishedVersions = []) {
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
encoding: 'utf8'
}).trim()
const version = createHourlyBuildVersion(packageJson.version, now)
const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions)
const version = createHourlyBuildVersion(base, now)
return { commit, version, name: formatHourlyReleaseName(version, buildNumber, commit, now) }
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
const buildNumber = Number(process.env.ORCA_HOURLY_BUILD_NUMBER ?? '1')
const identity = getHourlyBuildIdentity(new Date(), buildNumber)
const identity = getHourlyBuildIdentity(new Date(), buildNumber, readPublishedVersionsFromEnv())
// Consumed by the workflow via $GITHUB_OUTPUT.
process.stdout.write(
`version=${identity.version}\ncommit=${identity.commit}\nname=${identity.name}\n`
@@ -227,12 +227,79 @@ describe('orchestration skill guidance', () => {
expect(agentGuidance).toContain('After sending `worker_done`, end your turn')
expect(agentGuidance).toContain('idle at the agent prompt')
expect(agentGuidance).toContain('Do not poll or keep calling `orca orchestration check`')
expect(agentGuidance).toContain(
'do not start more work, poll, or attempt to close the terminal yourself'
)
expect(agentGuidance).toContain('fresh preamble + TASK block delivered as new terminal input')
expect(skill).not.toContain('post-completion polling messages')
expect(skill).not.toContain('every 2 minutes')
})
it('makes settled worker terminal release an explicit coordinator step', () => {
const skill = readSkill()
const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop')
const agentGuidance = getSection(skill, 'Agent Guidance')
const nextAction = getSection(skill, 'Next Action')
expect(workerLoop).toContain(
'# Process every message. For each accepted worker_done that is not immediately reused:\n' +
'orca orchestration worker-release --dispatch <dispatch_id> --json'
)
expect(workerLoop).toContain(
'Acknowledge only after every message and required release decision is handled'
)
expect(workerLoop).toContain(
'read the `worker.agent_terminal_handle` field of `worker-show --dispatch <dispatch_id> --json`'
)
expect(workerLoop).toContain(
'orca orchestration worker-start --task <next_task_id> --terminal <handle> --json` so Orca ' +
'transfers cleanup ownership to the new Dispatch'
)
expect(workerLoop).toContain(
'Run `worker-release` after both succeeded and failed `worker_done` reports unless the user ' +
'explicitly asked to keep that worker live.'
)
expect(workerLoop).toContain('Release is post-completion cleanup, not cancellation')
expect(workerLoop).toContain('orca orchestration worker-retain --dispatch <dispatch_id> --json')
expect(workerLoop).toContain(
'the same Dispatch can be passed to `worker-release`, which clears the requested retention'
)
expect(agentGuidance).toContain(
'Coordinators must account for every settled worker terminal before waiting again or ending ' +
'the turn'
)
expect(agentGuidance).toContain('released workers remain readable through `worker-read`')
expect(nextAction).toContain(
'After every accepted `worker_done`, either transfer the exact terminal to an immediate ' +
'follow-up Dispatch or run `worker-release` before the next wait.'
)
})
it('never authorizes release from idle, timeout, or worker-side triggers', () => {
const skill = readSkill()
const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop')
const agentGuidance = getSection(skill, 'Agent Guidance')
// The prohibition sentence is the guard the negative patterns below rely on.
expect(workerLoop).toContain(
'Do not release a worker because of a timeout, TUI idle state, heartbeat, status, question, ' +
'escalation, or rejected/stale `worker_done`.'
)
expect(workerLoop).toContain(
'do not substitute `terminal close`; follow the exact recovery action in the receipt'
)
expect(skill).not.toMatch(
/release[^.]*\bon (?:a |the )?(?:tui-?idle|idle|timeout|heartbeat|question|escalation)\b/iu
)
expect(skill).not.toMatch(
/\b(?:after|on|upon) (?:a |the )?(?:tui-?idle|idle state|timeout|heartbeat)\b[^.]*\brelease/iu
)
expect(agentGuidance).toContain(
'do not start more work, poll, or attempt to close the terminal yourself'
)
expect(agentGuidance).not.toMatch(/worker-release[^.]*\byourself\b/iu)
})
it('documents @grok in the Messaging group address list', () => {
const skill = readSkill()
const messaging = getSection(skill, 'Messaging')
@@ -39,14 +39,23 @@ else
# Why the features are off: this repo holds releases and nothing else. Leaving
# issues open invites bug reports against a branch build in a repo nobody
# watches, where they are simply lost.
#
# Why --add-readme in a repo with no source: publishing a release creates a tag,
# and a tag needs a commit. Empty repo = "Repository is empty" 25 minutes in.
gh repo create "$ADHOC_REPO" \
--public \
--description "Adhoc macOS dev builds of Orca, cut from unlanded branches. Not a source repo." \
--add-readme \
--disable-issues \
--disable-wiki ||
fail "Could not create $ADHOC_REPO."
fi
# Also checked outside the create branch: a repo made before --add-readme is here.
if ! gh api "repos/$ADHOC_REPO/commits" --jq 'length' >/dev/null 2>&1; then
fail "$ADHOC_REPO has no commits — releases cannot be tagged. Add any file to it first."
fi
echo
echo "Granting $APP_SLUG access to $ADHOC_REPO..."
+2
View File
@@ -17,6 +17,8 @@ export default defineConfig({
environment: 'node',
// Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's.
execArgv: ['--no-experimental-webstorage'],
// Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does.
setupFiles: [resolve('config/scripts/happy-dom-mutation-observer-retention.ts')],
include: [
'src/**/*.test.ts',
'src/**/*.test.tsx',
+86
View File
@@ -0,0 +1,86 @@
# Windows setup-runner shell
On native Windows, Orca writes the `orca.yaml` setup script (and the issue command) to a generated
runner file and types a launch command into a terminal. The runner is a **`.cmd` batch file by
default**, exactly as it has been since setup hooks shipped.
A script opts into bash by starting with a `#!` interpreter line:
```yaml
scripts:
setup: |
#!/usr/bin/env bash
[ -f .env ] || cp .env.example .env
pnpm install
```
Without that line the script keeps running under `cmd.exe`:
```yaml
scripts:
setup: |
copy .env.example .env
xcopy /E assets dist
```
## Why the script declares it, not the terminal preference
`terminalWindowsShell` says which shell *interactive terminals* open in. It says nothing about the
language a project's setup script is written in. Deriving the runner from it had two consequences:
- Windows users with batch-syntax setup scripts silently switched to bash on upgrade, so `copy`,
`xcopy`, `set VAR=value`, and `if errorlevel 1` stopped working.
- Two people on the same repo got different interpreters for the same `orca.yaml`, so no project
could write a setup script that worked for all of its Windows contributors.
A `#!` line is per-project, explicit, and identical for everyone who checks the repo out.
The same rule applies to the per-user setup command in **Settings → repository hooks**
(`repo.hookSettings.scripts.setup`): it is merged into the same script that reaches the runner, so a
POSIX one-liner stored there needs its own `#!` line to run under bash on Windows.
## What the `#!` line does and does not select
The generated runner is always executed by bash (`bash <runner>`; Git Bash on native Windows), on
every platform. The `#!` line therefore does two things:
- It declares the script is written for a POSIX shell, which is what selects the bash runner.
- Its option flags are replayed with `set`, so `#!/usr/bin/env -S bash -euo pipefail` really does
get `pipefail`. Without that replay the flags would be silently dropped, because `bash <runner>`
never parses the interpreter line. Only the flags `set` itself accepts
(`[--abefhkmnptuvxBCHP] [-o option]`) are replayed; invocation-only ones such as `-l` are
dropped, because `set -l` exits 2 and would abort the runner before its first line.
The interpreter name itself is not honored beyond "is this a POSIX shell": `#!/bin/sh` and
`#!/bin/zsh` scripts run under bash, exactly as they already did on macOS and Linux.
## Requirements for the bash runner
A `#!` line only takes effect when Orca can actually launch bash from the configured terminal — the
terminal shell must resolve to Git Bash (`resolveWindowsGitBashShellPath`). The generated runner
uses MSYS `/c/...` paths, which Cygwin and the WSL shim do not accept, and the launch command is
typed into whatever shell the terminal opened with.
When bash is not available (a PowerShell/cmd terminal, or an SSH-to-Windows host, which always uses
the remote's `.cmd` runner) the `#!` script is **not** executed under cmd. The generated `.cmd`
runner prints why and exits 1, because running the interpreter-agnostic prefix of a bash script
(`pnpm install`, `git submodule update`) and only failing at the first bash-only line leaves a
half-set-up worktree that looks finished.
## Launching a `.cmd` runner from a Git Bash terminal
The runner format and the shell that types the launch command are independent: a Git Bash terminal
with a batch-syntax setup script gets a `.cmd` runner launched from a bash pane. `cmd.exe /c
"C:\..."` cannot be used there — MSYS rewrites the bare `/c` switch into a drive path, so cmd opens
interactively and the runner never executes (issue #6896). Those launches reuse the PowerShell
`ProcessStartInfo` launcher (`buildWindowsCmdRunnerDelayedLaunchCommand`), which carries the switch
and the runner path outside the command line. `WorktreeSetupLaunch.shell` therefore describes the
launching pane; the runner file's `.cmd`/`.sh` extension describes the format.
The `wait-for-setup` gate follows the same split. The pane types the gate and already quoted the
agent startup command for itself, so a `.cmd` runner launched from a Git Bash pane still gets the
bash gate — PowerShell's `Invoke-Expression` cannot parse POSIX `'\''` escaping. The gate wraps the
same `ProcessStartInfo` launcher, so the batch runner is never handed to bash.
WSL worktrees and non-Windows platforms are unaffected: they always use the bash runner. SSH hosts
choose their runner from the remote path format, never from local Windows preferences.
+1
View File
@@ -1,5 +1,6 @@
node_modules/
src/terminal/terminal-webview-engine.generated.ts
src/components/pr-sidebar/mermaid-webview-engine.generated.ts
.expo/
dist/
/android/
+2 -2
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.36",
"version": "0.0.37",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -75,7 +75,7 @@
"allowBackup": false,
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
"package": "com.stably.orca.mobile",
"versionCode": 9
"versionCode": 10
},
"plugins": [
"expo-router",
+9 -2
View File
@@ -164,7 +164,8 @@ export function HostScreen({
const [filters, setFilters] = useState<FilterState>({
filterRepoIds: new Set(),
hideSleeping: false,
hideDefaultBranch: false
hideDefaultBranch: false,
alwaysShowDefaultBranch: true
})
const [groupMode, setGroupMode] = useState<MobileGroupMode>('repo')
const [workspaceStatuses, setWorkspaceStatuses] = useState<readonly WorkspaceStatusDefinition[]>(
@@ -195,6 +196,7 @@ export function HostScreen({
sortMode: 'recent',
hideSleeping: false,
hideDefaultBranch: false,
alwaysShowDefaultBranch: true,
filterRepoIds: [],
collapsedGroups: [],
workspaceStatuses: DEFAULT_MOBILE_WORKSPACE_STATUSES
@@ -206,6 +208,7 @@ export function HostScreen({
sortMode,
hideSleeping: filters.hideSleeping,
hideDefaultBranch: filters.hideDefaultBranch,
alwaysShowDefaultBranch: filters.alwaysShowDefaultBranch !== false,
filterRepoIds: [...filters.filterRepoIds],
collapsedGroups: [...collapsedGroups],
workspaceStatuses
@@ -222,7 +225,8 @@ export function HostScreen({
setFilters({
filterRepoIds: new Set(next.filterRepoIds),
hideSleeping: next.hideSleeping,
hideDefaultBranch: next.hideDefaultBranch
hideDefaultBranch: next.hideDefaultBranch,
alwaysShowDefaultBranch: next.alwaysShowDefaultBranch
})
}, [])
@@ -234,6 +238,9 @@ export function HostScreen({
if (!client) {
return
}
// alwaysShowDefaultBranchWorkspace is deliberately absent: mobile reads it
// but has no toggle, so echoing its local default would silently revert a
// desktop opt-out on the first filter tap before ui.get lands (#8873).
const payload: WorkspaceViewSettings = {
groupBy: groupModeToDesktop(next.groupMode),
sortBy: next.sortMode,
+59 -61
View File
@@ -107,6 +107,7 @@ import type {
TerminalWebViewHandle
} from '../../../../src/terminal/terminal-webview-contract'
import { isTerminalOscLinkRanges } from '../../../../src/terminal/terminal-osc-link-ranges'
import { computeActiveTerminalKeyboardLift } from '../../../../src/terminal/terminal-keyboard-avoidance-lift'
import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit'
import {
getDefaultTerminalAccessoryBuiltInIds,
@@ -235,6 +236,11 @@ import {
} from '../../../../src/session/mobile-terminal-prune-decision'
import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream'
import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe'
import {
TerminalViewportResubscribeBudget,
readTerminalViewportDims,
runTerminalViewportFitPass
} from '../../../../src/session/mobile-terminal-viewport-resubscribe'
import { activateMobileSessionTab } from '../../../../src/session/mobile-session-tab-activation'
import { MobileTerminalDiagnostics } from '../../../../src/session/mobile-terminal-diagnostics'
import { runAcceptedMobileSessionTabsEffects } from '../../../../src/session/mobile-session-tabs-accepted-effects'
@@ -1030,6 +1036,8 @@ export default function SessionScreen() {
const subscribingHandlesRef = useRef<Set<string>>(new Set())
const initializedHandlesRef = useRef<Set<string>>(new Set())
const terminalDiagnosticsRef = useRef(new MobileTerminalDiagnostics())
// Why: bounds the scrollback→resubscribe fit loop per handle (STA-3337).
const viewportResubscribeBudgetRef = useRef(new TerminalViewportResubscribeBudget())
// Why: don't subscribe until the WebView fires web-ready — iOS may defer JS in hidden WebViews and init() messages would queue unrendered.
const webReadyHandlesRef = useRef<Set<string>>(new Set())
const activeHandleRef = useRef<string | null>(null)
@@ -1376,6 +1384,7 @@ export default function SessionScreen() {
subscribingHandlesRef.current.clear()
initializedHandlesRef.current.clear()
terminalDiagnosticsRef.current.clearTerminalCache()
viewportResubscribeBudgetRef.current.clear()
webReadyHandlesRef.current.clear()
subscribeSeqRef.current.clear()
layoutSeqRef.current.clear()
@@ -1505,10 +1514,11 @@ export default function SessionScreen() {
return
}
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
const cols = (data.cols as number) || 80
const rows = (data.rows as number) || 24
const scrollbackCols = cols
const scrollbackRows = rows
const { hostCols, hostRows } = readTerminalViewportDims(data)
// Why: absent host dims must not be coerced into a comparable size — 80x24
// never equals a phone viewport and armed a zero-delay resubscribe loop (STA-3337).
const cols = hostCols ?? viewportRef.current?.cols ?? 80
const rows = hostRows ?? viewportRef.current?.rows ?? 24
const initialData =
typeof data.serialized === 'string' && data.serialized.length > 0
? data.serialized
@@ -1527,46 +1537,36 @@ export default function SessionScreen() {
ref.init(cols, rows, initialData, false, oscLinks)
initializedHandlesRef.current.add(handle)
if (data.displayMode) {
const displayMode = data.displayMode as MobileDisplayMode
// Why: same-mode frames must keep the Map identity, or every stream pass re-renders the whole route.
setTerminalModes((prev) =>
new Map(prev).set(handle, data.displayMode as MobileDisplayMode)
prev.get(handle) === displayMode ? prev : new Map(prev).set(handle, displayMode)
)
}
// Why: cold-start refit — init()'s fit can run against a transient scrollWidth, so re-fire against a settled DOM.
scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200)
// Why: first subscribe has no viewport (xterm not loaded yet), so measure after init and resubscribe so the server can phone-fit.
const needsResubscribe =
!viewportMeasuredRef.current ||
(viewportRef.current != null &&
(scrollbackCols !== viewportRef.current.cols ||
scrollbackRows !== viewportRef.current.rows))
if (needsResubscribe) {
void (async () => {
// Why: wait for init()'s rAF chain before measuring, else the measure races ahead and returns null (log dump 2026-05-06).
await getTerminalRef(handle)?.awaitReady()
if (subscribeSeqRef.current.get(handle) !== seq) {
return
}
const dims = await getTerminalRef(handle)?.measureFitDimensions(
terminalFrameHeightRef.current || undefined
)
// Why: re-check seq — the awaits may have let a newer subscribe cycle arm; tearing it down would resubscribe a stale generation.
if (subscribeSeqRef.current.get(handle) !== seq) {
return
}
if (!getTerminalRef(handle)) {
return
}
// Why: scrollback came back at cols=80 (server's null-viewport fallback), so this subscriber record has no viewport — resubscribe so the server stores it.
if (dims) {
diagnostics.streamResubscribing(handle, seq, dims)
viewportRef.current = dims
viewportMeasuredRef.current = true
unsubscribeTerminal(handle)
initializedHandlesRef.current.delete(handle)
subscribeToTerminal(handle)
}
})()
}
// Why: first subscribe has no viewport (xterm not loaded yet), so measure after init
// and resubscribe so the server can phone-fit — bounded per handle so a
// non-converging host degrades visibly instead of hot-looping (STA-3337).
runTerminalViewportFitPass({
handle,
seq,
hostCols,
hostRows,
budget: viewportResubscribeBudgetRef.current,
diagnostics,
viewportRef,
viewportMeasuredRef,
subscribeSeqRef,
initializedHandlesRef,
terminalUnsubsRef,
terminalFrameHeightRef,
getTerminalRef,
unsubscribeTerminal,
subscribeToTerminal,
scheduleDelayedAction,
showToast
})
} else if (data.type === 'metadata') {
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
} else if (data.type === 'data') {
@@ -1591,8 +1591,12 @@ export default function SessionScreen() {
} else if (data.type === 'resized') {
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
// Server resize: reinit xterm on a full-buffer snapshot (width reflow rewraps scrollback), else just resize geometry.
const cols = (data.cols as number) || 80
const rows = (data.rows as number) || 24
const viewport = viewportMeasuredRef.current ? viewportRef.current : null
const [cols, rows] = viewportResubscribeBudgetRef.current.observeResize(
handle,
data,
viewport
)
const serialized = typeof data.serialized === 'string' ? data.serialized : null
diagnostics.streamResized(handle, seq, eventSeq, data, getTerminalRef(handle) != null)
const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined
@@ -1602,8 +1606,10 @@ export default function SessionScreen() {
getTerminalRef(handle)?.resize(cols, rows)
}
if (data.displayMode) {
const displayMode = data.displayMode as MobileDisplayMode
// Why: same-mode frames must keep the Map identity, or every stream pass re-renders the whole route.
setTerminalModes((prev) =>
new Map(prev).set(handle, data.displayMode as MobileDisplayMode)
prev.get(handle) === displayMode ? prev : new Map(prev).set(handle, displayMode)
)
}
scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200)
@@ -1619,7 +1625,7 @@ export default function SessionScreen() {
}
subscribingHandlesRef.current.delete(handle)
},
[client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction]
[client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction, showToast]
)
const nativeChatStream = useMobileNativeChatTerminalStream({
@@ -1720,12 +1726,16 @@ export default function SessionScreen() {
unsubscribeTerminal(handle)
terminalRefs.current.delete(handle)
initializedHandlesRef.current.delete(handle)
viewportResubscribeBudgetRef.current.forget(handle)
clearTerminalLiveInputDefault(handle)
}
setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune))
// Why: a chat-covered handle the host reports again refills its rearm budget,
// so an exhausted rearm can't lock the composer until leave-chat.
nativeChatStream.notifyListedHandles(liveHandles)
// Why: same absence-gated refill for the viewport-fit budget — a handle that
// left the list and returned may converge now, so it earns fresh attempts.
viewportResubscribeBudgetRef.current.notifyListedHandles(liveHandles)
lastKnownTerminalCountRef.current = result.terminals.length
// Why: dedupe duplicate handles (rename/split race) to avoid a React duplicate-key throw; keep first for tab-strip order.
const seen = new Set<string>()
@@ -3570,6 +3580,7 @@ export default function SessionScreen() {
if (
current &&
current.cursorY === metrics.cursorY &&
current.contentBottomRow === metrics.contentBottomRow &&
current.rows === metrics.rows &&
current.altScreen === metrics.altScreen
) {
@@ -4251,24 +4262,11 @@ export default function SessionScreen() {
? Math.max(0, keyboardHeight - insets.bottom)
: keyboardHeight
: 0
const activeTerminalKeyboardLift = (() => {
if (keyboardLift <= 0 || !activeHandle) {
return 0
}
const metrics = terminalKeyboardMetrics.get(activeHandle)
if (!metrics || metrics.rows <= 0 || terminalFrameHeightRef.current <= 0) {
return keyboardLift
}
if (metrics.altScreen) {
return keyboardLift
}
const rowHeight = terminalFrameHeightRef.current / metrics.rows
const cursorBottom = (metrics.cursorY + 1) * rowHeight
const dockTop = terminalFrameHeightRef.current - keyboardLift
const margin = rowHeight
// Why: only move the terminal when the cursor would sit under the raised input dock; short top output stays put.
return Math.min(keyboardLift, Math.max(0, cursorBottom + margin - dockTop))
})()
const activeTerminalKeyboardLift = computeActiveTerminalKeyboardLift({
keyboardLift,
metrics: activeHandle ? terminalKeyboardMetrics.get(activeHandle) : undefined,
terminalFrameHeight: terminalFrameHeightRef.current
})
const toastAnimatedStyle = {
opacity: toastOpacityRef.current,
transform: [{ translateY: -keyboardLift }]
+75 -125
View File
@@ -18,14 +18,11 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
import { loadHosts } from '../src/transport/host-store'
import { navigateToMobileHostEdit } from '../src/transport/host-edit-navigation'
import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle'
import { pickResumeWorktree } from '../src/worktree/resume-worktree'
import { WORKTREE_PS_FULL_LIMIT } from '../src/worktree/worktree-catalog-snapshot-client'
import {
markHomeWorktreeCatalogUnavailable,
type HomeWorktreeSummary,
type HostWorktreeInfo
} from '../src/worktree/home-worktree-info'
import { fetchHomeHostWorktreeInfo } from '../src/worktree/home-host-worktree-fetch'
import { totalHomeStats, type HomeStatsSummary } from '../src/stats/home-stats-total'
import type { HomeWorktreeSummary, HostWorktreeInfo } from '../src/worktree/home-worktree-info'
import type { RpcClient } from '../src/transport/rpc-client'
import { createHostConnectRefetchGate } from '../src/transport/host-connect-refetch-gate'
import { sendSingleFlightRequest } from '../src/transport/request-single-flight'
import {
useAllHostClients,
@@ -68,13 +65,6 @@ function endpointLabel(endpoint: string): string {
}
}
type StatsSummary = {
totalAgentsSpawned: number
totalPRsCreated: number
totalAgentTimeMs: number
firstEventAt: number | null
}
type HomeTaskSettings = {
visibleTaskProviders?: unknown
}
@@ -123,7 +113,9 @@ function clientKey(client: RpcClient): number {
function fetchStats(
client: RpcClient,
hostId: string,
setStats: (s: StatsSummary) => void,
setStats: (
updater: (prev: Record<string, HomeStatsSummary>) => Record<string, HomeStatsSummary>
) => void,
disposed: () => boolean
) {
sendSingleFlightRequest(client, hostId, 'stats.summary')
@@ -132,61 +124,13 @@ function fetchStats(
return
}
if (response.ok) {
setStats(response.result as StatsSummary)
// Keyed by host: the header totals every desktop instead of showing whoever replied last.
setStats((prev) => ({ ...prev, [hostId]: response.result as HomeStatsSummary }))
}
})
.catch(() => {})
}
function fetchWorktreeInfo(
client: RpcClient,
hostId: string,
setInfo: (
updater: (prev: Record<string, HostWorktreeInfo>) => Record<string, HostWorktreeInfo>
) => void,
disposed: () => boolean
) {
const markUnavailable = () => {
setInfo((prev) => {
const current = prev[hostId]
const next = markHomeWorktreeCatalogUnavailable(current, hostId)
return next === current ? prev : { ...prev, [hostId]: next }
})
}
sendSingleFlightRequest(client, hostId, 'worktree.ps', { limit: WORKTREE_PS_FULL_LIMIT })
.then((response) => {
if (disposed()) {
return
}
if (response.ok) {
const result = response.result as { worktrees: HomeWorktreeSummary[] }
const worktrees = result.worktrees ?? []
setCachedWorktrees(hostId, worktrees)
const activeStatuses = new Set(['working', 'active', 'permission'])
const active = worktrees.filter((w) => w.status && activeStatuses.has(w.status))
// Mirror the desktop's focused workspace (see pickResumeWorktree).
const lastActive = pickResumeWorktree(worktrees)
setInfo((prev) => ({
...prev,
[hostId]: {
hostId,
totalWorktrees: worktrees.length,
activeCount: active.length,
lastActiveWorktree: lastActive
}
}))
} else {
markUnavailable()
}
})
.catch(() => {
if (!disposed()) {
markUnavailable()
}
})
}
function fetchAccountsSnapshot(
client: RpcClient,
hostId: string,
@@ -272,7 +216,7 @@ export default function HomeScreen() {
const [hostStates, setHostStates] = useState<Record<string, ConnectionState>>({})
const [hostAttempts, setHostAttempts] = useState<Record<string, number>>({})
const [hostLastConnected, setHostLastConnected] = useState<Record<string, number | null>>({})
const [stats, setStats] = useState<StatsSummary | null>(null)
const [statsByHost, setStatsByHost] = useState<Record<string, HomeStatsSummary>>({})
const [worktreeInfo, setWorktreeInfo] = useState<Record<string, HostWorktreeInfo>>({})
const [accountsByHost, setAccountsByHost] = useState<Record<string, AccountsSnapshot>>({})
const [taskProvidersByHost, setTaskProvidersByHost] = useState<Record<string, TaskProvider[]>>({})
@@ -285,6 +229,8 @@ export default function HomeScreen() {
// Why: shared clients from the per-host store, not N independent WebSockets. See docs/mobile-shared-client-per-host.md.
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
// Why: scoped to the paired hosts so an unpaired desktop's cached reply leaves the header total.
const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds])
const allClients = useAllHostClients(hostIds)
const hostPaths = useMemo(
() => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])),
@@ -375,8 +321,8 @@ export default function HomeScreen() {
})
for (const entry of allClientsRef.current) {
if (entry.client.getState() === 'connected') {
fetchStats(entry.client, entry.hostId, setStats, () => stale)
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale)
fetchStats(entry.client, entry.hostId, setStatsByHost, () => stale)
void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale)
fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale)
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => stale)
}
@@ -457,60 +403,70 @@ export default function HomeScreen() {
})
}, [allClients, hosts])
// Per-host notif/accounts subs + one-shot stats on 'connected'; re-runs per (hostId, client) pair, socket stays open so it's cheap.
useEffect(() => {
const cleanups: Array<() => void> = []
for (const entry of allClients) {
let unsubNotif: (() => void) | null = null
let unsubAccounts: (() => void) | null = null
let statsFetched = false
const wireUp = (state: ConnectionState) => {
if (state === 'connected') {
if (!unsubNotif) {
unsubNotif = subscribeToDesktopNotifications(entry.client, entry.hostId)
}
if (!unsubAccounts) {
unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => {
if (!payload || typeof payload !== 'object') {
return
// Notif/accounts subs + a snapshot read per connect for one host. Lives outside the effect body
// because react-doctor's effect-needs-cleanup false-positives on `subscribe` inside one; the
// returned disposer owns every handle allocated here.
const wireHostSubscriptions = (entry: {
hostId: string
client: RpcClient
state: ConnectionState
}) => {
let unsubNotif: (() => void) | null = null
let unsubAccounts: (() => void) | null = null
const refetchGate = createHostConnectRefetchGate()
const wireUp = (state: ConnectionState) => {
const reconnected = refetchGate.observe(state)
if (state === 'connected') {
if (!unsubNotif) {
unsubNotif = subscribeToDesktopNotifications(entry.client, entry.hostId)
}
if (!unsubAccounts) {
unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => {
if (!payload || typeof payload !== 'object') {
return
}
const evt = payload as { type?: string; snapshot?: unknown }
if (evt.type === 'ready' || evt.type === 'snapshot') {
try {
const snapshot = decodeAccountsSnapshot(evt.snapshot)
setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: snapshot }))
} catch {
// Keep the last proven snapshot; malformed remote data must
// not enter render state or crash the home host cards.
}
const evt = payload as { type?: string; snapshot?: unknown }
if (evt.type === 'ready' || evt.type === 'snapshot') {
try {
const snapshot = decodeAccountsSnapshot(evt.snapshot)
setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: snapshot }))
} catch {
// Keep the last proven snapshot; malformed remote data must
// not enter render state or crash the home host cards.
}
}
})
}
if (!statsFetched) {
statsFetched = true
fetchStats(entry.client, entry.hostId, setStats, () => false)
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false)
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false)
}
} else {
if (unsubNotif) {
unsubNotif()
unsubNotif = null
}
if (unsubAccounts) {
unsubAccounts()
unsubAccounts = null
}
}
})
}
// Why: the socket survives backgrounding/handoffs by reconnecting, so re-read the host
// snapshot on every reconnect — a one-shot latch left the card on stale data forever.
if (reconnected) {
fetchStats(entry.client, entry.hostId, setStatsByHost, () => false)
void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false)
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false)
}
} else {
if (unsubNotif) {
unsubNotif()
unsubNotif = null
}
if (unsubAccounts) {
unsubAccounts()
unsubAccounts = null
}
}
wireUp(entry.state)
const unsubState = entry.client.onStateChange(wireUp)
cleanups.push(() => {
unsubState()
unsubNotif?.()
unsubAccounts?.()
})
}
wireUp(entry.state)
const unsubState = entry.client.onStateChange(wireUp)
return () => {
unsubState()
unsubNotif?.()
unsubAccounts?.()
}
}
// Re-runs per (hostId, client) pair; the socket stays open so it's cheap.
useEffect(() => {
const cleanups = allClients.map((entry) => wireHostSubscriptions(entry))
return () => {
for (const c of cleanups) {
c()
@@ -750,7 +706,6 @@ export default function HomeScreen() {
const state = hostStates[item.id] ?? 'connecting'
const attempts = hostAttempts[item.id] ?? 0
const lastConnectedAt = hostLastConnected[item.id] ?? null
const info = worktreeInfo[item.id]
const verdict = classifyConnection({
state,
reconnectAttempts: attempts,
@@ -763,12 +718,7 @@ export default function HomeScreen() {
state={state}
verdict={verdict}
path={hostPaths[item.id] ?? 'lan'}
worktreeCounts={
info && !info.catalogUnavailable
? { total: info.totalWorktrees, active: info.activeCount }
: undefined
}
worktreeCountsUnavailable={info?.catalogUnavailable === true}
worktreeInfo={worktreeInfo[item.id]}
onPress={() => router.push(`/h/${item.id}`)}
onLongPress={() => {
triggerMediumImpact()
+2 -1
View File
@@ -7,7 +7,7 @@
"start": "node scripts/start-expo.mjs",
"android": "expo run:android",
"ios": "expo run:ios",
"postinstall": "node scripts/build-terminal-webview-engine.mjs",
"postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"lint": "oxlint",
@@ -48,6 +48,7 @@
"expo-status-bar": "^55.0.6",
"lowlight": "^3.3.0",
"lucide-react-native": "^1.14.0",
"mermaid": "11.16.0",
"react": "^19.2.6",
"react-dom": "19.2.6",
"react-native": "^0.83.9",
+792
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const scriptDir = import.meta.dirname
const mobileRoot = path.resolve(scriptDir, '..')
const outputPath = path.join(
mobileRoot,
'src',
'components',
'pr-sidebar',
'mermaid-webview-engine.generated.ts'
)
// Why: unlike the terminal engine (esbuilt from source for old-WebView shims),
// mermaid's prebuilt UMD bundle is embedded verbatim — it is the exact artifact
// the diagram WebView previously fetched from a CDN, now pinned and
// integrity-checked through the lockfile and available offline.
async function main() {
const packageJsonPath = require.resolve('mermaid/package.json')
const [packageJson, bundleJs] = await Promise.all([
readFile(packageJsonPath, 'utf8').then(JSON.parse),
readFile(path.join(path.dirname(packageJsonPath), 'dist', 'mermaid.min.js'), 'utf8')
])
// A literal </script> inside the bundle would close the inline script tag it
// is embedded in; \/ is identical to / in JS strings, regexes, and comments.
const inlineSafeJs = bundleJs.replace(/<\/script/gi, '<\\/script')
const source = [
'// Generated by scripts/build-mermaid-webview-engine.mjs.',
`// Package: mermaid@${packageJson.version} (dist/mermaid.min.js, embedded verbatim).`,
'// Do not edit by hand; regenerate via pnpm postinstall.',
`export const MERMAID_ENGINE_JS = ${JSON.stringify(inlineSafeJs)}`,
''
].join('\n')
await writeFile(outputPath, source)
}
await main()
+3 -16
View File
@@ -5,25 +5,12 @@
// WebSocket reconnects and the first responses come back.
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { AccountsSnapshot } from '../components/AccountUsage'
// Why: the canonical shape, so persisted counts keep carrying countsProvenAt — the home card
// needs it to know whether a rehydrated count is minutes or days old.
import type { HostWorktreeInfo } from '../worktree/home-worktree-info'
const STORAGE_KEY = 'orca:home-snapshot:v1'
type WorktreeSummary = {
worktreeId: string
repo: string
branch: string
displayName: string
liveTerminalCount: number
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
}
type HostWorktreeInfo = {
hostId: string
totalWorktrees: number
activeCount: number
lastActiveWorktree: WorktreeSummary | null
}
export type HomeSnapshot = {
worktreeInfo: Record<string, HostWorktreeInfo>
accountsByHost: Record<string, AccountsSnapshot>
@@ -0,0 +1,93 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ConnectionVerdict } from '../transport/connection-health'
import type { HostProfile } from '../transport/types'
import {
markHomeWorktreeCatalogUnavailable,
type HostWorktreeInfo
} from '../worktree/home-worktree-info'
import { MobileHostCard } from './MobileHostCard'
vi.mock('react-native', () => ({
Pressable: 'Pressable',
StyleSheet: { create: <T,>(styles: T) => styles },
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight', Monitor: 'Monitor' }))
vi.mock('./StatusDot', () => ({ StatusDot: 'StatusDot' }))
const host: HostProfile = {
id: 'host-1',
name: 'Studio',
endpoint: 'ws://studio.local:8765',
deviceToken: 'token',
publicKeyB64: 'key',
lastConnected: 0
}
const verdict: ConnectionVerdict = { kind: 'normal', label: 'Connected' }
const loaded: HostWorktreeInfo = {
hostId: 'host-1',
totalWorktrees: 12,
activeCount: 2,
lastActiveWorktree: null,
countsProvenAt: Date.now()
}
describe('MobileHostCard', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
async function renderCard(worktreeInfo: HostWorktreeInfo | undefined): Promise<string[]> {
await act(async () => {
renderer = create(
createElement(MobileHostCard, {
host,
state: 'connected',
verdict,
path: 'lan',
worktreeInfo,
onPress: () => {},
onLongPress: () => {}
})
)
})
return renderer!.root
.findAllByType('Text')
.flatMap((node) => node.children.filter((child) => typeof child === 'string'))
}
it('renders the counts the host proved', async () => {
expect(await renderCard(loaded)).toContain('12 worktrees · 2 active')
})
it('keeps rendering the last proven counts after a failed refresh', async () => {
// The regression this card shipped once: the caller dropped the counts the
// failure path deliberately preserved.
expect(await renderCard(markHomeWorktreeCatalogUnavailable(loaded, 'host-1'))).toContain(
'Last known: 12 worktrees · 2 active'
)
})
it('never asserts a count for a catalog that failed with nothing proven', async () => {
expect(await renderCard(markHomeWorktreeCatalogUnavailable(undefined, 'host-1'))).toContain(
'Worktree list unavailable'
)
})
it('shows no worktree line before the first read lands', async () => {
const lines = await renderCard(undefined)
expect(lines).not.toContain('0 worktrees')
expect(lines).not.toContain('Worktree list unavailable')
})
})
+5 -9
View File
@@ -6,6 +6,7 @@ import { mobileConnectionPathLabel } from '../transport/mobile-connection-path-l
import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client'
import type { ConnectionState, HostProfile } from '../transport/types'
import { colors, radii, spacing } from '../theme/mobile-theme'
import { homeHostWorktreeSummary, type HostWorktreeInfo } from '../worktree/home-worktree-info'
import { StatusDot } from './StatusDot'
export function MobileHostCard(props: {
@@ -13,20 +14,15 @@ export function MobileHostCard(props: {
state: ConnectionState
verdict: ConnectionVerdict
path: MobileConnectionPath
worktreeCounts?: { total: number; active: number }
// Why (STA-3123): the host is connected but its worktree catalog request failed,
// so the card must say "unavailable" instead of asserting a count of zero.
worktreeCountsUnavailable?: boolean
// Why: the card owns the fresh/stale/unavailable wording so no caller can re-gate the counts
// away (STA-3123 shipped that bug once already).
worktreeInfo?: HostWorktreeInfo
onPress: () => void
onLongPress: () => void
}) {
const connected = props.state === 'connected'
const isError = ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind)
const worktreeSummary = props.worktreeCounts
? `${props.worktreeCounts.total} worktree${props.worktreeCounts.total === 1 ? '' : 's'}${props.worktreeCounts.active > 0 ? ` · ${props.worktreeCounts.active} active` : ''}`
: props.worktreeCountsUnavailable
? 'Worktree list unavailable'
: null
const worktreeSummary = homeHostWorktreeSummary(props.worktreeInfo)
return (
<Pressable
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
@@ -13,6 +13,7 @@ vi.mock('react-native', () => ({
Text: 'Text',
View: 'View'
}))
vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' }))
function flattenText(node: ReactTestInstance): string {
return node.children
@@ -1,8 +1,40 @@
import { describe, expect, it } from 'vitest'
import { isMobileMermaidLanguage } from './mobile-mermaid-language'
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
import { parseMobileMarkdown } from './mobile-markdown-parser'
describe('isMobileMermaidLanguage', () => {
it('matches mermaid case-insensitively after trim', () => {
expect(isMobileMermaidLanguage('mermaid')).toBe(true)
expect(isMobileMermaidLanguage('Mermaid')).toBe(true)
expect(isMobileMermaidLanguage(' MERMAID ')).toBe(true)
})
it('rejects non-mermaid languages and missing language', () => {
expect(isMobileMermaidLanguage(undefined)).toBe(false)
expect(isMobileMermaidLanguage('')).toBe(false)
expect(isMobileMermaidLanguage('ts')).toBe(false)
expect(isMobileMermaidLanguage('mermaidx')).toBe(false)
})
})
describe('parseMobileMarkdown', () => {
it('parses mermaid fences as code blocks with language mermaid', () => {
expect(parseMobileMarkdown('```mermaid\ngraph TD; A-->B\n```')).toEqual([
{ type: 'code', text: 'graph TD; A-->B', language: 'mermaid', closed: true }
])
expect(isMobileMermaidLanguage('mermaid')).toBe(true)
})
it('marks an unterminated fence as not closed while it streams', () => {
expect(parseMobileMarkdown('```mermaid\ngraph TD; A-->B')).toEqual([
{ type: 'code', text: 'graph TD; A-->B', language: 'mermaid', closed: false }
])
expect(parseMobileMarkdown('```mermaid\ngraph TD; A-->B\n```')[0]).toMatchObject({
closed: true
})
})
it('parses GFM tables into table blocks', () => {
expect(parseMobileMarkdown('| Name | State |\n| --- | --- |\n| Orca | Open |')).toEqual([
{
+19
View File
@@ -12,7 +12,9 @@ import {
isIntrawordUnderscoreToken,
trimAutolinkTrailingPunctuation
} from './markdown-inline-token-rules'
import { isMobileMermaidLanguage } from './mobile-mermaid-language'
import { parseMobileMarkdown } from './mobile-markdown-parser'
import { MermaidDiagram } from './pr-sidebar/MermaidDiagram'
type Props = {
content?: string
@@ -29,6 +31,8 @@ type Props = {
const MAX_TABLE_ROWS = 40
const MAX_TABLE_COLUMNS = 8
/** Prose base size — passed to MermaidDiagram fallback mono text. */
const MERMAID_BASE = 13
// Web/mail hrefs open the system handler; file-target hrefs (file: URIs and
// scheme-less paths — the entire desktop file-link contract) go to onOpenFile.
@@ -177,6 +181,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
if (!text) {
return fallback ? <Text style={styles.paragraph}>{fallback}</Text> : null
}
const mermaidSourceOccurrences = new Map<string, number>()
return (
<View style={styles.root}>
@@ -199,6 +204,20 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
)
}
if (block.type === 'code') {
// Mermaid fences render as diagrams (WebView), not as raw code — same as PR sidebar.
// Unclosed fences are still streaming: mounting the WebView per tick would
// reload its document up to 20x/sec, so they stay raw code until terminated.
if (isMobileMermaidLanguage(block.language) && block.closed) {
const occurrence = mermaidSourceOccurrences.get(block.text) ?? 0
mermaidSourceOccurrences.set(block.text, occurrence + 1)
return (
<MermaidDiagram
key={`${block.text}:${occurrence}`}
source={block.text}
base={MERMAID_BASE}
/>
)
}
return (
<View key={index} style={styles.codeBlock}>
{block.language ? <Text style={styles.codeLanguage}>{block.language}</Text> : null}
@@ -0,0 +1,80 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileMarkdown } from './MobileMarkdown'
vi.mock('react-native', () => ({
Linking: { openURL: vi.fn() },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: <T>(styles: T) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' }))
let renderer: ReactTestRenderer | undefined
afterEach(() => {
renderer?.unmount()
renderer = undefined
vi.restoreAllMocks()
})
function render(content: string): ReactTestRenderer {
act(() => {
renderer = create(createElement(MobileMarkdown, { content }))
})
return renderer!
}
function mermaidCount(tree: ReactTestRenderer): number {
return tree.root.findAllByType('MermaidDiagram' as never).length
}
function firstMermaid(tree: ReactTestRenderer) {
return tree.root.findByType('MermaidDiagram' as never)
}
describe('MobileMarkdown mermaid routing', () => {
it('routes a closed mermaid fence to MermaidDiagram', () => {
const tree = render('```mermaid\ngraph TD; A-->B\n```')
expect(mermaidCount(tree)).toBe(1)
})
it('keeps a streaming (unterminated) mermaid fence as raw code', () => {
const tree = render('```mermaid\ngraph TD; A-->B')
expect(mermaidCount(tree)).toBe(0)
})
it('keeps identical sibling diagrams uniquely keyed', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const diagram = '```mermaid\ngraph TD; A-->B\n```'
const tree = render(`${diagram}\n\n${diagram}`)
const warnings = consoleError.mock.calls.flat().join(' ')
expect(mermaidCount(tree)).toBe(2)
expect(warnings).not.toContain('same key')
})
it('preserves a completed diagram while later prose streams', () => {
const diagram = '```mermaid\ngraph TD; A-->B\n```'
const tree = render(diagram)
const initial = firstMermaid(tree)
act(() => tree.update(createElement(MobileMarkdown, { content: `${diagram}\n\nNext` })))
expect(firstMermaid(tree)).toBe(initial)
})
it('remounts a diagram when its source changes', () => {
const tree = render('```mermaid\ngraph TD; A-->B\n```')
const initial = firstMermaid(tree)
act(() =>
tree.update(createElement(MobileMarkdown, { content: '```mermaid\ngraph TD; A-->C\n```' }))
)
expect(firstMermaid(tree)).not.toBe(initial)
})
it('does not route other code fences to MermaidDiagram', () => {
const tree = render('```ts\nconst a = 1\n```')
expect(mermaidCount(tree)).toBe(0)
})
})
@@ -2,7 +2,7 @@ export type MobileMarkdownBlock =
| { type: 'paragraph'; text: string }
| { type: 'heading'; level: number; text: string }
| { type: 'quote'; text: string }
| { type: 'code'; text: string; language?: string }
| { type: 'code'; text: string; language?: string; closed: boolean }
| { type: 'list'; ordered: boolean; items: Array<{ text: string; checked?: boolean }> }
| { type: 'image'; alt: string; url: string }
| { type: 'table'; headers: string[]; rows: string[][] }
@@ -42,10 +42,12 @@ export function parseMobileMarkdown(content: string): MobileMarkdownBlock[] {
code.push(lines[index] ?? '')
index += 1
}
if (index < lines.length) {
// closed=false means the fence is still streaming in (no terminator yet).
const closed = index < lines.length
if (closed) {
index += 1
}
blocks.push({ type: 'code', text: code.join('\n'), language: fence[1] })
blocks.push({ type: 'code', text: code.join('\n'), language: fence[1], closed })
continue
}
@@ -0,0 +1,4 @@
/** True when a fenced code block language should render as a Mermaid diagram. */
export function isMobileMermaidLanguage(language?: string): boolean {
return language?.trim().toLowerCase() === 'mermaid'
}
@@ -1,7 +1,8 @@
import { useMemo, useState } from 'react'
import { memo, useMemo, useState } from 'react'
import { ScrollView, StyleSheet, Text, View } from 'react-native'
import { WebView } from 'react-native-webview'
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
import { MERMAID_ENGINE_JS } from './mermaid-webview-engine.generated'
type Props = {
source: string
@@ -9,11 +10,15 @@ type Props = {
}
// Renders a ```mermaid fence as a diagram via a sandboxed WebView (mermaid has no
// native RN renderer). Mermaid is loaded from a CDN inside the WebView HTML, the
// SVG is themed dark to match the sidebar, and the WebView posts back its rendered
// height so we can size to content. On any failure (no network, parse error,
// render error) we fall back to the raw source in a labeled mono code box.
export function MermaidDiagram({ source, base }: Props) {
// native RN renderer). Mermaid ships inside the app as a generated bundle embedded
// in the WebView HTML — no network — the SVG is themed dark to match the sidebar,
// and the WebView posts back its rendered height so we can size to content. On any
// failure (parse error, render error) we fall back to the raw source in a labeled
// mono code box.
// memo: both props are primitives; without it every mounted diagram re-renders
// per frame during pinch-to-zoom (textScale updates), marshalling the full HTML
// string across the Fabric boundary each time.
export const MermaidDiagram = memo(function MermaidDiagram({ source, base }: Props) {
const [height, setHeight] = useState(0)
const [failed, setFailed] = useState(false)
const html = useMemo(() => buildHtml(source), [source])
@@ -58,7 +63,7 @@ export function MermaidDiagram({ source, base }: Props) {
/>
</View>
)
}
})
function MermaidFallback({ source, base }: Props) {
return (
@@ -73,21 +78,33 @@ function MermaidFallback({ source, base }: Props) {
)
}
// Self-contained HTML: load mermaid from CDN, render the graph, post the body
// JSON.stringify escapes quotes and control chars but leaves `<`, `>`, `&`, and
// the U+2028/U+2029 line separators raw — so a source containing `</script>`
// would close this inline <script> and let the rest execute as markup. Diagram
// source is untrusted (agent output, PR/chat content), so escape those to \uXXXX;
// the literal still parses back to the exact original string inside the WebView.
function encodeSourceForScript(source: string): string {
return JSON.stringify(source).replace(
/[<>&\u2028\u2029]/g,
(char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`
)
}
// Self-contained HTML: embedded mermaid bundle, render the graph, post the body
// height (or "error") back to RN. Theme variables match the dark sidebar palette.
function buildHtml(source: string): string {
// JSON.stringify safely escapes the user's diagram source for embedding.
const encoded = JSON.stringify(source)
export function buildHtml(source: string): string {
const encoded = encodeSourceForScript(source)
return `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; base-uri 'none'; form-action 'none'" />
<style>
html, body { margin: 0; padding: 0; background: ${colors.bgRaised}; }
#c { padding: 8px; }
#c svg { max-width: 100%; height: auto; }
</style>
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<script>${MERMAID_ENGINE_JS}</script>
</head>
<body>
<div id="c"><pre class="mermaid"></pre></div>
@@ -114,7 +131,7 @@ function buildHtml(source: string): string {
}
});
mermaid.run({ querySelector: '.mermaid' })
.then(reportHeight)
.then(function () { reportHeight(); })
.catch(function () { post('error'); });
} catch (e) {
post('error');
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from 'vitest'
import { buildHtml } from './MermaidDiagram'
import { MERMAID_ENGINE_JS } from './mermaid-webview-engine.generated'
vi.mock('react-native', () => ({
ScrollView: 'ScrollView',
StyleSheet: { create: <T>(styles: T) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
vi.mock('react-native-webview', () => ({ WebView: 'WebView' }))
// The diagram source is untrusted (agent output, PR/chat content). It is embedded
// inside an inline <script>, so it must not be able to close that script element.
describe('buildHtml source escaping', () => {
it('does not let a </script> payload break out of the inline script', () => {
const payload = 'graph TD; A-->B</script><script>window.evil=1</script>'
const countClosers = (html: string) => (html.match(/<\/script>/gi) ?? []).length
// The payload's two </script> must add zero raw closers over a benign render —
// they were neutralized to \u003c instead of closing our inline script.
const benign = countClosers(buildHtml('graph TD; A-->B'))
expect(countClosers(buildHtml(payload))).toBe(benign)
expect(buildHtml(payload)).toContain('\\u003c/script')
})
it('escapes the U+2028/U+2029 line separators that would break the JS literal', () => {
const payload = `a${String.fromCharCode(0x2028)}b${String.fromCharCode(0x2029)}c`
const html = buildHtml(payload)
expect(html).toContain('\\u2028')
expect(html).toContain('\\u2029')
expect(html.includes(String.fromCharCode(0x2028))).toBe(false)
expect(html.includes(String.fromCharCode(0x2029))).toBe(false)
})
it('still round-trips ordinary source to the exact original string', () => {
const payload = 'graph LR\n A["node & <tag>"] --> B'
const html = buildHtml(payload)
const match = html.match(/\.textContent = (".*?");\n {4}mermaid\.initialize/s)
expect(match).not.toBeNull()
expect(JSON.parse(match![1]!)).toBe(payload)
})
// Same gate as the terminal document: mermaid is embedded from the lockfile-pinned
// package, so the document itself must load nothing external (offline-safe, no CDN
// supply-chain exposure). The engine's internal URL literals (xmlns, docs links)
// are inert data, so the gate checks the document with the engine stripped out.
it('embeds the mermaid engine and loads no external resource', () => {
const html = buildHtml('graph TD; A-->B')
expect(html).toContain(MERMAID_ENGINE_JS)
expect(html.replace(MERMAID_ENGINE_JS, '')).not.toMatch(/\bhttps?:\/\//)
})
it('blocks external resources requested by diagram syntax', () => {
const html = buildHtml('flowchart LR\nA@{ img: "https://example.com/pixel.png" }')
const policy = html.match(/Content-Security-Policy" content="([^"]+)"/)?.[1]
expect(policy).toContain("default-src 'none'")
expect(policy).toContain('img-src data: blob:')
expect(policy).not.toMatch(/https?:/)
})
})
@@ -135,6 +135,56 @@ describe('openMobileFileTap', () => {
expect(switchSessionTab).toHaveBeenCalledWith(openedTab)
})
it('opens a sibling terminal path through the resolved owning worktree', async () => {
const client = createClient([
ok({
worktree: 'wt-2',
relativePath: 'docs/readme.md',
absolutePath: '/repo-b/docs/readme.md',
exists: true,
isDirectory: false,
openTarget: {
kind: 'worktree-file',
provider: 'local',
relativePath: 'docs/readme.md',
absolutePath: '/repo-b/docs/readme.md'
}
})
])
const pushPreviewRoute = vi.fn()
openMobileFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
pathText: '/repo-b/docs/readme.md',
line: null,
column: null,
pushPreviewRoute,
openBrowser: vi.fn(),
triggerOpenFeedback: vi.fn(),
fetchSessionTabs: vi.fn(),
getSessionTabs: () => [],
getActiveSessionTabId: () => null,
getActivationState: activeTerminalState,
switchSessionTab: vi.fn(),
scheduleDelayedAction: vi.fn()
})
await Promise.resolve()
await Promise.resolve()
expect(pushPreviewRoute).toHaveBeenCalledWith({
pathname: '/h/[hostId]/files/preview/[worktreeId]',
params: expect.objectContaining({
hostId: 'host-1',
worktreeId: 'wt-2',
source: 'worktree',
relativePath: 'docs/readme.md'
})
})
expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything())
})
it('opens worktree-contained line references through the preview route', async () => {
const client = createClient([
ok({
+14 -6
View File
@@ -95,13 +95,17 @@ async function openMobileFileTapAsync<T extends FileTapSessionTab>(
if (!shouldActivateOpenedMobileSessionTab(options.getActivationState(false))) {
return
}
const resolvedWorktreeId = resolved.worktree?.trim() || options.worktreeId
const resolvedWorktree = `id:${resolvedWorktreeId}`
const resolvedWorktreeName =
resolvedWorktreeId === options.worktreeId ? options.worktreeName : undefined
if (resolved.openTarget?.kind === 'absolute-file') {
options.triggerOpenFeedback()
options.pushPreviewRoute(
createMobileFilePreviewHref({
hostId: options.hostId,
worktreeId: options.worktreeId,
worktreeId: resolvedWorktreeId,
source: 'terminalArtifact',
absolutePath: resolved.openTarget.absolutePath,
grantId: resolved.openTarget.grantId,
@@ -113,7 +117,7 @@ async function openMobileFileTapAsync<T extends FileTapSessionTab>(
name: displayNameFromPath(resolved.openTarget.absolutePath),
...(options.line !== null ? { line: String(options.line) } : {}),
...(options.column !== null ? { column: String(options.column) } : {}),
...(options.worktreeName ? { worktreeName: options.worktreeName } : {})
...(resolvedWorktreeName ? { worktreeName: resolvedWorktreeName } : {})
})
)
return
@@ -128,17 +132,21 @@ async function openMobileFileTapAsync<T extends FileTapSessionTab>(
return
}
options.triggerOpenFeedback()
if (options.line !== null || options.column !== null) {
if (
resolvedWorktreeId !== options.worktreeId ||
options.line !== null ||
options.column !== null
) {
options.pushPreviewRoute(
createMobileFilePreviewHref({
hostId: options.hostId,
worktreeId: options.worktreeId,
worktreeId: resolvedWorktreeId,
source: 'worktree',
relativePath: openedPath,
name: displayNameFromPath(openedPath),
...(options.line !== null ? { line: String(options.line) } : {}),
...(options.column !== null ? { column: String(options.column) } : {}),
...(options.worktreeName ? { worktreeName: options.worktreeName } : {})
...(resolvedWorktreeName ? { worktreeName: resolvedWorktreeName } : {})
})
)
return
@@ -153,7 +161,7 @@ async function openMobileFileTapAsync<T extends FileTapSessionTab>(
}
const openResponse = await options.client.sendRequest(
'files.open',
{ worktree, relativePath: openedPath },
{ worktree: resolvedWorktree, relativePath: openedPath },
{ timeoutMs: 15_000 }
)
if (!openResponse.ok) {
@@ -141,12 +141,35 @@ export class MobileTerminalDiagnostics {
})
}
streamResubscribing(handle: string, seq: number, dims: { cols: number; rows: number }): void {
streamResubscribing(
handle: string,
seq: number,
dims: { cols: number; rows: number },
attempt: number,
delayMs: number
): void {
logMobileTerminalDiagnostic('stream-resubscribe-for-viewport', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
cols: dims.cols,
rows: dims.rows
rows: dims.rows,
attempt,
delayMs
})
}
streamResubscribeHeld(handle: string, seq: number): void {
logMobileTerminalDiagnostic('stream-resubscribe-held-absent-dims', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq
})
}
streamResubscribeExhausted(handle: string, seq: number, attempts: number): void {
logMobileTerminalDiagnostic('stream-resubscribe-exhausted', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
attempts
})
}
@@ -0,0 +1,472 @@
import { describe, expect, it, vi } from 'vitest'
import {
MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS,
TerminalViewportResubscribeBudget,
readTerminalViewportDims,
resolveTerminalViewportResubscribe,
runTerminalViewportFitPass,
shouldResubscribeAfterViewportMeasure,
type TerminalViewportFitPassArgs
} from './mobile-terminal-viewport-resubscribe'
const PHONE = { cols: 40, rows: 50 }
describe('readTerminalViewportDims', () => {
it('accepts only usable numeric host dimensions', () => {
expect(readTerminalViewportDims({ cols: 40, rows: 50 })).toEqual({
hostCols: 40,
hostRows: 50
})
expect(readTerminalViewportDims({ cols: Number.NaN, rows: 0 })).toEqual({
hostCols: null,
hostRows: null
})
})
})
describe('resolveTerminalViewportResubscribe', () => {
it('resubscribes immediately on the first pass when the viewport is unmeasured', () => {
expect(
resolveTerminalViewportResubscribe({
hostCols: 80,
hostRows: 24,
viewportMeasured: false,
viewport: null,
attempts: 0
})
).toEqual({ kind: 'resubscribe', delayMs: 0 })
})
it('caps even the unmeasured pass once the budget is spent', () => {
expect(
resolveTerminalViewportResubscribe({
hostCols: 80,
hostRows: 24,
viewportMeasured: false,
viewport: null,
attempts: MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS
})
).toEqual({ kind: 'exhausted' })
})
it('holds on absent host dims instead of resubscribing (STA-3337 regression)', () => {
for (const [hostCols, hostRows] of [
[null, null],
[80, null],
[null, 24]
] as const) {
expect(
resolveTerminalViewportResubscribe({
hostCols,
hostRows,
viewportMeasured: true,
viewport: PHONE,
attempts: 0
})
).toEqual({ kind: 'hold' })
}
})
it('keeps holding on absent dims across repeated frames without spending budget', () => {
for (let frame = 0; frame < 50; frame += 1) {
expect(
resolveTerminalViewportResubscribe({
hostCols: null,
hostRows: null,
viewportMeasured: true,
viewport: PHONE,
attempts: 0
}).kind
).toBe('hold')
}
})
it('converges when host dims match the measured viewport', () => {
expect(
resolveTerminalViewportResubscribe({
hostCols: PHONE.cols,
hostRows: PHONE.rows,
viewportMeasured: true,
viewport: PHONE,
attempts: 2
})
).toEqual({ kind: 'converged' })
})
it('backs off across mismatch attempts and then exhausts', () => {
const delays = [0, 1, 2].map((attempts) => {
const decision = resolveTerminalViewportResubscribe({
hostCols: 80,
hostRows: 24,
viewportMeasured: true,
viewport: PHONE,
attempts
})
if (decision.kind !== 'resubscribe') {
throw new Error(`expected resubscribe at attempt ${attempts}, got ${decision.kind}`)
}
return decision.delayMs
})
expect(delays[0]).toBe(0)
expect(delays[1]).toBeGreaterThan(0)
expect(delays[2]).toBeGreaterThan(delays[1])
expect(
resolveTerminalViewportResubscribe({
hostCols: 80,
hostRows: 24,
viewportMeasured: true,
viewport: PHONE,
attempts: MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS
})
).toEqual({ kind: 'exhausted' })
})
})
describe('shouldResubscribeAfterViewportMeasure', () => {
it('always resubscribes when the viewport was never measured (server must learn it)', () => {
expect(
shouldResubscribeAfterViewportMeasure({
hostCols: PHONE.cols,
hostRows: PHONE.rows,
measured: PHONE,
viewportWasMeasured: false
})
).toBe(true)
})
it('skips the resubscribe when the fresh measure already matches the host', () => {
expect(
shouldResubscribeAfterViewportMeasure({
hostCols: PHONE.cols,
hostRows: PHONE.rows,
measured: PHONE,
viewportWasMeasured: true
})
).toBe(false)
})
it('resubscribes when the host still disagrees with the fresh measure', () => {
expect(
shouldResubscribeAfterViewportMeasure({
hostCols: 80,
hostRows: 24,
measured: PHONE,
viewportWasMeasured: true
})
).toBe(true)
})
})
describe('TerminalViewportResubscribeBudget', () => {
const exhaust = (budget: TerminalViewportResubscribeBudget, handle: string) => {
for (let i = 0; i < MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS; i += 1) {
budget.chargeAttempt(handle)
}
}
it('starts at zero and counts charged attempts per handle', () => {
const budget = new TerminalViewportResubscribeBudget()
expect(budget.attempts('t1')).toBe(0)
budget.chargeAttempt('t1')
budget.chargeAttempt('t1')
expect(budget.attempts('t1')).toBe(2)
expect(budget.attempts('t2')).toBe(0)
})
it('resets attempts and re-arms the announcement on convergence', () => {
const budget = new TerminalViewportResubscribeBudget()
exhaust(budget, 't1')
expect(budget.shouldAnnounceExhaustion('t1')).toBe(true)
budget.markConverged('t1')
expect(budget.attempts('t1')).toBe(0)
expect(budget.shouldAnnounceExhaustion('t1')).toBe(true)
})
it('announces exhaustion exactly once', () => {
const budget = new TerminalViewportResubscribeBudget()
exhaust(budget, 't1')
expect(budget.shouldAnnounceExhaustion('t1')).toBe(true)
expect(budget.shouldAnnounceExhaustion('t1')).toBe(false)
})
it('does not refill an exhausted handle that stayed listed', () => {
const budget = new TerminalViewportResubscribeBudget()
exhaust(budget, 't1')
for (let refresh = 0; refresh < 5; refresh += 1) {
budget.notifyListedHandles(new Set(['t1']))
}
expect(budget.attempts('t1')).toBe(MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS)
})
it('refills only after the exhausted handle went absent and came back', () => {
const budget = new TerminalViewportResubscribeBudget()
exhaust(budget, 't1')
expect(budget.shouldAnnounceExhaustion('t1')).toBe(true)
budget.notifyListedHandles(new Set())
// Still exhausted while absent — the refill lands on the return.
expect(budget.attempts('t1')).toBe(MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS)
budget.notifyListedHandles(new Set(['t1']))
expect(budget.attempts('t1')).toBe(0)
expect(budget.shouldAnnounceExhaustion('t1')).toBe(true)
})
it('leaves below-cap handles untouched by list refreshes', () => {
const budget = new TerminalViewportResubscribeBudget()
budget.chargeAttempt('t1')
budget.notifyListedHandles(new Set())
budget.notifyListedHandles(new Set(['t1']))
expect(budget.attempts('t1')).toBe(1)
})
it('forget clears one handle, clear clears everything', () => {
const budget = new TerminalViewportResubscribeBudget()
exhaust(budget, 't1')
exhaust(budget, 't2')
budget.forget('t1')
expect(budget.attempts('t1')).toBe(0)
expect(budget.attempts('t2')).toBe(MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS)
budget.clear()
expect(budget.attempts('t2')).toBe(0)
})
})
describe('runTerminalViewportFitPass', () => {
const HANDLE = 't1'
function makeHarness(overrides: {
hostCols?: number | null
hostRows?: number | null
viewportMeasured?: boolean
viewport?: { cols: number; rows: number } | null
measured?: { cols: number; rows: number } | null
budget?: TerminalViewportResubscribeBudget
}) {
const budget = overrides.budget ?? new TerminalViewportResubscribeBudget()
const diagnostics = {
streamResubscribing: vi.fn(),
streamResubscribeHeld: vi.fn(),
streamResubscribeExhausted: vi.fn()
}
const terminalUnsubsRef = { current: new Map<string, () => void>([[HANDLE, () => {}]]) }
const scheduled: { fn: () => void; ms: number }[] = []
const webView = {
awaitReady: () => Promise.resolve(),
measureFitDimensions: () => Promise.resolve(overrides.measured ?? PHONE)
}
const unsubscribeTerminal = vi.fn((handle: string) => {
terminalUnsubsRef.current.delete(handle)
})
// Mimic the real subscribe path: arming registers an unsubscribe handle.
const subscribeToTerminal = vi.fn((handle: string) => {
terminalUnsubsRef.current.set(handle, () => {})
})
const showToast = vi.fn()
const args: TerminalViewportFitPassArgs = {
handle: HANDLE,
seq: 1,
hostCols: overrides.hostCols ?? null,
hostRows: overrides.hostRows ?? null,
budget,
diagnostics,
viewportRef: { current: overrides.viewport ?? null },
viewportMeasuredRef: { current: overrides.viewportMeasured ?? false },
subscribeSeqRef: { current: new Map([[HANDLE, 1]]) },
initializedHandlesRef: { current: new Set([HANDLE]) },
terminalUnsubsRef,
terminalFrameHeightRef: { current: 0 },
getTerminalRef: () => webView,
unsubscribeTerminal,
subscribeToTerminal,
scheduleDelayedAction: (fn, ms) => scheduled.push({ fn, ms }),
showToast
}
return {
args,
budget,
diagnostics,
scheduled,
subscribeToTerminal,
unsubscribeTerminal,
showToast
}
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
it('holds without any teardown when host dims are absent (STA-3337 regression)', async () => {
const h = makeHarness({ viewportMeasured: true, viewport: PHONE })
runTerminalViewportFitPass(h.args)
await settle()
expect(h.unsubscribeTerminal).not.toHaveBeenCalled()
expect(h.subscribeToTerminal).not.toHaveBeenCalled()
expect(h.diagnostics.streamResubscribeHeld).toHaveBeenCalledTimes(1)
})
it('measures and resubscribes immediately on the first pass, charging one attempt', async () => {
const h = makeHarness({ hostCols: 80, hostRows: 24 })
runTerminalViewportFitPass(h.args)
await settle()
expect(h.unsubscribeTerminal).toHaveBeenCalledTimes(1)
expect(h.subscribeToTerminal).toHaveBeenCalledTimes(1)
expect(h.args.viewportRef.current).toEqual(PHONE)
expect(h.args.viewportMeasuredRef.current).toBe(true)
expect(h.budget.attempts(HANDLE)).toBe(1)
})
it('treats an equal fresh measure as convergence instead of resubscribing', async () => {
const budget = new TerminalViewportResubscribeBudget()
budget.chargeAttempt(HANDLE)
// Stale cached viewport disagrees with the host, but the fresh measure matches it.
const h = makeHarness({
hostCols: PHONE.cols,
hostRows: PHONE.rows,
viewportMeasured: true,
viewport: { cols: 30, rows: 20 },
measured: PHONE,
budget
})
runTerminalViewportFitPass(h.args)
await settle()
expect(h.subscribeToTerminal).not.toHaveBeenCalled()
expect(h.budget.attempts(HANDLE)).toBe(0)
})
it('defers later attempts through the backoff scheduler while keeping the stream up', async () => {
const budget = new TerminalViewportResubscribeBudget()
budget.chargeAttempt(HANDLE)
const h = makeHarness({
hostCols: 80,
hostRows: 24,
viewportMeasured: true,
viewport: PHONE,
budget
})
runTerminalViewportFitPass(h.args)
await settle()
expect(h.scheduled).toHaveLength(1)
expect(h.scheduled[0].ms).toBeGreaterThan(0)
// The stream must still be up until the deferred retry fires.
expect(h.unsubscribeTerminal).not.toHaveBeenCalled()
h.scheduled[0].fn()
expect(h.unsubscribeTerminal).toHaveBeenCalledTimes(1)
expect(h.subscribeToTerminal).toHaveBeenCalledTimes(1)
expect(h.budget.attempts(HANDLE)).toBe(2)
})
it('drops a deferred retry whose subscribe generation went stale', async () => {
const budget = new TerminalViewportResubscribeBudget()
budget.chargeAttempt(HANDLE)
const h = makeHarness({
hostCols: 80,
hostRows: 24,
viewportMeasured: true,
viewport: PHONE,
budget
})
runTerminalViewportFitPass(h.args)
await settle()
expect(h.scheduled).toHaveLength(1)
h.args.subscribeSeqRef.current.set(HANDLE, 2)
h.scheduled[0].fn()
expect(h.unsubscribeTerminal).not.toHaveBeenCalled()
expect(h.budget.attempts(HANDLE)).toBe(1)
})
it('drops a deferred retry after the live stream converges', async () => {
const budget = new TerminalViewportResubscribeBudget()
budget.chargeAttempt(HANDLE)
const h = makeHarness({
hostCols: 80,
hostRows: 24,
viewportMeasured: true,
viewport: PHONE,
budget
})
runTerminalViewportFitPass(h.args)
await settle()
expect(h.scheduled).toHaveLength(1)
expect(h.budget.observeResize(HANDLE, PHONE, PHONE)).toEqual([PHONE.cols, PHONE.rows])
h.scheduled[0].fn()
expect(h.unsubscribeTerminal).not.toHaveBeenCalled()
expect(h.subscribeToTerminal).not.toHaveBeenCalled()
expect(h.budget.attempts(HANDLE)).toBe(0)
})
it('announces exhaustion once and stops touching the stream', async () => {
const budget = new TerminalViewportResubscribeBudget()
for (let i = 0; i < MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS; i += 1) {
budget.chargeAttempt(HANDLE)
}
const h = makeHarness({
hostCols: 80,
hostRows: 24,
viewportMeasured: true,
viewport: PHONE,
budget
})
runTerminalViewportFitPass(h.args)
runTerminalViewportFitPass(h.args)
await settle()
expect(h.unsubscribeTerminal).not.toHaveBeenCalled()
expect(h.subscribeToTerminal).not.toHaveBeenCalled()
expect(h.showToast).toHaveBeenCalledTimes(1)
expect(h.diagnostics.streamResubscribeExhausted).toHaveBeenCalledTimes(2)
})
})
describe('STA-3337 stream shapes', () => {
it('empty scrollback with absent dims settles after a single register pass', () => {
const budget = new TerminalViewportResubscribeBudget()
// Pass 1: no viewport yet — measure and resubscribe so the server learns it.
const first = resolveTerminalViewportResubscribe({
hostCols: null,
hostRows: null,
viewportMeasured: false,
viewport: null,
attempts: budget.attempts('t1')
})
expect(first).toEqual({ kind: 'resubscribe', delayMs: 0 })
budget.chargeAttempt('t1')
// Pass 2+: host still reports no dims — the stream must be left alone.
for (let frame = 0; frame < 10; frame += 1) {
expect(
resolveTerminalViewportResubscribe({
hostCols: null,
hostRows: null,
viewportMeasured: true,
viewport: PHONE,
attempts: budget.attempts('t1')
}).kind
).toBe('hold')
}
expect(budget.attempts('t1')).toBe(1)
})
it('non-converging numeric dims degrade after the bounded backoff run', () => {
const budget = new TerminalViewportResubscribeBudget()
const kinds: string[] = []
for (let frame = 0; frame < 6; frame += 1) {
const decision = resolveTerminalViewportResubscribe({
hostCols: 80,
hostRows: 24,
viewportMeasured: frame > 0,
viewport: frame > 0 ? PHONE : null,
attempts: budget.attempts('t1')
})
kinds.push(decision.kind)
if (decision.kind === 'resubscribe') {
budget.chargeAttempt('t1')
}
}
expect(kinds).toEqual([
'resubscribe',
'resubscribe',
'resubscribe',
'exhausted',
'exhausted',
'exhausted'
])
expect(budget.shouldAnnounceExhaustion('t1')).toBe(true)
expect(budget.shouldAnnounceExhaustion('t1')).toBe(false)
})
})
@@ -0,0 +1,293 @@
/** Bounds the scrollback→measure→resubscribe fit loop (STA-3337): a host whose
* frame dims can never equal the phone viewport must not re-arm the stream
* forever — it broke gesture recognition and drained battery at ~25 cycles/s. */
import type { MobileTerminalDiagnostics } from './mobile-terminal-diagnostics'
export const MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS = 3
/** Attempt-indexed teardown delay. Attempt 0 is the ordinary first fit pass
* (server learns the viewport) and must stay immediate; later attempts mean
* the server answered with non-matching dims, so probe at a decaying rate. */
const TERMINAL_VIEWPORT_RESUBSCRIBE_BACKOFF_MS = [0, 750, 3000] as const
export type TerminalViewportDims = { readonly cols: number; readonly rows: number }
function readPositiveDimension(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
}
export function readTerminalViewportDims(data: Readonly<Record<string, unknown>>): {
readonly hostCols: number | null
readonly hostRows: number | null
} {
return {
hostCols: readPositiveDimension(data.cols),
hostRows: readPositiveDimension(data.rows)
}
}
export type TerminalViewportResubscribeDecision =
| { readonly kind: 'resubscribe'; readonly delayMs: number }
| { readonly kind: 'converged' }
| { readonly kind: 'hold' }
| { readonly kind: 'exhausted' }
function resubscribeDelayMs(attempts: number): number {
return TERMINAL_VIEWPORT_RESUBSCRIBE_BACKOFF_MS[
Math.min(attempts, TERMINAL_VIEWPORT_RESUBSCRIBE_BACKOFF_MS.length - 1)
]
}
export function resolveTerminalViewportResubscribe(args: {
hostCols: number | null
hostRows: number | null
viewportMeasured: boolean
viewport: TerminalViewportDims | null
attempts: number
}): TerminalViewportResubscribeDecision {
const overBudget = args.attempts >= MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS
// First subscribe carries no viewport; resubscribing is how the server learns it.
if (!args.viewportMeasured || args.viewport == null) {
return overBudget ? { kind: 'exhausted' } : { kind: 'resubscribe', delayMs: 0 }
}
// Why: a host that doesn't report PTY dims can never converge — resubscribing
// replays the identical frame, so keep the stream instead of probing it.
if (args.hostCols == null || args.hostRows == null) {
return { kind: 'hold' }
}
if (args.hostCols === args.viewport.cols && args.hostRows === args.viewport.rows) {
return { kind: 'converged' }
}
return overBudget
? { kind: 'exhausted' }
: { kind: 'resubscribe', delayMs: resubscribeDelayMs(args.attempts) }
}
/** Post-measure re-check: the pre-measure mismatch may have been a stale cached
* viewport. Resubscribing is only productive when the server still disagrees
* with the fresh measure, or was never told the viewport at all. */
export function shouldResubscribeAfterViewportMeasure(args: {
hostCols: number | null
hostRows: number | null
measured: TerminalViewportDims
viewportWasMeasured: boolean
}): boolean {
if (!args.viewportWasMeasured) {
return true
}
return args.hostCols !== args.measured.cols || args.hostRows !== args.measured.rows
}
/** Per-handle resubscribe budget, mirroring the chat-side rearm bound: attempts
* refill only when the handle actually left terminal.list and came back. A
* still-listed non-converging handle re-funded on every list refresh would undo
* the bound this class exists to enforce. */
export class TerminalViewportResubscribeBudget {
private readonly attemptsByHandle = new Map<string, number>()
private readonly absentSinceExhaustion = new Set<string>()
private readonly announcedExhaustion = new Set<string>()
private readonly retryGenerationByHandle = new Map<string, object>()
attempts(handle: string): number {
return this.attemptsByHandle.get(handle) ?? 0
}
chargeAttempt(handle: string): void {
this.attemptsByHandle.set(handle, this.attempts(handle) + 1)
}
retryGeneration(handle: string): object {
const existing = this.retryGenerationByHandle.get(handle)
if (existing) {
return existing
}
const generation = {}
this.retryGenerationByHandle.set(handle, generation)
return generation
}
isRetryGenerationCurrent(handle: string, generation: object): boolean {
return this.retryGenerationByHandle.get(handle) === generation
}
observeResize(
handle: string,
data: Readonly<Record<string, unknown>>,
viewport: TerminalViewportDims | null
): readonly [number, number] {
const { hostCols, hostRows } = readTerminalViewportDims(data)
const cols = hostCols ?? 80
const rows = hostRows ?? 24
if (viewport?.cols === cols && viewport.rows === rows) {
this.markConverged(handle)
}
return [cols, rows]
}
markConverged(handle: string): void {
this.forget(handle)
}
/** True exactly once per exhaustion so the degraded state is announced, not spammed. */
shouldAnnounceExhaustion(handle: string): boolean {
if (this.announcedExhaustion.has(handle)) {
return false
}
this.announcedExhaustion.add(handle)
return true
}
notifyListedHandles(liveHandles: ReadonlySet<string>): void {
for (const handle of Array.from(this.attemptsByHandle.keys())) {
if (this.attempts(handle) < MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS) {
continue
}
if (!liveHandles.has(handle)) {
this.absentSinceExhaustion.add(handle)
continue
}
// Why: only an absence marker buys a refill — the handle's PTY may be live
// again, so a fresh budget (and a fresh degrade announcement) is warranted.
if (this.absentSinceExhaustion.delete(handle)) {
this.forget(handle)
}
}
}
forget(handle: string): void {
this.attemptsByHandle.delete(handle)
this.absentSinceExhaustion.delete(handle)
this.announcedExhaustion.delete(handle)
this.retryGenerationByHandle.delete(handle)
}
clear(): void {
this.attemptsByHandle.clear()
this.absentSinceExhaustion.clear()
this.announcedExhaustion.clear()
this.retryGenerationByHandle.clear()
}
}
type MutableRef<T> = { current: T }
type TerminalFitWebView = {
awaitReady: () => Promise<unknown>
measureFitDimensions: (frameHeight?: number) => Promise<TerminalViewportDims | null | undefined>
}
export type TerminalViewportFitPassArgs = {
handle: string
seq: number
hostCols: number | null
hostRows: number | null
budget: TerminalViewportResubscribeBudget
diagnostics: Pick<
MobileTerminalDiagnostics,
'streamResubscribing' | 'streamResubscribeHeld' | 'streamResubscribeExhausted'
>
viewportRef: MutableRef<TerminalViewportDims | null>
viewportMeasuredRef: MutableRef<boolean>
subscribeSeqRef: MutableRef<Map<string, number>>
initializedHandlesRef: MutableRef<Set<string>>
terminalUnsubsRef: MutableRef<Map<string, () => void>>
terminalFrameHeightRef: MutableRef<number>
getTerminalRef: (handle: string | null) => TerminalFitWebView | undefined
unsubscribeTerminal: (handle: string) => void
subscribeToTerminal: (handle: string) => void
scheduleDelayedAction: (fn: () => void, ms: number) => void
showToast: (message: string, durationMs?: number) => void
}
/** One bounded fit pass per scrollback frame: converge, hold, degrade visibly,
* or measure and resubscribe (backing off) so the server can phone-fit. */
export function runTerminalViewportFitPass(args: TerminalViewportFitPassArgs): void {
const { handle, seq, hostCols, hostRows, budget, diagnostics } = args
const retryGeneration = budget.retryGeneration(handle)
const decision = resolveTerminalViewportResubscribe({
hostCols,
hostRows,
viewportMeasured: args.viewportMeasuredRef.current,
viewport: args.viewportRef.current,
attempts: budget.attempts(handle)
})
if (decision.kind === 'converged') {
budget.markConverged(handle)
return
}
if (decision.kind === 'hold') {
diagnostics.streamResubscribeHeld(handle, seq)
return
}
if (decision.kind === 'exhausted') {
diagnostics.streamResubscribeExhausted(handle, seq, budget.attempts(handle))
if (budget.shouldAnnounceExhaustion(handle)) {
args.showToast("Couldn't fit the terminal to this screen", 4000)
}
return
}
const viewportWasMeasured = args.viewportMeasuredRef.current
void (async () => {
// Why: wait for init()'s rAF chain before measuring, else the measure races ahead and returns null (log dump 2026-05-06).
await args.getTerminalRef(handle)?.awaitReady()
if (
args.subscribeSeqRef.current.get(handle) !== seq ||
!budget.isRetryGenerationCurrent(handle, retryGeneration)
) {
return
}
const dims = await args
.getTerminalRef(handle)
?.measureFitDimensions(args.terminalFrameHeightRef.current || undefined)
// Why: re-check seq — the awaits may have let a newer subscribe cycle arm; tearing it down would resubscribe a stale generation.
if (
args.subscribeSeqRef.current.get(handle) !== seq ||
!budget.isRetryGenerationCurrent(handle, retryGeneration)
) {
return
}
if (!args.getTerminalRef(handle) || !dims) {
return
}
args.viewportRef.current = dims
args.viewportMeasuredRef.current = true
if (
!shouldResubscribeAfterViewportMeasure({
hostCols,
hostRows,
measured: dims,
viewportWasMeasured
})
) {
// Why: the pre-measure mismatch was a stale cached viewport; the server already agrees.
budget.markConverged(handle)
return
}
const resubscribe = (): void => {
if (
args.subscribeSeqRef.current.get(handle) !== seq ||
!budget.isRetryGenerationCurrent(handle, retryGeneration)
) {
return
}
if (!args.getTerminalRef(handle)) {
return
}
diagnostics.streamResubscribing(handle, seq, dims, budget.attempts(handle), decision.delayMs)
args.unsubscribeTerminal(handle)
args.initializedHandlesRef.current.delete(handle)
args.subscribeToTerminal(handle)
// Why: only a resubscribe that actually armed spends budget; one turned away by its own gates never reached the host.
if (args.terminalUnsubsRef.current.has(handle)) {
budget.chargeAttempt(handle)
}
}
if (decision.delayMs > 0) {
// Why: keep the live stream up through the backoff so input keeps flowing; teardown happens only when the retry fires.
args.scheduleDelayedAction(resubscribe, decision.delayMs)
} else {
resubscribe()
}
})()
}
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import { totalHomeStats, type HomeStatsSummary } from './home-stats-total'
function stats(overrides: Partial<HomeStatsSummary> = {}): HomeStatsSummary {
return {
totalAgentsSpawned: 10,
totalPRsCreated: 2,
totalAgentTimeMs: 60_000,
firstEventAt: 1_700_000_000_000,
...overrides
}
}
describe('totalHomeStats', () => {
it('has nothing to show before any host answers', () => {
expect(totalHomeStats({}, ['host-1'])).toBeNull()
})
it('passes a single host through unchanged', () => {
expect(totalHomeStats({ 'host-1': stats() }, ['host-1'])).toEqual(stats())
})
it('sums every desktop instead of letting the last reply win', () => {
const total = totalHomeStats(
{
'host-1': stats(),
'host-2': stats({ totalAgentsSpawned: 5, totalPRsCreated: 1, totalAgentTimeMs: 30_000 })
},
['host-1', 'host-2']
)
expect(total).toMatchObject({
totalAgentsSpawned: 15,
totalPRsCreated: 3,
totalAgentTimeMs: 90_000
})
})
it('drops a removed desktop from the total', () => {
// Replies are cached for the life of the process, so the entry outlives the pairing.
const byHost = {
'host-1': stats(),
'host-2': stats({ totalAgentsSpawned: 5, totalPRsCreated: 1, totalAgentTimeMs: 30_000 })
}
expect(totalHomeStats(byHost, ['host-1'])).toEqual(stats())
expect(totalHomeStats(byHost, [])).toBeNull()
})
it('keeps the earliest known first event', () => {
const total = totalHomeStats(
{
'host-1': stats({ firstEventAt: 2_000 }),
'host-2': stats({ firstEventAt: null }),
'host-3': stats({ firstEventAt: 1_000 })
},
['host-1', 'host-2', 'host-3']
)
expect(total?.firstEventAt).toBe(1_000)
})
it('reports no first event when no host has one', () => {
expect(
totalHomeStats({ 'host-1': stats({ firstEventAt: null }) }, ['host-1'])?.firstEventAt
).toBeNull()
})
it('ignores a malformed reply instead of poisoning the header', () => {
const total = totalHomeStats(
{
'host-1': stats(),
'host-2': null as unknown as HomeStatsSummary,
'host-3': { totalAgentsSpawned: 'lots' } as unknown as HomeStatsSummary
},
['host-1', 'host-2', 'host-3']
)
expect(total).toEqual(stats())
})
})
+51
View File
@@ -0,0 +1,51 @@
export type HomeStatsSummary = {
totalAgentsSpawned: number
totalPRsCreated: number
totalAgentTimeMs: number
firstEventAt: number | null
}
/**
* Why: the home header shows one lifetime-usage row for every paired desktop. Each host answers
* stats.summary for itself, so a single shared slot made the row flip to whichever host replied
* last — visible churn now that every reconnect re-reads. Sum instead; one host still totals itself.
*
* Summing only `hostIds` keeps an unpaired desktop out of the total: replies are cached per host
* for the life of the process, so an entry outlives the host it describes.
*/
export function totalHomeStats(
byHost: Record<string, HomeStatsSummary>,
hostIds: readonly string[]
): HomeStatsSummary | null {
const hosts = hostIds.filter((id) => id in byHost).map((id) => byHost[id])
if (hosts.length === 0) {
return null
}
const total: HomeStatsSummary = {
totalAgentsSpawned: 0,
totalPRsCreated: 0,
totalAgentTimeMs: 0,
firstEventAt: null
}
for (const host of hosts) {
// The rows come straight off the wire unvalidated; a malformed desktop reply must not
// NaN out or crash the header for every other host.
if (!host || typeof host !== 'object') {
continue
}
total.totalAgentsSpawned += finiteOrZero(host.totalAgentsSpawned)
total.totalPRsCreated += finiteOrZero(host.totalPRsCreated)
total.totalAgentTimeMs += finiteOrZero(host.totalAgentTimeMs)
if (typeof host.firstEventAt === 'number' && Number.isFinite(host.firstEventAt)) {
total.firstEventAt =
total.firstEventAt === null
? host.firstEventAt
: Math.min(total.firstEventAt, host.firstEventAt)
}
}
return total
}
function finiteOrZero(value: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest'
import { computeActiveTerminalKeyboardLift } from './terminal-keyboard-avoidance-lift'
import { parseTerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract'
import type { TerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract'
const FRAME_HEIGHT = 800
const ROWS = 40
const KEYBOARD_LIFT = 300
function metrics(
overrides: Partial<TerminalKeyboardAvoidanceMetrics> = {}
): TerminalKeyboardAvoidanceMetrics {
return { cursorY: 0, contentBottomRow: 0, rows: ROWS, altScreen: false, ...overrides }
}
describe('computeActiveTerminalKeyboardLift', () => {
it('returns 0 when the keyboard is closed', () => {
expect(
computeActiveTerminalKeyboardLift({
keyboardLift: 0,
metrics: metrics({ cursorY: 30, contentBottomRow: 34 }),
terminalFrameHeight: FRAME_HEIGHT
})
).toBe(0)
})
it('falls back to the full lift when metrics are missing', () => {
expect(
computeActiveTerminalKeyboardLift({
keyboardLift: KEYBOARD_LIFT,
metrics: undefined,
terminalFrameHeight: FRAME_HEIGHT
})
).toBe(KEYBOARD_LIFT)
})
it('falls back to the full lift when rows or frame height are unmeasured', () => {
expect(
computeActiveTerminalKeyboardLift({
keyboardLift: KEYBOARD_LIFT,
metrics: metrics({ rows: 0 }),
terminalFrameHeight: FRAME_HEIGHT
})
).toBe(KEYBOARD_LIFT)
expect(
computeActiveTerminalKeyboardLift({
keyboardLift: KEYBOARD_LIFT,
metrics: metrics(),
terminalFrameHeight: 0
})
).toBe(KEYBOARD_LIFT)
})
it('lifts fully for alt-screen TUIs', () => {
expect(
computeActiveTerminalKeyboardLift({
keyboardLift: KEYBOARD_LIFT,
metrics: metrics({ cursorY: 10, contentBottomRow: 10, altScreen: true }),
terminalFrameHeight: FRAME_HEIGHT
})
).toBe(KEYBOARD_LIFT)
})
it('clears a main-buffer footer while an old payload retains cursor-only behavior', () => {
const candidate = computeActiveTerminalKeyboardLift({
keyboardLift: KEYBOARD_LIFT,
metrics: metrics({ cursorY: 30, contentBottomRow: 34 }),
terminalFrameHeight: FRAME_HEIGHT
})
const oldPayload = parseTerminalKeyboardAvoidanceMetrics({ cursorY: 30, rows: ROWS })
const cursorOnly = computeActiveTerminalKeyboardLift({
keyboardLift: KEYBOARD_LIFT,
metrics: oldPayload,
terminalFrameHeight: FRAME_HEIGHT
})
expect({ candidate, cursorOnly }).toEqual({ candidate: 220, cursorOnly: 140 })
})
it('keeps short output near the top put (no lift)', () => {
expect(
computeActiveTerminalKeyboardLift({
keyboardLift: KEYBOARD_LIFT,
metrics: metrics({ cursorY: 2, contentBottomRow: 5 }),
terminalFrameHeight: FRAME_HEIGHT
})
).toBe(0)
})
it('matches cursor-clearing behavior for a scrolled shell (prompt at the bottom)', () => {
const lift = computeActiveTerminalKeyboardLift({
keyboardLift: KEYBOARD_LIFT,
metrics: metrics({ cursorY: 38, contentBottomRow: 38 }),
terminalFrameHeight: FRAME_HEIGHT
})
expect(lift).toBe(KEYBOARD_LIFT)
})
it('never exceeds the keyboard lift', () => {
const lift = computeActiveTerminalKeyboardLift({
keyboardLift: KEYBOARD_LIFT,
metrics: metrics({ cursorY: 39, contentBottomRow: 39 }),
terminalFrameHeight: FRAME_HEIGHT
})
expect(lift).toBeLessThanOrEqual(KEYBOARD_LIFT)
})
it('uses the platform-adjusted lift proportionally on iOS and Android', () => {
const tuiMetrics = metrics({ cursorY: 30, contentBottomRow: 34 })
const android = computeActiveTerminalKeyboardLift({
keyboardLift: 300,
metrics: tuiMetrics,
terminalFrameHeight: FRAME_HEIGHT
})
const ios = computeActiveTerminalKeyboardLift({
keyboardLift: 266,
metrics: tuiMetrics,
terminalFrameHeight: FRAME_HEIGHT
})
expect({ android, ios }).toEqual({ android: 220, ios: 186 })
})
})
@@ -0,0 +1,29 @@
import type { TerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract'
type ActiveTerminalKeyboardLiftParams = {
keyboardLift: number
metrics: TerminalKeyboardAvoidanceMetrics | undefined
terminalFrameHeight: number
}
export function computeActiveTerminalKeyboardLift(
params: ActiveTerminalKeyboardLiftParams
): number {
const { keyboardLift, metrics, terminalFrameHeight } = params
if (keyboardLift <= 0) {
return 0
}
if (!metrics || metrics.rows <= 0 || terminalFrameHeight <= 0) {
return keyboardLift
}
if (metrics.altScreen) {
return keyboardLift
}
const rowHeight = terminalFrameHeight / metrics.rows
// Main-buffer TUI footer rows can sit below the caret.
const anchorRow = Math.max(metrics.cursorY, metrics.contentBottomRow)
const anchorBottom = (anchorRow + 1) * rowHeight
const dockTop = terminalFrameHeight - keyboardLift
const margin = rowHeight
return Math.min(keyboardLift, Math.max(0, anchorBottom + margin - dockTop))
}
@@ -0,0 +1,43 @@
export const TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS = `
function lineHasVisibleContent(line, cell) {
if (line.translateToString(true).trim().length > 0) return true;
if (!cell || !line.getCell) return false;
var limit = Math.min(term.cols || 0, line.length || 0);
for (var x = 0; x < limit; x++) {
var current = line.getCell(x, cell);
if (!current) continue;
if (!current.isBgDefault() || current.isInverse()) return true;
if (typeof current.isUnderline === 'function' && current.isUnderline()) return true;
if (typeof current.isStrikethrough === 'function' && current.isStrikethrough()) return true;
if (typeof current.isOverline === 'function' && current.isOverline()) return true;
}
return false;
}
function computeContentBottomRow() {
if (!term || !term.buffer || !term.buffer.active) return 0;
var buffer = term.buffer.active;
var top = buffer.viewportY || 0;
var cell = buffer.getNullCell ? buffer.getNullCell() : null;
for (var y = (term.rows || 0) - 1; y >= 0; y--) {
try {
var line = buffer.getLine(top + y);
if (line && lineHasVisibleContent(line, cell)) return y;
} catch (e) {}
}
return 0;
}
function emitKeyboardAvoidanceMetrics() {
if (!term) return;
var alt = false;
try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {}
notify({
type: 'keyboard-avoidance-metrics',
cursorY: term.buffer && term.buffer.active ? term.buffer.active.cursorY : 0,
contentBottomRow: alt ? 0 : computeContentBottomRow(),
rows: term.rows || 0,
altScreen: alt
});
}
`
@@ -0,0 +1,215 @@
import { readFileSync } from 'node:fs'
import { Script } from 'node:vm'
import { Terminal } from '@xterm/xterm'
import { describe, expect, it, vi } from 'vitest'
import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from './terminal-keyboard-avoidance-metrics-injected'
import { parseTerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract'
const terminalHtmlSource = readFileSync(
new URL('./terminal-webview-html.ts', import.meta.url),
'utf8'
)
const reflowSource = readFileSync(
new URL('./terminal-webview-reflow-injected.ts', import.meta.url),
'utf8'
)
type Cell = { isBgDefault: () => boolean; isInverse: () => number }
type MetricsNotification = {
type: string
cursorY: number
contentBottomRow: number
rows: number
altScreen: boolean
}
function makeLine(text = '', styledColumns: number[] = []) {
const styled = new Set(styledColumns)
return {
isWrapped: false,
length: 10,
translateToString: vi.fn(() => text),
getCell: (column: number): Cell => ({
isBgDefault: () => !styled.has(column),
isInverse: () => 0
})
}
}
function runMetrics(lines: (ReturnType<typeof makeLine> | undefined)[], altScreen = false) {
const notifications: Record<string, unknown>[] = []
const buffer = {
cursorY: 2,
viewportY: 3,
type: altScreen ? 'alternate' : 'normal',
getLine: (index: number) => lines[index - 3],
getNullCell: () => ({})
}
const context = {
notifications,
notify: (message: Record<string, unknown>) => notifications.push(message),
term: { buffer: { active: buffer }, cols: 10, rows: lines.length }
}
new Script(
`${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS}\nemitKeyboardAvoidanceMetrics();`
).runInNewContext(context)
return notifications[0] as MetricsNotification
}
function runTerminalMetrics(term: Terminal) {
const notifications: Record<string, unknown>[] = []
new Script(
`${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS}\nemitKeyboardAvoidanceMetrics();`
).runInNewContext({
notify: (message: Record<string, unknown>) => notifications.push(message),
term
})
return notifications[0] as MetricsNotification
}
function write(term: Terminal, data: string): Promise<void> {
return new Promise((resolve) => term.write(data, resolve))
}
describe('terminal keyboard-avoidance WebView metrics', () => {
it('finds text on wrapped rows using the visible viewport offset', () => {
const lines = [makeLine('header'), makeLine(''), makeLine('wrapped footer')]
lines[2]!.isWrapped = true
expect(runMetrics(lines)).toMatchObject({ contentBottomRow: 2 })
})
it('supports cells without decoration APIs and keeps background-only ANSI chrome visible', () => {
expect(runMetrics([makeLine('header'), makeLine(''), makeLine('')])).toMatchObject({
contentBottomRow: 0
})
expect(runMetrics([makeLine('header'), makeLine(''), makeLine('', [4])])).toMatchObject({
contentBottomRow: 2
})
})
it('classifies real xterm text and styled whitespace by rendered visibility', async () => {
const cases = [
{ name: 'default spaces', data: ' ', expected: 0 },
{ name: 'text', data: 'footer', expected: 7 },
{ name: 'background', data: '\x1b[41m \x1b[0m', expected: 7 },
{ name: 'inverse', data: '\x1b[7m \x1b[0m', expected: 7 },
{ name: 'underline', data: '\x1b[4m \x1b[0m', expected: 7 },
{ name: 'strikethrough', data: '\x1b[9m \x1b[0m', expected: 7 },
{ name: 'overline', data: '\x1b[53m \x1b[0m', expected: 7 },
// Hidden text still reserves TUI layout, so keyboard avoidance treats it as content.
{ name: 'invisible text', data: '\x1b[8mfooter\x1b[0m', expected: 7 }
]
for (const { name, data, expected } of cases) {
const term = new Terminal({ cols: 10, rows: 8 })
try {
await write(term, `\x1b[8;1H${data}`)
expect(runTerminalMetrics(term), name).toMatchObject({ contentBottomRow: expected })
} finally {
term.dispose()
}
}
})
it('tracks the real xterm viewport and alternate screen', async () => {
const term = new Terminal({ cols: 10, rows: 4, scrollback: 100 })
try {
await write(term, 'header\r\n\r\n\r\n\r\nfooter')
expect(runTerminalMetrics(term)).toMatchObject({ contentBottomRow: 3, altScreen: false })
term.scrollLines(-2)
expect(runTerminalMetrics(term)).toMatchObject({ contentBottomRow: 0, altScreen: false })
await write(term, '\x1b[?1049h\x1b[4m \x1b[0m')
expect(runTerminalMetrics(term)).toMatchObject({ contentBottomRow: 0, altScreen: true })
} finally {
term.dispose()
}
})
it('follows real xterm resize and reset state', async () => {
const term = new Terminal({ cols: 10, rows: 8 })
try {
await write(term, '\x1b[8;1Hfooter')
expect(runTerminalMetrics(term)).toMatchObject({ contentBottomRow: 7 })
term.resize(10, 4)
expect(runTerminalMetrics(term)).toMatchObject({ contentBottomRow: 3 })
term.reset()
expect(runTerminalMetrics(term)).toMatchObject({ contentBottomRow: 0 })
} finally {
term.dispose()
}
})
it('keeps real xterm metrics compatible with old payloads', async () => {
const term = new Terminal({ cols: 10, rows: 8 })
try {
await write(term, '\x1b[8;1Hfooter\x1b[2;1H')
const { cursorY, rows, altScreen } = runTerminalMetrics(term)
expect(parseTerminalKeyboardAvoidanceMetrics({ cursorY, rows, altScreen })).toEqual({
cursorY: 1,
contentBottomRow: 1,
rows: 8,
altScreen: false
})
} finally {
term.dispose()
}
})
it('releases real xterm metric observers across terminal lifecycles', async () => {
for (let cycle = 0; cycle < 25; cycle += 1) {
const term = new Terminal({ cols: 10, rows: 4 })
let emissions = 0
const observer = term.onWriteParsed(() => {
runTerminalMetrics(term)
emissions += 1
})
try {
await write(term, `cycle ${cycle}`)
expect(emissions).toBeGreaterThan(0)
observer.dispose()
const disposedAt = emissions
await write(term, ' after dispose')
expect(emissions).toBe(disposedAt)
} finally {
observer.dispose()
term.dispose()
}
}
})
it('stops at the first bottom-up match and skips scans on alternate screen', () => {
const footer = makeLine('footer')
const header = makeLine('header')
expect(runMetrics([header, makeLine(''), footer])).toMatchObject({ contentBottomRow: 2 })
expect(footer.translateToString).toHaveBeenCalledTimes(1)
expect(header.translateToString).not.toHaveBeenCalled()
footer.translateToString.mockImplementation(() => {
throw new Error('alternate screen must not scan')
})
expect(runMetrics([header, makeLine(''), footer], true)).toMatchObject({
altScreen: true,
contentBottomRow: 0
})
})
it('refreshes metrics after every buffer geometry reset', () => {
const resizeStart = terminalHtmlSource.indexOf(' function resize(cols, rows)')
const resizeEnd = terminalHtmlSource.indexOf('\n // reflow()', resizeStart)
const clearStart = terminalHtmlSource.indexOf("} else if (msg.type === 'clear') {")
const clearEnd = terminalHtmlSource.indexOf("} else if (msg.type === 'measure')", clearStart)
const textScaleStart = terminalHtmlSource.indexOf(' function applyTextScale(scale)')
const textScaleEnd = terminalHtmlSource.indexOf('\n var panX', textScaleStart)
for (const block of [
terminalHtmlSource.slice(resizeStart, resizeEnd),
terminalHtmlSource.slice(clearStart, clearEnd),
terminalHtmlSource.slice(textScaleStart, textScaleEnd),
reflowSource
]) {
expect(block.indexOf('emitKeyboardAvoidanceMetrics()')).toBeGreaterThan(
block.includes('term.resize') ? block.indexOf('term.resize') : block.indexOf('term.reset')
)
}
})
})
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { parseTerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract'
describe('parseTerminalKeyboardAvoidanceMetrics', () => {
it('parses a full payload', () => {
expect(
parseTerminalKeyboardAvoidanceMetrics({
cursorY: 30,
contentBottomRow: 34,
rows: 40,
altScreen: true
})
).toEqual({ cursorY: 30, contentBottomRow: 34, rows: 40, altScreen: true })
})
it('defaults contentBottomRow to cursorY when absent (older WebView bundles)', () => {
expect(parseTerminalKeyboardAvoidanceMetrics({ cursorY: 12, rows: 40 })).toEqual({
cursorY: 12,
contentBottomRow: 12,
rows: 40,
altScreen: false
})
})
it('defaults non-numeric fields to zero', () => {
expect(parseTerminalKeyboardAvoidanceMetrics({})).toEqual({
cursorY: 0,
contentBottomRow: 0,
rows: 0,
altScreen: false
})
})
it('bounds untrusted numeric fields to the reported viewport', () => {
expect(
parseTerminalKeyboardAvoidanceMetrics({
cursorY: Number.POSITIVE_INFINITY,
contentBottomRow: 99.8,
rows: 40.7,
altScreen: 'true'
})
).toEqual({ cursorY: 0, contentBottomRow: 39, rows: 40, altScreen: false })
expect(
parseTerminalKeyboardAvoidanceMetrics({
cursorY: -4,
contentBottomRow: Number.NaN,
rows: -1
})
).toEqual({ cursorY: 0, contentBottomRow: 0, rows: 0, altScreen: false })
})
})
@@ -14,10 +14,34 @@ export type TerminalModes = {
export type TerminalKeyboardAvoidanceMetrics = {
cursorY: number
// Main-buffer TUIs can render footer rows below the caret.
contentBottomRow: number
rows: number
altScreen: boolean
}
export function parseTerminalKeyboardAvoidanceMetrics(
msg: Record<string, unknown>
): TerminalKeyboardAvoidanceMetrics {
const rows = toNonNegativeInteger(msg.rows)
const maxRow = Math.max(0, rows - 1)
const cursorY = Math.min(toNonNegativeInteger(msg.cursorY), maxRow)
const contentBottomRow =
msg.contentBottomRow === undefined
? cursorY
: Math.min(toNonNegativeInteger(msg.contentBottomRow), maxRow)
return {
cursorY,
contentBottomRow,
rows,
altScreen: msg.altScreen === true
}
}
function toNonNegativeInteger(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0
}
export type MobileTerminalTheme = RuntimeMobileTerminalTheme
export type TerminalSelectionEvents = {
+5 -11
View File
@@ -3,6 +3,7 @@ import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-typ
import { colors } from '../theme/mobile-theme'
import { TERMINAL_TEXT_SCALES } from '../storage/preferences'
import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from './terminal-keyboard-avoidance-metrics-injected'
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected'
import { TERMINAL_SURFACE_SWAP_JS } from './terminal-webview-surface-swap-injected'
@@ -287,6 +288,7 @@ window.onerror = function(msg) {
if (cols < MIN_FIT_COLS) return;
var rows = Math.max(8, Math.floor(window.innerHeight / cellH));
term.resize(cols, rows);
emitKeyboardAvoidanceMetrics();
}
applyFitScale('text-scale');
});
@@ -802,6 +804,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
if (!term) return;
initRows = rows || initRows;
term.resize(cols || term.cols, rows || term.rows);
emitKeyboardAvoidanceMetrics();
applyFitScale('resize-msg');
notify({ type: 'ready', cols: cols, rows: rows });
}
@@ -958,6 +961,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
initialOscLinkEvictionReady = false;
if (term) { term.clear(); term.reset(); }
emitModesIfChanged();
emitKeyboardAvoidanceMetrics();
resetEvictionCounter();
if (selMode === 'select') {
notify({ type: 'selection-evicted' });
@@ -1100,17 +1104,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
sgrMousePixelsMode: false
};
function emitKeyboardAvoidanceMetrics() {
if (!term) return;
var alt = false;
try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {}
notify({
type: 'keyboard-avoidance-metrics',
cursorY: term.buffer && term.buffer.active ? term.buffer.active.cursorY : 0,
rows: term.rows || 0,
altScreen: alt
});
}
${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS}
function attachTermObservers() {
if (!term) return;
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from 'vitest'
import { dispatchTerminalWebViewNotification } from './terminal-webview-notification-dispatch'
describe('dispatchTerminalWebViewNotification', () => {
it('preserves bounded keyboard metrics through the dispatcher', () => {
const onKeyboardAvoidanceMetrics = vi.fn()
dispatchTerminalWebViewNotification(
{
type: 'keyboard-avoidance-metrics',
cursorY: 30,
contentBottomRow: 99,
rows: 40,
altScreen: true
},
{ onKeyboardAvoidanceMetrics, reportEngineError: vi.fn() }
)
expect(onKeyboardAvoidanceMetrics).toHaveBeenCalledWith({
cursorY: 30,
contentBottomRow: 39,
rows: 40,
altScreen: true
})
})
it('keeps old WebView payloads cursor-compatible', () => {
const onKeyboardAvoidanceMetrics = vi.fn()
dispatchTerminalWebViewNotification(
{ type: 'keyboard-avoidance-metrics', cursorY: 12, rows: 40 },
{ onKeyboardAvoidanceMetrics, reportEngineError: vi.fn() }
)
expect(onKeyboardAvoidanceMetrics).toHaveBeenCalledWith({
cursorY: 12,
contentBottomRow: 12,
rows: 40,
altScreen: false
})
})
})
@@ -1,4 +1,7 @@
import type { TerminalSelectionEvents } from './terminal-webview-contract'
import {
parseTerminalKeyboardAvoidanceMetrics,
type TerminalSelectionEvents
} from './terminal-webview-contract'
export type TerminalWebViewNotificationHandlers = Omit<
TerminalSelectionEvents,
@@ -63,13 +66,7 @@ export function dispatchTerminalWebViewNotification(
handlers.onOpenUrl?.(url)
}
} else if (msg.type === 'keyboard-avoidance-metrics') {
const cursorY = typeof msg.cursorY === 'number' ? msg.cursorY : 0
const rows = typeof msg.rows === 'number' ? msg.rows : 0
handlers.onKeyboardAvoidanceMetrics?.({
cursorY,
rows,
altScreen: !!msg.altScreen
})
handlers.onKeyboardAvoidanceMetrics?.(parseTerminalKeyboardAvoidanceMetrics(msg))
} else if (msg.type === 'haptic') {
const kind = msg.kind
if (kind === 'selection' || kind === 'success' || kind === 'error' || kind === 'edge-bump') {
@@ -28,5 +28,6 @@ export const TERMINAL_REFLOW_JS = `
}
applyFitScale('reflow-msg');
updateScrollIndicator(false);
emitKeyboardAvoidanceMetrics();
}
`
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { createHostConnectRefetchGate } from './host-connect-refetch-gate'
import type { ConnectionState } from './types'
function refetchesFor(states: ConnectionState[]): number {
const gate = createHostConnectRefetchGate()
return states.filter((state) => gate.observe(state)).length
}
describe('createHostConnectRefetchGate', () => {
it('fetches once on the first connect', () => {
expect(refetchesFor(['connecting', 'handshaking', 'connected'])).toBe(1)
})
it('fetches again after a dropped socket comes back', () => {
expect(
refetchesFor(['connected', 'reconnecting', 'connecting', 'handshaking', 'connected'])
).toBe(2)
})
it('does not storm while the connection holds', () => {
expect(refetchesFor(['connected', 'connected', 'connected'])).toBe(1)
})
it('never fetches while the host stays down', () => {
expect(refetchesFor(['connecting', 'disconnected', 'reconnecting', 'auth-failed'])).toBe(0)
})
})
@@ -0,0 +1,18 @@
import type { ConnectionState } from './types'
// Why: a logical client survives socket drops, so screens that fetch once per client latched
// after the first connect and never refreshed after a background/handoff reconnect. Gate the
// refetch on the transition INTO 'connected' — one read per reconnect, no polling timer.
export function createHostConnectRefetchGate(): {
observe: (state: ConnectionState) => boolean
} {
let connected = false
return {
observe(state) {
const nowConnected = state === 'connected'
const crossedIntoConnected = nowConnected && !connected
connected = nowConnected
return crossedIntoConnected
}
}
}
@@ -0,0 +1,83 @@
import { openAuthenticatedDirectEndpoint } from './mobile-direct-endpoint-probe'
import type { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis'
import type { RpcClient } from './rpc-client'
import type { HostProfile } from './types'
import type { MobileConnectionPath } from './stable-logical-rpc-client'
const DIRECT_PROBE_INTERVAL_MS = 15_000
// While the runtime channel rides the relay, periodically probe the direct
// endpoint and migrate back once hysteresis proves it stable.
export class DirectReturnProbe {
private timer: ReturnType<typeof setTimeout> | null = null
constructor(
private readonly deps: {
now: () => number
setTimer: typeof setTimeout
clearTimer: typeof clearTimeout
openDirect: (endpoint: string) => RpcClient
},
private readonly hooks: {
hysteresis: MobileEndpointHysteresis
host: () => HostProfile
canSchedule: () => boolean
canAttempt: () => boolean
beginOperation: () => void
migrate: (client: RpcClient, path: MobileConnectionPath) => Promise<void>
onDirectMigrated: () => Promise<void>
afterProbe: () => void
}
) {}
schedule(delayMs = DIRECT_PROBE_INTERVAL_MS): void {
if (!this.hooks.canSchedule() || this.timer) {
return
}
this.timer = this.deps.setTimer(() => {
this.timer = null
void this.probe()
}, delayMs)
}
clear(): void {
if (this.timer) {
this.deps.clearTimer(this.timer)
this.timer = null
}
}
private async probe(): Promise<void> {
if (!this.hooks.canAttempt() || !this.hooks.hysteresis.canProbe(this.deps.now())) {
this.schedule()
return
}
this.hooks.beginOperation()
let successful: Awaited<ReturnType<typeof openAuthenticatedDirectEndpoint>> = null
try {
successful = await openAuthenticatedDirectEndpoint(
this.hooks.host(),
this.deps.openDirect,
12_000
)
if (!successful) {
this.hooks.hysteresis.recordDirectFailure(this.deps.now())
return
}
if (!this.hooks.hysteresis.recordDirectSuccess(this.deps.now())) {
successful.client.close()
return
}
await this.hooks.migrate(successful.client, successful.path)
successful = null
this.hooks.hysteresis.recordMigration(this.deps.now())
await this.hooks.onDirectMigrated()
} finally {
successful?.client.close()
// Why: a relay drop or backoff timer can arrive while the probe owns the
// operation mutex; afterProbe releases it and replays deferred recovery.
this.hooks.afterProbe()
this.schedule()
}
}
}
@@ -90,6 +90,7 @@ function createSupervisor(
readBundle: readMobileRelayCredentialBundle,
writeBundle: writeMobileRelayCredentialBundle,
saveHost,
onLog,
now: Date.now,
randomBytes: ExpoCrypto.getRandomBytes,
setTimer: setTimeout,
@@ -3,7 +3,7 @@ import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bund
import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
import type { resolveMobileRelayEndpoint } from './mobile-relay-resume-director'
import type { RpcClient } from './rpc-client'
import type { HostProfile } from './types'
import type { ConnectionLogSink, HostProfile } from './types'
export type MobileEndpointSupervisorDependencies = {
openDirect: (endpoint: string) => RpcClient
@@ -20,4 +20,5 @@ export type MobileEndpointSupervisorDependencies = {
randomBytes: (length: number) => Uint8Array
setTimer: typeof setTimeout
clearTimer: typeof clearTimeout
onLog?: ConnectionLogSink
}
@@ -0,0 +1,169 @@
import { vi } from 'vitest'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
import type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor'
import type { RpcClient } from './rpc-client'
import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionState, HostProfile, RpcResponse } from './types'
export class FakeSession implements RpcClient {
readonly sendRequest = vi.fn(
async (_method: string, _params?: unknown): Promise<RpcResponse> => ({
id: 'rpc-1',
ok: true,
result: {},
_meta: { runtimeId: 'runtime-1' }
})
)
readonly subscribe = vi.fn(() => () => {})
readonly updateTerminalSubscriptionViewport = vi.fn()
readonly notifyForeground = vi.fn()
readonly close = vi.fn()
private readonly listeners = new Set<(state: ConnectionState) => void>()
constructor(private state: ConnectionState) {}
getState = () => this.state
getReconnectAttempt = () => 0
getLastConnectedAt = () => null
onStateChange = (listener: (state: ConnectionState) => void) => {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
publishState(state: ConnectionState): void {
this.state = state
for (const listener of this.listeners) {
listener(state)
}
}
}
export class FakeRelaySession extends FakeSession implements MobileRelayRpcSession {
constructor(
state: ConnectionState,
private readonly failure: Error | null = null,
private readonly resumeExpiry = Date.now() + 30 * 24 * 3_600_000,
private readonly renewed = true
) {
super(state)
}
// Why: production-realistic defaults — fictional fake values hid three
// live defects in this subsystem (latch, churn, int32 timer overflow).
getAttachDeadlineAt = () => Date.now() + 10_000
getResumeExpiresAt = () => this.resumeExpiry
getResumeConfirmation = () => ({
v: 1 as const,
reqId: 'confirm-1',
currentVersion: 2,
acceptedAs: this.renewed ? ('current' as const) : ('grace' as const),
renewed: this.renewed,
resumeExpiresAt: this.resumeExpiry
})
getFailure = () => this.failure
}
export class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
private path: MobileConnectionPath
private generation = 1
constructor(state: ConnectionState, path: MobileConnectionPath) {
super(state)
this.path = path
}
migrateTo = vi.fn(async (session: RpcClient, path: MobileConnectionPath) => {
if (session.getState() !== 'connected') {
session.close()
throw new Error(`replacement session ${session.getState()}`)
}
this.path = path
this.generation += 1
this.publishState('connected')
})
suspendActiveSession = vi.fn(() => this.publishState('disconnected'))
getActivePath = () => this.path
getGeneration = () => this.generation
}
export const relay = {
v: 1 as const,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2 as const
}
export const host: HostProfile = {
id: 'host-1',
name: 'Blue Whale',
endpoint: 'ws://192.168.1.10:6768',
deviceToken: 'device-token',
publicKeyB64: 'A'.repeat(44),
lastConnected: 1,
endpoints: [
{ id: 'direct-primary', kind: 'lan', url: 'ws://192.168.1.10:6768' },
{ id: 'relay-primary', kind: 'relay', url: 'wss://relay-c1.onorca.dev/v1/connect/id' }
],
relayHostId: relay.relayHostId,
relay
}
export const bundle: MobileRelayCredentialBundle = {
v: 1,
hostId: host.id,
deviceToken: host.deviceToken,
current: {
token: 'A'.repeat(43),
hash: 'B'.repeat(43),
version: 2,
expiresAt: Number.MAX_SAFE_INTEGER
}
}
export function dependencies(
overrides: Partial<MobileEndpointSupervisorDependencies> = {}
): MobileEndpointSupervisorDependencies {
return {
openDirect: vi.fn(() => new FakeSession('connected')),
openRelay: vi.fn(() => new FakeRelaySession('connected')),
resolveRelay: vi.fn(async ({ relay }) => relay),
readBundle: vi.fn(async () => bundle),
writeBundle: vi.fn(async () => {}),
saveHost: vi.fn(async () => {}),
now: Date.now,
randomBytes: (length) => new Uint8Array(length).fill(1),
setTimer: setTimeout,
clearTimer: clearTimeout,
...overrides
}
}
export function mockCredentialRotation(logical: FakeLogicalClient): void {
let installResult: Record<string, unknown> | null = null
logical.sendRequest.mockImplementation(async (method, params) => {
const request = params as { installReqId?: string; reqId?: string }
if (method === 'pairing.provisionRelay') {
installResult = {
v: 1,
reqId: request.reqId,
authorizationMode: 'authenticated-direct',
currentVersion: 3,
resumeExpiresAt: Date.now() + 300_000,
graceExpiresAt: Date.now() + 60_000
}
return { id: 'rpc-2', ok: true, result: installResult, _meta: { runtimeId: 'runtime-1' } }
}
return {
id: 'rpc-1',
ok: true,
result: {
v: 1,
relay,
installStatus: installResult
? { v: 1, reqId: request.installReqId, state: 'committed', result: installResult }
: { v: 1, reqId: request.installReqId, state: 'not-found' }
},
_meta: { runtimeId: 'runtime-1' }
}
})
}
@@ -2,177 +2,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import { hashMobileRelayCredential } from './mobile-relay-credential-hash'
import { RelayOuterError } from './mobile-relay-e2ee-link'
import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
import {
MobileEndpointSupervisor,
type MobileEndpointSupervisorDependencies
} from './mobile-endpoint-supervisor'
import type { RpcClient } from './rpc-client'
import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionState, HostProfile, RpcResponse } from './types'
bundle,
dependencies,
FakeLogicalClient,
FakeRelaySession,
FakeSession,
host,
mockCredentialRotation,
relay
} from './mobile-endpoint-supervisor-test-fakes'
import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))
class FakeSession implements RpcClient {
readonly sendRequest = vi.fn(
async (_method: string, _params?: unknown): Promise<RpcResponse> => ({
id: 'rpc-1',
ok: true,
result: {},
_meta: { runtimeId: 'runtime-1' }
})
)
readonly subscribe = vi.fn(() => () => {})
readonly updateTerminalSubscriptionViewport = vi.fn()
readonly notifyForeground = vi.fn()
readonly close = vi.fn()
private readonly listeners = new Set<(state: ConnectionState) => void>()
constructor(private state: ConnectionState) {}
getState = () => this.state
getReconnectAttempt = () => 0
getLastConnectedAt = () => null
onStateChange = (listener: (state: ConnectionState) => void) => {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
publishState(state: ConnectionState): void {
this.state = state
for (const listener of this.listeners) {
listener(state)
}
}
}
class FakeRelaySession extends FakeSession implements MobileRelayRpcSession {
constructor(
state: ConnectionState,
private readonly failure: Error | null = null,
private readonly lease = Date.now() + 120_000
) {
super(state)
}
getLeaseExpiresAt = () => this.lease
getResumeConfirmation = () => ({
v: 1 as const,
reqId: 'confirm-1',
currentVersion: 2,
acceptedAs: 'current' as const,
renewed: true,
resumeExpiresAt: Date.now() + 300_000
})
getFailure = () => this.failure
}
class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
private path: MobileConnectionPath
private generation = 1
constructor(state: ConnectionState, path: MobileConnectionPath) {
super(state)
this.path = path
}
migrateTo = vi.fn(async (session: RpcClient, path: MobileConnectionPath) => {
if (session.getState() !== 'connected') {
session.close()
throw new Error(`replacement session ${session.getState()}`)
}
this.path = path
this.generation += 1
this.publishState('connected')
})
suspendActiveSession = vi.fn(() => this.publishState('disconnected'))
getActivePath = () => this.path
getGeneration = () => this.generation
}
const relay = {
v: 1 as const,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2 as const
}
const host: HostProfile = {
id: 'host-1',
name: 'Blue Whale',
endpoint: 'ws://192.168.1.10:6768',
deviceToken: 'device-token',
publicKeyB64: 'A'.repeat(44),
lastConnected: 1,
endpoints: [
{ id: 'direct-primary', kind: 'lan', url: 'ws://192.168.1.10:6768' },
{ id: 'relay-primary', kind: 'relay', url: 'wss://relay-c1.onorca.dev/v1/connect/id' }
],
relayHostId: relay.relayHostId,
relay
}
const bundle: MobileRelayCredentialBundle = {
v: 1,
hostId: host.id,
deviceToken: host.deviceToken,
current: {
token: 'A'.repeat(43),
hash: 'B'.repeat(43),
version: 2,
expiresAt: Number.MAX_SAFE_INTEGER
}
}
function dependencies(
overrides: Partial<MobileEndpointSupervisorDependencies> = {}
): MobileEndpointSupervisorDependencies {
return {
openDirect: vi.fn(() => new FakeSession('connected')),
openRelay: vi.fn(() => new FakeRelaySession('connected')),
resolveRelay: vi.fn(async ({ relay }) => relay),
readBundle: vi.fn(async () => bundle),
writeBundle: vi.fn(async () => {}),
saveHost: vi.fn(async () => {}),
now: Date.now,
randomBytes: (length) => new Uint8Array(length).fill(1),
setTimer: setTimeout,
clearTimer: clearTimeout,
...overrides
}
}
function mockCredentialRotation(logical: FakeLogicalClient): void {
let installResult: Record<string, unknown> | null = null
logical.sendRequest.mockImplementation(async (method, params) => {
const request = params as { installReqId?: string; reqId?: string }
if (method === 'pairing.provisionRelay') {
installResult = {
v: 1,
reqId: request.reqId,
authorizationMode: 'authenticated-direct',
currentVersion: 3,
resumeExpiresAt: Date.now() + 300_000,
graceExpiresAt: Date.now() + 60_000
}
return { id: 'rpc-2', ok: true, result: installResult, _meta: { runtimeId: 'runtime-1' } }
}
return {
id: 'rpc-1',
ok: true,
result: {
v: 1,
relay,
installStatus: installResult
? { v: 1, reqId: request.installReqId, state: 'committed', result: installResult }
: { v: 1, reqId: request.installReqId, state: 'not-found' }
},
_meta: { runtimeId: 'runtime-1' }
}
})
}
describe('mobile endpoint supervisor', () => {
beforeEach(() => {
vi.useFakeTimers()
@@ -716,6 +561,7 @@ describe('mobile endpoint supervisor', () => {
.mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4408)))
const deps = dependencies({
openRelay,
openDirect: vi.fn(() => new FakeSession('disconnected')),
resolveRelay: vi.fn(() => resolvePending)
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
@@ -744,12 +590,13 @@ describe('mobile endpoint supervisor', () => {
.mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4408)))
const deps = dependencies({
openRelay,
openDirect: vi.fn(() => new FakeSession('disconnected')),
resolveRelay: vi.fn(() => resolvePending)
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(1000)
await vi.advanceTimersByTimeAsync(60_000)
await vi.waitFor(() => expect(deps.resolveRelay).toHaveBeenCalledOnce())
supervisor.setForeground(false)
finishResolve?.(relay)
@@ -785,11 +632,14 @@ describe('mobile endpoint supervisor', () => {
.fn()
.mockReturnValueOnce(new FakeRelaySession('connected', null, Date.now() + 31_000))
.mockImplementation(() => new FakeRelaySession('disconnected', new RelayOuterError(4404)))
const deps = dependencies({ openRelay })
const deps = dependencies({
openRelay,
openDirect: vi.fn(() => new FakeSession('disconnected'))
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(1000)
await vi.advanceTimersByTimeAsync(60_000)
expect(openRelay).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(5000)
@@ -797,6 +647,27 @@ describe('mobile endpoint supervisor', () => {
supervisor.stop()
})
it('skips forced rotation for a session resumed without renewal', async () => {
// Why: renewed=false means a re-resume provably returns the same unchanged
// deadline; rotating anyway churned one session replacement per clamp floor.
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi.fn(
() => new FakeRelaySession('connected', null, Date.now() + 31_000, false)
)
const deps = dependencies({
openRelay,
openDirect: vi.fn(() => new FakeSession('disconnected'))
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
expect(openRelay).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(5 * 60_000)
expect(openRelay).toHaveBeenCalledTimes(1)
supervisor.stop()
})
it('keeps a fatal lease-replacement gate after the active relay later drops', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi
@@ -808,12 +679,13 @@ describe('mobile endpoint supervisor', () => {
.mockImplementation(() => new FakeRelaySession('connected'))
const deps = dependencies({
openRelay,
openDirect: vi.fn(() => new FakeSession('disconnected')),
randomBytes: () => new Uint8Array([128, 0])
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(1000)
await vi.advanceTimersByTimeAsync(60_000)
expect(openRelay).toHaveBeenCalledTimes(2)
// The old relay can outlive its rejected lease replacement, then close separately.
@@ -833,12 +705,13 @@ describe('mobile endpoint supervisor', () => {
.mockImplementation(() => new FakeRelaySession('connected'))
const deps = dependencies({
openRelay,
openDirect: vi.fn(() => new FakeSession('disconnected')),
randomBytes: () => new Uint8Array([128, 0])
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(1000)
await vi.advanceTimersByTimeAsync(60_000)
expect(openRelay).toHaveBeenCalledTimes(2)
supervisor.setForeground(true)
@@ -1,5 +1,5 @@
import { openAuthenticatedDirectEndpoint } from './mobile-direct-endpoint-probe'
import type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor-contract'
import { DirectReturnProbe } from './mobile-direct-return-probe'
import { RelayReconnectController } from './mobile-relay-reconnect-controller'
import { RelayLeaseRotationTimer } from './mobile-relay-lease-rotation-timer'
import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis'
@@ -9,6 +9,8 @@ import {
persistRelayHost,
toError
} from './mobile-endpoint-supervisor-support'
import { selectDialableRelayCredentials } from './mobile-relay-credential-selection'
import { createRelayRecoveryLog, type RelayRecoveryLog } from './mobile-relay-recovery-log'
import {
applyResumeConfirmation,
mobileRelayCredentialNeedsRotation,
@@ -20,7 +22,6 @@ import type { HostProfile } from './types'
export type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor-contract'
const DIRECT_PROBE_INTERVAL_MS = 15_000
const DIRECT_OBSERVATION_MS = 30_000
const MINIMUM_DWELL_MS = 60_000
const FAILURE_COOLDOWN_MS = 60_000
@@ -32,11 +33,12 @@ export class MobileEndpointSupervisor {
private operationInFlight = false
private credentialRotationInFlight = false
private relayRotationPending = false
private probeTimer: ReturnType<typeof setTimeout> | null = null
private unsubscribeState: (() => void) | null = null
private readonly hysteresis: MobileEndpointHysteresis
private readonly relayReconnect: RelayReconnectController
private readonly leaseRotation: RelayLeaseRotationTimer
private readonly logRelay: RelayRecoveryLog
private readonly directProbe: DirectReturnProbe
constructor(
private readonly logical: StableLogicalRpcClient,
@@ -49,24 +51,52 @@ export class MobileEndpointSupervisor {
failureCooldownMs: FAILURE_COOLDOWN_MS,
minimumDwellMs: MINIMUM_DWELL_MS
})
this.logRelay = createRelayRecoveryLog(dependencies.now, dependencies.onLog)
this.relayReconnect = new RelayReconnectController(dependencies, this.recoverRelay.bind(this))
this.leaseRotation = new RelayLeaseRotationTimer(dependencies, () => {
this.relayRotationPending = true
void this.recoverRelay(true)
})
this.directProbe = new DirectReturnProbe(dependencies, {
hysteresis: this.hysteresis,
host: () => this.host,
canSchedule: () =>
!this.stopped && this.foreground && this.logical.getActivePath() === 'relay',
canAttempt: () => !this.stopped && this.foreground && !this.operationInFlight,
beginOperation: () => {
this.operationInFlight = true
},
migrate: (client, path) => this.logical.migrateTo(client, path),
onDirectMigrated: async () => {
this.leaseRotation.clear()
this.relayRotationPending = false
await this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection())
},
afterProbe: () => {
this.operationInFlight = false
if (this.relayRotationPending || this.logical.getState() !== 'connected') {
void this.recoverRelay(this.relayRotationPending)
}
}
})
}
async start(): Promise<void> {
this.bundle = await this.dependencies.readBundle(this.host.id).catch(() => null)
if (this.stopped || !this.bundle || !this.host.relay) {
if (this.stopped || !this.host.relay) {
return
}
if (!this.bundle) {
// Why: a Keychain race at open must not kill relay recovery for the whole
// process lifetime; each recovery attempt re-reads the durable bundle.
this.logRelay('credential bundle unavailable at start; recovery will re-read')
}
this.unsubscribeState = this.logical.onStateChange((state) => {
if (state === 'connected') {
if (this.logical.getActivePath() !== 'relay') {
void this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection())
}
this.scheduleDirectProbe()
this.directProbe.schedule()
} else {
// Why: the direct client enters reconnecting after its first failed
// dial and may never publish disconnected while its retry loop lives.
@@ -78,7 +108,7 @@ export class MobileEndpointSupervisor {
// are still loading, before the supervisor subscribes to state changes.
await this.recoverRelay()
} else {
this.scheduleDirectProbe()
this.directProbe.schedule()
}
}
@@ -87,11 +117,11 @@ export class MobileEndpointSupervisor {
this.foreground = foreground
if (foreground) {
this.relayReconnect.handleForeground(this.logical, wasForeground)
this.scheduleDirectProbe(0)
this.directProbe.schedule(0)
} else {
// Why: background phones must not hold billed relay data splices.
this.relayReconnect.suspendActiveRelay(this.logical)
this.clearDirectProbeTimer()
this.directProbe.clear()
this.relayReconnect.clear()
this.leaseRotation.clear()
}
@@ -101,7 +131,7 @@ export class MobileEndpointSupervisor {
this.stopped = true
this.unsubscribeState?.()
this.unsubscribeState = null
this.clearDirectProbeTimer()
this.directProbe.clear()
this.relayReconnect.clear()
this.leaseRotation.clear()
}
@@ -112,7 +142,6 @@ export class MobileEndpointSupervisor {
this.stopped ||
!this.foreground ||
this.operationInFlight ||
!this.bundle ||
!this.host.relay ||
(!forceReplacement && !this.relayReconnect.needsRecovery(this.logical.getState()))
) {
@@ -121,23 +150,42 @@ export class MobileEndpointSupervisor {
// Why: revival and lease timers can overlap resume failures; one shared cooldown
// prevents PEER_DROPPED/LIMIT_EXCEEDED reconnect churn.
if (this.relayReconnect.shouldDefer()) {
this.logRelay('recovery deferred by cooldown or gate')
return
}
this.operationInFlight = true
let lastError: Error | null = null
let retryAfterOperation = false
try {
const credentials = this.relayReconnect.eligibleCredentials(
this.bundle.current,
this.bundle.grace
)
for (const credential of credentials) {
const selection = await selectDialableRelayCredentials({
bundle: this.bundle,
controller: this.relayReconnect,
readBundle: () => this.dependencies.readBundle(this.host.id),
onAdoptedFresherBundle: () => this.logRelay('adopted fresher durable credential bundle')
})
this.bundle = selection.bundle
if (selection.credentials.length === 0) {
// Why: "expired" vs "missing" separates a sleep-past-expiry phone
// (needs re-pair or LAN) from a Keychain failure in field reports.
this.logRelay(
selection.bundle
? 'relay credential expired or rejected; slow reprobe armed'
: 'no relay credential bundle; slow reprobe armed'
)
this.relayReconnect.armCredentialReprobe()
return
}
for (const credential of selection.credentials) {
const result = await this.tryRelayCredential(credential)
if (result.ok) {
retryAfterOperation = this.logical.getState() !== 'connected'
return
}
lastError = result.error
this.logRelay(
'relay dial failed',
`${result.error.name}: ${String(result.error.message).slice(0, 80)}`
)
if (this.relayReconnect.shouldTryGraceAfterRelayFailure(result.error)) {
// Why: a rejected version stays invalid; retry only the grace credential.
this.relayReconnect.recordRejectedCredential(credential.version)
@@ -145,12 +193,10 @@ export class MobileEndpointSupervisor {
break
}
}
if (credentials.length > 0) {
// Why: cleanup may happen while a relay dial is awaiting the network;
// record its outcome without recreating a foreground retry timer.
const scheduleRetry = !forceReplacement && this.foreground && !this.stopped
this.relayReconnect.registerFailure(lastError, scheduleRetry)
}
// Why: cleanup may happen while a relay dial is awaiting the network;
// record its outcome without recreating a foreground retry timer.
const scheduleRetry = !forceReplacement && this.foreground && !this.stopped
this.relayReconnect.registerFailure(lastError, scheduleRetry)
} finally {
this.operationInFlight = false
if (forceReplacement && this.relayRotationPending && !this.stopped && this.foreground) {
@@ -207,6 +253,7 @@ export class MobileEndpointSupervisor {
}
this.relayRotationPending = false
this.hysteresis.recordMigration(this.dependencies.now())
this.logRelay('runtime channel migrated to relay')
const confirmation = session.getResumeConfirmation()
if (confirmation) {
this.bundle = applyResumeConfirmation(this.bundle, credential.version, confirmation)
@@ -215,71 +262,24 @@ export class MobileEndpointSupervisor {
await this.dependencies.writeBundle(this.bundle).catch(() => {})
}
// Why: async persistence can finish after stop/background; never recreate a stale timer.
// Why: rotate against the resume credential's expiry, never the hello's
// leaseExpiresAt — that field is the cell's ~10s attach-reservation
// deadline, and using it forced a session replacement every second.
// Why: renewed=false means a re-resume provably returns the same
// unchanged deadline — rotating then just churns one replacement per
// clamp floor until a fresh credential arrives over direct or disk.
this.leaseRotation.scheduleFromLease(
this.stopped || !this.foreground ? null : session.getLeaseExpiresAt()
this.stopped || !this.foreground || confirmation?.renewed === false
? null
: (confirmation?.resumeExpiresAt ?? session.getResumeExpiresAt())
)
this.scheduleDirectProbe()
this.directProbe.schedule()
return { ok: true }
} catch (error) {
return { ok: false, error: session.getFailure() ?? toError(error) }
}
}
private scheduleDirectProbe(delayMs = DIRECT_PROBE_INTERVAL_MS): void {
if (
this.stopped ||
!this.foreground ||
this.logical.getActivePath() !== 'relay' ||
this.probeTimer
) {
return
}
this.probeTimer = this.dependencies.setTimer(() => {
this.probeTimer = null
void this.probeDirect()
}, delayMs)
}
private async probeDirect(): Promise<void> {
if (
this.stopped ||
!this.foreground ||
this.operationInFlight ||
!this.hysteresis.canProbe(this.dependencies.now())
) {
this.scheduleDirectProbe()
return
}
this.operationInFlight = true
let successful: Awaited<ReturnType<typeof openAuthenticatedDirectEndpoint>> = null
try {
const openDirect = this.dependencies.openDirect
successful = await openAuthenticatedDirectEndpoint(this.host, openDirect, 12_000)
if (!successful) {
this.hysteresis.recordDirectFailure(this.dependencies.now())
return
}
if (!this.hysteresis.recordDirectSuccess(this.dependencies.now())) {
successful.client.close()
return
}
await this.logical.migrateTo(successful.client, successful.path)
successful = null
this.hysteresis.recordMigration(this.dependencies.now())
this.leaseRotation.clear()
this.relayRotationPending = false
await this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection())
} finally {
successful?.client.close()
this.operationInFlight = false
// Why: a relay drop or backoff timer can arrive while the direct probe owns the mutex.
if (this.relayRotationPending || this.logical.getState() !== 'connected') {
void this.recoverRelay(this.relayRotationPending)
}
this.scheduleDirectProbe()
}
}
private async rotateCredentialIfNeeded(force = false): Promise<void> {
if (
this.stopped ||
@@ -321,11 +321,4 @@ export class MobileEndpointSupervisor {
}
}
}
private clearDirectProbeTimer(): void {
if (this.probeTimer) {
this.dependencies.clearTimer(this.probeTimer)
this.probeTimer = null
}
}
}
@@ -0,0 +1,42 @@
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import type { RelayReconnectController } from './mobile-relay-reconnect-controller'
// Why: pairing recovery, re-pairing, or a raced rotation can land a fresh
// durable bundle after the supervisor snapshotted its copy; a gated retry must
// see it instead of dialing (or refusing to dial) on stale state forever.
//
// Adoption is decided by OUTCOME, not by version: renewals extend expiresAt
// without bumping current.version, and a re-pair restarts the version counter,
// so version comparison cannot tell fresh from stale. The durable bundle is
// adopted exactly when it yields a dialable (unexpired, non-rejected)
// credential while the in-memory one does not — which also means a revoked
// version can never be resurrected from a stale disk copy.
export async function selectDialableRelayCredentials(args: {
bundle: MobileRelayCredentialBundle | null
controller: RelayReconnectController
readBundle: () => Promise<MobileRelayCredentialBundle | null>
onAdoptedFresherBundle: () => void
}): Promise<{
bundle: MobileRelayCredentialBundle | null
credentials: MobileRelayCredentialBundle['current'][]
}> {
const memory = args.bundle
const memoryCredentials = memory
? args.controller.eligibleCredentials(memory.current, memory.grace)
: []
if (memoryCredentials.length > 0) {
// Why: in-memory can be newer than disk (a resume confirmation whose
// durable write failed); never let a stale disk copy shadow it.
return { bundle: memory, credentials: memoryCredentials }
}
const disk = await args.readBundle().catch(() => null)
if (disk) {
const diskCredentials = args.controller.eligibleCredentials(disk.current, disk.grace)
if (diskCredentials.length > 0) {
args.controller.acceptFreshCredential(disk.current.version)
args.onAdoptedFresherBundle()
return { bundle: disk, credentials: diskCredentials }
}
}
return { bundle: memory ?? disk, credentials: [] }
}
@@ -2,6 +2,13 @@
// little before the deadline (and retry shortly if a forced rotation didn't land)
// so the session never lapses. Owns the single lease/rotation timer slot.
const LEASE_ROTATION_MARGIN_MS = 30_000
// Why: both ends must be clamped. The floor bounds any bad deadline to one
// forced rotation per minute instead of a sub-second loop; the ceiling keeps
// the delay far below setTimeout's 32-bit limit (a 30-day resume TTL minus the
// margin overflows int32 and fires at 1ms), at the cost of a harmless
// re-resume every few hours on long-lived sessions.
const LEASE_ROTATION_MIN_DELAY_MS = 60_000
const LEASE_ROTATION_MAX_DELAY_MS = 6 * 60 * 60 * 1000
export type RelayLeaseRotationDependencies = {
now: () => number
@@ -23,9 +30,12 @@ export class RelayLeaseRotationTimer {
if (!leaseExpiresAt) {
return
}
const delay = Math.max(
1000,
leaseExpiresAt - this.dependencies.now() - LEASE_ROTATION_MARGIN_MS
const delay = Math.min(
LEASE_ROTATION_MAX_DELAY_MS,
Math.max(
LEASE_ROTATION_MIN_DELAY_MS,
leaseExpiresAt - this.dependencies.now() - LEASE_ROTATION_MARGIN_MS
)
)
this.arm(delay)
}
@@ -6,7 +6,9 @@ import { RelayReconnectController } from './mobile-relay-reconnect-controller'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))
vi.mock('expo-crypto', () => ({
getRandomBytes: (length: number) => new Uint8Array(length)
}))
describe('relay reconnect controller', () => {
beforeEach(() => {
@@ -47,16 +49,146 @@ describe('relay reconnect controller', () => {
expect(onRetry).not.toHaveBeenCalled()
})
it('waits for an external signal after rejected E2EE authentication', () => {
it('reprobes slowly after rejected E2EE authentication instead of parking forever', () => {
// Why: on a relay-only phone a permanent gate is a permanent outage — the
// desktop can commit pairing credentials moments after the first rejection.
const onRetry = vi.fn()
const reconnect = createController(onRetry)
reconnect.registerFailure(new MobileE2EEAuthenticationError())
expect(reconnect.shouldDefer()).toBe(true)
expect(vi.getTimerCount()).toBe(0)
vi.advanceTimersByTime(60_000)
expect(onRetry).not.toHaveBeenCalled()
vi.advanceTimersByTime(59_000)
expect(onRetry).not.toHaveBeenCalled()
vi.advanceTimersByTime(1_000)
expect(onRetry).toHaveBeenCalledTimes(1)
// The reprobe tick passes the gate exactly once, then defers again.
expect(reconnect.shouldDefer()).toBe(false)
expect(reconnect.shouldDefer()).toBe(true)
})
it('keeps the fresh-credential gate reprobing after each failed gated attempt', () => {
const onRetry = vi.fn()
const reconnect = createController(onRetry)
reconnect.registerFailure(new RelayOuterError(4401))
expect(reconnect.shouldDefer()).toBe(true)
vi.advanceTimersByTime(60_000)
expect(onRetry).toHaveBeenCalledTimes(1)
expect(reconnect.shouldDefer()).toBe(false)
// The gated attempt fails again with only rejected credentials on hand.
reconnect.registerFailure(new RelayOuterError(4401))
// The gated cadence escalates: the second reprobe waits twice as long.
vi.advanceTimersByTime(60_000)
expect(onRetry).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(60_000)
expect(onRetry).toHaveBeenCalledTimes(2)
})
it('resets the gated cadence when the app returns to the foreground', () => {
// Why: reopening the app is the strongest "conditions changed" signal a
// phone produces — it must not wait out an escalated 15-minute tick even
// when it cannot lift the credential gate itself.
const onRetry = vi.fn()
const reconnect = createController(onRetry)
const logical = { getState: () => 'disconnected' } as never
reconnect.registerFailure(new RelayOuterError(4401))
vi.advanceTimersByTime(60_000)
expect(onRetry).toHaveBeenCalledTimes(1)
// A failed gated attempt escalates the next tick beyond the base cadence.
reconnect.registerFailure(new RelayOuterError(4401))
reconnect.clear()
reconnect.handleForeground(logical, false)
expect(onRetry).toHaveBeenCalledTimes(2)
expect(reconnect.shouldDefer()).toBe(true)
vi.advanceTimersByTime(60_000)
expect(onRetry).toHaveBeenCalledTimes(3)
})
it('mints the gate pass when arming a no-bundle reprobe under a held gate', () => {
// Why: an external-signal gate never records rejected versions, so the
// no-dialable-bundle path must still mint the tick's pass token — a plain
// cooldown tick bounces off shouldDefer and doubles the effective cadence.
const onRetry = vi.fn()
const reconnect = createController(onRetry)
reconnect.registerFailure(new MobileE2EEAuthenticationError())
expect(reconnect.shouldDefer()).toBe(true)
vi.advanceTimersByTime(60_000)
expect(onRetry).toHaveBeenCalledTimes(1)
expect(reconnect.shouldDefer()).toBe(false)
// The gated attempt finds no dialable credential at all and re-arms.
reconnect.armCredentialReprobe()
vi.advanceTimersByTime(120_000)
expect(onRetry).toHaveBeenCalledTimes(2)
expect(reconnect.shouldDefer()).toBe(false)
})
it('does not arm a gate reprobe timer when the supervisor declined retries', () => {
// Why: scheduleRetry=false means background or stopped — a tick firing
// there wakes nothing useful, and the next foreground resume re-arms.
const onRetry = vi.fn()
const reconnect = createController(onRetry)
const logical = { getState: () => 'disconnected' } as never
reconnect.registerFailure(new MobileE2EEAuthenticationError(), false)
expect(vi.getTimerCount()).toBe(0)
reconnect.registerFailure(new RelayOuterError(4401), false)
expect(vi.getTimerCount()).toBe(0)
reconnect.registerFailure(new RelayOuterError(4429), false)
expect(vi.getTimerCount()).toBe(0)
vi.runAllTimers()
expect(onRetry).not.toHaveBeenCalled()
// Resume restores the gated cadence instead of stalling forever.
reconnect.handleForeground(logical, false)
expect(reconnect.shouldDefer()).toBe(true)
expect(vi.getTimerCount()).toBe(1)
})
it('does not let an orphaned reprobe timer swallow the next fast backoff', () => {
const onRetry = vi.fn()
const reconnect = createController(onRetry)
const logical = { getState: () => 'disconnected' } as never
reconnect.registerFailure(new MobileE2EEAuthenticationError())
// A foreground revival nudge clears the external-signal gate and its timer.
reconnect.handleForeground(logical, true)
expect(onRetry).toHaveBeenCalledTimes(1)
// A plain transport failure must schedule its own fast retry, not wait
// out a leftover 60s reprobe timer.
reconnect.registerFailure(new RelayOuterError(4429))
vi.advanceTimersByTime(600)
expect(onRetry).toHaveBeenCalledTimes(2)
})
it('lifts the fresh-credential gate when a durable bundle carries a new version', () => {
const onRetry = vi.fn()
const reconnect = createController(onRetry)
reconnect.recordRejectedCredential(2)
reconnect.armCredentialReprobe()
expect(reconnect.shouldDefer()).toBe(true)
reconnect.acceptFreshCredential(2)
expect(reconnect.shouldDefer()).toBe(true)
reconnect.acceptFreshCredential(3)
expect(reconnect.shouldDefer()).toBe(false)
expect(
reconnect.eligibleCredentials(
{ token: 'fresh', version: 3, expiresAt: Number.MAX_SAFE_INTEGER },
null
)
).toHaveLength(1)
})
it('upgrades host-revival gating to fresh credentials without later downgrading it', () => {
@@ -6,18 +6,10 @@ import {
import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel'
import { RelayOuterError } from './mobile-relay-e2ee-link'
import { RELAY_STABLE_CONNECTION_MS, RelayRetryDelays } from './mobile-relay-retry-delays'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionState } from './types'
// Why: relay resume closes and silent cellular NAT rebinds otherwise cause
// immediate re-dials that ping-pong the phone between connected and disconnected.
const RELAY_BACKOFF_MIN_MS = 250
const RELAY_BACKOFF_BASE_MS = 500
const RELAY_BACKOFF_CEILING_MS = 30_000
const RELAY_STABLE_CONNECTION_MS = RELAY_BACKOFF_CEILING_MS
const RELAY_HOST_OFFLINE_RETRY_MIN_MS = 5_000
const RELAY_HOST_OFFLINE_RETRY_MAX_MS = 15_000
export type RelayReconnectDependencies = {
now: () => number
randomBytes: (length: number) => Uint8Array
@@ -34,21 +26,29 @@ export class RelayReconnectController {
private timer: ReturnType<typeof setTimeout> | null = null
private activeSession: MobileRelayRpcSession | null = null
private recoveryGate: RecoveryGate | null = null
private gateReprobePending = false
private gateReprobeStreak = 0
private readonly rejectedCredentialVersions = new Set<number>()
private readonly delays: RelayRetryDelays
constructor(
private readonly dependencies: RelayReconnectDependencies,
private readonly onRetry: (forceReplacement?: boolean) => void
) {}
) {
this.delays = new RelayRetryDelays(dependencies.randomBytes)
}
handleForeground(logical: StableLogicalRpcClient, wasForeground: boolean): void {
if (!wasForeground) {
// Why: an app resume is a fresh signal, unlike repeated network-flap nudges.
// Why: an app resume is a fresh signal, unlike repeated network-flap
// nudges — it resets the gated cadence even when it cannot lift the
// credential gate, so reopening the app never waits out a 15min tick.
this.gateReprobeStreak = 0
if (this.recoveryGate !== 'fresh-credential') {
this.reset()
}
} else if (this.recoveryGate === 'external-signal') {
this.recoveryGate = null
this.liftGate()
}
if (
wasForeground &&
@@ -87,8 +87,7 @@ export class RelayReconnectController {
this.activeSession = session
this.activeRelayConnectedAt = this.dependencies.now()
this.nextAttemptAt = 0
this.recoveryGate = null
this.clearTimer()
this.liftGate()
}
resetForDirectConnection(): boolean {
@@ -97,10 +96,14 @@ export class RelayReconnectController {
this.activeSession = null
this.activeRelayConnectedAt = null
if (needsCredentialRefresh) {
// Why: the rejected credential stays unusable until its replacement is durable.
// Why: the rejected credential stays unusable until its replacement is
// durable. No reprobe timer here — direct is live, rotation over it
// clears the gate, and any later state failure re-arms via shouldDefer.
this.consecutiveFailures = 0
this.nextAttemptAt = 0
this.recoveryGate = 'fresh-credential'
this.gateReprobePending = false
this.gateReprobeStreak = 0
this.clearTimer()
} else {
this.reset()
@@ -128,10 +131,43 @@ export class RelayReconnectController {
if (eligible.length === 0 && this.rejectedCredentialVersions.size > 0) {
this.recoveryGate = 'fresh-credential'
this.clearTimer()
this.scheduleGateReprobe()
}
return eligible
}
// For callers that found no dialable credential at all: keep a slow retry
// alive so a later durable write can recover.
armCredentialReprobe(): void {
if (this.rejectedCredentialVersions.size > 0) {
this.recoveryGate = 'fresh-credential'
this.clearTimer()
this.scheduleGateReprobe()
return
}
if (this.recoveryGate) {
// Why: under a held gate the tick must mint its pass token — a plain
// cooldown tick bounces off shouldDefer and doubles the effective cadence.
this.clearTimer()
this.scheduleGateReprobe()
return
}
// Why: a merely missing or expired bundle must not enter the credential
// gate — that gate forces a rotation on the next direct connect. A plain
// cooldown retries the read on the same escalating cadence.
const delay = this.delays.gateReprobeDelayMs(this.gateReprobeStreak)
this.nextAttemptAt = this.dependencies.now() + delay
this.clearTimer()
this.scheduleReprobeTick(delay, false)
}
// A durable bundle whose current version is not rejected reopens the gate.
acceptFreshCredential(version: number): void {
if (this.recoveryGate === 'fresh-credential' && !this.rejectedCredentialVersions.has(version)) {
this.liftGate()
}
}
recordRejectedCredential(version: number): void {
this.rejectedCredentialVersions.add(version)
}
@@ -154,6 +190,12 @@ export class RelayReconnectController {
// re-dial. Arms the self-scheduled retry so recovery still happens on its own.
shouldDefer(): boolean {
if (this.recoveryGate) {
if (this.gateReprobePending) {
// Why: the slow reprobe tick gets exactly one attempt through the gate.
this.gateReprobePending = false
return false
}
this.scheduleGateReprobe()
return true
}
if (this.dependencies.now() < this.nextAttemptAt) {
@@ -173,8 +215,12 @@ export class RelayReconnectController {
this.recoveryGate === 'fresh-credential' ||
(this.recoveryGate === 'external-signal' && recovery?.kind !== 'disable-relay-credential')
) {
// Why: only the gate's external signal can make a known-fatal recovery retryable.
// Why: a failed reprobe stays gated, but the slow cadence must keep going
// — unless the supervisor is backgrounded/stopped; resume re-arms it.
this.clearTimer()
if (scheduleRetry) {
this.scheduleGateReprobe()
}
return
}
const now = this.dependencies.now()
@@ -189,18 +235,29 @@ export class RelayReconnectController {
this.activeRelayConnectedAt = null
this.consecutiveFailures += 1
const delay =
recovery?.kind === 'retry-after-host-offline' ? this.hostOfflineDelayMs() : this.delayMs()
recovery?.kind === 'retry-after-host-offline'
? this.delays.hostOfflineDelayMs()
: this.delays.transportDelayMs(this.consecutiveFailures)
this.nextAttemptAt = now + delay
if (error instanceof MobileE2EEAuthenticationError) {
// Why: pairing state cannot change on a timer; polling only wakes the radio.
// Why: an E2EE rejection is usually pairing revocation, but it also fires
// transiently right after pairing while the desktop commits credentials —
// reprobe slowly instead of waiting forever for a UI nudge.
this.recoveryGate = 'external-signal'
this.clearTimer()
if (scheduleRetry) {
this.scheduleGateReprobe()
}
return
}
if (recovery?.kind === 'disable-relay-credential') {
// Why: a rejected outer credential cannot recover until direct connectivity refreshes it.
// Why: never redial a rejected credential fast, but keep a slow reprobe
// alive — the caller re-reads durable state before each gated attempt.
this.recoveryGate = 'fresh-credential'
this.clearTimer()
if (scheduleRetry) {
this.scheduleGateReprobe()
}
return
}
if (recovery?.kind === 'retry-after-host-offline') {
@@ -235,12 +292,12 @@ export class RelayReconnectController {
this.consecutiveFailures = 0
this.activeRelayConnectedAt = null
this.nextAttemptAt = 0
this.recoveryGate = null
this.clearTimer()
this.liftGate()
}
clear(): void {
this.clearTimer()
this.gateReprobePending = false
this.activeSession = null
this.activeRelayConnectedAt = null
}
@@ -263,20 +320,34 @@ export class RelayReconnectController {
}, delay)
}
private delayMs(): number {
const exponent = Math.max(0, this.consecutiveFailures - 1)
const cap = Math.min(RELAY_BACKOFF_CEILING_MS, RELAY_BACKOFF_BASE_MS * 2 ** exponent)
// Full jitter (uniform in [0, cap)), floored so retries never busy-loop.
return Math.max(RELAY_BACKOFF_MIN_MS, Math.floor(cap * this.jitterFraction()))
private scheduleGateReprobe(): void {
this.scheduleReprobeTick(this.delays.gateReprobeDelayMs(this.gateReprobeStreak), true)
}
private hostOfflineDelayMs(): number {
const range = RELAY_HOST_OFFLINE_RETRY_MAX_MS - RELAY_HOST_OFFLINE_RETRY_MIN_MS
return RELAY_HOST_OFFLINE_RETRY_MIN_MS + Math.floor(range * this.jitterFraction())
private scheduleReprobeTick(delay: number, mintToken: boolean): void {
if (this.timer) {
return
}
this.timer = this.dependencies.setTimer(() => {
this.timer = null
// Why: the streak advances once per fired tick — arm attempts within one
// cycle recompute the same delay instead of triple-escalating it.
this.gateReprobeStreak = Math.min(this.gateReprobeStreak + 1, 8)
// Why: the tick token is only minted while its gate still holds; a later
// gate must not spend a stale token and bypass its own cooldown.
if (mintToken && this.recoveryGate) {
this.gateReprobePending = true
}
this.onRetry()
}, delay)
}
private jitterFraction(): number {
const [high, low] = this.dependencies.randomBytes(2)
return (((high ?? 0) << 8) | (low ?? 0)) / 0x1_00_00
// Why: clearing a gate must also drop its timer, pending tick, and cadence —
// an orphaned reprobe timer would otherwise swallow the next fast backoff.
private liftGate(): void {
this.recoveryGate = null
this.gateReprobePending = false
this.gateReprobeStreak = 0
this.clearTimer()
}
}
@@ -0,0 +1,22 @@
import type { ConnectionLogSink } from './types'
export type RelayRecoveryLog = (message: string, detail?: string) => void
// Why: relay recovery failed silently in production for weeks; every decision
// must reach logcat and the in-app connection log.
export function createRelayRecoveryLog(
now: () => number,
onLog?: ConnectionLogSink
): RelayRecoveryLog {
let sequence = 0
return (message, detail) => {
console.log(`[relay] ${message}`, detail ?? '')
onLog?.({
id: `relay-${++sequence}`,
ts: now(),
level: 'info',
message: `Relay: ${message}`,
...(detail ? { detail } : {})
})
}
}
@@ -0,0 +1,45 @@
// Why: relay resume closes and silent cellular NAT rebinds otherwise cause
// immediate re-dials that ping-pong the phone between connected and disconnected.
const RELAY_BACKOFF_MIN_MS = 250
const RELAY_BACKOFF_BASE_MS = 500
const RELAY_BACKOFF_CEILING_MS = 30_000
export const RELAY_STABLE_CONNECTION_MS = RELAY_BACKOFF_CEILING_MS
const RELAY_HOST_OFFLINE_RETRY_MIN_MS = 5_000
const RELAY_HOST_OFFLINE_RETRY_MAX_MS = 15_000
// Why: gates must slow recovery down, never end it — a relay-only phone has no
// direct path to refresh credentials, so a timerless gate is a permanent outage.
// The cadence escalates and jitters so a permanently revoked device is not a
// forever one-minute beacon and a fleet-wide gating event cannot phase-align.
const RELAY_GATE_REPROBE_BASE_MS = 60_000
const RELAY_GATE_REPROBE_CEILING_MS = 15 * 60_000
// Every relay retry delay in one place: transport backoff, known-offline
// polling, and the escalating gated reprobe cadence, all jittered.
export class RelayRetryDelays {
constructor(private readonly randomBytes: (length: number) => Uint8Array) {}
transportDelayMs(consecutiveFailures: number): number {
const exponent = Math.max(0, consecutiveFailures - 1)
const cap = Math.min(RELAY_BACKOFF_CEILING_MS, RELAY_BACKOFF_BASE_MS * 2 ** exponent)
// Full jitter (uniform in [0, cap)), floored so retries never busy-loop.
return Math.max(RELAY_BACKOFF_MIN_MS, Math.floor(cap * this.jitterFraction()))
}
hostOfflineDelayMs(): number {
const range = RELAY_HOST_OFFLINE_RETRY_MAX_MS - RELAY_HOST_OFFLINE_RETRY_MIN_MS
return RELAY_HOST_OFFLINE_RETRY_MIN_MS + Math.floor(range * this.jitterFraction())
}
gateReprobeDelayMs(streak: number): number {
const base = Math.min(
RELAY_GATE_REPROBE_CEILING_MS,
RELAY_GATE_REPROBE_BASE_MS * 2 ** Math.min(streak, 8)
)
return Math.floor(base * (0.75 + 0.5 * this.jitterFraction()))
}
private jitterFraction(): number {
const [high, low] = this.randomBytes(2)
return (((high ?? 0) << 8) | (low ?? 0)) / 0x1_00_00
}
}
@@ -119,7 +119,7 @@ describe('mobile relay RPC session', () => {
})
expect(confirmationRequest.params).not.toHaveProperty('relayDeviceId')
expect(confirmationRequest.params).not.toHaveProperty('acceptedCredentialVersion')
expect(session.getLeaseExpiresAt()).toEqual(expect.any(Number))
expect(session.getAttachDeadlineAt()).toEqual(expect.any(Number))
})
it('rejects a mismatched outer credential version and closes the physical link', () => {
@@ -19,7 +19,10 @@ type PendingRequest = {
}
export type MobileRelayRpcSession = RpcClient & {
getLeaseExpiresAt(): number | null
// The cell's attach-reservation deadline (~10s). Diagnostics only — never
// schedule anything from it; rotation keys off getResumeExpiresAt().
getAttachDeadlineAt(): number | null
getResumeExpiresAt(): number | null
getResumeConfirmation(): DeviceResumeConfirmed | null
getFailure(): Error | null
}
@@ -40,7 +43,8 @@ export function connectMobileRelayRpcSession(args: {
let state: ConnectionState = 'connecting'
let requestCounter = 0
let lastConnectedAt: number | null = null
let leaseExpiresAt: number | null = null
let attachDeadlineAt: number | null = null
let resumeExpiresAt: number | null = null
let resumeConfirmation: DeviceResumeConfirmed | null = null
let failure: Error | null = null
let closed = false
@@ -65,7 +69,8 @@ export function connectMobileRelayRpcSession(args: {
fail(new Error('relay resume credential version mismatch'))
return
}
leaseExpiresAt = hello.leaseExpiresAt
attachDeadlineAt = hello.leaseExpiresAt
resumeExpiresAt = hello.resumeExpiresAt
publishState('handshaking')
},
onAuthenticated: () => void confirmResume(),
@@ -109,7 +114,8 @@ export function connectMobileRelayRpcSession(args: {
streams.clear()
publishState('disconnected')
},
getLeaseExpiresAt: () => leaseExpiresAt,
getAttachDeadlineAt: () => attachDeadlineAt,
getResumeExpiresAt: () => resumeExpiresAt,
getResumeConfirmation: () => resumeConfirmation,
getFailure: () => failure
}
@@ -131,6 +137,7 @@ export function connectMobileRelayRpcSession(args: {
throw new Error('relay resume confirmation missing')
}
resumeConfirmation = result.resumeConfirmation
resumeExpiresAt = result.resumeConfirmation.resumeExpiresAt
lastConnectedAt = Date.now()
publishState('connected')
} catch (error) {
@@ -0,0 +1,454 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { connect, type RpcClient } from './rpc-client'
import {
createStableLogicalRpcClient,
type MobileConnectionPath,
type StableLogicalRpcClient
} from './stable-logical-rpc-client'
import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel'
import { RelayOuterError } from './mobile-relay-e2ee-link'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
import {
MobileEndpointSupervisor,
type MobileEndpointSupervisorDependencies
} from './mobile-endpoint-supervisor'
import type { ConnectionState, HostProfile, RpcResponse } from './types'
// Regression suite for the 2026-08 field failure: a phone paired over the
// relay whose direct LAN endpoint is unreachable (Tailscale off) dialed the
// LAN endpoint forever and never recovered a relay runtime session.
vi.mock('react-native', () => ({ Platform: { OS: 'android' } }))
vi.mock('expo-secure-store', () => ({
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked'
}))
vi.mock('expo-crypto', () => ({
getRandomBytes: (length: number) => new Uint8Array(length)
}))
vi.mock('./e2ee', () => ({
generateKeyPair: () => ({
publicKey: new Uint8Array(32),
secretKey: new Uint8Array(32)
}),
deriveSharedKey: () => new Uint8Array(32),
publicKeyFromBase64: () => new Uint8Array(32),
publicKeyToBase64: () => 'client-public-key',
encrypt: (plaintext: string) => plaintext,
decrypt: (raw: string) => raw,
decryptBytes: (bytes: Uint8Array) => bytes
}))
class FakeSession implements RpcClient {
readonly sendRequest = vi.fn(
async (): Promise<RpcResponse> => ({
id: 'rpc-1',
ok: true,
result: {},
_meta: { runtimeId: 'runtime-1' }
})
)
readonly subscribe = vi.fn(() => () => {})
readonly updateTerminalSubscriptionViewport = vi.fn()
readonly notifyForeground = vi.fn()
readonly close = vi.fn()
private readonly listeners = new Set<(state: ConnectionState) => void>()
constructor(private state: ConnectionState) {}
getState = () => this.state
getReconnectAttempt = () => 0
getLastConnectedAt = () => null
onStateChange = (listener: (state: ConnectionState) => void) => {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
publishState(state: ConnectionState): void {
this.state = state
for (const listener of this.listeners) {
listener(state)
}
}
}
class FakeRelaySession extends FakeSession implements MobileRelayRpcSession {
constructor(
state: ConnectionState,
private readonly failure: Error | null = null
) {
super(state)
}
// Why: production-realistic constants — fictional fake values hid three
// live defects in this subsystem (latch, churn, int32 timer overflow).
getAttachDeadlineAt = () => Date.now() + 10_000
getResumeExpiresAt = () => Date.now() + 30 * 24 * 3_600_000
getResumeConfirmation = () => null
getFailure = () => this.failure
}
class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
private path: MobileConnectionPath
constructor(state: ConnectionState, path: MobileConnectionPath) {
super(state)
this.path = path
}
migrateTo = vi.fn(async (session: RpcClient, path: MobileConnectionPath) => {
if (session.getState() !== 'connected') {
session.close()
throw new Error(`replacement session ${session.getState()}`)
}
this.path = path
this.publishState('connected')
})
suspendActiveSession = vi.fn(() => this.publishState('disconnected'))
getActivePath = () => this.path
}
const relay = {
v: 1 as const,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2 as const
}
const DIRECT_ENDPOINT = 'ws://100.88.90.25:6768'
const host: HostProfile = {
id: 'host-1',
name: 'Blue Whale',
endpoint: DIRECT_ENDPOINT,
deviceToken: 'device-token',
publicKeyB64: 'A'.repeat(44),
lastConnected: 1,
endpoints: [
{ id: 'direct-primary', kind: 'lan', url: DIRECT_ENDPOINT },
{
id: 'relay-primary',
kind: 'relay',
url: 'wss://relay-c1.onorca.dev/v1/connect/id'
}
],
relayHostId: relay.relayHostId,
relay
}
function bundleWith(version: number, expiresAt: number): MobileRelayCredentialBundle {
return {
v: 1,
hostId: host.id,
deviceToken: host.deviceToken,
current: {
token: `token-v${version}`.padEnd(43, 'A'),
hash: 'B'.repeat(43),
version,
expiresAt
}
}
}
function dependencies(
overrides: Partial<MobileEndpointSupervisorDependencies> = {}
): MobileEndpointSupervisorDependencies {
return {
openDirect: vi.fn(() => new FakeSession('connected')),
openRelay: vi.fn(() => new FakeRelaySession('connected')),
resolveRelay: vi.fn(async ({ relay }) => relay),
readBundle: vi.fn(async () => bundleWith(2, Number.MAX_SAFE_INTEGER)),
writeBundle: vi.fn(async () => {}),
saveHost: vi.fn(async () => {}),
now: Date.now,
randomBytes: (length: number) => new Uint8Array(length),
setTimer: setTimeout,
clearTimer: clearTimeout,
...overrides
}
}
describe('relay runtime recovery without direct connectivity', () => {
beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-02T12:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('recovers from a rejected outer credential once a fresher bundle is durable', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi
.fn()
.mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4401)))
.mockImplementation(() => new FakeRelaySession('connected'))
const readBundle = vi
.fn(async () => bundleWith(3, Number.MAX_SAFE_INTEGER))
.mockResolvedValueOnce(bundleWith(2, Number.MAX_SAFE_INTEGER))
const deps = dependencies({ openRelay, readBundle })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(0)
expect(openRelay).toHaveBeenCalledOnce()
// Pre-fix, the fresh-credential gate latched here with no timer and no exit.
await vi.advanceTimersByTimeAsync(60_000)
expect(openRelay).toHaveBeenCalledTimes(2)
expect(openRelay).toHaveBeenLastCalledWith(
relay,
expect.objectContaining({ version: 3 }),
expect.any(String)
)
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
it('recovers when the credential bundle was unreadable at supervisor start', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const readBundle = vi
.fn(async () => bundleWith(2, Number.MAX_SAFE_INTEGER))
.mockResolvedValueOnce(null)
const deps = dependencies({ readBundle })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
// Pre-fix, a null first read killed relay recovery for the process lifetime.
await supervisor.start()
await vi.advanceTimersByTimeAsync(0)
expect(deps.openRelay).toHaveBeenCalled()
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
it('recovers from an expired-at-start bundle after a fresh credential lands', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const expired = bundleWith(2, Date.now() - 1)
const readBundle = vi.fn(async () => expired)
const deps = dependencies({ readBundle })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(0)
expect(deps.openRelay).not.toHaveBeenCalled()
readBundle.mockImplementation(async () => bundleWith(3, Number.MAX_SAFE_INTEGER))
// Pre-fix, an expired bundle produced a silent no-op with nothing scheduled.
await vi.advanceTimersByTimeAsync(60_000)
expect(deps.openRelay).toHaveBeenCalledOnce()
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
it('retries after an E2EE authentication rejection without a UI nudge', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi
.fn()
.mockReturnValueOnce(
new FakeRelaySession('disconnected', new MobileE2EEAuthenticationError())
)
.mockImplementation(() => new FakeRelaySession('connected'))
const deps = dependencies({ openRelay })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(0)
expect(openRelay).toHaveBeenCalledOnce()
// Pre-fix, this state waited indefinitely for a foreground/navigation event.
await vi.advanceTimersByTimeAsync(60_000)
expect(openRelay).toHaveBeenCalledTimes(2)
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
it('adopts a durable renewal that extends expiry without bumping the version', async () => {
// Why: resume confirmations and pairing recovery renew expiresAt while
// keeping current.version — version comparison cannot see this freshness.
const logical = new FakeLogicalClient('disconnected', 'lan')
const expired = bundleWith(2, Date.now() - 1)
const readBundle = vi
.fn(async () => bundleWith(2, Number.MAX_SAFE_INTEGER))
.mockResolvedValueOnce(expired)
.mockResolvedValueOnce(expired)
const deps = dependencies({ readBundle })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(0)
expect(deps.openRelay).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(60_000)
expect(deps.openRelay).toHaveBeenCalledOnce()
expect(deps.openRelay).toHaveBeenLastCalledWith(
relay,
expect.objectContaining({ version: 2 }),
expect.any(String)
)
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
it('adopts a re-paired credential whose version counter restarted', async () => {
// Why: re-pairing overwrites the same keychain slot with a NEW credential
// record whose counter restarts at 1 — lower than the rejected version.
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi
.fn()
.mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4401)))
.mockImplementation(() => new FakeRelaySession('connected'))
const readBundle = vi
.fn(async () => bundleWith(1, Number.MAX_SAFE_INTEGER))
.mockResolvedValueOnce(bundleWith(4, Number.MAX_SAFE_INTEGER))
const deps = dependencies({ openRelay, readBundle })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(0)
expect(openRelay).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(60_000)
expect(openRelay).toHaveBeenCalledTimes(2)
expect(openRelay).toHaveBeenLastCalledWith(
relay,
expect.objectContaining({ version: 1 }),
expect.any(String)
)
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
it('does not churn relay sessions off the cell attach-reservation deadline', async () => {
// Why: the relay-hello's leaseExpiresAt is a ~10s attach deadline. Keying
// rotation off it replaced the session every second, killing any RPC
// slower than the cycle (the field symptom: "Worktree list unavailable").
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi.fn(() => new FakeRelaySession('connected'))
// Direct stays unreachable, as in the field — return probes must not
// confuse the churn measurement by migrating back to direct.
const openDirect = vi.fn(() => new FakeSession('disconnected'))
const deps = dependencies({ openRelay, openDirect })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(0)
expect(openRelay).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(5 * 60_000)
expect(openRelay).toHaveBeenCalledOnce()
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
it('recovers immediately on a background/foreground cycle after an E2EE rejection', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi
.fn()
.mockReturnValueOnce(
new FakeRelaySession('disconnected', new MobileE2EEAuthenticationError())
)
.mockImplementation(() => new FakeRelaySession('connected'))
const deps = dependencies({ openRelay })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(0)
expect(openRelay).toHaveBeenCalledOnce()
supervisor.setForeground(false)
supervisor.setForeground(true)
await vi.advanceTimersByTimeAsync(0)
expect(openRelay).toHaveBeenCalledTimes(2)
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
})
// The field failure's first symptom: a real direct rpc-client dialing an
// unroutable LAN endpoint (instant 1006) while relay credentials sit unused.
class DeadSocket {
static constructed: string[] = []
onopen: (() => void) | null = null
onclose: ((event: { code?: number; reason?: string }) => void) | null = null
onerror: ((event: unknown) => void) | null = null
onmessage: ((event: { data: unknown }) => void) | null = null
readyState = 0
constructor(readonly url: string) {
DeadSocket.constructed.push(url)
setTimeout(() => {
this.readyState = 3
this.onclose?.({ code: 1006, reason: '' })
}, 50)
}
send(): void {}
close(): void {
this.readyState = 3
}
}
describe('failover with a real direct rpc-client', () => {
beforeEach(() => {
DeadSocket.constructed = []
vi.stubGlobal('WebSocket', DeadSocket)
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-02T12:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('migrates the logical client to relay after the direct dial fails', async () => {
const logical = createStableLogicalRpcClient(
connect(DIRECT_ENDPOINT, host.deviceToken, host.publicKeyB64),
'tailscale' as MobileConnectionPath
)
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(3_000)
expect(deps.openRelay).toHaveBeenCalled()
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
logical.close()
})
it('reaches relay even when the bundle read settles after the first direct failure', async () => {
const logical = createStableLogicalRpcClient(
connect(DIRECT_ENDPOINT, host.deviceToken, host.publicKeyB64),
'tailscale' as MobileConnectionPath
)
const deps = dependencies({
readBundle: vi.fn(async () => {
await new Promise((resolve) => setTimeout(resolve, 200))
return bundleWith(2, Number.MAX_SAFE_INTEGER)
})
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
const started = supervisor.start()
await vi.advanceTimersByTimeAsync(300)
await started
await vi.advanceTimersByTimeAsync(3_000)
expect(deps.openRelay).toHaveBeenCalled()
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
logical.close()
})
})
@@ -0,0 +1,236 @@
import { describe, expect, it } from 'vitest'
import { createHostConnectRefetchGate } from '../transport/host-connect-refetch-gate'
import type { RpcClient } from '../transport/rpc-client'
import {
createStableLogicalRpcClient,
LogicalClientCutoverError
} from '../transport/stable-logical-rpc-client'
import type { ConnectionState, RpcResponse } from '../transport/types'
import { fetchHomeHostWorktreeInfo } from './home-host-worktree-fetch'
import type { HostWorktreeInfo } from './home-worktree-info'
type FakeSession = {
client: RpcClient
calls: number
settle: (response: RpcResponse | Error) => void
}
function fakeSession(): FakeSession {
const pending: Array<(response: RpcResponse | Error) => void> = []
const fake: FakeSession = {
calls: 0,
settle(response: RpcResponse | Error) {
const next = pending.shift()
next?.(response)
},
client: {
sendRequest: () => {
fake.calls += 1
return new Promise<RpcResponse>((resolve, reject) => {
pending.push((response) =>
response instanceof Error ? reject(response) : resolve(response)
)
})
},
subscribe: () => () => {},
updateTerminalSubscriptionViewport: () => {},
getState: (): ConnectionState => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
onStateChange: () => () => {},
notifyForeground: () => {},
close: () => {}
}
}
return fake
}
// Drains the promise chain so a retry queued in a .catch has reached the transport.
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
function catalogResponse(count: number, active: number): RpcResponse {
const worktrees = Array.from({ length: count }, (_, index) => ({
worktreeId: `wt-${index}`,
repo: 'orca',
branch: `branch-${index}`,
displayName: `Workspace ${index}`,
liveTerminalCount: 0,
status: index < active ? ('working' as const) : ('done' as const)
}))
return { ok: true, result: { worktrees } } as RpcResponse
}
function infoStore() {
let state: Record<string, HostWorktreeInfo> = {}
return {
get current() {
return state
},
setInfo(updater: (prev: Record<string, HostWorktreeInfo>) => Record<string, HostWorktreeInfo>) {
state = updater(state)
}
}
}
const notDisposed = () => false
describe('fetchHomeHostWorktreeInfo', () => {
it('keeps the last proven counts when the in-flight read rejects', async () => {
const store = infoStore()
const host = fakeSession()
const loaded = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed)
host.settle(catalogResponse(12, 2))
await loaded
const failed = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed)
host.settle(new Error('socket closed mid-request'))
await failed
expect(store.current['host-1']).toEqual({
hostId: 'host-1',
totalWorktrees: 12,
activeCount: 2,
lastActiveWorktree: expect.objectContaining({ worktreeId: 'wt-0' }),
catalogUnavailable: true,
staleCounts: true,
// Age-stamped so the card can stop calling day-old counts "last known".
countsProvenAt: expect.any(Number)
})
})
it('stamps when the host proved the counts', async () => {
const store = infoStore()
const host = fakeSession()
const before = Date.now()
const loaded = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed)
host.settle(catalogResponse(3, 1))
await loaded
const provenAt = store.current['host-1'].countsProvenAt
expect(provenAt).toBeGreaterThanOrEqual(before)
expect(provenAt).toBeLessThanOrEqual(Date.now())
})
it('marks a host whose catalog never loaded as unavailable, not empty', async () => {
const store = infoStore()
const host = fakeSession()
const failed = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed)
host.settle({ ok: false, error: { code: 'internal' } } as RpcResponse)
await failed
expect(store.current['host-1']).toMatchObject({
totalWorktrees: 0,
catalogUnavailable: true
})
expect(store.current['host-1'].staleCounts).toBeUndefined()
})
it('re-reads the catalog on reconnect and clears the stale flag', async () => {
const store = infoStore()
const host = fakeSession()
const gate = createHostConnectRefetchGate()
const connect = async (response: RpcResponse | Error) => {
if (!gate.observe('connected')) {
return
}
const done = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed)
host.settle(response)
await done
}
await connect(catalogResponse(12, 2))
// Socket dies mid-poll: counts survive, flagged stale.
const dropped = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed)
host.settle(new Error('socket closed mid-request'))
await dropped
gate.observe('reconnecting')
expect(store.current['host-1'].staleCounts).toBe(true)
await connect(catalogResponse(13, 3))
expect(store.current['host-1']).toMatchObject({
totalWorktrees: 13,
activeCount: 3
})
expect(store.current['host-1'].staleCounts).toBeUndefined()
expect(store.current['host-1'].catalogUnavailable).toBeUndefined()
// Two connects + the dropped poll: the gate must not re-read while the link holds.
expect(host.calls).toBe(3)
await connect(catalogResponse(13, 3))
expect(host.calls).toBe(3)
})
it('re-reads through a relay→direct cutover, which never leaves connected', async () => {
const store = infoStore()
const relay = fakeSession()
const direct = fakeSession()
const logical = createStableLogicalRpcClient(relay.client, 'relay')
const gate = createHostConnectRefetchGate()
const gateFires: ConnectionState[] = []
logical.onStateChange((state) => {
if (gate.observe(state)) {
gateFires.push(state)
}
})
gate.observe(logical.getState())
const loaded = fetchHomeHostWorktreeInfo(logical, 'host-1', store.setInfo, notDisposed)
relay.settle(catalogResponse(12, 2))
await loaded
// The supervisor's direct probe migrates while a refresh is on the wire.
const interrupted = fetchHomeHostWorktreeInfo(logical, 'host-1', store.setInfo, notDisposed)
await logical.migrateTo(direct.client, 'lan')
await flush()
expect(direct.calls).toBe(1)
direct.settle(catalogResponse(13, 3))
await interrupted
// The gate can't cover this: migrateTo republishes 'connected' from 'connected'.
expect(gateFires).toEqual([])
expect(store.current['host-1']).toMatchObject({ totalWorktrees: 13, activeCount: 3 })
expect(store.current['host-1'].staleCounts).toBeUndefined()
expect(store.current['host-1'].catalogUnavailable).toBeUndefined()
})
it('gives up on a host that keeps cutting over so the card still reports the failure', async () => {
const store = infoStore()
const host = fakeSession()
const loaded = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed)
host.settle(catalogResponse(12, 2))
await loaded
const cutoverStorm = fetchHomeHostWorktreeInfo(
host.client,
'host-1',
store.setInfo,
notDisposed
)
for (let attempt = 0; attempt < 6; attempt += 1) {
host.settle(new LogicalClientCutoverError())
await flush()
}
await cutoverStorm
// 1 original + CUTOVER_RETRY_LIMIT retries, then the failure is surfaced.
expect(host.calls).toBe(4)
expect(store.current['host-1'].staleCounts).toBe(true)
})
it('ignores a response that lands after the screen is disposed', async () => {
const store = infoStore()
const host = fakeSession()
const done = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, () => true)
host.settle(new Error('socket closed mid-request'))
await done
expect(store.current['host-1']).toBeUndefined()
})
})
@@ -0,0 +1,79 @@
import { setCachedWorktrees } from '../cache/worktree-cache'
import { sendSingleFlightRequest } from '../transport/request-single-flight'
import type { RpcClient } from '../transport/rpc-client'
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import {
markHomeWorktreeCatalogUnavailable,
type HomeWorktreeSummary,
type HostWorktreeInfo
} from './home-worktree-info'
import { pickResumeWorktree } from './resume-worktree'
import { WORKTREE_PS_FULL_LIMIT } from './worktree-catalog-snapshot-client'
const ACTIVE_STATUSES = new Set(['working', 'active', 'permission'])
// Why: a relay↔direct cutover rejects in-flight reads without ever leaving 'connected', so the
// connect gate never re-arms. Re-issue on the replacement session; cap it so a migration loop
// can't spin. See runtime-capability-probe.ts for the same hazard on status.get.
const CUTOVER_RETRY_LIMIT = 2
export type HostWorktreeInfoSetter = (
updater: (prev: Record<string, HostWorktreeInfo>) => Record<string, HostWorktreeInfo>
) => void
/** Reads one host's worktree catalog for the Home card, preserving proven counts on failure. */
export function fetchHomeHostWorktreeInfo(
client: RpcClient,
hostId: string,
setInfo: HostWorktreeInfoSetter,
disposed: () => boolean
): Promise<void> {
const markUnavailable = (): void => {
setInfo((prev) => {
const current = prev[hostId]
const next = markHomeWorktreeCatalogUnavailable(current, hostId)
return next === current ? prev : { ...prev, [hostId]: next }
})
}
const attempt = (cutoverRetriesLeft: number): Promise<void> =>
sendSingleFlightRequest(client, hostId, 'worktree.ps', { limit: WORKTREE_PS_FULL_LIMIT })
.then((response) => {
if (disposed()) {
return
}
if (!response.ok) {
markUnavailable()
return
}
const result = response.result as { worktrees?: HomeWorktreeSummary[] }
const worktrees = result.worktrees ?? []
setCachedWorktrees(hostId, worktrees)
const active = worktrees.filter((w) => w.status && ACTIVE_STATUSES.has(w.status))
// Mirror the desktop's focused workspace (see pickResumeWorktree).
const lastActive = pickResumeWorktree(worktrees)
setInfo((prev) => ({
...prev,
[hostId]: {
hostId,
totalWorktrees: worktrees.length,
activeCount: active.length,
lastActiveWorktree: lastActive,
countsProvenAt: Date.now()
}
}))
})
.catch((error: unknown) => {
if (disposed()) {
return
}
// A cutover raises only after migrateTo installed an authenticated replacement, so the
// read was interrupted, not answered — ask again instead of latching "Last known".
if (cutoverRetriesLeft > 0 && isLogicalClientCutoverError(error)) {
return attempt(cutoverRetriesLeft - 1)
}
// Any other rejection (socket died mid-request) is a failed refresh, not an empty host.
markUnavailable()
})
return attempt(CUTOVER_RETRY_LIMIT)
}
+78 -2
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
import { markHomeWorktreeCatalogUnavailable, type HostWorktreeInfo } from './home-worktree-info'
import {
HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS,
homeHostWorktreeSummary,
markHomeWorktreeCatalogUnavailable,
type HostWorktreeInfo
} from './home-worktree-info'
describe('markHomeWorktreeCatalogUnavailable', () => {
it('marks a host unavailable when no catalog has loaded', () => {
@@ -28,7 +33,8 @@ describe('markHomeWorktreeCatalogUnavailable', () => {
expect(markHomeWorktreeCatalogUnavailable(current, 'host-1')).toEqual({
...current,
catalogUnavailable: true
catalogUnavailable: true,
staleCounts: true
})
})
@@ -44,3 +50,73 @@ describe('markHomeWorktreeCatalogUnavailable', () => {
expect(markHomeWorktreeCatalogUnavailable(current, 'host-1')).toBe(current)
})
})
describe('homeHostWorktreeSummary', () => {
const provenAt = 1_700_000_000_000
const loaded: HostWorktreeInfo = {
hostId: 'host-1',
totalWorktrees: 12,
activeCount: 2,
lastActiveWorktree: null,
countsProvenAt: provenAt
}
it('summarizes a freshly loaded catalog', () => {
expect(homeHostWorktreeSummary(loaded, provenAt)).toBe('12 worktrees · 2 active')
expect(
homeHostWorktreeSummary({ ...loaded, totalWorktrees: 1, activeCount: 0 }, provenAt)
).toBe('1 worktree')
})
it('keeps showing the last proven counts after a failed refresh', () => {
const afterFailure = markHomeWorktreeCatalogUnavailable(loaded, 'host-1')
expect(homeHostWorktreeSummary(afterFailure, provenAt + 30_000)).toBe(
'Last known: 12 worktrees · 2 active'
)
})
it('keeps the last proven counts however long ago the refresh failed', () => {
const afterFailure = markHomeWorktreeCatalogUnavailable(loaded, 'host-1')
// A session that stays connected past the live window then hits one failed refresh is the
// headline case: the counts are history, not nothing.
expect(
homeHostWorktreeSummary(afterFailure, provenAt + HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS + 1)
).toBe('Last known: 12 worktrees · 2 active')
expect(homeHostWorktreeSummary(afterFailure, provenAt + 3 * 86_400_000)).toBe(
'Last known: 12 worktrees · 2 active'
)
})
it('stops calling aged-out counts live even while a refresh is only pending', () => {
// A snapshot rehydrated from a previous app session describes a host we have not reached yet.
expect(homeHostWorktreeSummary(loaded, provenAt + HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS)).toBe(
'12 worktrees · 2 active'
)
expect(
homeHostWorktreeSummary(loaded, provenAt + HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS + 1)
).toBe('Last known: 12 worktrees · 2 active')
expect(homeHostWorktreeSummary(loaded, provenAt + 3 * 86_400_000)).toBe(
'Last known: 12 worktrees · 2 active'
)
})
it('treats counts persisted before age stamping as no longer live', () => {
const preUpgrade: HostWorktreeInfo = { ...loaded, countsProvenAt: undefined }
expect(homeHostWorktreeSummary(preUpgrade, provenAt)).toBe(
'Last known: 12 worktrees · 2 active'
)
expect(
homeHostWorktreeSummary(markHomeWorktreeCatalogUnavailable(preUpgrade, 'host-1'), provenAt)
).toBe('Last known: 12 worktrees · 2 active')
})
it('reports unavailable only when no catalog ever loaded', () => {
expect(homeHostWorktreeSummary(markHomeWorktreeCatalogUnavailable(undefined, 'host-1'))).toBe(
'Worktree list unavailable'
)
expect(homeHostWorktreeSummary(undefined)).toBeNull()
})
})
+41 -1
View File
@@ -15,8 +15,17 @@ export type HostWorktreeInfo = {
activeCount: number
lastActiveWorktree: HomeWorktreeSummary | null
catalogUnavailable?: boolean
// The counts are the last proven ones, kept across a failed refresh.
staleCounts?: boolean
// When the host last answered with these counts (ms epoch); absent in snapshots an older
// build persisted, which is why an unstamped count counts as expired rather than fresh.
countsProvenAt?: number
}
// Why: the home snapshot is persisted across launches, so counts could otherwise be rendered as
// live while describing a workspace set proven days ago. Past this window they are only history.
export const HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS = 10 * 60_000
export function markHomeWorktreeCatalogUnavailable(
current: HostWorktreeInfo | undefined,
hostId: string
@@ -25,7 +34,9 @@ export function markHomeWorktreeCatalogUnavailable(
return current
}
if (current) {
return { ...current, catalogUnavailable: true }
// Why: `current` predates this failure, so its counts are proven host truth — a dropped
// socket must not erase them, only flag them as no longer live.
return { ...current, catalogUnavailable: true, staleCounts: true }
}
return {
hostId,
@@ -35,3 +46,32 @@ export function markHomeWorktreeCatalogUnavailable(
catalogUnavailable: true
}
}
/** The host card's worktree line, or null when nothing is known yet. */
export function homeHostWorktreeSummary(
info: HostWorktreeInfo | undefined,
now: number = Date.now()
): string | null {
if (!info) {
return null
}
// Why (STA-3123): a catalog that never loaded must not assert a count the host has not proven.
if (info.catalogUnavailable && !info.staleCounts) {
return 'Worktree list unavailable'
}
const counts = `${info.totalWorktrees} worktree${info.totalWorktrees === 1 ? '' : 's'}${
info.activeCount > 0 ? ` · ${info.activeCount} active` : ''
}`
// Age bounds liveness, not the counts themselves: a failed refresh — or a rehydrated snapshot the
// host has not re-confirmed — is still the last thing it told us, so keep it and drop the claim
// that it is current.
return info.staleCounts || !provenRecently(info, now) ? `Last known: ${counts}` : counts
}
function provenRecently(info: HostWorktreeInfo, now: number): boolean {
// A snapshot written before countsProvenAt existed has an unknowable age; treat it as expired.
return (
typeof info.countsProvenAt === 'number' &&
now - info.countsProvenAt <= HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS
)
}
@@ -106,6 +106,95 @@ describe('filterWorktrees', () => {
).toEqual([featureNamedMain])
})
it('keeps a sleeping main worktree visible under hide-sleeping by default (#8873)', () => {
const main = worktree({ worktreeId: 'main', branch: 'main', isMainWorktree: true })
const feature = worktree({ worktreeId: 'feature', isMainWorktree: false })
expect(
filterWorktrees(
[main, feature],
{ filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false },
''
)
).toEqual([main])
})
it('keeps a sleeping folder workspace visible under hide-sleeping', () => {
const folder = worktree({
workspaceKind: 'folder-workspace',
worktreeId: 'folder:workspace-1',
branch: '',
isMainWorktree: true
})
expect(
filterWorktrees(
[folder],
{ filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false },
''
)
).toEqual([folder])
})
it('re-hides the sleeping main worktree when the desktop setting is off', () => {
const main = worktree({ worktreeId: 'main', branch: 'main', isMainWorktree: true })
expect(
filterWorktrees(
[main],
{
filterRepoIds: new Set(),
hideSleeping: true,
hideDefaultBranch: false,
alwaysShowDefaultBranch: false
},
''
)
).toEqual([])
})
it('falls back to the branch heuristic for hosts that omit isMainWorktree', () => {
const legacyMain = worktree({ worktreeId: 'legacy-main', branch: 'refs/heads/master' })
expect(
filterWorktrees(
[legacyMain],
{ filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false },
''
)
).toEqual([legacyMain])
})
it('keeps a sleeping folder workspace on hosts that omit isMainWorktree', () => {
// A folder workspace has no branch, so the legacy branch heuristic can never
// recognise it — without its own arm, #8873 still reproduces on old desktops.
const legacyFolder = worktree({
workspaceKind: 'folder-workspace',
worktreeId: 'folder:legacy-1',
branch: ''
})
expect(
filterWorktrees(
[legacyFolder],
{ filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false },
''
)
).toEqual([legacyFolder])
})
it('lets hide-default-branch still win over the sleeping exemption', () => {
const main = worktree({ worktreeId: 'main', branch: 'main', isMainWorktree: true })
expect(
filterWorktrees(
[main],
{ filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: true },
''
)
).toEqual([])
})
it('keeps folder workspaces when default branch hiding is enabled', () => {
const folder = worktree({
workspaceKind: 'folder-workspace',
+16 -1
View File
@@ -65,6 +65,19 @@ function isDefaultBranchWorkspace(w: Worktree): boolean {
return branch === 'main' || branch === 'master'
}
/**
* Whether "Hide sleeping" must keep this row — the project's entry point (#8873).
* Falls back to the branch heuristic for hosts that predate isMainWorktree; a
* folder workspace is always its project's entry point, and isDefaultBranchWorkspace
* rejects it by design, so it needs its own legacy arm or #8873 still reproduces.
*/
function isSleepingSweepExempt(w: Worktree, alwaysShowDefaultBranch: boolean | undefined): boolean {
if (alwaysShowDefaultBranch === false) {
return false
}
return w.isMainWorktree ?? (w.workspaceKind === 'folder-workspace' || isDefaultBranchWorkspace(w))
}
function orderMainWorktreeFirst(worktrees: Worktree[]): Worktree[] {
const mainWorktrees = worktrees.filter((worktree) => worktree.isMainWorktree)
if (mainWorktrees.length === 0) {
@@ -80,7 +93,9 @@ export function filterWorktrees(
): Worktree[] {
let result = worktrees.filter((w) => !w.isArchived)
if (filters.hideSleeping) {
result = result.filter(isWorktreeActive)
result = result.filter(
(w) => isSleepingSweepExempt(w, filters.alwaysShowDefaultBranch) || isWorktreeActive(w)
)
}
if (filters.hideDefaultBranch) {
result = result.filter((w) => !isDefaultBranchWorkspace(w))
@@ -52,6 +52,8 @@ export type FilterState = {
filterRepoIds: Set<string>
hideSleeping: boolean
hideDefaultBranch: boolean
/** Absent means on: #8873's exemption must fail open on older host payloads. */
alwaysShowDefaultBranch?: boolean
}
export type Section = { key: string; title: string; icon?: 'pin'; data: Worktree[] }
@@ -16,6 +16,7 @@ export type WorkspaceViewSettings = {
sortBy?: 'name' | 'smart' | 'recent' | 'repo' | 'manual'
hideSleepingWorkspaces?: boolean
hideDefaultBranchWorkspace?: boolean
alwaysShowDefaultBranchWorkspace?: boolean
filterRepoIds?: string[]
collapsedGroups?: string[]
workspaceStatuses?: WorkspaceStatusDefinition[]
@@ -60,6 +61,7 @@ export type MobileViewState = {
sortMode: MobileSortMode
hideSleeping: boolean
hideDefaultBranch: boolean
alwaysShowDefaultBranch: boolean
filterRepoIds: string[]
collapsedGroups: string[]
workspaceStatuses: readonly WorkspaceStatusDefinition[]
@@ -83,6 +85,8 @@ export function applyDesktopViewSettings(
sortMode: sortMode ?? current.sortMode,
hideSleeping: settings.hideSleepingWorkspaces ?? current.hideSleeping,
hideDefaultBranch: settings.hideDefaultBranchWorkspace ?? current.hideDefaultBranch,
alwaysShowDefaultBranch:
settings.alwaysShowDefaultBranchWorkspace ?? current.alwaysShowDefaultBranch,
filterRepoIds: settings.filterRepoIds ?? current.filterRepoIds,
collapsedGroups: settings.collapsedGroups ?? current.collapsedGroups,
workspaceStatuses
+3 -1
View File
@@ -9,6 +9,8 @@ export default defineConfig({
oxc: vitestOxcConfig,
test: {
environment: 'node',
include: ['src/**/*.test.ts']
// .tsx too: component tests exist (react-test-renderer + mocked react-native) and were
// silently never collected, so render-level regressions shipped untested.
include: ['src/**/*.test.ts', 'src/**/*.test.tsx']
}
})
+14 -23
View File
@@ -1,5 +1,4 @@
using System;
using System.Collections.Specialized;
using System.Diagnostics;
using System.IO;
using System.Text;
@@ -43,18 +42,17 @@ internal static class OrcaCliLauncher
// Why: launching without cmd.exe preserves embedded newlines while matching the
// packaged batch launcher's Electron-as-Node environment contract.
MoveEnvironmentVariable(startInfo.EnvironmentVariables, "NODE_OPTIONS", "ORCA_NODE_OPTIONS");
MoveEnvironmentVariable(
startInfo.EnvironmentVariables,
"NODE_REPL_EXTERNAL_MODULE",
"ORCA_NODE_REPL_EXTERNAL_MODULE"
// Why: ProcessStartInfo's env copy rejects duplicate PATH/Path keys; mutating this
// short-lived process preserves the native block for child inheritance (#12046).
MoveEnvironmentVariable("NODE_OPTIONS", "ORCA_NODE_OPTIONS");
MoveEnvironmentVariable("NODE_REPL_EXTERNAL_MODULE", "ORCA_NODE_REPL_EXTERNAL_MODULE");
Environment.SetEnvironmentVariable("ELECTRON_RUN_AS_NODE", "1");
Environment.SetEnvironmentVariable("ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER", "1");
string requestedCliCommand = Environment.GetEnvironmentVariable("ORCA_CLI_COMMAND");
Environment.SetEnvironmentVariable(
"ORCA_CLI_COMMAND",
requestedCliCommand == "orca-ide" ? "orca-ide" : "orca"
);
startInfo.EnvironmentVariables["ELECTRON_RUN_AS_NODE"] = "1";
startInfo.EnvironmentVariables["ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER"] = "1";
startInfo.EnvironmentVariables["ORCA_CLI_COMMAND"] =
Environment.GetEnvironmentVariable("ORCA_CLI_COMMAND") == "orca-ide"
? "orca-ide"
: "orca";
using (Process child = Process.Start(startInfo))
{
@@ -69,19 +67,12 @@ internal static class OrcaCliLauncher
}
}
private static void MoveEnvironmentVariable(
StringDictionary environment,
string sourceName,
string targetName
)
private static void MoveEnvironmentVariable(string sourceName, string targetName)
{
string value = Environment.GetEnvironmentVariable(sourceName);
environment.Remove(sourceName);
environment.Remove(targetName);
if (value != null)
{
environment[targetName] = value;
}
Environment.SetEnvironmentVariable(sourceName, null);
// Why: a null value clears the target, matching the previous unconditional Remove.
Environment.SetEnvironmentVariable(targetName, value);
}
private static string BuildArguments(string cliPath, string[] args)
+1 -1
View File
@@ -209,7 +209,7 @@
"lowlight": "^3.3.0",
"lucide-react": "^0.577.0",
"marked": "^17.0.1",
"mermaid": "^11.15.0",
"mermaid": "^11.16.0",
"monaco-editor": "^0.55.1",
"oxfmt": "^0.52.0",
"oxlint": "^1.75.0",
+12 -12
View File
@@ -13,7 +13,7 @@ patchedDependencies:
hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
path: config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch
'@xterm/addon-serialize@0.15.0-beta.287':
hash: 81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9
hash: 96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd
path: config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch
'@xterm/addon-webgl@0.20.0-beta.286':
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
@@ -46,7 +46,7 @@ importers:
version: 2.5.6
'@xterm/addon-serialize':
specifier: 0.15.0-beta.287
version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
version: 0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
'@xterm/headless':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287
@@ -283,8 +283,8 @@ importers:
specifier: ^17.0.1
version: 17.0.6
mermaid:
specifier: ^11.15.0
version: 11.15.0
specifier: ^11.16.0
version: 11.16.0
monaco-editor:
specifier: ^0.55.1
version: 0.55.1
@@ -1041,8 +1041,8 @@ packages:
resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==}
engines: {node: '>= 10.0.0'}
'@mermaid-js/parser@1.1.1':
resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==}
'@mermaid-js/parser@1.2.0':
resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==}
'@modelcontextprotocol/sdk@1.29.0':
resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
@@ -5211,8 +5211,8 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
mermaid@11.15.0:
resolution: {integrity: sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==}
mermaid@11.16.0:
resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==}
micromark-core-commonmark@2.0.3:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
@@ -7511,7 +7511,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@mermaid-js/parser@1.1.1':
'@mermaid-js/parser@1.2.0':
dependencies:
'@chevrotain/types': 11.1.2
@@ -9596,7 +9596,7 @@ snapshots:
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
@@ -11647,11 +11647,11 @@ snapshots:
merge2@1.4.1: {}
mermaid@11.15.0:
mermaid@11.16.0:
dependencies:
'@braintree/sanitize-url': 7.1.2
'@iconify/utils': 3.1.1
'@mermaid-js/parser': 1.1.1
'@mermaid-js/parser': 1.2.0
'@types/d3': 7.4.3
'@upsetjs/venn.js': 2.0.0
cytoscape: 3.33.3
+12 -3
View File
@@ -224,10 +224,18 @@ Wait until every expected Dispatch settles, not for a fixed number of batches:
```bash
orca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json
# Process every message in the returned Delivery, then atomically ack and continue:
# Process every message. For each accepted worker_done that is not immediately reused:
orca orchestration worker-release --dispatch <dispatch_id> --json
# Acknowledge only after every message and required release decision is handled:
orca orchestration check --ack <delivery_id> --wait --types worker_done,escalation,question --timeout-ms 900000 --json
```
After processing each accepted `worker_done`, choose the terminal's next owner before you acknowledge the Delivery or wait again. If the same exact agent has an immediate follow-up Task, read the `worker.agent_terminal_handle` field of `worker-show --dispatch <dispatch_id> --json`, then run `orca orchestration worker-start --task <next_task_id> --terminal <handle> --json` so Orca transfers cleanup ownership to the new Dispatch. Otherwise run `orca orchestration worker-release --dispatch <dispatch_id> --json`.
Run `worker-release` after both succeeded and failed `worker_done` reports unless the user explicitly asked to keep that worker live. Release is post-completion cleanup, not cancellation: Orca first preserves inspectable output, then closes only the exact agent terminal owned by that settled Dispatch. Reused or pre-existing terminals, setup terminals, coordinators, active workers, user-taken-over terminals, and identities Orca cannot prove are retained. If the user explicitly asks to keep the live terminal for debugging, record that exception with `orca orchestration worker-retain --dispatch <dispatch_id> --json` instead of silently skipping cleanup. When the user is finished, the same Dispatch can be passed to `worker-release`, which clears the requested retention and releases the terminal.
Do not release a worker because of a timeout, TUI idle state, heartbeat, status, question, escalation, or rejected/stale `worker_done`. If release returns `release_pending` or `release_unknown`, do not substitute `terminal close`; follow the exact recovery action in the receipt. A replayed Delivery may repeat `worker-release` safely.
Workers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:
```bash
@@ -364,11 +372,12 @@ Wait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding
- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:
`orca orchestration send --type worker_done --subject "<short status>" --body "<3-sentence summary: what you did, what you found, what's left>" --task-id <task_id> --dispatch-id <dispatch_id> --outcome succeeded --files-modified "path/a" --report-path "<optional>" --json`
- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.
- After sending `worker_done`, end your turn and idle at the agent prompt. Do not poll or keep calling `orca orchestration check`; the coordinator re-engages you with a fresh preamble + TASK block delivered as new terminal input.
- After sending `worker_done`, end your turn and idle at the agent prompt. The coordinator may reuse or release this terminal after it processes your report; do not start more work, poll, or attempt to close the terminal yourself. If it reuses you, it re-engages you with a fresh preamble + TASK block delivered as new terminal input.
- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:
`orca orchestration send --type heartbeat --subject "alive" --payload '{"taskId":"<task_id>","dispatchId":"<dispatch_id>","phase":"implementing"}' --json`
- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.
- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.
- Coordinators must account for every settled worker terminal before waiting again or ending the turn: immediately reuse the exact worker for a new Dispatch, explicitly retain it at the user's request with `worker-retain`, or run `worker-release`. Do not leave a completed worker live merely to inspect output; released workers remain readable through `worker-read`.
- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.
## Example
@@ -383,6 +392,6 @@ orca orchestration check --wait --types worker_done,escalation,question --timeou
## Next Action
Coordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology.
Coordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. After every accepted `worker_done`, either transfer the exact terminal to an immediate follow-up Dispatch or run `worker-release` before the next wait.
Worker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.
File diff suppressed because one or more lines are too long
+3
View File
@@ -109,6 +109,9 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
'orchestration worker-read',
'orchestration worker-stop',
'orchestration worker-abandon',
'orchestration worker-release',
'orchestration worker-retain',
'orchestration worker-list',
'orchestration dispatch',
'orchestration ask',
'orchestration dispatch-show',
+110
View File
@@ -79,6 +79,16 @@ const TASK_STATUS_VALUES = [
'blocked'
] as const
// Why: mirrors WorkerTerminalListState (orchestration/types.ts) so a bad --terminal-state fails before the RPC.
const WORKER_TERMINAL_LIST_STATES = [
'active',
'reclaimable',
'retained',
'release_pending',
'release_unknown',
'released'
] as const
type LifecycleSendRejection = {
action: 'rejected'
code: string
@@ -422,6 +432,33 @@ function formatWorkerTranscriptMessage(message: NativeChatMessage): string {
return `[${message.role}] ${blocks.join('\n')}`.trimEnd()
}
type WorkerReleaseReceipt = {
dispatchId: string
state: string
reason?: string
processAction: string
archive: { source: string | null; status: string | null } | null
recovery?: string
lastError?: string
}
function formatWorkerRelease(value: WorkerReleaseReceipt): string {
const head = `Worker ${value.dispatchId} terminal [${value.state}]`
const lines = [
`${head}${value.reason ? ` reason=${value.reason}` : ''} process=${value.processAction}`
]
if (value.archive) {
lines.push(`archive ${value.archive.source ?? 'none'} [${value.archive.status ?? 'unknown'}]`)
}
if (value.lastError) {
lines.push(value.lastError)
}
if (value.recovery) {
lines.push(value.recovery)
}
return lines.join('\n')
}
function safeJson(value: unknown): string {
try {
return JSON.stringify(value)
@@ -930,6 +967,79 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
)
},
'orchestration worker-release': async ({ flags, client, json }) => {
const result = await callMutation<WorkerReleaseReceipt>(
client,
flags,
'orchestration.workerRelease',
{ dispatch: getRequiredStringFlag(flags, 'dispatch') }
)
// Why: only an unprovable close is a failure; retained/pending/already-released are settled answers.
if (result.result.state === 'release_unknown') {
process.exitCode = 1
}
printResult(result, json, formatWorkerRelease)
},
'orchestration worker-retain': async ({ flags, client, json }) => {
const result = await callMutation<WorkerReleaseReceipt>(
client,
flags,
'orchestration.workerRetain',
{ dispatch: getRequiredStringFlag(flags, 'dispatch') }
)
if (result.result.state === 'release_unknown') {
process.exitCode = 1
}
printResult(result, json, formatWorkerRelease)
},
'orchestration worker-list': async ({ flags, client, json }) => {
const terminalState = getOptionalStringFlag(flags, 'terminal-state')
if (
terminalState &&
!WORKER_TERMINAL_LIST_STATES.includes(
terminalState as (typeof WORKER_TERMINAL_LIST_STATES)[number]
)
) {
throw new RuntimeClientError(
'invalid_argument',
`invalid --terminal-state '${terminalState}', expected one of: ${WORKER_TERMINAL_LIST_STATES.join(', ')}`
)
}
const result = await client.call<{
workers: {
dispatchId: string
taskId: string
runId: string
workerState: string
dispatchStatus: string
agentTerminalHandle: string | null
terminalState: string | null
resource: unknown
}[]
counts: Record<string, number>
}>('orchestration.workerList', {
run: getOptionalStringFlag(flags, 'run'),
terminalState
})
printResult(result, json, (r) => {
if (r.workers.length === 0) {
return 'No workers found.'
}
const rows = r.workers
.map(
(w) =>
`${w.dispatchId} task=${w.taskId} [${w.workerState}] terminal=${w.terminalState ?? 'none'}`
)
.join('\n')
const counts = Object.entries(r.counts)
.map(([state, count]) => `${state}=${count}`)
.join(' ')
return counts ? `${rows}\nTerminals: ${counts}` : rows
})
},
'orchestration dispatch': async ({ flags, client, cwd, json }) => {
const from = await resolveCoordinatorTerminalHandle(flags, cwd, client)
const dryRun = flags.has('dry-run') ? true : undefined
+6
View File
@@ -111,6 +111,9 @@ Orchestration:
orchestration worker-read Read bounded output from one supervised worker
orchestration worker-stop Stop one supervised worker
orchestration worker-abandon Fence an uncertain worker without claiming it stopped
orchestration worker-release Release a settled worker's terminal after archiving its output
orchestration worker-retain Keep a worker terminal live for debugging
orchestration worker-list Report worker terminal resource accounting
orchestration coordinator-start Start the legacy automatic coordinator loop
orchestration coordinator-stop Stop the legacy automatic coordinator loop
orchestration gate-create Create a decision gate blocking a task
@@ -444,6 +447,9 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
if (command === 'orchestration worker-read' && flag === 'cursor') {
return '--cursor <cursor> Opaque cursor returned by a previous worker-read page'
}
if (command === 'orchestration worker-list' && flag === 'terminal-state') {
return '--terminal-state <state> Terminal accounting filter: active, reclaimable, retained, release_pending, release_unknown, or released'
}
if (command === 'linear list-issues' && flag === 'workspace') {
return '--workspace <id|all> Connected Linear workspace id, or all'
}
+9
View File
@@ -362,6 +362,15 @@ describe('orca root help', () => {
expect(logSpy.mock.calls[0][0]).toContain(
'orchestration worker-abandon Fence an uncertain worker without claiming it stopped'
)
expect(logSpy.mock.calls[0][0]).toContain(
"orchestration worker-release Release a settled worker's terminal after archiving its output"
)
expect(logSpy.mock.calls[0][0]).toContain(
'orchestration worker-retain Keep a worker terminal live for debugging'
)
expect(logSpy.mock.calls[0][0]).toContain(
'orchestration worker-list Report worker terminal resource accounting'
)
expect(callMock).not.toHaveBeenCalled()
})
@@ -67,5 +67,39 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
'orca orchestration worker-abandon --dispatch <dispatch_id> [--retry-request <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'dispatch', 'retry-request'],
notes: ['Retains all possibly-live resources and performs no process or filesystem action.']
},
{
path: ['orchestration', 'worker-release'],
summary: 'Release the terminal of one settled supervised worker',
usage:
'orca orchestration worker-release --dispatch <dispatch_id> [--retry-request <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'dispatch', 'retry-request'],
notes: [
'Post-completion cleanup for a settled (succeeded or failed) worker; closes only the exact coordinator-owned agent terminal of that worker.',
'An inspectable output archive is preserved before the terminal closes, so worker-read still returns output afterwards.',
'Never closes setup terminals, configured tabs, reused or pre-existing terminals, user-taken-over terminals, or unproven identities.',
'Idempotent: repeating the call reports already_released. Only release_unknown exits 1; retained, release_pending, and already_released exit 0.'
]
},
{
path: ['orchestration', 'worker-retain'],
summary: 'Keep one supervised worker terminal live for debugging',
usage:
'orca orchestration worker-retain --dispatch <dispatch_id> [--retry-request <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'dispatch', 'retry-request'],
notes: [
'Records a durable user-requested exception; a later explicit worker-release clears it and releases the terminal.',
'Performs no process or filesystem action.'
]
},
{
path: ['orchestration', 'worker-list'],
summary: 'List supervised worker terminal resource accounting',
usage:
'orca orchestration worker-list [--run <run_id>] [--terminal-state <active|reclaimable|retained|release_pending|release_unknown|released>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'run', 'terminal-state'],
notes: [
'Terminal state is process accounting and is reported separately from Task status; a completed Task can still own a live terminal.'
]
}
]
@@ -119,6 +119,7 @@ describe('restored subagent liveness sweep', () => {
try {
expect(paneStatus(server)).toEqual({ state: 'working', subagents: [WORKING_CHILD] })
const previous = server.getStatusSnapshotForPane(PANE)[0]
expect(previous?.restoredUnconfirmed).toBe(true)
const previousReceivedAt = previous?.receivedAt ?? 0
const reconciledAt = previousReceivedAt + 1
const now = vi.spyOn(Date, 'now').mockReturnValue(previousReceivedAt)
@@ -130,6 +131,8 @@ describe('restored subagent liveness sweep', () => {
receivedAt: reconciledAt,
stateStartedAt: reconciledAt
})
// Why: the reconciled `done` is process-probe-verified, so it must shed the hydrated-unconfirmed marker.
expect(server.getStatusSnapshotForPane(PANE)[0]?.restoredUnconfirmed).toBeUndefined()
now.mockRestore()
} finally {
server.stop()
@@ -163,7 +166,9 @@ describe('restored subagent liveness sweep', () => {
state: 'waiting',
subagents: undefined,
receivedAt: previous?.receivedAt,
stateStartedAt: previous?.stateStartedAt
stateStartedAt: previous?.stateStartedAt,
// Why: a still-nonterminal reconciled row remains unconfirmed — only hooks or a terminal fact confirm it.
restoredUnconfirmed: true
})
} finally {
server.stop()
+288
View File
@@ -7026,6 +7026,294 @@ describe('Last-status persistence', () => {
}
})
it('restores hydrated nonterminal statuses as unconfirmed until a live event lands', async () => {
const firstServer = new AgentHookServer()
await firstServer.start({ env: 'production', userDataPath })
firstServer.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'may finish offline', agentType: 'claude' }
},
'conn-1'
)
firstServer.ingestRemote(
{
paneKey: GOOD_PANE,
tabId: 'tab-good',
worktreeId: 'wt-1',
payload: { state: 'done', prompt: 'finished before restart', agentType: 'claude' }
},
'conn-1'
)
firstServer.flushStatusPersistSync()
firstServer.stop()
const server = new AgentHookServer()
await server.start({ env: 'production', userDataPath })
try {
const listener = vi.fn()
server.setListener(listener)
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({ paneKey: PANE, restoredUnconfirmed: true })
)
const byPane = new Map(server.getStatusSnapshot().map((entry) => [entry.paneKey, entry]))
expect(byPane.get(PANE)).toMatchObject({ state: 'working', restoredUnconfirmed: true })
// Why: `done` is a terminal fact — no later transition can have been missed, so it restores confirmed.
expect(byPane.get(GOOD_PANE)?.restoredUnconfirmed).toBeUndefined()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'done', prompt: 'confirmed live', agentType: 'codex' }
},
'conn-1'
)
const confirmed = server.getStatusSnapshot().find((entry) => entry.paneKey === PANE)
expect(confirmed).toMatchObject({
state: 'done',
prompt: 'confirmed live',
agentType: 'codex'
})
expect(confirmed?.restoredUnconfirmed).toBeUndefined()
} finally {
server.stop()
}
})
it('lets an identical live OSC observation confirm a hydrated row', async () => {
const payload = {
state: 'working' as const,
prompt: 'still running',
agentType: 'claude' as const
}
const firstServer = new AgentHookServer()
await firstServer.start({ env: 'production', userDataPath })
firstServer.ingestTerminalStatus({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
connectionId: null,
payload
})
firstServer.flushStatusPersistSync()
firstServer.stop()
const server = new AgentHookServer()
await server.start({ env: 'production', userDataPath })
try {
const restored = server.getStatusSnapshot()[0]
if (!restored) {
throw new Error('expected hydrated status')
}
expect(restored?.restoredUnconfirmed).toBe(true)
vi.spyOn(Date, 'now').mockReturnValue(restored.receivedAt - 1_000)
server.ingestTerminalStatus({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
connectionId: null,
payload
})
const confirmed = server.getStatusSnapshot()[0]
expect(confirmed?.restoredUnconfirmed).toBeUndefined()
expect(confirmed?.receivedAt).toBe(restored.receivedAt + 1)
} finally {
server.stop()
}
})
it('does not let a hydrated Claude permission suppress the first live working event', async () => {
const firstServer = new AgentHookServer()
await firstServer.start({ env: 'production', userDataPath })
firstServer.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hookEventName: 'PermissionRequest',
payload: {
state: 'waiting',
prompt: 'review command',
agentType: 'claude',
toolName: 'Bash',
toolInput: 'dangerous command'
}
},
'conn-1'
)
firstServer.flushStatusPersistSync()
firstServer.stop()
const server = new AgentHookServer()
await server.start({ env: 'production', userDataPath })
try {
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hookEventName: 'PreToolUse',
payload: {
state: 'working',
prompt: 'review command',
agentType: 'claude',
toolName: 'Read',
toolInput: 'package.json'
}
},
'conn-1'
)
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
toolName: 'Read'
})
expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined()
} finally {
server.stop()
}
})
it('does not inherit a stale tool id from hydrated Claude progress', async () => {
const firstServer = new AgentHookServer()
await firstServer.start({ env: 'production', userDataPath })
firstServer.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hookEventName: 'PreToolUse',
toolUseId: 'tool-old',
toolAgentType: 'main',
payload: {
state: 'working',
prompt: 'run tests',
agentType: 'claude',
toolName: 'Bash',
toolInput: 'pnpm test'
}
},
'conn-1'
)
firstServer.flushStatusPersistSync()
firstServer.stop()
const server = new AgentHookServer()
await server.start({ env: 'production', userDataPath })
try {
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hookEventName: 'PermissionRequest',
toolAgentType: 'main',
payload: {
state: 'waiting',
prompt: 'run tests',
agentType: 'claude',
toolName: 'Bash',
toolInput: 'pnpm test'
}
},
'conn-1'
)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hookEventName: 'PostToolUse',
toolUseId: 'tool-new',
toolAgentType: 'main',
payload: {
state: 'working',
prompt: 'run tests',
agentType: 'claude',
toolName: 'Bash',
toolInput: 'pnpm test'
}
},
'conn-1'
)
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
} finally {
server.stop()
}
})
it('never persists the unconfirmed flag and re-stamps it on every hydrate', async () => {
const firstServer = new AgentHookServer()
await firstServer.start({ env: 'production', userDataPath })
firstServer.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'still running', agentType: 'claude' }
},
'conn-1'
)
firstServer.flushStatusPersistSync()
firstServer.stop()
const secondServer = new AgentHookServer()
await secondServer.start({ env: 'production', userDataPath })
secondServer.flushStatusPersistSync()
secondServer.stop()
expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain('restoredUnconfirmed')
const thirdServer = new AgentHookServer()
await thirdServer.start({ env: 'production', userDataPath })
try {
expect(thirdServer.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: PANE, state: 'working', restoredUnconfirmed: true })
])
} finally {
thirdServer.stop()
}
})
it('refuses interrupt inference on an unconfirmed hydrated row', async () => {
const firstServer = new AgentHookServer()
await firstServer.start({ env: 'production', userDataPath })
firstServer.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'long task', agentType: 'codex' }
},
'conn-1'
)
firstServer.flushStatusPersistSync()
firstServer.stop()
const server = new AgentHookServer()
await server.start({ env: 'production', userDataPath })
try {
const baseline = server.getStatusSnapshot()[0]
expect(baseline).toMatchObject({ paneKey: PANE, restoredUnconfirmed: true })
const applied = server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'long task',
baselineAgentType: 'codex',
intent: 'plain-escape'
})
// Why: synthesizing `done` onto a never-confirmed `working` would fabricate a transition from stale disk state.
expect(applied).toBe(false)
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
expect(server.getStatusSnapshot()[0]?.interrupted).toBeUndefined()
} finally {
server.stop()
}
})
it('keeps SSH status ordering monotonic across hydration and clock rollback', async () => {
const now = 1_700_000_000_000
vi.spyOn(Date, 'now').mockReturnValue(now)
+37 -5
View File
@@ -83,6 +83,8 @@ export type { AgentHookSource }
type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
receivedAt: number
stateStartedAt: number
/** Stamped at hydrate for nonterminal states; never persisted (hydrate re-stamps) and cleared by any accepted live event replacing the entry. */
restoredUnconfirmed?: true
}
type NormalizedLocalHook = {
@@ -92,7 +94,7 @@ type NormalizedLocalHook = {
type PersistedAgentHookEventPayload = Omit<
EnrichedAgentHookEventPayload,
'claudeRunningNonAgentTask' | 'launchToken' | 'promptInteractionKey'
'claudeRunningNonAgentTask' | 'launchToken' | 'promptInteractionKey' | 'restoredUnconfirmed'
> & {
launchTokenHash?: string
}
@@ -378,6 +380,7 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta
...(entry.providerSession ? { providerSession: entry.providerSession } : {}),
...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}),
...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}),
...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
...entry.payload
}
}
@@ -434,6 +437,9 @@ function shouldKeepClaudePermissionVisible(
previous: EnrichedAgentHookEventPayload | undefined,
next: AgentHookEventPayload
): boolean {
if (previous?.restoredUnconfirmed) {
return false
}
if (
previous?.payload.agentType !== 'claude' ||
previous.payload.state !== 'waiting' ||
@@ -507,6 +513,7 @@ function shouldInheritClaudeToolUseIdForPermission(
next: AgentHookEventPayload
): boolean {
if (
previous?.restoredUnconfirmed ||
previous?.payload.agentType !== 'claude' ||
previous.payload.state !== 'working' ||
previous.hookEventName !== 'PreToolUse' ||
@@ -728,6 +735,10 @@ export class AgentHookServer {
if (existing.providerSessionOnly) {
return false
}
// Why: inference must not fabricate a `done` onto a row whose `working` was never confirmed this runtime.
if (existing.restoredUnconfirmed) {
return false
}
const payload = existing.payload
const agentType: AgentType | undefined = payload.agentType
// Why: Droid's Ctrl+C exits the CLI (handled by PTY lifecycle) rather than interrupting the current turn.
@@ -816,6 +827,10 @@ export class AgentHookServer {
if (!existing) {
return false
}
// Why: inference must not fabricate a transition onto a row whose state was never confirmed this runtime.
if (existing.restoredUnconfirmed) {
return false
}
const payload = existing.payload
// Why: only Claude's interactive question clears on typed input — tool name (not hook event) discriminates; real permission waits stay sticky.
if (
@@ -1069,8 +1084,13 @@ export class AgentHookServer {
const connectionClearWatermark = payload.connectionId
? this.connectionTimestampWatermarkById.get(payload.connectionId)
: undefined
// Why: Date.now() can repeat across reconnect; a remote replay must sort strictly after its connection's transient clear.
const now = Math.max(Date.now(), (connectionClearWatermark ?? -1) + 1)
// Why: renderer ordering rejects older rows; live evidence must sort after reconnect clears and restored rows across clock rollback.
const restoredStatusWatermark = previous?.restoredUnconfirmed ? previous.receivedAt : undefined
const now = Math.max(
Date.now(),
(connectionClearWatermark ?? -1) + 1,
(restoredStatusWatermark ?? -1) + 1
)
if (payload.connectionId) {
this.connectionTimestampWatermarkById.set(payload.connectionId, now)
}
@@ -1128,7 +1148,8 @@ export class AgentHookServer {
? {
agentType: previous.payload.agentType,
state: previous.payload.state,
updatedAt: previous.receivedAt
updatedAt: previous.receivedAt,
restoredUnconfirmed: previous.restoredUnconfirmed
}
: undefined,
incoming: rootContextPreservingPayload.payload.agentType,
@@ -1740,6 +1761,7 @@ export class AgentHookServer {
| EnrichedAgentHookEventPayload
| undefined
if (
!previous?.restoredUnconfirmed &&
previous?.connectionId === connectionId &&
previous.tabId === tabId &&
previous.worktreeId === worktreeId &&
@@ -2371,8 +2393,12 @@ export class AgentHookServer {
const reconciledAt = stateChanged
? Math.max(Date.now(), enriched.receivedAt + 1)
: enriched.receivedAt
// Why: a reconciled `done` is process-probe-verified, not hydrated guesswork — carrying
// restoredUnconfirmed onto it would make freshness gates suppress a legitimate completion.
const { restoredUnconfirmed, ...reconciledBase } = enriched
const reconciled: EnrichedAgentHookEventPayload = {
...enriched,
...reconciledBase,
...(state !== 'done' && restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
receivedAt: reconciledAt,
stateStartedAt: stateChanged ? reconciledAt : enriched.stateStartedAt,
payload: { ...enriched.payload, state, subagents }
@@ -2505,6 +2531,10 @@ export class AgentHookServer {
(entry.payload.subagents?.length ?? 0) - (hydratedPayload.subagents?.length ?? 0)
entry.payload = hydratedPayload
}
if (entry.payload.state !== 'done') {
// Why: the terminal transition may have fired while no receiver was up; restore as unconfirmed, never as live truth.
entry.restoredUnconfirmed = true
}
this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry)
if (entry.connectionId) {
// Why: a restart can see an earlier wall clock; seed ordering so new events stay after disk state.
@@ -2621,6 +2651,8 @@ export class AgentHookServer {
const {
claudeRunningNonAgentTask: _claudeRunningNonAgentTask,
promptInteractionKey: _promptInteractionKey,
// Why: never persisted — hydrate re-stamps it, so a stored copy could only drift.
restoredUnconfirmed: _restoredUnconfirmed,
launchToken,
...persistedPayload
} = payload as EnrichedAgentHookEventPayload
@@ -21,7 +21,11 @@ function fakeChild(): FakeChild {
resume: ReturnType<typeof vi.fn>
}
stderr: EventEmitter
stdin: EventEmitter & { write: ReturnType<typeof vi.fn> }
stdin: EventEmitter & {
write: ReturnType<typeof vi.fn>
destroyed: boolean
writable: boolean
}
kill: ReturnType<typeof vi.fn>
}
child.stdout = Object.assign(new EventEmitter(), {
@@ -29,7 +33,18 @@ function fakeChild(): FakeChild {
resume: vi.fn()
})
child.stderr = new EventEmitter()
child.stdin = Object.assign(new EventEmitter(), { write: vi.fn(() => true) })
const stdin = new EventEmitter() as EventEmitter & {
write: ReturnType<typeof vi.fn>
destroyed: boolean
writable: boolean
}
stdin.write = vi.fn((_data, onWritten?: (error?: Error | null) => void) => {
onWritten?.(null)
return true
})
stdin.destroyed = false
stdin.writable = true
child.stdin = stdin
child.kill = vi.fn()
return child as unknown as FakeChild
}
@@ -166,7 +181,7 @@ describe('waitForWslRelaySentinel', () => {
const drain = vi.fn()
const writeMock = child.stdin.write as ReturnType<typeof vi.fn>
writeMock.mockImplementation((_data, onWritten) => {
onWritten(null)
onWritten?.(null)
return false
})
const promise = waitForWslRelaySentinel(child)
@@ -180,4 +195,16 @@ describe('waitForWslRelaySentinel', () => {
expect(drain).toHaveBeenCalledOnce()
expect(transport.supportsWriteSettlement).toBe(true)
})
it('swallows async stdin EPIPE after the guest dies so it is not uncaught', async () => {
const child = fakeChild()
const promise = waitForWslRelaySentinel(child)
emitStdout(child, RELAY_SENTINEL)
await promise
// Real pipes emit EPIPE on the stream after a failed write; without a
// listener Node surfaces that as an unhandled exception (CI flake).
expect(() => {
child.stdin.emit('error', Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))
}).not.toThrow()
})
})
@@ -88,6 +88,12 @@ export function waitForWslRelaySentinel(
child.on('error', (err) =>
fail({ kind: 'exit', code: null, stderr: `${stderrOutput}\n${err.message}` })
)
// Why: stdin.write() surfaces EPIPE asynchronously when the guest dies
// mid-flight (try/catch only covers the sync throw). Without a listener
// Node treats that as an uncaught exception and fails the whole process.
child.stdin.on('error', () => {
// Channel already closing — mux close handling takes over.
})
child.on('exit', (code) => {
exitCode = code
})
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { abandonRemoteSessionScanOnCancel } from './ai-vault-scan-cancellation'
describe('abandonRemoteSessionScanOnCancel', () => {
it('stops waiting on a scan that has no transport-level abort', async () => {
const controller = new AbortController()
const pending = abandonRemoteSessionScanOnCancel(
new Promise<string>(() => {}),
controller.signal
)
controller.abort()
await expect(pending).rejects.toMatchObject({ name: 'AbortError' })
})
it('rejects immediately when the caller already cancelled', async () => {
const controller = new AbortController()
controller.abort()
await expect(
abandonRemoteSessionScanOnCancel(Promise.resolve('scanned'), controller.signal)
).rejects.toMatchObject({ name: 'AbortError' })
})
it('passes the scan result through without a signal', async () => {
await expect(abandonRemoteSessionScanOnCancel(Promise.resolve('scanned'))).resolves.toBe(
'scanned'
)
})
})
@@ -0,0 +1,47 @@
import { AI_VAULT_SCAN_CANCELLED_MESSAGE } from '../../shared/ai-vault-types'
export function throwIfAiVaultScanCancelled(signal?: AbortSignal): void {
if (!signal?.aborted) {
return
}
throw createAiVaultScanCancelledError()
}
// Why: the runtime RPC transport has no abort hook, so cancellation can only
// stop the caller waiting on it — the in-flight request settles on its own
// timeout instead of holding an 'all'-scope merge open after every waiter left.
export function abandonRemoteSessionScanOnCancel<T>(
promise: Promise<T>,
signal?: AbortSignal
): Promise<T> {
if (!signal) {
return promise
}
return new Promise<T>((resolve, reject) => {
if (signal.aborted) {
// The scanner promise already exists; observe it so its later failure is
// not an unhandled rejection after this caller walked away.
void promise.catch(() => undefined)
reject(createAiVaultScanCancelledError())
return
}
const onAbort = (): void => reject(createAiVaultScanCancelledError())
signal.addEventListener('abort', onAbort, { once: true })
void promise.then(
(value) => {
signal.removeEventListener('abort', onAbort)
resolve(value)
},
(error: unknown) => {
signal.removeEventListener('abort', onAbort)
reject(error)
}
)
})
}
export function createAiVaultScanCancelledError(): Error {
const error = new Error(AI_VAULT_SCAN_CANCELLED_MESSAGE)
error.name = 'AbortError'
return error
}
@@ -0,0 +1,148 @@
import { describe, expect, it, vi } from 'vitest'
import { AiVaultScanCoordinator } from './ai-vault-scan-coordinator'
const EMPTY_RESULT = { sessions: [], issues: [], scannedAt: '2026-07-27T00:00:00.000Z' }
describe('AiVaultScanCoordinator', () => {
it('keeps a shared scan alive when only one caller cancels', async () => {
const coordinator = new AiVaultScanCoordinator()
let resolveScan: ((result: typeof EMPTY_RESULT) => void) | undefined
let sharedSignal: AbortSignal | undefined
const start = vi.fn(
(signal: AbortSignal) =>
new Promise<typeof EMPTY_RESULT>((resolve) => {
sharedSignal = signal
resolveScan = resolve
})
)
const controller = new AbortController()
const first = coordinator.run({ key: 'scope', signal: controller.signal, start })
const second = coordinator.run({ key: 'scope', start })
await Promise.resolve()
controller.abort()
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
expect(sharedSignal?.aborted).toBe(false)
resolveScan?.(EMPTY_RESULT)
await expect(second).resolves.toEqual(EMPTY_RESULT)
expect(start).toHaveBeenCalledTimes(1)
})
it('preempts a non-forced scan and re-joins its caller onto the forced scan', async () => {
const coordinator = new AiVaultScanCoordinator()
const signals: AbortSignal[] = []
let resolveForced: ((result: typeof EMPTY_RESULT) => void) | undefined
const start = vi.fn((signal: AbortSignal) => {
signals.push(signal)
return new Promise<typeof EMPTY_RESULT>((resolve) => {
if (signals.length === 1) {
signal.addEventListener('abort', () => resolve(EMPTY_RESULT), { once: true })
} else {
resolveForced = resolve
}
})
})
const first = coordinator.run({ key: 'scope', start })
await Promise.resolve()
const forced = coordinator.run({ key: 'scope', force: true, start })
const joined = coordinator.run({ key: 'scope', force: true, start })
await Promise.resolve()
expect(signals[0]?.aborted).toBe(true)
expect(start).toHaveBeenCalledTimes(2)
resolveForced?.(EMPTY_RESULT)
// The first caller never asked to cancel, so someone else's Refresh must
// hand it the replacement's result instead of a spurious cancellation.
await expect(Promise.all([first, forced, joined])).resolves.toEqual([
EMPTY_RESULT,
EMPTY_RESULT,
EMPTY_RESULT
])
})
it('keeps coalescing forced callers onto a forced scan that is still fresh', async () => {
vi.useFakeTimers()
try {
const coordinator = new AiVaultScanCoordinator()
let resolveScan: ((result: typeof EMPTY_RESULT) => void) | undefined
const start = vi.fn(
() =>
new Promise<typeof EMPTY_RESULT>((resolve) => {
resolveScan = resolve
})
)
const first = coordinator.run({ key: 'scope', force: true, start })
await Promise.resolve()
vi.advanceTimersByTime(4_999)
const second = coordinator.run({ key: 'scope', force: true, start })
await Promise.resolve()
expect(start).toHaveBeenCalledTimes(1)
resolveScan?.(EMPTY_RESULT)
// Both callers settle off the single shared scan instead of preempting it.
await expect(Promise.all([first, second])).resolves.toEqual([EMPTY_RESULT, EMPTY_RESULT])
} finally {
vi.useRealTimers()
}
})
it('lets a forced refresh preempt a forced scan that hung past the coalescing window', async () => {
vi.useFakeTimers()
try {
const coordinator = new AiVaultScanCoordinator()
const signals: AbortSignal[] = []
let resolveSecond: ((result: typeof EMPTY_RESULT) => void) | undefined
const start = vi.fn((signal: AbortSignal) => {
signals.push(signal)
return new Promise<typeof EMPTY_RESULT>((resolve) => {
if (signals.length > 1) {
resolveSecond = resolve
}
})
})
const stuck = coordinator.run({ key: 'scope', force: true, start })
await Promise.resolve()
vi.advanceTimersByTime(5_000)
const retry = coordinator.run({ key: 'scope', force: true, start })
await Promise.resolve()
expect(signals[0]?.aborted).toBe(true)
expect(start).toHaveBeenCalledTimes(2)
resolveSecond?.(EMPTY_RESULT)
// The caller stranded on the hung scan rides the replacement out rather
// than being told its own refresh was cancelled.
await expect(Promise.all([stuck, retry])).resolves.toEqual([EMPTY_RESULT, EMPTY_RESULT])
} finally {
vi.useRealTimers()
}
})
it('starts a fresh scan after every waiter cancels', async () => {
const coordinator = new AiVaultScanCoordinator()
const signals: AbortSignal[] = []
const start = vi.fn((signal: AbortSignal) => {
signals.push(signal)
if (signals.length > 1) {
return Promise.resolve(EMPTY_RESULT)
}
return new Promise<typeof EMPTY_RESULT>((resolve) => {
signal.addEventListener('abort', () => resolve(EMPTY_RESULT), { once: true })
})
})
const controller = new AbortController()
const first = coordinator.run({ key: 'scope', signal: controller.signal, start })
await Promise.resolve()
controller.abort()
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
const second = coordinator.run({ key: 'scope', start })
await expect(second).resolves.toEqual(EMPTY_RESULT)
expect(start).toHaveBeenCalledTimes(2)
expect(signals[0]?.aborted).toBe(true)
})
})
@@ -0,0 +1,155 @@
import {
AI_VAULT_SCAN_CANCELLED_MESSAGE,
type AiVaultListResult
} from '../../shared/ai-vault-types'
// Matches the renderer's forced-rescan throttle: forced callers that arrive
// inside this window share one scan, later ones may preempt a scan that hung.
const FORCED_SCAN_PREEMPT_AFTER_MS = 5_000
type ScanEntry = {
controller: AbortController
force: boolean
startedAt: number
promise: Promise<AiVaultListResult>
waiterCount: number
// The last waiter leaving must not abort a scan that already finished.
settled: boolean
// Set when a forced caller replaced this entry, so its waiters re-join the
// replacement instead of surfacing someone else's refresh as their own cancel.
preemptedBy: ScanEntry | null
}
export class AiVaultScanCoordinator {
private readonly entries = new Map<string, ScanEntry>()
run(args: {
key: string
force?: boolean
signal?: AbortSignal
start: (signal: AbortSignal) => Promise<AiVaultListResult>
}): Promise<AiVaultListResult> {
if (args.signal?.aborted) {
return Promise.reject(scanCancellationError())
}
let entry = this.entries.get(args.key)
const preempted = entry && args.force === true && canPreemptForForcedScan(entry) ? entry : null
if (preempted) {
this.removeEntry(args.key, preempted)
entry = undefined
}
if (!entry) {
entry = this.createEntry(args.key, args.force === true, args.start)
this.entries.set(args.key, entry)
}
if (preempted) {
// The replacement must be registered before the abort lands: waiters of
// the old scan re-join it synchronously from their abort listener.
preempted.preemptedBy = entry
preempted.controller.abort()
}
return this.attach(args.key, entry, args.signal)
}
private createEntry(
key: string,
force: boolean,
start: (signal: AbortSignal) => Promise<AiVaultListResult>
): ScanEntry {
const controller = new AbortController()
const entry: ScanEntry = {
controller,
force,
startedAt: Date.now(),
promise: Promise.resolve().then(() => {
if (controller.signal.aborted) {
throw scanCancellationError()
}
return start(controller.signal)
}),
waiterCount: 0,
settled: false,
preemptedBy: null
}
const onSettled = (): void => {
entry.settled = true
this.removeEntry(key, entry)
}
void entry.promise.then(onSettled, onSettled)
return entry
}
private attach(key: string, entry: ScanEntry, signal?: AbortSignal): Promise<AiVaultListResult> {
entry.waiterCount++
return new Promise((resolve, reject) => {
let attached = true
const detach = (): void => {
if (!attached) {
return
}
attached = false
signal?.removeEventListener('abort', onAbort)
entry.controller.signal.removeEventListener('abort', onAbort)
entry.waiterCount--
if (entry.waiterCount === 0 && !entry.settled && !entry.controller.signal.aborted) {
this.removeEntry(key, entry)
entry.controller.abort()
}
}
const onAbort = (): void => {
if (!attached) {
return
}
// Why: a forced refresh aborts the shared entry, but the other waiters
// never asked to cancel — rejecting them turns one window's Refresh into
// a cancelled multi-host merge somewhere else. Re-join the replacement.
const replacement = signal?.aborted ? null : entry.preemptedBy
detach()
if (!replacement) {
reject(scanCancellationError())
return
}
void this.attach(key, replacement, signal).then(resolve, reject)
}
signal?.addEventListener('abort', onAbort, { once: true })
entry.controller.signal.addEventListener('abort', onAbort, { once: true })
if (signal?.aborted || entry.controller.signal.aborted) {
onAbort()
return
}
void entry.promise.then(
(result) => {
if (attached) {
detach()
resolve(result)
}
},
(error) => {
if (attached) {
detach()
reject(error)
}
}
)
})
}
private removeEntry(key: string, entry: ScanEntry): void {
if (this.entries.get(key) === entry) {
this.entries.delete(key)
}
}
}
// Why: without this a forced scan that never settles (no inactivity timer on the
// legacy SSH file-stream reader, see #11364) makes every later Refresh join the
// dead promise, so the panel stays empty until the app restarts.
function canPreemptForForcedScan(entry: ScanEntry): boolean {
return !entry.force || Date.now() - entry.startedAt >= FORCED_SCAN_PREEMPT_AFTER_MS
}
function scanCancellationError(): Error {
const error = new Error(AI_VAULT_SCAN_CANCELLED_MESSAGE)
error.name = 'AbortError'
return error
}
+62 -44
View File
@@ -3,6 +3,13 @@ import { scanAiVaultSessions } from './session-scanner'
import { getWslHomeAsync, listWslDistrosAsync } from '../wsl'
import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { AiVaultScanCoordinator } from './ai-vault-scan-coordinator'
import {
aiVaultSessionDepthCovers,
requestedAiVaultSessionDepth,
truncateAiVaultListResult,
type AiVaultSessionDepth
} from '../../shared/ai-vault-session-depth'
// Why: ONE module owns the scan cache so the desktop IPC handler AND the runtime
// RPC method share a single cache instance — opening the desktop panel and the
@@ -19,65 +26,77 @@ export type AiVaultSessionSources = {
type CachedAiVaultList = {
key: string
depth: AiVaultSessionDepth
result: AiVaultListResult
expiresAt: number
}
let cachedList: CachedAiVaultList | null = null
let inflightList: Promise<AiVaultListResult> | null = null
let inflightKey: string | null = null
let scanCoordinator = new AiVaultScanCoordinator()
let sources: AiVaultSessionSources = {}
export function configureAiVaultSessionSources(next: AiVaultSessionSources): void {
sources = next
}
export async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListResult> {
export async function listAiVaultSessions(
args?: AiVaultListArgs,
options: { signal?: AbortSignal } = {}
): Promise<AiVaultListResult> {
// Scope paths change the result set, so they must be part of the cache key.
const key = JSON.stringify({
limit: args?.limit ?? 'default',
scopePaths: args?.scopePaths ?? []
})
const key = JSON.stringify({ scopePaths: [...new Set(args?.scopePaths ?? [])].sort() })
const depth = requestedAiVaultSessionDepth(args)
const scanKey = JSON.stringify({ key, depth })
const now = Date.now()
// Why: opening this panel repeatedly should not re-parse hundreds of JSONL
// transcripts; explicit refreshes bypass the cache but not an active scan.
if (args?.force !== true && cachedList?.key === key && cachedList.expiresAt > now) {
return cachedList.result
// transcripts; explicit refreshes bypass the cache and preempt stale scans.
if (
args?.force !== true &&
cachedList?.key === key &&
cachedList.expiresAt > now &&
aiVaultSessionDepthCovers(cachedList.depth, depth)
) {
return truncateAiVaultListResult(cachedList.result, depth, args?.scopePaths)
}
if (inflightList && inflightKey === key) {
return inflightList
}
inflightKey = key
const additionalCodexSessionsDirs =
sources.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? []
inflightList = (async () =>
scanAiVaultSessions({
limit: args?.limit,
scopePaths: args?.scopePaths,
additionalCodexSessionsDirs,
wslHomeDirs: await getAiVaultWslHomeDirs(),
// Why: this scan is always host-local; callers addressing this host by a
// runtime id get the result restamped at the RPC edge, never rescanned.
executionHostId: LOCAL_EXECUTION_HOST_ID
}))()
.then((result) => {
cachedList = {
key,
result,
expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS
return scanCoordinator.run({
key: scanKey,
force: args?.force,
signal: options.signal,
start: async (scanSignal) => {
const additionalCodexSessionsDirs =
sources.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? []
const result = await scanAiVaultSessions({
limit: args?.limit,
unlimited: args?.unlimited,
scopePaths: args?.scopePaths,
additionalCodexSessionsDirs,
wslHomeDirs: await getAiVaultWslHomeDirs(),
// Cancelled/superseded callers must stop the parse, not just stop
// waiting for it — the scan owns hundreds of transcript reads.
signal: scanSignal,
// Why: this scan is always host-local; callers addressing this host by a
// runtime id get the result restamped at the RPC edge, never rescanned.
executionHostId: LOCAL_EXECUTION_HOST_ID
})
if (!scanSignal.aborted) {
const current = cachedList
if (
args?.force === true ||
current?.key !== key ||
current.expiresAt <= Date.now() ||
!aiVaultSessionDepthCovers(current.depth, depth)
) {
cachedList = {
key,
depth,
result,
expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS
}
}
}
return result
})
.finally(() => {
// Only clear tracking if it still refers to this request: a concurrent
// different-key scan may have replaced it and must stay dedupable.
if (inflightKey === key) {
inflightKey = null
inflightList = null
}
})
return inflightList
}
})
}
// Exported for the subagent-transcript IPC path, which validates
@@ -95,7 +114,6 @@ export async function getAiVaultWslHomeDirs(): Promise<string[]> {
// Why: tests reset module-level cache/source state between cases.
export function resetAiVaultSessionListCacheForTests(): void {
cachedList = null
inflightList = null
inflightKey = null
scanCoordinator = new AiVaultScanCoordinator()
sources = {}
}

Some files were not shown because too many files have changed in this diff Show More