mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
Merge main and harden Windows PATH handling
Resolve the PATH expansion overlaps, preserve inherited duplicate blocks, support arbitrary PATH casing, and add a real Windows duplicate-environment launcher test.
This commit is contained in:
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,6 +44,14 @@ const PARCEL_WATCHER_PLATFORM_PREFIX_BY_PLATFORM = {
|
||||
linux: 'watcher-linux',
|
||||
win32: 'watcher-win32'
|
||||
}
|
||||
const ELECTRON_ARCHITECTURE_BY_ENUM = {
|
||||
0: 'ia32',
|
||||
1: 'x64',
|
||||
2: 'arm',
|
||||
3: 'arm64',
|
||||
4: 'universal'
|
||||
}
|
||||
const PACKAGED_NATIVE_ARCHITECTURES = new Set(['ia32', 'x64', 'arm', 'arm64'])
|
||||
const TYPE_DECLARATION_ARTIFACT_RE = /\.d\.(?:c|m)?ts(?:\.map)?$/
|
||||
const VERSIONED_ONNXRUNTIME_DYLIB_RE = /^libonnxruntime\.\d[\d.]*\.dylib$/
|
||||
|
||||
@@ -170,7 +178,7 @@ function collectPackagedRuntimePackages(electronPlatformName = process.platform)
|
||||
// optionalDependency (e.g. @parcel/watcher-linux-x64-glibc) that the
|
||||
// dependencies graph above never reaches. Include the ones installed for the
|
||||
// build's supported architectures; afterPack pruning trims non-target
|
||||
// platforms. Without this the packaged main bundle's import of
|
||||
// platform/architecture variants. Without this the packaged main bundle's import of
|
||||
// '@parcel/watcher' resolves at runtime but throws loading its binary.
|
||||
const parcelWatcherDir = packages.get('@parcel/watcher')
|
||||
if (parcelWatcherDir) {
|
||||
@@ -246,13 +254,44 @@ function verifyPackagedMainRuntimeDeps(resourcesDir, asar = require('@electron/a
|
||||
}
|
||||
|
||||
function normalizeNodePtyWindowsArch(electronArch) {
|
||||
if (electronArch === 'x64' || electronArch === 1) {
|
||||
return 'x64'
|
||||
const architecture = normalizeElectronArchitecture(electronArch)
|
||||
if (architecture !== 'x64' && architecture !== 'arm64') {
|
||||
throw new Error(`Unsupported packaged node-pty Windows architecture: ${architecture}`)
|
||||
}
|
||||
if (electronArch === 'arm64' || electronArch === 3) {
|
||||
return 'arm64'
|
||||
return architecture
|
||||
}
|
||||
|
||||
function normalizeElectronArchitecture(electronArch) {
|
||||
const architecture =
|
||||
typeof electronArch === 'number'
|
||||
? ELECTRON_ARCHITECTURE_BY_ENUM[electronArch]
|
||||
: electronArch === 'armv7l'
|
||||
? 'arm'
|
||||
: electronArch
|
||||
if (!PACKAGED_NATIVE_ARCHITECTURES.has(architecture)) {
|
||||
throw new Error(`Unsupported packaged runtime architecture: ${String(electronArch)}`)
|
||||
}
|
||||
return architecture
|
||||
}
|
||||
|
||||
function pruneNodePtyNativeDirectories(directory, platformPrefix, electronArch, allowsSuffix) {
|
||||
if (!existsSync(directory)) {
|
||||
return
|
||||
}
|
||||
const architecture = normalizeElectronArchitecture(electronArch)
|
||||
const targetPrefix = `${platformPrefix}${architecture}`
|
||||
const platformPrefixes = Object.values(NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM)
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || !platformPrefixes.some((prefix) => entry.name.startsWith(prefix))) {
|
||||
continue
|
||||
}
|
||||
const matchesTarget =
|
||||
entry.name.startsWith(platformPrefix) &&
|
||||
(entry.name === targetPrefix || (allowsSuffix && entry.name.startsWith(`${targetPrefix}-`)))
|
||||
if (!matchesTarget) {
|
||||
rmSync(join(directory, entry.name), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
return process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
}
|
||||
|
||||
function findNodePtyConptySourceDir(nodePtyDir, windowsArch) {
|
||||
@@ -308,14 +347,19 @@ function prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch)
|
||||
|
||||
const allowedPrebuildPrefix = NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM[electronPlatformName]
|
||||
if (allowedPrebuildPrefix) {
|
||||
const prebuildsDir = join(nodePtyDir, 'prebuilds')
|
||||
if (existsSync(prebuildsDir)) {
|
||||
for (const entry of readdirSync(prebuildsDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && !entry.name.startsWith(allowedPrebuildPrefix)) {
|
||||
rmSync(join(prebuildsDir, entry.name), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
pruneNodePtyNativeDirectories(
|
||||
join(nodePtyDir, 'prebuilds'),
|
||||
allowedPrebuildPrefix,
|
||||
electronArch,
|
||||
false
|
||||
)
|
||||
// Why: sequential cross-arch rebuilds accumulate ABI-tagged outputs here.
|
||||
pruneNodePtyNativeDirectories(
|
||||
join(nodePtyDir, 'bin'),
|
||||
allowedPrebuildPrefix,
|
||||
electronArch,
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
if (electronPlatformName === 'win32') {
|
||||
@@ -328,7 +372,7 @@ function prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch)
|
||||
}
|
||||
}
|
||||
|
||||
function prunePackagedParcelWatcher(resourcesDir, electronPlatformName) {
|
||||
function prunePackagedParcelWatcher(resourcesDir, electronPlatformName, electronArch) {
|
||||
const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
|
||||
if (!existsSync(parcelDir)) {
|
||||
return
|
||||
@@ -336,9 +380,11 @@ function prunePackagedParcelWatcher(resourcesDir, electronPlatformName) {
|
||||
|
||||
// Why: we package every installed @parcel/watcher-<platform> optional
|
||||
// subpackage (supportedArchitectures fetches all), but each build only needs
|
||||
// its own platform's binary. Keep the core package and the matching platform
|
||||
// subpackages; drop the rest so a Linux serve doesn't ship macOS/Windows .node.
|
||||
// its own platform/architecture binaries. Keep the core package and matching
|
||||
// native variants; drop the rest.
|
||||
const keepPrefix = PARCEL_WATCHER_PLATFORM_PREFIX_BY_PLATFORM[electronPlatformName]
|
||||
const architecture = normalizeElectronArchitecture(electronArch)
|
||||
const targetPrefix = keepPrefix ? `${keepPrefix}-${architecture}` : null
|
||||
for (const entry of readdirSync(parcelDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || entry.name === 'watcher') {
|
||||
continue
|
||||
@@ -348,7 +394,11 @@ function prunePackagedParcelWatcher(resourcesDir, electronPlatformName) {
|
||||
if (!entry.name.startsWith('watcher-')) {
|
||||
continue
|
||||
}
|
||||
if (keepPrefix && entry.name.startsWith(keepPrefix)) {
|
||||
if (
|
||||
keepPrefix &&
|
||||
entry.name.startsWith(keepPrefix) &&
|
||||
(entry.name === targetPrefix || entry.name.startsWith(`${targetPrefix}-`))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
rmSync(join(parcelDir, entry.name), { recursive: true, force: true })
|
||||
@@ -395,8 +445,9 @@ function prunePackagedZodSources(resourcesDir) {
|
||||
}
|
||||
|
||||
function prunePackagedRuntimeNodeModules(resourcesDir, electronPlatformName, electronArch) {
|
||||
prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch)
|
||||
prunePackagedParcelWatcher(resourcesDir, electronPlatformName)
|
||||
const architecture = normalizeElectronArchitecture(electronArch)
|
||||
prunePackagedNodePty(resourcesDir, electronPlatformName, architecture)
|
||||
prunePackagedParcelWatcher(resourcesDir, electronPlatformName, architecture)
|
||||
prunePackagedRuntimeTypeDeclarations(resourcesDir)
|
||||
prunePackagedSherpaOnnx(resourcesDir, electronPlatformName)
|
||||
prunePackagedZodSources(resourcesDir)
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+351
-31
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-08-01",
|
||||
"updatedAt": "2026-08-03",
|
||||
"policy": {
|
||||
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
|
||||
"blockingPromotion": {
|
||||
@@ -1031,6 +1031,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",
|
||||
@@ -2114,31 +2234,47 @@
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "agent-session",
|
||||
"layer": "renderer-state",
|
||||
"layer": "cross-boundary",
|
||||
"surfaces": [
|
||||
"agent launch",
|
||||
"workspace activation",
|
||||
"sleep and hibernate restore",
|
||||
"provider session dedupe",
|
||||
"sidebar and mobile identity"
|
||||
"sidebar and mobile identity",
|
||||
"runtime-owned background PTY mount and remount"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Local macOS evidence over the ownership/dedupe suite on main@1282f5c2d. Queued/pending resume-claim indexing, same-session and wrong-session hook proofs, and Electron repeat-activation coverage arrive with the pending stack (#7008).",
|
||||
"coveredProviders": ["local", "daemon", "remote-runtime"],
|
||||
"coverageNotes": "Renderer ownership/dedupe contracts cover provider-session claims. Local and daemon attach-only contracts prove an existing stable-pane owner is adopted without provider creation, while remote-runtime transport contracts preserve adopted ownership through cancellation. The Electron oracle covers a local macOS runtime and daemon with real agent, Setup, and unrelated-canary processes; SSH, WSL, paired-server, Linux, and Windows remain contract-only or unrun.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/6800",
|
||||
"https://github.com/stablyai/orca/pull/5240",
|
||||
"https://github.com/stablyai/orca/pull/6411",
|
||||
"https://github.com/stablyai/orca/pull/6833"
|
||||
"https://github.com/stablyai/orca/pull/6833",
|
||||
"https://github.com/stablyai/orca/pull/11789",
|
||||
"https://github.com/stablyai/orca/pull/11819"
|
||||
],
|
||||
"invariant": "Workspace activation, launch, restore, sleep, hibernate, dedupe, clearing, and reconnect code must not replay or resume a provider session id already owned, queued, pending, or live in that workspace.",
|
||||
"oracle": "The current renderer-state slice asserts provider-session claim keys are owned by preserved active tabs, inactive split leaves, visible non-focused split groups, live records, quit records, worktree-sleep records, queued startup payloads, time-bounded resume bridge claims, and same-session live hook evidence; duplicates clear without launching a second resume command. The provider list is the risk scope, not proof that every provider has a live integration gate.",
|
||||
"invariant": "Workspace activation, launch, restore, sleep, hibernate, dedupe, clearing, mount, remount, and reconnect code must not replay or resume a provider session id already owned, queued, pending, live, or durably bound to a host PTY in that workspace. A renderer with missing projection state must adopt the exact runtime-owned PTY for the original tab and leaf rather than create a replacement.",
|
||||
"oracle": "Renderer-state tests assert provider-session ownership across preserved and queued panes. Main/provider contracts assert atomic attach-only adoption, stable host/worktree/tab/leaf identity, no fresh spawn on adoption, and safe paired-runtime cancellation. The Electron oracle creates inactive runtime-owned Codex and Setup PTYs plus an unrelated canary, seeds an exact resumable provider session, removes only the target renderer projections, and activates the workspace. It requires byte-stable handle, PTY, incarnation, tab, leaf, process PID, renderer graph, persisted binding, runtime id, graph epoch, and daemon PID across first mount and reload; PID-specific DOM keyboard I/O must remain live with one launch, zero resume argv, zero signals, zero interruption text, and no canary mutation.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts"
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/lib/pane-manager/pane-fit.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts",
|
||||
"pnpm exec electron-vite build --mode e2e && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/live-background-terminal-mount-authority.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/lib/resume-sleeping-agent-session.test.ts",
|
||||
"src/main/providers/local-pty-provider.test.ts",
|
||||
"src/main/daemon/terminal-host.test.ts",
|
||||
"src/main/daemon/daemon-pty-adapter.test.ts",
|
||||
"src/main/ipc/pty.test.ts",
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/renderer/src/lib/pane-manager/pane-fit.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-transport.test.ts",
|
||||
"tests/e2e/live-background-terminal-mount-authority.spec.ts"
|
||||
],
|
||||
"testFiles": ["src/renderer/src/lib/resume-sleeping-agent-session.test.ts"],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/lib/resume-sleeping-agent-session.test.ts",
|
||||
@@ -2148,6 +2284,42 @@
|
||||
"active stable-pane records owned by preserved or visible panes are not resumed again",
|
||||
"hibernated stable panes with cleared live PTY bindings are skipped"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/pty.test.ts",
|
||||
"assertions": [
|
||||
"a completed runtime-owned stable pane is adopted with its original PTY and incarnation while renderer resume intent is stripped",
|
||||
"an exact persisted owner is attach-only adopted when the runtime projection is missing",
|
||||
"runtime and persisted stable-pane owner conflicts fail closed before provider creation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/pane-manager/pane-fit.test.ts",
|
||||
"assertions": [
|
||||
"withheld hidden-window animation frames exhaust the bounded fit retry and release its continuation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"assertions": [
|
||||
"same-generation explicit reattach drains the authoritative snapshot before immediate live bytes and ACKs their delivery credit"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-transport.test.ts",
|
||||
"assertions": [
|
||||
"paired-runtime stable-pane adoption reports reattach without fresh-spawn ownership",
|
||||
"cancellation after a paired-runtime adoption cannot close the original owner"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/live-background-terminal-mount-authority.spec.ts",
|
||||
"assertions": [
|
||||
"first mount and renderer reload preserve exact agent and Setup handle, PTY, incarnation, tab, leaf, and PID identity",
|
||||
"PID-specific keyboard input and output remain user-visible in both mounted panes",
|
||||
"runtime inventory, renderer graph, persisted session, runtime epoch, and daemon PID converge without replacement or resume",
|
||||
"the unrelated canary remains writable and receives no signal across target projection repair"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
@@ -2162,30 +2334,32 @@
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 15,
|
||||
"scope": "local renderer state test"
|
||||
"p95Seconds": 180,
|
||||
"scope": "focused renderer/main/provider contracts plus one isolated Electron mount-and-reload journey"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "Registered after targeted tests were found; needs soak history before blocking promotion."
|
||||
"evidence": "The renderer gate has prior local evidence; the stable-pane Electron oracle is new and needs CI soak before blocking promotion."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Tests encode provider-session dedupe and ownership claims across active/inactive/visible split records, queued pendingStartupByTabId resume payloads, time-bounded runtime automaticAgentResumeClaimsByTabId bridge claims, live same-session hook evidence, wrong-session hook rejection, and a bounded queued-claim index over many records/tabs. Needs saved red/green artifact for the class-level replay invariant."
|
||||
"evidence": "Renderer tests encode provider-session dedupe across active, inactive, queued, and live claims. The cross-boundary Electron oracle is constructed for byte-identical latest-main, candidate, and candidate-revert runs; record those three terminal results before promoting this gate."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Current state tests are cheap and assert queued pending-startup provider-session ids are indexed once per activation. PRs adding new ownership scans must show bounded work over records and no hidden-pane wake loop before blocking promotion."
|
||||
"evidence": "Renderer state tests assert bounded provider-session indexing. Stable-pane adoption is a targeted owner lookup and attach-only call; focused contracts require no provider listing scan, fresh spawn callback, or repeated resume probe. The Electron oracle checks exact launch counts but is not a throughput benchmark."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Run in soak for at least 100 consecutive passes or 14 days across required CI platforms.",
|
||||
"Add bounded-work assertions for delayed hook/status ownership scans if those paths grow.",
|
||||
"Attach red/green evidence that display/replay evidence alone cannot claim ownership."
|
||||
"Attach red/green evidence that display/replay evidence alone cannot claim ownership.",
|
||||
"Record byte-identical latest-main, candidate, and candidate-revert Electron results."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Providers listed on this gate are affected identity surfaces; the current executable command is renderer-state coverage, not live local/daemon/SSH/WSL/remote-runtime coverage.",
|
||||
"Current command models live same-session and wrong-session hook evidence, but does not run the real hook timing through Electron.",
|
||||
"Current command does not run a real workspace activation loop repeatedly through Electron."
|
||||
"Providers listed on this gate are affected identity surfaces; live integration is limited to local macOS while daemon and remote-runtime adoption also have focused contracts.",
|
||||
"The Electron oracle seeds the production hook-store contract instead of running an authenticated Codex hook end to end.",
|
||||
"The live Electron topology is local macOS only; folder workspaces, SSH, WSL, paired headed/headless servers, Linux, and Windows are not exercised by that journey.",
|
||||
"The oracle covers first activation and one renderer reload, not repeated soak activation or an installed-app update."
|
||||
],
|
||||
"demotionRule": "Demote or quarantine if failures are non-actionable or if a duplicate resume escape occurs outside the modeled matrix."
|
||||
},
|
||||
@@ -6457,11 +6631,12 @@
|
||||
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "daemon", "ssh"],
|
||||
"coverageNotes": "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, 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. 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. 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, 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.",
|
||||
"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",
|
||||
@@ -6470,6 +6645,7 @@
|
||||
"pnpm run test:e2e -- tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"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/orca-runtime.test.ts",
|
||||
@@ -6483,6 +6659,16 @@
|
||||
"tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts",
|
||||
"assertions": [
|
||||
"an exact current worker settles once through an app/runtime update even when renderer graph identity is absent",
|
||||
"only an attested current coordinator may explicitly take over retained live work",
|
||||
"Task, Dispatch, terminal authority, completion, and filesystem bytes are neither lost nor duplicated",
|
||||
"foreign pane evidence cannot borrow retained lifecycle authority",
|
||||
"ordinary mail and remote attachments use the same attested pane and process authority"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts",
|
||||
"assertions": [
|
||||
@@ -6562,6 +6748,42 @@
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-03",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts --reporter=dot",
|
||||
"result": "failed",
|
||||
"durationSeconds": 3.07,
|
||||
"summary": "The byte-identical 5f45c270f1 oracle, applied as the sole tree overlay in 063340a8ba on origin/main@34291f07e9, failed 3 of 5 rows: completion remained dispatched, takeover lacked a stable pane, and remote attachment authority lost process identity."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-03",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 3.13,
|
||||
"summary": "The same byte-identical oracle passed 5 tests on candidate@2748b0b29b, exercising the production verifier and fresh-runtime replay while preserving exact DB identity and unchanged fixture bytes."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-03",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts --reporter=dot",
|
||||
"result": "failed",
|
||||
"durationSeconds": 3.14,
|
||||
"summary": "With current-authority propagation actually disabled in candidate child 08a7db37bf, the same byte-identical oracle failed 2 of 5 rows: completion remained dispatched and remote attachment authority was rejected."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-03",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 3.1,
|
||||
"summary": "After reverting the intentional break in d2a4e2e024, the same byte-identical oracle passed all 5 tests."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-28",
|
||||
"runner": "local",
|
||||
@@ -6609,7 +6831,7 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The focused presentation and stale-handle tests failed against the pre-fix implementation. The restart journey additionally failed first on empty retained output while the original PTY/PID remained live, then passed after the scoped recovered-worker snapshot fallback. The live incident and pre-fix Electron topology showed workspace re-entry replaying provider resume against a worker whose tab binding was missing. Saved intentional-break and CI artifacts are still needed."
|
||||
"evidence": "The byte-identical 5f45c270f1 service oracle is red as the sole overlay 063340a8ba on origin/main@34291f07e9, green on candidate@2748b0b29b, red with current-authority propagation actually disabled in child 08a7db37bf, and green again after revert d2a4e2e024. The focused presentation and stale-handle tests failed against the earlier pre-fix implementation. The restart journey additionally failed first on empty retained output while the original PTY/PID remained live, then passed after the scoped recovered-worker snapshot fallback. The live incident and pre-fix Electron topology showed workspace re-entry replaying provider resume against a worker whose tab binding was missing. Distinct installed A/B and CI artifacts are still needed."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
@@ -6631,6 +6853,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",
|
||||
@@ -7781,8 +8101,8 @@
|
||||
"https://github.com/stablyai/orca/pull/5787",
|
||||
"https://github.com/stablyai/orca/pull/8034"
|
||||
],
|
||||
"invariant": "After a renderer lifecycle reset (did-start-loading / render-process-gone / destroyed), no surviving PTY remains delivery-gated by pre-reset unacked bytes: main's in-flight counters and pending backlog equal the true state of the new page (zero in-flight, zero pending). Delivery then resumes only once the reloaded page's pty:data dispatcher re-registers and signals pty:rendererDispatcherReady; during the boot window before that handshake main holds all sends (data accrues losslessly in the capped pending backlog) so bytes cannot be dropped into a listener-less page and re-pin the gate. The hold itself cannot become a permanent freeze: a one-shot ~10s watchdog armed on each reset force-opens the gate (incrementing rendererDispatcherReadyForcedCount) if the handshake is lost, and the real handshake or a re-registration cancels it. The reset fires only for a main-frame load: did-start-loading also fires for in-page subframe loads (sandboxed srcDoc iframes in notebook HTML output), which are filtered out via isLoadingMainFrame() so a subframe load never clears accounting or holds the gate on the still-alive page. If a lifecycle-reset edge is missed entirely — a main-frame reload overlapped by an in-page subframe load emits no did-start-loading at all — a backstop still recovers: because the handshake is one-shot per page load, receiving pty:rendererDispatcherReady while the gate is already open proves a reset was missed (or the watchdog force-opened the gate), so the handler reconciles by clearing the stale accounting before re-opening. The renderer sends that handshake exactly once per page load, after its pty:data listener registers.",
|
||||
"oracle": "Ingest more than 512 KB of PTY output with no renderer ACKs and assert the per-PTY gate closes (sends stop at the 512 KB high-water, remainder accrues as pending). Fire the registered did-start-loading listener and assert rendererInFlightChars and pendingChars are zero and the new diagnostics record the reset (rendererLifecycleResetCount 1, lastLifecycleResetClearedChars 512 KB). Then, before any dispatcher-ready handshake, ingest another chunk and assert it is NOT sent and NOT counted in-flight (held for the boot window, accruing in pending). Finally fire the pty:rendererDispatcherReady handshake and assert the held chunk is delivered to the renderer. Counters-zero without proving both the boot-window hold and that delivery resumes is insufficient. Additional cases prove the boot-window hold also covers the interactive direct-send fast path (input-primed keystroke echo is held, not sent, until the handshake) and that the self-heal watchdog force-opens the gate (rendererDispatcherReadyForcedCount 1) when no handshake arrives, while a timely handshake cancels the watchdog and leaves no orphaned timer. A further case fires did-start-loading with isLoadingMainFrame() false (a subframe/iframe load) and asserts accounting is untouched (rendererLifecycleResetCount stays 0, pending preserved, ready stays true) and delivery still drains on ACK — proving an in-page iframe load cannot trigger a spurious freeze. A backstop case saturates the gate, then fires pty:rendererDispatcherReady while the gate is still open (ready true) with no preceding reset — modeling a missed lifecycle edge — and asserts the handler reconciles: in-flight and pending clear, rendererLifecycleResetCount increments, and fresh output flows immediately (a straggler ACK is clamped and cannot underflow). A renderer-side case (pty-dispatcher-pi-routing.test.ts) asserts ensurePtyDispatcher() sends pty:rendererDispatcherReady exactly once across two attach calls — proving the send fires (it is optional-chained) and the one-shot guard holds.",
|
||||
"invariant": "After a renderer lifecycle reset (main-frame did-start-navigation / render-process-gone / destroyed), no surviving PTY remains delivery-gated by pre-reset unacked bytes: main's in-flight counters and pending backlog equal the true state of the new page (zero in-flight, zero pending). Delivery then resumes only once the reloaded page's pty:data dispatcher re-registers and signals pty:rendererDispatcherReady; during the boot window before that handshake main holds all sends (data accrues losslessly in the capped pending backlog) so bytes cannot be dropped into a listener-less page and re-pin the gate. The hold itself cannot become a permanent freeze: a one-shot ~10s watchdog armed on each reset force-opens the gate (incrementing rendererDispatcherReadyForcedCount) if the handshake is lost, and the real handshake or a re-registration cancels it. The reset fires only for a new-document main-frame navigation: did-start-navigation carries exact frame and same-document details, so overlapping subframe or in-page navigation never clears accounting or holds the gate on the still-alive page. If a renderer lifecycle edge is otherwise missed, a backstop still recovers: because the handshake is one-shot per page load, receiving pty:rendererDispatcherReady while the gate is already open proves a reset was missed (or the watchdog force-opened the gate), so the handler reconciles by clearing the stale accounting before re-opening. The renderer sends that handshake exactly once per page load, after its pty:data listener registers.",
|
||||
"oracle": "Ingest more than 512 KB of PTY output with no renderer ACKs and assert the per-PTY gate closes (sends stop at the 512 KB high-water, remainder accrues as pending). Fire the registered main-frame did-start-navigation listener and assert rendererInFlightChars and pendingChars are zero and the new diagnostics record the reset (rendererLifecycleResetCount 1, lastLifecycleResetClearedChars 512 KB). Then, before any dispatcher-ready handshake, ingest another chunk and assert it is NOT sent and NOT counted in-flight (held for the boot window, accruing in pending). Finally fire the pty:rendererDispatcherReady handshake and assert the held chunk is delivered to the renderer. Counters-zero without proving both the boot-window hold and that delivery resumes is insufficient. Additional cases prove the boot-window hold also covers the interactive direct-send fast path (input-primed keystroke echo is held, not sent, until the handshake) and that the self-heal watchdog force-opens the gate (rendererDispatcherReadyForcedCount 1) when no handshake arrives, while a timely handshake cancels the watchdog and leaves no orphaned timer. A further case opens the new page with a main-frame navigation and dispatcher handshake, then fires an overlapping subframe navigation and asserts the gate stays ready, the reset count stays at exactly one, and fresh output delivers without the watchdog — proving an iframe cannot reclose the live page. A backstop case saturates the gate, then fires pty:rendererDispatcherReady while the gate is still open (ready true) with no preceding reset — modeling a missed lifecycle edge — and asserts the handler reconciles: in-flight and pending clear, rendererLifecycleResetCount increments, and fresh output flows immediately (a straggler ACK is clamped and cannot underflow). A renderer-side case (pty-dispatcher-pi-routing.test.ts) asserts ensurePtyDispatcher() sends pty:rendererDispatcherReady exactly once across two attach calls — proving the send fires (it is optional-chained) and the one-shot guard holds.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts"
|
||||
],
|
||||
@@ -7795,12 +8115,12 @@
|
||||
"file": "src/main/ipc/pty.test.ts",
|
||||
"assertions": [
|
||||
"a PTY saturated past the 512 KB per-PTY high-water with no ACKs stops sending and accrues pending output (gate closed)",
|
||||
"firing the registered did-start-loading listener zeroes rendererInFlightChars and pendingData and records rendererLifecycleResetCount and lastLifecycleResetClearedChars",
|
||||
"firing the registered main-frame did-start-navigation listener zeroes rendererInFlightChars and pendingData and records rendererLifecycleResetCount and lastLifecycleResetClearedChars",
|
||||
"after the reset, output ingested during the boot window is NOT sent and NOT counted in-flight until the pty:rendererDispatcherReady handshake fires (held in pending)",
|
||||
"firing the pty:rendererDispatcherReady handshake releases the held backlog and delivery resumes (delivery gated on the handshake, not just counters cleared)",
|
||||
"interactive input-primed keystroke echo is also held during the boot window (interactive fast path gated on the handshake) and delivered once it fires",
|
||||
"when no handshake arrives, the ~10s watchdog force-opens the gate (rendererDispatcherReadyForcedCount 1) and the held backlog drains; a timely handshake cancels the watchdog and leaves no orphaned timer",
|
||||
"a did-start-loading with isLoadingMainFrame() false (in-page subframe/iframe load) does NOT reset accounting (rendererLifecycleResetCount stays 0, pending and in-flight preserved, rendererPtyDispatcherReady stays true) and delivery still drains on ACK",
|
||||
"an overlapping subframe did-start-navigation after the fresh dispatcher handshake does NOT reclose delivery (rendererLifecycleResetCount stays 1, rendererPtyDispatcherReady stays true, forced count stays 0) and fresh output delivers immediately",
|
||||
"a pty:rendererDispatcherReady handshake arriving while the gate is still open (ready true, no preceding reset — a missed lifecycle edge) reconciles the stale accounting: in-flight and pending clear, rendererLifecycleResetCount increments, fresh output flows, and a straggler ACK is clamped",
|
||||
"re-registering handlers (macOS re-activate / new window) cancels the prior registration's armed dispatcher-ready watchdog via the cross-registration bridge, leaving no orphaned ~10s timer to force-open a dead window's gate"
|
||||
]
|
||||
@@ -7814,13 +8134,13 @@
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-09",
|
||||
"date": "2026-08-02",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 1,
|
||||
"summary": "243 tests passed (232 main-process + 11 renderer dispatcher) including the lifecycle-reset, boot-window (dispatcher-ready handshake), interactive-gate hold, watchdog self-heal, main-frame-filter (subframe did-start-loading is ignored), missed-reset reconcile backstop (handshake-while-open), cross-registration watchdog-cancel, and renderer-side one-shot handshake-send regressions. Removing the reset call reproduces the reload freeze (rendererInFlightChars stays 524288); removing the send-hold reproduces the boot-window leak; removing the interactive-path flag check sends keystroke echo into the not-yet-ready page; removing the watchdog arm leaves the gate held forever; removing the watchdog cancel leaves an orphaned timer after the handshake; removing the isLoadingMainFrame filter lets a subframe iframe load run a spurious reset; removing the handshake-while-open reconcile leaves the survivors pinned at 524288 after a missed lifecycle edge."
|
||||
"durationSeconds": 5,
|
||||
"summary": "439 tests passed including the lifecycle-reset, boot-window (dispatcher-ready handshake), interactive-gate hold, watchdog self-heal, exact-navigation filter (overlapping subframe navigation is ignored), missed-reset reconcile backstop (handshake-while-open), cross-registration watchdog-cancel, and renderer-side one-shot handshake-send regressions. Removing the reset call reproduces the reload freeze (rendererInFlightChars stays 524288); removing the send-hold reproduces the boot-window leak; removing the interactive-path flag check sends keystroke echo into the not-yet-ready page; removing the watchdog arm leaves the gate held forever; removing the watchdog cancel leaves an orphaned timer after the handshake; switching back to aggregate did-start-loading state lets an overlapping iframe load reclose the gate; removing the handshake-while-open reconcile leaves the survivors pinned at 524288 after a missed lifecycle edge."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
@@ -7833,7 +8153,7 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Locally verified red/green on every load-bearing branch: (1) reset call removed -> rendererInFlightChars stays 524288 after did-start-loading; (2) boot-window send-hold removed -> post-reload output is sent into the not-yet-ready page ('NOT sent until handshake' fails); (3) interactive-path flag check removed -> input-primed keystroke echo is sent during the hold; (4) watchdog arm removed -> the gate is never force-opened and the held backlog never drains; (5) watchdog cancel removed -> an orphaned ~10s timer survives the handshake (getTimerCount 1); (6) isLoadingMainFrame filter removed -> a subframe did-start-loading runs a spurious reset (rendererLifecycleResetCount 1, pending cleared, ready dropped) — locally verified red; (7) handshake-while-open reconcile removed -> a pty:rendererDispatcherReady arriving after a missed lifecycle edge leaves the gate pinned (rendererInFlightChars stays 524288, pending 90112, rendererLifecycleResetCount 0) — locally verified red; (8) cross-registration bridge cancel removed (top-of-registerPtyHandlers clearRendererDispatcherReadyWatchdog) -> a prior registration's armed watchdog survives re-registration as an orphaned timer (getTimerCount 1 instead of 0) — locally verified red. With the full fix all eight are green. The performance budget below still holds: the watchdog is a single unref'd one-shot per reset, not per-chunk. Needs a saved CI or intentional-break artifact before blocking promotion."
|
||||
"evidence": "Locally verified red/green on every load-bearing branch: (1) reset call removed -> rendererInFlightChars stays 524288 after main-frame did-start-navigation; (2) boot-window send-hold removed -> post-reload output is sent into the not-yet-ready page ('NOT sent until handshake' fails); (3) interactive-path flag check removed -> input-primed keystroke echo is sent during the hold; (4) watchdog arm removed -> the gate is never force-opened and the held backlog never drains; (5) watchdog cancel removed -> an orphaned ~10s timer survives the handshake (getTimerCount 1); (6) aggregate did-start-loading classification restored -> an overlapping subframe navigation recloses the gate after the handshake (ready false until watchdog) — deterministically red; (7) handshake-while-open reconcile removed -> a pty:rendererDispatcherReady arriving after a missed lifecycle edge leaves the gate pinned (rendererInFlightChars stays 524288, pending 90112, rendererLifecycleResetCount 0) — locally verified red; (8) cross-registration bridge cancel removed (top-of-registerPtyHandlers clearRendererDispatcherReadyWatchdog) -> a prior registration's armed watchdog survives re-registration as an orphaned timer (getTimerCount 1 instead of 0) — locally verified red. With the full fix all eight are green. The performance budget below still holds: the watchdog is a single unref'd one-shot per reset, not per-chunk. Needs a saved CI or intentional-break artifact before blocking promotion."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
|
||||
@@ -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, readFileSync, 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'
|
||||
@@ -103,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([])
|
||||
})
|
||||
})
|
||||
@@ -420,40 +420,42 @@ describe('electron-builder config', () => {
|
||||
expect(findAsarEntry(['/out/main/index.js'], 'out/main/index.js')).toBe('/out/main/index.js')
|
||||
})
|
||||
|
||||
it('prunes non-target node-pty prebuilds from packaged runtime resources', async () => {
|
||||
it('prunes non-target node-pty architecture outputs from packaged runtime resources', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-node-pty-prune-'))
|
||||
try {
|
||||
const prebuildsDir = join(resourcesDir, 'node_modules', 'node-pty', 'prebuilds')
|
||||
const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
|
||||
const prebuildsDir = join(nodePtyDir, 'prebuilds')
|
||||
const binDir = join(nodePtyDir, 'bin')
|
||||
await mkdir(join(prebuildsDir, 'darwin-arm64'), { recursive: true })
|
||||
await mkdir(join(prebuildsDir, 'darwin-x64'), { recursive: true })
|
||||
await mkdir(join(prebuildsDir, 'linux-x64'), { recursive: true })
|
||||
await mkdir(join(prebuildsDir, 'win32-x64'), { recursive: true })
|
||||
await mkdir(join(resourcesDir, 'node_modules', 'node-pty', 'third_party', 'conpty'), {
|
||||
recursive: true
|
||||
})
|
||||
await mkdir(join(resourcesDir, 'node_modules', 'node-pty', 'deps', 'winpty'), {
|
||||
await mkdir(join(binDir, 'darwin-arm64-148'), { recursive: true })
|
||||
await mkdir(join(binDir, 'darwin-x64-148'), { recursive: true })
|
||||
await mkdir(join(nodePtyDir, 'third_party', 'conpty'), {
|
||||
recursive: true
|
||||
})
|
||||
await mkdir(join(nodePtyDir, 'deps', 'winpty'), { recursive: true })
|
||||
|
||||
prunePackagedNodePty(resourcesDir, 'darwin')
|
||||
prunePackagedNodePty(resourcesDir, 'darwin', 3)
|
||||
|
||||
await expect(readdir(prebuildsDir).then((entries) => entries.sort())).resolves.toEqual([
|
||||
'darwin-arm64',
|
||||
'darwin-x64'
|
||||
])
|
||||
await expect(
|
||||
readdir(join(resourcesDir, 'node_modules', 'node-pty', 'third_party'))
|
||||
).resolves.toEqual([])
|
||||
await expect(
|
||||
readdir(join(resourcesDir, 'node_modules', 'node-pty', 'deps'))
|
||||
).resolves.toEqual([])
|
||||
await expect(readdir(prebuildsDir)).resolves.toEqual(['darwin-arm64'])
|
||||
await expect(readdir(binDir)).resolves.toEqual(['darwin-arm64-148'])
|
||||
await expect(readdir(join(nodePtyDir, 'third_party'))).resolves.toEqual([])
|
||||
await expect(readdir(join(nodePtyDir, 'deps'))).resolves.toEqual([])
|
||||
expect(() => prunePackagedNodePty(resourcesDir, 'darwin', 4)).toThrow(
|
||||
'Unsupported packaged runtime architecture: 4'
|
||||
)
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('copies the Windows node-pty ConPTY runtime beside the rebuilt addon', async () => {
|
||||
for (const arch of ['x64', 'arm64']) {
|
||||
for (const [arch, electronArch] of [
|
||||
['x64', 1],
|
||||
['arm64', 3]
|
||||
]) {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), `orca-node-pty-conpty-${arch}-`))
|
||||
try {
|
||||
const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
|
||||
@@ -472,7 +474,7 @@ describe('electron-builder config', () => {
|
||||
)
|
||||
}
|
||||
|
||||
prunePackagedNodePty(resourcesDir, 'win32', arch)
|
||||
prunePackagedNodePty(resourcesDir, 'win32', electronArch)
|
||||
|
||||
await expect(readFile(join(releaseDir, 'conpty', 'conpty.dll'), 'utf8')).resolves.toBe(
|
||||
`dll payload ${arch}`
|
||||
@@ -500,7 +502,7 @@ describe('electron-builder config', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('prunes non-target @parcel/watcher platform subpackages from packaged runtime resources', async () => {
|
||||
it('prunes non-target @parcel/watcher architecture subpackages', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-'))
|
||||
try {
|
||||
const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
|
||||
@@ -511,13 +513,15 @@ describe('electron-builder config', () => {
|
||||
await mkdir(join(parcelDir, 'watcher-linux-arm64-glibc'), { recursive: true })
|
||||
await mkdir(join(parcelDir, 'watcher-win32-x64'), { recursive: true })
|
||||
|
||||
prunePackagedParcelWatcher(resourcesDir, 'linux')
|
||||
prunePackagedParcelWatcher(resourcesDir, 'linux', 'arm64')
|
||||
|
||||
await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
|
||||
'watcher',
|
||||
'watcher-linux-arm64-glibc',
|
||||
'watcher-linux-x64-glibc'
|
||||
'watcher-linux-arm64-glibc'
|
||||
])
|
||||
expect(() => prunePackagedParcelWatcher(resourcesDir, 'linux', 'universal')).toThrow(
|
||||
'Unsupported packaged runtime architecture: universal'
|
||||
)
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -533,7 +537,7 @@ describe('electron-builder config', () => {
|
||||
// A hypothetical future @parcel/* runtime dep that is NOT a watcher subpackage.
|
||||
await mkdir(join(parcelDir, 'transformer-js'), { recursive: true })
|
||||
|
||||
prunePackagedParcelWatcher(resourcesDir, 'linux')
|
||||
prunePackagedParcelWatcher(resourcesDir, 'linux', 1)
|
||||
|
||||
await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
|
||||
'transformer-js',
|
||||
@@ -653,7 +657,8 @@ describe('electron-builder config', () => {
|
||||
|
||||
await electronBuilderConfig.afterPack({
|
||||
appOutDir: join(root, 'linux-unpacked'),
|
||||
electronPlatformName: 'linux'
|
||||
electronPlatformName: 'linux',
|
||||
arch: 1
|
||||
})
|
||||
|
||||
expect((await stat(launcherPath)).mode & 0o111).not.toBe(0)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
export class BoundedLiveFreezeHistory {
|
||||
#entries = []
|
||||
#limit
|
||||
#nextIndex = 0
|
||||
#totalCount = 0
|
||||
|
||||
constructor(limit) {
|
||||
if (!Number.isInteger(limit) || limit <= 0) {
|
||||
throw new Error(`History limit must be a positive integer, got ${limit}`)
|
||||
}
|
||||
this.#limit = limit
|
||||
}
|
||||
|
||||
add(entry) {
|
||||
this.#totalCount += 1
|
||||
if (this.#entries.length < this.#limit) {
|
||||
this.#entries.push(entry)
|
||||
return
|
||||
}
|
||||
this.#entries[this.#nextIndex] = entry
|
||||
this.#nextIndex = (this.#nextIndex + 1) % this.#limit
|
||||
}
|
||||
|
||||
get retainedCount() {
|
||||
return this.#entries.length
|
||||
}
|
||||
|
||||
get totalCount() {
|
||||
return this.#totalCount
|
||||
}
|
||||
|
||||
values() {
|
||||
if (this.#entries.length < this.#limit || this.#nextIndex === 0) {
|
||||
return [...this.#entries]
|
||||
}
|
||||
return [...this.#entries.slice(this.#nextIndex), ...this.#entries.slice(0, this.#nextIndex)]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BoundedLiveFreezeHistory } from './live-freeze-bounded-history.mjs'
|
||||
|
||||
describe('BoundedLiveFreezeHistory', () => {
|
||||
it('retains the newest entries in insertion order and counts the full run', () => {
|
||||
const history = new BoundedLiveFreezeHistory(3)
|
||||
|
||||
for (let value = 1; value <= 7; value += 1) {
|
||||
history.add(value)
|
||||
}
|
||||
|
||||
expect(history.values()).toEqual([5, 6, 7])
|
||||
expect(history.retainedCount).toBe(3)
|
||||
expect(history.totalCount).toBe(7)
|
||||
})
|
||||
|
||||
it('rejects invalid retention limits', () => {
|
||||
expect(() => new BoundedLiveFreezeHistory(0)).toThrow('positive integer')
|
||||
expect(() => new BoundedLiveFreezeHistory(1.5)).toThrow('positive integer')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Pure metrics helpers for the live remote bulk-open freeze harness.
|
||||
* Kept separate so unit tests can drive the same code the repro uses.
|
||||
*/
|
||||
|
||||
export const DEFAULT_SOFT_MS = 2000
|
||||
export const DEFAULT_HARD_MS = 5000
|
||||
|
||||
export function readFreezeNumberEnv(name, fallback) {
|
||||
const raw = process.env[name]
|
||||
if (raw == null || raw.trim() === '') {
|
||||
return fallback
|
||||
}
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new Error(`Invalid ${name}: expected a finite number, got ${JSON.stringify(raw)}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function extractTerminalHandle(result) {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return null
|
||||
}
|
||||
const candidates = [
|
||||
result.handle,
|
||||
result.terminalHandle,
|
||||
result.agentTerminalHandle,
|
||||
typeof result.terminal === 'string' ? result.terminal : result.terminal?.handle,
|
||||
result.startupTerminal?.handle,
|
||||
result.tab?.terminal,
|
||||
result.tab?.handle
|
||||
]
|
||||
for (const value of candidates) {
|
||||
if (typeof value === 'string' && value.startsWith('term_')) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
for (const value of Object.values(result)) {
|
||||
if (typeof value === 'string' && value.startsWith('term_')) {
|
||||
return value
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
for (const nested of Object.values(value)) {
|
||||
if (typeof nested === 'string' && nested.startsWith('term_')) {
|
||||
return nested
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function worktreeSelector(wt) {
|
||||
if (typeof wt?.id === 'string' && wt.id.length > 0) {
|
||||
return `id:${wt.id}`
|
||||
}
|
||||
if (typeof wt?.path === 'string' && wt.path.length > 0) {
|
||||
return `path:${wt.path}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Peak stall across individual switch latency and concurrent batch wall.
|
||||
* Hard freeze when peak >= hardMs (default 5000).
|
||||
*/
|
||||
export function evaluateFreezeSignals({
|
||||
maxSwitchMs = 0,
|
||||
maxBatchWallMs = 0,
|
||||
statusProbeMs = 0,
|
||||
memoryProbeMs = null,
|
||||
softMs = DEFAULT_SOFT_MS,
|
||||
hardMs = DEFAULT_HARD_MS
|
||||
}) {
|
||||
const peakLatencyMs = Math.max(maxSwitchMs, maxBatchWallMs)
|
||||
const softFreeze =
|
||||
peakLatencyMs >= softMs ||
|
||||
statusProbeMs >= softMs ||
|
||||
(memoryProbeMs != null && memoryProbeMs >= softMs)
|
||||
const hardFreeze =
|
||||
peakLatencyMs >= hardMs ||
|
||||
statusProbeMs >= hardMs ||
|
||||
(memoryProbeMs != null && memoryProbeMs >= hardMs)
|
||||
return { peakLatencyMs, softFreeze, hardFreeze }
|
||||
}
|
||||
|
||||
export function shouldCapSwitchTargets(maxSwitchTargets) {
|
||||
return Number.isFinite(maxSwitchTargets) && maxSwitchTargets > 0
|
||||
}
|
||||
|
||||
export function applySwitchTargetCap(targets, maxSwitchTargets) {
|
||||
if (!shouldCapSwitchTargets(maxSwitchTargets)) {
|
||||
return targets
|
||||
}
|
||||
return targets.slice(0, maxSwitchTargets)
|
||||
}
|
||||
|
||||
/** Scenarios that model real user recovery, not concurrent CLI pileup. */
|
||||
export const REALISTIC_SCENARIOS = [
|
||||
'idle-backlog-open',
|
||||
'idle-backlog-reconnect-open',
|
||||
'restart-proxy',
|
||||
/** Idle + flood + reconnect storm overlapped with concurrent open fan-out. */
|
||||
'lockup-storm'
|
||||
]
|
||||
|
||||
/**
|
||||
* Permanent lockup: app/host stops making progress — not a single recovered timeout.
|
||||
* Distinct from multi-second hard stall that still recovers (status answers, most opens ok).
|
||||
*/
|
||||
export function evaluatePermanentLockup({
|
||||
timedOutOps = 0,
|
||||
statusHangMs = 0,
|
||||
consecutiveSwitchFailures = 0,
|
||||
openFailed = 0,
|
||||
openTotal = 0,
|
||||
permanentTimeoutMs = 60_000,
|
||||
/** Fraction of opens that must fail to count as lockup without status hang. */
|
||||
failRateThreshold = 0.25,
|
||||
minTimedOutOps = 3
|
||||
}) {
|
||||
const failRate = openTotal > 0 ? openFailed / openTotal : 0
|
||||
const permanentLockup =
|
||||
statusHangMs >= permanentTimeoutMs ||
|
||||
timedOutOps >= minTimedOutOps ||
|
||||
consecutiveSwitchFailures >= 5 ||
|
||||
(openTotal >= 8 && failRate >= failRateThreshold)
|
||||
return {
|
||||
permanentLockup,
|
||||
timedOutOps,
|
||||
statusHangMs,
|
||||
consecutiveSwitchFailures,
|
||||
failRate,
|
||||
recoveredHardStallCandidate:
|
||||
!permanentLockup && timedOutOps < minTimedOutOps && statusHangMs < permanentTimeoutMs
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Peak across open latencies + optional reconnect-refresh wall + probes.
|
||||
* Used by the naturalistic harness (no parallel switch amp).
|
||||
*/
|
||||
export function evaluateRealisticFreezeSignals({
|
||||
maxOpenMs = 0,
|
||||
firstOpenMs = 0,
|
||||
reconnectRefreshMs = 0,
|
||||
statusProbeMs = 0,
|
||||
memoryProbeMs = null,
|
||||
softMs = DEFAULT_SOFT_MS,
|
||||
hardMs = DEFAULT_HARD_MS
|
||||
}) {
|
||||
const peakLatencyMs = Math.max(maxOpenMs, firstOpenMs, reconnectRefreshMs)
|
||||
return evaluateFreezeSignals({
|
||||
maxSwitchMs: peakLatencyMs,
|
||||
maxBatchWallMs: 0,
|
||||
statusProbeMs,
|
||||
memoryProbeMs,
|
||||
softMs,
|
||||
hardMs
|
||||
})
|
||||
}
|
||||
|
||||
export function humanPaceDelayMs(baseMs, jitterMs = 0) {
|
||||
const base = Math.max(0, baseMs)
|
||||
const jitter = Math.max(0, jitterMs)
|
||||
if (jitter === 0) {
|
||||
return base
|
||||
}
|
||||
return base + Math.floor(Math.random() * (jitter + 1))
|
||||
}
|
||||
|
||||
/** Full-app forever freeze: host RPC dead for a continuous window, not a recovered stall. */
|
||||
export const DEFAULT_FOREVER_WINDOW_MS = 30_000
|
||||
export const DEFAULT_STATUS_SLOW_MS = 15_000
|
||||
|
||||
/**
|
||||
* Analyze mid-storm status samples for a continuous unhealthy window.
|
||||
* Sample: { tMs, ms, ok, hang }
|
||||
*/
|
||||
export function evaluateFullAppFreeze({
|
||||
statusSamples = [],
|
||||
statusSummary = {},
|
||||
foreverWindowMs = DEFAULT_FOREVER_WINDOW_MS,
|
||||
statusSlowMs = DEFAULT_STATUS_SLOW_MS,
|
||||
killOnlyRecovery = false
|
||||
}) {
|
||||
const infrastructureErrors = statusSamples.filter((sample) => sample.infrastructureError)
|
||||
const infrastructureErrorCount = Math.max(
|
||||
infrastructureErrors.length,
|
||||
statusSummary.infrastructureErrorCount ?? 0
|
||||
)
|
||||
const maxStatusMs = Math.max(
|
||||
0,
|
||||
...statusSamples.map((s) => s.ms || 0),
|
||||
statusSummary.maxStatusMs ?? 0
|
||||
)
|
||||
if (killOnlyRecovery) {
|
||||
return {
|
||||
foreverUiLockupObserved: true,
|
||||
longestUnhealthyWindowMs: foreverWindowMs,
|
||||
maxStatusMs,
|
||||
unhealthySampleCount: statusSummary.sampleCount ?? statusSamples.length,
|
||||
infrastructureErrorCount,
|
||||
reason: 'kill-only recovery documented'
|
||||
}
|
||||
}
|
||||
|
||||
const unhealthy = statusSamples.map((s) => {
|
||||
const hang = !s.infrastructureError && (Boolean(s.hang) || s.ok === false)
|
||||
const slow = !s.infrastructureError && (s.ms || 0) >= statusSlowMs
|
||||
return { ...s, unhealthy: hang || slow }
|
||||
})
|
||||
|
||||
let longest = statusSummary.longestUnhealthyWindowMs ?? 0
|
||||
let runStart = null
|
||||
for (const s of unhealthy) {
|
||||
if (s.unhealthy) {
|
||||
runStart ??= s.tMs ?? 0
|
||||
const end = (s.tMs ?? 0) + (s.ms || 0)
|
||||
longest = Math.max(longest, end - runStart)
|
||||
} else {
|
||||
runStart = null
|
||||
}
|
||||
}
|
||||
|
||||
// If timestamps missing, fall back to consecutive unhealthy count * assumed interval.
|
||||
if (longest === 0 && unhealthy.some((s) => s.unhealthy)) {
|
||||
let run = 0
|
||||
for (const s of unhealthy) {
|
||||
if (s.unhealthy) {
|
||||
run += 1
|
||||
longest = Math.max(longest, run)
|
||||
} else {
|
||||
run = 0
|
||||
}
|
||||
}
|
||||
// Without wall clock, consecutive count alone is not a ms window.
|
||||
longest = 0
|
||||
}
|
||||
|
||||
const foreverUiLockupObserved = longest >= foreverWindowMs
|
||||
const unhealthySampleCount = Math.max(
|
||||
unhealthy.filter((s) => s.unhealthy).length,
|
||||
statusSummary.unhealthySampleCount ?? 0
|
||||
)
|
||||
|
||||
return {
|
||||
foreverUiLockupObserved,
|
||||
longestUnhealthyWindowMs: longest,
|
||||
maxStatusMs,
|
||||
unhealthySampleCount,
|
||||
infrastructureErrorCount,
|
||||
reason: foreverUiLockupObserved
|
||||
? `status unhealthy ≥${foreverWindowMs}ms continuous`
|
||||
: infrastructureErrorCount > 0
|
||||
? `status watchdog infrastructure errors: ${infrastructureErrorCount}`
|
||||
: maxStatusMs >= statusSlowMs
|
||||
? `status slow peak ${maxStatusMs}ms but no ≥${foreverWindowMs}ms window`
|
||||
: 'status remained healthy through storm'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applySwitchTargetCap,
|
||||
evaluateFreezeSignals,
|
||||
evaluateFullAppFreeze,
|
||||
evaluatePermanentLockup,
|
||||
evaluateRealisticFreezeSignals,
|
||||
extractTerminalHandle,
|
||||
humanPaceDelayMs,
|
||||
readFreezeNumberEnv,
|
||||
REALISTIC_SCENARIOS,
|
||||
shouldCapSwitchTargets,
|
||||
worktreeSelector
|
||||
} from './live-remote-bulk-open-freeze-metrics.mjs'
|
||||
|
||||
describe('live-remote-bulk-open-freeze-metrics', () => {
|
||||
it('extracts term_ handles from nested create payloads', () => {
|
||||
expect(extractTerminalHandle({ handle: 'term_abc' })).toBe('term_abc')
|
||||
expect(extractTerminalHandle({ terminal: { handle: 'term_nested' } })).toBe('term_nested')
|
||||
expect(extractTerminalHandle({ tab: { terminal: 'term_tab' } })).toBe('term_tab')
|
||||
expect(extractTerminalHandle({ startupTerminal: { handle: 'term_start' } })).toBe('term_start')
|
||||
expect(extractTerminalHandle({ junk: { deep: 'term_deep' } })).toBe('term_deep')
|
||||
expect(extractTerminalHandle({ handle: 'not-a-term' })).toBeNull()
|
||||
expect(extractTerminalHandle(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('builds worktree selectors from id/path', () => {
|
||||
expect(
|
||||
worktreeSelector({ id: 'repo::C:/Users/neil/orca/orca', path: 'C:/Users/neil/orca/orca' })
|
||||
).toBe('id:repo::C:/Users/neil/orca/orca')
|
||||
expect(worktreeSelector({ path: '/tmp/x' })).toBe('path:/tmp/x')
|
||||
expect(worktreeSelector({})).toBeNull()
|
||||
})
|
||||
|
||||
it('does not cap switch targets when max is 0 (regression for Math.max(2,0) bug)', () => {
|
||||
expect(shouldCapSwitchTargets(0)).toBe(false)
|
||||
expect(shouldCapSwitchTargets(-1)).toBe(false)
|
||||
expect(shouldCapSwitchTargets(2)).toBe(true)
|
||||
const many = Array.from({ length: 111 }, (_, i) => `term_${i}`)
|
||||
expect(applySwitchTargetCap(many, 0)).toHaveLength(111)
|
||||
expect(applySwitchTargetCap(many, 2)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('classifies hard freeze at >=5000ms peak (individual or batch wall)', () => {
|
||||
expect(evaluateFreezeSignals({ maxSwitchMs: 3874, maxBatchWallMs: 3874 }).hardFreeze).toBe(
|
||||
false
|
||||
)
|
||||
expect(evaluateFreezeSignals({ maxSwitchMs: 3874, maxBatchWallMs: 3874 }).softFreeze).toBe(true)
|
||||
|
||||
const hardIndividual = evaluateFreezeSignals({ maxSwitchMs: 19954, maxBatchWallMs: 1000 })
|
||||
expect(hardIndividual.hardFreeze).toBe(true)
|
||||
expect(hardIndividual.peakLatencyMs).toBe(19954)
|
||||
|
||||
const hardBatch = evaluateFreezeSignals({ maxSwitchMs: 900, maxBatchWallMs: 20201 })
|
||||
expect(hardBatch.hardFreeze).toBe(true)
|
||||
expect(hardBatch.peakLatencyMs).toBe(20201)
|
||||
})
|
||||
|
||||
it('evaluates naturalistic peaks without requiring parallel batch amp', () => {
|
||||
expect(REALISTIC_SCENARIOS).toContain('idle-backlog-open')
|
||||
expect(REALISTIC_SCENARIOS).toContain('idle-backlog-reconnect-open')
|
||||
expect(REALISTIC_SCENARIOS).toContain('lockup-storm')
|
||||
const soft = evaluateRealisticFreezeSignals({
|
||||
maxOpenMs: 3200,
|
||||
firstOpenMs: 2800,
|
||||
reconnectRefreshMs: 900
|
||||
})
|
||||
expect(soft.softFreeze).toBe(true)
|
||||
expect(soft.hardFreeze).toBe(false)
|
||||
expect(soft.peakLatencyMs).toBe(3200)
|
||||
|
||||
const hardFromReconnect = evaluateRealisticFreezeSignals({
|
||||
maxOpenMs: 800,
|
||||
firstOpenMs: 700,
|
||||
reconnectRefreshMs: 6200
|
||||
})
|
||||
expect(hardFromReconnect.hardFreeze).toBe(true)
|
||||
expect(hardFromReconnect.peakLatencyMs).toBe(6200)
|
||||
})
|
||||
|
||||
it('flags full-app freeze only for continuous unhealthy status window ≥30s', () => {
|
||||
const healthy = evaluateFullAppFreeze({
|
||||
statusSamples: [
|
||||
{ tMs: 0, ms: 150, ok: true },
|
||||
{ tMs: 2000, ms: 180, ok: true },
|
||||
{ tMs: 4000, ms: 140, ok: true }
|
||||
],
|
||||
foreverWindowMs: 30_000,
|
||||
statusSlowMs: 15_000
|
||||
})
|
||||
expect(healthy.foreverUiLockupObserved).toBe(false)
|
||||
|
||||
const forever = evaluateFullAppFreeze({
|
||||
statusSamples: [
|
||||
{ tMs: 0, ms: 16_000, ok: true },
|
||||
{ tMs: 16_000, ms: 16_000, ok: true },
|
||||
{ tMs: 32_000, ms: 16_000, ok: false, hang: true }
|
||||
],
|
||||
foreverWindowMs: 30_000,
|
||||
statusSlowMs: 15_000
|
||||
})
|
||||
expect(forever.foreverUiLockupObserved).toBe(true)
|
||||
expect(forever.longestUnhealthyWindowMs).toBeGreaterThanOrEqual(30_000)
|
||||
|
||||
expect(
|
||||
evaluateFullAppFreeze({ statusSamples: [], killOnlyRecovery: true }).foreverUiLockupObserved
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not classify watchdog infrastructure errors as an app freeze', () => {
|
||||
const result = evaluateFullAppFreeze({
|
||||
statusSamples: Array.from({ length: 25 }, (_, index) => ({
|
||||
tMs: index * 1500,
|
||||
ms: 1,
|
||||
ok: false,
|
||||
infrastructureError: true,
|
||||
error: 'spawn ENOENT'
|
||||
}))
|
||||
})
|
||||
|
||||
expect(result.foreverUiLockupObserved).toBe(false)
|
||||
expect(result.unhealthySampleCount).toBe(0)
|
||||
expect(result.infrastructureErrorCount).toBe(25)
|
||||
})
|
||||
|
||||
it('preserves full-run watchdog peaks after sample retention rotates', () => {
|
||||
const result = evaluateFullAppFreeze({
|
||||
statusSamples: [{ tMs: 60_000, ms: 100, ok: true }],
|
||||
statusSummary: {
|
||||
sampleCount: 40,
|
||||
maxStatusMs: 16_000,
|
||||
unhealthySampleCount: 3,
|
||||
infrastructureErrorCount: 2,
|
||||
longestUnhealthyWindowMs: 32_000
|
||||
},
|
||||
foreverWindowMs: 30_000,
|
||||
statusSlowMs: 15_000
|
||||
})
|
||||
|
||||
expect(result.foreverUiLockupObserved).toBe(true)
|
||||
expect(result.longestUnhealthyWindowMs).toBe(32_000)
|
||||
expect(result.maxStatusMs).toBe(16_000)
|
||||
expect(result.unhealthySampleCount).toBe(3)
|
||||
expect(result.infrastructureErrorCount).toBe(2)
|
||||
})
|
||||
|
||||
it('rejects invalid numeric environment values', () => {
|
||||
process.env.ORCA_FREEZE_TEST_NUMBER = 'not-a-number'
|
||||
expect(() => readFreezeNumberEnv('ORCA_FREEZE_TEST_NUMBER', 5)).toThrow(
|
||||
'Invalid ORCA_FREEZE_TEST_NUMBER'
|
||||
)
|
||||
delete process.env.ORCA_FREEZE_TEST_NUMBER
|
||||
expect(readFreezeNumberEnv('ORCA_FREEZE_TEST_NUMBER', 5)).toBe(5)
|
||||
})
|
||||
|
||||
it('distinguishes recovered hard stall from permanent lockup', () => {
|
||||
// Single reveal timeout with healthy status is NOT permanent app lockup.
|
||||
expect(
|
||||
evaluatePermanentLockup({
|
||||
timedOutOps: 1,
|
||||
statusHangMs: 0,
|
||||
consecutiveSwitchFailures: 1,
|
||||
openFailed: 1,
|
||||
openTotal: 64
|
||||
}).permanentLockup
|
||||
).toBe(false)
|
||||
expect(
|
||||
evaluatePermanentLockup({
|
||||
timedOutOps: 3,
|
||||
statusHangMs: 0,
|
||||
consecutiveSwitchFailures: 0,
|
||||
openFailed: 3,
|
||||
openTotal: 64
|
||||
}).permanentLockup
|
||||
).toBe(true)
|
||||
expect(
|
||||
evaluatePermanentLockup({
|
||||
timedOutOps: 0,
|
||||
statusHangMs: 60_000,
|
||||
consecutiveSwitchFailures: 0,
|
||||
permanentTimeoutMs: 60_000
|
||||
}).permanentLockup
|
||||
).toBe(true)
|
||||
expect(
|
||||
evaluatePermanentLockup({
|
||||
timedOutOps: 0,
|
||||
statusHangMs: 0,
|
||||
consecutiveSwitchFailures: 5
|
||||
}).permanentLockup
|
||||
).toBe(true)
|
||||
expect(
|
||||
evaluatePermanentLockup({
|
||||
timedOutOps: 0,
|
||||
openFailed: 20,
|
||||
openTotal: 40
|
||||
}).permanentLockup
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('human pace delay stays within base+jitter', () => {
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
const d = humanPaceDelayMs(250, 150)
|
||||
expect(d).toBeGreaterThanOrEqual(250)
|
||||
expect(d).toBeLessThanOrEqual(400)
|
||||
}
|
||||
expect(humanPaceDelayMs(100, 0)).toBe(100)
|
||||
})
|
||||
|
||||
it('reads the real hard-freeze lab report when present', async () => {
|
||||
const { readdirSync, readFileSync, existsSync } = await import('node:fs')
|
||||
const { resolve } = await import('node:path')
|
||||
const reportDir = resolve(process.cwd(), 'test-results/freeze-repro')
|
||||
if (!existsSync(reportDir)) {
|
||||
// Local clones without lab artifacts still pass pure metrics tests above.
|
||||
return
|
||||
}
|
||||
const reportName = readdirSync(reportDir).find(
|
||||
(name) => name.startsWith('live-bulk-open-freeze-') && name.endsWith('.json')
|
||||
)
|
||||
if (reportName == null) {
|
||||
return
|
||||
}
|
||||
const report = JSON.parse(readFileSync(resolve(reportDir, reportName), 'utf8'))
|
||||
const evaluated = evaluateFreezeSignals({
|
||||
maxSwitchMs: report.maxSwitchMs,
|
||||
maxBatchWallMs: report.maxBatchWallMs ?? 0,
|
||||
statusProbeMs: report.statusProbeMs ?? 0,
|
||||
memoryProbeMs: report.memoryProbeMs,
|
||||
softMs: report.softMs,
|
||||
hardMs: report.hardMs
|
||||
})
|
||||
expect(evaluated.hardFreeze).toBe(report.hardFreeze)
|
||||
expect(evaluated.peakLatencyMs).toBeGreaterThanOrEqual(5000)
|
||||
expect(typeof report.environment).toBe('string')
|
||||
expect(report.environment.length).toBeGreaterThan(0)
|
||||
expect(report.switchTargets).toBeGreaterThan(50)
|
||||
expect(report.parallel).toBeGreaterThanOrEqual(8)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,358 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Live freeze repro against a running Orca desktop + paired remote runtime.
|
||||
*
|
||||
* Models bulk-open of remote sessions under multi-worktree load.
|
||||
*
|
||||
* Usage:
|
||||
* node config/scripts/live-remote-bulk-open-freeze-repro.mjs
|
||||
* ORCA_FREEZE_ENV=paired-remote ORCA_FREEZE_CREATE=12 ORCA_FREEZE_SWITCH_PASSES=5 \
|
||||
* ORCA_FREEZE_PARALLEL=8 node config/scripts/live-remote-bulk-open-freeze-repro.mjs
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdirSync, writeFileSync, copyFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { BoundedLiveFreezeHistory } from './live-freeze-bounded-history.mjs'
|
||||
import {
|
||||
applySwitchTargetCap,
|
||||
DEFAULT_HARD_MS,
|
||||
DEFAULT_SOFT_MS,
|
||||
evaluateFreezeSignals,
|
||||
extractTerminalHandle,
|
||||
readFreezeNumberEnv,
|
||||
shouldCapSwitchTargets,
|
||||
worktreeSelector
|
||||
} from './live-remote-bulk-open-freeze-metrics.mjs'
|
||||
import { createOrcaRpc } from './live-remote-freeze-rpc.mjs'
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..')
|
||||
const reportDir = path.join(root, 'test-results', 'freeze-repro')
|
||||
const envName = process.env.ORCA_FREEZE_ENV || 'paired-remote'
|
||||
const createCount = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_CREATE', 0))
|
||||
const switchPasses = Math.max(1, readFreezeNumberEnv('ORCA_FREEZE_SWITCH_PASSES', 3))
|
||||
const parallel = Math.max(1, readFreezeNumberEnv('ORCA_FREEZE_PARALLEL', 1))
|
||||
// 0 = no cap (use all live terminals). Only positive env values limit targets.
|
||||
const maxSwitchTargets = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_MAX_SWITCH_TARGETS', 0))
|
||||
const softMs = readFreezeNumberEnv('ORCA_FREEZE_SOFT_MS', DEFAULT_SOFT_MS)
|
||||
const hardMs = readFreezeNumberEnv('ORCA_FREEZE_HARD_MS', DEFAULT_HARD_MS)
|
||||
const createWorktreeSpan = Math.max(1, readFreezeNumberEnv('ORCA_FREEZE_CREATE_WT_SPAN', 16))
|
||||
const preFloodMs = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_PRE_FLOOD_MS', 3000))
|
||||
const scratchDir = process.env.ORCA_FREEZE_SCRATCH || ''
|
||||
|
||||
const { orcaJsonSync, orcaJsonAsync } = createOrcaRpc({ envName })
|
||||
|
||||
async function mapPool(items, concurrency, worker) {
|
||||
const results = Array.from({ length: items.length })
|
||||
let next = 0
|
||||
async function run() {
|
||||
while (next < items.length) {
|
||||
const index = next
|
||||
next += 1
|
||||
results[index] = await worker(items[index], index)
|
||||
}
|
||||
}
|
||||
const runners = Array.from({ length: Math.min(concurrency, items.length) }, () => run())
|
||||
await Promise.all(runners)
|
||||
return results
|
||||
}
|
||||
|
||||
function sampleOrcaIfPossible() {
|
||||
if (process.platform !== 'darwin') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const status = orcaJsonSync(['status'], { local: true }).result
|
||||
const pid = status?.app?.pid
|
||||
if (!pid) {
|
||||
return null
|
||||
}
|
||||
const out = path.join(reportDir, `orca-sample-${Date.now()}.txt`)
|
||||
const sampled = spawnSync('sample', [String(pid), '5', '-file', out], {
|
||||
timeout: 20_000,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
return sampled.status === 0 ? out : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function floodCommand(marker) {
|
||||
// Continuous 2KB frames @ ~8ms — agent-like remote output.
|
||||
const script =
|
||||
"const m=process.argv[1];process.stdout.write('READY:'+m+'\\n');let f=0;const c='A'.repeat(2048);setInterval(()=>{f++;process.stdout.write('BG:'+m+':'+f+':'+c+'\\n')},8);process.stdin.resume()"
|
||||
return `node -e ${JSON.stringify(script)} ${JSON.stringify(marker)}`
|
||||
}
|
||||
|
||||
async function main() {
|
||||
mkdirSync(reportDir, { recursive: true })
|
||||
const notes = []
|
||||
const timings = new BoundedLiveFreezeHistory(120)
|
||||
const amplificationSteps = []
|
||||
|
||||
console.log(
|
||||
`[live-freeze] env=${envName} create=${createCount} passes=${switchPasses} parallel=${parallel}`
|
||||
)
|
||||
|
||||
const status = orcaJsonSync(['status'])
|
||||
notes.push(
|
||||
`remote version=${status.result?.runtime?.appVersion} state=${status.result?.runtime?.state}`
|
||||
)
|
||||
const local = orcaJsonSync(['status'], { local: true })
|
||||
notes.push(`local version=${local.result?.runtime?.appVersion} pid=${local.result?.app?.pid}`)
|
||||
|
||||
const worktrees = orcaJsonSync(['worktree', 'list']).result
|
||||
const wtList = worktrees?.worktrees || worktrees?.items || worktrees || []
|
||||
if (!Array.isArray(wtList) || wtList.length === 0) {
|
||||
throw new Error(`No worktrees on environment ${envName}`)
|
||||
}
|
||||
notes.push(`remote worktrees=${wtList.length}`)
|
||||
amplificationSteps.push(`baseline worktrees=${wtList.length}`)
|
||||
|
||||
const targets = wtList.slice(0, Math.min(createWorktreeSpan, wtList.length))
|
||||
const created = []
|
||||
|
||||
// Parallel flood-terminal creates across many worktrees.
|
||||
if (createCount > 0) {
|
||||
amplificationSteps.push(`create=${createCount} parallel=${Math.min(parallel, createCount)}`)
|
||||
const createJobs = Array.from({ length: createCount }, (_, i) => i)
|
||||
await mapPool(createJobs, Math.min(parallel, createCount), async (i) => {
|
||||
const wt = targets[i % targets.length]
|
||||
const selector = worktreeSelector(wt)
|
||||
if (!selector) {
|
||||
notes.push(`create ${i} skipped: no selector`)
|
||||
return
|
||||
}
|
||||
const marker = `LIVE_BULK_${Date.now()}_${i}`
|
||||
try {
|
||||
const createdTerm = await orcaJsonAsync(
|
||||
[
|
||||
'terminal',
|
||||
'create',
|
||||
'--worktree',
|
||||
selector,
|
||||
'--title',
|
||||
`freeze-repro-${i}`,
|
||||
'--command',
|
||||
floodCommand(marker)
|
||||
],
|
||||
{ timeoutMs: 180_000 }
|
||||
)
|
||||
timings.add({ op: 'terminal.create', ms: createdTerm.elapsedMs, ok: true, index: i })
|
||||
const handle = extractTerminalHandle(createdTerm.result)
|
||||
if (handle) {
|
||||
created.push({ handle, marker, worktree: selector })
|
||||
console.log(
|
||||
`[live-freeze] created ${handle} on ${selector} in ${createdTerm.elapsedMs.toFixed(0)}ms`
|
||||
)
|
||||
} else {
|
||||
notes.push(
|
||||
`create ${i} missing handle: ${JSON.stringify(createdTerm.result).slice(0, 400)}`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
timings.add({ op: 'terminal.create', ms: null, ok: false, error: String(error), index: i })
|
||||
notes.push(`create ${i} failed: ${String(error).slice(0, 300)}`)
|
||||
console.warn(`[live-freeze] create failed: ${String(error)}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (preFloodMs > 0 && created.length > 0) {
|
||||
amplificationSteps.push(`preFloodMs=${preFloodMs}`)
|
||||
await new Promise((r) => setTimeout(r, preFloodMs))
|
||||
}
|
||||
|
||||
let live = []
|
||||
try {
|
||||
const listed = orcaJsonSync(['terminal', 'list'])
|
||||
const terms = listed.result?.terminals || []
|
||||
live = terms
|
||||
.filter(
|
||||
(t) => typeof t.handle === 'string' && t.handle.startsWith('term_') && t.connected !== false
|
||||
)
|
||||
.map((t) => ({ handle: t.handle, title: t.title, worktreeId: t.worktreeId }))
|
||||
notes.push(`live terminals listed=${live.length}`)
|
||||
} catch (error) {
|
||||
notes.push(`terminal list failed: ${String(error).slice(0, 200)}`)
|
||||
}
|
||||
|
||||
let switchTargets = [...created.map((c) => c.handle), ...live.map((t) => t.handle)].filter(
|
||||
(v, i, a) => typeof v === 'string' && a.indexOf(v) === i
|
||||
)
|
||||
|
||||
if (shouldCapSwitchTargets(maxSwitchTargets) && switchTargets.length > maxSwitchTargets) {
|
||||
switchTargets = applySwitchTargetCap(switchTargets, maxSwitchTargets)
|
||||
amplificationSteps.push(`capped switchTargets=${maxSwitchTargets}`)
|
||||
}
|
||||
|
||||
if (switchTargets.length < 2) {
|
||||
throw new Error(
|
||||
`Need ≥2 terminals to bulk-switch; got ${switchTargets.length}. notes=${notes.join('; ')}`
|
||||
)
|
||||
}
|
||||
|
||||
amplificationSteps.push(
|
||||
`switchTargets=${switchTargets.length} passes=${switchPasses} parallel=${parallel}`
|
||||
)
|
||||
console.log(
|
||||
`[live-freeze] bulk-switching ${switchTargets.length} terminals × ${switchPasses} passes (parallel=${parallel})`
|
||||
)
|
||||
|
||||
let maxSwitchMs = 0
|
||||
let maxBatchWallMs = 0
|
||||
let sumSwitchMs = 0
|
||||
let switchCount = 0
|
||||
const switchStarted = performance.now()
|
||||
|
||||
for (let pass = 0; pass < switchPasses; pass += 1) {
|
||||
// Chunk targets into concurrent batches — piles load onto client/UI path.
|
||||
for (let offset = 0; offset < switchTargets.length; offset += parallel) {
|
||||
const batch = switchTargets.slice(offset, offset + parallel)
|
||||
const batchStarted = performance.now()
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (handle) => {
|
||||
try {
|
||||
const sw = await orcaJsonAsync(['terminal', 'switch', '--terminal', handle], {
|
||||
timeoutMs: 90_000
|
||||
})
|
||||
return { handle, ms: sw.elapsedMs, ok: true }
|
||||
} catch (error) {
|
||||
return { handle, error: String(error), ok: false }
|
||||
}
|
||||
})
|
||||
)
|
||||
const batchWall = performance.now() - batchStarted
|
||||
maxBatchWallMs = Math.max(maxBatchWallMs, batchWall)
|
||||
for (const item of batchResults) {
|
||||
if (item.ok) {
|
||||
maxSwitchMs = Math.max(maxSwitchMs, item.ms)
|
||||
sumSwitchMs += item.ms
|
||||
switchCount += 1
|
||||
timings.add({ op: 'terminal.switch', handle: item.handle, ms: item.ms, batchWall })
|
||||
if (item.ms >= softMs) {
|
||||
console.warn(`[live-freeze] SOFT lag on switch ${item.handle}: ${item.ms.toFixed(0)}ms`)
|
||||
}
|
||||
if (item.ms >= hardMs) {
|
||||
console.warn(`[live-freeze] HARD lag on switch ${item.handle}: ${item.ms.toFixed(0)}ms`)
|
||||
}
|
||||
} else {
|
||||
timings.add({ op: 'terminal.switch', handle: item.handle, error: item.error })
|
||||
notes.push(`switch ${item.handle} failed: ${String(item.error).slice(0, 200)}`)
|
||||
}
|
||||
}
|
||||
if (batchWall >= softMs) {
|
||||
console.warn(`[live-freeze] SOFT batch wall=${batchWall.toFixed(0)}ms size=${batch.length}`)
|
||||
}
|
||||
if (batchWall >= hardMs) {
|
||||
console.warn(`[live-freeze] HARD batch wall=${batchWall.toFixed(0)}ms size=${batch.length}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bulkWallMs = performance.now() - switchStarted
|
||||
const avgSwitchMs = switchCount ? sumSwitchMs / switchCount : 0
|
||||
|
||||
const statusProbe = orcaJsonSync(['status'], { local: true })
|
||||
let memoryProbeMs = null
|
||||
try {
|
||||
const mem = orcaJsonSync(['diagnostics', 'memory'], { local: true, timeoutMs: 120_000 })
|
||||
memoryProbeMs = mem.elapsedMs
|
||||
notes.push(`memory diagnostic ms=${mem.elapsedMs.toFixed(0)}`)
|
||||
} catch (error) {
|
||||
notes.push(`memory diagnostic failed: ${String(error).slice(0, 200)}`)
|
||||
}
|
||||
|
||||
const { peakLatencyMs, softFreeze, hardFreeze } = evaluateFreezeSignals({
|
||||
maxSwitchMs,
|
||||
maxBatchWallMs,
|
||||
statusProbeMs: statusProbe.elapsedMs,
|
||||
memoryProbeMs,
|
||||
softMs,
|
||||
hardMs
|
||||
})
|
||||
|
||||
let samplePath = null
|
||||
if (softFreeze || hardFreeze) {
|
||||
samplePath = sampleOrcaIfPossible()
|
||||
if (samplePath) {
|
||||
notes.push(`sample=${samplePath}`)
|
||||
} else {
|
||||
notes.push('sample unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
const report = {
|
||||
topology: 'live-paired-remote',
|
||||
environment: envName,
|
||||
localVersion: local.result?.runtime?.appVersion,
|
||||
remoteVersion: status.result?.runtime?.appVersion,
|
||||
remoteWorktreeCount: wtList.length,
|
||||
createdTerminals: created.length,
|
||||
switchTargets: switchTargets.length,
|
||||
switchPasses,
|
||||
parallel,
|
||||
maxSwitchMs,
|
||||
maxBatchWallMs,
|
||||
peakLatencyMs,
|
||||
avgSwitchMs,
|
||||
bulkWallMs,
|
||||
statusProbeMs: statusProbe.elapsedMs,
|
||||
memoryProbeMs,
|
||||
softFreeze,
|
||||
hardFreeze,
|
||||
softMs,
|
||||
hardMs,
|
||||
amplificationSteps,
|
||||
notes,
|
||||
timingCount: timings.totalCount,
|
||||
timings: timings.values()
|
||||
}
|
||||
|
||||
const outPath = path.join(reportDir, `live-bulk-open-freeze-${envName}.json`)
|
||||
writeFileSync(outPath, `${JSON.stringify(report, null, 2)}\n`)
|
||||
// Also write a stamped peak report so amplification runs don't overwrite history.
|
||||
const stamped = path.join(reportDir, `live-bulk-open-freeze-${envName}-peak-${Date.now()}.json`)
|
||||
writeFileSync(stamped, `${JSON.stringify(report, null, 2)}\n`)
|
||||
console.log(`[live-freeze] report ${outPath}`)
|
||||
console.log(`[live-freeze] stamped ${stamped}`)
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
|
||||
if (scratchDir) {
|
||||
try {
|
||||
mkdirSync(scratchDir, { recursive: true })
|
||||
copyFileSync(outPath, path.join(scratchDir, 'live-bulk-open-freeze-report.json'))
|
||||
writeFileSync(
|
||||
path.join(scratchDir, 'live-freeze-amplify-summary.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
peakLatencyMs,
|
||||
hardFreeze,
|
||||
softFreeze,
|
||||
amplificationSteps,
|
||||
stamped
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
)
|
||||
} catch (error) {
|
||||
notes.push(`scratch copy failed: ${String(error).slice(0, 200)}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (hardFreeze) {
|
||||
process.exitCode = 2
|
||||
console.error('[live-freeze] HARD FREEZE SIGNAL')
|
||||
} else if (softFreeze) {
|
||||
process.exitCode = 1
|
||||
console.error('[live-freeze] SOFT FREEZE SIGNAL')
|
||||
} else {
|
||||
console.log('[live-freeze] no freeze signal under thresholds')
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[live-freeze] failed', error)
|
||||
process.exit(3)
|
||||
})
|
||||
@@ -0,0 +1,241 @@
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
|
||||
export const MAX_ORCA_RPC_OUTPUT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
export function appendOrcaRpcOutput(output, chunk, bytes, limit = MAX_ORCA_RPC_OUTPUT_BYTES) {
|
||||
const nextBytes = bytes + Buffer.byteLength(chunk)
|
||||
return {
|
||||
output: nextBytes > limit ? output : output + chunk,
|
||||
bytes: nextBytes,
|
||||
exceeded: nextBytes > limit
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveOrcaCliCommand({ env = process.env, platform = process.platform } = {}) {
|
||||
if (env.ORCA_CLI_COMMAND?.trim()) {
|
||||
return env.ORCA_CLI_COMMAND.trim()
|
||||
}
|
||||
if (env.ORCA_DEV_REPO_ROOT) {
|
||||
return 'orca-dev'
|
||||
}
|
||||
return platform === 'linux' ? 'orca-ide' : 'orca'
|
||||
}
|
||||
|
||||
export function resolveOrcaCliInvocation({
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
nodeExecutable = process.execPath
|
||||
} = {}) {
|
||||
const command = resolveOrcaCliCommand({ env, platform })
|
||||
const commandName = platform === 'win32' ? path.win32.basename(command).toLowerCase() : command
|
||||
if (
|
||||
platform === 'win32' &&
|
||||
env.ORCA_DEV_REPO_ROOT &&
|
||||
(commandName === 'orca-dev' || commandName === 'orca-dev.cmd')
|
||||
) {
|
||||
const defaultUserDataPath = path.win32.join(
|
||||
env.APPDATA ?? path.win32.join(env.USERPROFILE ?? '', 'AppData', 'Roaming'),
|
||||
'orca-dev'
|
||||
)
|
||||
return {
|
||||
command: nodeExecutable,
|
||||
prefixArgs: [path.win32.join(env.ORCA_DEV_REPO_ROOT, 'out', 'cli', 'index.js')],
|
||||
env: {
|
||||
...env,
|
||||
ORCA_USER_DATA_PATH:
|
||||
env.ORCA_USER_DATA_PATH ?? env.ORCA_DEV_USER_DATA_PATH ?? defaultUserDataPath,
|
||||
ORCA_DEV_CLI_INVOCATION: '1',
|
||||
ORCA_APP_EXECUTABLE:
|
||||
env.ORCA_APP_EXECUTABLE ??
|
||||
path.win32.join(
|
||||
env.ORCA_DEV_REPO_ROOT,
|
||||
'node_modules',
|
||||
'electron',
|
||||
'dist',
|
||||
'electron.exe'
|
||||
),
|
||||
ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT: '1'
|
||||
}
|
||||
}
|
||||
}
|
||||
return { command, prefixArgs: [] }
|
||||
}
|
||||
|
||||
export function createOrcaRpc({
|
||||
envName,
|
||||
cliCommand,
|
||||
env = process.env,
|
||||
platform = process.platform
|
||||
}) {
|
||||
const cliInvocation = cliCommand
|
||||
? { command: cliCommand, prefixArgs: [] }
|
||||
: resolveOrcaCliInvocation({ env, platform })
|
||||
const commandLabel = cliCommand ?? resolveOrcaCliCommand({ env, platform })
|
||||
const commandArgs = (args, local) => [
|
||||
...cliInvocation.prefixArgs,
|
||||
...args,
|
||||
...(local ? [] : ['--environment', envName]),
|
||||
'--json'
|
||||
]
|
||||
|
||||
function orcaJsonSync(args, opts = {}) {
|
||||
const started = performance.now()
|
||||
const result = spawnSync(cliInvocation.command, commandArgs(args, opts.local), {
|
||||
encoding: 'utf8',
|
||||
env: cliInvocation.env,
|
||||
maxBuffer: MAX_ORCA_RPC_OUTPUT_BYTES,
|
||||
timeout: opts.timeoutMs ?? 120_000
|
||||
})
|
||||
const elapsedMs = performance.now() - started
|
||||
if (result.error) {
|
||||
throw new Error(`${commandLabel} ${args.join(' ')} failed to start: ${String(result.error)}`)
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`${commandLabel} ${args.join(' ')} failed (${result.status}): ${result.stderr || result.stdout}`
|
||||
)
|
||||
}
|
||||
const parsed = JSON.parse(result.stdout)
|
||||
if (parsed.ok === false) {
|
||||
throw new Error(`${commandLabel} ${args.join(' ')} ok=false: ${JSON.stringify(parsed)}`)
|
||||
}
|
||||
return { parsed, elapsedMs, result: parsed.result }
|
||||
}
|
||||
|
||||
function orcaJsonAsync(args, opts = {}) {
|
||||
const started = performance.now()
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cliInvocation.command, commandArgs(args, opts.local), {
|
||||
env: cliInvocation.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let outputBytes = 0
|
||||
let settled = false
|
||||
let timer
|
||||
const fail = (error) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
}
|
||||
const append = (stream, chunk) => {
|
||||
if (settled) {
|
||||
return stream
|
||||
}
|
||||
const appended = appendOrcaRpcOutput(stream, chunk, outputBytes)
|
||||
outputBytes = appended.bytes
|
||||
if (appended.exceeded) {
|
||||
child.kill('SIGKILL')
|
||||
fail(new Error(`${commandLabel} ${args.join(' ')} exceeded 20 MiB output limit`))
|
||||
return stream
|
||||
}
|
||||
return appended.output
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
fail(
|
||||
new Error(
|
||||
`${commandLabel} ${args.join(' ')} timed out after ${opts.timeoutMs ?? 120_000}ms`
|
||||
)
|
||||
)
|
||||
}, opts.timeoutMs ?? 120_000)
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout = append(stdout, chunk)
|
||||
})
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr = append(stderr, chunk)
|
||||
})
|
||||
child.on('error', fail)
|
||||
child.on('close', (code) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
clearTimeout(timer)
|
||||
const elapsedMs = performance.now() - started
|
||||
if (code !== 0) {
|
||||
fail(
|
||||
new Error(
|
||||
`${commandLabel} ${args.join(' ')} failed (${code}): ${stderr || stdout}`.slice(
|
||||
0,
|
||||
800
|
||||
)
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(stdout)
|
||||
if (parsed.ok === false) {
|
||||
fail(
|
||||
new Error(
|
||||
`${commandLabel} ${args.join(' ')} ok=false: ${JSON.stringify(parsed)}`.slice(
|
||||
0,
|
||||
800
|
||||
)
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
resolve({ parsed, elapsedMs, result: parsed.result })
|
||||
} catch (error) {
|
||||
fail(
|
||||
new Error(
|
||||
`${commandLabel} parse failed: ${String(error)}; stdout=${stdout.slice(0, 400)}`
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function runReconnectRefreshStorm(notes) {
|
||||
const started = performance.now()
|
||||
const jobs = [
|
||||
() => orcaJsonAsync(['status'], { timeoutMs: 90_000 }),
|
||||
() => orcaJsonAsync(['worktree', 'list'], { timeoutMs: 120_000 }),
|
||||
() => orcaJsonAsync(['terminal', 'list'], { timeoutMs: 120_000 }),
|
||||
() => orcaJsonAsync(['status'], { local: true, timeoutMs: 60_000 }),
|
||||
() => orcaJsonAsync(['worktree', 'list'], { timeoutMs: 120_000 }),
|
||||
() => orcaJsonAsync(['terminal', 'list'], { timeoutMs: 120_000 })
|
||||
]
|
||||
const results = await Promise.all(
|
||||
jobs.map(async (job, index) => {
|
||||
try {
|
||||
const result = await job()
|
||||
return { index, ok: true, ms: result.elapsedMs }
|
||||
} catch (error) {
|
||||
notes.push(`reconnect-refresh job ${index} failed: ${String(error).slice(0, 200)}`)
|
||||
return { index, ok: false, ms: null, error: String(error) }
|
||||
}
|
||||
})
|
||||
)
|
||||
const wallMs = performance.now() - started
|
||||
const maxJobMs = Math.max(0, ...results.map((result) => result.ms || 0))
|
||||
notes.push(
|
||||
`reconnect-refresh wall=${wallMs.toFixed(0)}ms maxJob=${maxJobMs.toFixed(0)}ms ok=${results.filter((result) => result.ok).length}/${results.length}`
|
||||
)
|
||||
return { wallMs, maxJobMs, results }
|
||||
}
|
||||
|
||||
async function runRestartProxy(notes) {
|
||||
const started = performance.now()
|
||||
try {
|
||||
const opened = await orcaJsonAsync(['open'], { local: true, timeoutMs: 120_000 })
|
||||
notes.push(`orca open ms=${opened.elapsedMs.toFixed(0)}`)
|
||||
} catch (error) {
|
||||
notes.push(`orca open failed: ${String(error).slice(0, 200)}`)
|
||||
}
|
||||
const storm = await runReconnectRefreshStorm(notes)
|
||||
return { wallMs: performance.now() - started, storm }
|
||||
}
|
||||
|
||||
return { orcaJsonSync, orcaJsonAsync, runReconnectRefreshStorm, runRestartProxy }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendOrcaRpcOutput,
|
||||
resolveOrcaCliCommand,
|
||||
resolveOrcaCliInvocation
|
||||
} from './live-remote-freeze-rpc.mjs'
|
||||
|
||||
describe('live remote freeze RPC', () => {
|
||||
it('resolves the Orca CLI for managed, dev, Linux, and default runtimes', () => {
|
||||
expect(resolveOrcaCliCommand({ env: { ORCA_CLI_COMMAND: 'custom-orca' } })).toBe('custom-orca')
|
||||
expect(resolveOrcaCliCommand({ env: { ORCA_DEV_REPO_ROOT: '/repo' } })).toBe('orca-dev')
|
||||
expect(resolveOrcaCliCommand({ env: {}, platform: 'linux' })).toBe('orca-ide')
|
||||
expect(resolveOrcaCliCommand({ env: {}, platform: 'win32' })).toBe('orca')
|
||||
})
|
||||
|
||||
it('bypasses the Windows dev cmd shim with the built Node CLI', () => {
|
||||
const invocation = resolveOrcaCliInvocation({
|
||||
env: {
|
||||
APPDATA: 'C:\\Users\\dev\\AppData\\Roaming',
|
||||
ORCA_CLI_COMMAND: 'C:\\repo\\out\\bin\\orca-dev.cmd',
|
||||
ORCA_DEV_REPO_ROOT: 'C:\\repo'
|
||||
},
|
||||
platform: 'win32',
|
||||
nodeExecutable: 'C:\\Program Files\\nodejs\\node.exe'
|
||||
})
|
||||
|
||||
expect(invocation).toMatchObject({
|
||||
command: 'C:\\Program Files\\nodejs\\node.exe',
|
||||
prefixArgs: ['C:\\repo\\out\\cli\\index.js'],
|
||||
env: {
|
||||
ORCA_USER_DATA_PATH: 'C:\\Users\\dev\\AppData\\Roaming\\orca-dev',
|
||||
ORCA_DEV_CLI_INVOCATION: '1',
|
||||
ORCA_APP_EXECUTABLE: 'C:\\repo\\node_modules\\electron\\dist\\electron.exe',
|
||||
ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT: '1'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('caps combined asynchronous output before retaining the overflow chunk', () => {
|
||||
const first = appendOrcaRpcOutput('', '1234', 0, 5)
|
||||
expect(first).toEqual({ output: '1234', bytes: 4, exceeded: false })
|
||||
|
||||
const overflow = appendOrcaRpcOutput(first.output, '67', first.bytes, 5)
|
||||
expect(overflow).toEqual({ output: '1234', bytes: 6, exceeded: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,645 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Naturalistic freeze repro — idle/reconnect recovery stories on large remotes.
|
||||
*
|
||||
* Unlike the bulk parallel-switch amplifier, this models:
|
||||
* 1) agents streaming on remote while user is idle (backlog builds)
|
||||
* 2) user returns and opens sessions one-by-one (or after reconnect refresh)
|
||||
*
|
||||
* Scenarios:
|
||||
* idle-backlog-open — idle with flood, then human-paced sequential open
|
||||
* idle-backlog-reconnect-open — same + wake-like metadata refresh storm, then open
|
||||
* restart-proxy — idle, then orca open + status/list storm + open
|
||||
* (does NOT kill the desktop; proxies restore work)
|
||||
*
|
||||
* Usage:
|
||||
* ORCA_FREEZE_ENV=paired-remote ORCA_FREEZE_SCENARIO=idle-backlog-open \
|
||||
* node config/scripts/live-remote-realistic-freeze-repro.mjs
|
||||
*
|
||||
* pnpm run repro:live-remote-realistic-freeze
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { createOrcaRpc } from './live-remote-freeze-rpc.mjs'
|
||||
import { startStatusWatchdog } from './live-remote-status-watchdog.mjs'
|
||||
import { BoundedLiveFreezeHistory } from './live-freeze-bounded-history.mjs'
|
||||
import {
|
||||
DEFAULT_FOREVER_WINDOW_MS,
|
||||
DEFAULT_HARD_MS,
|
||||
DEFAULT_SOFT_MS,
|
||||
DEFAULT_STATUS_SLOW_MS,
|
||||
evaluateFullAppFreeze,
|
||||
evaluatePermanentLockup,
|
||||
evaluateRealisticFreezeSignals,
|
||||
extractTerminalHandle,
|
||||
humanPaceDelayMs,
|
||||
readFreezeNumberEnv,
|
||||
REALISTIC_SCENARIOS,
|
||||
worktreeSelector
|
||||
} from './live-remote-bulk-open-freeze-metrics.mjs'
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..')
|
||||
const reportDir = path.join(root, 'test-results', 'freeze-repro')
|
||||
const envName = process.env.ORCA_FREEZE_ENV || 'paired-remote'
|
||||
const scenario = process.env.ORCA_FREEZE_SCENARIO || 'idle-backlog-open'
|
||||
const createCount = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_CREATE', 0))
|
||||
const openCount = Math.max(2, readFreezeNumberEnv('ORCA_FREEZE_OPEN_COUNT', 20))
|
||||
const idleMs = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_IDLE_MS', 45_000))
|
||||
const paceMs = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_PACE_MS', 250))
|
||||
const paceJitterMs = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_PACE_JITTER_MS', 150))
|
||||
const createWorktreeSpan = Math.max(1, readFreezeNumberEnv('ORCA_FREEZE_CREATE_WT_SPAN', 12))
|
||||
const softMs = readFreezeNumberEnv('ORCA_FREEZE_SOFT_MS', DEFAULT_SOFT_MS)
|
||||
const hardMs = readFreezeNumberEnv('ORCA_FREEZE_HARD_MS', DEFAULT_HARD_MS)
|
||||
/** Concurrent opens during lockup-storm (wake refresh overlaps fan-out). */
|
||||
const stormParallel = Math.max(1, readFreezeNumberEnv('ORCA_FREEZE_STORM_PARALLEL', 16))
|
||||
/** Kill a switch if it exceeds this — counts toward permanent lockup. */
|
||||
const opTimeoutMs = Math.max(10_000, readFreezeNumberEnv('ORCA_FREEZE_OP_TIMEOUT_MS', 60_000))
|
||||
const permanentTimeoutMs = Math.max(15_000, readFreezeNumberEnv('ORCA_FREEZE_PERMANENT_MS', 60_000))
|
||||
const foreverWindowMs = Math.max(
|
||||
10_000,
|
||||
readFreezeNumberEnv('ORCA_FREEZE_FOREVER_WINDOW_MS', DEFAULT_FOREVER_WINDOW_MS)
|
||||
)
|
||||
const statusSlowMs = Math.max(
|
||||
5_000,
|
||||
readFreezeNumberEnv('ORCA_FREEZE_STATUS_SLOW_MS', DEFAULT_STATUS_SLOW_MS)
|
||||
)
|
||||
const watchdogIntervalMs = Math.max(
|
||||
500,
|
||||
readFreezeNumberEnv('ORCA_FREEZE_WATCHDOG_INTERVAL_MS', 1500)
|
||||
)
|
||||
const scratchDir = process.env.ORCA_FREEZE_SCRATCH || ''
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
const rpc = createOrcaRpc({ envName })
|
||||
const { orcaJsonSync, orcaJsonAsync, runReconnectRefreshStorm, runRestartProxy } = rpc
|
||||
|
||||
async function mapPool(items, concurrency, worker) {
|
||||
const results = Array.from({ length: items.length })
|
||||
let next = 0
|
||||
async function run() {
|
||||
while (next < items.length) {
|
||||
const index = next
|
||||
next += 1
|
||||
results[index] = await worker(items[index], index)
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(concurrency, Math.max(items.length, 1)) }, () => run())
|
||||
)
|
||||
return results
|
||||
}
|
||||
|
||||
function floodCommand(marker) {
|
||||
const script =
|
||||
"const m=process.argv[1];process.stdout.write('READY:'+m+'\n');let f=0;const c='A'.repeat(2048);setInterval(()=>{f++;process.stdout.write('BG:'+m+':'+f+':'+c+'\n')},8);process.stdin.resume()"
|
||||
return `node -e ${JSON.stringify(script)} ${JSON.stringify(marker)}`
|
||||
}
|
||||
|
||||
function sampleOrcaIfPossible() {
|
||||
if (process.platform !== 'darwin') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const status = orcaJsonSync(['status'], { local: true }).result
|
||||
const pid = status?.app?.pid
|
||||
if (!pid) {
|
||||
return null
|
||||
}
|
||||
const out = path.join(reportDir, `orca-sample-realistic-${Date.now()}.txt`)
|
||||
const sampled = spawnSync('sample', [String(pid), '5', '-file', out], {
|
||||
timeout: 20_000,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
return sampled.status === 0 ? out : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function listLiveTerminalHandles() {
|
||||
const listed = orcaJsonSync(['terminal', 'list'])
|
||||
const terms = listed.result?.terminals || []
|
||||
return terms
|
||||
.filter((t) => typeof t.handle === 'string' && t.handle.startsWith('term_'))
|
||||
.map((t) => ({
|
||||
handle: t.handle,
|
||||
title: t.title,
|
||||
worktreeId: t.worktreeId,
|
||||
connected: t.connected
|
||||
}))
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!REALISTIC_SCENARIOS.includes(scenario)) {
|
||||
throw new Error(
|
||||
`Unknown ORCA_FREEZE_SCENARIO=${scenario}. Expected one of: ${REALISTIC_SCENARIOS.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
mkdirSync(reportDir, { recursive: true })
|
||||
const notes = []
|
||||
const phases = []
|
||||
const openTimings = new BoundedLiveFreezeHistory(100)
|
||||
|
||||
console.log(
|
||||
`[realistic-freeze] scenario=${scenario} env=${envName} create=${createCount} idleMs=${idleMs} openCount=${openCount} paceMs=${paceMs}`
|
||||
)
|
||||
|
||||
const local = orcaJsonSync(['status'], { local: true })
|
||||
const remote = orcaJsonSync(['status'])
|
||||
notes.push(
|
||||
`local version=${local.result?.runtime?.appVersion} pid=${local.result?.app?.pid}`,
|
||||
`remote version=${remote.result?.runtime?.appVersion} state=${remote.result?.runtime?.state}`
|
||||
)
|
||||
|
||||
const worktrees = orcaJsonSync(['worktree', 'list']).result
|
||||
const wtList = worktrees?.worktrees || worktrees?.items || worktrees || []
|
||||
if (!Array.isArray(wtList) || wtList.length === 0) {
|
||||
throw new Error(`No worktrees on environment ${envName}`)
|
||||
}
|
||||
notes.push(`remote worktrees=${wtList.length}`)
|
||||
phases.push({ phase: 'baseline', worktrees: wtList.length })
|
||||
|
||||
// --- Phase: seed flood terminals (agent-like backlog sources) ---
|
||||
const created = []
|
||||
if (createCount > 0) {
|
||||
const targets = wtList.slice(0, Math.min(createWorktreeSpan, wtList.length))
|
||||
await mapPool(
|
||||
Array.from({ length: createCount }, (_, i) => i),
|
||||
Math.min(4, createCount),
|
||||
async (i) => {
|
||||
const wt = targets[i % targets.length]
|
||||
const selector = worktreeSelector(wt)
|
||||
if (!selector) {
|
||||
return
|
||||
}
|
||||
const marker = `REALISTIC_${Date.now()}_${i}`
|
||||
try {
|
||||
const createdTerm = await orcaJsonAsync(
|
||||
[
|
||||
'terminal',
|
||||
'create',
|
||||
'--worktree',
|
||||
selector,
|
||||
'--title',
|
||||
`realistic-freeze-${i}`,
|
||||
'--command',
|
||||
floodCommand(marker)
|
||||
],
|
||||
{ timeoutMs: 180_000 }
|
||||
)
|
||||
const handle = extractTerminalHandle(createdTerm.result)
|
||||
if (handle) {
|
||||
created.push({ handle, marker, worktree: selector })
|
||||
console.log(
|
||||
`[realistic-freeze] flood terminal ${handle} (${createdTerm.elapsedMs.toFixed(0)}ms)`
|
||||
)
|
||||
} else {
|
||||
notes.push(
|
||||
`create ${i} missing handle: ${JSON.stringify(createdTerm.result).slice(0, 300)}`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
notes.push(`create ${i} failed: ${String(error).slice(0, 250)}`)
|
||||
console.warn(`[realistic-freeze] create failed: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
)
|
||||
phases.push({ phase: 'seed-flood', created: created.length })
|
||||
}
|
||||
|
||||
// Prefer created floods for open pass; fill with existing live terminals.
|
||||
let live = []
|
||||
try {
|
||||
live = listLiveTerminalHandles()
|
||||
notes.push(`live terminals listed=${live.length}`)
|
||||
} catch (error) {
|
||||
notes.push(`terminal list failed: ${String(error).slice(0, 200)}`)
|
||||
}
|
||||
|
||||
const openTargets = [...created.map((c) => c.handle), ...live.map((t) => t.handle)].filter(
|
||||
(v, i, a) => typeof v === 'string' && a.indexOf(v) === i
|
||||
)
|
||||
|
||||
if (openTargets.length < 2) {
|
||||
throw new Error(`Need ≥2 terminals; got ${openTargets.length}. ${notes.join('; ')}`)
|
||||
}
|
||||
|
||||
const openList = openTargets.slice(0, Math.min(openCount, openTargets.length))
|
||||
|
||||
// --- Phase: park — leave one session focused, rest accumulate flood while "away" ---
|
||||
try {
|
||||
const parkHandle = openList[0]
|
||||
const parked = await orcaJsonAsync(['terminal', 'switch', '--terminal', parkHandle], {
|
||||
timeoutMs: 60_000
|
||||
})
|
||||
notes.push(`park switch ms=${parked.elapsedMs.toFixed(0)} handle=${parkHandle}`)
|
||||
} catch (error) {
|
||||
notes.push(`park switch failed: ${String(error).slice(0, 200)}`)
|
||||
}
|
||||
|
||||
console.log(`[realistic-freeze] idle ${idleMs}ms while remotes stream (user away / asleep)`)
|
||||
const idleStarted = performance.now()
|
||||
await sleep(idleMs)
|
||||
phases.push({ phase: 'idle', idleMs, actualMs: performance.now() - idleStarted })
|
||||
|
||||
// --- Phase: recovery trigger ---
|
||||
let reconnectRefreshMs = 0
|
||||
let timedOutOps = 0
|
||||
let consecutiveSwitchFailures = 0
|
||||
let maxConsecutiveSwitchFailures = 0
|
||||
|
||||
if (scenario === 'idle-backlog-reconnect-open' || scenario === 'lockup-storm') {
|
||||
console.log(
|
||||
'[realistic-freeze] wake/reconnect proxy: parallel status/worktree/terminal refresh'
|
||||
)
|
||||
const storm = await runReconnectRefreshStorm(notes)
|
||||
reconnectRefreshMs = Math.max(storm.wallMs, storm.maxJobMs)
|
||||
phases.push({
|
||||
phase: 'reconnect-refresh',
|
||||
wallMs: storm.wallMs,
|
||||
maxJobMs: storm.maxJobMs
|
||||
})
|
||||
} else if (scenario === 'restart-proxy') {
|
||||
console.log('[realistic-freeze] restart proxy: orca open + refresh storm (no process kill)')
|
||||
const restart = await runRestartProxy(notes)
|
||||
reconnectRefreshMs = Math.max(restart.wallMs, restart.storm.wallMs, restart.storm.maxJobMs)
|
||||
phases.push({
|
||||
phase: 'restart-proxy',
|
||||
wallMs: restart.wallMs,
|
||||
reconnectWallMs: restart.storm.wallMs
|
||||
})
|
||||
}
|
||||
|
||||
// --- Phase: open sessions ---
|
||||
// lockup-storm: overlap a second reconnect storm with concurrent switch fan-out
|
||||
// (models wake + bulk session restore, not human serial clicks).
|
||||
let maxOpenMs = 0
|
||||
let firstOpenMs = 0
|
||||
let sumOpenMs = 0
|
||||
let openOk = 0
|
||||
let maxBatchWallMs = 0
|
||||
const openStarted = performance.now()
|
||||
|
||||
let statusWatch = null
|
||||
if (scenario === 'lockup-storm') {
|
||||
console.log(
|
||||
`[realistic-freeze] LOCKUP STORM: concurrent open parallel=${stormParallel} + overlapping reconnect refresh (timeout=${opTimeoutMs}ms); mid-storm status watchdog every ${watchdogIntervalMs}ms`
|
||||
)
|
||||
statusWatch = startStatusWatchdog({
|
||||
intervalMs: watchdogIntervalMs,
|
||||
timeoutMs: Math.min(permanentTimeoutMs, foreverWindowMs),
|
||||
statusSlowMs
|
||||
})
|
||||
// Fire reconnect storm again concurrently with first open wave.
|
||||
const overlapStormPromise = runReconnectRefreshStorm(notes)
|
||||
for (let offset = 0; offset < openList.length; offset += stormParallel) {
|
||||
const batch = openList.slice(offset, offset + stormParallel)
|
||||
const batchStarted = performance.now()
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (handle, batchIndex) => {
|
||||
const index = offset + batchIndex
|
||||
try {
|
||||
const sw = await orcaJsonAsync(['terminal', 'switch', '--terminal', handle], {
|
||||
timeoutMs: opTimeoutMs
|
||||
})
|
||||
return { handle, index, ms: sw.elapsedMs, ok: true, timedOut: false }
|
||||
} catch (error) {
|
||||
const msg = String(error)
|
||||
const timedOut = /timed out/i.test(msg)
|
||||
return { handle, index, error: msg, ok: false, timedOut }
|
||||
}
|
||||
})
|
||||
)
|
||||
const batchWall = performance.now() - batchStarted
|
||||
maxBatchWallMs = Math.max(maxBatchWallMs, batchWall)
|
||||
for (const item of batchResults) {
|
||||
if (item.ok) {
|
||||
openOk += 1
|
||||
sumOpenMs += item.ms
|
||||
maxOpenMs = Math.max(maxOpenMs, item.ms)
|
||||
if (item.index === 0 || firstOpenMs === 0) {
|
||||
firstOpenMs = item.ms
|
||||
}
|
||||
consecutiveSwitchFailures = 0
|
||||
openTimings.add({
|
||||
handle: item.handle,
|
||||
ms: item.ms,
|
||||
index: item.index,
|
||||
batchWall
|
||||
})
|
||||
if (item.ms >= hardMs) {
|
||||
console.warn(
|
||||
`[realistic-freeze] HARD open #${item.index} ${item.handle}: ${item.ms.toFixed(0)}ms`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (item.timedOut) {
|
||||
timedOutOps += 1
|
||||
}
|
||||
consecutiveSwitchFailures += 1
|
||||
maxConsecutiveSwitchFailures = Math.max(
|
||||
maxConsecutiveSwitchFailures,
|
||||
consecutiveSwitchFailures
|
||||
)
|
||||
openTimings.add({
|
||||
handle: item.handle,
|
||||
error: item.error,
|
||||
index: item.index,
|
||||
timedOut: item.timedOut
|
||||
})
|
||||
notes.push(
|
||||
`open ${item.handle} failed${item.timedOut ? ' (TIMEOUT)' : ''}: ${String(item.error).slice(0, 160)}`
|
||||
)
|
||||
console.warn(
|
||||
`[realistic-freeze] open FAIL #${item.index}${item.timedOut ? ' TIMEOUT' : ''}: ${item.handle}`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (batchWall >= hardMs) {
|
||||
console.warn(
|
||||
`[realistic-freeze] HARD batch wall=${batchWall.toFixed(0)}ms size=${batch.length}`
|
||||
)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const overlap = await overlapStormPromise
|
||||
reconnectRefreshMs = Math.max(reconnectRefreshMs, overlap.wallMs, overlap.maxJobMs)
|
||||
phases.push({
|
||||
phase: 'overlap-reconnect-refresh',
|
||||
wallMs: overlap.wallMs,
|
||||
maxJobMs: overlap.maxJobMs
|
||||
})
|
||||
} catch (error) {
|
||||
notes.push(`overlap reconnect failed: ${String(error).slice(0, 200)}`)
|
||||
}
|
||||
phases.push({
|
||||
phase: 'lockup-storm-open',
|
||||
count: openList.length,
|
||||
ok: openOk,
|
||||
maxOpenMs,
|
||||
firstOpenMs,
|
||||
maxBatchWallMs,
|
||||
timedOutOps,
|
||||
parallel: stormParallel
|
||||
})
|
||||
} else {
|
||||
console.log(
|
||||
`[realistic-freeze] human-paced open of ${openList.length} sessions (pace≈${paceMs}ms + jitter)`
|
||||
)
|
||||
for (let i = 0; i < openList.length; i += 1) {
|
||||
const handle = openList[i]
|
||||
try {
|
||||
const sw = await orcaJsonAsync(['terminal', 'switch', '--terminal', handle], {
|
||||
timeoutMs: opTimeoutMs
|
||||
})
|
||||
openOk += 1
|
||||
sumOpenMs += sw.elapsedMs
|
||||
maxOpenMs = Math.max(maxOpenMs, sw.elapsedMs)
|
||||
if (i === 0) {
|
||||
firstOpenMs = sw.elapsedMs
|
||||
}
|
||||
consecutiveSwitchFailures = 0
|
||||
openTimings.add({ handle, ms: sw.elapsedMs, index: i })
|
||||
if (sw.elapsedMs >= softMs) {
|
||||
console.warn(`[realistic-freeze] SOFT open #${i} ${handle}: ${sw.elapsedMs.toFixed(0)}ms`)
|
||||
}
|
||||
if (sw.elapsedMs >= hardMs) {
|
||||
console.warn(`[realistic-freeze] HARD open #${i} ${handle}: ${sw.elapsedMs.toFixed(0)}ms`)
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = String(error)
|
||||
const timedOut = /timed out/i.test(msg)
|
||||
if (timedOut) {
|
||||
timedOutOps += 1
|
||||
}
|
||||
consecutiveSwitchFailures += 1
|
||||
maxConsecutiveSwitchFailures = Math.max(
|
||||
maxConsecutiveSwitchFailures,
|
||||
consecutiveSwitchFailures
|
||||
)
|
||||
openTimings.add({ handle, error: msg, index: i, timedOut })
|
||||
notes.push(`open ${handle} failed${timedOut ? ' (TIMEOUT)' : ''}: ${msg.slice(0, 200)}`)
|
||||
}
|
||||
if (i < openList.length - 1) {
|
||||
await sleep(humanPaceDelayMs(paceMs, paceJitterMs))
|
||||
}
|
||||
}
|
||||
phases.push({
|
||||
phase: 'human-paced-open',
|
||||
count: openList.length,
|
||||
ok: openOk,
|
||||
maxOpenMs,
|
||||
firstOpenMs,
|
||||
openWallMs: performance.now() - openStarted
|
||||
})
|
||||
}
|
||||
|
||||
const openWallMs = performance.now() - openStarted
|
||||
|
||||
let midStormWatch = {
|
||||
samples: [],
|
||||
durationMs: 0,
|
||||
sampleCount: 0,
|
||||
maxStatusMs: 0,
|
||||
unhealthySampleCount: 0,
|
||||
infrastructureErrorCount: 0,
|
||||
longestUnhealthyWindowMs: 0
|
||||
}
|
||||
if (statusWatch) {
|
||||
midStormWatch = await statusWatch.stop()
|
||||
notes.push(
|
||||
`mid-storm status samples=${midStormWatch.sampleCount} durationMs=${midStormWatch.durationMs.toFixed(0)}`
|
||||
)
|
||||
phases.push({
|
||||
phase: 'mid-storm-status-watchdog',
|
||||
samples: midStormWatch.sampleCount,
|
||||
durationMs: midStormWatch.durationMs,
|
||||
maxStatusMs: midStormWatch.maxStatusMs
|
||||
})
|
||||
}
|
||||
|
||||
// Post-storm health: does local status still answer?
|
||||
let statusProbeMs = null
|
||||
let statusHangMs = 0
|
||||
const statusStarted = performance.now()
|
||||
try {
|
||||
const statusProbe = await orcaJsonAsync(['status'], {
|
||||
local: true,
|
||||
timeoutMs: permanentTimeoutMs
|
||||
})
|
||||
statusProbeMs = statusProbe.elapsedMs
|
||||
} catch (error) {
|
||||
statusHangMs = performance.now() - statusStarted
|
||||
notes.push(
|
||||
`status probe FAILED after ${statusHangMs.toFixed(0)}ms: ${String(error).slice(0, 200)}`
|
||||
)
|
||||
console.error(`[realistic-freeze] status probe failed — possible permanent lockup`)
|
||||
}
|
||||
|
||||
let memoryProbeMs = null
|
||||
try {
|
||||
const mem = await orcaJsonAsync(['diagnostics', 'memory'], {
|
||||
local: true,
|
||||
timeoutMs: permanentTimeoutMs
|
||||
})
|
||||
memoryProbeMs = mem.elapsedMs
|
||||
notes.push(`memory diagnostic ms=${mem.elapsedMs.toFixed(0)}`)
|
||||
} catch (error) {
|
||||
notes.push(`memory diagnostic failed: ${String(error).slice(0, 200)}`)
|
||||
}
|
||||
|
||||
const peakForSignals = Math.max(maxOpenMs, firstOpenMs, maxBatchWallMs)
|
||||
const signals = evaluateRealisticFreezeSignals({
|
||||
maxOpenMs: peakForSignals,
|
||||
firstOpenMs,
|
||||
reconnectRefreshMs,
|
||||
statusProbeMs: statusProbeMs ?? 0,
|
||||
memoryProbeMs,
|
||||
softMs,
|
||||
hardMs
|
||||
})
|
||||
|
||||
const lockup = evaluatePermanentLockup({
|
||||
timedOutOps,
|
||||
statusHangMs,
|
||||
consecutiveSwitchFailures: maxConsecutiveSwitchFailures,
|
||||
openFailed: openList.length - openOk,
|
||||
openTotal: openList.length,
|
||||
permanentTimeoutMs
|
||||
})
|
||||
|
||||
const fullApp = evaluateFullAppFreeze({
|
||||
statusSamples: midStormWatch.samples,
|
||||
statusSummary: midStormWatch,
|
||||
foreverWindowMs,
|
||||
statusSlowMs
|
||||
})
|
||||
const watchdogInfrastructureErrorCount = midStormWatch.infrastructureErrorCount
|
||||
if (statusHangMs >= foreverWindowMs) {
|
||||
fullApp.foreverUiLockupObserved = true
|
||||
fullApp.longestUnhealthyWindowMs = Math.max(fullApp.longestUnhealthyWindowMs, statusHangMs)
|
||||
fullApp.reason = `post-storm status hang ${statusHangMs.toFixed(0)}ms`
|
||||
}
|
||||
|
||||
const recoveredHardStall = signals.hardFreeze && !fullApp.foreverUiLockupObserved && openOk > 0
|
||||
|
||||
let samplePath = null
|
||||
if (signals.softFreeze || signals.hardFreeze || fullApp.foreverUiLockupObserved) {
|
||||
samplePath = sampleOrcaIfPossible()
|
||||
if (samplePath) {
|
||||
notes.push(`sample=${samplePath}`)
|
||||
} else {
|
||||
notes.push('sample unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
const storyByScenario = {
|
||||
'idle-backlog-open': 'User away while remotes stream; returns and opens sessions one-by-one.',
|
||||
'idle-backlog-reconnect-open':
|
||||
'User away; wake-like reconnect metadata storm; then opens sessions.',
|
||||
'restart-proxy': 'User away; restart-proxy discovery; then opens sessions.',
|
||||
'lockup-storm':
|
||||
'Idle flood + reconnect refresh + concurrent open + mid-storm status watchdog (full-app freeze bar).'
|
||||
}
|
||||
|
||||
const report = {
|
||||
topology: 'live-paired-remote-realistic',
|
||||
scenario,
|
||||
story: storyByScenario[scenario] || scenario,
|
||||
environment: envName,
|
||||
localVersion: local.result?.runtime?.appVersion,
|
||||
remoteVersion: remote.result?.runtime?.appVersion,
|
||||
remoteWorktreeCount: wtList.length,
|
||||
createdFloodTerminals: created.length,
|
||||
openTargets: openList.length,
|
||||
idleMs,
|
||||
paceMs,
|
||||
paceJitterMs,
|
||||
stormParallel: scenario === 'lockup-storm' ? stormParallel : 1,
|
||||
firstOpenMs,
|
||||
maxOpenMs,
|
||||
maxBatchWallMs,
|
||||
avgOpenMs: openOk ? sumOpenMs / openOk : 0,
|
||||
openWallMs,
|
||||
openOk,
|
||||
openFailed: openList.length - openOk,
|
||||
reconnectRefreshMs,
|
||||
peakLatencyMs: Math.max(signals.peakLatencyMs, maxBatchWallMs),
|
||||
statusProbeMs,
|
||||
statusHangMs,
|
||||
memoryProbeMs,
|
||||
softFreeze: signals.softFreeze,
|
||||
hardFreeze: signals.hardFreeze,
|
||||
recoveredHardStall,
|
||||
permanentLockup: lockup.permanentLockup,
|
||||
foreverUiLockupObserved: fullApp.foreverUiLockupObserved,
|
||||
foreverFreeze: fullApp,
|
||||
midStormStatusSamples: midStormWatch.samples,
|
||||
midStormStatusSampleCount: midStormWatch.sampleCount,
|
||||
watchdogInfrastructureErrorCount,
|
||||
timedOutOps,
|
||||
maxConsecutiveSwitchFailures,
|
||||
softMs,
|
||||
hardMs,
|
||||
foreverWindowMs,
|
||||
statusSlowMs,
|
||||
permanentTimeoutMs,
|
||||
opTimeoutMs,
|
||||
phases,
|
||||
notes,
|
||||
openTimingCount: openTimings.totalCount,
|
||||
openTimings: openTimings.values()
|
||||
}
|
||||
|
||||
const outPath = path.join(reportDir, `live-realistic-freeze-${envName}-${scenario}.json`)
|
||||
const stamped = path.join(
|
||||
reportDir,
|
||||
`live-realistic-freeze-${envName}-${scenario}-peak-${Date.now()}.json`
|
||||
)
|
||||
writeFileSync(outPath, `${JSON.stringify(report, null, 2)}\n`)
|
||||
writeFileSync(stamped, `${JSON.stringify(report, null, 2)}\n`)
|
||||
console.log(`[realistic-freeze] report ${outPath}`)
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
|
||||
if (scratchDir) {
|
||||
try {
|
||||
mkdirSync(scratchDir, { recursive: true })
|
||||
copyFileSync(outPath, path.join(scratchDir, 'live-realistic-freeze-report.json'))
|
||||
} catch (error) {
|
||||
console.warn(`[realistic-freeze] scratch copy failed: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (watchdogInfrastructureErrorCount > 0) {
|
||||
process.exitCode = 3
|
||||
console.error('[realistic-freeze] WATCHDOG INFRASTRUCTURE FAILURE')
|
||||
} else if (fullApp.foreverUiLockupObserved) {
|
||||
process.exitCode = 5
|
||||
console.error('[realistic-freeze] FULL-APP FOREVER FREEZE (status unhealthy ≥ forever window)')
|
||||
} else if (lockup.permanentLockup) {
|
||||
process.exitCode = 4
|
||||
console.error(
|
||||
'[realistic-freeze] PERMANENT LOCKUP HEURISTIC (timeouts/fail-rate) — check foreverUiLockupObserved'
|
||||
)
|
||||
} else if (signals.hardFreeze) {
|
||||
process.exitCode = 2
|
||||
console.error(
|
||||
'[realistic-freeze] HARD FREEZE SIGNAL (recovered multi-second stall — not forever lockup)'
|
||||
)
|
||||
} else if (signals.softFreeze) {
|
||||
process.exitCode = 1
|
||||
console.error('[realistic-freeze] SOFT FREEZE SIGNAL')
|
||||
} else {
|
||||
console.log('[realistic-freeze] no freeze signal under thresholds')
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[realistic-freeze] failed', error)
|
||||
process.exit(3)
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Mid-storm host health samples for forever-freeze detection.
|
||||
* Polls `orca status --json` on an interval while a load storm runs.
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
import { BoundedLiveFreezeHistory } from './live-freeze-bounded-history.mjs'
|
||||
import { resolveOrcaCliInvocation } from './live-remote-freeze-rpc.mjs'
|
||||
|
||||
/**
|
||||
* @param {{ intervalMs?: number, timeoutMs?: number, cliCommand?: string, sampleHistoryLimit?: number, statusSlowMs?: number }} opts
|
||||
*/
|
||||
export function startStatusWatchdog(opts = {}) {
|
||||
const intervalMs = opts.intervalMs ?? 2000
|
||||
const timeoutMs = opts.timeoutMs ?? 30_000
|
||||
const cliInvocation = opts.cliCommand
|
||||
? { command: opts.cliCommand, prefixArgs: [] }
|
||||
: resolveOrcaCliInvocation()
|
||||
const samples = new BoundedLiveFreezeHistory(opts.sampleHistoryLimit ?? 240)
|
||||
const statusSlowMs = opts.statusSlowMs ?? 15_000
|
||||
let stopped = false
|
||||
let inFlight = false
|
||||
let infrastructureErrorCount = 0
|
||||
let longestUnhealthyWindowMs = 0
|
||||
let maxStatusMs = 0
|
||||
let runStartMs = null
|
||||
let unhealthySampleCount = 0
|
||||
const startedAt = performance.now()
|
||||
|
||||
const record = (sample) => {
|
||||
samples.add(sample)
|
||||
maxStatusMs = Math.max(maxStatusMs, sample.ms || 0)
|
||||
if (sample.infrastructureError) {
|
||||
infrastructureErrorCount += 1
|
||||
}
|
||||
const unhealthy =
|
||||
!sample.infrastructureError &&
|
||||
(Boolean(sample.hang) || sample.ok === false || (sample.ms || 0) >= statusSlowMs)
|
||||
if (!unhealthy) {
|
||||
runStartMs = null
|
||||
return
|
||||
}
|
||||
unhealthySampleCount += 1
|
||||
runStartMs ??= sample.tMs ?? 0
|
||||
longestUnhealthyWindowMs = Math.max(
|
||||
longestUnhealthyWindowMs,
|
||||
(sample.tMs ?? 0) + (sample.ms || 0) - runStartMs
|
||||
)
|
||||
}
|
||||
|
||||
const summary = () => ({
|
||||
sampleCount: samples.totalCount,
|
||||
maxStatusMs,
|
||||
unhealthySampleCount,
|
||||
infrastructureErrorCount,
|
||||
longestUnhealthyWindowMs
|
||||
})
|
||||
|
||||
const probe = () =>
|
||||
new Promise((resolve) => {
|
||||
const t0 = performance.now()
|
||||
const child = spawn(
|
||||
cliInvocation.command,
|
||||
[...cliInvocation.prefixArgs, 'status', '--json'],
|
||||
{
|
||||
env: cliInvocation.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}
|
||||
)
|
||||
let settled = false
|
||||
const finish = (result) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
resolve(result)
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
finish({
|
||||
tMs: t0 - startedAt,
|
||||
ms: performance.now() - t0,
|
||||
ok: false,
|
||||
hang: true
|
||||
})
|
||||
}, timeoutMs)
|
||||
child.stdout.on('data', () => {})
|
||||
child.stderr.on('data', () => {})
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer)
|
||||
finish({
|
||||
tMs: t0 - startedAt,
|
||||
ms: performance.now() - t0,
|
||||
ok: false,
|
||||
hang: false,
|
||||
infrastructureError: true,
|
||||
error: String(error)
|
||||
})
|
||||
})
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer)
|
||||
finish({
|
||||
tMs: t0 - startedAt,
|
||||
ms: performance.now() - t0,
|
||||
ok: code === 0,
|
||||
hang: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const tick = async ({ force = false } = {}) => {
|
||||
if ((!force && stopped) || inFlight) {
|
||||
return
|
||||
}
|
||||
inFlight = true
|
||||
try {
|
||||
const sample = await probe()
|
||||
record(sample)
|
||||
} finally {
|
||||
inFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
void tick()
|
||||
}, intervalMs)
|
||||
void tick()
|
||||
|
||||
return {
|
||||
stop: async () => {
|
||||
stopped = true
|
||||
clearInterval(interval)
|
||||
// Wait for in-flight probe, then force one final sample.
|
||||
const deadline = performance.now() + timeoutMs + 1000
|
||||
while (inFlight && performance.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
}
|
||||
await tick({ force: true })
|
||||
return {
|
||||
samples: samples.values(),
|
||||
...summary(),
|
||||
durationMs: performance.now() - startedAt
|
||||
}
|
||||
},
|
||||
getSamples: () => samples.values(),
|
||||
getSummary: summary
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { startStatusWatchdog } from './live-remote-status-watchdog.mjs'
|
||||
|
||||
describe('startStatusWatchdog', () => {
|
||||
it('collects status samples and stops cleanly', async () => {
|
||||
// Real path: actually invokes `orca status --json` (must be available in CI/dev with Orca or fail soft).
|
||||
const watch = startStatusWatchdog({ intervalMs: 50, timeoutMs: 5_000 })
|
||||
await new Promise((r) => setTimeout(r, 180))
|
||||
const result = await watch.stop()
|
||||
expect(result.samples.length).toBeGreaterThanOrEqual(1)
|
||||
expect(result.durationMs).toBeGreaterThan(0)
|
||||
for (const s of result.samples) {
|
||||
expect(typeof s.ms).toBe('number')
|
||||
expect(typeof s.ok).toBe('boolean')
|
||||
expect(typeof s.hang).toBe('boolean')
|
||||
}
|
||||
})
|
||||
|
||||
it('marks CLI spawn failures as infrastructure errors', async () => {
|
||||
const watch = startStatusWatchdog({
|
||||
intervalMs: 50,
|
||||
timeoutMs: 1000,
|
||||
cliCommand: 'orca-freeze-watchdog-missing-command'
|
||||
})
|
||||
const result = await watch.stop()
|
||||
|
||||
expect(result.samples.length).toBeGreaterThanOrEqual(1)
|
||||
expect(result.samples.every((sample) => sample.infrastructureError === true)).toBe(true)
|
||||
expect(result.samples.every((sample) => sample.hang === false)).toBe(true)
|
||||
})
|
||||
|
||||
it('bounds retained samples while preserving full-run counters', async () => {
|
||||
const watch = startStatusWatchdog({
|
||||
intervalMs: 50,
|
||||
timeoutMs: 1000,
|
||||
cliCommand: 'orca-freeze-watchdog-missing-command',
|
||||
sampleHistoryLimit: 1
|
||||
})
|
||||
const result = await watch.stop()
|
||||
|
||||
expect(result.samples).toHaveLength(1)
|
||||
expect(result.sampleCount).toBeGreaterThan(result.samples.length)
|
||||
expect(result.infrastructureErrorCount).toBe(result.sampleCount)
|
||||
expect(result.maxStatusMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const extraArgs = process.argv.slice(2)
|
||||
const pnpmEntry = process.env.npm_execpath
|
||||
if (!pnpmEntry) {
|
||||
throw new Error('npm_execpath is required; run this harness through pnpm')
|
||||
}
|
||||
const env = {
|
||||
...process.env,
|
||||
ORCA_E2E_SSH_DOCKER: '1'
|
||||
}
|
||||
|
||||
const runtime = spawnSync(process.execPath, [pnpmEntry, 'run', 'ensure:electron-runtime'], {
|
||||
stdio: 'inherit',
|
||||
env
|
||||
})
|
||||
|
||||
if (runtime.status !== 0) {
|
||||
process.exit(runtime.status ?? 1)
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
pnpmEntry,
|
||||
'exec',
|
||||
'playwright',
|
||||
'test',
|
||||
'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--workers=1',
|
||||
...extraArgs
|
||||
],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
env
|
||||
}
|
||||
)
|
||||
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -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..."
|
||||
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SectionList,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
RefreshControl
|
||||
} from 'react-native'
|
||||
import { View, Text, StyleSheet, SectionList, Pressable, Alert, RefreshControl } from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router'
|
||||
import {
|
||||
@@ -95,6 +86,7 @@ import { useWorkspaceSections } from '../../../src/worktree/use-workspace-sectio
|
||||
import { getMobileWorkspaceLineageGroupKey } from '../../../src/worktree/mobile-workspace-lineage'
|
||||
import { areWorktreeListsEqual } from '../../../src/worktree/worktree-list-snapshot'
|
||||
import { WorktreeCatalogSnapshotClient } from '../../../src/worktree/worktree-catalog-snapshot-client'
|
||||
import { HostWorkspaceListStates } from '../../../src/worktree/host-workspace-list-states'
|
||||
import { repoColor } from '../../../src/worktree/repo-color'
|
||||
import {
|
||||
WORKSPACE_GROUP_OPTIONS as GROUP_OPTIONS,
|
||||
@@ -154,6 +146,9 @@ export function HostScreen({
|
||||
const forceReconnectHost = useForceReconnect()
|
||||
const [worktrees, setWorktrees] = useState<Worktree[]>(initialCache ?? [])
|
||||
const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null)
|
||||
// Why (STA-3123): error code of the last failed worktree.ps, so a broken catalog
|
||||
// path renders as a failure instead of an empty host. Cleared on the next success.
|
||||
const [catalogError, setCatalogError] = useState<string | null>(null)
|
||||
// Why: track the locally-opened worktree so the active-row highlight moves instantly instead of waiting for the next poll.
|
||||
const [optimisticActiveWorktreeId, setOptimisticActiveWorktreeId] = useState<string | null>(null)
|
||||
// One tick drives every visible agent row's relative timestamp.
|
||||
@@ -326,6 +321,7 @@ export function HostScreen({
|
||||
repoMetadataFetchedAtRef.current = 0
|
||||
// Why: useState initializer runs only on first mount, so re-seed the cache when Expo Router reuses this screen for a new hostId.
|
||||
const freshCache = hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null
|
||||
setCatalogError(null)
|
||||
if (freshCache) {
|
||||
setWorktrees(freshCache)
|
||||
setLastKnownWorktrees(freshCache)
|
||||
@@ -427,17 +423,27 @@ export function HostScreen({
|
||||
const requestHostId = hostId
|
||||
|
||||
try {
|
||||
const pendingCatalog = await worktreeCatalogRef.current.fetch(requestClient, requestHostId)
|
||||
const fetched = await worktreeCatalogRef.current.fetch(requestClient, requestHostId)
|
||||
if (clientRef.current !== requestClient || hostId !== requestHostId) {
|
||||
return
|
||||
}
|
||||
if (!options.allowDuringModal && newWorktreeModalVisibleRef.current) {
|
||||
return
|
||||
}
|
||||
// Why (STA-3123): a failed catalog request must not pass for "0 worktrees";
|
||||
// surface it so a broken remote host is diagnosable instead of looking empty.
|
||||
if (fetched.kind === 'request_failed') {
|
||||
setCatalogError(fetched.code)
|
||||
return
|
||||
}
|
||||
if (fetched.pending.admission.kind === 'invalid') {
|
||||
setCatalogError('invalid_response')
|
||||
}
|
||||
// Why: unchanged responses still yield the confirmed rows, so every poll reasserts
|
||||
// host truth over optimistic local edits regardless of payload size.
|
||||
const confirmed = worktreeCatalogRef.current.admit(pendingCatalog)
|
||||
const confirmed = worktreeCatalogRef.current.admit(fetched.pending)
|
||||
if (confirmed) {
|
||||
setCatalogError(null)
|
||||
// Why: reuse the existing array on identical snapshots to keep SectionList/sort rebuilds off the tap path.
|
||||
setWorktrees((current) =>
|
||||
areWorktreeListsEqual(current, confirmed) ? current : confirmed
|
||||
@@ -486,6 +492,9 @@ export function HostScreen({
|
||||
}
|
||||
} catch {
|
||||
// Will retry on reconnect
|
||||
if (clientRef.current === requestClient && hostId === requestHostId) {
|
||||
setCatalogError('network_error')
|
||||
}
|
||||
} finally {
|
||||
fetchWorktreesInFlightRef.current = false
|
||||
}
|
||||
@@ -1099,27 +1108,15 @@ export function HostScreen({
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{((connState === 'connecting' || connState === 'reconnecting') &&
|
||||
displayWorktrees.length === 0) ||
|
||||
(connState === 'connected' && !worktreesLoaded && displayWorktrees.length === 0) ? (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Empty state */}
|
||||
{connState === 'connected' && worktreesLoaded && sections.length === 0 && (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.emptyText}>
|
||||
{search
|
||||
? 'No matching worktrees'
|
||||
: activeFilterCount > 0
|
||||
? 'No worktrees match filters'
|
||||
: 'No worktrees'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<HostWorkspaceListStates
|
||||
connState={connState}
|
||||
worktreesLoaded={worktreesLoaded}
|
||||
displayCount={displayWorktrees.length}
|
||||
sectionCount={sections.length}
|
||||
catalogError={catalogError}
|
||||
search={search}
|
||||
activeFilterCount={activeFilterCount}
|
||||
/>
|
||||
|
||||
{sections.length > 0 && (
|
||||
<SectionList
|
||||
|
||||
@@ -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>()
|
||||
@@ -3590,6 +3600,7 @@ export default function SessionScreen() {
|
||||
if (
|
||||
current &&
|
||||
current.cursorY === metrics.cursorY &&
|
||||
current.contentBottomRow === metrics.contentBottomRow &&
|
||||
current.rows === metrics.rows &&
|
||||
current.altScreen === metrics.altScreen
|
||||
) {
|
||||
@@ -4271,24 +4282,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 }]
|
||||
|
||||
+17
-39
@@ -20,6 +20,11 @@ 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 type { RpcClient } from '../src/transport/rpc-client'
|
||||
import { sendSingleFlightRequest } from '../src/transport/request-single-flight'
|
||||
import {
|
||||
@@ -70,26 +75,6 @@ type StatsSummary = {
|
||||
firstEventAt: number | null
|
||||
}
|
||||
|
||||
type WorktreeSummary = {
|
||||
worktreeId: string
|
||||
repo: string
|
||||
branch: string
|
||||
displayName: string
|
||||
liveTerminalCount: number
|
||||
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
|
||||
// The worktree the desktop currently has focused (exactly one is true).
|
||||
isActive?: boolean
|
||||
// Last terminal-output time (ms); breaks ties when nothing is focused.
|
||||
lastOutputAt?: number
|
||||
}
|
||||
|
||||
type HostWorktreeInfo = {
|
||||
hostId: string
|
||||
totalWorktrees: number
|
||||
activeCount: number
|
||||
lastActiveWorktree: WorktreeSummary | null
|
||||
}
|
||||
|
||||
type HomeTaskSettings = {
|
||||
visibleTaskProviders?: unknown
|
||||
}
|
||||
@@ -161,21 +146,11 @@ function fetchWorktreeInfo(
|
||||
) => void,
|
||||
disposed: () => boolean
|
||||
) {
|
||||
// Why: only seed a zeroed entry when the host has no prior info; keep cached data on transient failure so counts don't flip to 0 during reconnects.
|
||||
const markLoadedIfMissing = () => {
|
||||
const markUnavailable = () => {
|
||||
setInfo((prev) => {
|
||||
if (prev[hostId]) {
|
||||
return prev
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[hostId]: {
|
||||
hostId,
|
||||
totalWorktrees: 0,
|
||||
activeCount: 0,
|
||||
lastActiveWorktree: null
|
||||
}
|
||||
}
|
||||
const current = prev[hostId]
|
||||
const next = markHomeWorktreeCatalogUnavailable(current, hostId)
|
||||
return next === current ? prev : { ...prev, [hostId]: next }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -185,7 +160,7 @@ function fetchWorktreeInfo(
|
||||
return
|
||||
}
|
||||
if (response.ok) {
|
||||
const result = response.result as { worktrees: WorktreeSummary[] }
|
||||
const result = response.result as { worktrees: HomeWorktreeSummary[] }
|
||||
const worktrees = result.worktrees ?? []
|
||||
setCachedWorktrees(hostId, worktrees)
|
||||
const activeStatuses = new Set(['working', 'active', 'permission'])
|
||||
@@ -202,12 +177,12 @@ function fetchWorktreeInfo(
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
markLoadedIfMissing()
|
||||
markUnavailable()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!disposed()) {
|
||||
markLoadedIfMissing()
|
||||
markUnavailable()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -554,7 +529,7 @@ export default function HomeScreen() {
|
||||
const resumeWorktree = useMemo(() => {
|
||||
// Why: only surface Resume for connected hosts; a stale worktree taps into a route that can't load.
|
||||
if (lastVisited && hostStates[lastVisited.hostId] === 'connected') {
|
||||
const cached = getCachedWorktrees(lastVisited.hostId) as WorktreeSummary[] | null
|
||||
const cached = getCachedWorktrees(lastVisited.hostId) as HomeWorktreeSummary[] | null
|
||||
const match = cached?.find((w) => w.worktreeId === lastVisited.worktreeId)
|
||||
if (match) {
|
||||
return { hostId: lastVisited.hostId, worktree: match }
|
||||
@@ -789,8 +764,11 @@ export default function HomeScreen() {
|
||||
verdict={verdict}
|
||||
path={hostPaths[item.id] ?? 'lan'}
|
||||
worktreeCounts={
|
||||
info ? { total: info.totalWorktrees, active: info.activeCount } : undefined
|
||||
info && !info.catalogUnavailable
|
||||
? { total: info.totalWorktrees, active: info.activeCount }
|
||||
: undefined
|
||||
}
|
||||
worktreeCountsUnavailable={info?.catalogUnavailable === true}
|
||||
onPress={() => router.push(`/h/${item.id}`)}
|
||||
onLongPress={() => {
|
||||
triggerMediumImpact()
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
Generated
+792
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()
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# Repro driver for "mobile sits on Connecting… after leaving and re-entering the
|
||||
# app" (Slack P0, iOS 0.0.39(2)).
|
||||
#
|
||||
# Two things the iOS Simulator will not do on its own, and how this works around
|
||||
# them:
|
||||
#
|
||||
# * It never suspends an app the way a device does, so the JS runtime keeps
|
||||
# servicing timers while backgrounded and the bug hides. SIGSTOP on the app
|
||||
# process reproduces real suspension: timers frozen, socket left dangling.
|
||||
# * SIGSTOP on the desktop runtime makes its port blackhole — the kernel still
|
||||
# completes the TCP handshake from the listen backlog, but no WebSocket
|
||||
# upgrade ever comes back, which is what a wedged Tailscale tunnel or a relay
|
||||
# with nothing behind it looks like to the phone.
|
||||
#
|
||||
# Everything is driven with xcrun simctl rather than `orca emulator`, because the
|
||||
# emulator CLI routes through the desktop runtime that this script freezes.
|
||||
#
|
||||
# Setup:
|
||||
# node scripts/start-emulator.mjs --device "iPhone 17 Pro" --wait-for-ready \
|
||||
# > /tmp/orca-emulator-boot.log 2>&1 &
|
||||
#
|
||||
# Usage: repro-mobile-foreground-stall.sh <udid> <paired-host-port> [log]
|
||||
set -euo pipefail
|
||||
|
||||
UDID="${1:?simulator udid}"
|
||||
PORT="${2:?port of the paired host, from the [net] logs}"
|
||||
LOG="${3:-/tmp/orca-emulator-boot.log}"
|
||||
BUNDLE_ID=com.stably.orca.mobile
|
||||
# Long enough for the tiered backoff to reach its 30s/60s tail.
|
||||
ESCALATE_SECONDS=200
|
||||
|
||||
APP=$(pgrep -f "CoreSimulator.*Orca.app/Orca" | head -1)
|
||||
DESK=$(pgrep -f "serve-mobile-pairing" | head -1)
|
||||
: "${APP:?mobile app is not running in the simulator}"
|
||||
: "${DESK:?headless desktop runtime is not running}"
|
||||
|
||||
strip() { sed 's/\x1b\[[0-9;]*m//g'; }
|
||||
since() { sed -n "$(( $1 + 1 )),\$p" "$LOG" | strip; }
|
||||
step() { echo "$(date +%T) $*"; }
|
||||
|
||||
echo "### app=$APP desktop=$DESK port=$PORT"
|
||||
|
||||
step "[1] desktop goes unreachable"
|
||||
kill -STOP "$DESK"
|
||||
sleep "$ESCALATE_SECONDS"
|
||||
step "[2] backoff at $(since 0 | grep "$PORT" | grep -o '"attempt": [0-9]*' | tail -1)"
|
||||
|
||||
MARK=$(wc -l < "$LOG")
|
||||
while true; do
|
||||
since "$MARK" | grep "$PORT" | grep -q '"to": "connecting"' && break
|
||||
sleep 0.3
|
||||
done
|
||||
step "[3] connect window open — user switches away"
|
||||
xcrun simctl launch "$UDID" com.apple.mobilesafari >/dev/null 2>&1
|
||||
sleep 1
|
||||
kill -STOP "$APP"
|
||||
step "[4] phone suspended mid-dial"
|
||||
sleep 5
|
||||
|
||||
MARK=$(wc -l < "$LOG")
|
||||
kill -CONT "$APP"
|
||||
sleep 0.3
|
||||
xcrun simctl launch "$UDID" "$BUNDLE_ID" >/dev/null 2>&1
|
||||
T0=$(date +%s)
|
||||
step "[5] user returns to Orca <-- t0, desktop still down"
|
||||
|
||||
# The desktop is deliberately still unreachable here: the only question is
|
||||
# whether returning to the app abandons the dead dial or waits it out.
|
||||
sleep 6
|
||||
echo "--- first 6s after t0 ---"
|
||||
since "$MARK" | grep -E "$PORT|foreground" \
|
||||
| grep -E 'state |foreground|scheduleReconnect|openConnection' | head -8
|
||||
|
||||
kill -CONT "$DESK"
|
||||
step "[6] desktop healthy again"
|
||||
for _ in $(seq 1 45); do
|
||||
if since "$MARK" | grep "$PORT" | grep -q '"to": "connected"'; then
|
||||
step ">>> connected $(( $(date +%s) - T0 ))s after t0"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
step ">>> STILL STUCK 90s after t0"
|
||||
exit 1
|
||||
@@ -14,6 +14,9 @@ export function MobileHostCard(props: {
|
||||
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
|
||||
onPress: () => void
|
||||
onLongPress: () => void
|
||||
}) {
|
||||
@@ -21,7 +24,9 @@ export function MobileHostCard(props: {
|
||||
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` : ''}`
|
||||
: null
|
||||
: props.worktreeCountsUnavailable
|
||||
? 'Worktree list unavailable'
|
||||
: null
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
isFilePathCodeSpan,
|
||||
normalizeFilePath
|
||||
} from './markdown-file-path-detection'
|
||||
import { isMobileMermaidLanguage } from './mobile-mermaid-language'
|
||||
import { parseMobileMarkdown } from './mobile-markdown-parser'
|
||||
import { MermaidDiagram } from './pr-sidebar/MermaidDiagram'
|
||||
|
||||
type Props = {
|
||||
content?: string
|
||||
@@ -23,6 +25,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
|
||||
|
||||
function openMarkdownUrl(url: string): void {
|
||||
const trimmed = url.trim()
|
||||
@@ -153,6 +157,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}>
|
||||
@@ -175,6 +180,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?:/)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})()
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createElement } from 'react'
|
||||
import TestRenderer from 'react-test-renderer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
|
||||
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
|
||||
|
||||
const APPROVAL = JSON.stringify({
|
||||
approval: { tool: 'Bash', summary: 'pnpm build > build.log 2>&1' }
|
||||
})
|
||||
|
||||
function permissionFor(status: Partial<AgentStatusEntry> | null): unknown {
|
||||
let captured: unknown
|
||||
function Probe(): null {
|
||||
captured = useMobileNativeChatPrompts({
|
||||
enabled: true,
|
||||
status: status as AgentStatusEntry | null,
|
||||
messages: []
|
||||
}).permission
|
||||
return null
|
||||
}
|
||||
TestRenderer.act(() => {
|
||||
TestRenderer.create(createElement(Probe))
|
||||
})
|
||||
return captured
|
||||
}
|
||||
|
||||
describe('useMobileNativeChatPrompts approval-envelope state gate', () => {
|
||||
it('renders no approval card while the agent is working', () => {
|
||||
expect(permissionFor({ state: 'working', interactivePrompt: APPROVAL })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders no approval card after the turn is done', () => {
|
||||
expect(permissionFor({ state: 'done', interactivePrompt: APPROVAL })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders no approval card without a status', () => {
|
||||
expect(permissionFor(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the approval card while the agent is waiting', () => {
|
||||
expect(permissionFor({ state: 'waiting', interactivePrompt: APPROVAL })).toMatchObject({
|
||||
title: 'Allow Bash?',
|
||||
detail: 'pnpm build > build.log 2>&1'
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the approval card while the agent is blocked', () => {
|
||||
expect(permissionFor({ state: 'blocked', interactivePrompt: APPROVAL })).toMatchObject({
|
||||
title: 'Allow Bash?'
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers the heuristic numbered menu over the envelope while paused', () => {
|
||||
const permission = permissionFor({
|
||||
state: 'waiting',
|
||||
interactivePrompt: APPROVAL,
|
||||
lastAssistantMessage: 'Allow this Bash command?\n1. Yes\n2. No'
|
||||
}) as { options: Array<{ label: string }> } | null
|
||||
expect(permission).toMatchObject({ title: 'Permission requested' })
|
||||
expect(permission?.options.map((o) => o.label)).toEqual(['Yes', 'No'])
|
||||
})
|
||||
})
|
||||
@@ -19,15 +19,18 @@ export function useMobileNativeChatPrompts(args: {
|
||||
}): MobileNativeChatPrompts {
|
||||
const { enabled, status, messages } = args
|
||||
const blocked = status?.state === 'waiting' || status?.state === 'blocked'
|
||||
// Both permission paths sit inside the paused gate: an approval envelope can
|
||||
// outlive its answer (the host keeps it sticky), so only a waiting/blocked
|
||||
// agent may surface it — never a working or done one (STA-3144).
|
||||
const permission =
|
||||
(blocked && status
|
||||
? detectAgentPermission({
|
||||
blocked && status
|
||||
? (detectAgentPermission({
|
||||
state: status.state,
|
||||
lastAssistantMessage: status.lastAssistantMessage,
|
||||
toolName: status.toolName,
|
||||
toolInput: status.toolInput
|
||||
})
|
||||
: null) ?? parseApprovalFromStatus(status?.interactivePrompt)
|
||||
}) ?? parseApprovalFromStatus(status.interactivePrompt))
|
||||
: null
|
||||
const question =
|
||||
blocked && status && !permission ? parseAgentQuestion(status.lastAssistantMessage ?? '') : null
|
||||
const askFromStatus = useMemo(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,301 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { connect } from './rpc-client'
|
||||
import { classifyConnection, verdictDisplayLabel } from './connection-health'
|
||||
|
||||
// Slack P0 — "mobile connection issue. was using it with my linux host then
|
||||
// after i navigated out of the app and back it doesn't work anymore. Now i went
|
||||
// back again it's connected now."
|
||||
//
|
||||
// The tell is that the SECOND foreground fixed it. notifyForeground only ever
|
||||
// acted on 'connected' (probe the socket) and 'reconnecting' (redial now). A
|
||||
// phone that is suspended mid-dial resumes in 'connecting'/'handshaking', where
|
||||
// the nudge did nothing: the user waited out the remainder of the 12s connect
|
||||
// budget on a socket opened over a network path that no longer existed, and
|
||||
// then the *preserved* backoff delay — up to 60s of "Connecting…" against a
|
||||
// desktop that was answering the whole time. By the next foreground the client
|
||||
// had landed in 'reconnecting', where the nudge works, so it connected at once.
|
||||
|
||||
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) => `encrypted:${plaintext}`,
|
||||
decrypt: (raw: string) => raw.replace(/^encrypted:/, ''),
|
||||
decryptBytes: (bytes: Uint8Array) => bytes
|
||||
}))
|
||||
|
||||
// Mirrors React Native's WebSocket: readyState lives in JS and only advances on
|
||||
// a delivered event, so a socket the OS killed while the app was suspended stays
|
||||
// CONNECTING forever from the client's point of view.
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0
|
||||
static OPEN = 1
|
||||
static CLOSING = 2
|
||||
static CLOSED = 3
|
||||
readonly CONNECTING = 0
|
||||
readonly OPEN = 1
|
||||
|
||||
readyState = MockWebSocket.CONNECTING
|
||||
onopen: (() => void) | null = null
|
||||
onclose: ((event?: unknown) => void) | null = null
|
||||
onmessage: ((event: { data: unknown }) => void) | null = null
|
||||
onerror: ((event?: unknown) => void) | null = null
|
||||
// A socket the OS tore down while suspended never reports its own close.
|
||||
deliversCloseEvent = true
|
||||
sent: string[] = []
|
||||
|
||||
constructor(readonly endpoint: string) {
|
||||
sockets.push(this)
|
||||
}
|
||||
|
||||
send(payload: string): void {
|
||||
this.sent.push(payload)
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.readyState === MockWebSocket.CLOSED) {
|
||||
return
|
||||
}
|
||||
this.readyState = MockWebSocket.CLOSED
|
||||
if (this.deliversCloseEvent) {
|
||||
this.onclose?.({ code: 1006, wasClean: false })
|
||||
}
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.readyState = MockWebSocket.OPEN
|
||||
this.onopen?.()
|
||||
}
|
||||
|
||||
authenticate(): void {
|
||||
this.open()
|
||||
this.onmessage?.({ data: JSON.stringify({ type: 'e2ee_ready' }) })
|
||||
this.onmessage?.({ data: 'encrypted:{"type":"e2ee_authenticated"}' })
|
||||
}
|
||||
}
|
||||
|
||||
const sockets: MockWebSocket[] = []
|
||||
const originalWebSocket = globalThis.WebSocket
|
||||
|
||||
function latest(): MockWebSocket {
|
||||
const socket = sockets[sockets.length - 1]
|
||||
if (!socket) {
|
||||
throw new Error('no socket opened')
|
||||
}
|
||||
return socket
|
||||
}
|
||||
|
||||
function label(client: ReturnType<typeof connect>, endpoint: string): string {
|
||||
return verdictDisplayLabel(
|
||||
classifyConnection({
|
||||
state: client.getState(),
|
||||
reconnectAttempts: client.getReconnectAttempt(),
|
||||
lastConnectedAt: client.getLastConnectedAt(),
|
||||
endpoint,
|
||||
nowMs: Date.now()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// A suspended app is not a slow app: wall-clock time passes but not one JS timer
|
||||
// runs, so the backoff loop does NOT keep cycling in the background. Advancing
|
||||
// the system clock without draining the timer queue is what reproduces that —
|
||||
// the connect timer armed before the suspension is still pending on resume, and
|
||||
// the client is still sitting in the state it was suspended in.
|
||||
function suspend(backgroundMs: number): void {
|
||||
for (const socket of sockets) {
|
||||
// iOS reclaims a suspended app's sockets without telling JS.
|
||||
socket.deliversCloseEvent = false
|
||||
}
|
||||
vi.setSystemTime(Date.now() + backgroundMs)
|
||||
}
|
||||
|
||||
// Steps the clock one second at a time and reports how long the user has to sit
|
||||
// on "Connecting…" before the client opens its next socket.
|
||||
async function millisecondsUntilNextDial(dialsBefore: number): Promise<number> {
|
||||
const limitMs = 180_000
|
||||
for (let elapsedMs = 0; elapsedMs <= limitMs; elapsedMs += 1_000) {
|
||||
if (sockets.length > dialsBefore) {
|
||||
return elapsedMs
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
}
|
||||
return limitMs
|
||||
}
|
||||
|
||||
// Waits out the current backoff so the phone is suspended mid-dial — the state
|
||||
// the reporter's phone resumed into — rather than between dials.
|
||||
async function advanceUntilDialing(client: ReturnType<typeof connect>): Promise<void> {
|
||||
for (let elapsedMs = 0; elapsedMs <= 120_000; elapsedMs += 250) {
|
||||
if (client.getState() === 'connecting') {
|
||||
return
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
}
|
||||
throw new Error(`client never re-entered connecting (state=${client.getState()})`)
|
||||
}
|
||||
|
||||
const TAILSCALE_ENDPOINT = 'ws://100.84.12.9:6769'
|
||||
|
||||
describe('foregrounding a phone that was suspended mid-dial', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
sockets.length = 0
|
||||
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
globalThis.WebSocket = originalWebSocket
|
||||
})
|
||||
|
||||
it('abandons a dial that predates the foreground edge instead of waiting it out', async () => {
|
||||
const client = connect(TAILSCALE_ENDPOINT, 'token', 'server-key')
|
||||
latest().authenticate()
|
||||
expect(client.getState()).toBe('connected')
|
||||
|
||||
// The user leaves the app. The link dies while the phone is suspended, so
|
||||
// the client is left holding a socket that will never open.
|
||||
latest().close()
|
||||
expect(client.getState()).toBe('reconnecting')
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(client.getState()).toBe('connecting')
|
||||
const doomed = latest()
|
||||
|
||||
suspend(120_000)
|
||||
|
||||
// Back in the app. The desktop is healthy and answering.
|
||||
const socketsBeforeForeground = sockets.length
|
||||
client.notifyForeground()
|
||||
|
||||
expect(sockets.length).toBe(socketsBeforeForeground + 1)
|
||||
expect(latest()).not.toBe(doomed)
|
||||
latest().authenticate()
|
||||
expect(client.getState()).toBe('connected')
|
||||
})
|
||||
|
||||
it('does not strand the user on "Connecting…" for a minute after returning', async () => {
|
||||
const client = connect(TAILSCALE_ENDPOINT, 'token', 'server-key')
|
||||
latest().authenticate()
|
||||
latest().close()
|
||||
|
||||
// The desktop drops off the network while the user is still in the app, so
|
||||
// the tiered backoff climbs into its slow tail (15s, 30s, 60s) before they
|
||||
// ever leave. This is what makes the post-resume wait a full minute.
|
||||
for (let dial = 0; dial < 6; dial++) {
|
||||
await vi.advanceTimersByTimeAsync(12_000 + 60_000)
|
||||
}
|
||||
expect(client.getReconnectAttempt()).toBeGreaterThanOrEqual(6)
|
||||
await advanceUntilDialing(client)
|
||||
|
||||
suspend(90_000)
|
||||
const dialsBefore = sockets.length
|
||||
client.notifyForeground()
|
||||
|
||||
// The desktop is reachable again and the user is looking at the screen, so
|
||||
// the redial has to be in flight now — not after the abandoned socket's
|
||||
// connect budget expires and another tail-length backoff is waited out.
|
||||
expect(await millisecondsUntilNextDial(dialsBefore)).toBe(0)
|
||||
|
||||
latest().authenticate()
|
||||
expect(client.getState()).toBe('connected')
|
||||
expect(label(client, TAILSCALE_ENDPOINT)).toBe('Connected')
|
||||
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('clears the escalated label once the foreground redial lands', async () => {
|
||||
const client = connect(TAILSCALE_ENDPOINT, 'token', 'server-key')
|
||||
latest().authenticate()
|
||||
latest().close()
|
||||
for (let dial = 0; dial < 4; dial++) {
|
||||
await vi.advanceTimersByTimeAsync(12_000 + 60_000)
|
||||
}
|
||||
// Four failed dials is past WARNING_ATTEMPTS, so the card reads "Can't
|
||||
// connect — check Tailscale" rather than a neutral "Connecting…".
|
||||
expect(label(client, TAILSCALE_ENDPOINT)).toBe("Can't connect — check Tailscale")
|
||||
await advanceUntilDialing(client)
|
||||
const doomed = latest()
|
||||
|
||||
suspend(120_000)
|
||||
client.notifyForeground()
|
||||
|
||||
expect(latest()).not.toBe(doomed)
|
||||
latest().authenticate()
|
||||
expect(client.getState()).toBe('connected')
|
||||
expect(client.getReconnectAttempt()).toBe(0)
|
||||
expect(label(client, TAILSCALE_ENDPOINT)).toBe('Connected')
|
||||
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('keeps escalating when the user checks back repeatedly during a real outage', async () => {
|
||||
const client = connect(TAILSCALE_ENDPOINT, 'token', 'server-key')
|
||||
latest().authenticate()
|
||||
latest().close()
|
||||
|
||||
// The desktop really is gone. Abandoning the stale dial must book the failure
|
||||
// it represents, or each visit resets the counter faster than it can climb and
|
||||
// the card reassures the user with "Connecting…" indefinitely (issue #10119).
|
||||
for (let visit = 0; visit < 14; visit++) {
|
||||
await advanceUntilDialing(client)
|
||||
suspend(30_000)
|
||||
client.notifyForeground()
|
||||
}
|
||||
|
||||
expect(client.getReconnectAttempt()).toBeGreaterThanOrEqual(12)
|
||||
expect(label(client, TAILSCALE_ENDPOINT)).toBe("Can't reach desktop — check Tailscale")
|
||||
|
||||
// And the escalation is not sticky: the desktop comes back, the card clears.
|
||||
latest().authenticate()
|
||||
expect(label(client, TAILSCALE_ENDPOINT)).toBe('Connected')
|
||||
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('leaves a dial opened after the foreground edge alone', async () => {
|
||||
const client = connect(TAILSCALE_ENDPOINT, 'token', 'server-key')
|
||||
latest().authenticate()
|
||||
latest().close()
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
const fresh = latest()
|
||||
expect(client.getState()).toBe('connecting')
|
||||
|
||||
// A Wi-Fi→cellular handoff fires a second revival nudge right behind the
|
||||
// AppState one. It must not churn the dial that the first nudge just opened.
|
||||
client.notifyForeground()
|
||||
const afterFirstNudge = latest()
|
||||
client.notifyForeground()
|
||||
client.notifyForeground()
|
||||
|
||||
expect(latest()).toBe(afterFirstNudge)
|
||||
expect(fresh.endpoint).toBe(TAILSCALE_ENDPOINT)
|
||||
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('abandons a handshake stalled across the suspension', async () => {
|
||||
const client = connect(TAILSCALE_ENDPOINT, 'token', 'server-key')
|
||||
latest().authenticate()
|
||||
latest().close()
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
// The upgrade completed but the desktop never sent e2ee_ready before the
|
||||
// phone was suspended — the classic wedged relay/Tailscale half-open.
|
||||
latest().open()
|
||||
expect(client.getState()).toBe('handshaking')
|
||||
const wedged = latest()
|
||||
|
||||
suspend(120_000)
|
||||
client.notifyForeground()
|
||||
|
||||
expect(latest()).not.toBe(wedged)
|
||||
latest().authenticate()
|
||||
expect(client.getState()).toBe('connected')
|
||||
|
||||
client.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { ConnectionState } from './types'
|
||||
|
||||
// Why: RN auto-pongs pings natively, so JS needs an app-level probe to detect half-open sockets.
|
||||
const ACTIVITY_PROBE_INTERVAL_MS = 20_000
|
||||
const ACTIVITY_PROBE_TIMEOUT_MS = 8_000
|
||||
|
||||
export type RpcActivityProbeDependencies = {
|
||||
getState: () => ConnectionState
|
||||
getSocket: () => WebSocket | null
|
||||
// Counts only validated inbound traffic, so a malformed frame cannot pass for liveness.
|
||||
getInboundSequence: () => number
|
||||
nextId: () => string
|
||||
registerPending: (id: string, onSettled: () => void) => void
|
||||
clearPending: (id: string) => void
|
||||
sendProbe: (id: string) => boolean
|
||||
forceReconnect: (socket: WebSocket) => void
|
||||
}
|
||||
|
||||
export type RpcActivityProbe = {
|
||||
start: () => void
|
||||
stop: () => void
|
||||
run: () => void
|
||||
}
|
||||
|
||||
export function createRpcActivityProbe(
|
||||
dependencies: RpcActivityProbeDependencies
|
||||
): RpcActivityProbe {
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let inFlight = false
|
||||
|
||||
function stop(): void {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
function run(): void {
|
||||
const probeWs = dependencies.getSocket()
|
||||
if (dependencies.getState() !== 'connected' || !probeWs || inFlight) {
|
||||
return
|
||||
}
|
||||
inFlight = true
|
||||
const id = dependencies.nextId()
|
||||
const probeInboundSequence = dependencies.getInboundSequence()
|
||||
let timedOut = false
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true
|
||||
inFlight = false
|
||||
dependencies.clearPending(id)
|
||||
// Why: any validated frame answered for the socket, even if this probe did not.
|
||||
if (dependencies.getInboundSequence() > probeInboundSequence) {
|
||||
return
|
||||
}
|
||||
console.log('[net] activity-probe TIMEOUT — forcing reconnect', {
|
||||
state: dependencies.getState()
|
||||
})
|
||||
// Why: stale probe timers must not close a replacement socket.
|
||||
if (probeWs === dependencies.getSocket() && probeWs.readyState === WebSocket.OPEN) {
|
||||
dependencies.forceReconnect(probeWs)
|
||||
}
|
||||
}, ACTIVITY_PROBE_TIMEOUT_MS)
|
||||
|
||||
dependencies.registerPending(id, () => {
|
||||
if (timedOut) {
|
||||
return
|
||||
}
|
||||
inFlight = false
|
||||
clearTimeout(timeout)
|
||||
})
|
||||
|
||||
if (!dependencies.sendProbe(id)) {
|
||||
inFlight = false
|
||||
clearTimeout(timeout)
|
||||
dependencies.clearPending(id)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
run,
|
||||
stop,
|
||||
start(): void {
|
||||
stop()
|
||||
timer = setInterval(run, ACTIVITY_PROBE_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
|
||||
import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-request-budget'
|
||||
import { isRpcResponse } from './rpc-response-shape'
|
||||
import { createRpcActivityProbe } from './rpc-client-activity-probe'
|
||||
import { isStaleForegroundDial } from './rpc-stale-dial'
|
||||
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
|
||||
|
||||
type PendingRequest = {
|
||||
@@ -126,9 +128,6 @@ const HANDSHAKE_TIMEOUT_MS = 5_000
|
||||
// Why: RN may not expose WebSocket.readyState constants, but the CONNECTING protocol value (0) is stable across runtimes.
|
||||
const WEBSOCKET_CONNECTING_STATE = 0
|
||||
|
||||
// Why: RN auto-pongs pings natively, so JS needs an app-level probe to detect half-open sockets.
|
||||
const ACTIVITY_PROBE_INTERVAL_MS = 20_000
|
||||
|
||||
export type ConnectOptions = {
|
||||
onStateChange?: (state: ConnectionState) => void
|
||||
// Fires for every lifecycle event so the UI can show where 'Connecting…' is stuck (e.g. broken Tailscale route).
|
||||
@@ -169,8 +168,6 @@ export function connect(
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let connectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let handshakeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let activityProbeTimer: ReturnType<typeof setInterval> | null = null
|
||||
let activityProbeInFlight = false
|
||||
let intentionallyClosed = false
|
||||
// Consecutive auth rejections; tolerate up to AUTH_RETRY_BUDGET (issue #5200) before latching to avoid a needless re-pair.
|
||||
let authRejectionCount = 0
|
||||
@@ -181,6 +178,7 @@ export function connect(
|
||||
let inboundSequence = 0
|
||||
let lastWsClosedAt: number | null = null
|
||||
let wsConstructionCounter = 0
|
||||
let dialStartedAt = 0
|
||||
|
||||
// Why: fresh ephemeral keypair per connection provides forward secrecy.
|
||||
let sharedKey: Uint8Array | null = null
|
||||
@@ -302,6 +300,7 @@ export function connect(
|
||||
msSinceLastInbound: lastInboundAt != null ? now - lastInboundAt : null
|
||||
})
|
||||
setState('connecting')
|
||||
dialStartedAt = now
|
||||
sharedKey = null
|
||||
|
||||
emitLog(
|
||||
@@ -328,11 +327,7 @@ export function connect(
|
||||
'WebSocket connect timeout',
|
||||
`No TCP/WS handshake within ${CONNECT_TIMEOUT_MS / 1000}s — endpoint unreachable?`
|
||||
)
|
||||
openingWs.close()
|
||||
if (ws === openingWs) {
|
||||
synthesizedCloses.remember(openingWs, authenticationGeneration)
|
||||
handleSocketClosed(openingWs, { timedOut: true })
|
||||
}
|
||||
closeAndSynthesize(openingWs)
|
||||
}
|
||||
}, CONNECT_TIMEOUT_MS)
|
||||
|
||||
@@ -372,12 +367,7 @@ export function connect(
|
||||
'Handshake timeout',
|
||||
`No e2ee_ready/e2ee_authenticated within ${HANDSHAKE_TIMEOUT_MS / 1000}s`
|
||||
)
|
||||
openingWs.close()
|
||||
// Why: React Native can omit onclose for a wedged iOS transport.
|
||||
if (ws === openingWs) {
|
||||
synthesizedCloses.remember(openingWs, authenticationGeneration)
|
||||
handleSocketClosed(openingWs, { timedOut: true })
|
||||
}
|
||||
closeAndSynthesize(openingWs)
|
||||
}, HANDSHAKE_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
@@ -422,17 +412,14 @@ export function connect(
|
||||
try {
|
||||
const msg = JSON.parse(plaintext)
|
||||
if (msg.type === 'e2ee_authenticated') {
|
||||
if (handshakeTimer) {
|
||||
clearTimeout(handshakeTimer)
|
||||
handshakeTimer = null
|
||||
}
|
||||
clearHandshakeTimer()
|
||||
console.log('[net] e2ee_authenticated — connected', {
|
||||
streamCount: streamListeners.size
|
||||
})
|
||||
openingWsAuthenticated = true
|
||||
setState('connected')
|
||||
emitLog('success', 'Authenticated', 'Channel ready for RPC')
|
||||
startActivityProbe()
|
||||
activityProbe.start()
|
||||
for (const [id, stream] of streamListeners) {
|
||||
if (stream.cancelled) {
|
||||
removeStreamListener(id)
|
||||
@@ -458,10 +445,7 @@ export function connect(
|
||||
}
|
||||
} else if (msg.type === 'e2ee_error' || (!msg.ok && msg.error?.code === 'unauthorized')) {
|
||||
console.log('[net] e2ee auth FAILED', { msgType: msg.type, error: msg.error })
|
||||
if (handshakeTimer) {
|
||||
clearTimeout(handshakeTimer)
|
||||
handshakeTimer = null
|
||||
}
|
||||
clearHandshakeTimer()
|
||||
handleAuthRejection('Unauthorized — pairing may be revoked')
|
||||
}
|
||||
} catch {
|
||||
@@ -654,11 +638,8 @@ export function connect(
|
||||
activeBrowserScreencastRequestId = null
|
||||
pendingBrowserScreencastRequestId = null
|
||||
markStreamsForReplay()
|
||||
if (handshakeTimer) {
|
||||
clearTimeout(handshakeTimer)
|
||||
handshakeTimer = null
|
||||
}
|
||||
stopActivityProbe()
|
||||
clearHandshakeTimer()
|
||||
activityProbe.stop()
|
||||
if (intentionallyClosed) {
|
||||
console.log('[net] handleSocketClosed — intentional close')
|
||||
setState('disconnected')
|
||||
@@ -768,66 +749,33 @@ export function connect(
|
||||
}
|
||||
}
|
||||
|
||||
// Why: app-level liveness probe (see ACTIVITY_PROBE_INTERVAL_MS) — force-closes the WS on failure so onclose reconnects.
|
||||
function runActivityProbe() {
|
||||
if (state !== 'connected' || !ws || activityProbeInFlight) {
|
||||
return
|
||||
}
|
||||
activityProbeInFlight = true
|
||||
const probeWs = ws
|
||||
const id = nextId()
|
||||
const probeInboundSequence = inboundSequence
|
||||
let timedOut = false
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true
|
||||
activityProbeInFlight = false
|
||||
pending.delete(id)
|
||||
if (inboundSequence > probeInboundSequence) {
|
||||
return
|
||||
}
|
||||
console.log('[net] activity-probe TIMEOUT — forcing reconnect', { state })
|
||||
// Why: stale probe timers must not close a replacement socket.
|
||||
if (probeWs === ws && probeWs.readyState === WebSocket.OPEN) {
|
||||
probeWs.close()
|
||||
// Why: React Native can omit onclose for a wedged iOS transport.
|
||||
if (probeWs === ws) {
|
||||
synthesizedCloses.remember(probeWs, authenticationGeneration)
|
||||
handleSocketClosed(probeWs, { timedOut: true })
|
||||
}
|
||||
}
|
||||
}, 8_000)
|
||||
pending.set(id, {
|
||||
resolve: () => {
|
||||
if (timedOut) {
|
||||
return
|
||||
}
|
||||
activityProbeInFlight = false
|
||||
clearTimeout(timeout)
|
||||
},
|
||||
reject: () => {
|
||||
if (timedOut) {
|
||||
return
|
||||
}
|
||||
activityProbeInFlight = false
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
})
|
||||
if (!sendEncrypted({ id, deviceToken, method: 'status.get' })) {
|
||||
activityProbeInFlight = false
|
||||
clearTimeout(timeout)
|
||||
pending.delete(id)
|
||||
function clearHandshakeTimer() {
|
||||
if (handshakeTimer) {
|
||||
clearTimeout(handshakeTimer)
|
||||
handshakeTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function startActivityProbe() {
|
||||
stopActivityProbe()
|
||||
activityProbeTimer = setInterval(runActivityProbe, ACTIVITY_PROBE_INTERVAL_MS)
|
||||
// Why: a revival signal dials at once rather than waiting out the armed backoff.
|
||||
// Only the caller knows whether the attempt that led here should be forgiven.
|
||||
function redialNow(resetAttempts: boolean) {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
if (resetAttempts) {
|
||||
reconnectAttempt = 0
|
||||
}
|
||||
openConnection()
|
||||
}
|
||||
|
||||
function stopActivityProbe() {
|
||||
if (activityProbeTimer) {
|
||||
clearInterval(activityProbeTimer)
|
||||
activityProbeTimer = null
|
||||
// Why: React Native can omit onclose for a wedged iOS transport, so every forced
|
||||
// close has to synthesize the close event it may never deliver.
|
||||
function closeAndSynthesize(socket: WebSocket) {
|
||||
socket.close()
|
||||
if (ws === socket) {
|
||||
synthesizedCloses.remember(socket, authenticationGeneration)
|
||||
handleSocketClosed(socket, { timedOut: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1005,6 +953,17 @@ export function connect(
|
||||
}
|
||||
}
|
||||
|
||||
const activityProbe = createRpcActivityProbe({
|
||||
getState: () => state,
|
||||
getSocket: () => ws,
|
||||
getInboundSequence: () => inboundSequence,
|
||||
nextId,
|
||||
registerPending: (id, onSettled) => pending.set(id, { resolve: onSettled, reject: onSettled }),
|
||||
clearPending: (id) => pending.delete(id),
|
||||
sendProbe: (id) => sendEncrypted({ id, deviceToken, method: 'status.get' }),
|
||||
forceReconnect: closeAndSynthesize
|
||||
})
|
||||
|
||||
openConnection()
|
||||
|
||||
return {
|
||||
@@ -1160,22 +1119,31 @@ export function connect(
|
||||
if (state === 'connected') {
|
||||
// Why: OS can kill the TCP path while backgrounded without onclose; probe now to detect the half-open socket in ≤8s (issue #5049).
|
||||
console.log('[net] foreground — probing live connection')
|
||||
startActivityProbe()
|
||||
runActivityProbe()
|
||||
activityProbe.start()
|
||||
activityProbe.run()
|
||||
return
|
||||
}
|
||||
const dialing = ws
|
||||
const dialAgeMs = Date.now() - dialStartedAt
|
||||
let abandoned = false
|
||||
if (dialing && isStaleForegroundDial(state, dialAgeMs)) {
|
||||
console.log('[net] foreground — abandoning stale dial', { state, dialAgeMs })
|
||||
closeAndSynthesize(dialing)
|
||||
abandoned = true
|
||||
}
|
||||
if (state === 'reconnecting') {
|
||||
// Why: foreground is a strong user signal — restart immediately instead of waiting out a 60s/90s backoff timer.
|
||||
console.log('[net] foreground — restarting reconnect loop', {
|
||||
attempt: reconnectAttempt,
|
||||
hadTimer: !!reconnectTimer
|
||||
})
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
reconnectAttempt = 0
|
||||
openConnection()
|
||||
// Why: an abandoned dial keeps the failure it already represents. It never
|
||||
// authenticated, so it is the same failure the connect timeout would have
|
||||
// booked had we waited it out — we skip the wait, we don't pardon it. Zeroing
|
||||
// there would let a resume (or a flapping network) reset the counter faster
|
||||
// than it climbs, pinning the card at "Connecting…" through a real outage
|
||||
// (issue #10119). A redial with no dial to abandon is a genuinely fresh start.
|
||||
redialNow(!abandoned)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1186,11 +1154,8 @@ export function connect(
|
||||
reconnectTimer = null
|
||||
}
|
||||
clearConnectTimer()
|
||||
if (handshakeTimer) {
|
||||
clearTimeout(handshakeTimer)
|
||||
handshakeTimer = null
|
||||
}
|
||||
stopActivityProbe()
|
||||
clearHandshakeTimer()
|
||||
activityProbe.stop()
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ConnectionState } from './types'
|
||||
|
||||
// Why: a dial older than this predates the revival signal, so it was opened over a
|
||||
// network path that may no longer exist. Younger dials belong to the current
|
||||
// foreground session — AppState 'active' and a Wi-Fi→cellular change fire back to
|
||||
// back, and both reach the same nudge, so this also keeps them from churning it.
|
||||
const STALE_DIAL_AGE_MS = 2_000
|
||||
|
||||
// A phone suspended mid-dial resumes still holding that socket, and React Native
|
||||
// keeps its readyState at CONNECTING because no close event was ever delivered.
|
||||
// Treating 'connecting'/'handshaking' as progress worth waiting out left the user
|
||||
// on the rest of the 12s connect budget over a dead path, then a preserved backoff
|
||||
// delay — up to a further 60s of "Connecting…" against an answering desktop.
|
||||
export function isStaleForegroundDial(state: ConnectionState, dialAgeMs: number): boolean {
|
||||
return (state === 'connecting' || state === 'handshaking') && dialAgeMs >= STALE_DIAL_AGE_MS
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { markHomeWorktreeCatalogUnavailable, type HostWorktreeInfo } from './home-worktree-info'
|
||||
|
||||
describe('markHomeWorktreeCatalogUnavailable', () => {
|
||||
it('marks a host unavailable when no catalog has loaded', () => {
|
||||
expect(markHomeWorktreeCatalogUnavailable(undefined, 'host-1')).toEqual({
|
||||
hostId: 'host-1',
|
||||
totalWorktrees: 0,
|
||||
activeCount: 0,
|
||||
lastActiveWorktree: null,
|
||||
catalogUnavailable: true
|
||||
})
|
||||
})
|
||||
|
||||
it('marks cached counts unavailable without discarding the cached snapshot', () => {
|
||||
const current: HostWorktreeInfo = {
|
||||
hostId: 'host-1',
|
||||
totalWorktrees: 3,
|
||||
activeCount: 1,
|
||||
lastActiveWorktree: {
|
||||
worktreeId: 'worktree-1',
|
||||
repo: 'orca',
|
||||
branch: 'feature',
|
||||
displayName: 'Feature',
|
||||
liveTerminalCount: 1
|
||||
}
|
||||
}
|
||||
|
||||
expect(markHomeWorktreeCatalogUnavailable(current, 'host-1')).toEqual({
|
||||
...current,
|
||||
catalogUnavailable: true
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses an already unavailable snapshot to avoid repeated render churn', () => {
|
||||
const current: HostWorktreeInfo = {
|
||||
hostId: 'host-1',
|
||||
totalWorktrees: 0,
|
||||
activeCount: 0,
|
||||
lastActiveWorktree: null,
|
||||
catalogUnavailable: true
|
||||
}
|
||||
|
||||
expect(markHomeWorktreeCatalogUnavailable(current, 'host-1')).toBe(current)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
export type HomeWorktreeSummary = {
|
||||
worktreeId: string
|
||||
repo: string
|
||||
branch: string
|
||||
displayName: string
|
||||
liveTerminalCount: number
|
||||
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
|
||||
isActive?: boolean
|
||||
lastOutputAt?: number
|
||||
}
|
||||
|
||||
export type HostWorktreeInfo = {
|
||||
hostId: string
|
||||
totalWorktrees: number
|
||||
activeCount: number
|
||||
lastActiveWorktree: HomeWorktreeSummary | null
|
||||
catalogUnavailable?: boolean
|
||||
}
|
||||
|
||||
export function markHomeWorktreeCatalogUnavailable(
|
||||
current: HostWorktreeInfo | undefined,
|
||||
hostId: string
|
||||
): HostWorktreeInfo {
|
||||
if (current?.catalogUnavailable) {
|
||||
return current
|
||||
}
|
||||
if (current) {
|
||||
return { ...current, catalogUnavailable: true }
|
||||
}
|
||||
return {
|
||||
hostId,
|
||||
totalWorktrees: 0,
|
||||
activeCount: 0,
|
||||
lastActiveWorktree: null,
|
||||
catalogUnavailable: true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
selectHostWorkspaceListState,
|
||||
type HostWorkspaceListStateInput
|
||||
} from './host-workspace-list-state'
|
||||
|
||||
function input(overrides: Partial<HostWorkspaceListStateInput>): HostWorkspaceListStateInput {
|
||||
return {
|
||||
connState: 'connected',
|
||||
worktreesLoaded: true,
|
||||
displayCount: 0,
|
||||
sectionCount: 0,
|
||||
catalogError: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
// Regression guard for STA-3123: a remote host whose worktree.ps fails must render
|
||||
// as a catalog failure, never as a healthy host with zero workspaces.
|
||||
describe('selectHostWorkspaceListState', () => {
|
||||
it('reports catalog-error instead of empty when the fetch failed with no rows', () => {
|
||||
expect(selectHostWorkspaceListState(input({ catalogError: 'forbidden' }))).toBe('catalog-error')
|
||||
expect(
|
||||
selectHostWorkspaceListState(input({ catalogError: 'forbidden', worktreesLoaded: false }))
|
||||
).toBe('catalog-error')
|
||||
})
|
||||
|
||||
it('keeps showing cached rows (no overlay) when a later fetch fails', () => {
|
||||
expect(
|
||||
selectHostWorkspaceListState(
|
||||
input({ catalogError: 'network_error', displayCount: 3, sectionCount: 2 })
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('still reports empty for a filtered-out cached list during a failure', () => {
|
||||
expect(
|
||||
selectHostWorkspaceListState(
|
||||
input({ catalogError: 'network_error', displayCount: 3, sectionCount: 0 })
|
||||
)
|
||||
).toBe('empty')
|
||||
})
|
||||
|
||||
it('reports empty only after a successful load confirmed zero workspaces', () => {
|
||||
expect(selectHostWorkspaceListState(input({}))).toBe('empty')
|
||||
expect(selectHostWorkspaceListState(input({ worktreesLoaded: false }))).toBe('loading')
|
||||
})
|
||||
|
||||
it('shows the spinner while connecting regardless of error state', () => {
|
||||
expect(
|
||||
selectHostWorkspaceListState(input({ connState: 'reconnecting', catalogError: 'x' }))
|
||||
).toBe('loading')
|
||||
expect(
|
||||
selectHostWorkspaceListState(
|
||||
input({ connState: 'connecting', worktreesLoaded: false, catalogError: null })
|
||||
)
|
||||
).toBe('loading')
|
||||
})
|
||||
|
||||
it('renders nothing while disconnected with cached rows', () => {
|
||||
expect(
|
||||
selectHostWorkspaceListState(
|
||||
input({ connState: 'disconnected', displayCount: 2, sectionCount: 1 })
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
|
||||
export type HostWorkspaceListStateInput = {
|
||||
connState: ConnectionState
|
||||
worktreesLoaded: boolean
|
||||
displayCount: number
|
||||
sectionCount: number
|
||||
catalogError: string | null
|
||||
}
|
||||
|
||||
// Why (STA-3123): one owner for the list's loading/failed/empty precedence, so a
|
||||
// failed worktree.ps can never render as a healthy host with zero workspaces.
|
||||
export function selectHostWorkspaceListState(
|
||||
input: HostWorkspaceListStateInput
|
||||
): 'loading' | 'catalog-error' | 'empty' | null {
|
||||
const { connState, worktreesLoaded, displayCount, sectionCount, catalogError } = input
|
||||
const connecting = connState === 'connecting' || connState === 'reconnecting'
|
||||
if (
|
||||
(connecting && displayCount === 0) ||
|
||||
(connState === 'connected' && !worktreesLoaded && displayCount === 0 && !catalogError)
|
||||
) {
|
||||
return 'loading'
|
||||
}
|
||||
if (connState !== 'connected') {
|
||||
return null
|
||||
}
|
||||
if (catalogError && displayCount === 0) {
|
||||
return 'catalog-error'
|
||||
}
|
||||
if (worktreesLoaded && sectionCount === 0) {
|
||||
return 'empty'
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'
|
||||
import { colors, spacing, typography } from '../theme/mobile-theme'
|
||||
import {
|
||||
selectHostWorkspaceListState,
|
||||
type HostWorkspaceListStateInput
|
||||
} from './host-workspace-list-state'
|
||||
|
||||
export function HostWorkspaceListStates(
|
||||
props: HostWorkspaceListStateInput & {
|
||||
search: string
|
||||
activeFilterCount: number
|
||||
}
|
||||
) {
|
||||
const state = selectHostWorkspaceListState(props)
|
||||
if (state === 'loading') {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (state === 'catalog-error') {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.emptyText}>Could not load workspaces from this host</Text>
|
||||
<Text style={styles.catalogErrorDetail}>
|
||||
{`worktree.ps failed (${props.catalogError}) — retrying automatically`}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (state === 'empty') {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.emptyText}>
|
||||
{props.search
|
||||
? 'No matching worktrees'
|
||||
: props.activeFilterCount > 0
|
||||
? 'No worktrees match filters'
|
||||
: 'No worktrees'}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
emptyText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize
|
||||
},
|
||||
catalogErrorDetail: {
|
||||
marginTop: spacing.xs,
|
||||
paddingHorizontal: spacing.lg,
|
||||
color: colors.textMuted,
|
||||
fontSize: typography.metaSize,
|
||||
textAlign: 'center'
|
||||
}
|
||||
})
|
||||
@@ -3,9 +3,17 @@ import type { RpcClient } from '../transport/rpc-client'
|
||||
import {
|
||||
admitWorktreeCatalogResponse,
|
||||
WORKTREE_PS_FULL_LIMIT,
|
||||
WorktreeCatalogSnapshotClient
|
||||
WorktreeCatalogSnapshotClient,
|
||||
type WorktreeCatalogFetchResult
|
||||
} from './worktree-catalog-snapshot-client'
|
||||
|
||||
function admitFetched(
|
||||
snapshots: WorktreeCatalogSnapshotClient,
|
||||
fetched: WorktreeCatalogFetchResult
|
||||
) {
|
||||
return snapshots.admit(fetched.kind === 'response' ? fetched.pending : null)
|
||||
}
|
||||
|
||||
describe('admitWorktreeCatalogResponse', () => {
|
||||
it('accepts new-host full and unchanged responses', () => {
|
||||
const full = admitWorktreeCatalogResponse(
|
||||
@@ -94,8 +102,8 @@ describe('WorktreeCatalogSnapshotClient', () => {
|
||||
)
|
||||
const snapshots = new WorktreeCatalogSnapshotClient()
|
||||
|
||||
const first = snapshots.admit(await snapshots.fetch(client, 'host-1'))
|
||||
const second = snapshots.admit(await snapshots.fetch(client, 'host-1'))
|
||||
const first = admitFetched(snapshots, await snapshots.fetch(client, 'host-1'))
|
||||
const second = admitFetched(snapshots, await snapshots.fetch(client, 'host-1'))
|
||||
|
||||
expect(first).toEqual(rows)
|
||||
expect(second).toEqual(rows)
|
||||
@@ -109,7 +117,7 @@ describe('WorktreeCatalogSnapshotClient', () => {
|
||||
const snapshots = new WorktreeCatalogSnapshotClient()
|
||||
|
||||
await snapshots.fetch(client, 'host-1')
|
||||
snapshots.admit(await snapshots.fetch(client, 'host-1'))
|
||||
admitFetched(snapshots, await snapshots.fetch(client, 'host-1'))
|
||||
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'worktree.ps', {
|
||||
limit: WORKTREE_PS_FULL_LIMIT,
|
||||
@@ -121,7 +129,7 @@ describe('WorktreeCatalogSnapshotClient', () => {
|
||||
const client = clientWithResults({ worktrees: [], snapshotId: 'snapshot-1' })
|
||||
const snapshots = new WorktreeCatalogSnapshotClient()
|
||||
|
||||
snapshots.admit(await snapshots.fetch(client, 'host-1'))
|
||||
admitFetched(snapshots, await snapshots.fetch(client, 'host-1'))
|
||||
snapshots.admit(null)
|
||||
await snapshots.fetch(client, 'host-1')
|
||||
|
||||
@@ -139,8 +147,8 @@ describe('WorktreeCatalogSnapshotClient', () => {
|
||||
)
|
||||
const snapshots = new WorktreeCatalogSnapshotClient()
|
||||
|
||||
snapshots.admit(await snapshots.fetch(client, 'host-1'))
|
||||
snapshots.admit(await snapshots.fetch(client, 'host-1'))
|
||||
admitFetched(snapshots, await snapshots.fetch(client, 'host-1'))
|
||||
admitFetched(snapshots, await snapshots.fetch(client, 'host-1'))
|
||||
await snapshots.fetch(client, 'host-1')
|
||||
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(3, 'worktree.ps', {
|
||||
@@ -154,7 +162,7 @@ describe('WorktreeCatalogSnapshotClient', () => {
|
||||
const secondClient = clientWithResults({ worktrees: [], snapshotId: 'snapshot-2' })
|
||||
const snapshots = new WorktreeCatalogSnapshotClient()
|
||||
|
||||
snapshots.admit(await snapshots.fetch(firstClient, 'host-1'))
|
||||
admitFetched(snapshots, await snapshots.fetch(firstClient, 'host-1'))
|
||||
await snapshots.fetch(secondClient, 'host-2')
|
||||
|
||||
expect(secondClient.sendRequest).toHaveBeenCalledWith('worktree.ps', {
|
||||
@@ -170,8 +178,8 @@ describe('WorktreeCatalogSnapshotClient', () => {
|
||||
|
||||
// Host A's response is still in flight when the screen switches to host B.
|
||||
const stale = await snapshots.fetch(firstClient, 'host-1')
|
||||
snapshots.admit(await snapshots.fetch(secondClient, 'host-2'))
|
||||
expect(snapshots.admit(stale)).toBeNull()
|
||||
admitFetched(snapshots, await snapshots.fetch(secondClient, 'host-2'))
|
||||
expect(admitFetched(snapshots, stale)).toBeNull()
|
||||
|
||||
await snapshots.fetch(secondClient, 'host-2')
|
||||
expect(secondClient.sendRequest).toHaveBeenNthCalledWith(2, 'worktree.ps', {
|
||||
@@ -179,4 +187,40 @@ describe('WorktreeCatalogSnapshotClient', () => {
|
||||
afterSnapshotId: 'snapshot-2'
|
||||
})
|
||||
})
|
||||
|
||||
// Why (STA-3123): a failed worktree.ps rendered as "0 worktrees"; callers need the
|
||||
// failure code to show an error state instead of an empty host.
|
||||
it('surfaces the RPC error code on failure and keeps the admitted token', async () => {
|
||||
const responses: unknown[] = [
|
||||
{ id: 'request', ok: true, result: { worktrees: [], snapshotId: 'snapshot-1' } },
|
||||
{ id: 'request', ok: false, error: { code: 'forbidden', message: 'nope' } },
|
||||
{ id: 'request', ok: true, result: { unchanged: true, snapshotId: 'snapshot-1' } }
|
||||
]
|
||||
const client = {
|
||||
sendRequest: vi.fn(async () => responses.shift())
|
||||
} as unknown as RpcClient
|
||||
const snapshots = new WorktreeCatalogSnapshotClient()
|
||||
|
||||
admitFetched(snapshots, await snapshots.fetch(client, 'host-1'))
|
||||
const failed = await snapshots.fetch(client, 'host-1')
|
||||
expect(failed).toEqual({ kind: 'request_failed', code: 'forbidden' })
|
||||
|
||||
await snapshots.fetch(client, 'host-1')
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(3, 'worktree.ps', {
|
||||
limit: WORKTREE_PS_FULL_LIMIT,
|
||||
afterSnapshotId: 'snapshot-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a generic failure code when the error carries none', async () => {
|
||||
const client = {
|
||||
sendRequest: vi.fn(async () => ({ id: 'request', ok: false, error: { message: 'x' } }))
|
||||
} as unknown as RpcClient
|
||||
const snapshots = new WorktreeCatalogSnapshotClient()
|
||||
|
||||
expect(await snapshots.fetch(client, 'host-1')).toEqual({
|
||||
kind: 'request_failed',
|
||||
code: 'request_failed'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import type { RpcFailure, RpcSuccess } from '../transport/types'
|
||||
import type { Worktree } from './workspace-list-sections'
|
||||
|
||||
// Why: worktree.ps silently truncates at 200; use a high cap so large hosts don't drop workspaces.
|
||||
@@ -10,12 +10,19 @@ export type WorktreeCatalogAdmission<T> =
|
||||
| { kind: 'unchanged'; snapshotId: string }
|
||||
| { kind: 'invalid' }
|
||||
|
||||
type PendingWorktreeCatalog = {
|
||||
export type PendingWorktreeCatalog = {
|
||||
admission: WorktreeCatalogAdmission<Worktree>
|
||||
client: RpcClient
|
||||
hostId: string
|
||||
}
|
||||
|
||||
// Why (STA-3123): a failed worktree.ps must stay distinguishable from an empty
|
||||
// catalog — collapsing both to "no rows" made unreachable remote-server catalogs
|
||||
// render as a healthy host with 0 worktrees.
|
||||
export type WorktreeCatalogFetchResult =
|
||||
| { kind: 'response'; pending: PendingWorktreeCatalog }
|
||||
| { kind: 'request_failed'; code: string }
|
||||
|
||||
function validSnapshotId(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 && value.length <= 128 ? value : null
|
||||
}
|
||||
@@ -56,7 +63,7 @@ export class WorktreeCatalogSnapshotClient {
|
||||
private snapshotId: string | null = null
|
||||
private confirmedWorktrees: Worktree[] | null = null
|
||||
|
||||
async fetch(client: RpcClient, hostId: string): Promise<PendingWorktreeCatalog | null> {
|
||||
async fetch(client: RpcClient, hostId: string): Promise<WorktreeCatalogFetchResult> {
|
||||
if (this.client !== client || this.hostId !== hostId) {
|
||||
this.client = client
|
||||
this.hostId = hostId
|
||||
@@ -69,15 +76,22 @@ export class WorktreeCatalogSnapshotClient {
|
||||
afterSnapshotId: requestedSnapshotId
|
||||
})
|
||||
if (!response.ok) {
|
||||
return null
|
||||
const code = (response as RpcFailure).error?.code
|
||||
return {
|
||||
kind: 'request_failed',
|
||||
code: typeof code === 'string' && code.length > 0 ? code : 'request_failed'
|
||||
}
|
||||
}
|
||||
return {
|
||||
admission: admitWorktreeCatalogResponse<Worktree>(
|
||||
(response as RpcSuccess).result,
|
||||
requestedSnapshotId
|
||||
),
|
||||
client,
|
||||
hostId
|
||||
kind: 'response',
|
||||
pending: {
|
||||
admission: admitWorktreeCatalogResponse<Worktree>(
|
||||
(response as RpcSuccess).result,
|
||||
requestedSnapshotId
|
||||
),
|
||||
client,
|
||||
hostId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,9 +42,8 @@ internal static class OrcaCliLauncher
|
||||
|
||||
// Why: launching without cmd.exe preserves embedded newlines while matching the
|
||||
// packaged batch launcher's Electron-as-Node environment contract.
|
||||
// Why: reading ProcessStartInfo.EnvironmentVariables copies the process block into a
|
||||
// case-insensitive dictionary and throws when it holds both PATH and Path
|
||||
// (stablyai/orca#12046); mutating our own env leaves the child inheriting it verbatim.
|
||||
// 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");
|
||||
|
||||
+7
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.165-rc.0",
|
||||
"version": "1.4.168-rc.1",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
@@ -116,7 +116,11 @@
|
||||
"bench:multi-workspace-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-multi-workspace-typing-bench.mjs",
|
||||
"bench:cold-park-reveal": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs",
|
||||
"bench:cold-park-resource": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs",
|
||||
"bench:compare": "node config/scripts/compare-benchmark-artifacts.mjs"
|
||||
"bench:compare": "node config/scripts/compare-benchmark-artifacts.mjs",
|
||||
"test:e2e:remote-bulk-open-freeze": "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/remote-session-bulk-open-freeze-repro.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"test:e2e:ssh-docker-bulk-open-freeze": "node config/scripts/run-ssh-docker-bulk-open-freeze-e2e.mjs",
|
||||
"repro:live-remote-bulk-open-freeze": "node config/scripts/live-remote-bulk-open-freeze-repro.mjs",
|
||||
"repro:live-remote-realistic-freeze": "node config/scripts/live-remote-realistic-freeze-repro.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
@@ -205,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",
|
||||
|
||||
Generated
+34
-34
@@ -13,13 +13,13 @@ 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
|
||||
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
|
||||
'@xterm/xterm@6.1.0-beta.287':
|
||||
hash: 082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6
|
||||
hash: 037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8
|
||||
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
|
||||
node-pty@1.1.0:
|
||||
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
|
||||
@@ -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=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
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
|
||||
@@ -206,25 +206,25 @@ importers:
|
||||
version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
|
||||
'@xterm/addon-fit':
|
||||
specifier: 0.12.0-beta.287
|
||||
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
|
||||
'@xterm/addon-ligatures':
|
||||
specifier: 0.11.0-beta.287
|
||||
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
|
||||
'@xterm/addon-search':
|
||||
specifier: 0.17.0-beta.287
|
||||
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
|
||||
'@xterm/addon-unicode11':
|
||||
specifier: 0.10.0-beta.287
|
||||
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
|
||||
'@xterm/addon-web-links':
|
||||
specifier: 0.13.0-beta.287
|
||||
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
|
||||
'@xterm/addon-webgl':
|
||||
specifier: 0.20.0-beta.286
|
||||
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
|
||||
'@xterm/xterm':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
version: 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -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
|
||||
|
||||
@@ -9582,39 +9582,39 @@ snapshots:
|
||||
|
||||
'@xmldom/xmldom@0.8.13': {}
|
||||
|
||||
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
|
||||
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
|
||||
lru-cache: 11.5.1
|
||||
opentype.js: 2.0.0
|
||||
|
||||
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
'@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=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
'@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=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
|
||||
|
||||
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
|
||||
|
||||
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
|
||||
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
|
||||
|
||||
'@xterm/headless@6.1.0-beta.287': {}
|
||||
|
||||
'@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)': {}
|
||||
'@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)': {}
|
||||
|
||||
abbrev@4.0.0: {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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.'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatTerminalFocus } from './terminal-format'
|
||||
|
||||
describe('formatTerminalFocus', () => {
|
||||
it('distinguishes superseded navigation from a winning focus', () => {
|
||||
expect(
|
||||
formatTerminalFocus({
|
||||
focus: {
|
||||
handle: 'term_stale',
|
||||
tabId: 'tab-stale',
|
||||
worktreeId: 'worktree-1',
|
||||
navigated: false
|
||||
}
|
||||
})
|
||||
).toBe(
|
||||
'Focus request for terminal term_stale was superseded or host navigation was skipped (tab tab-stale).'
|
||||
)
|
||||
expect(
|
||||
formatTerminalFocus({
|
||||
focus: { handle: 'term_winner', tabId: 'tab-winner', worktreeId: 'worktree-1' }
|
||||
})
|
||||
).toBe('Focused terminal term_winner (tab tab-winner).')
|
||||
})
|
||||
})
|
||||
@@ -164,6 +164,9 @@ export function formatTerminalSplit(result: { split: RuntimeTerminalSplit }): st
|
||||
}
|
||||
|
||||
export function formatTerminalFocus(result: { focus: RuntimeTerminalFocus }): string {
|
||||
if (result.focus.navigated === false) {
|
||||
return `Focus request for terminal ${result.focus.handle} was superseded or host navigation was skipped (tab ${result.focus.tabId}).`
|
||||
}
|
||||
return `Focused terminal ${result.focus.handle} (tab ${result.focus.tabId}).`
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
|
||||
const REMOTE_CONTENT_YIELD_LINE_COUNT = 200
|
||||
const REMOTE_CONTENT_YIELD_CHAR_COUNT = 256 * 1024
|
||||
|
||||
/** Splits transcript content into lines. Without a signal there is nothing to
|
||||
* observe, so it splits in one pass; with one it yields to the event loop so a
|
||||
* cancelled scan stops mid-transcript instead of parsing megabytes for a caller
|
||||
* that already left. */
|
||||
export function remoteSessionContentLines(
|
||||
content: string,
|
||||
signal?: AbortSignal
|
||||
): Iterable<string> | AsyncIterable<string> {
|
||||
return signal ? cancellableContentLines(content, signal) : content.split(/\r?\n/)
|
||||
}
|
||||
|
||||
async function* cancellableContentLines(
|
||||
content: string,
|
||||
signal: AbortSignal
|
||||
): AsyncGenerator<string> {
|
||||
// Content below one yield window would otherwise never observe cancellation.
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
let lineStart = 0
|
||||
let yieldStart = 0
|
||||
let linesSinceYield = 0
|
||||
|
||||
for (let index = 0; index <= content.length; index++) {
|
||||
if (index < content.length && content.charCodeAt(index) !== 10) {
|
||||
continue
|
||||
}
|
||||
const lineEnd = index > lineStart && content.charCodeAt(index - 1) === 13 ? index - 1 : index
|
||||
yield content.slice(lineStart, lineEnd)
|
||||
lineStart = index + 1
|
||||
linesSinceYield++
|
||||
if (
|
||||
linesSinceYield >= REMOTE_CONTENT_YIELD_LINE_COUNT ||
|
||||
index - yieldStart >= REMOTE_CONTENT_YIELD_CHAR_COUNT
|
||||
) {
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
await yieldToEventLoop()
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
linesSinceYield = 0
|
||||
yieldStart = index
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,24 @@
|
||||
import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileStat, IFilesystemProvider } from '../providers/types'
|
||||
import type { FileStat } from '../providers/types'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import { recordRemoteSessionScanIssue } from './remote-session-scan-issues'
|
||||
import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import { errorMessage } from './session-scanner-values'
|
||||
|
||||
export async function statRemoteSessionFile(
|
||||
provider: IFilesystemProvider,
|
||||
provider: RemoteSessionFilesystemProvider,
|
||||
path: string,
|
||||
agent: AiVaultAgent,
|
||||
executionHostId: ExecutionHostId,
|
||||
issues: AiVaultScanIssue[],
|
||||
options?: { missingIsExpected?: boolean }
|
||||
options?: { missingIsExpected?: boolean; signal?: AbortSignal }
|
||||
): Promise<FileWithMtime | null> {
|
||||
try {
|
||||
throwIfAiVaultScanCancelled(options?.signal)
|
||||
const stat = await provider.stat(path)
|
||||
throwIfAiVaultScanCancelled(options?.signal)
|
||||
const mtimeMs = remoteSessionMtimeMs(stat)
|
||||
return {
|
||||
path,
|
||||
@@ -25,8 +30,14 @@ export async function statRemoteSessionFile(
|
||||
...(typeof stat.nlink === 'number' ? { nlink: stat.nlink } : {})
|
||||
}
|
||||
} catch (error) {
|
||||
throwIfAiVaultScanCancelled(options?.signal)
|
||||
if (!options?.missingIsExpected || !isMissingRemoteSessionPathError(error)) {
|
||||
issues.push({ executionHostId, agent, path, message: errorMessage(error) })
|
||||
recordRemoteSessionScanIssue(issues, {
|
||||
executionHostId,
|
||||
agent,
|
||||
path,
|
||||
message: errorMessage(error)
|
||||
})
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mapRemoteScanBatches } from './remote-session-scan-batching'
|
||||
|
||||
describe('mapRemoteScanBatches', () => {
|
||||
it('observes an abort that lands while the final batch yields', async () => {
|
||||
const controller = new AbortController()
|
||||
|
||||
await expect(
|
||||
mapRemoteScanBatches(
|
||||
['a', 'b'],
|
||||
2,
|
||||
async (item) => {
|
||||
controller.abort()
|
||||
return item
|
||||
},
|
||||
controller.signal
|
||||
)
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
|
||||
it('observes an abort for empty inputs that never enter the loop', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
await expect(
|
||||
mapRemoteScanBatches([], 8, async (item) => item, controller.signal)
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
|
||||
it('returns every batch result when nothing cancels', async () => {
|
||||
expect(await mapRemoteScanBatches([1, 2, 3], 2, async (item) => item * 2)).toEqual([2, 4, 6])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
|
||||
// Batching bounds concurrent remote round trips; the yield between batches keeps
|
||||
// the process responsive, and the batch boundary is where cancellation lands.
|
||||
export async function mapRemoteScanBatches<T, U>(
|
||||
items: readonly T[],
|
||||
concurrency: number,
|
||||
mapper: (item: T) => Promise<U>,
|
||||
signal?: AbortSignal
|
||||
): Promise<U[]> {
|
||||
const results: U[] = []
|
||||
for (let index = 0; index < items.length; index += concurrency) {
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
results.push(...(await Promise.all(items.slice(index, index + concurrency).map(mapper))))
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
// An abort can land while the last batch yields, and empty inputs never enter
|
||||
// the loop at all — observe it here so neither path returns as a success.
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types'
|
||||
|
||||
// Why: discovery batches (8 sources) each stat in batches of 8, and parse
|
||||
// batches read whole transcripts — nested fan-out put ~64 filesystem round
|
||||
// trips on one SSH mux or relay event loop at once, blowing the scan budget and
|
||||
// starving pty/fs/hook traffic. Every scan shares one in-flight ceiling.
|
||||
const REMOTE_SCAN_FILESYSTEM_CONCURRENCY = 8
|
||||
|
||||
/** Wraps a provider so all of one scan's filesystem calls share a single
|
||||
* in-flight ceiling, regardless of how the callers nest their batches. */
|
||||
export function limitRemoteScanFilesystemConcurrency(
|
||||
provider: RemoteSessionFilesystemProvider,
|
||||
maxInFlight: number = REMOTE_SCAN_FILESYSTEM_CONCURRENCY
|
||||
): RemoteSessionFilesystemProvider {
|
||||
const gate = createConcurrencyGate(maxInFlight)
|
||||
return {
|
||||
readDir: (dirPath) => gate(() => provider.readDir(dirPath)),
|
||||
readFile: (filePath) => gate(() => provider.readFile(filePath)),
|
||||
stat: (filePath) => gate(() => provider.stat(filePath))
|
||||
}
|
||||
}
|
||||
|
||||
function createConcurrencyGate(maxInFlight: number): <T>(run: () => Promise<T>) => Promise<T> {
|
||||
const limit = Math.max(1, Math.floor(maxInFlight))
|
||||
const waiting: (() => void)[] = []
|
||||
let inFlight = 0
|
||||
|
||||
return async <T>(run: () => Promise<T>): Promise<T> => {
|
||||
if (inFlight < limit) {
|
||||
inFlight++
|
||||
} else {
|
||||
// The releasing call hands its slot over directly, so a queued caller can
|
||||
// never race a fresh one into an over-limit slot.
|
||||
await new Promise<void>((resolve) => waiting.push(resolve))
|
||||
}
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
const next = waiting.shift()
|
||||
if (next) {
|
||||
next()
|
||||
} else {
|
||||
inFlight--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
|
||||
const REMOTE_SCAN_ISSUE_LIMIT = 500
|
||||
|
||||
export function recordRemoteSessionScanIssue(
|
||||
issues: AiVaultScanIssue[],
|
||||
issue: AiVaultScanIssue
|
||||
): void {
|
||||
if (issues.length < REMOTE_SCAN_ISSUE_LIMIT - 1) {
|
||||
issues.push(issue)
|
||||
return
|
||||
}
|
||||
if (issues.length === REMOTE_SCAN_ISSUE_LIMIT - 1) {
|
||||
// Kinded: this row is a scan notice, not a skipped transcript — the panel
|
||||
// counts unkinded issues as skipped transcript files.
|
||||
issues.push({
|
||||
executionHostId: issue.executionHostId,
|
||||
agent: issue.agent,
|
||||
kind: 'notice',
|
||||
path: 'Agent Session History scan',
|
||||
message: 'Additional scan issues were omitted.'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,50 @@
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values'
|
||||
import { remoteSessionContentLines } from './remote-session-content-lines'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types'
|
||||
|
||||
const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl'
|
||||
|
||||
export async function remoteCodexIndexTitles(args: {
|
||||
provider: IFilesystemProvider
|
||||
provider: RemoteSessionFilesystemProvider
|
||||
codexHome: string
|
||||
hostPlatform: RemoteHostPlatform
|
||||
titleCaches: Map<string, Promise<Map<string, string>>>
|
||||
signal?: AbortSignal
|
||||
}): Promise<Map<string, string>> {
|
||||
const cached = args.titleCaches.get(args.codexHome)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const pending = readRemoteCodexIndexTitles(args.provider, args.codexHome, args.hostPlatform)
|
||||
const pending = readRemoteCodexIndexTitles(
|
||||
args.provider,
|
||||
args.codexHome,
|
||||
args.hostPlatform,
|
||||
args.signal
|
||||
)
|
||||
args.titleCaches.set(args.codexHome, pending)
|
||||
return pending
|
||||
}
|
||||
|
||||
async function readRemoteCodexIndexTitles(
|
||||
provider: IFilesystemProvider,
|
||||
provider: RemoteSessionFilesystemProvider,
|
||||
codexHome: string,
|
||||
hostPlatform: RemoteHostPlatform
|
||||
hostPlatform: RemoteHostPlatform,
|
||||
signal?: AbortSignal
|
||||
): Promise<Map<string, string>> {
|
||||
const titleBySessionId = new Map<string, string>()
|
||||
try {
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
const { content, isBinary } = await provider.readFile(
|
||||
joinRemotePath(hostPlatform, codexHome, CODEX_SESSION_INDEX_FILE)
|
||||
)
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
if (isBinary) {
|
||||
return titleBySessionId
|
||||
}
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
for await (const line of remoteSessionContentLines(content, signal)) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
@@ -45,6 +56,7 @@ async function readRemoteCodexIndexTitles(
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
// Codex indexes are opportunistic; raw transcripts remain sufficient.
|
||||
}
|
||||
return titleBySessionId
|
||||
|
||||
@@ -6,6 +6,9 @@ import { isMissingRemoteSessionPathError, statRemoteSessionFile } from './remote
|
||||
import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import { errorMessage } from './session-scanner-values'
|
||||
import { mapRemoteScanBatches } from './remote-session-scan-batching'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import { recordRemoteSessionScanIssue } from './remote-session-scan-issues'
|
||||
import type {
|
||||
RemoteScannerContext,
|
||||
RemoteSessionCandidate,
|
||||
@@ -26,15 +29,22 @@ export async function discoverRemoteSourceCandidates(args: {
|
||||
? partitionSubagentTranscriptPaths(walked)
|
||||
: null
|
||||
const paths = partition ? partition.sessionFilePaths : walked
|
||||
const files = await mapDiscoveryConcurrently(paths, (path) =>
|
||||
statRemoteSessionFile(
|
||||
args.context.provider,
|
||||
path,
|
||||
args.source.agent,
|
||||
args.context.executionHostId,
|
||||
args.issues,
|
||||
{ missingIsExpected: Boolean(args.source.fixedChildFileSegments) }
|
||||
)
|
||||
const files = await mapRemoteScanBatches(
|
||||
paths,
|
||||
REMOTE_DISCOVERY_CONCURRENCY,
|
||||
(path) =>
|
||||
statRemoteSessionFile(
|
||||
args.context.provider,
|
||||
path,
|
||||
args.source.agent,
|
||||
args.context.executionHostId,
|
||||
args.issues,
|
||||
{
|
||||
missingIsExpected: Boolean(args.source.fixedChildFileSegments),
|
||||
signal: args.context.signal
|
||||
}
|
||||
),
|
||||
args.context.signal
|
||||
)
|
||||
return files
|
||||
.filter((file): file is FileWithMtime => Boolean(file))
|
||||
@@ -50,10 +60,12 @@ async function listRemoteFixedChildFiles(
|
||||
context: RemoteScannerContext,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<string[]> {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
let entries
|
||||
try {
|
||||
entries = await context.provider.readDir(source.rootDir)
|
||||
} catch (err) {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
recordRemoteDirectoryIssue(source, context.executionHostId, issues, source.rootDir, err)
|
||||
return []
|
||||
}
|
||||
@@ -73,10 +85,12 @@ async function walkRemoteSessionFiles(
|
||||
dirPath = source.rootDir,
|
||||
depth = 0
|
||||
): Promise<string[]> {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
let entries
|
||||
try {
|
||||
entries = await context.provider.readDir(dirPath)
|
||||
} catch (err) {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
recordRemoteDirectoryIssue(source, context.executionHostId, issues, dirPath, err)
|
||||
return []
|
||||
}
|
||||
@@ -84,6 +98,7 @@ async function walkRemoteSessionFiles(
|
||||
const extensions = new Set(source.extensions)
|
||||
const files: string[] = []
|
||||
for (const entry of entries) {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
const fullPath = joinRemotePath(context.hostPlatform, dirPath, entry.name)
|
||||
if (
|
||||
entry.isDirectory &&
|
||||
@@ -112,18 +127,12 @@ function recordRemoteDirectoryIssue(
|
||||
err: unknown
|
||||
): void {
|
||||
if (!isMissingRemoteSessionPathError(err)) {
|
||||
issues.push({ executionHostId, agent: source.agent, path, message: errorMessage(err) })
|
||||
recordRemoteSessionScanIssue(issues, {
|
||||
executionHostId,
|
||||
agent: source.agent,
|
||||
kind: 'host',
|
||||
path,
|
||||
message: errorMessage(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function mapDiscoveryConcurrently<T, U>(
|
||||
items: readonly T[],
|
||||
mapper: (item: T) => Promise<U>
|
||||
): Promise<U[]> {
|
||||
const results: U[] = []
|
||||
for (let index = 0; index < items.length; index += REMOTE_DISCOVERY_CONCURRENCY) {
|
||||
const batch = items.slice(index, index + REMOTE_DISCOVERY_CONCURRENCY)
|
||||
results.push(...(await Promise.all(batch.map(mapper))))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
@@ -25,7 +25,9 @@ type RemoteContentParser = (
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions
|
||||
options: RemoteParserOptions,
|
||||
// Line-based parsers iterate cancellably; whole-document parsers ignore it.
|
||||
signal?: AbortSignal
|
||||
) => Promise<AiVaultSession | null> | AiVaultSession | null
|
||||
|
||||
export function remoteSessionSources(
|
||||
@@ -126,7 +128,8 @@ function remoteAntigravitySource(
|
||||
file,
|
||||
content,
|
||||
context.hostPlatform.os,
|
||||
parserOptions(context)
|
||||
parserOptions(context),
|
||||
context.signal
|
||||
)
|
||||
return session ? context.antigravityWorkspaceResolver.enrich(session, historyPath) : null
|
||||
}
|
||||
@@ -150,7 +153,9 @@ function source(
|
||||
filePredicate,
|
||||
directoryPredicate,
|
||||
parse: (file, content, context) =>
|
||||
Promise.resolve(parseContent(file, content, context.hostPlatform.os, parserOptions(context)))
|
||||
Promise.resolve(
|
||||
parseContent(file, content, context.hostPlatform.os, parserOptions(context), context.signal)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,13 +198,15 @@ function remoteCodexSources(
|
||||
codexHome,
|
||||
executionHostId: context.executionHostId,
|
||||
executionHostPlatform: context.hostPlatform.os,
|
||||
signal: context.signal,
|
||||
readIndexedTitle: async (sessionId) =>
|
||||
(
|
||||
await remoteCodexIndexTitles({
|
||||
provider: context.provider,
|
||||
codexHome,
|
||||
hostPlatform,
|
||||
titleCaches: context.titleCaches
|
||||
titleCaches: context.titleCaches,
|
||||
signal: context.signal
|
||||
})
|
||||
).get(sessionId) ?? null
|
||||
})
|
||||
@@ -233,27 +240,30 @@ function piParser(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('pi', file, content, platform, options)
|
||||
return parseMessageGraphSessionContent('pi', file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
function ompParser(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('omp', file, content, platform, options)
|
||||
return parseMessageGraphSessionContent('omp', file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
function openClawParser(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('openclaw', file, content, platform, options)
|
||||
return parseMessageGraphSessionContent('openclaw', file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
function remotePathSegments(path: string): string[] {
|
||||
|
||||
@@ -6,13 +6,19 @@ import type { FileWithMtime } from './session-scanner-types'
|
||||
import type { AntigravityWorkspaceResolver } from './session-scanner-antigravity-history'
|
||||
|
||||
export type RemoteScannerContext = {
|
||||
provider: IFilesystemProvider
|
||||
provider: RemoteSessionFilesystemProvider
|
||||
executionHostId: ExecutionHostId
|
||||
hostPlatform: RemoteHostPlatform
|
||||
signal?: AbortSignal
|
||||
titleCaches: Map<string, Promise<Map<string, string>>>
|
||||
antigravityWorkspaceResolver: AntigravityWorkspaceResolver
|
||||
}
|
||||
|
||||
export type RemoteSessionFilesystemProvider = Pick<
|
||||
IFilesystemProvider,
|
||||
'readDir' | 'readFile' | 'stat'
|
||||
>
|
||||
|
||||
export type RemoteParserOptions = {
|
||||
executionHostId: ExecutionHostId
|
||||
executionHostPlatform: NodeJS.Platform
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user