mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
merge: origin/main (STA-3077 SSH reconnect) into the daemon-hold branch
Three conflicts, all in the ownership-critical files, and the two changes turn out to be making the same argument from opposite ends. STA-3077 made hasPty three-valued: null now means "this provider cannot answer", where before the only answers were yes and no. Its reasoning — an owner that cannot answer must stay unknown rather than become a liveness proof — is the same rule this branch applies to daemon occupancy. Both sides kept: the new signature, and the owner gate that sits above it. The gate needed no change, because `hasPty?.(id) !== true` already reads a null as "not proven" and refuses. But it needed a test. Mutating `!== true` to `=== false` left all 1525 daemon tests green: every double in the suite answers false, which makes the two predicates indistinguishable, while in production the difference is now the whole point — a null read as ownership lets the in-process fallback answer for a daemon-owned session, closing the pane while the agent runs on as an orphan. Two tests added at that boundary; the mutation is caught. pty:signal keeps both additions in the order that makes sense: their superseded-id fence first, then the routing call whose rejection is already handled.
This commit is contained in:
@@ -56,17 +56,15 @@ CI enforces this for `src/preload/` and `src/shared/` — see `docs/preload-type
|
||||
|
||||
## Pull Requests
|
||||
|
||||
Each pull request should:
|
||||
Each pull request should follow [`.github/pull_request_template.md`](./pull_request_template.md). In particular:
|
||||
|
||||
- explain the user-visible change
|
||||
- stay focused on a single topic when possible
|
||||
- include screenshots or screen recordings for new UI or behavior changes
|
||||
- open with an ELI5 of the change (plain language paragraph; the PR title is the one-liner)
|
||||
- explain what changed and why, and stay focused on a single topic when possible
|
||||
- for any UI or interaction change, attach **before and after** screenshots (or short videos); if there is no visual change, say `No visual change` and why
|
||||
- include high-quality tests when behavior changes or bug fixes warrant them
|
||||
- include a brief code review summary from your AI coding agent that explicitly checks cross-platform compatibility, SSH/remote/local compatibility, supported agent and integration compatibility, performance risk, UI quality when applicable, and basic security risk
|
||||
- mention any platform-specific, remote/SSH-specific, agent-specific, integration-specific, or git-provider-specific behavior and testing notes
|
||||
- **Include your X (Twitter) handle!** We love giving shoutouts to our contributors when we merge features on [@orca_build](https://x.com/orca_build).
|
||||
|
||||
If there is no visual change, say that explicitly in the PR description.
|
||||
- **Include your X (Twitter) handle** in the PR template Author section — we shout out contributors when we merge features on [@orca_build](https://x.com/orca_build).
|
||||
|
||||
## Release Process
|
||||
|
||||
|
||||
@@ -1,29 +1,52 @@
|
||||
## Summary
|
||||
## ELI5
|
||||
|
||||
Describe the user-visible change.
|
||||
<!-- Simple high-level explanation -->
|
||||
|
||||
## Screenshots
|
||||
## What Changed
|
||||
|
||||
- Add screenshots or a screen recording for any new or changed UI behavior.
|
||||
- If there is no visual change, say `No visual change`.
|
||||
<!-- Describe the change clearly and keep scope tight. -->
|
||||
|
||||
## Why
|
||||
|
||||
<!-- What problem does this solve, and why is this approach right? -->
|
||||
|
||||
## Linked Issue
|
||||
|
||||
<!-- Link the issue this PR addresses, there should ALWAYS be one -->
|
||||
|
||||
Fixes #
|
||||
|
||||
## Visual Proof
|
||||
<!-- REQUIRED for UI / behavior changes. Please attach a BEFORE and AFTER that can easily tabbed/switched. Use videos for when appropriate over screenshots -->
|
||||
<!-- If there is truly no visual or interaction change, write exactly: `N/A` and briefly say why. -->
|
||||
<!-- For attachments NEVER add directly to the PR files (do not commit to files), use `gh image` extension or drag + drop (works for any attachment) -->
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] `pnpm lint`
|
||||
- [ ] `pnpm typecheck`
|
||||
- [ ] `pnpm test`
|
||||
- [ ] `pnpm build`
|
||||
- [ ] Added or updated high-quality tests that would catch regressions, or explained why tests were not needed
|
||||
<!-- How did you verify this? Steps a reviewer can follow. Which platforms did you actually test (macOS / Linux / Windows / SSH)? -->
|
||||
|
||||
## AI Review Report
|
||||
- [ ] I manually tested these changes locally
|
||||
- [ ] Automated tests added/updated, or explained why not below
|
||||
|
||||
Summarize the code review you ran with your AI coding agent. Include the main risks it checked, what it flagged, and what you changed or verified as a result.
|
||||
Confirm that the review explicitly checked cross-platform compatibility for macOS, Linux, and Windows, including shortcuts, labels, paths, shell behavior, and any Electron-specific platform differences touched by this PR.
|
||||
|
||||
## Security Audit
|
||||
## AI Disclosure
|
||||
<!-- DO NOT FILL IN IF YOU ARE STABLYAI TEAM MEMBER (INTERNAL CONTRIBUTOR), IGNORE SECTION: -->
|
||||
<!-- Which AI model if anyone was used, please state the details -->
|
||||
|
||||
Provide a basic security audit summary from your AI coding agent. Call out any input handling, command execution, path handling, auth, secrets, dependency, or IPC risks that were reviewed, plus any follow-up needed.
|
||||
## Review
|
||||
|
||||
## Notes
|
||||
Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance
|
||||
|
||||
Call out any platform-specific behavior, risks, or follow-up work.
|
||||
## Checklist
|
||||
|
||||
- [ ] This PR is small and focused
|
||||
- [ ] I explained what changed and why (including ELI5)
|
||||
- [ ] Before/after screenshots or videos attached for UI changes, or `N/A` with reason
|
||||
- [ ] Self-reviewed for correctness, security, and performance
|
||||
- [ ] Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
|
||||
- [ ] `pnpm lint`, `pnpm typecheck`, `pnpm test`, and `pnpm build` pass (or CI will cover; local preferred)
|
||||
|
||||
## Author
|
||||
|
||||
- X / Twitter: @your_handle
|
||||
<!-- Optional but appreciated -->
|
||||
|
||||
+22
-13
@@ -126,7 +126,7 @@ jobs:
|
||||
# scripts still need the Linux toolchain even though this shard reuses
|
||||
# the prebuilt Electron output.
|
||||
- name: Install native build tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zsh
|
||||
|
||||
# Why: Electron on Linux needs an X display even when the app
|
||||
# suppresses mainWindow.show() via ORCA_E2E_HEADLESS. xvfb provides a
|
||||
@@ -174,7 +174,7 @@ jobs:
|
||||
# ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron
|
||||
# launches but never creates a BrowserWindow.
|
||||
- name: Run E2E tests (${{ matrix.shard_name }})
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e --shard=${{ matrix.shard }}
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 pnpm run test:e2e --shard=${{ matrix.shard }}
|
||||
|
||||
# Why: Playwright retains traces/screenshots only on failure. Uploading
|
||||
# them as an artifact makes post-mortem debugging on CI possible without
|
||||
@@ -202,7 +202,7 @@ jobs:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Install native build and headless UI tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb zsh
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -232,8 +232,15 @@ jobs:
|
||||
env:
|
||||
TEST_FILES_JSON: ${{ inputs.test_files }}
|
||||
run: |
|
||||
mapfile -t TEST_FILES < <(jq -r '.[]' <<<"$TEST_FILES_JSON")
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 \
|
||||
mapfile -t TEST_FILES < <(jq -r '.[] | select(
|
||||
. != "tests/e2e/ssh-startup-exec-readiness.spec.ts" and
|
||||
. != "tests/e2e/paired-startup-exec-readiness.spec.ts"
|
||||
)' <<<"$TEST_FILES_JSON")
|
||||
if [ "${#TEST_FILES[@]}" -eq 0 ]; then
|
||||
echo "Changed startup-readiness specs are owned by the dedicated live lane."
|
||||
exit 0
|
||||
fi
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 \
|
||||
pnpm run test:e2e "${TEST_FILES[@]}" --workers=1
|
||||
|
||||
- name: Upload Playwright traces
|
||||
@@ -248,10 +255,13 @@ jobs:
|
||||
ssh-docker-watcher-isolation:
|
||||
name: ssh docker watcher isolation
|
||||
needs: build
|
||||
if: inputs.test_files == ''
|
||||
if: >-
|
||||
inputs.test_files == '' ||
|
||||
contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') ||
|
||||
contains(inputs.test_files, 'tests/e2e/paired-startup-exec-readiness.spec.ts')
|
||||
runs-on: ubuntu-latest
|
||||
# Why 35: the terminal parking + retention specs below add two more
|
||||
# docker-rig tests capped at 240s each on top of the watcher isolation pair.
|
||||
# Why 35: the parking, retention, startup-exec, and paired parity specs run
|
||||
# serially on isolated Electron/SSH fixtures after watcher isolation.
|
||||
timeout-minutes: 35
|
||||
|
||||
steps:
|
||||
@@ -261,7 +271,7 @@ jobs:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Install native build and headless UI tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 xvfb
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 xvfb zsh
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -295,10 +305,9 @@ jobs:
|
||||
- name: Run Docker SSH watcher isolation E2E
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation
|
||||
|
||||
# Why always(): these specs gate the C1 terminal parking/retention budget
|
||||
# over a real SSH host and run in no other lane, so a watcher-isolation
|
||||
# failure above must not silently skip them.
|
||||
- name: Run Docker SSH terminal parking + retention E2E
|
||||
# Why always(): this lane gates SSH parking/retention plus startup-exec
|
||||
# readiness across live SSH, headed paired, and headless serve topologies.
|
||||
- name: Run Docker SSH terminal parking + startup readiness E2E
|
||||
if: always()
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
|
||||
|
||||
|
||||
@@ -23,10 +23,9 @@ jobs:
|
||||
platform: linux
|
||||
- os: macos-15
|
||||
platform: mac
|
||||
# Why: Windows golden E2E is temporarily disabled on CI while its
|
||||
# flaky runner-only failures are investigated.
|
||||
# - os: windows-latest
|
||||
# platform: windows
|
||||
# Match the release gate: Windows runs the fresh-startup golden only.
|
||||
- os: windows-2022
|
||||
platform: windows
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -67,12 +66,22 @@ jobs:
|
||||
run: |
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:posix-profile-index-golden
|
||||
|
||||
- name: Run golden E2E tests on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:posix-profile-index-golden
|
||||
|
||||
- name: Run golden E2E tests on Windows
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:SKIP_BUILD = '1'
|
||||
$env:ORCA_E2E_FORWARD_APP_LOGS = '1'
|
||||
pnpm run test:e2e:windows-fresh-startup-golden
|
||||
|
||||
- name: Upload Playwright traces
|
||||
if: failure()
|
||||
|
||||
@@ -176,6 +176,12 @@ jobs:
|
||||
shell_contracts:
|
||||
name: shell contracts
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Why: the suites below gate their live fish tests on the binary, which is
|
||||
# right on a developer machine and wrong here — this job is a required check
|
||||
# and its fish lane is the only end-to-end guard for #9993, so a skip would
|
||||
# report green with nothing exercised. Turns those skips into failures.
|
||||
ORCA_REQUIRE_FISH: '1'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -186,8 +192,46 @@ jobs:
|
||||
# Why fish: shell-ready.test.ts gates its live fish test on the binary being
|
||||
# present, so without this the fish barrier is only covered by config-shape
|
||||
# assertions and never actually exercised.
|
||||
# Why release-4: DECSET 2031 arming lives in the fish 4.0 Rust tty_handoff, and
|
||||
# fish-color-scheme-child-stdin.node-pty.test.ts (#9993) needs it. Noble ships
|
||||
# 3.7, so the PPA is what makes that lane real.
|
||||
- name: Install zsh and fish
|
||||
run: sudo apt-get update && sudo apt-get install -y zsh fish
|
||||
run: |
|
||||
# Why the update/PPA/fish steps are tolerant: a repo the runner image already
|
||||
# ships can lack a Release file for this suite, and a failed add-apt-repository
|
||||
# still leaves its list entry behind — either makes `apt-get update` exit
|
||||
# non-zero and would red this required check over something unrelated to the
|
||||
# PR. Every fish outcome is judged by the version gate below instead, so only
|
||||
# the zsh install (which has no such gate) stays fatal here.
|
||||
sudo apt-get update || true
|
||||
# Why retry only here: adding the PPA is the network-flaky step, and the
|
||||
# version gate below is fatal, so a transient Launchpad blip would
|
||||
# otherwise red a required check on PRs unrelated to shells.
|
||||
for attempt in 1 2 3; do
|
||||
sudo add-apt-repository -y ppa:fish-shell/release-4 && break
|
||||
echo "add-apt-repository attempt ${attempt} failed; retrying" >&2
|
||||
sudo add-apt-repository -y -r ppa:fish-shell/release-4 || true
|
||||
sleep 5
|
||||
done
|
||||
sudo apt-get update || true
|
||||
# Why both shells on one line: pr-workflow-parallelism.test.mjs parses only the
|
||||
# first install command in this step to prove the lane really installs them.
|
||||
sudo apt-get install -y zsh fish
|
||||
|
||||
# Separate from the install so the failure names the contract, not an apt error.
|
||||
# ORCA_REQUIRE_FISH re-checks this at test time; this step just fails in seconds
|
||||
# instead of after a full dependency install.
|
||||
- name: Require fish 4+
|
||||
run: |
|
||||
version="$(fish --version 2>/dev/null || true)"
|
||||
major="${version##*version }"
|
||||
major="${major%%.*}"
|
||||
case "$major" in '' | *[!0-9]*) major=0 ;; esac
|
||||
echo "${version:-<fish not installed>}"
|
||||
if [ "$major" -lt 4 ]; then
|
||||
echo "::error::shell contracts needs fish 4+ (DECSET 2031 arming, #9993) but got '${version:-none}'. Fix the ppa:fish-shell/release-4 install rather than letting the fish lane skip." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
@@ -195,12 +239,14 @@ jobs:
|
||||
|
||||
- name: Test real shell contracts
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 \
|
||||
src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
|
||||
src/main/daemon/shell-ready.test.ts \
|
||||
src/main/daemon/node-pty-fd-leak.test.ts \
|
||||
src/main/providers/local-pty-shell-ready.test.ts \
|
||||
src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
|
||||
src/shared/posix-command-path-lookup.test.ts
|
||||
|
||||
test:
|
||||
@@ -230,11 +276,13 @@ jobs:
|
||||
- name: Test shard
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
|
||||
--exclude=src/main/daemon/shell-ready.test.ts \
|
||||
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
|
||||
--exclude=src/main/providers/local-pty-shell-ready.test.ts \
|
||||
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
|
||||
--exclude=src/shared/posix-command-path-lookup.test.ts \
|
||||
--exclude=tests/e2e/cross-version-wire/** \
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
@@ -378,8 +426,12 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Test Windows PowerShell CLI boundary
|
||||
run: pnpm exec vitest run --config config/vitest.config.ts src/main/cli/wsl-cli-powershell-boundary.test.ts
|
||||
- name: Test Windows-specific boundaries
|
||||
run: >-
|
||||
pnpm exec vitest run --config config/vitest.config.ts
|
||||
src/main/cli/wsl-cli-powershell-boundary.test.ts
|
||||
src/main/orca-profiles/profile-index-store.test.ts
|
||||
src/shared/secure-file-fsync-flags.test.ts
|
||||
|
||||
- name: Build package inputs
|
||||
run: pnpm run build:release
|
||||
|
||||
@@ -783,7 +783,7 @@ jobs:
|
||||
{
|
||||
echo "## Release E2E Signal"
|
||||
echo ""
|
||||
echo "- Terminal rendering golden is release-blocking."
|
||||
echo "- Platform golden E2E is release-blocking: terminal rendering and restrictive-umask profile writes on Linux/macOS, plus fresh startup on Windows."
|
||||
echo "- Full E2E runs separately after publication and cannot change the release result."
|
||||
echo "- Terminal rendering release evidence is diagnostic/non-blocking."
|
||||
echo ""
|
||||
@@ -817,7 +817,7 @@ jobs:
|
||||
terminal-rendering-golden:
|
||||
needs: cut
|
||||
if: needs.cut.outputs.should_release == 'true'
|
||||
name: terminal rendering golden ${{ matrix.platform }}
|
||||
name: golden e2e ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
@@ -830,10 +830,10 @@ jobs:
|
||||
platform: linux
|
||||
- os: macos-15
|
||||
platform: mac
|
||||
# Why: Windows terminal rendering golden is temporarily disabled on
|
||||
# CI while its flaky runner-only failures are investigated.
|
||||
# - os: windows-latest
|
||||
# platform: windows
|
||||
# Windows terminal rendering remains flaky; keep its blocking signal
|
||||
# scoped to the fresh-profile startup regression from #14130.
|
||||
- os: windows-2022
|
||||
platform: windows
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -866,22 +866,34 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Electron app for terminal rendering golden
|
||||
- name: Build Electron app for platform golden
|
||||
run: npx electron-vite build --mode e2e
|
||||
|
||||
- name: Run terminal rendering golden on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
run: |
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:posix-profile-index-golden
|
||||
|
||||
- name: Run terminal rendering golden on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
run: |
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:posix-profile-index-golden
|
||||
|
||||
- name: Run fresh-startup golden on Windows
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:SKIP_BUILD = '1'
|
||||
$env:ORCA_E2E_FORWARD_APP_LOGS = '1'
|
||||
pnpm run test:e2e:windows-fresh-startup-golden
|
||||
|
||||
- name: Upload Playwright traces
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: terminal-rendering-golden-${{ matrix.platform }}-playwright-traces
|
||||
name: golden-e2e-${{ matrix.platform }}-playwright-traces
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
@@ -102,7 +102,12 @@ docs/**
|
||||
!docs/reference/linux-glibc-compatibility.md
|
||||
!docs/reference/relay-grace-time-reconfiguration.md
|
||||
!docs/reference/remote-wire-compatibility.md
|
||||
!docs/reference/terminal-session-behavior-contract.md
|
||||
!docs/reference/terminal-session-correctness-handoff-2026-08-07/
|
||||
!docs/reference/terminal-session-correctness-handoff-2026-08-07/**
|
||||
!docs/reference/renderer-agent-status-performance.md
|
||||
!docs/reference/windows-setup-shell.md
|
||||
!docs/reference/worktree-scan-fingerprint.md
|
||||
|
||||
# Stably CLI (only docs/ are tracked)
|
||||
.stably/*
|
||||
@@ -125,6 +130,10 @@ playwright-report/
|
||||
/.agents/skills/
|
||||
/skills-lock.json
|
||||
|
||||
# Generated Clawpatch state includes machine-local paths and review records.
|
||||
/.clawpatch/
|
||||
/mobile/.clawpatch/
|
||||
|
||||
# Agent hook runtime endpoints (machine-local secrets)
|
||||
/agent-hooks/
|
||||
validation-screenshots/
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"typescript/array-type": "error",
|
||||
"typescript/consistent-generic-constructors": "error",
|
||||
"typescript/consistent-indexed-object-style": "error",
|
||||
"typescript/consistent-type-assertions": "error",
|
||||
"typescript/consistent-type-definitions": ["error", "type"],
|
||||
@@ -68,12 +69,16 @@
|
||||
"typescript/prefer-function-type": "error",
|
||||
"typescript/prefer-includes": "error",
|
||||
"typescript/prefer-optional-chain": "error",
|
||||
"typescript/prefer-ts-expect-error": "error",
|
||||
"typescript/switch-exhaustiveness-check": [
|
||||
"error",
|
||||
{ "allowDefaultCaseForExhaustiveSwitch": false }
|
||||
],
|
||||
"curly": "error",
|
||||
"no-unneeded-ternary": "error",
|
||||
"no-else-return": "error",
|
||||
"prefer-exponentiation-operator": "error",
|
||||
"prefer-object-has-own": "error",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
@@ -90,11 +95,16 @@
|
||||
"no-useless-return": "error",
|
||||
"prefer-template": "error",
|
||||
"unicorn/consistent-empty-array-spread": "error",
|
||||
"unicorn/consistent-existence-index-check": "error",
|
||||
"unicorn/empty-brace-spaces": "error",
|
||||
"unicorn/error-message": "error",
|
||||
"unicorn/no-array-fill-with-reference-type": "warn",
|
||||
"unicorn/no-array-reverse": "error",
|
||||
"unicorn/no-instanceof-builtins": "error",
|
||||
"unicorn/no-typeof-undefined": "error",
|
||||
"unicorn/no-unnecessary-array-splice-count": "error",
|
||||
"unicorn/no-useless-promise-resolve-reject": "error",
|
||||
"unicorn/no-zero-fractions": "error",
|
||||
"unicorn/prefer-array-find": "error",
|
||||
"unicorn/prefer-array-flat-map": "warn",
|
||||
"unicorn/prefer-array-index-of": "error",
|
||||
|
||||
@@ -8,7 +8,7 @@ const configByTarget = {
|
||||
renderer: { renderer: electronViteConfig.renderer }
|
||||
}
|
||||
|
||||
if (!target || !Object.prototype.hasOwnProperty.call(configByTarget, target)) {
|
||||
if (!target || !Object.hasOwn(configByTarget, target)) {
|
||||
throw new Error(`Invalid ORCA_ELECTRON_VITE_TARGET: ${target ?? '<unset>'}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ inline src/main/agent-hooks/server.test.ts
|
||||
inline src/main/agent-hooks/server.ts
|
||||
inline src/main/amp/hook-service.ts
|
||||
inline src/main/antigravity/hook-service.ts
|
||||
inline src/main/attribution/terminal-attribution.ts
|
||||
inline src/main/automations/external-manager.ts
|
||||
inline src/main/automations/hermes-cron-output.ts
|
||||
inline src/main/browser/agent-browser-bridge.test.ts
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"import/namespace": "warn",
|
||||
"import/no-cycle": ["warn", { "maxDepth": 3 }],
|
||||
"import/no-duplicates": "warn",
|
||||
"import/no-empty-named-blocks": "warn",
|
||||
"import/no-self-import": "warn"
|
||||
},
|
||||
"overrides": [
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"rules": {
|
||||
"typescript/await-thenable": "warn",
|
||||
"typescript/no-redundant-type-constituents": "warn",
|
||||
"typescript/restrict-plus-operands": "warn",
|
||||
"typescript/restrict-template-expressions": "warn",
|
||||
"typescript/switch-exhaustiveness-check": [
|
||||
|
||||
+1071
-46
File diff suppressed because it is too large
Load Diff
@@ -79,7 +79,7 @@ try {
|
||||
|
||||
function readArg(name) {
|
||||
const index = args.indexOf(name)
|
||||
return index >= 0 ? args[index + 1] : undefined
|
||||
return index !== -1 ? args[index + 1] : undefined
|
||||
}
|
||||
|
||||
function embeddedInfoPlist(identifier) {
|
||||
|
||||
@@ -56,5 +56,5 @@ function findFrameworkCompiler(env) {
|
||||
|
||||
function readArg(name) {
|
||||
const index = process.argv.indexOf(name)
|
||||
return index >= 0 ? process.argv[index + 1] : undefined
|
||||
return index !== -1 ? process.argv[index + 1] : undefined
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ function source(path) {
|
||||
function sourceBetween(contents, startMarker, endMarker) {
|
||||
const start = contents.indexOf(startMarker)
|
||||
const end = contents.indexOf(endMarker, start + startMarker.length)
|
||||
if (start < 0 || end < 0) {
|
||||
if (start === -1 || end === -1) {
|
||||
throw new Error(`Missing source boundary: ${startMarker} → ${endMarker}`)
|
||||
}
|
||||
return contents.slice(start, end)
|
||||
|
||||
@@ -196,7 +196,8 @@ describe('Electron Vite output contract', () => {
|
||||
})
|
||||
|
||||
it('rejects prototype properties as build targets', () => {
|
||||
expect(targetConfig).toContain('Object.prototype.hasOwnProperty.call(configByTarget, target)')
|
||||
// Own-property check only: an inherited key like `constructor` must not select a build target.
|
||||
expect(targetConfig).toContain('Object.hasOwn(configByTarget, target)')
|
||||
})
|
||||
|
||||
it('gives the dev terminal daemon helper the TCC identity watched by Orca', () => {
|
||||
|
||||
@@ -45,7 +45,7 @@ function readRuntimeArg() {
|
||||
}
|
||||
|
||||
const runtimeIndex = process.argv.indexOf('--runtime')
|
||||
if (runtimeIndex >= 0) {
|
||||
if (runtimeIndex !== -1) {
|
||||
return process.argv[runtimeIndex + 1]
|
||||
}
|
||||
|
||||
|
||||
@@ -227,7 +227,7 @@ function readGitBlobs(objectShas) {
|
||||
let offset = 0
|
||||
for (const requestedSha of uniqueShas) {
|
||||
const headerEnd = output.indexOf(10, offset)
|
||||
if (headerEnd < 0) {
|
||||
if (headerEnd === -1) {
|
||||
throw new Error(`Missing git cat-file header for ${requestedSha}`)
|
||||
}
|
||||
const header = output.subarray(offset, headerEnd).toString('utf8')
|
||||
@@ -648,8 +648,8 @@ async function main() {
|
||||
const argv = process.argv.slice(2)
|
||||
const rebuildFromTags = argv.includes('--rebuild-from-tags')
|
||||
const releaseIndex = argv.indexOf('--release')
|
||||
const releaseVersion = releaseIndex >= 0 ? argv[releaseIndex + 1] : null
|
||||
if (releaseIndex >= 0 && !releaseVersion) {
|
||||
const releaseVersion = releaseIndex !== -1 ? argv[releaseIndex + 1] : null
|
||||
if (releaseIndex !== -1 && !releaseVersion) {
|
||||
throw new Error('--release requires a version argument, e.g. --release 1.4.160')
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
|
||||
function parseCpuTimeSeconds(value) {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
const [dayOrTime, maybeTime] = trimmed.includes('-') ? trimmed.split('-', 2) : [null, trimmed]
|
||||
const days = dayOrTime === null ? 0 : Number(dayOrTime)
|
||||
const parts = maybeTime.split(':').map(Number)
|
||||
if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) {
|
||||
return null
|
||||
}
|
||||
if (parts.length === 3) {
|
||||
return days * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2]
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return days * 86400 + parts[0] * 60 + parts[1]
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
return days * 86400 + parts[0]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseUnixProcesses(stdout) {
|
||||
const rows = []
|
||||
for (const raw of stdout.split('\n')) {
|
||||
const line = raw.trim()
|
||||
if (!line) {
|
||||
continue
|
||||
}
|
||||
const match = line.match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.+)$/)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
rows.push({
|
||||
pid: Number(match[1]),
|
||||
ppid: Number(match[2]),
|
||||
percentCpu: Number(match[3]),
|
||||
rssBytes: Number(match[4]) * 1024,
|
||||
cpuTimeSeconds: parseCpuTimeSeconds(match[5]),
|
||||
command: match[6]
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function readUnixProcesses() {
|
||||
const stdout = execFileSync('ps', ['-axo', 'pid=,ppid=,pcpu=,rss=,cputime=,command='], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
|
||||
maxBuffer: 20 * 1024 * 1024
|
||||
})
|
||||
return parseUnixProcesses(stdout)
|
||||
}
|
||||
|
||||
function readWindowsProcesses() {
|
||||
const script =
|
||||
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,WorkingSetSize,CommandLine | ConvertTo-Json -Compress'
|
||||
const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', script], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 20 * 1024 * 1024
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr || 'PowerShell process enumeration failed')
|
||||
}
|
||||
const parsed = JSON.parse(result.stdout || '[]')
|
||||
const entries = Array.isArray(parsed) ? parsed : [parsed]
|
||||
return entries.map((entry) => ({
|
||||
pid: Number(entry.ProcessId),
|
||||
ppid: Number(entry.ParentProcessId),
|
||||
percentCpu: 0,
|
||||
cpuTimeSeconds: null,
|
||||
rssBytes: Number(entry.WorkingSetSize) || 0,
|
||||
command: String(entry.CommandLine || '')
|
||||
}))
|
||||
}
|
||||
|
||||
export function readProcessRows() {
|
||||
return process.platform === 'win32' ? readWindowsProcesses() : readUnixProcesses()
|
||||
}
|
||||
|
||||
export function descendantsOf(rows, rootPid) {
|
||||
const children = new Map()
|
||||
for (const row of rows) {
|
||||
const list = children.get(row.ppid) ?? []
|
||||
list.push(row)
|
||||
children.set(row.ppid, list)
|
||||
}
|
||||
const result = []
|
||||
const stack = [rootPid]
|
||||
const seen = new Set()
|
||||
while (stack.length > 0) {
|
||||
const pid = stack.pop()
|
||||
if (seen.has(pid)) {
|
||||
continue
|
||||
}
|
||||
seen.add(pid)
|
||||
const row = rows.find((candidate) => candidate.pid === pid)
|
||||
if (row) {
|
||||
result.push(row)
|
||||
}
|
||||
for (const child of children.get(pid) ?? []) {
|
||||
stack.push(child.pid)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function classify(row, rootPid) {
|
||||
const command = row.command.toLowerCase()
|
||||
if (row.pid === rootPid) {
|
||||
return 'main'
|
||||
}
|
||||
if (command.includes('daemon-entry')) {
|
||||
return 'daemon'
|
||||
}
|
||||
if (command.includes('--type=gpu-process')) {
|
||||
return 'gpu'
|
||||
}
|
||||
if (command.includes('--type=renderer')) {
|
||||
return 'renderer'
|
||||
}
|
||||
if (command.includes('--type=utility')) {
|
||||
return 'utility'
|
||||
}
|
||||
if (command.includes('--type=')) {
|
||||
return 'electron-other'
|
||||
}
|
||||
if (command.includes('node') || command.includes('/pi') || command.endsWith(' pi')) {
|
||||
return 'agent-or-node'
|
||||
}
|
||||
return 'other-descendant'
|
||||
}
|
||||
|
||||
const DEFAULT_WORKLOAD_OVERRUN_LIMIT_MS = 120_000
|
||||
|
||||
export async function sampleProcessTreeUntilWorkloadsComplete({
|
||||
rootPid,
|
||||
requestedDurationMs,
|
||||
intervalMs,
|
||||
workloadPromise,
|
||||
maxWorkloadOverrunMs = DEFAULT_WORKLOAD_OVERRUN_LIMIT_MS,
|
||||
readRows = readProcessRows,
|
||||
now = Date.now,
|
||||
wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}) {
|
||||
const samplingStartedAt = now()
|
||||
const requestedDeadline = samplingStartedAt + requestedDurationMs
|
||||
const hardDeadline = requestedDeadline + maxWorkloadOverrunMs
|
||||
let workloadSettled = false
|
||||
let workloadResult
|
||||
let workloadError
|
||||
let workloadSettledAt = null
|
||||
void workloadPromise.then(
|
||||
(result) => {
|
||||
workloadResult = result
|
||||
workloadSettled = true
|
||||
workloadSettledAt = now()
|
||||
},
|
||||
(error) => {
|
||||
workloadError = error
|
||||
workloadSettled = true
|
||||
workloadSettledAt = now()
|
||||
}
|
||||
)
|
||||
const samples = []
|
||||
let previousSnapshot = null
|
||||
const needsFinalWorkloadSample = () =>
|
||||
workloadSettledAt !== null && (previousSnapshot?.at ?? -Infinity) < workloadSettledAt
|
||||
while (
|
||||
now() <= requestedDeadline ||
|
||||
samples.length === 0 ||
|
||||
!workloadSettled ||
|
||||
needsFinalWorkloadSample()
|
||||
) {
|
||||
const sampledAt = now()
|
||||
if (workloadError) {
|
||||
throw workloadError
|
||||
}
|
||||
if (
|
||||
(!workloadSettled && sampledAt >= hardDeadline) ||
|
||||
(workloadSettledAt !== null && workloadSettledAt > hardDeadline)
|
||||
) {
|
||||
throw new Error(
|
||||
`Benchmark workload exceeded the ${maxWorkloadOverrunMs}ms sampling overrun limit`
|
||||
)
|
||||
}
|
||||
const processRows = descendantsOf(readRows(), rootPid)
|
||||
const rawProcesses = processRows.map((row) => ({ ...row, kind: classify(row, rootPid) }))
|
||||
if (previousSnapshot) {
|
||||
const elapsedSeconds = Math.max(0.001, (sampledAt - previousSnapshot.at) / 1000)
|
||||
const previousByPid = new Map(previousSnapshot.processes.map((proc) => [proc.pid, proc]))
|
||||
const processes = rawProcesses.map((row) => {
|
||||
const previous = previousByPid.get(row.pid)
|
||||
const canComputeDelta =
|
||||
typeof row.cpuTimeSeconds === 'number' && typeof previous?.cpuTimeSeconds === 'number'
|
||||
const cpu = canComputeDelta
|
||||
? Math.max(0, ((row.cpuTimeSeconds - previous.cpuTimeSeconds) / elapsedSeconds) * 100)
|
||||
: row.percentCpu
|
||||
return { ...row, cpu }
|
||||
})
|
||||
samples.push({
|
||||
at: sampledAt,
|
||||
elapsedMs: sampledAt - previousSnapshot.at,
|
||||
totalCpuPercent: processes.reduce((sum, proc) => sum + proc.cpu, 0),
|
||||
totalRssBytes: processes.reduce((sum, proc) => sum + proc.rssBytes, 0),
|
||||
processes
|
||||
})
|
||||
}
|
||||
previousSnapshot = { at: sampledAt, processes: rawProcesses }
|
||||
await wait(intervalMs)
|
||||
}
|
||||
if (workloadError) {
|
||||
throw workloadError
|
||||
}
|
||||
const measuredDurationMs = Math.max(
|
||||
0,
|
||||
(previousSnapshot?.at ?? samplingStartedAt) - samplingStartedAt
|
||||
)
|
||||
return {
|
||||
samples,
|
||||
workloadResult,
|
||||
samplingWindow: {
|
||||
requestedDurationMs,
|
||||
measuredDurationMs,
|
||||
maxWorkloadOverrunMs,
|
||||
extendedForWorkload: workloadSettledAt !== null && workloadSettledAt > requestedDeadline,
|
||||
workloadSettledElapsedMs:
|
||||
workloadSettledAt === null ? null : Math.max(0, workloadSettledAt - samplingStartedAt),
|
||||
workloadSettledBeforeStop: workloadSettled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mean(values) {
|
||||
return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
}
|
||||
|
||||
function percentile(sorted, fraction) {
|
||||
if (sorted.length === 0) {
|
||||
return 0
|
||||
}
|
||||
const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)
|
||||
return sorted[index]
|
||||
}
|
||||
|
||||
export function summarizeSamples(samples) {
|
||||
const byKind = new Map()
|
||||
for (const sample of samples) {
|
||||
for (const proc of sample.processes) {
|
||||
const bucket = byKind.get(proc.kind) ?? { cpuValues: [], rssValues: [], maxProcessCount: 0 }
|
||||
bucket.cpuValues.push(proc.cpu)
|
||||
bucket.rssValues.push(proc.rssBytes)
|
||||
byKind.set(proc.kind, bucket)
|
||||
}
|
||||
const counts = new Map()
|
||||
for (const proc of sample.processes) {
|
||||
counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
|
||||
}
|
||||
for (const [kind, count] of counts) {
|
||||
byKind.get(kind).maxProcessCount = Math.max(byKind.get(kind).maxProcessCount, count)
|
||||
}
|
||||
}
|
||||
const summary = {}
|
||||
for (const [kind, values] of byKind) {
|
||||
const cpuSorted = [...values.cpuValues].sort((a, b) => a - b)
|
||||
const rssSumBySample = samples.map((sample) =>
|
||||
sample.processes
|
||||
.filter((proc) => proc.kind === kind)
|
||||
.reduce((sum, proc) => sum + proc.rssBytes, 0)
|
||||
)
|
||||
summary[kind] = {
|
||||
meanCpuPercent: mean(values.cpuValues),
|
||||
p95CpuPercent: percentile(cpuSorted, 0.95),
|
||||
maxCpuPercent: Math.max(0, ...values.cpuValues),
|
||||
meanRssBytes: mean(rssSumBySample),
|
||||
maxProcessCount: values.maxProcessCount
|
||||
}
|
||||
}
|
||||
summary.total = {
|
||||
meanCpuPercent: mean(samples.map((sample) => sample.totalCpuPercent)),
|
||||
p95CpuPercent: percentile(
|
||||
samples.map((sample) => sample.totalCpuPercent).sort((a, b) => a - b),
|
||||
0.95
|
||||
),
|
||||
meanRssBytes: mean(samples.map((sample) => sample.totalRssBytes))
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
export function summarizeProcessInventory(samples) {
|
||||
const inventory = {}
|
||||
for (const sample of samples) {
|
||||
const counts = new Map()
|
||||
for (const proc of sample.processes) {
|
||||
counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
|
||||
const entry = inventory[proc.kind] ?? {
|
||||
maxProcessCount: 0,
|
||||
maxCpuPercent: 0,
|
||||
commandSamples: []
|
||||
}
|
||||
entry.maxCpuPercent = Math.max(entry.maxCpuPercent, proc.cpu)
|
||||
if (!entry.commandSamples.includes(proc.command) && entry.commandSamples.length < 6) {
|
||||
entry.commandSamples.push(proc.command)
|
||||
}
|
||||
inventory[proc.kind] = entry
|
||||
}
|
||||
for (const [kind, count] of counts) {
|
||||
inventory[kind].maxProcessCount = Math.max(inventory[kind].maxProcessCount, count)
|
||||
}
|
||||
}
|
||||
return inventory
|
||||
}
|
||||
|
||||
export function terminateProcesses(processes) {
|
||||
for (const proc of processes) {
|
||||
try {
|
||||
process.kill(proc.pid)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sampleProcessTreeUntilWorkloadsComplete } from './idle-cpu-process-sampling.mjs'
|
||||
|
||||
function createClock(workloadCompletesAt) {
|
||||
let currentMs = 0
|
||||
let resolveWorkload
|
||||
const workloadPromise = new Promise((resolve) => {
|
||||
resolveWorkload = resolve
|
||||
})
|
||||
const wait = async (durationMs) => {
|
||||
currentMs += durationMs
|
||||
if (currentMs >= workloadCompletesAt) {
|
||||
resolveWorkload('complete')
|
||||
}
|
||||
await Promise.resolve()
|
||||
}
|
||||
const readRows = () => [
|
||||
{
|
||||
pid: 10,
|
||||
ppid: 0,
|
||||
percentCpu: 0,
|
||||
rssBytes: 1_024,
|
||||
cpuTimeSeconds: currentMs / 2_000,
|
||||
command: 'electron'
|
||||
}
|
||||
]
|
||||
return { now: () => currentMs, readRows, wait, workloadPromise }
|
||||
}
|
||||
|
||||
describe('idle CPU process sampling window', () => {
|
||||
it('extends through a slow workload and captures a final CPU delta', async () => {
|
||||
const clock = createClock(40)
|
||||
const result = await sampleProcessTreeUntilWorkloadsComplete({
|
||||
rootPid: 10,
|
||||
requestedDurationMs: 20,
|
||||
intervalMs: 10,
|
||||
maxWorkloadOverrunMs: 100,
|
||||
...clock
|
||||
})
|
||||
|
||||
expect(result.workloadResult).toBe('complete')
|
||||
expect(result.samples.map((sample) => sample.at)).toEqual([10, 20, 30, 40])
|
||||
expect(result.samplingWindow).toEqual({
|
||||
requestedDurationMs: 20,
|
||||
measuredDurationMs: 40,
|
||||
maxWorkloadOverrunMs: 100,
|
||||
extendedForWorkload: true,
|
||||
workloadSettledElapsedMs: 40,
|
||||
workloadSettledBeforeStop: true
|
||||
})
|
||||
})
|
||||
|
||||
it('invalidates a run that reaches the workload overrun guard', async () => {
|
||||
const clock = createClock(Infinity)
|
||||
await expect(
|
||||
sampleProcessTreeUntilWorkloadsComplete({
|
||||
rootPid: 10,
|
||||
requestedDurationMs: 20,
|
||||
intervalMs: 10,
|
||||
maxWorkloadOverrunMs: 20,
|
||||
...clock
|
||||
})
|
||||
).rejects.toThrow('exceeded the 20ms sampling overrun limit')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
export async function configureRendererScaleFixture(page, options, repoPath) {
|
||||
return page.evaluate(
|
||||
({ agentsPerWorktree, lineageDepth, repoPath }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
const normalizePath = (value) =>
|
||||
String(value ?? '')
|
||||
.replaceAll('\\', '/')
|
||||
.toLowerCase()
|
||||
const primaryPath = normalizePath(repoPath)
|
||||
const compare = (left, right) => (left < right ? -1 : left > right ? 1 : 0)
|
||||
const state = store.getState()
|
||||
const worktrees = Object.values(state.worktreesByRepo)
|
||||
.flat()
|
||||
.filter((worktree) => !worktree.isArchived)
|
||||
.sort((left, right) => {
|
||||
const primaryOrder =
|
||||
Number(normalizePath(right.path) === primaryPath) -
|
||||
Number(normalizePath(left.path) === primaryPath)
|
||||
return (
|
||||
primaryOrder ||
|
||||
compare(normalizePath(left.path), normalizePath(right.path)) ||
|
||||
compare(left.id, right.id)
|
||||
)
|
||||
})
|
||||
const appliedLineageDepth = Math.min(lineageDepth, Math.max(0, worktrees.length - 1))
|
||||
if (lineageDepth === 0 && agentsPerWorktree === 0) {
|
||||
return {
|
||||
applied: false,
|
||||
requestedLineageDepth: lineageDepth,
|
||||
appliedLineageDepth,
|
||||
agentsPerWorktree,
|
||||
seededAgentRows: 0
|
||||
}
|
||||
}
|
||||
|
||||
state.setActiveView('terminal')
|
||||
state.setSidebarOpen(true)
|
||||
state.setGroupBy('none')
|
||||
state.setSortBy('recent')
|
||||
state.setShowActiveOnly(false)
|
||||
state.setShowSleepingWorkspaces(true)
|
||||
state.setHideDefaultBranchWorkspace(false)
|
||||
state.setFilterRepoIds([])
|
||||
|
||||
const lineageById = { ...store.getState().worktreeLineageById }
|
||||
const lineageParentIds = new Set()
|
||||
if (appliedLineageDepth > 0) {
|
||||
for (const worktree of worktrees) {
|
||||
delete lineageById[worktree.id]
|
||||
if (!worktree.instanceId) {
|
||||
throw new Error(`Worktree ${worktree.id} has no instanceId for lineage seeding`)
|
||||
}
|
||||
}
|
||||
for (let index = 1; index < worktrees.length; index += 1) {
|
||||
const child = worktrees[index]
|
||||
const parent = worktrees[Math.min(index - 1, appliedLineageDepth - 1)]
|
||||
lineageParentIds.add(parent.id)
|
||||
lineageById[child.id] = {
|
||||
worktreeId: child.id,
|
||||
worktreeInstanceId: child.instanceId,
|
||||
parentWorktreeId: parent.id,
|
||||
parentWorktreeInstanceId: parent.instanceId,
|
||||
origin: 'manual',
|
||||
capture: { source: 'manual-action', confidence: 'explicit' },
|
||||
createdAt: 1_700_000_000_000 + index
|
||||
}
|
||||
}
|
||||
}
|
||||
const collapsedGroups = new Set(store.getState().collapsedGroups)
|
||||
for (const parentId of lineageParentIds) {
|
||||
collapsedGroups.delete(`lineage:${parentId}`)
|
||||
}
|
||||
store.setState({ worktreeLineageById: lineageById, collapsedGroups })
|
||||
|
||||
let seededAgentRows = 0
|
||||
if (agentsPerWorktree > 0) {
|
||||
store.getState().setWorktreeCardMode('Default')
|
||||
store.getState().setAgentActivityDisplayMode('full')
|
||||
const fixtureNow = Date.now()
|
||||
worktrees.forEach((worktree, worktreeIndex) => {
|
||||
const next = store.getState()
|
||||
const tab =
|
||||
next.tabsByWorktree[worktree.id]?.[0] ??
|
||||
next.createTab(worktree.id, undefined, undefined, {
|
||||
activate: false,
|
||||
id: `idle-cpu-tab-${worktreeIndex}`
|
||||
})
|
||||
for (let agentIndex = 0; agentIndex < agentsPerWorktree; agentIndex += 1) {
|
||||
const agentType = agentIndex % 2 === 0 ? 'codex' : 'claude'
|
||||
const leafSequence =
|
||||
BigInt(worktreeIndex) * BigInt(agentsPerWorktree) + BigInt(agentIndex + 1)
|
||||
const leafId = `00000000-0000-4000-8000-${leafSequence.toString(16).padStart(12, '0')}`
|
||||
store.getState().setAgentStatus(
|
||||
`${tab.id}:${leafId}`,
|
||||
{
|
||||
state: 'working',
|
||||
prompt: `Idle CPU agent ${worktreeIndex + 1}.${agentIndex + 1}`,
|
||||
agentType
|
||||
},
|
||||
agentType,
|
||||
{ updatedAt: fixtureNow, stateStartedAt: fixtureNow },
|
||||
{ tabId: tab.id, worktreeId: worktree.id }
|
||||
)
|
||||
seededAgentRows += 1
|
||||
}
|
||||
})
|
||||
}
|
||||
return {
|
||||
applied: true,
|
||||
requestedLineageDepth: lineageDepth,
|
||||
appliedLineageDepth,
|
||||
lineageEdges: appliedLineageDepth > 0 ? Math.max(0, worktrees.length - 1) : 0,
|
||||
expandedLineageGroups: lineageParentIds.size,
|
||||
agentsPerWorktree,
|
||||
seededAgentRows,
|
||||
orderedWorktreeIds: worktrees.map((worktree) => worktree.id)
|
||||
}
|
||||
},
|
||||
{ agentsPerWorktree: options.agentsPerWorktree, lineageDepth: options.lineageDepth, repoPath }
|
||||
)
|
||||
}
|
||||
|
||||
export async function collectRendererCensus(page, configuredLineageDepth) {
|
||||
return page.evaluate((configuredDepth) => {
|
||||
const state = window.__store?.getState()
|
||||
if (!state) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
const worktrees = Object.values(state.worktreesByRepo).flat()
|
||||
const worktreeIds = new Set(worktrees.map((worktree) => worktree.id))
|
||||
const lineageDepthById = new Map()
|
||||
const getDepth = (worktreeId, trail = new Set()) => {
|
||||
if (lineageDepthById.has(worktreeId)) {
|
||||
return lineageDepthById.get(worktreeId)
|
||||
}
|
||||
const lineage = state.worktreeLineageById[worktreeId]
|
||||
if (!lineage || !worktreeIds.has(lineage.parentWorktreeId) || trail.has(worktreeId)) {
|
||||
return 0
|
||||
}
|
||||
const nextTrail = new Set(trail)
|
||||
nextTrail.add(worktreeId)
|
||||
const depth = 1 + getDepth(lineage.parentWorktreeId, nextTrail)
|
||||
lineageDepthById.set(worktreeId, depth)
|
||||
return depth
|
||||
}
|
||||
const logicalDepths = worktrees.map((worktree) => getDepth(worktree.id))
|
||||
const lineageParentIds = new Set(
|
||||
Object.values(state.worktreeLineageById)
|
||||
.filter((lineage) => worktreeIds.has(lineage.worktreeId))
|
||||
.map((lineage) => lineage.parentWorktreeId)
|
||||
)
|
||||
const sidebar = document.querySelector('[data-worktree-sidebar]')
|
||||
const mountedWorktreeIds = [
|
||||
...new Set(
|
||||
[...(sidebar?.querySelectorAll('[data-worktree-id]') ?? [])]
|
||||
.map((element) => element.getAttribute('data-worktree-id'))
|
||||
.filter(Boolean)
|
||||
)
|
||||
]
|
||||
const mountedAgentRows = [...(sidebar?.querySelectorAll('*') ?? [])].filter(
|
||||
(element) =>
|
||||
element.classList.contains('group/agent-row') ||
|
||||
element.classList.contains('compact-agent-row')
|
||||
).length
|
||||
let diagnosticCensus = null
|
||||
try {
|
||||
diagnosticCensus = window.__orcaTypingDiagnostic?.report().census ?? null
|
||||
} catch {}
|
||||
const collapsedLineageGroups = [...lineageParentIds].filter((parentId) =>
|
||||
state.collapsedGroups.has(`lineage:${parentId}`)
|
||||
).length
|
||||
return {
|
||||
capturedAt: new Date().toISOString(),
|
||||
worktrees: {
|
||||
store: worktrees.length,
|
||||
mountedCards: sidebar?.querySelectorAll('[data-worktree-card-surface]').length ?? 0,
|
||||
mountedUnique: mountedWorktreeIds.length,
|
||||
mountedIds: mountedWorktreeIds.slice(0, 100),
|
||||
mountedIdsTruncated: Math.max(0, mountedWorktreeIds.length - 100)
|
||||
},
|
||||
agentRows: {
|
||||
storeLive: Object.keys(state.agentStatusByPaneKey ?? {}).length,
|
||||
storeRetained: Object.keys(state.retainedAgentsByPaneKey ?? {}).length,
|
||||
mounted: mountedAgentRows,
|
||||
diagnosticMounted: diagnosticCensus?.agentRows.mountedDom ?? null
|
||||
},
|
||||
storeListeners: diagnosticCensus?.storeListeners ?? null,
|
||||
lineage: {
|
||||
configuredDepth,
|
||||
measuredMaxDepth: Math.max(0, ...logicalDepths),
|
||||
edges: logicalDepths.filter((depth) => depth > 0).length,
|
||||
groups: lineageParentIds.size,
|
||||
expandedGroups: lineageParentIds.size - collapsedLineageGroups,
|
||||
collapsedGroups: collapsedLineageGroups
|
||||
},
|
||||
diagnostic: diagnosticCensus
|
||||
}
|
||||
}, configuredLineageDepth)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
const RENDERER_TIMER_INTERVAL_MS = 100
|
||||
|
||||
export async function startRendererTimingProbe(page) {
|
||||
await page.evaluate((timerIntervalMs) => {
|
||||
const maxSamples = 5_000
|
||||
const maxEntries = 80
|
||||
let phaseStartedAt = performance.now()
|
||||
let phaseStartedAtIso = new Date().toISOString()
|
||||
let timerId = null
|
||||
let driftCount = 0
|
||||
let driftSamples = []
|
||||
let longTaskEntries = []
|
||||
let observer = null
|
||||
let longTaskSupported = false
|
||||
|
||||
const round = (value) => Math.round(value * 100) / 100
|
||||
const summarize = (values, totalCount = values.length) => {
|
||||
const sorted = [...values].sort((left, right) => left - right)
|
||||
const percentile = (fraction) =>
|
||||
sorted.length === 0
|
||||
? null
|
||||
: sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)]
|
||||
return {
|
||||
count: totalCount,
|
||||
retainedCount: sorted.length,
|
||||
mean:
|
||||
sorted.length === 0
|
||||
? null
|
||||
: round(sorted.reduce((sum, value) => sum + value, 0) / sorted.length),
|
||||
p50: sorted.length === 0 ? null : round(percentile(0.5)),
|
||||
p95: sorted.length === 0 ? null : round(percentile(0.95)),
|
||||
max: sorted.length === 0 ? null : round(sorted.at(-1))
|
||||
}
|
||||
}
|
||||
const recordLongTasks = (entries) => {
|
||||
for (const entry of entries) {
|
||||
longTaskEntries.push({
|
||||
startTime: entry.startTime,
|
||||
duration: entry.duration,
|
||||
name: entry.name
|
||||
})
|
||||
if (longTaskEntries.length > maxSamples) {
|
||||
longTaskEntries.shift()
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
longTaskSupported = PerformanceObserver.supportedEntryTypes?.includes('longtask') === true
|
||||
if (longTaskSupported) {
|
||||
observer = new PerformanceObserver((list) => recordLongTasks(list.getEntries()))
|
||||
observer.observe({ type: 'longtask', buffered: true })
|
||||
}
|
||||
} catch {
|
||||
observer = null
|
||||
longTaskSupported = false
|
||||
}
|
||||
const scheduleTimer = () => {
|
||||
const expectedAt = performance.now() + timerIntervalMs
|
||||
timerId = setTimeout(() => {
|
||||
driftCount += 1
|
||||
driftSamples.push(Math.max(0, performance.now() - expectedAt))
|
||||
if (driftSamples.length > maxSamples) {
|
||||
driftSamples.shift()
|
||||
}
|
||||
scheduleTimer()
|
||||
}, timerIntervalMs)
|
||||
}
|
||||
const snapshot = (reset) => {
|
||||
if (observer) {
|
||||
recordLongTasks(observer.takeRecords())
|
||||
}
|
||||
const capturedAt = performance.now()
|
||||
const phaseLongTasks = longTaskEntries.filter((entry) => entry.startTime >= phaseStartedAt)
|
||||
const durations = phaseLongTasks.map((entry) => entry.duration)
|
||||
const result = {
|
||||
startedAt: phaseStartedAtIso,
|
||||
capturedAt: new Date().toISOString(),
|
||||
durationMs: round(capturedAt - phaseStartedAt),
|
||||
timerIntervalMs,
|
||||
timerDriftMs: summarize(driftSamples, driftCount),
|
||||
longTasks: {
|
||||
supported: longTaskSupported,
|
||||
...summarize(durations),
|
||||
totalDurationMs: round(durations.reduce((sum, value) => sum + value, 0)),
|
||||
entries: phaseLongTasks.slice(-maxEntries).map((entry) => ({
|
||||
startMs: round(entry.startTime - phaseStartedAt),
|
||||
durationMs: round(entry.duration),
|
||||
name: entry.name
|
||||
})),
|
||||
entriesTruncated: Math.max(0, phaseLongTasks.length - maxEntries)
|
||||
}
|
||||
}
|
||||
if (reset) {
|
||||
phaseStartedAt = capturedAt
|
||||
phaseStartedAtIso = new Date().toISOString()
|
||||
driftCount = 0
|
||||
driftSamples = []
|
||||
longTaskEntries = []
|
||||
}
|
||||
return result
|
||||
}
|
||||
scheduleTimer()
|
||||
window.__orcaIdleCpuTimingProbe = {
|
||||
snapshot: () => snapshot(true),
|
||||
stop: () => {
|
||||
if (timerId !== null) {
|
||||
clearTimeout(timerId)
|
||||
}
|
||||
const result = snapshot(false)
|
||||
observer?.disconnect()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}, RENDERER_TIMER_INTERVAL_MS)
|
||||
}
|
||||
|
||||
export async function snapshotRendererTimingProbe(page) {
|
||||
return page.evaluate(() => window.__orcaIdleCpuTimingProbe?.snapshot() ?? null)
|
||||
}
|
||||
|
||||
export async function stopRendererTimingProbe(page) {
|
||||
return page.evaluate(() => window.__orcaIdleCpuTimingProbe?.stop() ?? null)
|
||||
}
|
||||
|
||||
export async function runZustandPublications(page, count, intervalMs) {
|
||||
return page.evaluate(
|
||||
({ count, intervalMs }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
reject(new Error('window.__store is not available'))
|
||||
return
|
||||
}
|
||||
const maxSamples = 5_000
|
||||
const startedAt = performance.now()
|
||||
const startedAtIso = new Date().toISOString()
|
||||
const schedulingDriftMs = []
|
||||
let completed = 0
|
||||
const finish = () => {
|
||||
const sorted = [...schedulingDriftMs].sort((left, right) => left - right)
|
||||
const percentile = (fraction) =>
|
||||
sorted.length === 0
|
||||
? null
|
||||
: sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)]
|
||||
const round = (value) => Math.round(value * 100) / 100
|
||||
resolve({
|
||||
requested: count,
|
||||
completed,
|
||||
intervalMs,
|
||||
startedAt: startedAtIso,
|
||||
completedAt: new Date().toISOString(),
|
||||
durationMs: round(performance.now() - startedAt),
|
||||
schedulingDriftMs: {
|
||||
retainedCount: sorted.length,
|
||||
p50: sorted.length === 0 ? null : round(percentile(0.5)),
|
||||
p95: sorted.length === 0 ? null : round(percentile(0.95)),
|
||||
max: sorted.length === 0 ? null : round(sorted.at(-1))
|
||||
}
|
||||
})
|
||||
}
|
||||
const publish = () => {
|
||||
const publishedAt = performance.now()
|
||||
const scheduledAt = startedAt + completed * intervalMs
|
||||
schedulingDriftMs.push(Math.max(0, publishedAt - scheduledAt))
|
||||
if (schedulingDriftMs.length > maxSamples) {
|
||||
schedulingDriftMs.shift()
|
||||
}
|
||||
try {
|
||||
// Why: an empty partial notifies every subscriber without changing domain state.
|
||||
store.setState({})
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
completed += 1
|
||||
if (completed >= count) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
const nextAt = startedAt + completed * intervalMs
|
||||
setTimeout(publish, Math.max(0, nextAt - performance.now()))
|
||||
}
|
||||
if (count === 0) {
|
||||
finish()
|
||||
} else {
|
||||
setTimeout(publish, 0)
|
||||
}
|
||||
}),
|
||||
{ count, intervalMs }
|
||||
)
|
||||
}
|
||||
@@ -28,6 +28,8 @@ const transientDownloadErrorCodes = new Set([
|
||||
'EAI_AGAIN',
|
||||
'ECONNREFUSED',
|
||||
'ECONNRESET',
|
||||
// GitHub release CDN can refuse HTTP/2 streams under load; retry like socket resets.
|
||||
'ERR_HTTP2_STREAM_ERROR',
|
||||
'ENETDOWN',
|
||||
'ENETRESET',
|
||||
'ENETUNREACH',
|
||||
@@ -185,7 +187,9 @@ async function downloadElectronArtifactWithRetry(downloadOptions) {
|
||||
function getDownloadRetryDelays() {
|
||||
const configured = process.env.ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS
|
||||
if (!configured) {
|
||||
return [1_000, 3_000]
|
||||
// Why: GitHub release CDN returns intermittent 503 / HTTP2 stream refusals
|
||||
// under CI fan-out; a few short attempts still exhaust during outages.
|
||||
return [1_000, 3_000, 5_000, 10_000]
|
||||
}
|
||||
|
||||
const delays = configured.split(',').map(Number)
|
||||
@@ -200,7 +204,7 @@ function isTransientDownloadError(error) {
|
||||
if (transientDownloadErrorCodes.has(candidate?.code)) {
|
||||
return true
|
||||
}
|
||||
const statusCode = candidate?.statusCode ?? candidate?.response?.statusCode
|
||||
const statusCode = getDownloadErrorStatusCode(candidate)
|
||||
if (
|
||||
statusCode === 408 ||
|
||||
statusCode === 425 ||
|
||||
@@ -213,6 +217,13 @@ function isTransientDownloadError(error) {
|
||||
return false
|
||||
}
|
||||
|
||||
function getDownloadErrorStatusCode(error) {
|
||||
// @electron/get FetchDownloader uses Fetch Response.status; older got uses statusCode.
|
||||
return (
|
||||
error?.statusCode ?? error?.status ?? error?.response?.statusCode ?? error?.response?.status
|
||||
)
|
||||
}
|
||||
|
||||
function getErrorChain(error) {
|
||||
const errors = []
|
||||
let candidate = error
|
||||
@@ -225,7 +236,7 @@ function getErrorChain(error) {
|
||||
|
||||
function formatDownloadError(error) {
|
||||
for (const candidate of getErrorChain(error)) {
|
||||
const statusCode = candidate?.statusCode ?? candidate?.response?.statusCode
|
||||
const statusCode = getDownloadErrorStatusCode(candidate)
|
||||
if (statusCode) {
|
||||
return `HTTP ${statusCode}`
|
||||
}
|
||||
|
||||
@@ -118,6 +118,58 @@ describe('install-electron-package-binary', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('retries HTTP/2 stream refusal Electron download failures', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir, {
|
||||
downloadFailures: 1,
|
||||
downloadErrorCode: 'ERR_HTTP2_STREAM_ERROR'
|
||||
})
|
||||
writeFakeExtractor(projectDir, { createExecutable: true })
|
||||
|
||||
const result = runInstallScript(projectDir, {
|
||||
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0'
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(
|
||||
readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')
|
||||
).toHaveLength(2)
|
||||
expect(result.stderr).toContain(
|
||||
'Transient Electron download failure (ERR_HTTP2_STREAM_ERROR)'
|
||||
)
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('retries Electron download HTTP 503 failures from Fetch Response.status', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir, {
|
||||
downloadFailures: 1,
|
||||
downloadHttpStatus: 503
|
||||
})
|
||||
writeFakeExtractor(projectDir, { createExecutable: true })
|
||||
|
||||
const result = runInstallScript(projectDir, {
|
||||
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0'
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(
|
||||
readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')
|
||||
).toHaveLength(2)
|
||||
expect(result.stderr).toContain('Transient Electron download failure (HTTP 503)')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fails after exhausting transient Electron download retries', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
@@ -279,7 +331,12 @@ module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8'))
|
||||
|
||||
function writeFakeElectronGet(
|
||||
projectDir,
|
||||
{ downloadNeverSettles = false, downloadFailures = 0, downloadErrorCode = 'ECONNRESET' } = {}
|
||||
{
|
||||
downloadNeverSettles = false,
|
||||
downloadFailures = 0,
|
||||
downloadErrorCode = 'ECONNRESET',
|
||||
downloadHttpStatus = null
|
||||
} = {}
|
||||
) {
|
||||
const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get')
|
||||
mkdirSync(getDir, { recursive: true })
|
||||
@@ -299,6 +356,11 @@ exports.downloadArtifact = async function downloadArtifact(details) {
|
||||
return new Promise(() => {})
|
||||
}
|
||||
if (downloadAttempt <= ${JSON.stringify(downloadFailures)}) {
|
||||
if (${JSON.stringify(downloadHttpStatus)} != null) {
|
||||
const error = new Error('Response code ' + ${JSON.stringify(downloadHttpStatus)})
|
||||
error.response = { status: ${JSON.stringify(downloadHttpStatus)} }
|
||||
throw error
|
||||
}
|
||||
const cause = Object.assign(new Error('download failed'), {
|
||||
code: ${JSON.stringify(downloadErrorCode)}
|
||||
})
|
||||
|
||||
@@ -30,8 +30,8 @@ const recoveryEnd = runtimeSource.indexOf(
|
||||
)
|
||||
const recoverySource = runtimeSource.slice(recoveryStart, recoveryEnd)
|
||||
if (
|
||||
recoveryStart < 0 ||
|
||||
recoveryEnd < 0 ||
|
||||
recoveryStart === -1 ||
|
||||
recoveryEnd === -1 ||
|
||||
!recoverySource.includes('await this.flushWorkspaceSessionOrThrowAsync()') ||
|
||||
recoverySource.includes('flushOrThrow()')
|
||||
) {
|
||||
|
||||
@@ -2273,12 +2273,6 @@
|
||||
"auto.components.settings.GitPane.976afc6b3e": {
|
||||
"ko": "워크스페이스를 만들면 Orca가 원격 기준 브랜치를 새로고침하고, 일치하는 로컬 브랜치를 안전하게 fast-forward합니다. 예:"
|
||||
},
|
||||
"auto.components.settings.GitPane.b5f534717a": {
|
||||
"ko": "공동 작성"
|
||||
},
|
||||
"auto.components.settings.GitPane.b9b5771bb1": {
|
||||
"ko": "표기"
|
||||
},
|
||||
"auto.components.settings.GitPane.b9c011fbc2": {
|
||||
"ko": "속도 제한"
|
||||
},
|
||||
@@ -2288,9 +2282,6 @@
|
||||
"auto.components.settings.GitPane.db3a127eb1": {
|
||||
"ko": "이렇게 하면 다음과 같은 명령이 유지됩니다"
|
||||
},
|
||||
"auto.components.settings.GitPane.e02ea23a32": {
|
||||
"ko": "Orca 표기"
|
||||
},
|
||||
"auto.components.settings.GitPane.f35007e6e8": {
|
||||
"ko": "git-username"
|
||||
},
|
||||
|
||||
@@ -44,7 +44,6 @@ export const KO_VALUE_OVERRIDES = {
|
||||
'Issue #{{value0}}': '이슈 #{{value0}}',
|
||||
'Linked issue #{{value0}}': '연결된 이슈 #{{value0}}',
|
||||
'Issue # or GitHub URL': '이슈 # 또는 GitHub URL',
|
||||
'Add Orca attribution to commits, PRs, and issues.': '커밋, PR 및 이슈에 Orca 속성을 추가합니다.',
|
||||
'Paste an issue URL, or enter a number. Leave blank to remove the link.':
|
||||
'이슈 URL을 붙여넣거나 숫자를 입력하세요. 링크를 제거하려면 비워두세요.',
|
||||
'Connect Linear in Settings to search issues.': '이슈를 검색하려면 설정에서 Linear를 연결하세요.',
|
||||
@@ -157,8 +156,7 @@ export const KO_VALUE_OVERRIDES = {
|
||||
'저장되지 않은 Git AI Author 변경사항이 있습니다. 떠나면 폐기됩니다.',
|
||||
'Discard unsaved Git AI Author changes?':
|
||||
'저장되지 않은 Git AI Author 변경사항을 삭제하시겠습니까?',
|
||||
'Branch naming, base refs, attribution, and Git AI Author.':
|
||||
'브랜치 이름 지정, 기본 참조, 속성 및 Git AI Author.',
|
||||
'Branch naming, base refs, and Git AI Author.': '브랜치 이름 지정, 기본 참조 및 Git AI Author.',
|
||||
'Used by repositories that inherit global hosted-review defaults.':
|
||||
'전역 호스팅 PR 기본값을 상속하는 저장소에서 사용됩니다.',
|
||||
'Defaults used when the hosted-review composer opens.':
|
||||
|
||||
@@ -164,7 +164,6 @@ export const SEARCH_KEYWORD_OVERRIDES = {
|
||||
gitlab: 'GitLab',
|
||||
gitea: 'Gitea',
|
||||
oauth: 'OAuth',
|
||||
attribution: '归因',
|
||||
skill: '技能',
|
||||
skills: '技能',
|
||||
phone: '手机',
|
||||
|
||||
@@ -25,10 +25,10 @@ describe('locale-translation-policy es PR glossary', () => {
|
||||
it('rewrites PR inside longer sentences', () => {
|
||||
expect(
|
||||
repairEs(
|
||||
'Add Orca attribution to commits, PRs, and issues.',
|
||||
'Agregue la atribución de Orca a commits, relaciones públicas y problemas.'
|
||||
'Split a large change into smaller PRs',
|
||||
'Dividir un cambio grande en relaciones públicas más pequeños'
|
||||
)
|
||||
).toBe('Agregue la atribución de Orca a commits, PR y problemas.')
|
||||
).toBe('Dividir un cambio grande en PR más pequeños')
|
||||
expect(
|
||||
repairEs(
|
||||
'Open the PR details to view current reviewers.',
|
||||
|
||||
@@ -44,22 +44,14 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('显示 Orca Mobile 按钮')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.GitPane.e02ea23a32',
|
||||
enValue: 'Orca Attribution',
|
||||
localeValue: 'Orca归属',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Orca 归因')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.hooks.useSettingsNavigationMetadata.ab4b21b58e',
|
||||
enValue: 'Branch naming, base refs, attribution, and Git AI Author.',
|
||||
localeValue: '分支命名、基本引用、归属和 Git AI 作者。',
|
||||
enValue: 'Branch naming, base refs, and Git AI Author.',
|
||||
localeValue: '分支命名、基本引用和 Git AI 作者。',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('分支命名、基础引用、归因和 Git AI Author。')
|
||||
).toBe('分支命名、基础引用和 Git AI Author。')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.1b18868569',
|
||||
|
||||
@@ -3,7 +3,6 @@ export const ZH_PHRASE_FIXES_ROUND5 = [
|
||||
{ pattern: /Orca集成开发环境/g, replacement: 'Orca IDE', whenEnIncludes: 'Orca IDE' },
|
||||
{ pattern: /Orca第一/g, replacement: 'Orca 优先', whenEnIncludes: 'Orca first' },
|
||||
{ pattern: /Orca移动/g, replacement: 'Orca Mobile', whenEnIncludes: 'Orca Mobile' },
|
||||
{ pattern: /Orca归属/g, replacement: 'Orca 归因', whenEnIncludes: 'Orca Attribution' },
|
||||
{ pattern: /Orca标志/g, replacement: 'Orca 标志', whenEnIncludes: 'Orca logo' },
|
||||
{ pattern: /喜欢Orca/g, replacement: '喜欢 Orca', whenEnIncludes: 'Enjoying Orca' },
|
||||
{ pattern: /认识Orca/g, replacement: '了解 Orca', whenEnIncludes: 'Get to know Orca' },
|
||||
|
||||
@@ -144,7 +144,6 @@ export const ZH_VALUE_OVERRIDES = {
|
||||
'Expand Orca': '展开 Orca',
|
||||
'Support Orca': '支持 Orca',
|
||||
'Restarting Orca…': '正在重启 Orca…',
|
||||
'Orca Attribution': 'Orca 归因',
|
||||
'Show Orca Mobile Button': '显示 Orca Mobile 按钮',
|
||||
'Codex Accounts': 'Codex 账户',
|
||||
'Codex Account': 'Codex 账户',
|
||||
@@ -186,8 +185,7 @@ export const ZH_VALUE_OVERRIDES = {
|
||||
"Give agents direct access to Orca's browser so they can test pages, capture screenshots, and act on what they see.":
|
||||
'让代理直接访问 Orca 的浏览器,以便测试页面、捕获屏幕截图并根据所见内容执行操作。',
|
||||
'X finishes, send it the review task.”': 'X 完成后,把评审任务发给它。”',
|
||||
'Branch naming, base refs, attribution, and Git AI Author.':
|
||||
'分支命名、基础引用、归因和 Git AI Author。',
|
||||
'Branch naming, base refs, and Git AI Author.': '分支命名、基础引用和 Git AI Author。',
|
||||
'You have unsaved Git AI Author changes. Leaving will discard them.':
|
||||
'您有未保存的 Git AI Author 更改。离开将丢弃它们。',
|
||||
'Discard unsaved Git AI Author changes?': '放弃未保存的 Git AI Author 更改?',
|
||||
|
||||
@@ -210,7 +210,7 @@ function connectInvalidPeer(socketPath) {
|
||||
const onData = (chunk) => {
|
||||
buffer += chunk
|
||||
const newline = buffer.indexOf('\n')
|
||||
if (newline < 0) {
|
||||
if (newline === -1) {
|
||||
return
|
||||
}
|
||||
let response
|
||||
|
||||
@@ -54,7 +54,7 @@ function extractOld(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) {
|
||||
const frames = []
|
||||
while (cursor.length > 0) {
|
||||
const frameStart = cursor.indexOf(JPEG_START)
|
||||
if (frameStart < 0) {
|
||||
if (frameStart === -1) {
|
||||
const keepLastByte = cursor.at(-1) === 0xff
|
||||
return { frames, pending: keepLastByte ? Buffer.from([0xff]) : Buffer.alloc(0) }
|
||||
}
|
||||
@@ -62,7 +62,7 @@ function extractOld(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) {
|
||||
cursor = cursor.subarray(frameStart)
|
||||
}
|
||||
const frameEnd = cursor.indexOf(JPEG_END, JPEG_START.length)
|
||||
if (frameEnd < 0) {
|
||||
if (frameEnd === -1) {
|
||||
const tail = cursor.length <= maxPendingBytes ? cursor : cursor.subarray(-maxPendingBytes)
|
||||
return { frames, pending: trackedCopy(tail) }
|
||||
}
|
||||
@@ -79,7 +79,7 @@ function extractNew(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) {
|
||||
const frames = []
|
||||
while (cursor.length > 0) {
|
||||
const frameStart = cursor.indexOf(JPEG_START)
|
||||
if (frameStart < 0) {
|
||||
if (frameStart === -1) {
|
||||
const keepLastByte = cursor.at(-1) === 0xff
|
||||
return { frames, pending: keepLastByte ? Buffer.from([0xff]) : Buffer.alloc(0) }
|
||||
}
|
||||
@@ -87,7 +87,7 @@ function extractNew(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) {
|
||||
cursor = cursor.subarray(frameStart)
|
||||
}
|
||||
const frameEnd = cursor.indexOf(JPEG_END, JPEG_START.length)
|
||||
if (frameEnd < 0) {
|
||||
if (frameEnd === -1) {
|
||||
const tail = cursor.length <= maxPendingBytes ? cursor : cursor.subarray(-maxPendingBytes)
|
||||
return { frames, pending: trackedCopy(tail) }
|
||||
}
|
||||
|
||||
@@ -281,7 +281,7 @@ describe('orchestration skill guidance', () => {
|
||||
expect(workerLoop).toContain('opaque provider model id with `--model`')
|
||||
expect(workerLoop).toContain('`--effort` requires `--model`')
|
||||
expect(workerLoop).toContain('neither option can combine with `--terminal`')
|
||||
expect(workerLoop).toContain('--agent claude --model aws-bedrock-opus-5 --effort high --json')
|
||||
expect(workerLoop).toContain('--agent claude --model opus --effort high --json')
|
||||
expect(workerLoop).toContain('`launch.requested` and `launch.effective`')
|
||||
})
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ describe('Electron runtime package contract', () => {
|
||||
'build:linux',
|
||||
'test:e2e',
|
||||
'test:e2e:terminal-rendering-golden',
|
||||
'test:e2e:posix-profile-index-golden',
|
||||
'test:e2e:terminal-rendering-release-evidence',
|
||||
'test:e2e:headful'
|
||||
]
|
||||
@@ -492,7 +493,7 @@ describe('Electron runtime package contract', () => {
|
||||
expect(uploadStep.with.path).toBe('${{ env.ORCA_E2E_TERMINAL_PERF_REPORT_PATH }}')
|
||||
})
|
||||
|
||||
it('keeps terminal rendering regressions in the manual golden E2E workflow', () => {
|
||||
it('keeps platform golden regressions in the manual and release workflows', () => {
|
||||
const packageScripts = packageJson.scripts
|
||||
const goldenWorkflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/golden-e2e-experiment.yml'), 'utf8')
|
||||
@@ -506,17 +507,19 @@ describe('Electron runtime package contract', () => {
|
||||
['mac', 'macOS'],
|
||||
['windows', 'Windows']
|
||||
])
|
||||
const goldenPlatforms = goldenWorkflow.jobs['golden-e2e'].strategy.matrix.include
|
||||
.map(({ platform }) => platform)
|
||||
.sort()
|
||||
const goldenRunSteps = goldenPlatforms.map((platform) => {
|
||||
const label = goldenPlatformLabels.get(platform)
|
||||
const goldenMatrix = goldenWorkflow.jobs['golden-e2e'].strategy.matrix.include
|
||||
const goldenPlatforms = goldenMatrix.map(({ platform }) => platform).sort()
|
||||
const goldenRunSteps = new Map(
|
||||
goldenPlatforms.map((platform) => {
|
||||
const label = goldenPlatformLabels.get(platform)
|
||||
|
||||
expect(label, platform).toBeDefined()
|
||||
expect(label, platform).toBeDefined()
|
||||
|
||||
return steps.find((step) => step.name === `Run golden E2E tests on ${label}`)
|
||||
})
|
||||
return [platform, steps.find((step) => step.name === `Run golden E2E tests on ${label}`)]
|
||||
})
|
||||
)
|
||||
const releaseGoldenJob = releaseWorkflow.jobs['terminal-rendering-golden']
|
||||
const releaseGoldenMatrix = releaseGoldenJob.strategy.matrix.include
|
||||
const releaseEvidenceJob = releaseWorkflow.jobs['terminal-rendering-release-evidence']
|
||||
const releaseBuildNeeds = releaseWorkflow.jobs.build.needs
|
||||
const publishReleaseNeeds = releaseWorkflow.jobs['publish-release'].needs
|
||||
@@ -535,15 +538,47 @@ describe('Electron runtime package contract', () => {
|
||||
expect(packageScripts['test:e2e:terminal-rendering-golden']).not.toContain(
|
||||
'terminal-long-table-scroll-restore.spec.ts'
|
||||
)
|
||||
expect(packageScripts['test:e2e:windows-fresh-startup-golden']).toContain(
|
||||
'golden-windows-fresh-startup.spec.ts'
|
||||
)
|
||||
expect(packageScripts['test:e2e:windows-fresh-startup-golden']).toContain(
|
||||
'@windows-fresh-startup-golden'
|
||||
)
|
||||
expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain(
|
||||
'golden-posix-profile-index-fsync.spec.ts'
|
||||
)
|
||||
expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain(
|
||||
'golden-posix-fresh-startup.spec.ts'
|
||||
)
|
||||
expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain(
|
||||
'@posix-profile-index-golden'
|
||||
)
|
||||
expect(packageScripts['test:e2e:terminal-rendering-release-evidence']).toContain(
|
||||
'terminal-opencode-emoji-table-rendering.spec.ts'
|
||||
)
|
||||
expect(packageScripts['test:e2e:terminal-rendering-release-evidence']).toContain(
|
||||
'terminal-long-table-scroll-restore.spec.ts'
|
||||
)
|
||||
for (const runStep of goldenRunSteps) {
|
||||
expect(runStep?.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
|
||||
}
|
||||
expect(goldenMatrix).toEqual([
|
||||
{ os: 'ubuntu-latest', platform: 'linux' },
|
||||
{ os: 'macos-15', platform: 'mac' },
|
||||
{ os: 'windows-2022', platform: 'windows' }
|
||||
])
|
||||
expect(goldenRunSteps.get('linux')?.run).toContain(
|
||||
'pnpm run test:e2e:terminal-rendering-golden'
|
||||
)
|
||||
expect(goldenRunSteps.get('linux')?.run).toContain(
|
||||
'pnpm run test:e2e:posix-profile-index-golden'
|
||||
)
|
||||
expect(goldenRunSteps.get('mac')?.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
|
||||
expect(goldenRunSteps.get('mac')?.run).toContain('pnpm run test:e2e:posix-profile-index-golden')
|
||||
expect(goldenRunSteps.get('windows')).toMatchObject({
|
||||
if: "runner.os == 'Windows'",
|
||||
shell: 'pwsh'
|
||||
})
|
||||
expect(goldenRunSteps.get('windows').run).toContain(
|
||||
'pnpm run test:e2e:windows-fresh-startup-golden'
|
||||
)
|
||||
expect(goldenWorkflow.on.pull_request).toBeUndefined()
|
||||
expect(goldenWorkflow.on.workflow_dispatch).toBeDefined()
|
||||
expect(releaseBuildNeeds).not.toContain('terminal-rendering-golden')
|
||||
@@ -552,12 +587,25 @@ describe('Electron runtime package contract', () => {
|
||||
expect(publishReleaseNeeds).toContain('build')
|
||||
expect(publishReleaseNeeds).not.toContain('terminal-rendering-release-evidence')
|
||||
expect(releaseGoldenJob['continue-on-error']).toBeUndefined()
|
||||
expect(releaseGoldenJob.strategy.matrix.include.map(({ platform }) => platform).sort()).toEqual(
|
||||
goldenPlatforms
|
||||
expect(releaseGoldenMatrix).toEqual(goldenMatrix)
|
||||
const releaseLinuxRunStep = releaseGoldenJob.steps.find(
|
||||
(step) => step.name === 'Run terminal rendering golden on Linux'
|
||||
)
|
||||
expect(releaseGoldenJob.steps.map((step) => step.run ?? '')).toContain(
|
||||
'xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden'
|
||||
expect(releaseLinuxRunStep.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
|
||||
expect(releaseLinuxRunStep.run).toContain('pnpm run test:e2e:posix-profile-index-golden')
|
||||
const releaseMacRunStep = releaseGoldenJob.steps.find(
|
||||
(step) => step.name === 'Run terminal rendering golden on macOS'
|
||||
)
|
||||
expect(releaseMacRunStep.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
|
||||
expect(releaseMacRunStep.run).toContain('pnpm run test:e2e:posix-profile-index-golden')
|
||||
const releaseWindowsRunStep = releaseGoldenJob.steps.find(
|
||||
(step) => step.name === 'Run fresh-startup golden on Windows'
|
||||
)
|
||||
expect(releaseWindowsRunStep).toMatchObject({
|
||||
if: "runner.os == 'Windows'",
|
||||
shell: 'pwsh'
|
||||
})
|
||||
expect(releaseWindowsRunStep.run).toContain('pnpm run test:e2e:windows-fresh-startup-golden')
|
||||
expect(releaseEvidenceJob['continue-on-error']).toBe(true)
|
||||
expect(
|
||||
releaseEvidenceJob.strategy.matrix.include.map(({ platform }) => platform).sort()
|
||||
|
||||
@@ -6,6 +6,10 @@ import { parse } from 'yaml'
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
const e2eWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
|
||||
const sshDockerRunner = readFileSync(
|
||||
join(projectDir, 'config/scripts/run-ssh-docker-terminal-parking-e2e.mjs'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const filterStep = prWorkflow.jobs['e2e-paths'].steps.find(
|
||||
(step) => step.name === 'Filter changed E2E specs'
|
||||
@@ -64,14 +68,36 @@ describe('PR E2E gate contract', () => {
|
||||
expect(e2eWorkflow.jobs.e2e.if).toBe("inputs.test_files == ''")
|
||||
expect(e2eWorkflow.jobs['changed-e2e'].if).toBe("inputs.test_files != ''")
|
||||
expect(e2eWorkflow.jobs['changed-e2e'].strategy).toBeUndefined()
|
||||
expect(e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if).toBe("inputs.test_files == ''")
|
||||
const changedRun = e2eWorkflow.jobs['changed-e2e'].steps.find(
|
||||
(step) => step.name === 'Run changed E2E specs'
|
||||
)
|
||||
expect(changedRun.env.TEST_FILES_JSON).toBe('${{ inputs.test_files }}')
|
||||
expect(changedRun.run).toContain('. != "tests/e2e/ssh-startup-exec-readiness.spec.ts"')
|
||||
expect(changedRun.run).toContain('. != "tests/e2e/paired-startup-exec-readiness.spec.ts"')
|
||||
expect(changedRun.run).toContain('if [ "${#TEST_FILES[@]}" -eq 0 ]')
|
||||
expect(changedRun.run).toContain('pnpm run test:e2e "${TEST_FILES[@]}" --workers=1')
|
||||
})
|
||||
|
||||
it('keeps startup-exec live parity in the isolated SSH lane', () => {
|
||||
const sshLaneCondition = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if
|
||||
expect(sshLaneCondition).toContain("inputs.test_files == ''")
|
||||
expect(sshLaneCondition).toContain('tests/e2e/ssh-startup-exec-readiness.spec.ts')
|
||||
expect(sshLaneCondition).toContain('tests/e2e/paired-startup-exec-readiness.spec.ts')
|
||||
expect(sshDockerRunner).toContain('tests/e2e/ssh-startup-exec-readiness.spec.ts')
|
||||
expect(sshDockerRunner).toContain('tests/e2e/paired-startup-exec-readiness.spec.ts')
|
||||
expect(sshDockerRunner).toContain("'electron-headless'")
|
||||
expect(sshDockerRunner).toContain("'electron-headful'")
|
||||
})
|
||||
|
||||
it('installs zsh in every Linux lane that can run paired startup readiness', () => {
|
||||
for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
|
||||
const installStep = e2eWorkflow.jobs[jobName].steps.find((step) =>
|
||||
step.name.startsWith('Install native build')
|
||||
)
|
||||
expect(installStep.run, jobName).toMatch(/\bzsh\b/)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps dedicated E2E workflows out of pull request CI', () => {
|
||||
const dedicatedWorkflows = [
|
||||
'golden-e2e-experiment.yml',
|
||||
|
||||
@@ -8,6 +8,7 @@ const dependencyAction = parse(
|
||||
)
|
||||
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
|
||||
const shellContractFiles = [
|
||||
'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts',
|
||||
'src/main/daemon/shell-ready.test.ts',
|
||||
'src/main/providers/local-pty-shell-ready.test.ts',
|
||||
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
|
||||
@@ -75,6 +76,7 @@ describe('PR workflow parallelism', () => {
|
||||
|
||||
expect(shellStep).toBeDefined()
|
||||
expect(shellInstall).toBeDefined()
|
||||
expect(shellStep.run.split(/\s+/)).toContain('--maxWorkers=1')
|
||||
// Why the whole workflow, not just the general shards: any other lane installing
|
||||
// these shells would silently start running the real-shell tests twice.
|
||||
expect(jobsInstallingPackages).toEqual(['shell_contracts'])
|
||||
|
||||
@@ -94,7 +94,7 @@ function waitForStdoutSentinel(proc, protocol, stderr) {
|
||||
stdoutBuffer = Buffer.concat([stdoutBuffer, chunk])
|
||||
const sentinel = Buffer.from(protocol.RELAY_SENTINEL)
|
||||
const index = stdoutBuffer.indexOf(sentinel)
|
||||
if (index < 0) {
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
@@ -148,7 +148,7 @@ function createRelayClient(entryPath, args, env, protocol) {
|
||||
stdoutBuffer = Buffer.concat([stdoutBuffer, chunk])
|
||||
const sentinel = Buffer.from(protocol.RELAY_SENTINEL)
|
||||
const index = stdoutBuffer.indexOf(sentinel)
|
||||
if (index < 0) {
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
ready = true
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
resolveEnvironment,
|
||||
resolveEnvironmentPairingOffer
|
||||
} from '../../src/shared/runtime-environment-store'
|
||||
import type { MemorySnapshot, RuntimeStatus } from '../../src/shared/types'
|
||||
import type { MemorySnapshot } from '../../src/shared/types'
|
||||
import type { RuntimeStatus } from '../../src/shared/runtime-types'
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const environmentName = process.env.ORCA_PROBE_ENVIRONMENT_NAME
|
||||
|
||||
@@ -1,15 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
import { _electron as electron } from '@stablyai/playwright-test'
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
descendantsOf,
|
||||
readProcessRows,
|
||||
sampleProcessTreeUntilWorkloadsComplete,
|
||||
summarizeProcessInventory,
|
||||
summarizeSamples,
|
||||
terminateProcesses
|
||||
} from './idle-cpu-process-sampling.mjs'
|
||||
import {
|
||||
collectRendererCensus,
|
||||
configureRendererScaleFixture
|
||||
} from './idle-cpu-renderer-scale-fixture.mjs'
|
||||
import {
|
||||
runZustandPublications,
|
||||
snapshotRendererTimingProbe,
|
||||
startRendererTimingProbe,
|
||||
stopRendererTimingProbe
|
||||
} from './idle-cpu-renderer-timing-probe.mjs'
|
||||
import { installSyntheticVisibleSpinners } from './idle-cpu-synthetic-spinners.mjs'
|
||||
|
||||
const DEFAULT_WARMUP_MS = 15_000
|
||||
const DEFAULT_SAMPLE_MS = 30_000
|
||||
const DEFAULT_INTERVAL_MS = 1_000
|
||||
const DEFAULT_WORKTREE_COUNT = 1
|
||||
const DEFAULT_ZUSTAND_PUBLICATION_INTERVAL_MS = 100
|
||||
const ONBOARDING_FINAL_STEP = 3
|
||||
const ONBOARDING_FLOW_VERSION = 2
|
||||
|
||||
@@ -19,6 +38,10 @@ function parseArgs(argv) {
|
||||
sampleMs: DEFAULT_SAMPLE_MS,
|
||||
intervalMs: DEFAULT_INTERVAL_MS,
|
||||
worktrees: DEFAULT_WORKTREE_COUNT,
|
||||
lineageDepth: 0,
|
||||
agentsPerWorktree: 0,
|
||||
zustandPublications: 0,
|
||||
zustandPublicationIntervalMs: DEFAULT_ZUSTAND_PUBLICATION_INTERVAL_MS,
|
||||
skipBuild: false,
|
||||
headful: false,
|
||||
output: null,
|
||||
@@ -47,6 +70,14 @@ function parseArgs(argv) {
|
||||
options.intervalMs = Number(readValue())
|
||||
} else if (arg === '--worktrees') {
|
||||
options.worktrees = Number(readValue())
|
||||
} else if (arg === '--lineage-depth') {
|
||||
options.lineageDepth = Number(readValue())
|
||||
} else if (arg === '--agents-per-worktree') {
|
||||
options.agentsPerWorktree = Number(readValue())
|
||||
} else if (arg === '--zustand-publications') {
|
||||
options.zustandPublications = Number(readValue())
|
||||
} else if (arg === '--zustand-publication-interval-ms') {
|
||||
options.zustandPublicationIntervalMs = Number(readValue())
|
||||
} else if (arg === '--output') {
|
||||
options.output = readValue()
|
||||
} else if (arg === '--skip-build') {
|
||||
@@ -73,6 +104,10 @@ function parseArgs(argv) {
|
||||
'sampleMs',
|
||||
'intervalMs',
|
||||
'worktrees',
|
||||
'lineageDepth',
|
||||
'agentsPerWorktree',
|
||||
'zustandPublications',
|
||||
'zustandPublicationIntervalMs',
|
||||
'syntheticVisibleSpinners',
|
||||
'syntheticSpinnerSteps'
|
||||
]) {
|
||||
@@ -82,16 +117,33 @@ function parseArgs(argv) {
|
||||
}
|
||||
options.worktrees = Math.max(1, Math.floor(options.worktrees))
|
||||
options.intervalMs = Math.max(250, Math.floor(options.intervalMs))
|
||||
options.lineageDepth = Math.floor(options.lineageDepth)
|
||||
options.agentsPerWorktree = Math.floor(options.agentsPerWorktree)
|
||||
options.zustandPublications = Math.floor(options.zustandPublications)
|
||||
options.zustandPublicationIntervalMs = Math.max(
|
||||
1,
|
||||
Math.floor(options.zustandPublicationIntervalMs)
|
||||
)
|
||||
options.syntheticVisibleSpinners = Math.max(0, Math.floor(options.syntheticVisibleSpinners))
|
||||
options.syntheticSpinnerSteps = Math.max(1, Math.floor(options.syntheticSpinnerSteps))
|
||||
if (!['smooth', 'steps'].includes(options.syntheticSpinnerAnimation)) {
|
||||
throw new Error(`Invalid --synthetic-spinner-animation: ${options.syntheticSpinnerAnimation}`)
|
||||
}
|
||||
if (options.lineageDepth > 0 && options.worktrees < 2) {
|
||||
throw new Error('--lineage-depth requires at least two --worktrees')
|
||||
}
|
||||
const publicationSpanMs =
|
||||
Math.max(0, options.zustandPublications - 1) * options.zustandPublicationIntervalMs
|
||||
if (publicationSpanMs > options.sampleMs) {
|
||||
throw new Error(
|
||||
`Zustand publication span ${publicationSpanMs}ms exceeds --sample-ms ${options.sampleMs}`
|
||||
)
|
||||
}
|
||||
return options
|
||||
}
|
||||
function printUsage() {
|
||||
console.log(
|
||||
`Usage: node config/scripts/run-idle-cpu-benchmark.mjs [options]\n\nOptions:\n --warmup-ms <n> Time to wait after app readiness before sampling (default ${DEFAULT_WARMUP_MS})\n --sample-ms <n> Sampling window duration (default ${DEFAULT_SAMPLE_MS})\n --interval-ms <n> Sampling cadence (default ${DEFAULT_INTERVAL_MS})\n --worktrees <n> Seed repo worktree count, including primary (default ${DEFAULT_WORKTREE_COUNT})\n --headful Show the Electron window while measuring\n --skip-build Reuse out/main/index.js instead of building first\n --output <path> Write JSON report to this path\n --disable-renderer-animations Inject measurement-only CSS that disables animations/transitions\n --synthetic-visible-spinners <n> Measurement-only: add visible working spinners\n --synthetic-spinner-animation <smooth|steps> Spinner animation style (default smooth)\n --synthetic-spinner-steps <n> Step count for --synthetic-spinner-animation steps (default 12)\n`
|
||||
`Usage: node config/scripts/run-idle-cpu-benchmark.mjs [options]\n\nOptions:\n --warmup-ms <n> Time to wait after app readiness before sampling (default ${DEFAULT_WARMUP_MS})\n --sample-ms <n> Sampling window duration (default ${DEFAULT_SAMPLE_MS})\n --interval-ms <n> Sampling cadence (default ${DEFAULT_INTERVAL_MS})\n --worktrees <n> Seed repo worktree count, including primary (default ${DEFAULT_WORKTREE_COUNT})\n --lineage-depth <n> Nest all worktrees under one expanded lineage, up to this depth\n --agents-per-worktree <n> Seed this many visible inline agent rows per worktree\n --zustand-publications <n> Publish exactly this many store updates during sampling\n --zustand-publication-interval-ms <n> Publication cadence (default ${DEFAULT_ZUSTAND_PUBLICATION_INTERVAL_MS})\n --headful Show the Electron window while measuring\n --skip-build Reuse out/main/index.js instead of building first\n --output <path> Write JSON report to this path\n --disable-renderer-animations Inject measurement-only CSS that disables animations/transitions\n --synthetic-visible-spinners <n> Measurement-only: add visible working spinners\n --synthetic-spinner-animation <smooth|steps> Spinner animation style (default smooth)\n --synthetic-spinner-steps <n> Step count for --synthetic-spinner-animation steps (default 12)\n`
|
||||
)
|
||||
}
|
||||
function run(command, args, options = {}) {
|
||||
@@ -188,140 +240,6 @@ function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function parseCpuTimeSeconds(value) {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
const [dayOrTime, maybeTime] = trimmed.includes('-') ? trimmed.split('-', 2) : [null, trimmed]
|
||||
const days = dayOrTime === null ? 0 : Number(dayOrTime)
|
||||
const parts = maybeTime.split(':').map(Number)
|
||||
if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) {
|
||||
return null
|
||||
}
|
||||
if (parts.length === 3) {
|
||||
return days * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2]
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return days * 86400 + parts[0] * 60 + parts[1]
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
return days * 86400 + parts[0]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseUnixProcesses(stdout) {
|
||||
const rows = []
|
||||
for (const raw of stdout.split('\n')) {
|
||||
const line = raw.trim()
|
||||
if (!line) {
|
||||
continue
|
||||
}
|
||||
const match = line.match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.+)$/)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
rows.push({
|
||||
pid: Number(match[1]),
|
||||
ppid: Number(match[2]),
|
||||
percentCpu: Number(match[3]),
|
||||
rssBytes: Number(match[4]) * 1024,
|
||||
cpuTimeSeconds: parseCpuTimeSeconds(match[5]),
|
||||
command: match[6]
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function readUnixProcesses() {
|
||||
const stdout = execFileSync('ps', ['-axo', 'pid=,ppid=,pcpu=,rss=,cputime=,command='], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
|
||||
maxBuffer: 20 * 1024 * 1024
|
||||
})
|
||||
return parseUnixProcesses(stdout)
|
||||
}
|
||||
|
||||
function readWindowsProcesses() {
|
||||
const script =
|
||||
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,WorkingSetSize,CommandLine | ConvertTo-Json -Compress'
|
||||
const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', script], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 20 * 1024 * 1024
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr || 'PowerShell process enumeration failed')
|
||||
}
|
||||
const parsed = JSON.parse(result.stdout || '[]')
|
||||
const entries = Array.isArray(parsed) ? parsed : [parsed]
|
||||
return entries.map((entry) => ({
|
||||
pid: Number(entry.ProcessId),
|
||||
ppid: Number(entry.ParentProcessId),
|
||||
percentCpu: 0,
|
||||
cpuTimeSeconds: null,
|
||||
rssBytes: Number(entry.WorkingSetSize) || 0,
|
||||
command: String(entry.CommandLine || '')
|
||||
}))
|
||||
}
|
||||
|
||||
function readProcessRows() {
|
||||
return process.platform === 'win32' ? readWindowsProcesses() : readUnixProcesses()
|
||||
}
|
||||
|
||||
function descendantsOf(rows, rootPid) {
|
||||
const children = new Map()
|
||||
for (const row of rows) {
|
||||
const list = children.get(row.ppid) ?? []
|
||||
list.push(row)
|
||||
children.set(row.ppid, list)
|
||||
}
|
||||
const result = []
|
||||
const stack = [rootPid]
|
||||
const seen = new Set()
|
||||
while (stack.length > 0) {
|
||||
const pid = stack.pop()
|
||||
if (seen.has(pid)) {
|
||||
continue
|
||||
}
|
||||
seen.add(pid)
|
||||
const row = rows.find((candidate) => candidate.pid === pid)
|
||||
if (row) {
|
||||
result.push(row)
|
||||
}
|
||||
for (const child of children.get(pid) ?? []) {
|
||||
stack.push(child.pid)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function classify(row, rootPid) {
|
||||
const command = row.command.toLowerCase()
|
||||
if (row.pid === rootPid) {
|
||||
return 'main'
|
||||
}
|
||||
if (command.includes('daemon-entry')) {
|
||||
return 'daemon'
|
||||
}
|
||||
if (command.includes('--type=gpu-process')) {
|
||||
return 'gpu'
|
||||
}
|
||||
if (command.includes('--type=renderer')) {
|
||||
return 'renderer'
|
||||
}
|
||||
if (command.includes('--type=utility')) {
|
||||
return 'utility'
|
||||
}
|
||||
if (command.includes('--type=')) {
|
||||
return 'electron-other'
|
||||
}
|
||||
if (command.includes('node') || command.includes('/pi') || command.endsWith(' pi')) {
|
||||
return 'agent-or-node'
|
||||
}
|
||||
return 'other-descendant'
|
||||
}
|
||||
|
||||
async function collectRendererIdleState(page) {
|
||||
return page.evaluate(() => {
|
||||
const describeElement = (element) => {
|
||||
@@ -364,93 +282,6 @@ async function collectRendererIdleState(page) {
|
||||
})
|
||||
}
|
||||
|
||||
function summarizeSamples(samples) {
|
||||
const byKind = new Map()
|
||||
for (const sample of samples) {
|
||||
for (const proc of sample.processes) {
|
||||
const bucket = byKind.get(proc.kind) ?? { cpuValues: [], rssValues: [], maxProcessCount: 0 }
|
||||
bucket.cpuValues.push(proc.cpu)
|
||||
bucket.rssValues.push(proc.rssBytes)
|
||||
byKind.set(proc.kind, bucket)
|
||||
}
|
||||
const counts = new Map()
|
||||
for (const proc of sample.processes) {
|
||||
counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
|
||||
}
|
||||
for (const [kind, count] of counts) {
|
||||
byKind.get(kind).maxProcessCount = Math.max(byKind.get(kind).maxProcessCount, count)
|
||||
}
|
||||
}
|
||||
const summary = {}
|
||||
for (const [kind, values] of byKind) {
|
||||
const cpuSorted = [...values.cpuValues].sort((a, b) => a - b)
|
||||
const rssSumBySample = samples.map((sample) =>
|
||||
sample.processes
|
||||
.filter((proc) => proc.kind === kind)
|
||||
.reduce((sum, proc) => sum + proc.rssBytes, 0)
|
||||
)
|
||||
summary[kind] = {
|
||||
meanCpuPercent: mean(values.cpuValues),
|
||||
p95CpuPercent: percentile(cpuSorted, 0.95),
|
||||
maxCpuPercent: Math.max(0, ...values.cpuValues),
|
||||
meanRssBytes: mean(rssSumBySample),
|
||||
maxProcessCount: values.maxProcessCount
|
||||
}
|
||||
}
|
||||
summary.total = {
|
||||
meanCpuPercent: mean(samples.map((sample) => sample.totalCpuPercent)),
|
||||
p95CpuPercent: percentile(
|
||||
samples.map((sample) => sample.totalCpuPercent).sort((a, b) => a - b),
|
||||
0.95
|
||||
),
|
||||
meanRssBytes: mean(samples.map((sample) => sample.totalRssBytes))
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
function summarizeProcessInventory(samples) {
|
||||
const inventory = {}
|
||||
for (const sample of samples) {
|
||||
const counts = new Map()
|
||||
for (const proc of sample.processes) {
|
||||
counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
|
||||
const entry = inventory[proc.kind] ?? {
|
||||
maxProcessCount: 0,
|
||||
maxCpuPercent: 0,
|
||||
commandSamples: []
|
||||
}
|
||||
entry.maxCpuPercent = Math.max(entry.maxCpuPercent, proc.cpu)
|
||||
if (!entry.commandSamples.includes(proc.command) && entry.commandSamples.length < 6) {
|
||||
entry.commandSamples.push(proc.command)
|
||||
}
|
||||
inventory[proc.kind] = entry
|
||||
}
|
||||
for (const [kind, count] of counts) {
|
||||
inventory[kind].maxProcessCount = Math.max(inventory[kind].maxProcessCount, count)
|
||||
}
|
||||
}
|
||||
return inventory
|
||||
}
|
||||
function mean(values) {
|
||||
return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
}
|
||||
|
||||
function percentile(sorted, fraction) {
|
||||
if (sorted.length === 0) {
|
||||
return 0
|
||||
}
|
||||
const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)
|
||||
return sorted[index]
|
||||
}
|
||||
|
||||
function terminateProcesses(processes) {
|
||||
for (const proc of processes) {
|
||||
try {
|
||||
process.kill(proc.pid)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
const root = path.resolve(import.meta.dirname, '..', '..')
|
||||
@@ -491,6 +322,11 @@ async function main() {
|
||||
const page = await app.firstWindow({ timeout: 120_000 })
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 })
|
||||
await page.waitForFunction(
|
||||
() => window.__store?.getState().workspaceSessionReady === true,
|
||||
null,
|
||||
{ timeout: 60_000 }
|
||||
)
|
||||
const measurementCss = []
|
||||
if (options.disableRendererAnimations) {
|
||||
measurementCss.push(
|
||||
@@ -506,63 +342,111 @@ async function main() {
|
||||
options.syntheticSpinnerAnimation,
|
||||
options.syntheticSpinnerSteps
|
||||
)
|
||||
await page.evaluate(async (repoPath) => {
|
||||
await window.api.repos.add({ path: repoPath })
|
||||
const fixtureState = await page.evaluate(async (repoPath) => {
|
||||
const added = await window.api.repos.add({ path: repoPath })
|
||||
if ('error' in added) {
|
||||
return { error: added.error }
|
||||
}
|
||||
const store = window.__store
|
||||
await store?.getState().fetchRepos()
|
||||
const repo = store?.getState().repos.find((candidate) => candidate.path === repoPath)
|
||||
const repo = store?.getState().repos.find((candidate) => candidate.id === added.repo.id)
|
||||
if (repo) {
|
||||
await store.getState().updateRepo(repo.id, { externalWorktreeVisibility: 'show' })
|
||||
await store.getState().fetchWorktrees(repo.id)
|
||||
const detected = await store
|
||||
.getState()
|
||||
.fetchWorktrees(repo.id, { requireAuthoritative: true })
|
||||
const importedWorktreePaths = (
|
||||
store.getState().detectedWorktreesByRepo[repo.id]?.worktrees ?? []
|
||||
)
|
||||
.filter((worktree) => !worktree.selectedCheckout)
|
||||
.map((worktree) => worktree.path)
|
||||
const updated = await store.getState().updateRepo(repo.id, {
|
||||
externalWorktreeVisibility: 'show',
|
||||
importedExternalWorktreePaths: importedWorktreePaths,
|
||||
externalWorktreeInboxBaselinePaths: importedWorktreePaths
|
||||
})
|
||||
const refreshed = await store
|
||||
.getState()
|
||||
.fetchWorktrees(repo.id, { requireAuthoritative: true })
|
||||
return {
|
||||
detected,
|
||||
updated,
|
||||
refreshed,
|
||||
detectedCount: store.getState().detectedWorktreesByRepo[repo.id]?.worktrees.length ?? 0,
|
||||
importedCount: importedWorktreePaths.length,
|
||||
visibleCount: store.getState().worktreesByRepo[repo.id]?.length ?? 0
|
||||
}
|
||||
}
|
||||
return { error: 'repo-not-found' }
|
||||
}, repoDir)
|
||||
console.log(`[idle-cpu] fixture ${JSON.stringify(fixtureState)}`)
|
||||
await page.waitForFunction(
|
||||
() => window.__store?.getState().workspaceSessionReady === true,
|
||||
(expectedWorktrees) => {
|
||||
const state = window.__store?.getState()
|
||||
return (
|
||||
state?.workspaceSessionReady === true &&
|
||||
Object.values(state.worktreesByRepo).flat().length === expectedWorktrees
|
||||
)
|
||||
},
|
||||
options.worktrees,
|
||||
{ timeout: 180_000 }
|
||||
)
|
||||
const scaleFixtureState = await configureRendererScaleFixture(page, options, repoDir)
|
||||
if (scaleFixtureState.applied) {
|
||||
console.log(`[idle-cpu] scale fixture ${JSON.stringify(scaleFixtureState)}`)
|
||||
}
|
||||
await page.waitForFunction(
|
||||
(expectedAgentRows) => {
|
||||
const state = window.__store?.getState()
|
||||
return (
|
||||
state !== undefined &&
|
||||
Object.keys(state.agentStatusByPaneKey ?? {}).length >= expectedAgentRows
|
||||
)
|
||||
},
|
||||
scaleFixtureState.seededAgentRows,
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
await page.waitForFunction(
|
||||
() => Boolean(document.querySelector('[data-worktree-sidebar] [data-worktree-id]')),
|
||||
null,
|
||||
{ timeout: 60_000 }
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
console.log(
|
||||
`[idle-cpu] root pid=${rootPid}; warmup=${options.warmupMs}ms sample=${options.sampleMs}ms interval=${options.intervalMs}ms worktrees=${options.worktrees}`
|
||||
`[idle-cpu] root pid=${rootPid}; warmup=${options.warmupMs}ms sample=${options.sampleMs}ms interval=${options.intervalMs}ms worktrees=${options.worktrees} lineage-depth=${options.lineageDepth} agents/worktree=${options.agentsPerWorktree} publications=${options.zustandPublications}`
|
||||
)
|
||||
await startRendererTimingProbe(page)
|
||||
await sleep(options.warmupMs)
|
||||
const rendererIdleState = await collectRendererIdleState(page)
|
||||
const deadline = Date.now() + options.sampleMs
|
||||
const samples = []
|
||||
let previousSnapshot = null
|
||||
while (Date.now() <= deadline || samples.length === 0) {
|
||||
const sampledAt = Date.now()
|
||||
const processRows = descendantsOf(readProcessRows(), rootPid)
|
||||
const rawProcesses = processRows.map((row) => ({ ...row, kind: classify(row, rootPid) }))
|
||||
if (previousSnapshot) {
|
||||
const elapsedSeconds = Math.max(0.001, (sampledAt - previousSnapshot.at) / 1000)
|
||||
const previousByPid = new Map(previousSnapshot.processes.map((proc) => [proc.pid, proc]))
|
||||
const processes = rawProcesses.map((row) => {
|
||||
const previous = previousByPid.get(row.pid)
|
||||
const canComputeDelta =
|
||||
typeof row.cpuTimeSeconds === 'number' && typeof previous?.cpuTimeSeconds === 'number'
|
||||
const cpu = canComputeDelta
|
||||
? Math.max(0, ((row.cpuTimeSeconds - previous.cpuTimeSeconds) / elapsedSeconds) * 100)
|
||||
: row.percentCpu
|
||||
return { ...row, cpu }
|
||||
})
|
||||
samples.push({
|
||||
at: sampledAt,
|
||||
elapsedMs: sampledAt - previousSnapshot.at,
|
||||
totalCpuPercent: processes.reduce((sum, proc) => sum + proc.cpu, 0),
|
||||
totalRssBytes: processes.reduce((sum, proc) => sum + proc.rssBytes, 0),
|
||||
processes
|
||||
})
|
||||
}
|
||||
previousSnapshot = { at: sampledAt, processes: rawProcesses }
|
||||
await sleep(options.intervalMs)
|
||||
}
|
||||
const rendererCensusBefore = await collectRendererCensus(page, options.lineageDepth)
|
||||
const rendererTimingBefore = await snapshotRendererTimingProbe(page)
|
||||
const publicationPromise = runZustandPublications(
|
||||
page,
|
||||
options.zustandPublications,
|
||||
options.zustandPublicationIntervalMs
|
||||
)
|
||||
const sampled = await sampleProcessTreeUntilWorkloadsComplete({
|
||||
rootPid,
|
||||
requestedDurationMs: options.sampleMs,
|
||||
intervalMs: options.intervalMs,
|
||||
workloadPromise: publicationPromise
|
||||
})
|
||||
const samples = sampled.samples
|
||||
const zustandPublications = sampled.workloadResult
|
||||
const rendererTimingAfter = await stopRendererTimingProbe(page)
|
||||
const rendererCensusAfter = await collectRendererCensus(page, options.lineageDepth)
|
||||
const report = {
|
||||
benchmark: 'orca-idle-cpu',
|
||||
createdAt: new Date().toISOString(),
|
||||
options,
|
||||
rootPid,
|
||||
platform: { platform: process.platform, arch: process.arch, cpus: os.cpus().length },
|
||||
fixtureState,
|
||||
scaleFixtureState,
|
||||
rendererIdleState,
|
||||
rendererCensusBefore,
|
||||
rendererCensusAfter,
|
||||
rendererTiming: { before: rendererTimingBefore, after: rendererTimingAfter },
|
||||
zustandPublications,
|
||||
samplingWindow: sampled.samplingWindow,
|
||||
sampleCount: samples.length,
|
||||
summary: summarizeSamples(samples),
|
||||
processInventory: summarizeProcessInventory(samples),
|
||||
@@ -578,7 +462,13 @@ async function main() {
|
||||
{
|
||||
summary: report.summary,
|
||||
processInventory: report.processInventory,
|
||||
sampleCount: report.sampleCount
|
||||
sampleCount: report.sampleCount,
|
||||
scaleFixtureState: report.scaleFixtureState,
|
||||
rendererCensusBefore: report.rendererCensusBefore,
|
||||
rendererCensusAfter: report.rendererCensusAfter,
|
||||
rendererTiming: report.rendererTiming,
|
||||
zustandPublications: report.zustandPublications,
|
||||
samplingWindow: report.samplingWindow
|
||||
},
|
||||
null,
|
||||
2
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const rawExtraArgs = process.argv.slice(2)
|
||||
const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
|
||||
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const env = {
|
||||
...process.env,
|
||||
ORCA_E2E_SSH_DOCKER: '1'
|
||||
}
|
||||
|
||||
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
|
||||
const spawnOptions = {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
shell: process.platform === 'win32'
|
||||
}
|
||||
|
||||
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
|
||||
|
||||
if (runtime.status !== 0) {
|
||||
process.exit(runtime.status ?? 1)
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
pnpm,
|
||||
[
|
||||
'exec',
|
||||
'playwright',
|
||||
'test',
|
||||
'tests/e2e/ssh-maxsessions-remote-pid-binding-identity.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--workers=1',
|
||||
...extraArgs
|
||||
],
|
||||
spawnOptions
|
||||
)
|
||||
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -0,0 +1,41 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const rawExtraArgs = process.argv.slice(2)
|
||||
const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
|
||||
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const env = {
|
||||
...process.env,
|
||||
ORCA_E2E_SSH_DOCKER: '1'
|
||||
}
|
||||
|
||||
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
|
||||
const spawnOptions = {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
shell: process.platform === 'win32'
|
||||
}
|
||||
|
||||
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
|
||||
|
||||
if (runtime.status !== 0) {
|
||||
process.exit(runtime.status ?? 1)
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
pnpm,
|
||||
[
|
||||
'exec',
|
||||
'playwright',
|
||||
'test',
|
||||
'tests/e2e/ssh-reconnect-pane-cardinality.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--workers=1',
|
||||
...extraArgs
|
||||
],
|
||||
spawnOptions
|
||||
)
|
||||
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -5,7 +5,8 @@ const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArg
|
||||
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const env = {
|
||||
...process.env,
|
||||
ORCA_E2E_SSH_DOCKER: '1'
|
||||
ORCA_E2E_SSH_DOCKER: '1',
|
||||
ORCA_E2E_WEB_CLIENT: '1'
|
||||
}
|
||||
|
||||
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
|
||||
@@ -21,9 +22,8 @@ if (runtime.status !== 0) {
|
||||
process.exit(runtime.status ?? 1)
|
||||
}
|
||||
|
||||
// Why both specs in one runner: they share the docker SSH rig and the same
|
||||
// ORCA_E2E_SSH_DOCKER gate — the SSH parking spec proves SSH panes park and
|
||||
// restore, the retention spec proves the C1 budget bounds what they retain.
|
||||
// Why one runner: these specs share the Docker SSH rig; startup-exec also joins
|
||||
// the paired desktop/headless parity proof under the same isolated CI lane.
|
||||
const result = spawnSync(
|
||||
pnpm,
|
||||
[
|
||||
@@ -32,10 +32,14 @@ const result = spawnSync(
|
||||
'test',
|
||||
'tests/e2e/ssh-terminal-parking.spec.ts',
|
||||
'tests/e2e/terminal-retention-budget.spec.ts',
|
||||
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
|
||||
'tests/e2e/paired-startup-exec-readiness.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--project',
|
||||
'electron-headful',
|
||||
'--workers=1',
|
||||
...extraArgs
|
||||
],
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const rawExtraArgs = process.argv.slice(2)
|
||||
const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
|
||||
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const env = {
|
||||
...process.env,
|
||||
ORCA_E2E_SSH_DOCKER: '1'
|
||||
}
|
||||
|
||||
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
|
||||
const spawnOptions = {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
shell: process.platform === 'win32'
|
||||
}
|
||||
|
||||
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
|
||||
|
||||
if (runtime.status !== 0) {
|
||||
process.exit(runtime.status ?? 1)
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
pnpm,
|
||||
[
|
||||
'exec',
|
||||
'playwright',
|
||||
'test',
|
||||
'tests/e2e/ssh-two-hosts-isolation.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--workers=1',
|
||||
...extraArgs
|
||||
],
|
||||
spawnOptions
|
||||
)
|
||||
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -17,8 +17,8 @@ const defaultFiles = [
|
||||
const cliArguments = process.argv.slice(2)
|
||||
const powerShellFlag = cliArguments.indexOf('--powershell')
|
||||
const configuredPowerShell =
|
||||
powerShellFlag >= 0 ? cliArguments[powerShellFlag + 1] : process.env.ORCA_POWERSHELL_EXECUTABLE
|
||||
if (powerShellFlag >= 0 && !configuredPowerShell) {
|
||||
powerShellFlag !== -1 ? cliArguments[powerShellFlag + 1] : process.env.ORCA_POWERSHELL_EXECUTABLE
|
||||
if (powerShellFlag !== -1 && !configuredPowerShell) {
|
||||
console.error('--powershell requires an executable path')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
@@ -222,8 +222,8 @@ if (process.argv[1]?.endsWith('terminal-e2e-helpers.mjs')) {
|
||||
const args = process.argv.slice(2)
|
||||
const portIdx = args.indexOf('--port')
|
||||
const cmdIdx = args.indexOf('--command')
|
||||
const port = portIdx >= 0 ? Number(args[portIdx + 1]) : 9444
|
||||
const command = cmdIdx >= 0 ? args[cmdIdx + 1] : null
|
||||
const port = portIdx !== -1 ? Number(args[portIdx + 1]) : 9444
|
||||
const command = cmdIdx !== -1 ? args[cmdIdx + 1] : null
|
||||
|
||||
const term = new OrcaTerminal(port)
|
||||
console.log('Connecting to Orca on CDP port', port, '...')
|
||||
|
||||
@@ -142,7 +142,7 @@ function readGeneratedScripts(home, minMtime) {
|
||||
const body = readFileSync(path, 'utf8')
|
||||
const captureIndex = body.indexOf('payload=$(cat)')
|
||||
const firstExitIndex = body.indexOf('exit 0')
|
||||
if (captureIndex < 0 || firstExitIndex <= captureIndex) {
|
||||
if (captureIndex === -1 || firstExitIndex <= captureIndex) {
|
||||
throw new Error([fileName, ' can exit before capturing stdin'].join(''))
|
||||
}
|
||||
return { body, fileName, path, source }
|
||||
|
||||
@@ -489,11 +489,11 @@ function verifyNativeArgumentGuardrails() {
|
||||
|
||||
function powerShellFunctionBody(source, name) {
|
||||
const start = source.indexOf(`function ${name}`)
|
||||
if (start < 0) {
|
||||
if (start === -1) {
|
||||
return null
|
||||
}
|
||||
const bodyStart = source.indexOf('{', start)
|
||||
if (bodyStart < 0) {
|
||||
if (bodyStart === -1) {
|
||||
return null
|
||||
}
|
||||
let depth = 0
|
||||
@@ -513,11 +513,11 @@ function powerShellFunctionBody(source, name) {
|
||||
|
||||
function swiftFunctionBody(source, name) {
|
||||
const start = source.indexOf(`func ${name}`)
|
||||
if (start < 0) {
|
||||
if (start === -1) {
|
||||
return null
|
||||
}
|
||||
const bodyStart = source.indexOf('{', start)
|
||||
if (bodyStart < 0) {
|
||||
if (bodyStart === -1) {
|
||||
return null
|
||||
}
|
||||
let depth = 0
|
||||
|
||||
@@ -175,7 +175,7 @@ async function main() {
|
||||
wslShellArgs(distro, ['/bin/sh', '-lc', escapeWslShCommandForWindows(script)])
|
||||
)
|
||||
const markerOffset = result.stdout.indexOf(outputMarker)
|
||||
if (markerOffset < 0) {
|
||||
if (markerOffset === -1) {
|
||||
throw new Error('Login shell did not emit the Git output marker')
|
||||
}
|
||||
result.stdout = result.stdout.subarray(markerOffset + Buffer.byteLength(outputMarker))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 18m">
|
||||
<title>downloads: 18m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 19m">
|
||||
<title>downloads: 19m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">18m</text>
|
||||
<text x="90" y="14">18m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">19m</text>
|
||||
<text x="90" y="14">19m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
@@ -36,7 +36,7 @@
|
||||
|
||||
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -179,12 +179,14 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="MiMo Code logo" width="16" valign="middle" /> MiMo Code</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="oh-my-pi logo" width="16" valign="middle" /> oh-my-pi</kbd></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Hermes Agent logo" width="16" valign="middle" /> Hermes Agent</kbd></a>
|
||||
<a href="https://devin.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=devin.ai&sz=64" alt="Devin logo" width="16" valign="middle" /> Devin</kbd></a>
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Goose logo" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Auggie logo" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Autohand Code logo" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
@@ -211,6 +213,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내
|
||||
|
||||
- **[onOrca.dev에서 다운로드](https://onorca.dev/download)**
|
||||
- 또는 빌드를 직접 받기: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [전체 빌드](https://github.com/stablyai/orca/releases/latest)
|
||||
- headless Linux 서버에서 `orca serve`를 실행하시나요? [Headless Linux 서버 가이드](../reference/headless-linux-server.md)를 확인하세요.
|
||||
|
||||
_또는 패키지 매니저로 설치:_
|
||||
|
||||
@@ -226,8 +229,8 @@ yay -S stably-orca-bin
|
||||
|
||||
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
|
||||
|
||||
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
|
||||
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) 또는 [TestFlight 참여](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [APK 0.0.42 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
@@ -235,6 +238,10 @@ yay -S stably-orca-bin
|
||||
|
||||
- **Discord:** **[Discord](https://discord.gg/fzjDKHxv8Q)** 커뮤니티에 참여하세요.
|
||||
- **Twitter / X:** 업데이트와 공지는 **[@orca_build](https://x.com/orca_build)** 를 팔로우하세요.
|
||||
- **WeChat:** QR 코드를 스캔해 Orca 커뮤니티 WeChat 그룹 7에 참여하세요.
|
||||
|
||||
<img src="../assets/wechat-qr-group7.jpg" alt="Orca 커뮤니티 WeChat 그룹 7 QR 코드" width="160" />
|
||||
|
||||
- **피드백과 아이디어:** 우리는 빠르게 출시합니다. 필요한 기능이 있나요? [새 기능을 요청](https://github.com/stablyai/orca/issues)하세요.
|
||||
- **개인정보 보호:** Orca가 수집하는 익명 사용 데이터와 수집 거부 방법은 [개인정보 및 텔레메트리 문서](https://www.onorca.dev/docs/telemetry)를 참고하세요.
|
||||
- **응원하기:** 이 리포지토리에 [Star](https://github.com/stablyai/orca)를 눌러 매일 공개되는 릴리스 소식을 확인해 주세요.
|
||||
@@ -249,6 +256,14 @@ yay -S stably-orca-bin
|
||||
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Orca 기여자" />
|
||||
</a>
|
||||
|
||||
<p align="center">
|
||||
<img src="../assets/star-history.png" alt="stablyai/orca GitHub Star 히스토리 차트" width="880" />
|
||||
</p>
|
||||
|
||||
## 서명된 빌드
|
||||
|
||||
Windows 코드 서명은 [SignPath.io](https://signpath.io)가 후원·제공하며, 인증서는 [SignPath Foundation](https://signpath.org)에서 제공합니다.
|
||||
|
||||
## 라이선스
|
||||
|
||||
Orca는 [MIT 라이선스](../../LICENSE)에 따라 자유롭게 사용할 수 있는 오픈 소스입니다.
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
# Renderer agent-status performance
|
||||
|
||||
## Status
|
||||
|
||||
This design is adopted for the renderer's high-frequency agent-status path. It
|
||||
keeps the existing event semantics while bounding the amount of synchronous
|
||||
store fanout performed for one IPC burst.
|
||||
|
||||
It lands in slices. This document and the `bench:idle-cpu` harness land first so
|
||||
the store changes can be reviewed against a baseline someone else measured. Until
|
||||
the store slice lands, the `setAgentStatuses` / `transactAgentStatuses` actions
|
||||
and the agent-status write workload described below are not yet on `main`; the
|
||||
harness measures scale, listener census, and raw publication fanout only.
|
||||
|
||||
## Context
|
||||
|
||||
Orca can display a large expanded worktree lineage inside one virtualized list
|
||||
row. Virtualizing the root row does not virtualize its descendants, so a
|
||||
100-worktree lineage can mount 100 `WorktreeCard` instances at once.
|
||||
|
||||
Agent-status IPC events are bursty. The renderer already groups live events into
|
||||
a 33 ms window, but the original flush applied every queued event with a
|
||||
separate Zustand write. Zustand synchronously visits every listener for every
|
||||
publication. The resulting work therefore grew with both the number of status
|
||||
events and the number of mounted subscriptions:
|
||||
|
||||
```text
|
||||
burst work ~= status events x store listeners x selector work
|
||||
```
|
||||
|
||||
A production trace captured the renderer repeatedly entering
|
||||
`flushLiveAgentStatusBurst -> applyAgentStatus -> setAgentStatus -> setState`
|
||||
through `Set.forEach`. A deterministic 100-worktree fixture reproduces the
|
||||
structural multiplier; see "Baseline on `main`" below for the currently measured
|
||||
listener count and publication cost.
|
||||
|
||||
The production app later recovered substantially when all configured remote
|
||||
hosts were removed. Host removal can stop relay/reconnect traffic, remove
|
||||
mounted remote worktrees, or both, depending on host type and removal options.
|
||||
That observation identifies remote presence as the production trigger but does
|
||||
not by itself distinguish traffic volume from mounted-listener fanout.
|
||||
|
||||
A read-only reconnect audit ruled out systematic double status emission from a
|
||||
full PTY replay: replay bytes bypass OSC status parsing. Reconnect still causes
|
||||
a full terminal-buffer repaint for every attached remote pane, which is a
|
||||
separate source of renderer work and remains a follow-up investigation.
|
||||
|
||||
## Goals
|
||||
|
||||
- Keep a large expanded lineage responsive during dense agent-status traffic.
|
||||
- Preserve every ordered status transition, including repeated updates for one
|
||||
pane inside the same burst.
|
||||
- Publish agent-status state once for a deferred live burst.
|
||||
- Preserve selector identity and child render isolation.
|
||||
- Make the regression reproducible without relying on a user's production data.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Changing the client/server status payload or remote protocol.
|
||||
- Deduplicating status events by pane.
|
||||
- Changing agent freshness, retention, history, title, completion, or provider
|
||||
session behavior.
|
||||
- Changing remote reconnect, PTY replay, or terminal repaint behavior.
|
||||
- Redesigning lineage presentation or collapsing worktrees automatically.
|
||||
|
||||
## Design
|
||||
|
||||
### Bound mounted subscription fanout
|
||||
|
||||
Sidebar components select cohesive state bundles with shallow equality instead
|
||||
of registering one listener per field. Derived arrays and maps retain their
|
||||
existing shallow identity behavior so unrelated store writes do not rerender a
|
||||
card. Full agent-list mode keeps its child-level subscription boundary; compact
|
||||
mode passes the already selected rows to avoid selecting the same inputs twice.
|
||||
|
||||
The deterministic 100-worktree fixture pins the resulting listener budget:
|
||||
|
||||
| Surface | Listener budget |
|
||||
| ------------------------------ | --------------: |
|
||||
| Worktree card state and caches | 2 |
|
||||
| Agent-row inputs | 1 |
|
||||
| Worktree activity status | 1 |
|
||||
| Closed context menu | 1 |
|
||||
|
||||
Unmount tests require the listener count to return to its prior baseline. In the
|
||||
bundled prototype, the fixture without seeded agents fell from 8,518 listeners
|
||||
to 1,218; with 100 visible agent rows the candidate mounted 1,618. Compare
|
||||
against the census in "Baseline on `main`", which the harness reports directly.
|
||||
|
||||
### Share working-spinner phase without per-element animation queries
|
||||
|
||||
Working rows keep the existing compositor-driven CSS animation and shared
|
||||
visual phase. Each mount derives one negative animation delay from the document
|
||||
timeline instead of querying `getAnimations()` and mutating the animation start
|
||||
time. This removes per-row Web Animations setup from dense status transitions
|
||||
without adding a JavaScript animation clock.
|
||||
|
||||
### Fold a burst in event order
|
||||
|
||||
The store exposes the single-update action and two batch forms:
|
||||
|
||||
- `setAgentStatus(paneKey, payload, ...)` retains the positional single-update
|
||||
API for the immediate live path.
|
||||
- `setAgentStatuses(updates)` applies a prebuilt ordered list, while
|
||||
`transactAgentStatuses(operation)` lets IPC derive each update against the
|
||||
exact staged state before the single commit.
|
||||
|
||||
Both entry points reuse the same single-update state transition. The batch
|
||||
reducer passes each resulting state into the next update, so a sequence such as
|
||||
`working -> waiting -> done` retains the same history and timestamps as three
|
||||
sequential calls. Updates are never keyed or deduplicated before the fold.
|
||||
|
||||
The live IPC queue is spliced before it is processed. This preserves the
|
||||
existing reentrancy guarantee: a synchronous subscriber can enqueue another
|
||||
event without causing the current queue to be drained recursively. The first
|
||||
event outside an active burst remains immediate; events accumulated within the
|
||||
33 ms window are applied as one ordered transaction. Startup snapshots and
|
||||
bounded pending-hydration retries use the same transaction path instead of
|
||||
publishing once per restored pane.
|
||||
|
||||
Each transaction builds pane-routing ownership once with the same first-match
|
||||
semantics as the standalone resolver. Split-layout leaf membership is indexed
|
||||
once per layout root, so a large snapshot performs linear tab and leaf work
|
||||
instead of rescanning every mounted worktree for every pane.
|
||||
|
||||
### Run effects after the transaction
|
||||
|
||||
Generated-title work that requires committed state is deferred until after the
|
||||
transaction. Accepted updates also request freshness scheduling; the outer batch
|
||||
coalesces those requests and schedules the shared freshness timer once after its
|
||||
single commit. Generated-title requests are folded in event order and published
|
||||
together, including first-write and forced-replacement semantics. Resolved tab
|
||||
titles are projected while the transaction folds, then final title changes are
|
||||
published together. Completion-triggered review refreshes remain deferred
|
||||
microtasks.
|
||||
|
||||
Bulk title application preserves event order and duplicate-tab behavior while
|
||||
indexing owners once, cloning each changed owner array once, and replacing each
|
||||
top-level map once. This keeps the post-commit title phase linear in mounted
|
||||
tabs plus changed titles.
|
||||
|
||||
This separation is important: invoking store actions from inside a Zustand
|
||||
updater would re-enter the store, while running an effect before the commit
|
||||
would let it observe stale state.
|
||||
|
||||
## Semantic invariants
|
||||
|
||||
Sequential and batched application must agree on:
|
||||
|
||||
- live and retained agent maps;
|
||||
- state history, `updatedAt`, and `stateStartedAt`;
|
||||
- agent identity, model, prompt, tools, assistant messages, and subagents;
|
||||
- orchestration and provider-session continuity;
|
||||
- sleeping-session and launch-config recovery records;
|
||||
- retired/closed-pane rejection and inherited-status suppression;
|
||||
- retention cleanup and live-map eviction;
|
||||
- `agentStatusEpoch` and `sortEpoch`;
|
||||
- automation completion observation across intermediate transitions;
|
||||
- generated-title inputs, freshness scheduling, and completion refreshes.
|
||||
|
||||
Equivalence tests use fixed timestamps and include repeated same-pane
|
||||
transitions. A publication-count test subscribes to the real store and requires
|
||||
one notification for a non-empty batch and none for an empty batch.
|
||||
|
||||
## Benchmark contract
|
||||
|
||||
The benchmark launches an E2E-mode Electron build with the store exposed only
|
||||
for instrumentation. It creates 100 worktrees in one expanded lineage, verifies
|
||||
100 mounted cards, captures the store listener census, and then applies seeded
|
||||
ordered agent-status traffic through the real store action.
|
||||
|
||||
The benchmark measures the synchronous store action, not the live IPC leading
|
||||
edge or post-commit notification path. A real-store snapshot test covers the
|
||||
end-to-end budget for 100 panes with auto-generated titles enabled: one status,
|
||||
one bulk generated-title, and one bulk resolved-title publication. Disabling
|
||||
generated titles removes that middle publication, independent of pane count.
|
||||
|
||||
The artifact records only fixed diagnostic fields needed for comparison:
|
||||
|
||||
- requested and completed batches and updates;
|
||||
- store action calls and observed publications;
|
||||
- elapsed time, throughput, and scheduling drift;
|
||||
- final-state verification;
|
||||
- renderer mean, p95, and maximum CPU;
|
||||
- renderer timer drift and long tasks;
|
||||
- mounted-card and listener counts.
|
||||
|
||||
Raw process inventories, temporary paths, pane identifiers, and DOM text are
|
||||
diagnostic-only and must not be embedded in the shareable report.
|
||||
|
||||
Run baseline and candidate on the same machine and OS with the same Electron
|
||||
build mode. CPU samples from macOS and Linux are comparable within that
|
||||
constraint; Windows process CPU collection currently cannot support this
|
||||
comparison.
|
||||
|
||||
## Harness
|
||||
|
||||
`pnpm run bench:idle-cpu` drives `config/scripts/run-idle-cpu-benchmark.mjs`,
|
||||
which composes four modules:
|
||||
|
||||
| Module | Responsibility |
|
||||
| ------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| `idle-cpu-renderer-scale-fixture.mjs` | Seeds the lineage, agent rows, and sidebar view state; takes the mounted-card and listener census |
|
||||
| `idle-cpu-renderer-timing-probe.mjs` | In-page timer drift and long-task probe; runs the no-op publication workload |
|
||||
| `idle-cpu-process-sampling.mjs` | Classifies the Electron process tree and samples per-role CPU/RSS |
|
||||
| `idle-cpu-synthetic-spinners.mjs` | Measurement-only visible spinners |
|
||||
|
||||
The sampling window extends past `--sample-ms` until the workload settles, and
|
||||
fails the run rather than reporting a truncated window if the workload overruns
|
||||
the guard. That is why a 2,000-publication run reports a measured window longer
|
||||
than the requested one.
|
||||
|
||||
`--zustand-publications` publishes an empty partial through the real store, so
|
||||
each publication costs exactly one full subscriber visit and nothing else. It
|
||||
isolates the `listeners x selector work` half of the burst-cost model from
|
||||
agent-status payload work, and it is store-API independent — it measures the
|
||||
same thing before and after the batching slice.
|
||||
|
||||
The agent-status write workload (`--agent-status-batches`,
|
||||
`--agent-status-write-mode`) is not in this harness yet. It depends on
|
||||
`setAgentStatuses`, so it lands with the store slice.
|
||||
|
||||
## Baseline on `main`
|
||||
|
||||
Measured on `main` at `077f5a11cd4` (macOS, arm64, 16 CPUs), Electron built with
|
||||
`electron-vite --mode e2e`, headless, 100 worktrees at lineage depth 99 with 100
|
||||
seeded agent rows, 10 s warmup and a 30 s sampling window.
|
||||
|
||||
Fixture scale is confirmed by the census rather than assumed: 100 store
|
||||
worktrees, 100 mounted cards, 100 mounted agent rows, and **9,279 store
|
||||
listeners**. That listener count is the multiplier the design targets.
|
||||
|
||||
2,000 no-op store publications at a 1 ms cadence, three repetitions. The
|
||||
listener census was 9,279 in every run.
|
||||
|
||||
| Measure | Median | Runs |
|
||||
| ------------------------ | ----------: | ------------------------------ |
|
||||
| Wall time to complete | 12,325.7 ms | 13,148.6 / 12,325.7 / 11,870.5 |
|
||||
| p50 scheduling drift | 5,150.3 ms | 5,432.3 / 5,150.3 / 4,945.2 |
|
||||
| p95 scheduling drift | 9,802.9 ms | 10,570.4 / 9,802.9 / 9,390.0 |
|
||||
| Renderer mean CPU | 18.25% | 20.25 / 18.25 / 15.88 |
|
||||
| Renderer p95 CPU | 32.59% | 40.07 / 32.59 / 31.28 |
|
||||
| Renderer timer drift p95 | 7.0 ms | 7.3 / 5.3 / 7.0 |
|
||||
|
||||
2,000 publications requested over 2 s take about 12 s, so the renderer sustains
|
||||
roughly 160 publications per second at this scale. Each publication is
|
||||
individually short - the long-task observer recorded zero entries in all three
|
||||
runs - so the cost surfaces as scheduling drift and sustained CPU rather than as
|
||||
discrete long tasks. Compare drift and CPU here, not long-task counts.
|
||||
|
||||
The idle control at the same scale with `--zustand-publications 0` reports 6.63%
|
||||
renderer mean CPU, 17.11% p95, and 1.6 ms p95 timer drift. Roughly 11.6 points
|
||||
of mean renderer CPU are therefore attributable to publication fanout rather than
|
||||
to the mounted fixture itself. 200 spinner animations run in both cases, so the
|
||||
control also bounds the animation cost out of the comparison.
|
||||
|
||||
Reproduce with:
|
||||
|
||||
```bash
|
||||
pnpm run bench:idle-cpu -- --worktrees 100 --lineage-depth 99 \
|
||||
--agents-per-worktree 1 --warmup-ms 10000 --sample-ms 30000 \
|
||||
--zustand-publications 2000 --zustand-publication-interval-ms 1 \
|
||||
--output /tmp/idle-cpu-baseline.json
|
||||
```
|
||||
|
||||
## Results
|
||||
|
||||
Three repetitions used 100 mounted worktrees, lineage depth 99, 100 seeded
|
||||
agent rows, and verified final state. Medians from the regenerated evidence set
|
||||
are:
|
||||
|
||||
| Single 2,000-update burst | Sequential | Batched |
|
||||
| ------------------------- | ---------: | ---------: |
|
||||
| Status-state publications | 2,000 | 1 |
|
||||
| Store action time | 3,692.0 ms | 188.7 ms |
|
||||
| Update throughput | 541.7/s | 10,598.8/s |
|
||||
| Renderer mean CPU | 36.2% | 2.9% |
|
||||
| Renderer p95 CPU | 107.3% | 8.2% |
|
||||
| p95 long task | 4,653 ms | 216 ms |
|
||||
|
||||
The direct store transaction performs 99.95% fewer status-state publications,
|
||||
spends 94.9% less time in the store action, and processes updates 19.6 times
|
||||
faster. Renderer mean CPU falls 92.0%, renderer p95 CPU falls 92.4%, and the p95
|
||||
long task falls 95.4%.
|
||||
|
||||
The 60-burst × 32-update case at 33 ms is a sustained saturation stress, not a
|
||||
real-time production SLO. Publications fall from 1,920 to 60 and median store
|
||||
action time falls from 2,791.9 ms to 323.3 ms. Median completion time falls from
|
||||
5,298.2 ms to 2,710.6 ms, p95 scheduling drift falls from 3,073.0 ms to 664.9
|
||||
ms, and long-task count falls from 57 to 1. Renderer p95 CPU remains saturated
|
||||
and noisy in this cadence, so it is not used as the discriminating measure.
|
||||
|
||||
The 20-pane artificial OpenCode regression passes with 12.4 ms median key echo,
|
||||
25.2 ms worst key echo, 19.4 ms maximum timer drift, and zero dropped renderer
|
||||
backlogs.
|
||||
|
||||
These figures come from the bundled prototype and are restated here as the
|
||||
target. They are re-measured with the harness when the store slice lands.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- The 100-worktree fixture stays at or below the pinned listener budgets.
|
||||
- A deferred transaction performs one status-state publication while preserving
|
||||
ordered final state, including live-map eviction at the 500-row cap.
|
||||
- A 100-pane startup snapshot performs one status and one bulk resolved-title
|
||||
publication with generated titles disabled; enabling generated titles adds at
|
||||
most one ordered bulk publication while preserving final statuses and titles.
|
||||
- Sequential-versus-batch equivalence tests pass across same-pane transitions
|
||||
and side-effect-bearing updates.
|
||||
- Renderer CPU tails and scheduling drift improve in repeated candidate runs.
|
||||
- The 20-pane artificial terminal test reports no dropped output backlog and no
|
||||
material typing-latency regression.
|
||||
- Web typecheck, focused unit tests, lint, max-lines ratchet, and E2E build pass.
|
||||
|
||||
## Compatibility
|
||||
|
||||
This is renderer-local. It adds no RPC field, stream opcode, persisted data, Git
|
||||
command, or provider-specific contract. Native, WSL, SSH, relay, folder
|
||||
workspace, and git-worktree status events enter the same renderer action. Mixed
|
||||
client/server versions therefore need no capability negotiation.
|
||||
|
||||
## Failure containment
|
||||
|
||||
The first live event remains immediate. Startup replay and bounded pending
|
||||
retries fold synchronously without waiting for the 33 ms live-burst window, but
|
||||
publish their accepted updates together. Empty batches are no-ops. If an update
|
||||
is stale or targets retired authority, the reducer skips only that update and
|
||||
continues folding later events in order.
|
||||
@@ -0,0 +1,146 @@
|
||||
# Terminal session behavior contract
|
||||
|
||||
Properties every terminal-session change must preserve. They describe
|
||||
observable behavior, not mechanism, so a test written against them stays valid
|
||||
under any implementation.
|
||||
|
||||
Oracles for the STA-3077 properties live in
|
||||
`src/main/ssh-reattach-pane-cardinality.test.ts`.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Reconnecting an SSH-backed workspace used to add panes the user never opened,
|
||||
and the remote host accumulated shells nobody was using — one report went from
|
||||
2 to 19 to 20 relay PTYs across three reconnects. The failures in this class
|
||||
share one shape: **something absent was treated as something dead**, or a
|
||||
reattach was allowed to create what it should only have bound.
|
||||
|
||||
## A. Unknown is not dead
|
||||
|
||||
A disconnect, timeout, absent inventory entry, or failed inspection means
|
||||
_unresolved_. None of them may terminate a PTY, release a binding, or authorize
|
||||
cleanup.
|
||||
|
||||
**A5** — a timer may never be the _sole cause_ of a destructive action. A timer
|
||||
that bounds a wait or triggers re-verification is fine; a timer that kills is
|
||||
not. Retry count never decides identity or liveness.
|
||||
|
||||
This is deliberately weaker than "no timers". Recovery budgets and scratch-file
|
||||
age-gates are correct code and must stay.
|
||||
|
||||
Two patterns make a retention bound safe, and both beat shortening it:
|
||||
|
||||
- measure **process time**, not wall clock, so a suspended laptop does not
|
||||
burn the budget;
|
||||
- gate aggressive reclamation on an **independent observation** — a second
|
||||
consecutive scan, or another client completing a handshake — so the clock
|
||||
bounds a wait while evidence authorizes the act.
|
||||
|
||||
**B** — respawn requires proof. A failure that does not prove the session is
|
||||
gone is unresolved: leave the shell running and keep the binding. An
|
||||
unrecognized failure must never become a respawn, because a duplicated shell
|
||||
is worse than a stalled one.
|
||||
|
||||
## B. Attach binds; it never creates
|
||||
|
||||
An ambiguous, unavailable, or rejected attach does not spawn a shell and does
|
||||
not grow topology — no minted tab, no minted leaf, no split root, no minted
|
||||
layout. Only an explicit create for a fresh pane may spawn.
|
||||
|
||||
Corollary: after a recovery, keystrokes cannot land on a different shell than
|
||||
the one that was visible before it.
|
||||
|
||||
## C. Exact-binding fencing
|
||||
|
||||
Mutating operations name the binding captured before the first await and are
|
||||
rejected when any component no longer matches. A stale operation cannot reach a
|
||||
later pane generation, a later PTY incarnation reusing the same id, or a
|
||||
restarted owner. Output and exit arriving under a stale binding are dropped.
|
||||
|
||||
The store already enforces this via `persistPtyBinding`'s `expectedBinding`
|
||||
compare-and-swap and the per-repo topology revision fence.
|
||||
|
||||
## D. Cardinality
|
||||
|
||||
One pane owns at most one live PTY binding, and one PTY incarnation belongs to
|
||||
at most one pane. Lease identity must therefore include the pane, not just the
|
||||
transport id — keying on `(targetId, ptyId)` alone is what let predecessors
|
||||
accumulate.
|
||||
|
||||
Superseding a lease marks it expired; it does not kill the remote process,
|
||||
because losing a lease is not proof the shell died.
|
||||
|
||||
## E. Ordering and loss
|
||||
|
||||
Output is delivered in PTY order. A gap is resolved by resnapshot, never by
|
||||
silently continuing. A correctness-bearing effect (exit, command completion,
|
||||
review link, bell) is neither lost while the renderer is unmounted nor applied
|
||||
twice after a replay.
|
||||
|
||||
Bounded replay plus idempotent application by event key satisfies this. A
|
||||
durable per-consumer delivery cursor is not required, and is not currently
|
||||
used.
|
||||
|
||||
**Lifecycle is state, not an event.** An exit that happens while nothing is
|
||||
attached is not recovered from a delivery channel — it is read during the
|
||||
attach handshake. The attach reply should carry whether the session is alive
|
||||
or exited, its exit code, and the resume offset. That is what removes the need
|
||||
to guarantee delivery of an exit _event_, and it is why no journal is required
|
||||
here.
|
||||
|
||||
Where a resume offset cannot be served, advance the offset explicitly by the
|
||||
size of the gap and persist the running total, so "output was lost here" stays
|
||||
a queryable fact rather than a silent discontinuity.
|
||||
|
||||
## F. Isolation
|
||||
|
||||
A failure on one host cannot affect another host; a failure in one workspace
|
||||
cannot affect another on the same connection. There is no process-wide failure
|
||||
fence.
|
||||
|
||||
## G. Liveness
|
||||
|
||||
Every fail-closed path needs a bounded, user-reachable recovery. A workspace
|
||||
must never reach a state that permanently refuses terminal operations with no
|
||||
operator escape — in particular, backpressure from a consumer that is gone
|
||||
forever must be releasable without that consumer's cooperation.
|
||||
|
||||
Unavailable transports retry with bounded backoff and bounded memory.
|
||||
|
||||
## H. Compatibility
|
||||
|
||||
Clients and hosts update independently. A capability is negotiated or the newer
|
||||
side degrades; a required operation is never silently dropped. New stream
|
||||
opcodes must be capability-negotiated, because decoders drop unknown opcodes
|
||||
silently. Nothing is gated on an app build id.
|
||||
|
||||
See [`remote-wire-compatibility.md`](./remote-wire-compatibility.md).
|
||||
|
||||
## I. Cost
|
||||
|
||||
No new work on the input hot path and none per output frame. Pane create and
|
||||
close latency must not regress, including over a high-latency SSH link. No
|
||||
startup-blocking probe of a host nothing has asked for.
|
||||
|
||||
---
|
||||
|
||||
## Test the call site, not the capability
|
||||
|
||||
A guard that exists and is never passed is indistinguishable from no guard.
|
||||
`mayCreate` was added to `persistPtyBinding`, was correct, and had no
|
||||
production caller for several commits — every store-level test passed the whole
|
||||
time, because they called the store directly.
|
||||
|
||||
So for anything that refuses a destructive action, pin the **caller**: assert
|
||||
that the reattach path passes the refusal, not merely that the store honours it
|
||||
when asked. The same applies to a classifier — assert the branch that consumes
|
||||
its verdict is reachable, since a guard behind an unreachable `catch` is dead.
|
||||
|
||||
## Deliberately not required
|
||||
|
||||
A durable event journal, per-consumer cumulative delivery cursors, and
|
||||
cryptographic device principals are **not** required by any property above.
|
||||
Each would need to earn its place by being the only way to satisfy one of them.
|
||||
|
||||
Principals, in particular, exist only to key cursors: without cursors there is
|
||||
nothing for them to key.
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
# Design: make "Start a new terminal" a replacement, not a spawn
|
||||
|
||||
Supersedes the mechanism in `PR-13326-SESSION-HANDOFF-2026-08-12.md` §"Approved bounded redesign".
|
||||
Keeps its **properties**; replaces several of its **mechanisms** with smaller ones, and adds the
|
||||
thing it was missing — a way to actually run the decisive test.
|
||||
|
||||
## 1. The defect, stated once
|
||||
|
||||
"Start a new terminal" is routed through a path whose job is *"resolve this pane's owner and attach
|
||||
it; spawn only if there is none."* Creation is that path's fallback, not its purpose. The pane's
|
||||
durable binding still names the old shell at click time, so the path resolves it and adopts it.
|
||||
|
||||
I confirmed this first-hand earlier in the branch: commit `ab6ce176f3f` detects adoption after the
|
||||
fact (`freshPtyId === sessionId`) and stops the callback from unbinding the live shell. Its own
|
||||
message records that suppressing the adoption itself was left undone. So the adoption is real, and
|
||||
the current code only contains its worst consequence.
|
||||
|
||||
## 2. Why every previous fix leaked
|
||||
|
||||
Each attempt expressed "do not adopt" as **another condition inside the adopting path**. Conditions
|
||||
are forgettable and order-dependent, and this branch has three shipped proofs of that failure mode:
|
||||
|
||||
- a guard centralised into a publisher that returned `void`, so four callers paired a conditional
|
||||
publish with an unconditional `return`;
|
||||
- the same rule applied to the tombstone branch and missed on the liveness branch beside it;
|
||||
- the incarnation fence sent on the pane-driven route and not on the reconnect route.
|
||||
|
||||
Every one was "the rule exists, but this path does not ask it." A fourth condition in the same path
|
||||
is the same bet again.
|
||||
|
||||
## 3. The design, and why it removes the defect
|
||||
|
||||
### P1 — Replacement is a separate operation that cannot adopt
|
||||
|
||||
Add a main-owned entry that performs replacement only. It contains **no owner-resolution call**: it
|
||||
does not read a stable-pane owner, does not accept `sessionId`/`attachOnly`, and constructs provider
|
||||
options from a narrow main-owned allowlist (dimensions + validated pane/workspace identity).
|
||||
|
||||
*Why this fixes it:* adoption stops being a branch that must be avoided and becomes code that is not
|
||||
present. A function with no owner-resolution cannot adopt for the same reason a function with no
|
||||
socket cannot make a network call. This converts "remember to skip adoption" (liveness) into "there
|
||||
is nothing here to adopt with" (structure).
|
||||
|
||||
*Falsifiable by:* route the action back through generic spawn — the oracle in P0 must redden.
|
||||
|
||||
### P2 — Ownership moves in one durable commit, keyed on exact incarnation
|
||||
|
||||
One Store method mutates and flushes once: exact-CAS the old binding and the old active lease,
|
||||
install the new binding + incarnation + attached lease, expire the exact old lease **without
|
||||
touching its process**, bump the topology/lease versions, flush.
|
||||
|
||||
*Why:* ownership lives in the durable binding and the lease. Moved separately, there is a window
|
||||
where a racing reconnect sees a half-moved pane and restores the predecessor. One commit means the
|
||||
window does not exist rather than being narrow.
|
||||
|
||||
*Why exact incarnation:* the relay reissues `pty-N` from 1 after a reset, so any CAS keyed on ptyId
|
||||
alone can match a different process. This branch already proved that and already carries the
|
||||
primitive: host-attested `SshRemotePtyLease.incarnationId`, the relay-side `expectedIncarnationId`
|
||||
fence, and `isRelayAttestedPtyIncarnationId` filtering synthesised values.
|
||||
|
||||
*Falsifiable by:* split the commit in two flushes and drive a reconnect between them.
|
||||
|
||||
### P3 — Ambiguity leaks, never kills
|
||||
|
||||
Provider/relay shutdown identifies a PTY by id only. After a dispatched spawn whose outcome is
|
||||
ambiguous, we cannot prove which incarnation a shutdown would reach. So: clean up a failed new shell
|
||||
only when its exact incarnation is provable; otherwise leave it running and unbound for the existing
|
||||
cleanup surface to reap.
|
||||
|
||||
*Why:* the costs are asymmetric. Leaking costs an orphan shell that a user can already list and
|
||||
terminate. Killing the wrong incarnation destroys a live agent session — the exact harm this whole
|
||||
program exists to prevent.
|
||||
|
||||
*Falsifiable by:* make the ambiguous path call shutdown; the oracle asserting zero kill RPCs on an
|
||||
unprovable outcome must redden.
|
||||
|
||||
### P4 — The successor is invisible until it is the owner
|
||||
|
||||
Spawn the replacement hidden and install its output handlers only after P2 commits.
|
||||
|
||||
*Why:* a new shell reusing the old raw id could otherwise deliver bytes into the predecessor's
|
||||
model. Ordering removes the window without a new quarantine subsystem.
|
||||
|
||||
### P5 — Stale renderer work does nothing
|
||||
|
||||
Guard every completion on: not disposed, current transport identity, current action generation. A
|
||||
stale callback must not clear a binding, republish the card, or kill a result. Main owns the durable
|
||||
transition, so the renderer never clears the old binding on success.
|
||||
|
||||
## 4. What I am deliberately NOT building, and the risk of each
|
||||
|
||||
The handoff prescribes more machinery. I judge these unnecessary for this defect; each is a
|
||||
reversible decision if an oracle shows otherwise.
|
||||
|
||||
| Prescribed | Decision | Reasoning | Risk if I am wrong |
|
||||
|---|---|---|---|
|
||||
| Opaque ticket bound to ~11 fields (sender, both generations, worktree, tab, leaf, pane key, both ids, both incarnations, expiry, 5-state machine) | **Replace** with an operation id + expected-owner `{ptyId, incarnationId}` CAS | The renderer is not a trust boundary — it can already open terminals. The ticket's real jobs are staleness and idempotency, which the CAS and op id give directly. Eleven bindings add eleven invalidation paths, and invalidation bugs are the same class we are trying to leave | A forged/stale request creates one blank shell for a pane the user owns — recoverable, and the CAS still refuses to move ownership off an unexpected incumbent |
|
||||
| A new shared owner mutex | **Reuse** `paneSpawnReservationsByOwnerKey` (already per-pane) and the runtime pane-create claim | Serialisation already exists at exactly this granularity; a second lock invites lock-order bugs | Two operations interleave on one pane; the P2 CAS still refuses the loser |
|
||||
| A quarantine subsystem for provider output | **Reuse** hidden spawn + install-after-commit ordering | Achieves the same property with existing options | Bytes reach the wrong model; caught by the P4 oracle |
|
||||
|
||||
## 5. The part the previous session was missing: the decisive test can now run
|
||||
|
||||
`tests/e2e/ssh-disconnected-pane-affordance.spec.ts` holds the decisive case at `test.fixme` because
|
||||
**the state was not inducible**: it needs the SSH target CONNECTED while exactly one pane's
|
||||
`pty.attach` fails with an error that does not prove the shell gone. Host faults tried (SIGSTOP the
|
||||
relay) take the whole connection down and produce the connection overlay instead.
|
||||
|
||||
That state is now inducible, by a mechanism this branch added for another reason. The relay refuses
|
||||
an attach whose `expectedIncarnationId` names a different shell, with
|
||||
`PTY "<id>" identity mismatch` — per-pty, connection healthy, and explicitly not proof of death
|
||||
(`SSH_PTY_IDENTITY_MISMATCH` never grounds a respawn).
|
||||
|
||||
So the test can seed the pane's lease with an incarnation that is not the host's, reconnect, and get
|
||||
exactly the state the banner needs — deterministically, with no timing fault.
|
||||
|
||||
**This is the highest-value item in the plan and it comes first.** The previous session looped
|
||||
because every claim was adjudicated by reading code; with the oracle running, the design is
|
||||
*demonstrated* instead of argued, and a regression reddens instead of being found in review.
|
||||
|
||||
## 6. Goalposts
|
||||
|
||||
Each is a behaviour with a stated mutation that must redden it. G1 is a prerequisite for believing
|
||||
any of the others.
|
||||
|
||||
| # | Goalpost | Mutation that must redden it |
|
||||
|---|---|---|
|
||||
| **G1** | The decisive E2E runs unskipped: with the target connected, one pane unreachable, clicking "Start a new terminal" yields exactly one new remote shell, the pane count is unchanged, and the host transcript shows no second agent launch | Remove the induction; the test can no longer reach the banner |
|
||||
| **G2** | The action never adopts: the pane's shell id/incarnation after the click differs from before, and the new shell has no command, resume metadata, launch token, agent, or startup delivery | Route the action back through generic `pty:spawn` |
|
||||
| **G3** | Ownership moves atomically: a reconnect racing the replacement cannot restore the predecessor; binding and lease agree at every observable point | Split the durable commit into two flushes |
|
||||
| **G4** | Ambiguity never kills: on an unprovable spawn outcome, zero shutdown RPCs are issued and neither process dies | Call ID-only shutdown on the ambiguous path |
|
||||
| **G5** | The successor is silent until it owns the pane: no provider bytes reach the predecessor's model, including when the raw id is reused | Install output handlers before the commit |
|
||||
| **G6** | Stale renderer work is inert: a disposed or superseded callback clears no binding, republishes no card, kills no result | Drop the generation guard on the completion path |
|
||||
|
||||
## 7. Sequence
|
||||
|
||||
0. Remove the rejected six-file WIP (targeted edits, not a broad restore — the worktree is dirty).
|
||||
1. **G1** — induction + un-`fixme`. Nothing else is believable until this runs.
|
||||
2. **G2** — the separate replacement operation.
|
||||
3. **G3** — the single durable commit.
|
||||
4. **G4/G5/G6** — ambiguity, ordering, staleness.
|
||||
5. Adversarial review on both models, then push.
|
||||
|
||||
## 8. Stop conditions
|
||||
|
||||
- If G1 cannot be made to run, stop and report. Building 2–6 without it repeats the previous loop.
|
||||
- If exact cleanup cannot be achieved within existing local/provider contracts, take the leak (P3).
|
||||
Do **not** add `expectedIncarnationId` to remote shutdown — that is a separate, capability-
|
||||
negotiated wire change with its own review.
|
||||
|
||||
---
|
||||
|
||||
## 9. Findings from running the gate (2026-08-13)
|
||||
|
||||
The oracle in §5 works: the state is inducible and the card appears reliably. Running it found
|
||||
three things that reading had not.
|
||||
|
||||
**Fixed — the card's buttons were unclickable.** `TerminalErrorToast` renders at `z-50` in the same
|
||||
bottom strip and was suppressed only for the connection overlay, not for the pane's own card. In
|
||||
the one state this affordance exists for, both actions were covered. `TerminalPane.tsx` now also
|
||||
suppresses it while the active pane shows the card.
|
||||
|
||||
**Fixed — a session id is the instruction to attach.** "Start a new terminal" was still sending the
|
||||
pane's recorded `sessionId`, which routes straight to reattach before any pane-owner logic runs, so
|
||||
the action attached the very shell it could not reach and created nothing. Skipping owner
|
||||
resolution in main was NOT enough on its own; the id had to be dropped at the last gate before the
|
||||
IPC (`admittedSessionId` in `pty-transport.ts`).
|
||||
|
||||
**OPEN — the action produces no spawn at all.** With both fixes in, the measured result after
|
||||
clicking is still `visible=true launches=1 shells=1`: the card clears briefly, no shell is created,
|
||||
and the card returns. The main log over the same window shows only the pane's own restore retries
|
||||
(three `spawn() called with sessionId=…` → `identity mismatch`), never a fresh spawn.
|
||||
|
||||
The evidence points at the action closure rather than the spawn path: the pane retries its restore
|
||||
while the card is up, each retry republishes the card, and the button the test clicks appears to
|
||||
belong to a superseded connection whose `startFreshSpawn` no longer does anything. That is the
|
||||
"stale renderer work" hazard from the original handoff, showing up as a dead button rather than as
|
||||
a wrong write.
|
||||
|
||||
**RESOLVED.** Instrumenting handler, transport and main in one correlated run disproved the
|
||||
closure hypothesis: the handler ran, the connection was live, and the renderer half was already
|
||||
correct — it sent no session id and asked for adoption to be refused. Main re-derived the id anyway.
|
||||
|
||||
The rule had been applied at the two places an owner is PRODUCED and missed at the one place it is
|
||||
CONSUMED: `spawnForStablePane` turns an owner into `sessionId` for the provider, which is what makes
|
||||
an attach an attach. Gating there — `if (args.owner && !args.refuseAdoption)` — closes it for every
|
||||
producer at once, present and future.
|
||||
|
||||
The gate now passes, and reverting that single condition reddens it.
|
||||
|
||||
Lesson worth keeping: this rule leaked at three sites in a row (early owner resolution, the
|
||||
transport's session id, the owner consumption). Each fix was necessary and none was sufficient. The
|
||||
one that finally held was the one placed where the value is USED rather than where it is derived.
|
||||
+823
@@ -0,0 +1,823 @@
|
||||
# STA-3077 / PR #13326 session handoff
|
||||
|
||||
> **Status:** paused at an approved bounded redesign; not release-ready.
|
||||
>
|
||||
> **Review/fix round:** 6.
|
||||
>
|
||||
> **Do not continue the current six-file implementation.** It is a rejected,
|
||||
> uncommitted grant-map approach. Remove it with `apply_patch`, then implement
|
||||
> the dedicated main-owned replacement transaction described below.
|
||||
|
||||
This is the continuation entrypoint for the current PR. It records the whole
|
||||
session at the point the user asked to pause, including what shipped to the PR
|
||||
branch, what failed review, the agreed design boundary, and the next safe work.
|
||||
|
||||
## Executive summary
|
||||
|
||||
The session started with STA-3077's SSH reconnect failure class: reconnects
|
||||
could graft panes the user never created, accumulate remote shells, fabricate
|
||||
process death from an attach failure, or let stale identities affect a
|
||||
successor. The branch now contains extensive fixes, tests, design records, and
|
||||
two user-visible recovery actions for an unreachable pane.
|
||||
|
||||
The pushed candidate is still incorrect in one important action. “Start a new
|
||||
terminal” invokes generic `pty:spawn` while the old durable pane binding remains.
|
||||
Main can adopt that old owner, so the action can reopen the old shell instead of
|
||||
creating the one blank shell the user requested.
|
||||
|
||||
An attempted six-file fix added `freshSpawnForUnreachablePtyId` to generic spawn
|
||||
and a process-global grant map. Three independent read-only audits rejected it.
|
||||
The common verdict was that replacement must be a dedicated local transaction
|
||||
owned by Electron main, with exact incarnation identity, one durable Store
|
||||
commit, runtime ownership transfer, output quarantine, reconnect fencing, and
|
||||
renderer generation guards.
|
||||
|
||||
The user approved this bounded redesign. It applies only to the broken
|
||||
unreachable-pane replacement operation. It is not authority to restart the
|
||||
earlier whole-system architecture effort.
|
||||
|
||||
## Repository checkpoint
|
||||
|
||||
Verified before this handoff was written:
|
||||
|
||||
| Item | Value |
|
||||
| ----------------------------------- | ---------------------------------------------------- |
|
||||
| Worktree | `/Users/nwparker/orca/workspaces/orca/eye-React-185` |
|
||||
| Local branch | `nwparker/sta-3077-reattach-pane-cardinality` |
|
||||
| HEAD | `df6edfcebd2ea401a6feda1fccf31189e57eceda` |
|
||||
| HEAD subject | `fix(ui): center narrow terminal recovery actions` |
|
||||
| Actual PR | `stablyai/orca#13326` |
|
||||
| Actual PR ref | `origin/nwparker/sta-3077-d-affordance-and-review` |
|
||||
| Actual PR ref SHA | exactly `df6edfcebd2ea401a6feda1fccf31189e57eceda` |
|
||||
| Configured upstream | `origin/nwparker/sta-3077-reattach-pane-cardinality` |
|
||||
| Configured upstream SHA | `518dac34415e0f67883461c3f58e5586209c7f24` |
|
||||
| Local `origin/main` | `889dd4c7d2fe255aac552e4dcc91204279dc022c` |
|
||||
| Merge base with local `origin/main` | `58a926170cd99baf8920a83a86799bfbd307d735` |
|
||||
| Local `origin/main...HEAD` | 10 commits on main / 109 commits on HEAD |
|
||||
|
||||
The configured upstream is stale. Its `[ahead 387]` status is not the PR's
|
||||
push state. The PR ref is exactly at HEAD. When the work is genuinely ready,
|
||||
push explicitly:
|
||||
|
||||
```bash
|
||||
git push origin HEAD:nwparker/sta-3077-d-affordance-and-review
|
||||
```
|
||||
|
||||
Before this document, the worktree had six modified tracked files, no untracked
|
||||
files, and a `+163/-11` uncommitted diff. `git diff --check` was clean. No
|
||||
product test, typecheck, lint, build, Electron, or E2E command was run against
|
||||
that dirty state.
|
||||
|
||||
## The intended outcome
|
||||
|
||||
For a pane whose old SSH terminal cannot currently be reached, the UI offers:
|
||||
|
||||
- **Try again:** attempt to reattach the exact existing shell without claiming
|
||||
it exited or spawning a replacement.
|
||||
- **Start a new terminal:** keep the same pane and leaf, create exactly one
|
||||
genuinely blank shell, move durable and runtime pane ownership to it, and
|
||||
leave the unproven old process alive but unbound.
|
||||
|
||||
The second action must never:
|
||||
|
||||
- silently adopt or resume the old shell;
|
||||
- resume an agent, command, launch token, startup sequence, or saved telemetry;
|
||||
- create another pane or tab;
|
||||
- kill a process whose exact incarnation is not proven;
|
||||
- let a reconnect restore the predecessor after replacement wins;
|
||||
- let stale renderer work clear, kill, or republish the successor;
|
||||
- expose provider output before the replacement commits; or
|
||||
- add replacement intent to SSH, relay, provider, paired-runtime, or stream
|
||||
wire contracts.
|
||||
|
||||
## What was done during the session
|
||||
|
||||
The branch has 109 first-parent commits over its current merge base, including
|
||||
two large updates from `origin/main`. The useful progression is below; use the
|
||||
git history for the complete commit-by-commit record.
|
||||
|
||||
### 1. Reconnect and persistence containment
|
||||
|
||||
The early work stopped SSH reconnect from treating persisted rows as permission
|
||||
to create UI, made pane binding authoritative during duplicate arbitration,
|
||||
and added rollback around durable lease retirement.
|
||||
|
||||
Representative commits:
|
||||
|
||||
- `5d8bb18fd3f` — stop reconnect from grafting panes and stacking leases.
|
||||
- `ae134995c13` — heal duplicate pane leases from older state.
|
||||
- `ccb082740fc` — let the durable pane binding outrank recency.
|
||||
- `8d9aeb55e03` — roll back lease retirement when durable writing fails.
|
||||
- `03d794a0f90` — pass `mayCreate: false` from the production reattach writer.
|
||||
- `a7558a837ca`, `13ca7630116`, and `8ec025e4e46` — add and strengthen pane,
|
||||
process, binding, and lease census oracles.
|
||||
|
||||
### 2. Unknown is not death
|
||||
|
||||
The session removed several paths that inferred a dead process from missing,
|
||||
unavailable, timed-out, or disconnected state.
|
||||
|
||||
Representative commits:
|
||||
|
||||
- `eec807d7594` — stop respawning a shell that may still be running.
|
||||
- `356d6b52b24` — apply positive-proof requirements to both reattach paths.
|
||||
- `b9d58423121` and `7589df8b348` — let PTY liveness remain unknown.
|
||||
- `7cd7fef927f` — a reattach not-found is not itself proof of shell death.
|
||||
- `e39514e9ab2` and `41613b5c84c` — distinguish bare not-found from a relay-
|
||||
proven exit and retire a lease only in the latter case.
|
||||
|
||||
This work also stopped one exhausted delivery recovery from tearing down every
|
||||
session on an SSH host (`07e382ec196`) and made parked delivery recoveries
|
||||
expire/re-enter correctly (`124e00e8a83`, `601604d17b1`).
|
||||
|
||||
### 3. Exact operation and incarnation fencing
|
||||
|
||||
The branch added or strengthened stale-operation refusal, host-attested shell
|
||||
identity, remembered-exit identity, and reconnect fences.
|
||||
|
||||
Representative commits:
|
||||
|
||||
- `0de5994be91` and `980e5d846b5` — fence stale writes, resize, and signals.
|
||||
- `235e45ad0f2` — record host-attested shell identity on a lease.
|
||||
- `30441f1ebbb` — fence recycled relay PTY IDs by shell identity.
|
||||
- `8f0c8e3ed35`, `42cbe34523f`, `c14ba64d9dc`, and `0cb187f6035` — require an
|
||||
exit proof to name the shell for which it grants replacement authority.
|
||||
- `5793f186e96`, `be02c4e7f5c`, and `901dd06b8d1` — apply incarnation and pane
|
||||
fences on reconnect, including clients unable to name a shell.
|
||||
- `c8d08e1b215` — strengthen the incarnation write-fence regression oracle.
|
||||
|
||||
These improvements are valuable, but the round-6 review found that raw PTY ID
|
||||
is still used as identity in important lease/runtime/cleanup paths. The new
|
||||
replacement transaction must finish that exact-incarnation work for its own
|
||||
surface rather than assuming the existing fencing is complete.
|
||||
|
||||
### 4. Pane arbitration and rollback hardening
|
||||
|
||||
Lease arbitration moved from tab-level assumptions toward the actual pane leaf,
|
||||
and rollback was hardened so an already-expired duplicate cannot be revived.
|
||||
|
||||
Representative commits:
|
||||
|
||||
- `b92498468a5` — arbitrate on the pane, not the tab.
|
||||
- `1697b3be163` — supersede on the leaf.
|
||||
- `7724a950ecd` — mutate only the plane that supplied the supersession proof.
|
||||
- `21ad47c0bc5` — fence duplicate-lease rollback and add a regression oracle.
|
||||
|
||||
The existing `upsertSshRemotePtyLease` key remains `(targetId, ptyId)`. That is
|
||||
insufficient when an old and a new process reuse the same raw ID but have
|
||||
different incarnations. Replacement requires exact lease identity.
|
||||
|
||||
### 5. Unreachable-pane product behavior
|
||||
|
||||
The renderer now presents a provider-neutral disconnected card with two
|
||||
explicit actions and copy that does not claim an unobserved exit.
|
||||
|
||||
Representative commits:
|
||||
|
||||
- `5e1b57bb2f9` — add the two-action disconnected pane affordance.
|
||||
- `d27927b84b5` — remove a remote-host-only assumption from its copy.
|
||||
- `037ee535b8a` — centralize unreachable-pane guards.
|
||||
- `f7eb6f26656` — suppress the entire saved startup set for a fresh shell.
|
||||
- `b46a1f8773e` — avoid stranding a local pane after restore failure.
|
||||
- `ab6ce176f3f` — do not unbind a live shell that generic spawn adopted.
|
||||
- `df6edfcebd2` — center the recovery actions in narrow panes.
|
||||
|
||||
The last item in that list makes the pushed behavior internally safer but also
|
||||
exposes the unresolved product bug: generic spawn can adopt the incumbent, so
|
||||
“Start a new terminal” is not guaranteed to start anything new.
|
||||
|
||||
### 6. Tests, design records, and platform evidence
|
||||
|
||||
The session added substantial focused and E2E coverage for local restart,
|
||||
daemon/WSL survival, SSH pane cardinality, two-host isolation, `MaxSessions=1`,
|
||||
mixed-version decoding, lease termination, relay exit proof, and renderer
|
||||
recovery behavior. The main records are:
|
||||
|
||||
- `docs/reference/terminal-session-behavior-contract.md`
|
||||
- this folder's `goalposts.md`, `design-final-detail.md`, and `resume-plan.md`
|
||||
- `src/main/ipc/pty.test.ts`
|
||||
- `src/main/ipc/pty-superseded-operation-fence.test.ts`
|
||||
- `src/main/persistence.test.ts`
|
||||
- `src/main/persistence-ssh-lease-termination.test.ts`
|
||||
- `src/main/runtime/orca-runtime.test.ts`
|
||||
- `src/main/ssh-reattach-pane-cardinality.test.ts`
|
||||
- `src/main/ssh/ssh-relay-reattach-exit-proof.test.ts`
|
||||
- `src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts`
|
||||
- `src/renderer/src/components/terminal-pane/pty-connection.test.ts`
|
||||
- `src/renderer/src/components/terminal-pane/pty-transport.test.ts`
|
||||
- `src/renderer/src/components/terminal-pane/TerminalPaneDisconnectedBanner.test.tsx`
|
||||
- `tests/e2e/ssh-disconnected-pane-affordance.spec.ts`
|
||||
|
||||
Historical receipts in `goalposts.md` include discriminating native macOS,
|
||||
Linux, Windows, physical WSL, daemon, and Docker work at earlier candidate
|
||||
SHAs. They are historical evidence, not a current-HEAD validation receipt.
|
||||
|
||||
Important proof corrections were also recorded during the session:
|
||||
|
||||
- a guard tested only through a direct Store call can still have no production
|
||||
caller;
|
||||
- a green E2E that also passes without the fix is a forward guard, not proof;
|
||||
- the disconnected-pane state is not currently inducible with the existing
|
||||
Docker host faults; and
|
||||
- the first recovery grant and a later pane-binding fold were deleted after
|
||||
review showed their premises were false or their path was unreachable.
|
||||
|
||||
## The proven current bug
|
||||
|
||||
At pushed HEAD, the renderer's disconnected action calls `startFreshSpawn`,
|
||||
which calls the normal transport `connect`, which invokes generic `pty:spawn`.
|
||||
The old durable binding is still present. Generic spawn resolves stable pane
|
||||
ownership and may attach/adopt that incumbent rather than create a blank shell.
|
||||
|
||||
Consequences:
|
||||
|
||||
- “Start a new terminal” can reopen the old terminal.
|
||||
- A same-ID/new-incarnation result is not representable in the current renderer
|
||||
result contract.
|
||||
- The action's success path may compare or clear ownership by raw PTY ID.
|
||||
- The full product oracle is not running.
|
||||
|
||||
The decisive E2E is
|
||||
`tests/e2e/ssh-disconnected-pane-affordance.spec.ts`, test “starting a new
|
||||
terminal adds exactly one shell and leaves the panes alone,” beginning near line 314. It is still unconditionally held by
|
||||
`test.fixme(true, UNREACHABLE_PANE_INDUCTION_UNAVAILABLE)` near line 318.
|
||||
|
||||
## Dirty WIP that must be removed
|
||||
|
||||
The six uncommitted files implement the rejected
|
||||
`freshSpawnForUnreachablePtyId`/grant-map design:
|
||||
|
||||
| File | Uncommitted behavior |
|
||||
| ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `src/main/ipc/pty.ts` | Adds a max-256 process-global grant map, records a raw `StablePaneOwner` after attach failure, accepts replacement intent on generic spawn, skips adoption, and performs best-effort raw-ID cleanup. |
|
||||
| `src/preload/api-types.ts` | Adds `freshSpawnForUnreachablePtyId` to generic spawn. |
|
||||
| `src/preload/index.ts` | Forwards that generic spawn option. |
|
||||
| `src/renderer/src/components/terminal-pane/pty-connection.ts` | Passes the old `sessionId` as replacement authority. |
|
||||
| `src/renderer/src/components/terminal-pane/pty-transport-types.ts` | Adds the option to the generic transport contract. |
|
||||
| `src/renderer/src/components/terminal-pane/pty-transport.ts` | Forwards it to generic spawn and suppresses telemetry with saved startup. |
|
||||
|
||||
Why it was rejected:
|
||||
|
||||
1. Raw PTY ID is not process identity; same-ID/different-incarnation leases and
|
||||
runtime owners collapse.
|
||||
2. Renderer input becomes replacement authority on a broad generic IPC.
|
||||
3. Grants have no TTL, sender binding, one-shot operation state, reload
|
||||
invalidation, or post-spawn revalidation.
|
||||
4. Concurrent pane reservations can return another operation's result rather
|
||||
than re-resolving the caller's intent.
|
||||
5. Binding and lease changes flush separately, so no atomic replacement exists.
|
||||
6. Provider output can reach the old model before persistence/runtime commit.
|
||||
7. Generic spawn still accepts renderer-derived environment and other startup
|
||||
data, so it is not a guaranteed blank shell.
|
||||
8. Runtime pane ownership is registered, not exactly transferred; lookup can
|
||||
continue returning the old record.
|
||||
9. Renderer callbacks remain authoritative after dispose or a newer attempt.
|
||||
10. Failure cleanup calls ID-only shutdown and can kill the wrong incarnation.
|
||||
11. The grant is minted on only one attach-failure path, while the UI action can
|
||||
be published from other paths, leaving a visible dead action.
|
||||
12. Replacement intent leaks into `PtySpawnOptions`-adjacent contracts, making
|
||||
remote-wire and mixed-version reasoning unnecessarily dangerous.
|
||||
|
||||
No uncommitted tests cover this WIP, and no test was run against it.
|
||||
|
||||
Remove only these WIP edits with `apply_patch`. Do not use `git reset`,
|
||||
`git checkout --`, or another broad restoration command in this dirty worktree.
|
||||
|
||||
## Round-6 independent review result
|
||||
|
||||
Three read-only reviews converged:
|
||||
|
||||
- **Relay/wire review:** request changes. It identified raw-ID identity,
|
||||
ambiguous cleanup, missing atomic Store commit, precommit output publication,
|
||||
broad IPC authority, incomplete blank-shell enforcement, and wire-
|
||||
compatibility blockers.
|
||||
- **Targeted transaction review:** recommended a dedicated IPC, owner-keyed
|
||||
mutex, token-keyed idempotency, combined Store transaction, reconnect CAS,
|
||||
post-image inspection on flush error, and deterministic race tests.
|
||||
- **Final readiness audit:** request changes. It independently found the same
|
||||
persistence/runtime/grant/incarnation/renderer/cleanup defects and one hard
|
||||
feasibility boundary: existing SSH shutdown is raw-ID-only.
|
||||
|
||||
All three reviews were read-only. They ran no tests or builds and changed no
|
||||
files.
|
||||
|
||||
## Approved bounded redesign
|
||||
|
||||
### 1. Main-issued replacement ticket
|
||||
|
||||
When main classifies an attach as unreachable but not proven dead, return a
|
||||
typed local result containing an opaque ticket. The ticket is authority, not a
|
||||
renderer-supplied PTY ID.
|
||||
|
||||
Bind it to:
|
||||
|
||||
- the issuing `webContents`/sender;
|
||||
- connection and provider generation;
|
||||
- worktree or folder-workspace identity;
|
||||
- tab, leaf, and pane key;
|
||||
- exact old application PTY ID and provider-native ID;
|
||||
- persisted and runtime incarnation IDs;
|
||||
- exact active lease identity;
|
||||
- issuance generation and short expiry;
|
||||
- a random operation ID; and
|
||||
- state `issued | running | committed | rejected | unresolved` plus the
|
||||
idempotent result where applicable.
|
||||
|
||||
Invalidate tickets on renderer reload/destruction, provider replacement,
|
||||
connection re-registration, expiry, or exact-owner change. A consumed or
|
||||
rejected ticket must not become valid again after any request was dispatched.
|
||||
|
||||
### 2. Dedicated local IPC
|
||||
|
||||
Add a local-only API such as:
|
||||
|
||||
```ts
|
||||
pty.replaceUnreachablePane({ ticketId, cols, rows })
|
||||
```
|
||||
|
||||
It should return a structured result such as:
|
||||
|
||||
```ts
|
||||
{
|
||||
;(ptyId, incarnationId, operationId)
|
||||
}
|
||||
```
|
||||
|
||||
It must not route through generic `pty:spawn` or `PtyTransport.connect`, and it
|
||||
must not add fields to provider, SSH, relay, paired-runtime, or stream payloads.
|
||||
Authenticate the sender as the main renderer window before ticket lookup.
|
||||
|
||||
### 3. Shared owner lock and idempotency
|
||||
|
||||
Use one host/worktree/pane owner mutex for:
|
||||
|
||||
- ordinary pane spawn/adoption;
|
||||
- unreachable-pane replacement; and
|
||||
- relay reconnect's final owner activation.
|
||||
|
||||
Each waiter must re-resolve its own intent after acquiring the lock. It may not
|
||||
return another operation's result merely because it waited on the same lock.
|
||||
|
||||
Use the ticket's operation ID for idempotency. A double-click, lost response,
|
||||
or retry while running awaits the same operation and returns the same result.
|
||||
It must create at most one shell.
|
||||
|
||||
### 4. Main-constructed blank spawn
|
||||
|
||||
Under the owner lock, re-read the workspace and re-derive CWD. Repeat folder-
|
||||
workspace path validation at that moment; do not trust the path captured when
|
||||
the ticket was issued.
|
||||
|
||||
Construct provider spawn options from a narrow main-owned allowlist. Exclude:
|
||||
|
||||
- command and command delivery;
|
||||
- `sessionId`, `attachOnly`, or resume metadata;
|
||||
- launch config, token, agent, or telemetry;
|
||||
- arbitrary renderer environment or environment deletion;
|
||||
- shell override;
|
||||
- startup command delivery; and
|
||||
- sequenced-startup environment.
|
||||
|
||||
Only the validated pane/workspace identity, dimensions, and provider-required
|
||||
blank-shell metadata may cross the provider boundary.
|
||||
|
||||
### 5. Exact incarnation lease identity
|
||||
|
||||
Lease identity for this transaction must include incarnation, not only
|
||||
`(targetId, ptyId)`. Old `pty-1/inc-A` and new `pty-1/inc-B` must coexist as:
|
||||
|
||||
- old exact lease: expired/unbound; and
|
||||
- new exact lease: attached/bound.
|
||||
|
||||
Every mark, remove, attach, rollback, and reconnect CAS touched by replacement
|
||||
must name the exact lease incarnation. Preserve compatibility with persisted
|
||||
legacy rows that lack optional incarnation data; unknown identity may not
|
||||
authorize destructive cleanup.
|
||||
|
||||
### 6. One Store transaction
|
||||
|
||||
Add one Store method, for example `replaceUnreachableSshPaneOwner`, that under
|
||||
one mutation and one flush:
|
||||
|
||||
1. exact-CASes the old durable pane binding;
|
||||
2. exact-CASes the old active lease;
|
||||
3. installs the new binding and incarnation;
|
||||
4. installs the new exact attached lease;
|
||||
5. expires the exact old lease without shutting down its process;
|
||||
6. advances topology and lease mutation versions, including affected already-
|
||||
expired siblings; and
|
||||
7. flushes once.
|
||||
|
||||
Snapshot every mutated in-memory field before starting.
|
||||
|
||||
Because durable-file replacement renames the new image before directory fsync,
|
||||
a thrown flush is not proof that disk stayed old. Inspect the persisted post-
|
||||
image before deciding what happened:
|
||||
|
||||
- **Old image:** restore memory and clean only a provably exact new process.
|
||||
- **New image:** treat the transaction as committed.
|
||||
- **Mixed or unreadable:** kill neither process; mark the operation unresolved,
|
||||
quarantine publication, and reconcile from disk/provider evidence.
|
||||
|
||||
### 7. Runtime ownership replacement
|
||||
|
||||
Add a non-exit runtime primitive along these lines:
|
||||
|
||||
```ts
|
||||
runtime.replaceTerminalPaneOwner({
|
||||
paneKey,
|
||||
worktreeId,
|
||||
expected: { ptyId, incarnationId },
|
||||
replacement: { ptyId, incarnationId }
|
||||
})
|
||||
```
|
||||
|
||||
It must:
|
||||
|
||||
- exact-CAS the current owner;
|
||||
- clear the old record's pane ownership without `onPtyExit`;
|
||||
- leave the old process live and unbound;
|
||||
- move the pane/leaf/handle lookup to the replacement;
|
||||
- invalidate old handles so they cannot silently target the successor; and
|
||||
- correctly reset ID-keyed state when raw PTY ID is reused with a new
|
||||
incarnation.
|
||||
|
||||
Prepare and validate runtime replacement before the Store commit. Make the
|
||||
final in-memory step synchronous and designed not to fail. If an unexpected
|
||||
post-durable failure still occurs, never roll durable ownership back to the old
|
||||
process and never kill either shell; quarantine and retry publication/
|
||||
reconciliation idempotently.
|
||||
|
||||
### 8. Quarantine provider output
|
||||
|
||||
Capture the provider object and generation before spawn and recheck them after
|
||||
every await. Quarantine output for the exact
|
||||
`{ provider generation, ptyId, incarnationId }` until durable and runtime
|
||||
replacement commit.
|
||||
|
||||
Only then activate source delivery and publish the new runtime owner. A new
|
||||
shell reusing the old raw ID must never send bytes into the predecessor model.
|
||||
|
||||
### 9. Fence relay reconnect
|
||||
|
||||
Relay reconnect must acquire the same owner lock after network attach and,
|
||||
before changing ownership or delivery, exact-CAS:
|
||||
|
||||
- the current pane binding;
|
||||
- the lease incarnation; and
|
||||
- the provider/connection generation.
|
||||
|
||||
Only a winning reconnect may set ownership, restore incarnation, activate
|
||||
source delivery, or register runtime state. A reconnect for the predecessor
|
||||
must be unable to publish it after replacement commits.
|
||||
|
||||
### 10. Renderer adoption rules
|
||||
|
||||
The renderer calls only the dedicated API. Guard every completion/failure by:
|
||||
|
||||
- connection object not disposed;
|
||||
- current transport identity;
|
||||
- current action generation; and
|
||||
- matching ticket/operation generation.
|
||||
|
||||
Accept same-raw-ID/new-incarnation as a real successor. Adopt only the exact
|
||||
structured committed result. A stale or disposed callback does nothing: it does
|
||||
not clear a binding, republish the card, or kill a result. Main owns the durable
|
||||
transition, so the renderer must never clear the old durable binding after
|
||||
success.
|
||||
|
||||
## Hard cleanup boundary and selected policy
|
||||
|
||||
Current provider and SSH relay shutdown APIs identify only a PTY ID. If spawn
|
||||
returns `pty-1/inc-B`, then reconnect races and `pty-1` may refer to a different
|
||||
incarnation by the time rollback runs. Calling `shutdown('pty-1')` is not exact
|
||||
cleanup.
|
||||
|
||||
The safe policy for this PR is:
|
||||
|
||||
- do not add replacement intent to the remote wire;
|
||||
- use existing capability-gated create-operation replay and host-attested
|
||||
incarnation when they provide conclusive identity;
|
||||
- clean a failed new shell only when its exact incarnation is provable through
|
||||
a provider-owned rollback handle or equivalent existing evidence;
|
||||
- otherwise park/quarantine the ambiguous shell for reconciliation;
|
||||
- never blind-retry an ambiguous legacy spawn; and
|
||||
- never risk killing the incumbent merely to avoid a leak.
|
||||
|
||||
Legacy relays without create-operation replay or host-attested incarnation must
|
||||
fail closed or leave the outcome unresolved. If implementation cannot satisfy
|
||||
this with current local/provider contracts, stop at the design gate. Do not
|
||||
silently add `expectedIncarnationId` to remote shutdown; that is a separate,
|
||||
capability-negotiated wire design and review.
|
||||
|
||||
## Transaction outcome table
|
||||
|
||||
| Outcome | Durable owner | Process action | Publication |
|
||||
| -------------------------------------------- | ----------------- | ------------------------------------------------- | ------------------------------------- |
|
||||
| Ticket/pre-spawn validation or fence failure | Old | None | Reject |
|
||||
| Spawn failure before dispatch | Old | None | Reject; ticket may be safely terminal |
|
||||
| Spawn response ambiguous after dispatch | Old or unresolved | No blind retry or ID-only shutdown | Park/reconcile |
|
||||
| Known new shell, post-spawn CAS loss | Winner or old | Stop only the exact new incarnation when provable | Publish nothing |
|
||||
| Store commit succeeds | New | Leave old process alive; expire exact old lease | Publish new |
|
||||
| Flush throws; disk has old image | Old | Clean exact new only when provable | Reject |
|
||||
| Flush throws; disk has new image | New | Leave both processes | Publish new |
|
||||
| Flush throws; disk mixed/unreadable | Unresolved | Kill neither | Quarantine/reconcile |
|
||||
| Post-commit runtime/publication failure | New | Never roll back or kill | Retry publication idempotently |
|
||||
|
||||
## Required deterministic oracles
|
||||
|
||||
These are completion requirements, not optional follow-ups.
|
||||
|
||||
### Main transaction
|
||||
|
||||
- Seed an exact failed owner, ticket, binding, and lease.
|
||||
- One explicit replacement call makes exactly one provider fresh spawn.
|
||||
- Provider options contain no session, attach, command, startup, resume,
|
||||
renderer env, launch metadata, or telemetry.
|
||||
- The same pane remains; the old shell receives no shutdown or synthetic exit.
|
||||
- Exactly one combined Store handoff occurs and runtime lookup resolves the new
|
||||
owner.
|
||||
|
||||
### Authorization and lifecycle
|
||||
|
||||
- Missing, expired, replayed, wrong-sender, wrong-host, wrong-worktree,
|
||||
wrong-pane, and renderer-reload tickets reject before provider spawn.
|
||||
- Changed/missing binding, persisted/runtime incarnation, lease, provider, or
|
||||
generation rejects before mutation.
|
||||
- Folder-workspace path changes after ticket issuance are revalidated under the
|
||||
lock.
|
||||
|
||||
### Identity and races
|
||||
|
||||
- Old and new may both be raw `pty-1` with distinct incarnations.
|
||||
- Double-click and lost-response retry create one operation and one shell.
|
||||
- Ordinary spawn racing replacement does not inherit the replacement result.
|
||||
- Reconnect before spawn yields no spawn.
|
||||
- Reconnect after spawn cannot republish the predecessor.
|
||||
- Provider replacement during every await prevents an unsafe commit.
|
||||
- A delayed old-incarnation exit cannot affect the new incarnation.
|
||||
|
||||
### Persistence and durability
|
||||
|
||||
- Success survives save/reload with exactly one flush.
|
||||
- Old exact lease is expired, new exact lease attached, and new binding exact.
|
||||
- Pre-rename failure restores old memory/disk state.
|
||||
- Post-rename failure recognizes the new image as committed.
|
||||
- Mixed/unreadable post-image kills neither process.
|
||||
- Duplicate-retirement rollback cannot revive an already-expired sibling.
|
||||
- Same raw PTY ID with different incarnations remains representable.
|
||||
|
||||
### Runtime and renderer
|
||||
|
||||
- Exact non-exit runtime rebind moves pane/leaf/handle lookup to the successor.
|
||||
- The old owner remains live but unbound and receives no exit.
|
||||
- Disposed, stale-generation, late-success, and late-failure callbacks cannot
|
||||
spawn, clear, kill, or republish over newer state.
|
||||
- Same-ID/new-incarnation structured results are adopted.
|
||||
- A disposed transport never kills a main-committed replacement.
|
||||
|
||||
### Compatibility and E2E
|
||||
|
||||
- No replacement field appears in provider, SSH, relay, runtime RPC, paired
|
||||
RPC, or stream payloads.
|
||||
- Legacy relay degradation is fail-closed/unresolved, never destructive.
|
||||
- Current-to-release and release-to-current terminal compatibility suites stay
|
||||
green.
|
||||
- Unfix the decisive Docker test and add a deterministic production seam that
|
||||
leaves the target connected while one pane attach is unreachable.
|
||||
- The E2E must prove old PID plus start ticks stay alive, exactly one blank
|
||||
shell is added, pane/leaf count is unchanged, new durable `(id,
|
||||
incarnation)` is bound, new lease is attached, old exact lease is expired,
|
||||
and no agent/resume/startup transcript is duplicated.
|
||||
|
||||
## Remaining work, in dependency order
|
||||
|
||||
### Phase 0 — resume safely and freeze the invariants
|
||||
|
||||
1. Read this document and the repository `AGENTS.md` instructions.
|
||||
2. Run the checkpoint commands below.
|
||||
3. Confirm the PR ref remains at the recorded SHA and inspect any newly arrived
|
||||
changes before editing.
|
||||
4. Treat ambiguous cleanup as unresolved rather than adding a remote wire field.
|
||||
5. If that constraint becomes infeasible, stop and return to the user instead
|
||||
of widening scope.
|
||||
|
||||
### Phase 1 — remove the rejected WIP
|
||||
|
||||
Use `apply_patch` to remove only the current six-file
|
||||
`freshSpawnForUnreachablePtyId` and grant-map edits. Preserve every committed
|
||||
change and any unrelated user work. Run `git diff --check` and verify the only
|
||||
remaining change is this handoff document before beginning the replacement.
|
||||
|
||||
### Phase 2 — build the identity and persistence foundation
|
||||
|
||||
1. Add incarnation-aware exact lease identity with legacy-row compatibility.
|
||||
2. Add exact lease lookup/mark/remove APIs required by replacement and
|
||||
reconnect.
|
||||
3. Add the single Store replacement transaction and one-flush rollback state.
|
||||
4. Add post-image inspection for pre-rename, post-rename, and mixed/unreadable
|
||||
outcomes.
|
||||
5. Write persistence tests first and demonstrate the intended failure on the
|
||||
current implementation.
|
||||
|
||||
### Phase 3 — build runtime and coordination primitives
|
||||
|
||||
1. Add the exact non-exit runtime pane-owner replacement primitive.
|
||||
2. Add the host/worktree/pane owner mutex shared by normal spawn,
|
||||
replacement, and reconnect final activation.
|
||||
3. Add operation-token idempotency independent from that lock.
|
||||
4. Update reconnect to revalidate exact binding, lease incarnation, and
|
||||
provider generation after it acquires the lock.
|
||||
5. Add same-ID/new-incarnation and delayed-old-exit tests.
|
||||
|
||||
### Phase 4 — implement the main replacement transaction
|
||||
|
||||
1. Issue typed, sender-bound, expiring, one-shot tickets from every renderer
|
||||
path that can publish the unreachable action.
|
||||
2. Add the dedicated preload/main IPC and main-window sender check.
|
||||
3. Re-derive CWD and validate folder workspaces under the lock.
|
||||
4. Construct the blank provider spawn from the main allowlist.
|
||||
5. Capture provider generation and quarantine exact new output.
|
||||
6. Revalidate after every await; prepare runtime; execute the Store commit;
|
||||
finish runtime activation; publish output/result.
|
||||
7. Implement all transition-table outcomes without ID-only cleanup.
|
||||
|
||||
### Phase 5 — adopt through the renderer
|
||||
|
||||
1. Remove replacement from generic transport/spawn types.
|
||||
2. Thread the typed ticket and structured committed result through the
|
||||
disconnected action only.
|
||||
3. Add disposed/current-transport/action-generation guards.
|
||||
4. Remove renderer-side binding clearing or committed-result killing.
|
||||
5. Verify UI layout, copy, and behavior through Electron Playwright CDP as
|
||||
required by repository instructions.
|
||||
|
||||
### Phase 6 — validate and review once
|
||||
|
||||
1. Run the focused deterministic suite.
|
||||
2. Make the Docker E2E inducible, remove its unconditional `fixme`, and prove
|
||||
it fails for the intended reason on the unfixed candidate before claiming
|
||||
it as evidence.
|
||||
3. Run typecheck, lint, full unit tests, build, reliability gates, and relevant
|
||||
SSH/daemon/cross-version regressions.
|
||||
4. Run independent repository and release-readiness reviews, including
|
||||
security, performance, mobile/backcompat, Windows, Linux/glibc, SSH, folder
|
||||
workspace, and provider-neutrality coverage.
|
||||
5. Resolve every P0/P1 and relevant P2. If another architectural invariant
|
||||
fails, pause instead of entering an unbounded seventh patch loop.
|
||||
|
||||
### Phase 7 — deliver
|
||||
|
||||
Only after the implementation and reviews are clean:
|
||||
|
||||
1. Commit the replacement work.
|
||||
2. Push explicitly to
|
||||
`origin/nwparker/sta-3077-d-affordance-and-review`.
|
||||
3. Update PR #13326's body with the transaction, compatibility behavior, and
|
||||
exact validation receipts.
|
||||
4. Resolve the seven saved review threads only when their findings are actually
|
||||
addressed.
|
||||
5. Request `@coderabbitai review` and monitor CI to completion.
|
||||
6. Use the `orca-linear` workflow to post one concise STA-3077 completion
|
||||
update and attach the PR if needed.
|
||||
|
||||
## First safe resume commands
|
||||
|
||||
Run from the primary worktree before editing:
|
||||
|
||||
```bash
|
||||
git status --short --branch --untracked-files=all
|
||||
git rev-parse HEAD
|
||||
git rev-parse origin/nwparker/sta-3077-d-affordance-and-review
|
||||
git rev-parse origin/main
|
||||
git rev-list --left-right --count origin/main...HEAD
|
||||
git diff --stat
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected before any external state changes:
|
||||
|
||||
- HEAD and the PR ref are both `df6edfcebd2ea401a6feda1fccf31189e57eceda`;
|
||||
- the six rejected production-file edits are present;
|
||||
- this handoff file is the only additional local artifact; and
|
||||
- `git diff --check` exits 0.
|
||||
|
||||
Do not use the configured upstream to infer PR state.
|
||||
|
||||
## Suggested validation commands after implementation
|
||||
|
||||
Adjust the focused file list only when the final implementation clearly moves
|
||||
the covered behavior. Record exact commands and outcomes in the PR.
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/main/ipc/pty.test.ts \
|
||||
src/main/persistence.test.ts \
|
||||
src/main/persistence-ssh-lease-termination.test.ts \
|
||||
src/main/runtime/orca-runtime.test.ts \
|
||||
src/main/ssh-reattach-pane-cardinality.test.ts \
|
||||
src/main/ssh/ssh-relay-session.test.ts \
|
||||
src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts \
|
||||
src/main/ssh/ssh-relay-session-reattach-pane-fence.test.ts \
|
||||
src/renderer/src/components/terminal-pane/pty-connection.test.ts \
|
||||
src/renderer/src/components/terminal-pane/pty-transport.test.ts \
|
||||
src/renderer/src/components/terminal-pane/TerminalPaneDisconnectedBanner.test.tsx \
|
||||
--reporter=dot
|
||||
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
For the Docker product oracle, use the repo's Electron Playwright configuration
|
||||
with `ORCA_E2E_SSH_DOCKER=1` after adding the deterministic induction seam and
|
||||
removing the test's unconditional `fixme`. Keep the run POSIX-only as the
|
||||
existing Docker fault harness requires.
|
||||
|
||||
Run the current↔release cross-version terminal suite and the relevant Docker
|
||||
SSH cardinality, `MaxSessions=1`, and two-host scripts as part of broad
|
||||
regression coverage.
|
||||
|
||||
## Validation record at pause
|
||||
|
||||
### Current redesign/WIP
|
||||
|
||||
No product validation was run after the rejected six-file WIP was introduced.
|
||||
No product validation was run during the three final read-only audits or while
|
||||
writing this handoff. Therefore this document claims no current test, typecheck,
|
||||
lint, build, Electron, Docker, cross-version, or packaging pass.
|
||||
|
||||
### Read-only checkpoint commands run for this handoff
|
||||
|
||||
The following repository inspections completed successfully:
|
||||
|
||||
```bash
|
||||
git status --short --branch
|
||||
git status --short --branch --untracked-files=all
|
||||
git rev-parse HEAD
|
||||
git branch --show-current
|
||||
git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}'
|
||||
git rev-parse origin/nwparker/sta-3077-d-affordance-and-review
|
||||
git rev-parse origin/nwparker/sta-3077-reattach-pane-cardinality
|
||||
git rev-parse origin/main
|
||||
git rev-list --left-right --count origin/main...HEAD
|
||||
git merge-base origin/main HEAD
|
||||
git log --oneline --decorate -12
|
||||
git reflog --date=iso
|
||||
git diff --stat
|
||||
git diff --name-status
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Observed results are recorded in the repository checkpoint above. In
|
||||
particular, `git diff --check` exited 0.
|
||||
|
||||
Historical validation details are preserved in `goalposts.md` and
|
||||
`resume-plan.md`. Those receipts belong to their recorded SHAs and must not be
|
||||
reported as validation of the final replacement candidate without rerunning
|
||||
them.
|
||||
|
||||
## Security, compatibility, migration, and release risks
|
||||
|
||||
- **IPC authority:** a renderer-supplied PTY ID is forgeable/piggybackable;
|
||||
tickets must be opaque, sender-bound, short-lived, one-shot, and revalidated.
|
||||
- **Same-ID reuse:** raw PTY ID cannot distinguish an incumbent from a
|
||||
successor. Every destructive or ownership operation needs exact incarnation.
|
||||
- **Durability:** a thrown fsync after rename can leave the new image on disk.
|
||||
Blind in-memory rollback can diverge from restart state.
|
||||
- **Remote ambiguity:** ID-only shutdown cannot safely clean same-ID reuse.
|
||||
Prefer a visible/reconcilable leak to killing an unproven process.
|
||||
- **Output isolation:** precommit bytes from a successor can corrupt the old
|
||||
pane model unless quarantined by provider generation plus incarnation.
|
||||
- **Mixed versions:** clients and hosts update independently. Keep replacement
|
||||
local; any future exact-shutdown wire field needs capability negotiation and
|
||||
both-direction live skew tests.
|
||||
- **Legacy persisted rows:** missing incarnation is unknown, not permission to
|
||||
mutate or delete. Migration must be additive and old-reader-safe.
|
||||
- **Folder and SSH workspaces:** re-resolve current paths and host generation
|
||||
under the lock; neither git worktree presence nor local filesystem access may
|
||||
be assumed.
|
||||
- **Cross-platform:** preserve macOS, Linux/glibc floor, Windows, WSL, path, and
|
||||
shortcut rules even though the Docker fault harness is POSIX-only.
|
||||
- **Performance:** avoid global scans, unbounded ticket/operation maps,
|
||||
repeated fsyncs, or hot-path lock contention. Bound retention and measure
|
||||
affected reconnect/spawn paths.
|
||||
- **Release evidence:** the key E2E remains held. The change is not release-
|
||||
ready until that product path is deterministic and discriminating.
|
||||
|
||||
## Stop conditions
|
||||
|
||||
Pause and return to the user if any of these occurs:
|
||||
|
||||
- exact cleanup appears to require an unreviewed remote-wire change;
|
||||
- the Store/runtime transaction cannot distinguish old, new, and mixed durable
|
||||
post-images;
|
||||
- replacement requires treating unknown identity as proof;
|
||||
- the deterministic E2E still cannot enter the actual production state;
|
||||
- the shared lock introduces a new global serialization or deadlock risk; or
|
||||
- the next independent review finds another architectural invariant failure.
|
||||
|
||||
The point of this handoff is to prevent another opaque loop: one bounded
|
||||
redesign, deterministic evidence, one adversarial review, then either delivery
|
||||
or a principled stop.
|
||||
@@ -0,0 +1,265 @@
|
||||
# Terminal session correctness and authority handoff
|
||||
|
||||
> **Status: preserved and paused, not complete, not release-ready.**
|
||||
>
|
||||
> The comprehensive program is at **0/8 proven goalposts** and
|
||||
> **2/13 proven journeys**. No agent may narrow, replace, or mark a goalpost
|
||||
> complete without the user's explicit approval and the proof named in this
|
||||
> folder.
|
||||
|
||||
This folder is the sole continuation entrypoint for the work previously called
|
||||
“React 185” or “terminal session authority.” That shorthand is too narrow: the
|
||||
program covers terminal identity, ownership, exact operations, reconnect,
|
||||
delivery, migration, daemon and SSH lifecycle, paired and remote runtimes,
|
||||
cross-platform compatibility, performance, and removal of superseded code.
|
||||
|
||||
## Read in this order
|
||||
|
||||
1. Read all three preserved source documents with the exact `git show` commands
|
||||
under [Repository checkpoint](#repository-checkpoint). The normative design,
|
||||
delivery ledger, and original pause handoff are mandatory, not optional
|
||||
implementation detail.
|
||||
2. [`goalposts.md`](./goalposts.md) — the current completion contract, amendable
|
||||
only by an explicit user decision.
|
||||
3. [`related-open-work.md`](./related-open-work.md) — live PR/issue overlap and
|
||||
conflicts.
|
||||
4. [`resume-plan.md`](./resume-plan.md) — safe continuation order and required
|
||||
validation receipts.
|
||||
|
||||
The older `terminal-session-authority-handoff-2.md` is useful as an adversarial
|
||||
report, but it is not authoritative. In particular, its “descoped,” “shipped,”
|
||||
identity-equivalence, timer, and LOC claims must not be copied as settled facts.
|
||||
|
||||
## Non-negotiable user objective
|
||||
|
||||
Deliver one coherent long-term terminal-session correctness model that:
|
||||
|
||||
- removes the root causes instead of leaving quarantine, retry-window,
|
||||
sliding-window, optional-metadata repair, or other reconciliation as the
|
||||
final correctness mechanism;
|
||||
- never treats missing, unavailable, timed-out, disconnected, or unknown state
|
||||
as proof that a process is dead or that replacement/destruction is safe;
|
||||
- works for local terminals, daemon, WSL, direct and nested SSH, paired runtime,
|
||||
remote server, folder workspaces, floating workspaces, and git worktrees;
|
||||
- remains correct when clients and hosts update independently;
|
||||
- works on macOS, Linux at the supported glibc floor, Windows, WSL, and Docker
|
||||
OpenSSH;
|
||||
- has no correctness, security, latency, throughput, memory, restore, startup,
|
||||
backpressure, or large-pane regression;
|
||||
- finishes with **strictly less aggregate production code than the frozen
|
||||
pre-program baseline** after all superseded implementations are deleted; and
|
||||
- is proven through real production paths and discriminating end-to-end
|
||||
journeys, not inferred from test counts or green CI.
|
||||
|
||||
Correctness and performance cannot be weakened to reach the code-size target.
|
||||
The code-size target also cannot be waived because a replacement architecture
|
||||
was added beside the old one.
|
||||
|
||||
## Authority and scope rules
|
||||
|
||||
- Direct user instructions govern scope.
|
||||
- The original normative design remains the contract except where a later,
|
||||
explicit user decision changes a named requirement.
|
||||
- This handoff tightens G6 to **strictly net-negative production LOC** because
|
||||
the user explicitly asked to end with less production code than the original
|
||||
baseline.
|
||||
- The default frozen accounting baseline is
|
||||
`5ed45739e94bdf6460364e033bfcec9b32c0b42a`, the base recorded by GitHub for
|
||||
PR #12600. This program subsumes that containment PR. Every later
|
||||
program-attributable merged prerequisite and every stack layer must be counted
|
||||
in aggregate; rebasing may not roll the accounting baseline forward and hide
|
||||
earlier additions. Changing this baseline requires an explicit user decision.
|
||||
- Focused tests, green CI, a review comment, a behavior-contract document, an
|
||||
open PR, or a construction milestone cannot amend the design or promote a
|
||||
release goalpost.
|
||||
- A replacement design is allowed, but it must map every original invariant,
|
||||
risk, and journey to an equal or stronger mechanism, close the known liveness
|
||||
gap, and receive explicit user approval before the ledger changes.
|
||||
- Only `not started`, `partial`, and `proven` are valid gate statuses.
|
||||
“Descoped,” “superseded,” and “met for what shipped” are not valid statuses
|
||||
for G0–G7 under the current objective.
|
||||
- A narrow incident PR may be independently useful or mergeable. It does not
|
||||
reduce the comprehensive denominator of eight goalposts and thirteen
|
||||
journeys.
|
||||
|
||||
## Settled status
|
||||
|
||||
| Scope | Status | Meaning |
|
||||
| -------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Construction preservation | **Complete locally; not remotely durable** | The 814-path construction tree and the original three authority documents are preserved at local branch `nwparker/react185-authority-snapshot` / `fddb19f6977`. |
|
||||
| Comprehensive goalposts | **0/8 proven** | G0–G4 and G6 are partial. G5 and G7 are not started. |
|
||||
| Required journeys | **2/13 proven** | Journeys 1 and 2 have clause-selective discriminating oracles run on every environment they name. The other eleven remain unproven. |
|
||||
| Current branch / PR #13111 | **Implemented, open, unapproved, incomplete** | Useful narrow SSH containment; not a long-term completion and not merged. |
|
||||
| PR #13110 | **Open, independent, unapproved** | Useful preload artifact guard; not shipped and has an unresolved major review thread. |
|
||||
| Less-code gate | **Failed on current candidate** | PR #13111 is net positive; the preserved construction snapshot is net `+60,903` production LOC. |
|
||||
|
||||
The numeric release checkpoint is therefore:
|
||||
|
||||
- **G0:** partial
|
||||
- **G1:** partial
|
||||
- **G2:** partial
|
||||
- **G3:** partial
|
||||
- **G4:** partial
|
||||
- **G5:** not started
|
||||
- **G6:** partial and currently failing its final LOC condition
|
||||
- **G7:** not started
|
||||
- **Total:** **0/8 proven, 6/8 partial, 2/8 not started**
|
||||
- **Journeys:** **2/13 proven** (Journey 1 natively on macOS/Linux/Windows; Journey 2 on macOS/Linux/physical WSL)
|
||||
|
||||
This is the **program artifact ledger**. Its partial G0, G1, G3, and G4 evidence
|
||||
exists only in the preserved construction snapshot and historical receipts. It
|
||||
does not mean those implementations exist on the current #13111 branch. The
|
||||
current candidate contributes credible partial G2 evidence and limited
|
||||
compatibility/review evidence, but it proves **zero** complete release gates.
|
||||
|
||||
## Repository checkpoint
|
||||
|
||||
Verified before creating this documentation folder:
|
||||
|
||||
- Worktree:
|
||||
`/Users/nwparker/orca/workspaces/orca/eye-React-185`
|
||||
- Current branch: `nwparker/sta-3077-reattach-pane-cardinality`
|
||||
- Current HEAD: `5369479be2953f45cea9ab5cfcde756dd9660548`
|
||||
- HEAD subject: `fix(pty): let liveness say unknown instead of forcing it to say dead`
|
||||
- Local `origin/main`: `0d29497f8279e2d4c2d26ffc8f3fb68cee2756a7`
|
||||
- Merge base: `e6e197feeddd1adda066c5cd22f7ee056a12c8c1`
|
||||
- Distance from local `origin/main`: 15 commits ahead, 19 behind
|
||||
- The worktree was clean before this handoff. The expected handoff-only changes
|
||||
are this folder and its `.gitignore` allow-list entry.
|
||||
- Preserved construction branch:
|
||||
`nwparker/react185-authority-snapshot`
|
||||
- Preserved construction commit:
|
||||
`fddb19f6977ae4ba4764c32afc9fe104c1ed2549`
|
||||
- Preservation location: local ref only; the branch has no upstream, tag, or
|
||||
remote branch containing this commit
|
||||
- Snapshot status: **unshippable salvage/reference only**
|
||||
|
||||
The exact commit ID is the preservation anchor. Do not checkout, switch, reset,
|
||||
rebase, commit, or push from the snapshot branch in the primary worktree. Read
|
||||
its authority documents without switching branches:
|
||||
|
||||
```bash
|
||||
git show fddb19f6977:docs/reference/terminal-session-authority.md
|
||||
git show fddb19f6977:docs/reference/terminal-session-authority-delivery.md
|
||||
git show fddb19f6977:docs/reference/terminal-session-authority-handoff.md
|
||||
```
|
||||
|
||||
Mine tests, design arguments, or narrow implementations only through `git show`
|
||||
or a disposable isolated worktree, and only after verifying that they address a
|
||||
reachable production path on the current base. A local tag protects against
|
||||
branch movement and ordinary GC only; it does not survive loss of this machine.
|
||||
Cross-machine/crash preservation requires a copy-verified off-machine bundle or
|
||||
an explicitly authorized remote ref. Do not infer permission to push it.
|
||||
|
||||
## Current narrow candidate: what is real
|
||||
|
||||
At current HEAD / PR
|
||||
[#13111](https://github.com/stablyai/orca/pull/13111):
|
||||
|
||||
- SSH relay reattach passes `mayCreate: false` to stop that call site from
|
||||
creating durable pane/layout state.
|
||||
- Duplicate complete pane leases are superseded and excluded from reattach.
|
||||
- Existing duplicate complete leases are reconciled during reconnect.
|
||||
- Reattach failure paths require positive evidence before cold respawn.
|
||||
- PTY inventory can report `unknown` rather than collapsing an unavailable
|
||||
provider into `false`/dead.
|
||||
- Focused tests and GitHub checks are green at HEAD.
|
||||
|
||||
These are credible partial G2 improvements. They do **not** establish the
|
||||
comprehensive design or release gates.
|
||||
|
||||
## Current narrow candidate: what remains unproved or wrong
|
||||
|
||||
- Lease row identity is still `(targetId, ptyId)`. Pane fields remain optional.
|
||||
Active-pane cardinality is enforced by sibling expiration and a healing scan,
|
||||
so the end state remains reconciliation-based rather than structurally
|
||||
pane-keyed.
|
||||
- Incomplete legacy leases bypass pane arbitration.
|
||||
- Superseded remote shells are deliberately left running and unreachable. The
|
||||
PR body acknowledges that the “accumulates unused shells” half of the incident
|
||||
remains unresolved.
|
||||
- The repeated-reconnect Docker test still passes with and without the fix: it
|
||||
is a forward guard, not causal proof. Its sibling in the same spec — 'leaves a
|
||||
lease whose durable pane is gone unbound…' — was reported as discriminating,
|
||||
but that DID NOT REPRODUCE on a second machine: with `mayCreate: false`
|
||||
removed from the reattach call site and the app rebuilt, both tests still
|
||||
passed. Its induction races `pty:kill` against a severed transport, so when
|
||||
the kill lands the lease is cleaned up and there is nothing to graft. Treat
|
||||
both tests as forward guards. No journey is proven.
|
||||
- The final Docker settle assertion has an unresolved major review thread: a
|
||||
late pane or shell can appear after the polling assertion has already passed.
|
||||
- The production-call-site wiring oracle reads source text. It does not execute
|
||||
the full production reattach path.
|
||||
- `mayCreate: false` is wired at one production binding call site, not proven
|
||||
across every recovery/reattach runtime. `removeSshRemotePtyLease` still has one
|
||||
production caller, the spawn-persistence rollback.
|
||||
- `WEDGED_DAEMON_GRACE_RETRIES = 11` remains reachable and still permits retry
|
||||
count to drive daemon replacement after unknown inventory.
|
||||
- `terminal-input-quarantine.ts` remains reachable.
|
||||
- No current-tree proof covers physical Windows, WSL, the Linux glibc floor,
|
||||
paired runtime, remote server, two independent SSH hosts, live mixed-version
|
||||
peers, or production-scale performance.
|
||||
- There is no candidate-versus-baseline performance result for the new
|
||||
per-upsert sibling scan or reconnect healing pass.
|
||||
- PR #13111 is open, has no approval decision, and is not shipped.
|
||||
|
||||
Current HEAD census against its merge base, before this documentation change:
|
||||
|
||||
| Category | Additions | Deletions | Net |
|
||||
| --------------------------------------------------- | --------: | --------: | -------: |
|
||||
| Production source, including a fixture under `src/` | 374 | 65 | **+309** |
|
||||
| Tests and end-to-end tests | 944 | 30 | +914 |
|
||||
| Documentation | 146 | 0 | +146 |
|
||||
| CI/configuration | 43 | 0 | +43 |
|
||||
| Total GitHub diff | 1,507 | 95 | +1,412 |
|
||||
|
||||
The production source plus Docker runner is net `+350`. Whichever taxonomy is
|
||||
used, the categories must remain separate and G6 is not proven.
|
||||
|
||||
## Corrections to the previous handoff
|
||||
|
||||
- PRs #13110 and #13111 are **open and green**, not shipped.
|
||||
- #13110 is implementation-independent from #13111, but emitted-preload safety
|
||||
remains inside G0/G5/G7 and any retained #13110 production/build logic counts
|
||||
in the aggregate G6 census.
|
||||
- The snapshot genuinely failed to wire `mayCreate`, pane-cardinality
|
||||
arbitration, or a complete shell-lifecycle fix into the affected legacy SSH
|
||||
path.
|
||||
- Pane key, PTY ID, incarnation ID, a narrow CAS, and a three-valued daemon
|
||||
resolver pre-existed and should be reused where their semantics match.
|
||||
- Existing `connectionId` and `worktreeId` do **not** establish equivalence to a
|
||||
final-host-minted authority identity and canonical host-local namespace.
|
||||
- The normative design never banned all timers. It permits bounded waits and
|
||||
backoff; it forbids elapsed time or retry count **alone** from deciding
|
||||
identity, liveness, takeover, replacement, or destruction.
|
||||
- The design does have a liveness gap: a permanently lost retained consumer can
|
||||
hold compaction at capacity without a sufficiently explicit bounded operator
|
||||
recovery path. G0 and G3 cannot become proven until that is resolved.
|
||||
- The claimed approximately 6,500-line comparison implementation has no
|
||||
reproducible census and cannot justify deleting requirements.
|
||||
|
||||
## Definition of done
|
||||
|
||||
The comprehensive work is done only when all of the following are true on one
|
||||
rebased, converged candidate SHA:
|
||||
|
||||
- every G0–G7 section in [`goalposts.md`](./goalposts.md) has its named
|
||||
production implementation and current proof;
|
||||
- all thirteen required journeys pass with their full oracles;
|
||||
- each new causal oracle is red on the unfixed baseline and green on the final
|
||||
candidate; formal verification may supplement but never replace a required
|
||||
live journey;
|
||||
- every open incident bound to the mandatory issue-to-journey matrix in
|
||||
[`related-open-work.md`](./related-open-work.md) passes its discriminating
|
||||
oracle or is ruled unrelated with explicit evidence and user acceptance;
|
||||
- correctness, security, wire, migration, platform, packaging, performance,
|
||||
restore, and scale reviews have no unresolved P0–P2 findings;
|
||||
- the aggregate production source census is strictly net-negative against the
|
||||
frozen pre-program baseline, including all program-attributable prerequisite
|
||||
PRs and stack layers;
|
||||
- no superseded reconciliation, quarantine, duplicate state machine, legacy
|
||||
writer, or test fixture remains reachable in production; and
|
||||
- the user explicitly accepts any deliberate change to the normative design.
|
||||
|
||||
Anything less must be reported as partial, regardless of test count or PR
|
||||
state.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Resume prompt — terminal session ownership
|
||||
|
||||
Paste the block below into a fresh session. Everything it needs is in the repo.
|
||||
|
||||
---
|
||||
|
||||
You are taking over an in-flight implementation in this repo. Read these, in
|
||||
order, before doing anything:
|
||||
|
||||
1. `docs/reference/terminal-session-correctness-handoff-2026-08-07/new-design-goalposts.md`
|
||||
— the tracked goalposts. This is the contract. Report progress against it.
|
||||
2. `docs/reference/terminal-session-correctness-handoff-2026-08-07/design-explained.html`
|
||||
— the design in plain English (open it; it has diagrams).
|
||||
3. `docs/reference/terminal-session-correctness-handoff-2026-08-07/design-final-detail.md`
|
||||
— the full detail: record shape, death rule, reattach algorithm, migration
|
||||
order, and the oracle list.
|
||||
4. `docs/reference/terminal-session-correctness-handoff-2026-08-07/counsel-design.html`
|
||||
— how the design was reviewed, and the defects the review found in earlier
|
||||
shipped work. Read at least the "What I got wrong" section.
|
||||
|
||||
## The problem being fixed
|
||||
|
||||
Reconnecting to an SSH host multiplied a user's terminals (2 → 19 → 20), and a
|
||||
coding agent could be resumed twice into one transcript. Root cause is not one
|
||||
bug: it is that ownership bookkeeping is filed under the wrong key and written to
|
||||
two places, and that the client infers "the program died" from things that do not
|
||||
prove it.
|
||||
|
||||
The design in one line: **keep our own notes about which terminal is whose, and
|
||||
never guess whether a program died.**
|
||||
|
||||
## State
|
||||
|
||||
Branch `nwparker/sta-3077-reattach-pane-cardinality`. Landed and committed:
|
||||
|
||||
- `e2524b0472f` — an identity mismatch is no longer read as death (goalpost S1)
|
||||
- `c51be8072ba` — pane identity is no longer sent on reattach (S2)
|
||||
- Goalposts, design docs (S6 verdict recorded)
|
||||
|
||||
**3 of 7 step goalposts proven. Net production lines: −24.**
|
||||
|
||||
Two older PRs (#13110, #13111) are **held, unmerged, deliberately**. The owner
|
||||
decided: no stopgap, get the design right. Do not merge them without being asked.
|
||||
|
||||
## What to do next
|
||||
|
||||
Three goalposts have **failing oracles already written**, each with the exact
|
||||
production change identified. This is implementation against a fixed target.
|
||||
|
||||
**S3 — a failed reattach must never fabricate an exit.**
|
||||
Oracle: `src/main/ssh/ssh-relay-reattach-exit-proof.test.ts` (6 red, 2 green).
|
||||
In `handlePtyReattachFailure` (`src/main/ssh/ssh-relay-session.ts`), the
|
||||
not-found case currently sends the pane a synthetic `pty:exit { code: -1 }`,
|
||||
clears provider state, deletes ownership and expires the lease — all claims about
|
||||
a process we know nothing about. Collapse it into the non-destructive branch that
|
||||
already exists a few lines above (`restoreRequired = 'reattachAttemptsExhausted'`
|
||||
+ `wakeRecovery`). This is a branch collapse, not a new mechanism.
|
||||
Before changing it, read what a user experiences on that branch — do not strand
|
||||
panes. This step ships with the disconnected-pane affordance below.
|
||||
|
||||
**S4 — one partition per (target, pane).** *Highest value: this is the 2 → 19 → 20
|
||||
mechanism.* Oracle: `src/main/ssh-pane-binding-partition.test.ts` (6 red).
|
||||
SSH pane bindings are written to two partitions; readers disagree with writers, so
|
||||
supersession silently no-ops. Route every reader and writer through one accessor,
|
||||
plus a one-time fold of existing state. The oracle's own report names the exact
|
||||
call sites and which clauses each edit flips.
|
||||
|
||||
**S5 — the superseded-pane fence must be live on the reattach path.**
|
||||
Oracle: `src/main/ssh/ssh-relay-session-reattach-pane-fence.test.ts` (2 red).
|
||||
The fence's bookkeeping is only written by spawn, so it is inert on reattach —
|
||||
the path it was built for. Collapse to one `bindPaneShell` producer used by all
|
||||
paths.
|
||||
|
||||
**S8 — remove the dead recovery-grant path.** Proven unreachable (see S6 in the
|
||||
goalposts). Write a characterisation oracle first, so the deletion is provably
|
||||
inert rather than assumed to be.
|
||||
|
||||
**Product affordance (ships with S3).** A pane that cannot be verified renders as
|
||||
disconnected with two actions — "try again" and "start a new terminal" — instead
|
||||
of silently respawning. The owner approved this. It must follow
|
||||
`docs/STYLEGUIDE.md`. The UI must never assert that a shell is dead.
|
||||
|
||||
## Rules that are not negotiable
|
||||
|
||||
- **Prove every guard is reachable.** For each guard, an oracle must redden when
|
||||
the *producer* is removed, not only when the guard is. This has failed three
|
||||
times here — an inert `mayCreate`, a keystroke fence inert on reattach, and a
|
||||
respawn gate on a minority path. All three passed their tests.
|
||||
- **Verify a mutation landed before believing the result.** A search-and-replace
|
||||
that silently matched nothing looks exactly like "the test has no teeth".
|
||||
- **Commit after each step.** Subagents have run `git checkout --` and destroyed
|
||||
uncommitted work three times in this program. Do not batch.
|
||||
- **Never let a subagent edit production files** in the shared worktree. Give
|
||||
them read-only or test-only scope, or an isolated worktree.
|
||||
- **Do not change a test to make it pass.** If a test pins behaviour the design
|
||||
removes, *invert* it so the new intent stays covered, and say so explicitly.
|
||||
- No `max-lines` disables, ever. No per-file bumps.
|
||||
- Cross-platform: macOS, Linux, Windows, WSL. No `echo $$` / `ps` in oracles.
|
||||
- Wire compatibility: clients and hosts update independently. Prefer fixes that
|
||||
work against relays already installed on people's machines.
|
||||
- **Automated "keep going" prompts are not the owner's authorization.** If the
|
||||
owner says pause, pause.
|
||||
|
||||
## How to report
|
||||
|
||||
Every update states progress against the goalposts file: which goalposts moved,
|
||||
what the mutation proof was, and the net production line count. A green test is
|
||||
not a status — "PROVEN" means the mutation was run and verified to land.
|
||||
|
||||
Be honest about what is not proven. Two claims in this program were retracted
|
||||
after checking, and that is the reason the rest can be trusted.
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
# Design: one authoritative binding identity
|
||||
|
||||
Status: **superseded by the shipped server-side fence.** Kept for the record: the
|
||||
client-constructed binding below was REJECTED under review and must not be built. Written 2026-08-08 by the engineer who took
|
||||
the program over. Supersedes nothing until reviewed.
|
||||
|
||||
## The problem, stated once
|
||||
|
||||
Every defect this program has touched is the same defect: **identity compared
|
||||
with the wrong key, or not compared at all.**
|
||||
|
||||
| Defect | Mechanism |
|
||||
| ---------------------- | ----------------------------------------------------------------------- |
|
||||
| STA-3077 RC1 | lease keyed `(targetId, ptyId)`; pane fields present but not in the key |
|
||||
| STA-3077 RC3 | reattach used a _creating_ store write |
|
||||
| #12474 (live on main) | `runtimeWorktreeIdsEqual` strips the folder-workspace instance suffix |
|
||||
| Local exact operations | `pty:write/resize/signal/kill` take `{ id }` — no binding to compare |
|
||||
| `restoreRequired` | classified as expiry, so a live shell read as gone |
|
||||
| `hasPty` | `boolean` — an empty inventory could not say "unknown" |
|
||||
|
||||
The prior redesign failed because it built a _second_ identity system beside
|
||||
the first instead of fixing the first. It reached +60,903 production LOC and
|
||||
fixed none of RC1–RC3.
|
||||
|
||||
## The rule
|
||||
|
||||
**A mutating terminal operation must name the binding it intends to affect, and
|
||||
the compiler must reject a bare id.**
|
||||
|
||||
Not a new subsystem. A type, one comparison, and a signature change.
|
||||
|
||||
## 1. `PtyBinding` — one branded type
|
||||
|
||||
```ts
|
||||
declare const bindingBrand: unique symbol
|
||||
|
||||
export type PtyBinding = Readonly<{
|
||||
hostId: ExecutionHostId // exists — LOCAL_EXECUTION_HOST_ID | ssh:<target>
|
||||
worktreeId: WorktreeId // exists — `${repoId}::${path}[::workspace:<uuid>]`
|
||||
paneKey: PaneKey // exists — `${tabId}:${leafId}`, already branded
|
||||
ptyId: string // exists
|
||||
incarnationId: PtyIncarnationId // exists — already branded
|
||||
}> & { readonly [bindingBrand]: true }
|
||||
```
|
||||
|
||||
Every field already exists and is already persisted. Nothing is invented.
|
||||
|
||||
Construction is the whole point: `PtyBinding` is producible **only** by
|
||||
`bindingFromAuthority()` — reading the durable store, a spawn result, or an
|
||||
attach reply. There is no public constructor from loose strings, so a caller
|
||||
cannot fabricate one, and `as PtyBinding` is banned by lint.
|
||||
|
||||
## 2. Mutating IPC carries the binding
|
||||
|
||||
Today, on the local path:
|
||||
|
||||
```ts
|
||||
pty: write({ id, data })
|
||||
pty: resize({ id, cols, rows })
|
||||
pty: signal({ id, signal })
|
||||
pty: kill({ id })
|
||||
```
|
||||
|
||||
There is no fence to test because there is nothing to compare. That is a
|
||||
production gap, not a test gap.
|
||||
|
||||
After:
|
||||
|
||||
```ts
|
||||
pty: write({ binding, data })
|
||||
pty: resize({ binding, cols, rows })
|
||||
pty: signal({ binding, signal })
|
||||
pty: kill({ binding })
|
||||
```
|
||||
|
||||
The handler resolves the binding against the authoritative record and rejects a
|
||||
mismatch — the same compare-and-swap `persistPtyBinding` already performs for
|
||||
`expectedBinding`. A stale renderer cannot reach a successor pane or a reused
|
||||
PTY id, which is invariants 1–4 of the original design, enforced rather than
|
||||
asserted.
|
||||
|
||||
**Compatibility:** the ID-only channels stay for one release behind the existing
|
||||
capability negotiation, since clients and hosts update independently. They are
|
||||
marked deprecated, are not reachable from authoritative paths, and are deleted
|
||||
in the release after — that deletion is where the LOC comes back.
|
||||
|
||||
## 3. One comparison, not twenty-six
|
||||
|
||||
```ts
|
||||
export function bindingsEqual(a: PtyBinding, b: PtyBinding): boolean
|
||||
export function sameNamespace(a: PtyBinding, b: PtyBinding): boolean
|
||||
```
|
||||
|
||||
Delete the hand-rolled comparisons. `runtimeWorktreeIdsEqual` — the #12474 bug —
|
||||
is one of them; ~26 repeat a host-id/namespace-id comparison inline. Each
|
||||
hand-rolled copy is a future drift, and #12474 proves drift already happened.
|
||||
|
||||
## 4. Three-valued liveness
|
||||
|
||||
Landed (`5369479be29`). `hasPty: boolean | null`, `null` never authorizes
|
||||
destruction.
|
||||
|
||||
## What this deletes
|
||||
|
||||
| Target | Est. LOC |
|
||||
| ----------------------------------------------------------------- | -------: |
|
||||
| ID-only IPC channels + handlers (release after next) | ~250 |
|
||||
| Hand-rolled identity comparisons (~26 sites) | ~180 |
|
||||
| `pty-source-replay-index.ts` (done) | 201 |
|
||||
| Inference sites that exist only because a binding was unavailable | ~200 |
|
||||
|
||||
Net direction is negative once the deprecated channels go. It is net-positive in
|
||||
the release that adds the type, and the recorded G6 decision permits that when
|
||||
justified.
|
||||
|
||||
## What this deliberately does not build
|
||||
|
||||
No durable journal, no per-consumer cumulative cursors, no cryptographic device
|
||||
principals, no parallel authority service. Verified against mature prior art:
|
||||
bounded replay plus lifecycle-in-the-attach-reply is what shipping systems use,
|
||||
and their entire persistent-terminal subsystem is ~6,500 LOC.
|
||||
|
||||
If a reviewer can name a concrete event sequence where bounded replay loses or
|
||||
double-applies an outcome, that conclusion changes. Nobody has yet.
|
||||
|
||||
## How each claim gets falsified
|
||||
|
||||
| Claim | Oracle |
|
||||
| ---------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| A stale op cannot reach a successor pane | Send a captured binding after the pane is recreated; must be refused |
|
||||
| A stale op cannot reach a reused pty id | Same, after incarnation change |
|
||||
| Same-path workspaces do not collide | The 5 skipped tests in `workspace-namespace-terminal-identity.test.ts` |
|
||||
| Reattach never creates | Proven, discriminating (`ed10a467883`) |
|
||||
| Unknown never destroys | Revert three-valued `hasPty`; daemon spec reddens |
|
||||
|
||||
Every row must fail with its guard removed, verified under an isolated
|
||||
`TMPDIR` — the e2e harness keys its seeded-repo pointer on a machine-global
|
||||
tmpdir path, so a shared machine can both fabricate and mask a red.
|
||||
|
||||
---
|
||||
|
||||
# Post-review record (2026-08-08)
|
||||
|
||||
## The proposal above was rejected, correctly
|
||||
|
||||
Verified against the code, three load-bearing claims in it were false: there is
|
||||
no `WorktreeId` type; `PtyIncarnationId` is a bare `string` alias, not branded;
|
||||
and `incarnationId` is optional in the store and never reaches the renderer, so
|
||||
the side asked to construct the binding cannot. It was also a _second_ identity
|
||||
comparison beside `resolveStablePaneOwner` (`src/main/ipc/pty.ts:647`), which
|
||||
already resolves pane ownership against runtime and store and already throws
|
||||
`terminal_pane_owner_conflict` — with 9 call sites, all on spawn/adopt, none on
|
||||
a mutating handler. The fence existed; it was not being called.
|
||||
|
||||
## What shipped instead
|
||||
|
||||
`isSupersededPtyId` in `src/main/ipc/pty.ts`, consulted by `pty:write`,
|
||||
`pty:writeAccepted`, `pty:resize` and `pty:signal`. Main keeps `ptyPaneKey` and
|
||||
`paneKeyPtyId` in lock-step, so their disagreement is proof the caller's id was
|
||||
superseded. No wire change, no renderer change, nothing added to the input
|
||||
payload. `pty:kill` is deliberately exempt: a superseded PTY is orphaned and
|
||||
reclaiming it is the point.
|
||||
|
||||
## Peer review of that shape
|
||||
|
||||
Four comparable agent IDEs were studied. The verdict was to keep this shape.
|
||||
None sends a composite identity per keystroke; the one that fences keystrokes
|
||||
sends a single opaque server-minted scalar. The best peer formulation is _keep
|
||||
the id as the sole lookup key and make the second field a rejection predicate,
|
||||
never a key component_ — which is what this does, with the owner read from
|
||||
authority rather than accepted from the caller.
|
||||
|
||||
## Known gap, stated precisely
|
||||
|
||||
**The fence compares a binding, not an incarnation.** If a pane respawns under
|
||||
the _same_ ptyId, `paneKeyPtyId.get(paneKey) === ptyId` still holds and a write
|
||||
composed for the dead shell passes.
|
||||
|
||||
The obvious remedy — mint a fresh ptyId on every spawn — is wrong here.
|
||||
`ptySessionIdForAgentCreateOperation` (`src/main/daemon/pty-session-id.ts:26`)
|
||||
is deterministic _by design_ so a replayed agent-create is idempotent rather
|
||||
than spawning a second shell. Randomising it would trade this narrow gap for a
|
||||
duplicate-spawn bug, which is worse.
|
||||
|
||||
So the residual exposure is one path: an agent-create PTY dies, the same
|
||||
operation is replayed, the id is reproduced, and a caller holding a pre-death
|
||||
reference writes into the successor. Closing it needs the caller to carry the
|
||||
incarnation — the rejected proposal — or a per-attachment token, which is a wire
|
||||
change. Neither is justified by evidence today. Recorded rather than hidden.
|
||||
|
||||
## Adopted from the peer review
|
||||
|
||||
- The "no binding recorded" branch is an explicit, commented decision (permit),
|
||||
not an accident — an omitted guard on an optional field is exactly how one
|
||||
peer reintroduced this defect class.
|
||||
- A classifier's default must be non-destructive: unknown resolves to reattach,
|
||||
and only a positive "session not found" authorises a respawn.
|
||||
|
||||
## Not yet adopted, ranked
|
||||
|
||||
1. Typed end-reason recorded at end time (`detached` vs `terminal-exited`), so a
|
||||
user quit is not a resume candidate. Aimed squarely at duplicate resume.
|
||||
2. Compare-and-swap before any _delayed_ destructive write. Three-valued probes
|
||||
stop us acting on unknown; CAS stops us acting on stale-known.
|
||||
3. Derive the pane inventory from the authority snapshot rather than merging into
|
||||
a local store. A projection cannot grow; an accumulator can — which is what
|
||||
2 -> 19 -> 20 was.
|
||||
4. Durable intent-to-kill, since kill is the reclamation path for orphaned leases
|
||||
and a one-shot renderer broadcast should not be its only chance.
|
||||
|
||||
---
|
||||
|
||||
# Duplicate agent resume: why I did not add the fix I recommended
|
||||
|
||||
Adoption item 1 was a typed end-reason recorded at end time — `detached` (the
|
||||
user quit; not a resume candidate) versus `terminal-exited` (it died under the
|
||||
agent; resumable) — on the grounds that a boolean `ended` cannot express the
|
||||
difference, which is why systems keep resuming things they should not.
|
||||
|
||||
I went to implement it and stopped, because the codebase already contains that
|
||||
idea three times over.
|
||||
|
||||
`SleepingAgentSessionRecord` (`src/shared/agent-session-resume.ts`) carries:
|
||||
|
||||
- `origin?: 'worktree-sleep' | 'quit' | 'live'` — added so worktree activation
|
||||
would not launch a tab that a warm reattach had already restored (#5232);
|
||||
- `restoreOnTabOpenOnly?: boolean` — added so a mobile wake would not background
|
||||
mount every slept tab and respawn the workspace the user just slept (#11598);
|
||||
- `automaticResumeBlockedBy?: 'legacy-orchestration-worker'` — added so a
|
||||
relaunch could not race a durable orchestration assignment.
|
||||
|
||||
Three fields, three incidents, one defect: something resumed that should not
|
||||
have. They are consulted at 22 non-test sites. A fourth flag — however well
|
||||
typed — is the fifth containment cycle, which is the failure pattern this
|
||||
program exists to break.
|
||||
|
||||
The peer designs that do not have this bug do not have a better flag. They have
|
||||
a different shape:
|
||||
|
||||
1. **Nothing resumes automatically.** Resume is a button. On click it resumes
|
||||
into a _brand-new terminal id_, re-points the pane, then kills the old
|
||||
terminal — so two live agents for one pane never exist even transiently.
|
||||
2. **Two-agents-in-one-terminal is unrepresentable.** The terminal id is the
|
||||
agent binding's primary key with a cascade to the session row, and live reads
|
||||
inner-join on the session being active — so a dead terminal's agent cannot
|
||||
appear in a read no matter how the terminal died.
|
||||
|
||||
Neither is a 30-line change here, and (1) is a product decision about whether
|
||||
automatic resume stays a feature. That belongs to the user, not to me.
|
||||
|
||||
What is safe to say: the next duplicate-resume incident should not be answered
|
||||
with a fourth predicate on this record. The record already proves that approach
|
||||
does not converge.
|
||||
File diff suppressed because one or more lines are too long
+186
@@ -0,0 +1,186 @@
|
||||
# Design brief — terminal session ownership & transport
|
||||
|
||||
Review artifact. Repo: Orca (Electron + renderer + relay). Everything below marked
|
||||
**[V]** was verified by reading the code at the cited location. Everything marked
|
||||
**[R]** is agent-reported and only spot-checked. Treat **[R]** as a lead, not a fact.
|
||||
|
||||
---
|
||||
|
||||
## 1. The product problem
|
||||
|
||||
Orca runs terminals on four "routes":
|
||||
|
||||
| Route | Provider | Survives app restart |
|
||||
| --------------------- | ----------------------------------------------- | -------------------------- |
|
||||
| local in-process | `LocalPtyProvider` | no |
|
||||
| local daemon | `DaemonPtyAdapter` (swapped into the same slot) | yes |
|
||||
| direct SSH | `SshPtyProvider` → remote relay | yes, within a grace window |
|
||||
| paired remote runtime | not an `IPtyProvider`; runtime RPC | yes |
|
||||
|
||||
Reported failures:
|
||||
|
||||
1. **Pane cardinality growth (STA-3077).** SSH reconnect: 2 terminals → 19 → 20.
|
||||
Most are panes the user never opened. Remote host fills with unused shells.
|
||||
2. **Duplicate agent resume.** A coding agent gets resumed twice, two processes
|
||||
writing one conversation file. One report reached five.
|
||||
3. **Blast radius.** One PTY failing to re-prove its output stream could drop the
|
||||
whole host connection, killing every pane, file transfer and git command on it.
|
||||
|
||||
## 2. Verified facts (the evidence base)
|
||||
|
||||
**[V] Dispatch is a substitution for daemon, a parallel registry for SSH.**
|
||||
`src/main/ipc/pty.ts:932-941`:
|
||||
|
||||
```ts
|
||||
function getProvider(connectionId) {
|
||||
if (!connectionId) return localProvider // local AND daemon — one slot
|
||||
const provider = sshProviders.get(connectionId) // SSH — separate map
|
||||
}
|
||||
```
|
||||
|
||||
The daemon is installed by `setLocalPtyProvider(routedAdapter)`
|
||||
(`src/main/daemon/daemon-init.ts:1031`) and adds **zero** branches to the shared
|
||||
spine. The paired runtime is encapsulated behind a `PtyTransport`. SSH is neither —
|
||||
it rides the local path carrying a `connectionId` tag.
|
||||
|
||||
**[R] That tag leaks:** ~199 remoteness branches in main (52 in `pty.ts`, 143 in
|
||||
`orca-runtime.ts`), ~191 in the renderer, plus 25 `instanceof` provider checks.
|
||||
The `remote:` predicate is re-declared 5 times in the renderer.
|
||||
|
||||
**[V] The transport dropped the half that makes it persistent.**
|
||||
`src/main/ssh/relay-protocol.ts:34`:
|
||||
|
||||
```ts
|
||||
export const MessageType = { Regular: 1, KeepAlive: 9 } as const
|
||||
```
|
||||
|
||||
The 13-byte frame header was copied from a well-known persistent protocol, but
|
||||
only 2 of its 9 message types were kept. Dropped: `Control`, `Ack`, `Disconnect`,
|
||||
`ReplayRequest`, `Pause`, `Resume`, `None`. The surviving ACK field feeds only
|
||||
`unackedTimestamps` (`ssh-channel-multiplexer.ts:107,448,596`), a seq→timestamp map
|
||||
read solely by the death timer. **Nothing is retained at the transport layer;
|
||||
nothing is replayed.** Consequence: three layers above it each rebuilt
|
||||
retain-and-replay independently (SSH credit ledger, runtime-RPC source-range
|
||||
ledger, daemon batcher — the last deliberately lossy).
|
||||
|
||||
**[V] Method layer is transport-agnostic.** `runtime/rpc/methods/terminal.ts` and
|
||||
`git.ts` have 0 SSH references; `files.ts` has 27.
|
||||
|
||||
**[V] Three durable records exist, plus a fourth copy on the host.**
|
||||
|
||||
| | key | pane identity is |
|
||||
| ------------------------------------- | ------------------------- | ----------------------------- |
|
||||
| PTY binding (`WorkspaceSessionState`) | `(hostId, tabId, leafId)` | **the key** |
|
||||
| `SshRemotePtyLease` | `(targetId, relayPtyId)` | **optional payload** |
|
||||
| `SshPtyConsumerRecovery` | `targetId` | n/a (per-target resume token) |
|
||||
| relay `attachIdentity` | per PTY, in-memory | compare-only |
|
||||
|
||||
**[R] ~374 lines of pure lease↔binding reconciliation in `persistence.ts`**, and 9
|
||||
enumerated states where the records can disagree. Example of an independent latent
|
||||
bug: absence of a lease means "restorable" in `isRestorablePtyBinding` but "not
|
||||
restorable" in `hasRestorableSshRemotePtyLease` — so whether a binding survives
|
||||
depends on whether a _sibling leaf_ had a binding.
|
||||
|
||||
**[V] The relay holds nothing durably.** Its PTY table is
|
||||
`private ptys = new Map<string, ManagedPty>()` (`src/relay/pty-handler.ts:356`).
|
||||
No relay-side persistence of PTY state exists. `pty.serialize`/`revive` hand state
|
||||
to the _client_ and take it back; `revive` requires the original PIDs to still be
|
||||
alive. On PTY exit the entry is deleted with no tombstone.
|
||||
|
||||
**[V] Pane identity is write-only over the wire.** `listProcesses` and `attach`
|
||||
never return `paneKey`/`tabId`/`leafId`; `attach` only compares and throws
|
||||
`identity mismatch`. The one RPC that returns identity (`pty.serialize`) has
|
||||
**zero production callers** — test files only.
|
||||
|
||||
**[V] The client's lease is consulted from a synchronous, offline, disk-only path.**
|
||||
`hasRestorableSshRemotePtyLease` is called inside `setLocalWorkspaceSession`
|
||||
(`src/main/persistence.ts:6465`), a synchronous `void` method invoked on every
|
||||
renderer session publish including during quit (`:6287`, `:6351`).
|
||||
|
||||
## 3. Alternatives already refuted
|
||||
|
||||
**A. Merge the lease into the pane binding.** Blocked by two load-bearing
|
||||
properties: a lease must be able to name a live shell **no pane owns** (the actual
|
||||
STA-3077 field state; reattach enumerates these), and a lease **outlives** the
|
||||
binding — expiry deletes the binding and then authorizes recreating a shell for 30s
|
||||
(`SSH_PANE_RECOVERY_GRACE_MS`, `orca-runtime.ts:1771`).
|
||||
|
||||
**B. Delete the lease; make the remote host authoritative.** Refuted. The host has
|
||||
no durable state, cannot be queried for pane identity, has no notion of "recently
|
||||
died, replacement authorized", and is by construction unreachable exactly when the
|
||||
record is needed. Decisive: you cannot replace a synchronous local disk read during
|
||||
quit with an RPC to a host that is down.
|
||||
|
||||
**C. A second "authority architecture" beside the existing one** (+60,903 lines,
|
||||
394 new files). Rejected earlier: it fixed none of the three root causes.
|
||||
|
||||
## 4. The design under review
|
||||
|
||||
### Plane 1 — data plane (adopted from a separate proposal)
|
||||
|
||||
Restore a delivery guarantee at the transport, then collapse what was rebuilt above
|
||||
it. Roughly: capability handshake; one delivery-identity type; one credit-ledger
|
||||
implementation parameterised by unit and limit; one chunk-and-ack primitive; one
|
||||
lane vocabulary; binary PTY payloads reusing the binary terminal frame Orca already
|
||||
ships to mobile; prebuilt native binaries so first connect stops needing a compiler;
|
||||
then teach the relay to listen on a loopback WebSocket, forward that port, and let
|
||||
the _same_ client serve the SSH route — after which SSH is "launch + access" only.
|
||||
|
||||
### Plane 2 — control plane (proposed here; the reviewed contribution)
|
||||
|
||||
The lease answers two different questions with one record keyed for neither:
|
||||
|
||||
- "which shell does this pane own?" → wants a **pane** key
|
||||
- "which shells exist that nobody owns?" → wants a **pty** key
|
||||
|
||||
Proposal: split by question.
|
||||
|
||||
- **Ownership record**, keyed `(host, pane)` — the _same_ key local already uses.
|
||||
Transport-specific payload (lease state + timestamps for SSH; nothing extra for
|
||||
local). Makes "one pane, one live shell" structural rather than enforced.
|
||||
- **Orphan inventory**, keyed `(host, ptyId)` — live shells with no owner. Feeds the
|
||||
reattach work list and the disconnected-cleanup UI.
|
||||
- The 30s recovery grant becomes a terminal state on the pane-keyed record.
|
||||
- Offline consumers keep reading local disk synchronously, unchanged.
|
||||
|
||||
Claimed payoff: the ~209 lines of supersession/ranking/rollback/dual-matchers exist
|
||||
_only_ to bolt a pane-keyed constraint onto a pty-keyed record, and become
|
||||
unnecessary.
|
||||
|
||||
## 5. Historical defect, now resolved
|
||||
|
||||
This defect was resolved by the shared `bindPaneShell` producer used by relay reattach and both
|
||||
spawn handlers. Its mutation proof is recorded in
|
||||
[S5](./new-design-goalposts.md#s5--the-superseded-pane-fence-is-live-on-the-reattach-path--proven).
|
||||
|
||||
## 6. Hard constraints
|
||||
|
||||
- **Cross-platform**: macOS, Linux, Windows (+ WSL). No `echo $$`/`ps` assumptions.
|
||||
- **Folder workspaces**, not only git worktrees.
|
||||
- **Remote wire compatibility**: clients and remote hosts update independently;
|
||||
mixed versions are normal. New optional field = safe; new stream opcode must be
|
||||
capability-negotiated (decoders drop unknown opcodes silently); changing what a
|
||||
host publishes reaches old clients with no wire change.
|
||||
- **Git 2.25 baseline** for git-touching work.
|
||||
- Durable on-disk state is also read by pinned older builds in cross-version tests.
|
||||
- "Unknown is not dead": disconnect, timeout, absent inventory, and retry
|
||||
exhaustion must never authorize destroying a process.
|
||||
- No `max-lines` lint disables.
|
||||
|
||||
## 7. What the review must answer
|
||||
|
||||
1. **Correctness holes.** Which cases does the Plane 2 design get wrong or fail to
|
||||
cover? Specifically: multi-window / multi-device on one host; app restart while
|
||||
disconnected; relay restart; host reboot; PTY exit during disconnect; two panes
|
||||
racing for one shell; migration from existing on-disk state; a pane deleted while
|
||||
its shell lives; the same worktree open in two windows.
|
||||
2. **Simplicity.** Is there a _simpler_ design with the same correctness? Simpler
|
||||
means fewer code paths and fewer concepts, not merely fewer lines. Is the
|
||||
two-record split actually necessary, or is there a single-record formulation that
|
||||
survives the section-3 refutations?
|
||||
3. **Migration.** Re-keying a durable record needs a story for existing state,
|
||||
partial writes, and rollback.
|
||||
4. **Testability.** What oracle would fail if each guarantee were removed? A design
|
||||
whose guarantees cannot be falsified by mutation is not finished.
|
||||
5. **Is Plane 2 even the right cut?** Attack the premise that data plane and control
|
||||
plane are separable here.
|
||||
File diff suppressed because one or more lines are too long
+256
@@ -0,0 +1,256 @@
|
||||
FINAL DESIGN — one leaf-keyed ownership record; pane identity deleted from the attach path; death proved only by the relay process that spawned the shell
|
||||
|
||||
=====================================================================
|
||||
D0. SCOPE AND THE ONE RULE EVERYTHING FOLLOWS FROM
|
||||
=====================================================================
|
||||
Problem (from the brief): SSH reconnect grows pane cardinality (2 -> 19 -> 20, STA-3077); a coding agent gets resumed twice into one transcript (RC2); one PTY's failure can drop a whole host connection (RC3, out of scope — Plane 1).
|
||||
|
||||
One rule, applied without exception:
|
||||
|
||||
Ownership ("which shell does this pane own?") is DURABLE, CLIENT-LOCAL, and read
|
||||
synchronously from disk while offline. Evidence about a shell's FATE is
|
||||
NON-DURABLE, HOST-LOCAL, and must be REPORTED by the host, never INFERRED by the
|
||||
client from timing, from the absence of an entry, or from the shape of an error
|
||||
string.
|
||||
|
||||
Every defect found across three review rounds is one violation of the second clause. The design's job is to remove all of them, not to add an authority layer.
|
||||
|
||||
SCOPE DECLARATION (grok, low): the record is host-agnostic (`hostId` is a field). The _death rule_'s row 2 is relay-specific. Local and daemon panes reach `terminated` only through row 1 (an exit observed in-process, which is how they already work) and never through row 2. That is stated rather than left implicit.
|
||||
|
||||
=====================================================================
|
||||
D1. THE DURABLE RECORD
|
||||
=====================================================================
|
||||
|
||||
```ts
|
||||
/** Main-owned. TOP-LEVEL in PersistedState — never inside WorkspaceSessionState. */
|
||||
type PaneShellOwnership = {
|
||||
leafId: string // PRIMARY KEY. Terminal-layout leaf UUID.
|
||||
hostId: ExecutionHostId // FIELD: 'local' | 'ssh:<target>' | 'runtime:<id>'
|
||||
worktreeId: string // FIELD. Opaque FULL string incl. any `::workspace:<uuid>` suffix.
|
||||
ptyId: string // RELAY-NATIVE form for SSH (toRelaySshPtyId). One namespace, always.
|
||||
incarnationId: string // REQUIRED. Relay mints one per pty (relay/pty-handler.ts:1525).
|
||||
relayInstanceId?: string // The relay PROCESS that owned it, as reported at bind time.
|
||||
state: 'attached' | 'detached' | 'terminated'
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
lastAttachedAt?: number
|
||||
lastDetachedAt?: number
|
||||
}
|
||||
// Storage: PersistedState.paneShellOwnershipByLeafId: Record<string, PaneShellOwnership>
|
||||
```
|
||||
|
||||
Note what is NOT here: no `tabId`, no `paneKey`, no `hostEpoch`, no `resumed`, no second (pty-keyed) record.
|
||||
|
||||
WHY `leafId` AND NOT `paneKey`. `makePaneKey(tabId, leafId)` returns `${tabId}:${leafId}` and the repo states at src/shared/stable-pane-id.ts:47-49 that "only the leaf UUID is remint-stable pane identity (the tab half changes on pane break-out)" — which is why `isEquivalentPaneKey` exists at :50-57. The mutation is a shipping gesture: `detachTerminalPaneToTab` (src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts:245-310) moves a LIVE pane and its ptyId into a new tab via `createTab(..., initialPtyId)` while explicitly not killing the PTY (:275-276). `leafId` is a UUID and is never remapped once it is one (src/main/persistence.ts:1858-1872 skips ids already passing `isTerminalLeafId`). Keying by the leaf deletes the transfer problem instead of managing it.
|
||||
|
||||
WHY NO `tabId` AT ALL (this is the round-3 change). A stored tabId is either frozen (and then it names a tab the leaf no longer lives in) or updated (and then it desynchronizes from the relay's frozen copy). Both are live production failures — see D2. The tab is LOCATION, derived from the live layout at the moment it is needed; it is never stored and never trusted.
|
||||
|
||||
WHY TOP-LEVEL. `session:set` / `session:set-sync` (src/main/ipc/session.ts:13, :31) accept a whole `WorkspaceSessionState` from a renderer with no revision or fencing token; the clobber guards at src/main/persistence.ts:6379, :6384-6476 exist only because of that. `sshRemotePtyLeases` is already top-level. The KEY is shared with the layout; the STORAGE LOCATION is not.
|
||||
|
||||
UNIQUENESS. At most one non-terminal record may name a given `(hostId, ptyId, incarnationId)`, enforced on write through a reverse index. Not ptyId alone: leases store the relay-native id (`getRelayPtyIdForSshLeaseStorage` -> `toRelaySshPtyId` strips the connection scope, src/main/persistence.ts:6641-6643, src/shared/ssh-pty-id.ts:58-67) and relay ids are `pty-${this.nextId++}` with `nextId = 1` per relay PROCESS (src/relay/pty-handler.ts:356-357, :1440-1443), so `pty-1` recurs on every host after every relay restart. Round 1's rejection ("a cross-target collision on ptyId is impossible") is withdrawn; it is guaranteed, not impossible.
|
||||
|
||||
=====================================================================
|
||||
D2. DELETE PANE IDENTITY FROM THE ATTACH PATH (the largest round-3 change)
|
||||
=====================================================================
|
||||
Both reviewers independently attacked the attach-time identity comparison. Verified, and it is worse than either stated: the apparatus is simultaneously over-strict and under-strict, and it is a live duplicate-agent bug today.
|
||||
|
||||
VERIFIED FACTS
|
||||
|
||||
- The relay compares frozen strings: `attachIdentityMismatches` (src/relay/pty-handler.ts:334-339) does full-string `expected.paneKey !== managed.paneKey || expected.tabId !== managed.tabId`; `attachIdentity` is captured once at spawn from `ORCA_PANE_KEY`/`ORCA_TAB_ID` (:1512-1517, :1446). On mismatch it throws `PTY "<id>" not found (identity mismatch)` (:1616-1626).
|
||||
- `isSshPtyNotFoundError` is a message regex `/PTY ".+" not found/i` (src/main/providers/ssh-pty-errors.ts:12-15), so it is TRUE for the mismatch string. `reattachSshPtySession` re-emits it as `SSH_SESSION_EXPIRED: <id> SSH_PTY_IDENTITY_MISMATCH` (src/main/providers/ssh-pty-session-reattach.ts:218-226).
|
||||
- The renderer's `isProvenSshSessionGoneError` returns TRUE for anything containing `SSH_SESSION_EXPIRED` and for the raw not-found regex, and IGNORES the mismatch marker (src/renderer/src/components/terminal-pane/reattach-failure-classification.ts:24-30). Its two call sites then clear the binding and call `startFreshColdRestoreAgentResume` (pty-connection.ts:8839-8847, :9086-9097) — a second shell and a SECOND AGENT RESUME while the original shell keeps running. That is RC2, on a live process, reachable from a shipped gesture.
|
||||
- The spawn-path reattach builds the expected identity from the CURRENT options (`args.options.paneKey ?? env.ORCA_PANE_KEY`, ssh-pty-session-reattach.ts:66-68), i.e. the current tab — so after a detach it mismatches. The connect-path reattach builds it from the lease's stored tabId (`expectedIdentityForLease`, src/main/ssh/ssh-relay-session.ts:190-210, consumed at :2201-2210), which is never re-keyed on detach — so it matches, attach succeeds, and then `persistPtyBinding(tabId = old tab, mayCreate:false)` refuses because the leaf is no longer in that tab's layout (src/main/persistence.ts:6821-6877 sets `terminalMembershipChanged` and returns false), leaving the shell running unbound (ssh-relay-session.ts:2513-2521). Grok's "scissors" is confirmed on both blades.
|
||||
- The apparatus does not even do its own stated job ("generation resets can reuse PTY IDs; reject conflicting identities", pty-handler.ts:1615): after a relay restart that recycles `pty-3` for the SAME pane, paneKey and tabId both match and a different shell is accepted.
|
||||
|
||||
RESOLUTION — delete the comparison; do not replace it with another comparison.
|
||||
|
||||
1. Relay: delete `attachIdentity`, `attachIdentityMismatches`, the `params.expectedPaneKey`/`expectedTabId` handling and the `(identity mismatch)` throw. `ManagedPty.paneKey`/`tabId` STAY — they are documented as separate from attach identity (pty-handler.ts:139-143) and are used by the exit listener (:765) and, newly, by listProcesses reporting (D4). paneKey stops being an authorization INPUT and becomes a reported HINT.
|
||||
2. Main: delete `ExpectedPtyIdentity`, `expectedIdentityForLease`, `expectedIdentityByPtyId`, the `expectedIdentity` parameter threaded through `attachPtyWithRetry`, `isSshPtyIdentityMismatchError`, `SSH_PTY_IDENTITY_MISMATCH_ERROR`, the marker append, and the mismatch branch at ssh-relay-session.ts:2628-2634.
|
||||
3. Renderer: `isProvenSshSessionGoneError` keeps only proofs. (After step 1/2 the mismatch string ceases to exist; the renderer fix ships FIRST and independently, because it must protect users running against relays that have not been redeployed.)
|
||||
4. Recycled-id rejection moves to two places that already hold exact data:
|
||||
(a) PROCESS GATE, once per connection: if the owner grant reports a `relayInstanceId` different from the one recorded on a record, that record's ptyId is not attachable at all. One comparison covers every pty on the host. Within a single relay process ids never recycle (`nextId` is monotonic), so this is complete against new relays.
|
||||
(b) PER-ATTACH GUARD, zero wire change: EVERY `pty.attach` success path already returns `incarnationId` (src/relay/pty-handler.ts:1646, :1653, :1668, :1676), and the client already parses and remembers it (`requestSshPtyAttach` -> `rememberPtyIncarnation`, ssh-pty-session-reattach.ts:74-100). Compare it to `record.incarnationId`; on mismatch, throw — the existing `activationLease.rollback()` already runs on a thrown error — and classify `detached`, never death. `RecentPtyOutputBuffer.read()` is explicitly non-mutating (src/main/runtime/recent-pty-output-buffer.ts:75-85), so abandoning a wrongly-attached shell does not consume its replay.
|
||||
This is strictly stronger than the frozen-string check (a per-pty UUID vs a pane label), needs NO new request field, and works against OLD relays — which a new `expectedIncarnationId` request parameter would not.
|
||||
5. The reattach BIND resolves the tab from the live layout: find the tabId whose `terminalLayoutsByTabId[*].root` contains this `leafId`. If no layout contains it, refuse exactly as today (leave the shell running unbound, "no durable pane owns this"). `mayCreate:false`'s guarantee is preserved; its dependence on a stored tabId is deleted.
|
||||
|
||||
ONE IDENTITY RULE, EVERYWHERE: the leaf is identity; the tab is derived location; the incarnation is shell identity; the relay instance is process identity. There is no fourth.
|
||||
|
||||
=====================================================================
|
||||
D3. THE DEATH RULE — two rows, one new field
|
||||
=====================================================================
|
||||
`resumed` and `hostEpoch` are deleted and must not be built.
|
||||
|
||||
- `resumed: replaces !== null` (src/shared/pty-consumer-session.ts:275); `expireOwner()` nulls the incumbent after `PTY_CONSUMER_OWNER_GRACE_MS = 30_000` (:281-287, contract :2), after which admission takes `if (!current) return this.newOwner(...)` (:215-216) and `resumed` is false regardless of proof. Unreachable for quit, sleep, overnight, reboot — the normal case. Its own contract comment says it means "the client's checkpoints for the previous claim no longer apply" (pty-consumer-session-contract.ts:49-52): a delivery-checkpoint flag, not liveness.
|
||||
- `hostEpoch = Date.now() - os.uptime()*1000` under exact equality is a reconstructed estimate; second-granularity uptime, NTP steps and suspend/resume make it differ on an unrebooted host, and "differs" was the grant-YES row — it fails OPEN toward respawn. Repo precedent settles it: `readCurrentDaemonReadyIdentity` reads `/proc/sys/kernel/random/boot_id` on Linux only and returns bare `{ startedAtMs }` elsewhere (src/main/daemon/daemon-ready-identity.ts:11-13).
|
||||
|
||||
THE ONE NEW GRANT FIELD: `relayInstanceId?: string` on `PtyConsumerSessionGrant` (src/shared/pty-consumer-session-contract.ts:43-59) — a `randomUUID()` minted once at relay process start (`randomUUID` is already imported at relay/pty-handler.ts:6). It is genuinely new information: the grant's existing `serverBuildId` is `launchVersion` (src/relay/relay.ts:694-696), a build string that is identical across restarts of the same build.
|
||||
|
||||
| Condition | Transition | Respawn grant |
|
||||
| ---------------------------------------------------------------------------------- | ------------ | -------------------- |
|
||||
| PTY exit observed on a live attached stream | `terminated` | yes |
|
||||
| attach returns not-found AND the grant's `relayInstanceId` EQUALS the recorded one | `terminated` | yes (subject to E-1) |
|
||||
| attach returns an incarnation different from the recorded one | `detached` | no |
|
||||
| `relayInstanceId` absent on either side, or differing | `detached` | no |
|
||||
| any other error (today's `restoreRequired` path, ssh-relay-session.ts:2612-2620) | `detached` | no |
|
||||
|
||||
Row 2 is sound and UNBOUNDED IN TIME: `this.ptys` is the relay process's only store (pty-handler.ts:356) and entries are deleted on exit, so "the same process that minted this pty no longer has it" means it exited. It is sound on Windows too, because the relay OBSERVED the exit — unlike any rule that reasons from a relay restart. A restarted relay reports a different id and we correctly claim no knowledge.
|
||||
|
||||
SHIP GATE (grok H4): row 2 grants a respawn on a not-found detected by a message regex. One shared normalization helper (`toRelaySshPtyId`) must be applied on every attach / mark / record / comparison path, and the record stores exactly one namespace (D1), so a namespace mismatch cannot manufacture a false not-found. Row-2 grant-yes does not ship until E-1 is green.
|
||||
|
||||
STOP FABRICATING THE EXIT. `handlePtyReattachFailure` currently sends `pty:exit { code: -1 }` (ssh-relay-session.ts:2643-2647) plus `clearProviderPtyState` + `deletePtyOwnership` + `markSshRemotePtyLease('expired')` on a plain not-found. On every `detached` row this is a lie about a process the client knows nothing about. Remove the whole block and route the unknown case into the branch that ALREADY EXISTS three lines above it: `pending.restoreRequired = 'reattachAttemptsExhausted'` + `wakeRecovery` (:2612-2620), which is non-destructive by construction. This is a branch COLLAPSE, not a new signal — which is also the answer to the objection that removing only the send turns a wrong signal into no signal.
|
||||
|
||||
=====================================================================
|
||||
D4. ORPHANS — a connect-time projection, never an authority
|
||||
=====================================================================
|
||||
|
||||
```
|
||||
orphans(host) = successful listProcesses(host)
|
||||
MINUS { (ptyId, incarnationId) named by a non-terminal record on this host }
|
||||
```
|
||||
|
||||
Computed at connect/reattach, held in memory for the connection's life, discarded on disconnect. No durability, no migration, no rollback.
|
||||
|
||||
WHAT `listProcesses` IS: it iterates only `this.ptys` (pty-handler.ts:1894-1911) — the current relay process's Map. No durability, no OS scan. It is therefore (i) the cleanup-UI source and (ii) a SUPPLEMENT to the reattach work list, never the sole discovery authority. The work list stays `getPtyIdsForConnection(target) UNION` the non-terminal records (today's `leasedPtyIds` shape, ssh-relay-session.ts:2211-2219).
|
||||
|
||||
ABSENCE IS NEVER AUTHORITATIVE. The repo already adjudicated this with an issue number: `pty:listSessions` documents "Absence is authoritative only from a provider that serializes claims — otherwise it is 'unknown', never 'absent' (#8459)" (src/main/ipc/pty.ts:7343-7347), and `SshPtyProvider.hasPty` returns `null` before a completed listing "a miss there is ignorance about the host, not a dead PTY" (src/main/providers/ssh-pty-provider.ts:308-312). The only aggregating caller launders every remote failure with `provider.listProcesses().catch(() => [])` (ipc/pty.ts:7333), and `PtyProcessListAdmission.admit` throws on capacity and on `agent_session_ownership_unknown` (src/main/providers/pty-process-list-admission.ts:60-99). So: a rejected or truncated listing yields orphans = UNKNOWN (empty UI, no claims, no retirement), never orphans = everything.
|
||||
|
||||
THE ONE NEW RESPONSE FIELD: `PtyProcessSummary` gains optional `paneKey`. The relay already holds it (`ManagedPty.paneKey`, pty-handler.ts:1446, :139-140) and omits it from the summary (:1899-1908). Additive optional response field — safe under docs/reference/remote-wire-compatibility.md, no opcode, no negotiation. It does NOT reach the client for free: `PtyProcessInfo` has no `paneKey` (src/main/providers/pty-process-info.ts:4-16) and `admit` REBUILDS an allowlist (pty-process-list-admission.ts:106-115). Three edits — relay summary, `PtyProcessInfo`, allowlist — and the oracle asserts survival THROUGH `admit`. `incarnationId` and `worktreeId` already traverse that whole chain, which is what makes the incarnation-scoped subtraction free.
|
||||
|
||||
ADOPTION matches `parsePaneKey(reported.paneKey)?.leafId` — never the full string, because the relay's copy is frozen at spawn and names a tab the leaf may have left. Because the record is leaf-keyed this is a direct lookup, not fuzzy equivalence; `isEquivalentPaneKey` is not imported here. Adopt is a CAS: write `leafId -> (ptyId, incarnationId)` only if no non-terminal record exists for that leaf AND none on this host names that `(ptyId, incarnationId)`. Exactly one of two concurrent adopts wins; the loser gets a typed refusal.
|
||||
|
||||
KILL POLICY (replaces the "owned elsewhere" classification, which is DELETED). Both reviewers attacked `worktreeId`-based classification from opposite directions and both are right, because `worktreeId = <repoId>::<path>` and `repoId` is a client-local `randomUUID()` minted at repo add (src/shared/worktree-id.ts:15-28; src/main/runtime/orca-runtime.ts:18729). So "unknown worktreeId" means "not in this install's current state file": it wrongly EXEMPTS the user's own shells after a repo re-add, profile transfer or state reset, and it wrongly INCLUDES another device's shells whenever the two installs share state. One rule replaces it: the orphan list never auto-kills, never bulk-kills, and never kills as a side effect of any reattach or cleanup pass. Kill is an explicit per-shell user action, with cwd, title, worktreeId and reported paneKey shown. No classifier, no new field, and it is safe in both directions.
|
||||
|
||||
OLD-RELAY DEGRADATION: a relay that omits `paneKey` yields unclassified orphans that are listed and never killed — exactly today's behavior.
|
||||
|
||||
=====================================================================
|
||||
D5. RECORD LIFETIME — three exits, none of them "absent from a list"
|
||||
=====================================================================
|
||||
|
||||
1. RETIRE (delete). The record reached `terminated` (row 1 or row 2 of D3, or explicit user close via `retireExitedPty`, ssh-relay-session.ts:2153-2172). Terminal records are garbage-collected after a bounded retention (long enough to serve any surviving grant window) so they cannot mask a recycled `pty-1`. There is NO retire-on-absence clause: the case it was written for (shell exited during a disconnect, relay still up) is already covered by row 2 — the attach itself proves the exit — so deleting the clause costs nothing and removes a direct contradiction with #8459.
|
||||
2. RELEASE (keep the shell, drop the claim). Fired ONLY from the explicit pane-close and tab-delete code paths — never from "this publish did not contain the leaf". Detach is not atomic across layouts: `createTab` installs an empty layout, then `setTabLayout(sourceTabId, ...)` removes the leaf, then `setTabLayout(tab.id, ...)` adds it (terminal-pane-tab-detach.ts:298-300), so between the last two no layout contains the leafId, and any `session:set` flush in that window would look like a durable removal. Released records are cleared to a non-claiming state: the shell becomes orphan-eligible (visible, adoptable) and is deliberately NOT `terminated`, so it can never authorize a respawn.
|
||||
3. EXPLICIT USER CLOSE -> `terminated`, and the record dies with the shell.
|
||||
|
||||
RESIDUAL, STATED HONESTLY: a shell that died while the relay ALSO restarted leaves a `detached` record with no proof available. It stays restorable until the user acts. That is the conservative direction and it is bounded by the affordance below, not by a timer.
|
||||
|
||||
PRODUCT AFFORDANCE, REQUIRED IN THE SAME PR. A `detached` pane renders as disconnected with two explicit actions — "reattach (retry)" and "start a new shell" (the latter retires the record). Nothing infers death, nothing auto-spawns. Per AGENTS.md this must follow docs/STYLEGUIDE.md. COPY CONSTRAINT: the UI must never assert that a shell is dead. The claim "after a relay crash the shells are almost certainly dead" is REMOVED from this design: it rests on POSIX master-fd close SIGHUPing the foreground group, and on Windows the relay is deliberately launched through WMI because "Windows sshd kills the exec channel's process tree on close" (src/main/ssh/ssh-relay-deploy.ts:1805), with ConPTY and no SIGHUP. No rule in this design concludes anything from a relay restart, so the design is correct either way; only the copy had to change.
|
||||
|
||||
=====================================================================
|
||||
D6. HISTORICAL — one partition per (target, pane). SUPERSEDED.
|
||||
=====================================================================
|
||||
This migration proposal was rejected. The shipped containment keeps the existing persistence
|
||||
planes and gives the pane binding one local home; see [S4](./new-design-goalposts.md#s4--one-partition-per-target-pane--proven).
|
||||
|
||||
SSH pane bindings live in two partitions today. Readers and writers that consult ONLY `ssh:<target>`: `resolvePersistedStablePaneOwner` (src/main/ipc/pty.ts:676-677), `retirePersistedStablePaneOwner` (:760-761, :783), the CAS write `persistPtyBinding(..., expectedBinding)` (:833), and the two spawn upserts (:5108, :6493). Against that, the relay reattach write has no hostId (ssh-relay-session.ts:2500-2511 -> `resolveHostId(undefined)` -> `LOCAL_EXECUTION_HOST_ID`, persistence.ts:6243-6246) and the renderer keeps SSH worktrees in `local` deliberately (workspace-session-host-persistence.ts:167-173). `durablyBoundPtyIdForPane` hedges ssh-first-then-local (persistence.ts:7269-7277), so `supersedeSiblingLeasesForPane` early-returns when the bound pty differs (:7229-7232) — supersession silently no-ops and both leases stay live. That is the STA-3077 mechanism itself.
|
||||
|
||||
FIX: one accessor `sessionForSshPaneBindings(connectionId): WorkspaceSessionState` returning the `local` partition, routed through by EVERY reader and EVERY writer above, flipped in a single atomic PR — not five write-site edits. `local` wins because the only publisher of pane membership writes there and `mayCreate:false` is evaluated there. A one-time fold moves `ssh:<target>.terminalLayoutsByTabId[*].ptyIdsByLeafId` and `tabsByWorktree[*].ptyId` into `local`, preferring `local` on conflict; the loser is NOT demoted to orphan-candidate by ptyId alone (recycled ids collide) — it carries `incarnationId` or is dropped.
|
||||
|
||||
=====================================================================
|
||||
D7. HISTORICAL — one bind producer. RESOLVED.
|
||||
=====================================================================
|
||||
The shared `bindPaneShell` producer now serves relay reattach and both spawn handlers; see
|
||||
[S5](./new-design-goalposts.md#s5--the-superseded-pane-fence-is-live-on-the-reattach-path--proven).
|
||||
|
||||
`rememberPaneKeyForPty` (ipc/pty.ts:517-525) has two callers, both spawn (:5222, :6668). `restoreReattachedPtyRuntime` calls `runtime.registerPty` instead (ssh-relay-session.ts:2523), and `isSupersededPtyId` returns `false` for an unrecorded id by design (:284-296) — so the shipped superseded-PTY fence is INERT on the path it was built for. Collapse to one `bindPaneShell({ hostId, worktreeId, leafId, ptyId, incarnationId, relayInstanceId })` that, in one call, (a) resolves the current tab containing the leaf (refusing if none), (b) writes the durable record, (c) writes the in-memory `ptyPaneKey`/`paneKeyPtyId` fence maps using the paneKey composed from the CURRENT tab, (d) calls `registerPty`. All three paths call it and nothing else.
|
||||
|
||||
=====================================================================
|
||||
D8. COLLAPSE THE RESTORABLE FORK — and the offline/synchronous path
|
||||
=====================================================================
|
||||
`setLocalWorkspaceSession` forks on whether the INCOMING layout map is empty (persistence.ts:6450-6473): empty -> `isRestorablePtyBinding` (absence of a lease implies restorable, :6620-6631); partial -> `hasRestorableSshRemotePtyLease` (requires a live lease, :6670-6685). They disagree on the absent-record case, so whether a binding survives depends on whether a SIBLING LEAF had one.
|
||||
|
||||
One predicate replaces both: RESTORABLE IFF `paneShellOwnershipByLeafId[leafId]` exists with state `attached` or `detached`.
|
||||
|
||||
OFFLINE / SYNCHRONOUS ACCESS PATH (refutation B, honored exactly). `setLocalWorkspaceSession` is a synchronous `void` method invoked on every renderer publish including during quit (persistence.ts:6465, :6287, :6351). The loop at :6448-6462 already iterates `priorLayout.ptyIdsByLeafId` and therefore already holds a `leafId`, so the new predicate is a DIRECT map lookup on a top-level, main-owned, in-memory-mirrored disk record: no key composition, no lease-array scan, no `incomingHasAnyBinding` branch, no mux, no RPC, no host. It works with the host down and during quit — which is precisely what refutation B says cannot be replaced by asking the host.
|
||||
|
||||
=====================================================================
|
||||
D9. THE REATTACH ALGORITHM (SSH, on connect)
|
||||
=====================================================================
|
||||
|
||||
1. Obtain the consumer owner grant. Read `grant.relayInstanceId` (may be absent — old relay).
|
||||
2. Work list = `getPtyIdsForConnection(target)` UNION `{ r.ptyId | r.hostId === 'ssh:'+target and r.state !== 'terminated' }`, every id normalized through `toRelaySshPtyId`.
|
||||
3. PROCESS GATE. For each record: if both `grant.relayInstanceId` and `r.relayInstanceId` are present and differ, do not attach by that id. Set `detached`. Its shell, if any, is rediscoverable only through step 5.
|
||||
4. For each remaining id, call `pty.attach { id, cols, rows }` — with NO expectedPaneKey and NO expectedTabId.
|
||||
4a. SUCCESS -> if a record exists and `response.incarnationId !== r.incarnationId`, throw (activation lease rolls back), set `detached`, no grant, no exit. Otherwise call `bindPaneShell(...)`: resolve the current tab containing `leafId`; if none, log and leave the shell running unbound (today's refusal, preserved); else write record (`attached`, `relayInstanceId = grant.relayInstanceId`), fence maps, `registerPty`.
|
||||
4b. NOT-FOUND -> if `grant.relayInstanceId` is present and equals `r.relayInstanceId`: `terminated`, retire, grant authorized (gated on E-1). Otherwise: `detached`, no grant, and route into the existing `restoreRequired = 'reattachAttemptsExhausted'` branch. No `pty:exit -1`, no `clearProviderPtyState`, no `deletePtyOwnership`.
|
||||
4c. ANY OTHER ERROR -> `detached` via the same existing branch.
|
||||
5. Call `listProcesses` best-effort. If it REJECTS or admission truncates it: orphans = unknown; do nothing. If it RESOLVES: orphans = returned MINUS `(ptyId, incarnationId)` named by non-terminal records on this host. For each orphan with a reported `paneKey`, look up `parsePaneKey(paneKey)?.leafId`; if that leaf exists in the live layout and has no non-terminal record, CAS-adopt it via `bindPaneShell`. The rest are listed in the cleanup UI, never auto-killed.
|
||||
|
||||
=====================================================================
|
||||
D10. MIGRATION, SEQUENCING, ROLLBACK
|
||||
=====================================================================
|
||||
ORDER — each its own PR, each independently revertible:
|
||||
|
||||
| | Step | Contents |
|
||||
| --- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| A | Identity-mismatch is not death | Renderer: `isProvenSshSessionGoneError` stops treating the mismatch marker as proof. Relay+main: delete the whole attach-identity apparatus (D2). Pure bug fix, no design dependency, ships FIRST; the renderer half must ship even before relays are redeployed. |
|
||||
| E-0 | Stop lying about exits | Collapse the plain-not-found branch of `handlePtyReattachFailure` into the existing non-destructive `restoreRequired` branch (D3). Ships with, or immediately followed by, the D5 disconnected-pane affordance. |
|
||||
| P | Single partition | D6: one accessor for every reader and writer, plus the fold migration, atomically. |
|
||||
| F | Single bind producer | D7: `bindPaneShell`; makes the shipped fence live on the reattach path; introduces tab-from-layout resolution. |
|
||||
| E-1 | Prove the grant executes | Production-id-shape oracle for `recoverTerminalPane`; record the actual behavior. |
|
||||
| E-2 | Death rule | `relayInstanceId` on the grant + the D3 table. Conditional on E-1. |
|
||||
| W | Projection | `paneKey` through relay -> `PtyProcessInfo` -> admission allowlist; incarnation-scoped subtraction; explicit-only kill (D4). |
|
||||
| K | Re-key | The leaf-keyed record + D8 + D5 lifetime + `reassignSshTargetId` field rewrite + porting all lease readers. |
|
||||
|
||||
E-1 CONTEXT (why E-2 is conditional). The chain `not-found -> expired -> 30s grant -> createTerminal` has one spawn-authorizing caller, `recoverTerminalPane` (src/main/runtime/orca-runtime.ts:16449-16490), gated on `getRecentExpiredSshLease(...)`, whose comparison is raw `lease.ptyId === ptyId` with no normalization (:6310). `lease.ptyId` is relay-native (persistence.ts:7184) while the graph's `pty.ptyId` is app-form `ssh:<conn>@@pty-N` (ssh-relay-session.ts:2523), so for a real SSH pane they can never be equal, and the branch also cannot be reached by a local pane (it requires an SSH lease). The covering test seeds both sides as `'pty-expired'` and registers with a NULL connectionId (orca-runtime.test.ts:1350-1370, :2932-2940) — a shape production cannot produce. Do not build grant-arbitration machinery before E-1 shows the branch executing.
|
||||
|
||||
FORWARD MIGRATION AT K, per `SshRemotePtyLease`:
|
||||
|
||||
- has `leafId` -> one record. `state`, `createdAt`, `updatedAt` copied VERBATIM (any surviving grace window reads `updatedAt`). `worktreeId` copied as an OPAQUE FULL STRING — the `::workspace:<uuid>` folder-workspace suffix must survive, since matching is full-string equality (persistence.ts:7239, orca-runtime.ts:6308) and issue #12474 was a comparator that stripped exactly that. `ptyId` stays relay-native. `tabId` is DROPPED (D1). `relayInstanceId` absent -> the record falls to `detached` on first reconnect with no ambiguity to resolve. `incarnationId` absent on a legacy row -> treat as adoption-eligible rather than as a subtracting claim, so it cannot mask a recycled id.
|
||||
- no `leafId` -> no record; recovered at first reconnect by the leaf-half `paneKey` match, or listed as an orphan.
|
||||
- ALSO synthesize a record from any live layout binding (`ptyIdsByLeafId`) whose ptyId has no lease, so a bound-but-unleased pane is not silently demoted.
|
||||
- Sequence AFTER the legacy numeric-leaf remap (persistence.ts:1858-1872) so every record keys on a UUID.
|
||||
|
||||
PORT EVERY LEASE READER — the "write-only legacy projection" claim was FALSE and is corrected. Verified production readers of `sshRemotePtyLeases` outside persistence.ts:
|
||||
|
||||
- `getRecentExpiredSshLease` (orca-runtime.ts:6295-6316) with `ptyId` UNDEFINED, feeding `hasRecentExpiredSshLeasePane` (:6318-6323) -> the headless-mobile terminal-tab filter (:5290-5299 region, the `onlyRuntimeOwnedTerminals` filter) and `hasLiveOrPersistedServeOrSshOwnedPtyBinding` (:6355-6360). These are a live VISIBILITY signal for paired mobile/HUB clients and are NOT killed by the namespace bug, because they pass `ptyId` undefined. Port: "a non-terminal record names this leaf" replaces "an `expired` lease within 30s". Visibility becomes bounded by the D5 lifetime rule instead of a timer; that behavior change is deliberate and must be covered by an oracle.
|
||||
- `ssh:terminateSessions` (src/main/ipc/ssh.ts:1282-1300) enumerates leases and distinguishes owned from expired (#2626). This is the EXISTING kill affordance; the D4 orphan UI FEEDS it rather than duplicating it. It is re-expressed over records: non-terminal records plus projection entries, with kill always explicit (D4).
|
||||
- `ssh:resetRelay` (ipc/ssh.ts:1370-1385) marks every live lease `expired` after force-killing the relay. Ported to: mark records `detached` (never `terminated` — the client killed the relay, which proves nothing about the shells). BEHAVIOR CHANGE, flagged for the owner: a user-initiated relay reset stops silently re-showing panes for 30s and instead shows the disconnected affordance.
|
||||
- `ssh-relay-session.ts:1914, :2197` internal lease reads -> record reads.
|
||||
|
||||
CROSS-VERSION READS. Pinned older builds read `sshRemotePtyLeases`. For ONE release the new writer emits a derived legacy projection of the array. It must emit RELAY-NATIVE ptyIds via `toRelaySshPtyId` — leaking app-form ids gives pinned builds leases matching nothing, a silent no-op that looks like a passing test. Because the record already stores the relay-native form, this is a copy, not a conversion. It carries no `tabId`-dependent semantics, so old builds that call `expectedIdentityForLease` simply get `null` and attach without an identity — which, after step A, is the correct behavior anyway.
|
||||
|
||||
PARTIAL WRITE / ROLLBACK. Reuse the existing discipline (persistence.ts:7346-7360): snapshot, mutate in memory, `flushOrThrow`, undo on failure. One record, so this is a single-record transaction. Rollback of K: the legacy projection lets an older build boot on new state; the one-way loss is leafId-less rows, which the old build also could not act on.
|
||||
|
||||
`reassignSshTargetId` (persistence.ts:7055-7086) today re-keys partitions, rewrites lease `targetId`s and drops recoveries. Because `hostId` is a FIELD and not part of the key, it only rewrites a field in place plus the reverse index; no re-keying.
|
||||
|
||||
=====================================================================
|
||||
D11. ORACLES — each must redden under a stated mutation
|
||||
=====================================================================
|
||||
|
||||
1. DETACH-TO-TAB OVER SSH, THROUGH THE REAL RELAY COMPARISON. Bind an SSH pane in tab A leaf L, run `detachTerminalPaneToTab` to tab B, disconnect, reconnect. Assert: exactly one shell on the host, no second agent resume, the pane reattaches and the ptyId lands in tab B's layout. THIS MUST BE RED BEFORE STEP A. If it is green today, the harness stubbed the relay comparison or omitted `ORCA_PANE_KEY`/`ORCA_TAB_ID` from the spawn env, and the whole detach oracle family is measuring nothing. Gate the program on it going red first.
|
||||
2. Identity mismatch is not proof of death. Assert `isProvenSshSessionGoneError` is false for the mismatch string, no record reaches `terminated`, no grant. Mutation: restore `.includes(SSH_SESSION_EXPIRED)` as the sole predicate (reattach-failure-classification.ts:29).
|
||||
3. Wrong-incarnation attach is abandoned, not adopted and not fatal. Relay returns a different `incarnationId` than recorded: assert no bind, no stream, `detached`, no grant, activation lease rolled back. Mutation: skip the response check.
|
||||
4. Same-instance not-found is proof; different or absent is not. Two runs, recorded `relayInstanceId` equal -> `terminated` + grant; differing/absent -> `detached` + no grant. Mutation: flip the comparison.
|
||||
5. Failed listing is not an empty listing. Make `listProcesses` reject, and separately make `admit` throw `agent_session_ownership_unknown`: assert zero records retired and zero orphans reported. Mutation: replace the failure with a resolved `[]` — must redden, proving the implementation does not reuse the `.catch(() => [])` shape at ipc/pty.ts:7333.
|
||||
6. No fabricated exit on unknown. Drive `handlePtyReattachFailure` with a not-found and a differing `relayInstanceId`: assert `detached`, no grant, no `pty:exit -1`, and that provider state and ownership are NOT cleared. Mutation: restore the block at ssh-relay-session.ts:2641-2647.
|
||||
7. Detach mid-publish does not release. Force a `session:set` flush between `setTabLayout(source)` and `setTabLayout(target)`: assert ownership is NOT released. Mutation: release on any publish missing the leaf.
|
||||
8. Pane/tab delete releases. Delete a tab while its shell is live: assert the record is released, the shell IS an orphan, and no respawn is authorized. Mutation: remove the release rule — the shell must become invisible to both adopt and the cleanup UI.
|
||||
9. Production id shapes in the recovery grant (E-1). Seed relay-native `pty-7`; register the runtime pty as `ssh:<conn>@@pty-7` with a NON-NULL connectionId; call `recoverTerminalPane`; assert the actual behavior. Mutation: swap either namespace.
|
||||
10. E-2 false not-found. Same `relayInstanceId`, not-found caused by the wrong id form while the shell is in the Map under the correct id: assert NO `terminated` and NO grant. Mutation: skip `toRelaySshPtyId` on the attach path.
|
||||
11. Projection is incarnation-scoped. Record `pty-1@incX`; restart the relay; it mints `pty-1@incY` for a different pane: assert the fresh shell IS an orphan and the stale record does NOT mask it. Mutation: subtract on ptyId alone.
|
||||
12. `paneKey` survives `admit`. Mutation: remove it from the allowlist rebuild (pty-process-list-admission.ts:106-115).
|
||||
13. Detached shell is still re-adopted. Seed a leafId-less lease whose relay-reported `paneKey` names a DIFFERENT tab than the live pane: assert the shell is attached exactly once and no second shell spawns. Mutation: match on full-string paneKey. This is the migration gate — green before K, re-run after.
|
||||
14. Paired-client visibility survives the state rename. Record in `detached`, ptyId absent from every layout: assert `buildHeadlessMobileSessionTerminalTabs` still yields the tab. Mutation: drop the `expired`-equivalent — orca-runtime.ts:5964/:6290/:6322/:6359 must all be covered, not just the dead :16478 caller.
|
||||
15. Cleanup UI reaches the user's own abandoned shells. Delete the repo row (new `repoId` on re-add) while shells run: assert they are offered for cleanup. Mutation: reinstate worktreeId-based "owned elsewhere" — must redden.
|
||||
16. Nothing is killed implicitly. Any reattach/cleanup pass over foreign or unrecognized shells: assert zero kill RPCs. Mutation: auto-kill unclassified orphans.
|
||||
17. One pane, one live shell across partitions. Two live shells for one `(worktree, tab, leaf)` with the binding only in `local`: assert exactly one is bound. Mutation: restore the ssh-first preference in `durablyBoundPtyIdForPane` (persistence.ts:7271).
|
||||
18. P covers readers. Seed a binding only in `local`, call stable-pane resolve/retire with `connectionId` set: must find it. Mutation: revert either reader.
|
||||
19. Adopt is exclusive. Two concurrent adopts of one orphan: one succeeds, the other gets a typed refusal.
|
||||
20. Renderer cannot clobber ownership. Publish a stale full `WorkspaceSessionState` via `session:set`: assert the ownership map is byte-identical.
|
||||
21. Offline quit path. Restorable predicate with store only and a null mux: must not throw, must not require an RPC (persistence.ts:6465).
|
||||
22. Folder-workspace suffix. Two `::workspace:` instances on one path: a record for A must not reattach into B.
|
||||
23. Target reassign carries ownership. `reassignSshTargetId(old, new)`: every record's `hostId` field and the reverse index follow, with no re-keying.
|
||||
24. Windows/WSL relay kill. Assert the rules produce `detached`, never `terminated`, and that no UI copy asserts death. Mutation: assert-death — must redden on a Windows fixture even though it would pass on POSIX.
|
||||
25. Old-relay degradation. `listProcesses` without `paneKey`, grant without `relayInstanceId`: projection non-empty, nothing killed, reattach falls back to the `getPtyIdsForConnection UNION records` work list, and the per-attach incarnation guard still rejects a recycled id.
|
||||
|
||||
=====================================================================
|
||||
D12. RE-TEST AGAINST THE SECTION-3 REFUTATIONS
|
||||
=====================================================================
|
||||
REFUTATION A LEG 1 — "a lease must name a live shell no pane owns." SURVIVES. Discovery never was the lease's job: reattach builds its list from `getPtyIdsForConnection(target) UNION` the durable rows (ssh-relay-session.ts:2211-2219), and the connect-time projection adds host-reported shells on top. Leaf-keying does not weaken this. D5's RELEASE-vs-RETIRE distinction is REQUIRED BY this leg: a record whose leaf is gone must be released (shell stays discoverable and adoptable) rather than deleted (knowledge lost) or terminated (respawn wrongly authorized).
|
||||
|
||||
REFUTATION A LEG 2 — "a lease outlives the binding; expiry authorizes a 30s recreate." First clause survives exactly: `detached`/`terminated` are states on a main-owned, top-level record that persists after the layout binding is wiped. The recreate clause is PRESERVED AS A CONDITIONAL, not deleted: E-2 keeps a grant, gated on `relayInstanceId` equality, and only after E-1 shows the branch executes at production id shapes. Deleting the grant outright — proposed twice — is still refused as a simplification, because it dies to A leg 2 as written and rests on a read-not-run conclusion; it remains available as an E-1 OUTCOME. What does ship regardless is the user affordance, which covers every case a narrowed grant would drop.
|
||||
|
||||
REFUTATION B — "make the host authoritative." SURVIVES, and is honored more cheaply than before. Ownership stays on local disk, top-level, main-owned, read synchronously inside `setLocalWorkspaceSession` during quit with the host down (D8). Everything this design changes is the record's KEY and WHAT MAY TRANSITION IT — never where it lives or how it is read. The host is consulted only while connected, and only for "which shells are in your Map?", "is this the same shell I bound?" (incarnation) and "are you the same process?" (relayInstanceId) — three questions it answers from data it already holds. Critically, D4's deletion of retire-on-absence is what keeps refutation B intact under load: a design that retired durable ownership because a remote listing timed out would have made the host authoritative by accident.
|
||||
|
||||
REFUTATION C — a second authority architecture. Untouched, and this revision deletes more than the last: no decision table, no `hostEpoch`, no `os.uptime` math, no per-platform boot semantics, no paneKey transfer/alias layer, no attach-time identity apparatus, no orphan-ownership classifier, no second durable record.
|
||||
|
||||
WHERE THE CUT IS. Plane 1 (delivery identity, credit ledger, chunk-and-ack, binary payloads, loopback WebSocket) proceeds in parallel. The cut is DURABILITY SCOPE vs EVIDENCE SOURCE, and this revision now applies that rule to all four inferences the prior round left standing: an error string means dead (deleted, D2), absence from a list means dead (deleted, D4/D5), a delivery-checkpoint flag means alive (deleted, D3), an arithmetic clock means rebooted (never built, D3). What remains on the wire is two optional fields: `relayInstanceId` on the grant and `paneKey` on the process summary. RC3 is entirely Plane 1's.
|
||||
@@ -0,0 +1,255 @@
|
||||
# Terminal session correctness — design overview
|
||||
|
||||
Plain-language explanation of what breaks, why, and what we changed. No prior
|
||||
context needed.
|
||||
|
||||
---
|
||||
|
||||
## 1. The cast of characters
|
||||
|
||||
When you look at a terminal in Orca, five different things are lined up behind
|
||||
that one rectangle on screen.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph YOU["What you see"]
|
||||
PANE["Pane<br/><i>a rectangle in a tab</i>"]
|
||||
end
|
||||
subgraph APP["Orca's bookkeeping"]
|
||||
BIND["Binding<br/><i>this pane uses that shell</i>"]
|
||||
LEASE["Lease<br/><i>a note saying: we own<br/>a remote shell</i>"]
|
||||
end
|
||||
subgraph MACHINE["The actual computer"]
|
||||
PTY["PTY<br/><i>the shell process</i>"]
|
||||
HOST["Host<br/><i>your Mac, or an SSH box</i>"]
|
||||
end
|
||||
|
||||
PANE -->|bound to| BIND
|
||||
BIND -->|points at| PTY
|
||||
LEASE -->|claims| PTY
|
||||
PTY -->|runs on| HOST
|
||||
```
|
||||
|
||||
The pane is what you see. The **PTY** is the real shell process doing the work.
|
||||
The **binding** is the note that connects them. On a remote machine there is also
|
||||
a **lease** — a second note saying "we still own that remote shell."
|
||||
|
||||
**Everything that went wrong is one of those notes pointing at the wrong thing.**
|
||||
|
||||
---
|
||||
|
||||
## 2. The bug, in one picture
|
||||
|
||||
You are working on an SSH machine. Your network hiccups. Orca reconnects.
|
||||
|
||||
**What should happen:** your two terminals come back, same shells, same work.
|
||||
|
||||
**What actually happened:**
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["You have 2 terminals"] --> B["Network hiccup"]
|
||||
B --> C["Orca reconnects"]
|
||||
C --> D["Now you have<br/>19 terminals"]
|
||||
D --> E["Hiccup again"]
|
||||
E --> F["Now 20.<br/>Most are ghosts you<br/>never opened"]
|
||||
|
||||
style D fill:#ffe6e6,stroke:#cc0000
|
||||
style F fill:#ffcccc,stroke:#cc0000
|
||||
```
|
||||
|
||||
That is a real report: **2 → 19 → 20**. Meanwhile the remote machine fills up
|
||||
with shells nobody is using, until it refuses to start any more.
|
||||
|
||||
A second, worse version: your AI coding agent gets **resumed twice**. Two copies
|
||||
of `claude` writing into the same conversation file. One report had **five**.
|
||||
|
||||
---
|
||||
|
||||
## 3. Why it happened — three root causes
|
||||
|
||||
### Cause 1: the lease forgot which pane it belonged to
|
||||
|
||||
Each lease is a note saying "we own remote shell X." The note _had_ a field for
|
||||
"which pane," but nobody checked it.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph BEFORE["Before — lease identified by shell only"]
|
||||
P1["Pane A"] --> L1["Lease: shell-1"]
|
||||
P1 -.->|reconnect makes<br/>a new shell| L2["Lease: shell-2"]
|
||||
P1 -.->|and another| L3["Lease: shell-3"]
|
||||
N1["Nothing retires<br/>the old notes"]
|
||||
end
|
||||
style N1 fill:#ffe6e6,stroke:#cc0000
|
||||
```
|
||||
|
||||
Every reconnect added a note and removed none. Next reconnect, Orca dutifully
|
||||
restored **all** of them — one pane per stale note.
|
||||
|
||||
**Fix:** a lease is now identified by _which pane_ it belongs to, not just which
|
||||
shell. One pane, one live lease. Older notes are retired.
|
||||
|
||||
> Retired means **marked expired, not killed**. The remote shell is deliberately
|
||||
> left running — losing a note is not proof the shell died.
|
||||
|
||||
### Cause 2: reconnecting could _create_ panes
|
||||
|
||||
The function that says "pane A uses shell X" was also allowed to _invent_ pane A
|
||||
if it didn't exist. That is correct when you open a new terminal. It is very
|
||||
wrong when reconnecting.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
R["Reconnect finds a<br/>lease for shell-7"] --> Q{"Does its pane<br/>still exist?"}
|
||||
Q -->|"Yes"| OK["Reattach. Correct."]
|
||||
Q -->|"No — before"| BAD["Invent the pane.<br/>Ghost appears."]
|
||||
Q -->|"No — after"| GOOD["Refuse. Leave the shell<br/>running, unbound."]
|
||||
|
||||
style BAD fill:#ffcccc,stroke:#cc0000
|
||||
style GOOD fill:#e6ffe6,stroke:#00aa00
|
||||
```
|
||||
|
||||
**Fix:** reattach now says _"bind only, never create."_ Spawning a new terminal
|
||||
still creates, exactly as before.
|
||||
|
||||
### Cause 3: a living shell was reported as dead
|
||||
|
||||
The remote side has two different bad-news messages:
|
||||
|
||||
| Real meaning | What it was reported as |
|
||||
| ------------------------------------------------------------- | ------------------------------ |
|
||||
| "This shell is gone." | `SESSION_EXPIRED` ✅ correct |
|
||||
| "Shell is fine, I just need to re-establish the output pipe." | `SESSION_EXPIRED` ❌ **wrong** |
|
||||
|
||||
Orca's reaction to "expired" is to start a fresh shell — and because it passes
|
||||
the old session id along, the AI agent gets resumed a second time. **That is the
|
||||
duplicate-agent bug.**
|
||||
|
||||
**Fix:** the second case gets its own message, and respawning now requires
|
||||
_positive proof_ the shell is gone. Anything unrecognised means "unknown", and
|
||||
unknown never justifies starting a replacement.
|
||||
|
||||
---
|
||||
|
||||
## 4. The one rule underneath all of it
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
E["Evidence"] --> A{"What does it<br/>prove?"}
|
||||
A -->|"Shell exited"| D["DEAD — cleanup allowed"]
|
||||
A -->|"Can't reach it"| U["UNKNOWN"]
|
||||
A -->|"Timed out"| U
|
||||
A -->|"Not in a list"| U
|
||||
A -->|"Tried 3 times"| U
|
||||
U --> N["Do nothing destructive.<br/>Wait. Retry. Ask."]
|
||||
|
||||
style D fill:#e6ffe6,stroke:#00aa00
|
||||
style U fill:#fff4e6,stroke:#dd8800
|
||||
style N fill:#fff4e6,stroke:#dd8800
|
||||
```
|
||||
|
||||
**Unknown is not dead.** A disconnect, a timeout, an empty list, or three failed
|
||||
retries tell you nothing about whether a process is alive. Every bug above is
|
||||
some version of treating silence as a death certificate.
|
||||
|
||||
We also made the liveness check able to _say_ "unknown". It used to return only
|
||||
yes/no — so a provider whose network was down had no way to express "I can't
|
||||
see, don't ask me" and answered "no", meaning dead.
|
||||
|
||||
---
|
||||
|
||||
## 5. What we changed
|
||||
|
||||
Six changes, ~214 lines of production code total.
|
||||
|
||||
| # | Change | Stops |
|
||||
| --- | ----------------------------------------------- | ----------------------------------------------- |
|
||||
| 1 | Reattach binds, never creates | Ghost panes |
|
||||
| 2 | Leases identified by pane | 2 → 19 → 20 growth |
|
||||
| 3 | Heal already-corrupted lease data | Existing installs stuck at 20 |
|
||||
| 4 | "Needs re-establishing" ≠ "expired" | Duplicate agent resume |
|
||||
| 5 | Liveness can answer "unknown" | Live shells declared dead |
|
||||
| 6 | One PTY's failure can't drop the shared channel | One bad pane killing every session on that host |
|
||||
|
||||
### Change 6 in a picture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph B["Before"]
|
||||
X1["Pane 3 can't re-prove<br/>its output stream"] --> X2["Give up after N tries"]
|
||||
X2 --> X3["Drop the entire<br/>connection to that host"]
|
||||
X3 --> X4["Every pane, file transfer<br/>and git command dies"]
|
||||
end
|
||||
subgraph A["After"]
|
||||
Y1["Pane 3 can't re-prove<br/>its output stream"] --> Y2["Park pane 3 only"]
|
||||
Y2 --> Y3["Its shell keeps running.<br/>Siblings untouched."]
|
||||
end
|
||||
style X4 fill:#ffcccc,stroke:#cc0000
|
||||
style Y3 fill:#e6ffe6,stroke:#00aa00
|
||||
```
|
||||
|
||||
### And a guard on every keystroke
|
||||
|
||||
Orca queues your typing. If a pane switches to a different shell mid-flight,
|
||||
queued keystrokes could land on the **new** shell.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
K["Keystroke for shell-1"] --> C{"Is shell-1 still<br/>this pane's shell?"}
|
||||
C -->|"Yes"| S["Deliver"]
|
||||
C -->|"No — pane moved on"| R["Refuse"]
|
||||
C -->|"Pane unknown"| S2["Deliver<br/><i>unknown ≠ stale</i>"]
|
||||
|
||||
style R fill:#fff4e6,stroke:#dd8800
|
||||
```
|
||||
|
||||
We did this **without changing the wire format** — Orca already keeps two lookup
|
||||
tables in lock-step, so if they disagree, that _is_ proof the id is stale. No new
|
||||
data is sent per keystroke.
|
||||
|
||||
---
|
||||
|
||||
## 6. The approach we rejected
|
||||
|
||||
A previous attempt built a **new authority architecture beside the old one**:
|
||||
+60,903 lines of production code. It fixed **none** of the three root causes —
|
||||
they were still present, untouched, underneath.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph W["Rejected"]
|
||||
O1["Old system<br/>(3 bugs)"]
|
||||
N1["New system<br/>+60,903 lines"]
|
||||
O1 -.->|"bugs still here"| O1
|
||||
end
|
||||
subgraph R["Taken"]
|
||||
O2["Old system<br/>(3 bugs fixed)<br/>+214 lines"]
|
||||
end
|
||||
style N1 fill:#ffcccc,stroke:#cc0000
|
||||
style O2 fill:#e6ffe6,stroke:#00aa00
|
||||
```
|
||||
|
||||
A second proposal of mine was also rejected in review — it would have added a
|
||||
_second_ identity system beside an existing one, which is the same mistake in
|
||||
miniature. Three of its factual claims turned out to be false when checked
|
||||
against the code.
|
||||
|
||||
**The lesson both times:** the machinery mostly existed already. The bug was that
|
||||
it wasn't being _called_.
|
||||
|
||||
---
|
||||
|
||||
## 7. What is still open
|
||||
|
||||
| Thing | State |
|
||||
| ------------------------ | -------------------------------------------------------------------------------- |
|
||||
| The three root causes | Fixed |
|
||||
| Duplicate agent resume | Root cause fixed; the codebase still carries 3 older patches for it |
|
||||
| Orphaned remote shells | Left running on purpose. Not reachable from the UI yet — needs a recovery screen |
|
||||
| The keystroke quarantine | Still needed. We proved deleting it re-opens a real `rm -rf` hazard |
|
||||
|
||||
**On that last one:** there is a safety module that drops the tail of a half-typed
|
||||
line after a recovery, because `echo hi; rm -rf x` can otherwise arrive as
|
||||
`cho hi; rm -rf x` — the shell rejects `cho` and then happily runs `rm -rf x`. We
|
||||
tried to delete it. We proved by experiment that we cannot, yet.
|
||||
File diff suppressed because one or more lines are too long
+324
@@ -0,0 +1,324 @@
|
||||
query TerminalSessionCorrectnessRelatedWork {
|
||||
repository(owner: "stablyai", name: "orca") {
|
||||
n8129: issueOrPullRequest(number: 8129) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n8275: issueOrPullRequest(number: 8275) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n8585: issueOrPullRequest(number: 8585) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n8618: issueOrPullRequest(number: 8618) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n9034: issueOrPullRequest(number: 9034) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n9092: issueOrPullRequest(number: 9092) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n9093: issueOrPullRequest(number: 9093) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n9138: issueOrPullRequest(number: 9138) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n9562: issueOrPullRequest(number: 9562) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n9585: issueOrPullRequest(number: 9585) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n9819: issueOrPullRequest(number: 9819) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n9820: issueOrPullRequest(number: 9820) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n9827: issueOrPullRequest(number: 9827) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n9833: issueOrPullRequest(number: 9833) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n10118: issueOrPullRequest(number: 10118) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n10208: issueOrPullRequest(number: 10208) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n10235: issueOrPullRequest(number: 10235) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n10385: issueOrPullRequest(number: 10385) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n10415: issueOrPullRequest(number: 10415) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n10744: issueOrPullRequest(number: 10744) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n11006: issueOrPullRequest(number: 11006) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n11265: issueOrPullRequest(number: 11265) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n11339: issueOrPullRequest(number: 11339) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n11342: issueOrPullRequest(number: 11342) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n11495: issueOrPullRequest(number: 11495) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n11574: issueOrPullRequest(number: 11574) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n11575: issueOrPullRequest(number: 11575) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n11596: issueOrPullRequest(number: 11596) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n11622: issueOrPullRequest(number: 11622) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n11729: issueOrPullRequest(number: 11729) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n11800: issueOrPullRequest(number: 11800) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n11801: issueOrPullRequest(number: 11801) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n11803: issueOrPullRequest(number: 11803) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n11804: issueOrPullRequest(number: 11804) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n11904: issueOrPullRequest(number: 11904) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12036: issueOrPullRequest(number: 12036) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12134: issueOrPullRequest(number: 12134) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12140: issueOrPullRequest(number: 12140) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12220: issueOrPullRequest(number: 12220) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12241: issueOrPullRequest(number: 12241) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12264: issueOrPullRequest(number: 12264) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12339: issueOrPullRequest(number: 12339) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12447: issueOrPullRequest(number: 12447) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12448: issueOrPullRequest(number: 12448) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12456: issueOrPullRequest(number: 12456) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12474: issueOrPullRequest(number: 12474) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12477: issueOrPullRequest(number: 12477) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12568: issueOrPullRequest(number: 12568) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12600: issueOrPullRequest(number: 12600) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12634: issueOrPullRequest(number: 12634) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12683: issueOrPullRequest(number: 12683) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12699: issueOrPullRequest(number: 12699) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12702: issueOrPullRequest(number: 12702) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12743: issueOrPullRequest(number: 12743) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12749: issueOrPullRequest(number: 12749) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12751: issueOrPullRequest(number: 12751) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12760: issueOrPullRequest(number: 12760) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12768: issueOrPullRequest(number: 12768) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12798: issueOrPullRequest(number: 12798) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12882: issueOrPullRequest(number: 12882) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12903: issueOrPullRequest(number: 12903) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n12987: issueOrPullRequest(number: 12987) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n13013: issueOrPullRequest(number: 13013) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n13110: issueOrPullRequest(number: 13110) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
n13111: issueOrPullRequest(number: 13111) {
|
||||
...IssueDetails
|
||||
...PullRequestDetails
|
||||
}
|
||||
p13110: pullRequest(number: 13110) {
|
||||
...ReviewDetails
|
||||
}
|
||||
p13111: pullRequest(number: 13111) {
|
||||
...ReviewDetails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment IssueDetails on Issue {
|
||||
number
|
||||
title
|
||||
url
|
||||
state
|
||||
stateReason
|
||||
updatedAt
|
||||
}
|
||||
|
||||
fragment PullRequestDetails on PullRequest {
|
||||
number
|
||||
title
|
||||
url
|
||||
state
|
||||
isDraft
|
||||
merged
|
||||
reviewDecision
|
||||
mergeable
|
||||
mergeStateStatus
|
||||
headRefName
|
||||
baseRefName
|
||||
additions
|
||||
deletions
|
||||
changedFiles
|
||||
updatedAt
|
||||
commits(last: 1) {
|
||||
nodes {
|
||||
commit {
|
||||
oid
|
||||
statusCheckRollup {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment ReviewDetails on PullRequest {
|
||||
reviewThreads(first: 100) {
|
||||
nodes {
|
||||
isResolved
|
||||
isOutdated
|
||||
comments(last: 1) {
|
||||
nodes {
|
||||
url
|
||||
author {
|
||||
login
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
# Goalposts and proof contract
|
||||
|
||||
This file answers one question: **what exact evidence is required before an
|
||||
agent may write “proven”?**
|
||||
|
||||
The current status is **0/8 proven goalposts** and **2/13 proven
|
||||
journeys**. Status may change only from evidence produced on the same rebased,
|
||||
converged release candidate. Historical snapshot tests and independently useful
|
||||
narrow PRs may be cited as partial evidence but cannot promote a row.
|
||||
|
||||
## Recorded user decisions
|
||||
|
||||
These amend the contract above. Only the user may add to this list.
|
||||
|
||||
### D1 — G6 relaxed from strictly net-negative (2026-08-07)
|
||||
|
||||
> "It's okay if we must increase LOC but try not to."
|
||||
|
||||
G6's pass condition is no longer a strict inequality against the frozen
|
||||
baseline. It is now: **minimise added production code, and justify every net
|
||||
addition against the correctness it buys.** A net-positive total does not fail
|
||||
G6 on its own; an unjustified one does.
|
||||
|
||||
Why this was needed: the deletion budget the plan assumed does not exist. An
|
||||
entrypoint-rooted import graph over all 20 real build entrypoints found that
|
||||
51 of the 53 candidate files in `src/relay/*pty-source-*`,
|
||||
`src/shared/pty-consumer-*`, and `src/main/ipc/ssh-pty-*` are reachable and
|
||||
value-instantiated on live paths. Only 2 files (263 LOC) are unreachable, and 2
|
||||
of the candidates did not exist at the baseline so deleting them earns no
|
||||
credit. Against roughly +1,021 LOC to offset, a strict inequality was not
|
||||
reachable without deleting load-bearing code — which the governing rule forbids.
|
||||
|
||||
Still binding: correctness may not be weakened to reduce line count, and a
|
||||
replacement architecture added beside the old one does not earn its lines.
|
||||
|
||||
## Proven journeys
|
||||
|
||||
### Journey evidence not yet sufficient to promote
|
||||
|
||||
**Journey 2 (daemon half).** `tests/e2e/daemon-restart-session-liveness.spec.ts`,
|
||||
3 tests. Two mutations each redden exactly one clause, on macOS and Linux:
|
||||
reverting three-valued `hasPty` reddens only the unknown-not-dead clause;
|
||||
widening the sole-provider fallback reddens only the stale-generation clause.
|
||||
The lead re-verified the first mutation independently. **Missing: physical WSL**,
|
||||
which the journey names explicitly. That host is now unblocked (the distro has a
|
||||
provisioned default user) and the run is outstanding.
|
||||
|
||||
**Journey 12.** `tests/e2e/cross-version-wire/`, 13 tests against a real published
|
||||
baseline. Reverting the restore-required publication to expiry reddens 7 of 13;
|
||||
restore greens. Lead-verified. **Missing: live skew.** These are in-process wire
|
||||
tests. The original ledger named live paired-runtime and SSH skew as the gap, and
|
||||
an in-process decoder test does not close it.
|
||||
|
||||
**Journey 4.** Sibling-pane isolation on one host reddens when
|
||||
`mux.dispose('connection_lost')` is restored. **The cross-host clause cannot be
|
||||
proven by mutation**: a mux belongs to one relay session per SSH target, so its
|
||||
dispose cannot cross a host boundary — the cross-host test stayed green under the
|
||||
mutation, which is the empirical receipt. That clause rests on
|
||||
isolation-by-construction, and saying otherwise would be a false claim.
|
||||
|
||||
**Journey 6.** `tests/e2e/ssh-maxsessions-remote-pid-binding-identity.spec.ts`,
|
||||
4 tests against a real Docker OpenSSH container configured `MaxSessions 1`, the
|
||||
cap read back from `sshd -T` inside each test. Three of the four clauses
|
||||
discriminate, clause-selectively, in single runs (the spec is not `serial`, so a
|
||||
red test never reports the others as "did not run"):
|
||||
|
||||
- dropping `mayCreate: false` from the reattach binding write reddens only the
|
||||
authority-reconnect test, with the unowned leaf grafted into the `local`
|
||||
partition;
|
||||
- marking leases `terminated` instead of `detached` in `beginShutdownDetach`
|
||||
reddens only the two restart tests, with a cold-spawned shell beside the
|
||||
surviving one.
|
||||
|
||||
**Missing: the transport-disconnect clause does not discriminate.** Four guard
|
||||
removals were tried and it stayed green under all of them, including publishing
|
||||
`SSH_SESSION_EXPIRED` in place of `SSH_SOURCE_RESTORE_REQUIRED` and making
|
||||
`isProvenSshSessionGoneError` return true for every error. A sever that leaves
|
||||
the detached relay holding its delivery record reattaches through the checkpoint
|
||||
path, so no reattach failure is ever classified and the respawn-requires-proof
|
||||
guards are off that path. Inducing `restoreRequired` needs a lost or mismatched
|
||||
delivery record, which this fault does not produce. That clause is therefore a
|
||||
forward guard and cannot promote the row. Also missing: only Docker-on-macOS was
|
||||
run; the explicit-close/proven-teardown retirement clause is not covered.
|
||||
|
||||
**Journey 13.** Not proven. One of ten named dimensions measured. The
|
||||
`isSupersededPtyId` fence costs roughly 14ns per call, but it was measured on
|
||||
lifted predicates in plain Node, not through real Electron IPC — that part is an
|
||||
inference, not a measurement.
|
||||
|
||||
### Correction: Journey 12's cross-version tests exercise a route production does not have
|
||||
|
||||
Recorded earlier that the cross-version suite "confirms the new
|
||||
`SSH_SOURCE_RESTORE_REQUIRED` token mutates nothing on an old client." That
|
||||
claim is withdrawn.
|
||||
|
||||
An attempt to build a LIVE two-process skew oracle established that the token
|
||||
never crosses a version boundary at all. It is minted in main
|
||||
(`src/main/providers/ssh-pty-provider.ts:110`) and consumed in the same app's
|
||||
renderer (`pty-connection.ts:8833`, `:9080`) over Electron IPC — always one
|
||||
version. The only cross-version terminal boundary, paired client to HUB runtime,
|
||||
goes through `remote-runtime-pty-transport.ts`, whose sole respawn trigger is
|
||||
`SSH_SESSION_EXPIRED` (line 1481), and whose reattach never reaches
|
||||
`SshPtyProvider.spawn`.
|
||||
|
||||
The in-process suite injects the token into `terminal.resolvePane`, which is a
|
||||
pure lookup and not a route production takes. So those 13 tests are a decoder
|
||||
guard, not evidence about old clients.
|
||||
|
||||
Two consequences, both good: the wire-compatibility risk this branch was thought
|
||||
to introduce **does not exist**, because there is no version boundary for the new
|
||||
token to cross. And Journey 12 needs a different oracle entirely — one aimed at a
|
||||
boundary that is genuinely cross-version.
|
||||
|
||||
### Journey 6 — evidence, and why it is not promotable
|
||||
|
||||
`tests/e2e/ssh-maxsessions-remote-pid-binding-identity.spec.ts`, 4 tests, each
|
||||
with its own container and Electron profile, against real OpenSSH with
|
||||
`MaxSessions 1` read back from `sshd -T` rather than assumed. Remote pids are
|
||||
read on the container two independent ways and must agree, each carrying its
|
||||
kernel start time.
|
||||
|
||||
Two mutations discriminate and are disjoint: removing `mayCreate: false` reddens
|
||||
only the reconnect clause; marking leases `terminated` instead of `detached` at
|
||||
quit reddens only the two restart clauses.
|
||||
|
||||
**Not promotable:** the "same remote PID and exact binding survive a transport
|
||||
disconnect" clause is a forward guard. Four separate guard removals left it
|
||||
green. It asserts a real property, but nothing shipped is load-bearing for it,
|
||||
so it cannot be claimed as proof of this branch's work.
|
||||
|
||||
### Journey 3 — evidence, and why it is not promotable
|
||||
|
||||
`tests/e2e/ssh-lazy-discovery-skipped-host-restart.spec.ts`, 3 tests, two real
|
||||
containers, sampling sshd's own accept log and live session census 20+ times over
|
||||
22 seconds with the in-use host as a positive control.
|
||||
|
||||
**Not promotable:** no mutation reddens clause 3 alone. The genuine cross-host
|
||||
lease-scoping guard is load-bearing, but removing it breaks the sibling host
|
||||
during setup, so the failure carries no clause information.
|
||||
|
||||
### Journey 2 — Daemon and physical WSL (proven 2026-08-08)
|
||||
|
||||
Oracle: `tests/e2e/daemon-restart-session-liveness.spec.ts`, 3 tests, plus
|
||||
`tests/e2e/helpers/daemon-shell-process-identity.ts`. The PTY leader is a real
|
||||
login shell that reports `$$` back through the production write path
|
||||
(`DaemonPtyRouter.write` -> daemon socket -> PTY), and that pid is resolved to a
|
||||
kernel start time, so a look-alike respawn cannot pass as a survivor.
|
||||
|
||||
It replaced a spec that never crossed the daemon boundary and whose successor
|
||||
generation owned nothing, which made "the live successor is neither killed nor
|
||||
replaced" vacuous.
|
||||
|
||||
| Environment | Result | Discrimination watched |
|
||||
| -------------------------------------------- | -------- | --------------------------------------------------- |
|
||||
| macOS 26.3.1 arm64 | 3 passed | 2 mutations, each reddening one clause |
|
||||
| Ubuntu 24.04 x86_64 native | 3 passed | same 2 mutations, same single-clause reds |
|
||||
| WSL2 Ubuntu 26.04 on a physical Windows host | 3 passed | reverting three-valued `hasPty` reddens test 1 only |
|
||||
|
||||
Clause-selective on all three. The spec runs `mode: 'serial'`, so a red test 1
|
||||
reports 2 and 3 as "did not run" rather than passing — selectivity was therefore
|
||||
established by re-running 2 and 3 _alone under the same mutation_ and watching
|
||||
them stay green, not by assuming it.
|
||||
|
||||
Mutation A (`hasPty` reverted to `activeSessionIds.has(id)`) reddens only the
|
||||
unknown-not-dead clause. Mutation B (widening the sole-provider fallback in
|
||||
`daemon-session-owner-resolution.ts`) reddens only the stale-generation clause.
|
||||
The lead independently reproduced Mutation A on macOS.
|
||||
|
||||
Also confirmed on the WSL host, independently of the spec: an Orca WSL-mode
|
||||
terminal now starts and returns real output. It could not start before — the
|
||||
distro had no provisioned default Unix user, so every interactive `wsl.exe`
|
||||
launch blocked on first-run provisioning.
|
||||
|
||||
Limits, stated rather than implied:
|
||||
|
||||
- The WSL run used `ELECTRON_RUN_AS_NODE=1`, which is what this oracle needs
|
||||
(real daemon processes and real PTYs, no renderer). It does **not** show that
|
||||
Electron's GUI starts under WSL; no display server was started and that
|
||||
remains an open question for any journey needing a window.
|
||||
- WSL evidence is against `d74f5ed0eae` and on Node 22 rather than the required 24. The other two platforms ran the same oracle on Node 24.
|
||||
- `src/main/providers` is red on that distro for an unrelated reason:
|
||||
`local-pty-shell-ready.test.ts` pins an exact OSC 133 marker count and saw 7
|
||||
splits where it expects 4. The WSL run attributed that to bash 5.3.9, but that
|
||||
is **not** the cause — macOS runs the same bash 5.3.9 and the spec passes there
|
||||
67/67. The trigger is environmental to that distro, most plausibly a
|
||||
system-wide bashrc contributing prompt hooks, and the underlying defect is that
|
||||
the spec asserts an absolute count of markers it does not own. Outside this
|
||||
journey's surface, so left for its owner rather than reinterpreted blind — but
|
||||
it means "the unit suites are green on WSL" would be false.
|
||||
|
||||
### Journey 1 — Local macOS, Linux, and Windows (proven 2026-08-08)
|
||||
|
||||
Oracle: `tests/e2e/local-terminal-restart-binding-identity.spec.ts`. Two tests —
|
||||
the same pane, full binding and OS shell process survive renderer reload and app
|
||||
restart; and a stale pre-spawn session write is rejected instead of retiring the
|
||||
live binding.
|
||||
|
||||
Process identity is proved by the shell reporting its own pid through the
|
||||
production write path, plus the kernel-reported start time, so a recycled pid
|
||||
cannot pass as a survivor.
|
||||
|
||||
| Platform | Result | Discrimination watched |
|
||||
| --------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| macOS 26.3.1 arm64 | 2 passed | mutation reddens; restore greens |
|
||||
| Ubuntu 24.04 x86_64, native | 2 passed | 4 cycles, incl. deleting `restorableBindings` reconciliation — reddens test 2 only |
|
||||
| Windows 11 26200, native | 2 passed | A: forcing `shutdownDaemon()` in will-quit reddens test 1 (pid 13756 -> 8852, start times 5.4s apart). B: disabling the same reconciliation reddens test 2 only |
|
||||
|
||||
Every run used an isolated TMPDIR, because the harness keys its seeded-repo
|
||||
pointer on a machine-global tmpdir path and concurrent runs can both fabricate
|
||||
and mask a red.
|
||||
|
||||
Selectivity holds on two platforms: mutation B reddens only the stale-operation
|
||||
test and leaves the restart test green, so the two clauses are independently
|
||||
proved rather than jointly.
|
||||
|
||||
Residual limit, stated: "every stale exact operation" is proved for the
|
||||
pre-spawn session-write class. Local `pty:write`/`resize`/`signal` are fenced by
|
||||
`isSupersededPtyId` with its own oracle, but that fence compares a binding, not
|
||||
an incarnation — see `binding-identity-design.md`.
|
||||
|
||||
## Universal proof rule
|
||||
|
||||
A gate is `proven` only when both exist:
|
||||
|
||||
1. the complete behavior is reachable through every named production path; and
|
||||
2. the named proof exercises that behavior and its failure boundaries on the
|
||||
final candidate.
|
||||
|
||||
Every proof receipt must record:
|
||||
|
||||
- candidate commit SHA and exact merge base;
|
||||
- unfixed baseline SHA used for red/green discrimination;
|
||||
- exact command or manual journey protocol;
|
||||
- OS, architecture, filesystem, runtime, Git, SSH, daemon, relay, client, and
|
||||
host versions relevant to that journey;
|
||||
- UTC start/end time, exit status, counts, and artifact/log location;
|
||||
- the production caller, persistence boundary, transport, and cleanup path
|
||||
exercised;
|
||||
- the expected oracle and the observed result; and
|
||||
- independent reviewer identity and unresolved-finding count.
|
||||
|
||||
For a new regression oracle, demonstrate that it fails for the intended reason
|
||||
on the unfixed baseline and passes on the candidate. A test that passes both is
|
||||
a forward guard and cannot prove the fix.
|
||||
|
||||
## G0 — One design contract
|
||||
|
||||
**Current status: partial.**
|
||||
|
||||
G0 is proven only when:
|
||||
|
||||
- every reachable production path follows one reconciled identity, host
|
||||
boundary, operations, delivery, migration, compatibility, and minimal-shape
|
||||
contract;
|
||||
- there is one final-host authority service, thin transport adapters, one app
|
||||
projection/controller, and one bounded pre-cutover legacy importer;
|
||||
- no adapter or renderer owns a parallel authority state machine;
|
||||
- all retained existing primitives are mapped by semantics, not similar names;
|
||||
- the lost-consumer/compaction liveness gap has a safe, bounded, authenticated,
|
||||
operator-reachable resolution that does not infer death from time; and
|
||||
- an independent production-graph audit finds no contradictory path.
|
||||
|
||||
The design document alone, a behavior contract, or focused tests do not prove
|
||||
G0.
|
||||
|
||||
## G1 — One final-host authority
|
||||
|
||||
**Current status: partial.**
|
||||
|
||||
G1 is proven only when local, daemon, WSL, direct SSH, nested SSH, paired
|
||||
runtime, and remote server all resolve:
|
||||
|
||||
- a stable identity minted or validated by the final PTY-owning host;
|
||||
- a canonical host-local namespace for worktree, folder, and floating
|
||||
workspaces;
|
||||
- one exact pane-generation/PTY-incarnation binding;
|
||||
- host connections keyed by final-host identity with lazy discovery;
|
||||
- concurrent host isolation; and
|
||||
- namespace-local admission, failure, grant, handover, and retirement.
|
||||
|
||||
`connectionId`, SSH target ID, client repository ID, path spelling, or
|
||||
`worktreeId` may be routing metadata. Their existing names and usage counts are
|
||||
not proof that they satisfy final-host identity or namespace semantics.
|
||||
|
||||
Proof requires the applicable live journeys below, including simultaneous hosts
|
||||
and independent client/host updates.
|
||||
|
||||
## G2 — Exact operations only
|
||||
|
||||
**Current status: partial.**
|
||||
|
||||
G2 is proven only when input, resize, signal, close, output, and exit are fenced
|
||||
by the full binding captured before any await:
|
||||
|
||||
- authority host;
|
||||
- namespace;
|
||||
- pane generation;
|
||||
- owner/writer incarnation;
|
||||
- physical PTY;
|
||||
- PTY incarnation; and
|
||||
- negotiated operation/source generation where applicable.
|
||||
|
||||
Stale, partial, absent, timed-out, disconnected, or unknown evidence must not
|
||||
affect a successor. An authoritative operation must never retry through an
|
||||
ID-only provider call or a legacy mutation path.
|
||||
|
||||
Proof must cover all operations across local, daemon, WSL, SSH, paired runtime,
|
||||
remote server, renderer fallback, restart, concurrent replacement, and both
|
||||
mixed-version directions. Store-row counts and source-text assertions are not
|
||||
sufficient.
|
||||
|
||||
## G3 — Durable ordered delivery
|
||||
|
||||
**Current status: partial.**
|
||||
|
||||
G3 is proven only when the final production design provides all observable
|
||||
properties below, even if its internal mechanism differs from the preserved
|
||||
construction design:
|
||||
|
||||
- complete boundary snapshot before later events;
|
||||
- producer held while boundary/replay is established;
|
||||
- contiguous replay before reconciliation and live resume;
|
||||
- durable semantic outcomes, including exit and state needed by a newly
|
||||
attached consumer;
|
||||
- durable idempotent main-process projection before acknowledgement;
|
||||
- final-host-owned cumulative acknowledgement or an explicitly approved
|
||||
equivalent with the same crash/replay guarantees;
|
||||
- renderer snapshot-plus-delta observation;
|
||||
- app, renderer, host, and transport restart resume;
|
||||
- gap detection and resnapshot without silent omission;
|
||||
- bounded memory, queues, pages, listeners, timers, and retained output;
|
||||
- independent consumer retirement and safe compaction liveness; and
|
||||
- no app-side duplicate cursor, settlement, receipt, or suffix-reconciliation
|
||||
authority.
|
||||
|
||||
Proof must include crash-before/after-ACK cuts, lost responses, disconnected
|
||||
replay, gap recovery, slow/stalled consumers, retired and permanently lost
|
||||
consumers, paired/remote restart, mixed versions, and scale.
|
||||
|
||||
## G4 — One-way legacy cutover
|
||||
|
||||
**Current status: partial.**
|
||||
|
||||
G4 is proven only when each namespace performs, in order:
|
||||
|
||||
1. explicit capability negotiation;
|
||||
2. a brief legacy-write freeze;
|
||||
3. exact non-mutating inventory;
|
||||
4. a deterministic import plan;
|
||||
5. validation with ambiguity kept visible and non-destructive;
|
||||
6. one self-contained durable authority commit;
|
||||
7. topology attachment; and
|
||||
8. exact client opening through the authoritative path.
|
||||
|
||||
There must be no dual writer, destructive inference, authority-to-legacy
|
||||
fallback, or second durable migration catalog after cutover. Old peers remain
|
||||
on an unchanged isolated legacy surface or fail before mutation.
|
||||
|
||||
Proof requires crash cuts at every phase, replay from the self-contained commit,
|
||||
ambiguous-row isolation, independent namespace failure, and legacy-writer and
|
||||
reconciliation deletion.
|
||||
|
||||
## G5 — Wire and platform compatibility
|
||||
|
||||
**Current status: not started.**
|
||||
|
||||
G5 is proven only when all exchanged changes follow the remote-wire
|
||||
compatibility contract and the final candidate passes:
|
||||
|
||||
- old client to new host;
|
||||
- new client to old host;
|
||||
- native macOS;
|
||||
- native Linux at Ubuntu 20.04 / glibc 2.31 compatibility floor;
|
||||
- native Windows;
|
||||
- physical WSL, including Git Bash/`.cmd` boundaries where relevant;
|
||||
- Docker OpenSSH;
|
||||
- daemon;
|
||||
- direct and nested SSH;
|
||||
- paired runtime;
|
||||
- remote server;
|
||||
- git worktree;
|
||||
- folder workspace;
|
||||
- floating workspace; and
|
||||
- drive-letter and UNC namespace paths.
|
||||
|
||||
Run both skew directions independently across every changed deployment boundary:
|
||||
app↔daemon, app↔SSH relay/final host, paired client↔paired runtime, remote
|
||||
client↔remote server, and mobile/E2EE RPC where affected. A single in-process
|
||||
codec test or one client/host pairing cannot stand in for this matrix.
|
||||
|
||||
New opcodes or semantics require explicit capability negotiation. An optional
|
||||
field that parses on an old peer does not by itself prove that old behavior
|
||||
remains correct.
|
||||
|
||||
Mocking `process.platform`, running Linux inside Docker, or passing in-process
|
||||
wire unit tests does not prove the corresponding native or live-skew row.
|
||||
|
||||
## G6 — Simpler, minimised production code
|
||||
|
||||
**Current status: partial; structural clauses remain open.**
|
||||
|
||||
Decision D1 replaces the original strict inequality: the final integrated
|
||||
program must minimise production source and justify every net addition against
|
||||
the correctness it buys.
|
||||
|
||||
The default baseline is
|
||||
`5ed45739e94bdf6460364e033bfcec9b32c0b42a`, the base recorded by GitHub for
|
||||
PR #12600. This broader program subsumes #12600. Changing the baseline requires
|
||||
an explicit user decision recorded before more implementation begins.
|
||||
|
||||
G6 is proven only when:
|
||||
|
||||
- aggregate program-attributable production source net LOC is **minimised and
|
||||
every net addition justified** against the frozen baseline — see decision D1
|
||||
under "Recorded user decisions", which replaced the original strict
|
||||
inequality once the assumed deletion budget was shown not to exist;
|
||||
- every program-attributable prerequisite merged after the baseline and every
|
||||
stacked PR is included, even if a later rebase places it in `main`;
|
||||
- overlapping changes are recomputed from the frozen baseline to the final tree
|
||||
so additions and later deletions are not double-counted;
|
||||
- unrelated upstream or user deletions cannot offset program additions;
|
||||
- production, test, documentation, CI/runner, generated, and vendored changes
|
||||
are reported separately;
|
||||
- every new production module is reachable from a real entrypoint;
|
||||
- no test fixture remains under production compilation;
|
||||
- there is one identity comparison, transition implementation, exact-operation
|
||||
client, mutation admission path, and delivery state machine;
|
||||
- no re-export shim or one-type module exists solely to preserve construction
|
||||
layering;
|
||||
- no superseded quarantine, sliding-window, retry-verdict, reconciliation,
|
||||
duplicate cursor, receipt ledger, legacy writer, or migration bridge remains
|
||||
reachable after cutover; and
|
||||
- an independent reachability and duplicate-state-machine audit is clean.
|
||||
|
||||
Tests or docs cannot offset positive production LOC. A smaller narrow PR is not
|
||||
proof if the final stack remains net positive. Deleting correctness or platform
|
||||
coverage to hit the number is prohibited.
|
||||
|
||||
Classify by behavior, not directory name: shipped runtime code, migration code,
|
||||
and build-time code that enforces a shipped artifact invariant are production;
|
||||
test-only runners and fixtures are tests even when misplaced, and their presence
|
||||
under production compilation independently fails this gate. Publish the final
|
||||
file-by-file classification so the count cannot be moved between buckets.
|
||||
|
||||
### G6 clause assessment (2026-08-08)
|
||||
|
||||
Checked against the current branch rather than assumed:
|
||||
|
||||
| Clause | State |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| aggregate net production LOC minimised and justified (D1) | +228, each addition justified in its commit; 201 LOC deleted after verifying unreachability from all 20 build entrypoints |
|
||||
| every new production module reachable from a real entrypoint | holds — no new unreachable module was added |
|
||||
| no test fixture under production compilation | not audited on this branch |
|
||||
| one identity comparison / transition / exact-op client / admission path / delivery state machine | **fails** — several remain, and this is the clause the rejected binding proposal would have made worse by adding a second identity comparison beside `resolveStablePaneOwner` |
|
||||
| no re-export shim or one-type module for layering | holds for this branch's additions |
|
||||
| **no superseded quarantine, sliding-window, retry-verdict, reconciliation, duplicate cursor, receipt ledger, legacy writer, or migration bridge reachable** | **fails** — `terminal-input-quarantine.ts` is imported by `pty-connection.ts:98` and `terminal-pane-recovery.ts:6` |
|
||||
| independent reachability and duplicate-state-machine audit clean | not run |
|
||||
|
||||
On the quarantine specifically: it is **not** subsumed by the superseded-PTY
|
||||
fence, and claiming otherwise would be wrong. The fence refuses writes aimed at
|
||||
a stale ptyId. The quarantine guards the user's _subsequent_ typing — the tail
|
||||
of a half-typed line — landing on the successor shell under its current and
|
||||
correct ptyId, which the fence never sees.
|
||||
|
||||
Deleting it therefore requires contract property B: a _different_ shell surfaces
|
||||
unresolved and never silently receives the pane, at which point there is no
|
||||
mangled tail to suppress. That is a behaviour change to the recovery path, not a
|
||||
deletion, and an earlier attempt to shortcut it was rejected for reintroducing
|
||||
the `echo hi; rm -rf x` hazard by disarming per-pane state that is keyed per-tab.
|
||||
|
||||
G6 is therefore not promotable, and its blocking clauses are structural rather
|
||||
than arithmetic.
|
||||
|
||||
### The input quarantine is load-bearing, not superseded (2026-08-08)
|
||||
|
||||
G6's clause lists "no superseded quarantine ... remains reachable" and
|
||||
`terminal-input-quarantine.ts` was assumed to be one. It is not, and the
|
||||
evidence is direct.
|
||||
|
||||
**The hazard is live.** Disabling the single call site in `pty-connection.ts`
|
||||
and running the pane's own oracle reproduces it verbatim, lead-verified:
|
||||
|
||||
AssertionError: expected "vi.fn()" to not be called with arguments:
|
||||
[ 'cho hi; rm -rf x' ]
|
||||
|
||||
Deleting the module without a replacement re-opens `rm -rf` execution.
|
||||
|
||||
**Contract property B was costed by building it, not estimated.** Threading the
|
||||
incarnation to the renderer compiles at **+26 production LOC**, and completing it
|
||||
(publishing an incarnation from `resolveTerminalPane`, plus a new exported reader
|
||||
for the module-private `ptyIncarnationById` map) is about **+33**. The
|
||||
cross-remount state the comparison needs must outlive the destroyed pane, so it
|
||||
becomes a module of roughly the size of the one being deleted — the two in-tree
|
||||
precedents are 88 and 86 lines. Adding a renderer-side identity comparison would
|
||||
also worsen G6's already-failing "one identity comparison" clause. Floor: about
|
||||
**+140 production LOC to delete 88**.
|
||||
|
||||
**And the route is not uniformly available.** `RuntimeTerminalCreate` and
|
||||
`RuntimeTerminalResolvePane` carry no `incarnationId`; remote connect results are
|
||||
built without one; and the transport latches `resolvePaneUnavailable` when a host
|
||||
answers `method_not_found`. Mixed client/host versions are the normal state, and
|
||||
an optional field does not make an old host publish it. So a paired client reads
|
||||
_unknown_ — which under this program's own governing rule is not proof of "same
|
||||
shell". Requiring proof makes every remote reattach surface unresolved; not
|
||||
requiring it needs a fallback, and the only correct fallback is this quarantine.
|
||||
Either way the module stays reachable.
|
||||
|
||||
`endpointReplaced` is also routine rather than rare — a daemon death remounts
|
||||
every live pane — so property B would convert each into a manual per-pane
|
||||
reconnect, which is the coverage deletion G6 explicitly prohibits.
|
||||
|
||||
**Consequence:** this clause of G6 cannot be closed by deletion. Either the
|
||||
clause is amended to recognise the module as load-bearing, or the program accepts
|
||||
a net-positive change to replace it with something weaker on remote hosts. That
|
||||
is a user decision (call it D5), not one to assume.
|
||||
|
||||
### G6 clause: test fixtures under production compilation (audited 2026-08-08)
|
||||
|
||||
Audited by importer rather than filename, then checked against what the build
|
||||
actually emits. **The clause is already satisfied on the meaning that matters,
|
||||
and cannot be closed by moving files on the other meaning.**
|
||||
|
||||
36 candidates; 4 have genuine production importers and are correctly placed. The
|
||||
other 32 (~3,300 LOC) have zero non-test importers.
|
||||
|
||||
**They do not ship.** Spot-checked the emitted bundle for
|
||||
`terminal-restore-parity`, `ipcEventsTestHarness`, `storeTestHelpers` and
|
||||
`sshRelayNativeDepsInstallFixture`: none appears in `out/`. Rollup drops them
|
||||
because no production entrypoint reaches them. Under "compiles into the shipped
|
||||
product", this clause holds today.
|
||||
|
||||
**Moving them would close nothing under the other reading.**
|
||||
`config/tsconfig.node.json` and `config/tsconfig.tc.web.json` declare `include`
|
||||
globs such as `../src/main/**/*` with **no `exclude` at all**. A `__tests__/`
|
||||
directory is matched by that glob exactly as any other path is, and so is every
|
||||
`*.test.ts` file in the repo. Relocating 32 fixtures would not remove one file
|
||||
from typecheck scope.
|
||||
|
||||
A sweep was started and stopped once this was verified, rather than landing 32
|
||||
moves across areas this program does not own for no measurable gain.
|
||||
|
||||
If the intent behind the clause is that typecheck scope should exclude test code,
|
||||
that is a repo-wide tsconfig change affecting every test file — different work
|
||||
with a different owner, and it should be stated as such rather than pursued by
|
||||
relocating fixtures.
|
||||
|
||||
One genuine defect was found and fixed while auditing:
|
||||
`terminal-pane/xterm-bypass-event-fixture.ts` and
|
||||
`terminal-pane/__fixtures__/xterm-bypass-event.ts` were byte-identical apart from
|
||||
an import path, and the `__fixtures__` copy had zero importers — a half-finished
|
||||
move left in place. The dead copy is deleted and the live one completed its move.
|
||||
|
||||
## G7 — No regression and reviewable comprehensive change
|
||||
|
||||
**Current status: not started.**
|
||||
|
||||
G7 is proven only on the rebased, converged candidate after G0–G6 and all
|
||||
thirteen journeys are proven. It requires:
|
||||
|
||||
- correctness and security gates;
|
||||
- A/B input latency and output throughput;
|
||||
- backpressure and bounded-memory results;
|
||||
- renderer/app/daemon/relay restore and startup results;
|
||||
- large-pane, long-session, and multi-host scale results;
|
||||
- native packaging/startup on macOS, Linux, and Windows;
|
||||
- WSL, Docker SSH, paired, remote, folder, floating, worktree, drive, and UNC
|
||||
coverage;
|
||||
- both live mixed-version directions;
|
||||
- final categorized LOC census;
|
||||
- independent repository review with no unresolved P0–P2 findings;
|
||||
- release-readiness review with no unresolved correctness, security,
|
||||
compatibility, or performance findings; and
|
||||
- a detailed comprehensive PR whose claims match the receipts.
|
||||
|
||||
Before implementation, record a performance protocol that makes “no
|
||||
regression” falsifiable:
|
||||
|
||||
- fixed candidate and baseline builds, hardware, OS, power mode, network shape,
|
||||
pane/session population, payloads, and background-load policy;
|
||||
- warm-up policy, randomized A/B order, sample count, raw-data location, and
|
||||
statistical method;
|
||||
- input latency, output throughput, memory, backpressure, restore, startup, and
|
||||
large-pane metrics with directionality;
|
||||
- deterministic ceilings for writes, scans, queues, listeners, timers, and
|
||||
allocations on hot paths; and
|
||||
- a predeclared equivalence/no-regression bound no larger than measured baseline
|
||||
noise. A nonzero bound handles measurement noise; it is not permission for a
|
||||
known slowdown and requires explicit user approval.
|
||||
|
||||
Unless an independently reviewed protocol justifies another count, use at least
|
||||
five warm-up trials and thirty measured trials per latency/startup/restore
|
||||
configuration, retain raw samples, and report confidence intervals. Throughput,
|
||||
backpressure, and memory tests must also include a fixed-duration steady-state
|
||||
run and a leak-slope result. Choose all workloads and thresholds before looking
|
||||
at candidate results.
|
||||
|
||||
Green CI, thousands of tests, mergeability, or “reviewable for what shipped” do
|
||||
not prove G7.
|
||||
|
||||
## Thirteen required production journeys
|
||||
|
||||
Every row is currently **not proven**.
|
||||
|
||||
The issue-to-journey matrix in
|
||||
[`related-open-work.md`](./related-open-work.md#mandatory-issue-to-journey-matrix)
|
||||
is part of these journeys, not optional context. Every bound incident needs a
|
||||
red-on-baseline/green-on-candidate oracle, or explicit evidence plus user
|
||||
acceptance that it is unrelated.
|
||||
|
||||
| # | Journey | Required oracle |
|
||||
| --: | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Local macOS, Linux, and Windows | The same pane, full binding, and OS process survive renderer and app restart; every stale exact operation is rejected. Run natively on all three OSes. |
|
||||
| 2 | Daemon and physical WSL | The same PTY survives client and daemon reconnect/restart boundaries; generation skew fails closed without killing or replacing a live successor. |
|
||||
| 3 | Lazy discovery and skipped-host restart | An unused host is not probed eagerly. After a restart that skipped it, lazy rediscovery restores only that host's sessions and never adopts host-current or sibling-host state. |
|
||||
| 4 | Concurrent multi-host connections | At least two distinct final-host connections operate simultaneously. One host's disconnect, CAS, timeout, or failure cannot affect the other. |
|
||||
| 5 | Namespace-partial admission failure | One namespace on a multiplexed connection fails challenge/CAS/grant publication while another commits. Only the failed host-plus-namespace is fenced. |
|
||||
| 6 | Docker OpenSSH with `MaxSessions=1` | The same remote PID and exact binding survive disconnect and client restart. Authority restart imports exactly or exposes unresolved recovery without creating, killing, or adopting. Explicit close or exact proven teardown durably retires the lease and process; unknown ownership stays visible and recoverable rather than being killed. Count actual remote processes, panes, tabs, bindings, leases, and session-cap slots before and after a settle interval. |
|
||||
| 7 | Two independent Docker SSH hosts | Two simultaneous final hosts keep endpoint credentials, principals, namespaces, sessions, cursors, failures, and cleanup completely isolated. |
|
||||
| 8 | Paired client and remote server | The final host remains authoritative across independently updated peers, pairing reconnect, client restart, and remote-runtime restart. |
|
||||
| 9 | Worktree, folder, floating, drive, and UNC namespaces | Each resolves the same stable host-local namespace across spelling/restart changes, without using client repository ID or target ID as identity. |
|
||||
| 10 | Stable proof and exact retry | One bounded device proof identity admits fresh process/session nonces. A lost response retries the exact challenge/request; changed, replayed, cross-host, cross-namespace, or host-current state is rejected. |
|
||||
| 11 | Identity reset and re-enrollment | Crash-resumable host retirement, relay revoke acknowledgement, transport closure, local credential removal, and atomic successor publication occur in order. Offline and old peers remain explicitly pending. |
|
||||
| 12 | Mixed versions in both directions | No unknown opcode or ungranted publication mutates state. Unsupported challenge, grant, delivery, or operation semantics stay on isolated legacy behavior or fail before mutation. |
|
||||
| 13 | Performance and scale | Under the predeclared protocol above, input/output latency, throughput, backpressure, memory, reconnect/restore, startup, large-pane, long-session, and multi-host ceilings show no regression against the fixed baseline. Raw samples, confidence intervals, deterministic counters, and leak slopes satisfy their predeclared bounds. |
|
||||
|
||||
## Cross-cutting correctness cases
|
||||
|
||||
Every relevant journey must exercise:
|
||||
|
||||
- stale, missing, unknown, rejected, timed-out, and disconnected evidence;
|
||||
- concurrent replacement and sibling-host/namespace isolation;
|
||||
- lost request and lost response;
|
||||
- cancellation and partial setup cleanup;
|
||||
- crash immediately before and after every durable boundary;
|
||||
- restart from disk rather than process-memory state;
|
||||
- duplicate and out-of-order delivery;
|
||||
- gaps, overflow, and slow/stalled consumers;
|
||||
- explicit close versus detach;
|
||||
- eventual exact retirement after explicit close or proven teardown while
|
||||
uncertain ownership remains intact and visible;
|
||||
- legacy data with missing optional fields;
|
||||
- relay/daemon incarnation reuse;
|
||||
- sleep/resume and clock movement where timers schedule retries;
|
||||
- exact remote process/PID census, not only persisted-row state; and
|
||||
- cleanup that requires positive identity proof.
|
||||
|
||||
## Forbidden proof substitutions
|
||||
|
||||
- A mock is not a native-platform journey.
|
||||
- A source grep is not a production call-path journey.
|
||||
- A row marked `expired` is not proof that its remote process exited.
|
||||
- Optional pane metadata plus an O(n) repair scan is not structural uniqueness.
|
||||
- An empty list from an unavailable provider is not proof of absence.
|
||||
- A timer or retry budget is not proof of death.
|
||||
- A broad test count is not a correctness oracle.
|
||||
- A test that passes unfixed code is not evidence that a change fixed the bug.
|
||||
- An open or green PR is not shipped behavior.
|
||||
- Historical construction receipts are not proof for a rebased candidate.
|
||||
- Prior art or an uncited LOC comparison cannot delete a requirement.
|
||||
|
||||
## Promotion template
|
||||
|
||||
Before changing any row to `proven`, add a receipt containing:
|
||||
|
||||
```text
|
||||
Gate or journey:
|
||||
Candidate SHA:
|
||||
Merge-base SHA:
|
||||
Unfixed/red SHA:
|
||||
Production path exercised:
|
||||
Exact command or manual protocol:
|
||||
Environment and versions:
|
||||
Expected oracle:
|
||||
Red result:
|
||||
Green result:
|
||||
UTC start/end:
|
||||
Artifact/log:
|
||||
Independent reviewer:
|
||||
Unresolved P0/P1/P2:
|
||||
LOC/performance impact:
|
||||
```
|
||||
|
||||
If any field is missing, the row remains partial or not started.
|
||||
+386
@@ -0,0 +1,386 @@
|
||||
# New design — goalposts
|
||||
|
||||
Tracks the design approved on 2026-08-09 (`design-explained.html`, detail in
|
||||
`counsel-design.html`). Updated as work lands. Progress in every status update
|
||||
is reported against this file.
|
||||
|
||||
## How to read a status
|
||||
|
||||
| Status | Means |
|
||||
| --- | --- |
|
||||
| **PROVEN** | An oracle asserts the behaviour, removing the production guard **reddens** that oracle, and the mutation was verified to actually land before the result was believed. |
|
||||
| **ORACLE RED** | The oracle is written and currently fails for the right reason. The production change is not written yet. This is the intended pre-implementation state. |
|
||||
| **NOT STARTED** | No oracle. |
|
||||
| **BLOCKED** | Waiting on another goalpost or an owner decision. |
|
||||
|
||||
A green test is **not** a status. This program shipped three guards that passed
|
||||
their tests while sitting off the route production takes; "PROVEN" exists to
|
||||
make that impossible to claim by accident.
|
||||
|
||||
---
|
||||
|
||||
## Scoreboard
|
||||
|
||||
| | Count |
|
||||
| --- | --- |
|
||||
| Step goalposts proven | **7 of 7** (one deleted as unreachable) |
|
||||
| Global goalposts proven | **5 of 6** — G1 missed, see below |
|
||||
| Scope removed by evidence | S7 (death rule) — never built |
|
||||
| Oracle clauses green | 26 |
|
||||
| Oracle clauses red (awaiting implementation) | 0 |
|
||||
| Net production lines, correctness work | **+13** |
|
||||
| Net production lines, incl. the approved affordance | **+83** |
|
||||
| Net test lines | +2,578 |
|
||||
|
||||
**G1 is not met, and that is reported rather than smoothed over.** The design's
|
||||
net-negative target assumed the remaining steps were collapses. Three were
|
||||
(S3 −21, S8 −32, and the relay half of S5), but two genuinely add code: the
|
||||
single `bindPaneShell` producer (+60, since one function replaces three
|
||||
divergent call sites without those sites shrinking much) and the D1
|
||||
disconnected-pane affordance (+70), which is a new product surface the owner
|
||||
approved — a feature, not a refactor. Excluding the affordance the correctness
|
||||
work is +13, i.e. roughly a wash. See "G1" below for the full accounting.
|
||||
|
||||
---
|
||||
|
||||
## Step goalposts
|
||||
|
||||
### S1 — An identity mismatch is never read as death · **PROVEN**
|
||||
|
||||
*Guarantee.* The relay reports a pane-identity mismatch by saying the pty was
|
||||
not found. It found it — comparing is how it noticed. That must never reach a
|
||||
respawn decision as proof of death.
|
||||
|
||||
- Oracle: `src/main/providers/ssh-pty-identity-mismatch-is-not-death.test.ts`,
|
||||
`src/renderer/src/components/terminal-pane/reattach-failure-classification.test.ts`
|
||||
- Clauses: 7 + 3
|
||||
- Mutations proven: restoring the destructive wrap reddens 2 clauses; deleting
|
||||
the classifier guard reddens 1 — clause-selective, each proven separately.
|
||||
- Landed: `e2524b0472f`
|
||||
|
||||
### S2 — Pane identity is not sent on reattach · **PROVEN**
|
||||
|
||||
*Guarantee.* Moving a pane to another tab must never make its terminal
|
||||
unreachable.
|
||||
|
||||
- Oracle: same file, `reattach does not ask the relay to police pane identity` (2 clauses)
|
||||
- Also inverted an existing test that pinned the removed behaviour, so the new
|
||||
intent stays covered rather than silently dropped.
|
||||
- Works against **already-deployed relays**: the relay's comparison is
|
||||
presence-guarded, so not sending the fields disarms it everywhere. No wire
|
||||
change, no redeploy.
|
||||
- Landed: `c51be8072ba`
|
||||
|
||||
### S3 — A failed reattach never fabricates an exit · **PROVEN**
|
||||
|
||||
*Guarantee.* On an unproven not-found, the pane is not told the program exited,
|
||||
ownership is not deleted, provider state is not cleared, and the lease is not
|
||||
expired. The case routes into the non-destructive recovery branch that already
|
||||
exists.
|
||||
|
||||
- Oracle: `src/main/ssh/ssh-relay-reattach-exit-proof.test.ts` — 8 green
|
||||
- Mutation proven: restoring the destructive block reddens 6 of 8 clauses; the
|
||||
2 producer pins stay green, so the mutation is clause-selective.
|
||||
- Two tests pinned the deleted premise ("attach verifies liveness before
|
||||
answering not-found") and were **inverted**, not patched.
|
||||
- Shipped with the disconnected-pane affordance below, since panes now stay
|
||||
visible instead of silently respawning.
|
||||
- Landed: `c19f1b386b4` (−21 production lines)
|
||||
|
||||
### S4 — One partition per (target, pane) · **PROVEN**
|
||||
|
||||
*Guarantee.* A pane that re-leases under a new relay id across reconnects ends
|
||||
with exactly **one** live claim; the predecessor is superseded, not left live.
|
||||
|
||||
- Oracle: `src/main/ssh-pane-binding-partition.test.ts` — 7 green (one clause added)
|
||||
- The highest-value goalpost: the mechanism behind the reported 2 → 19 → 20.
|
||||
- Mutation proven as a **2×2**, because the two edits mask each other:
|
||||
|
||||
| reader | load fold | result |
|
||||
| --- | --- | --- |
|
||||
| hedge (original) | off | 6 red — baseline |
|
||||
| hedge | off | 4 red — the 2 source-text clauses now green |
|
||||
| local-only | off | 1 red — the reader carries 3 clauses |
|
||||
| hedge | **on** | **all green** ← the fold masks the reader |
|
||||
| local-only | on | all green |
|
||||
|
||||
That fourth row is why an **eighth clause was added**: with the fold shipping,
|
||||
the fold erases the divergent copy at boot, so the reader guard would have
|
||||
shipped unproven — precisely the failure mode G5 exists to catch. The new
|
||||
clause rewrites `ssh:<target>` mid-session (orphan adoption still writes
|
||||
there) and reddens when the hedge is restored, pinning the reader on its own.
|
||||
- Five clauses in `ipc/pty.test.ts` pinned the two-partition shape and were
|
||||
**inverted** to the single home, each keeping an arity check so a re-added
|
||||
partition argument fails loudly.
|
||||
- Side effect found while verifying: the renderer never hydrated the `ssh:*`
|
||||
partition (`listKnownRuntimeHostIds` filters to `runtime:*`), so the Issue
|
||||
#217 force-quit binding protection had never worked for SSH panes. It does now.
|
||||
- Landed: `2733c59879b` (+9 production lines — the call sites shrank; the fold
|
||||
is new state repair)
|
||||
|
||||
### S5 — The superseded-pane fence is live on the reattach path · **PROVEN**
|
||||
|
||||
*Guarantee.* After a relay-driven reattach binds a pane, a stale write aimed at
|
||||
the superseded predecessor is **refused**. It used to be permitted, because the
|
||||
fence's bookkeeping was only written by spawn.
|
||||
|
||||
- Oracle: `src/main/ssh/ssh-relay-session-reattach-pane-fence.test.ts` — 4 green
|
||||
(one clause added)
|
||||
- Collapsed to one `bindPaneShell` producer used by the relay reattach and both
|
||||
spawn handlers. Error policy stays at the call sites because it genuinely
|
||||
differs: a caller that just created a shell must clean it up on a failed
|
||||
durable write; a caller that merely reattached must not detach anything.
|
||||
- Mutation proven, both sub-guards isolated:
|
||||
- drop the `rememberPaneKeyForPty` call → 3 clauses redden
|
||||
- prefer the stored `tabId` over the live layout → 1 clause reddens
|
||||
- That second clause is **new**. Every pre-existing clause used one tabId on
|
||||
both sides, so a producer that simply forwarded `lease.tabId` would have gone
|
||||
green and shipped the moved-pane bug unnoticed. Only the leaf half of a pane
|
||||
key is remint-stable; `detachTerminalPaneToTab` moves a live pane and its PTY.
|
||||
- Two source-text clauses were **strengthened**: they used to require the relay
|
||||
to hold a `persistPtyBinding` call and merely forbade an ssh-partition
|
||||
argument. The relay now has none, so they assert **zero** direct binding
|
||||
writes there — a second bind producer is exactly the defect being removed.
|
||||
- Repaired a latent false green: the "persistence fails" case in
|
||||
`ssh-relay-session-reconnect-incarnation` was passing because a missing mock
|
||||
made the call throw a TypeError that happened to emit the asserted
|
||||
`console.error`. The failure is now injected at the producer.
|
||||
- Landed: `0f409055d3f` (+60 production lines — the one step that grows)
|
||||
|
||||
### S6 — Settle whether the 30s recovery grant executes at all · **PROVEN — it is dead code**
|
||||
|
||||
*Result.* It cannot execute for a real SSH pane. Verified personally:
|
||||
|
||||
- The lease stores a **relay-native** pty id, normalized on write
|
||||
(`persistence.ts:7184`, comment: "app ids are global").
|
||||
- The caller passes the **app-form** id (`orca-runtime.ts:16478` ← `toAppSshPtyId`).
|
||||
- The comparison is raw `lease.ptyId === ptyId` (`orca-runtime.ts:6310`) with no
|
||||
normalization, so the two forms can never be equal.
|
||||
- The branch is also unreachable for a local pane, since it requires an SSH lease.
|
||||
- Its covering test seeds both sides as the same literal with a null
|
||||
connectionId — a shape production cannot produce.
|
||||
|
||||
### S7 — The death rule · **DELETED on S6's evidence**
|
||||
|
||||
Not built. The arbitration machinery existed to referee a branch that never
|
||||
executes. Per the design: "if E-1 shows the grant branch unreachable, E-2 is
|
||||
deleted and the marker simply never fires, which is the safe end state."
|
||||
|
||||
### S8 — Remove the dead recovery-grant path · **PROVEN**
|
||||
|
||||
*Guarantee.* Deleting it changes no behaviour, because it has none.
|
||||
|
||||
- Oracle: `src/main/runtime/ssh-pane-recovery-grant-reachability.test.ts`, written
|
||||
and green **before** the deletion, so inertness is demonstrated rather than assumed.
|
||||
- It mints **both** id forms from the production helpers rather than as two
|
||||
hand-typed literals, so it tracks the real namespace split instead of
|
||||
restating it, and seeds a lease qualifying on every other predicate (state,
|
||||
worktree, tab, leaf, grace window). Anti-vacuity clauses pin that control
|
||||
actually reaches the gate rather than bailing out earlier.
|
||||
- Mutation proven **before** deleting: normalizing the `lease.ptyId === ptyId`
|
||||
comparison makes the grant fire and reddens the oracle. That is the exact fix
|
||||
someone would reach for, so the oracle is pinned to unreachability, not to the
|
||||
throw.
|
||||
- Deleted: the grant tail, the `terminalPaneRecoveryByIdentity` dedup map (whose
|
||||
only consumer was the grant), and the dead `ptyId` parameter.
|
||||
- **Not** deleted, because over-deleting here breaks users: `getRecentExpiredSshLease`,
|
||||
`hasRecentExpiredSshLeasePane` and `SSH_PANE_RECOVERY_GRACE_MS` all stay. Their
|
||||
other two callers pass `ptyId` undefined, which short-circuits the broken
|
||||
comparison — those are live and feed headless-mobile terminal-tab visibility.
|
||||
- Also **not** done: deleting only the gate while keeping the spawn. That would
|
||||
have granted a respawn to every disconnected pane — a change in the dangerous
|
||||
direction. The refusal is what stays.
|
||||
- Three tests pinned the grant and were **inverted**; a fourth is now
|
||||
tautological and carries a comment saying so rather than being left hollow.
|
||||
- Honest limit, recorded in the oracle: unreachable **by construction** for SSH
|
||||
panes; for a local pane, unreachable only up to a random-UUID collision.
|
||||
- Landed: `7d44315437a` (−32 production lines)
|
||||
|
||||
### Product affordance (D1) — **SHIPPED with S3**
|
||||
|
||||
A pane that cannot be verified renders as disconnected with two explicit actions
|
||||
— "Try again" and "Start a new terminal" — instead of silently respawning.
|
||||
|
||||
- No new IPC channel: the silent-respawn decision was always renderer-local.
|
||||
Both actions are things the code already did, moved behind a click.
|
||||
- Copy constraint enforced **as an oracle**, not as a review note: the rendered
|
||||
text must match no death verb and show no wire token. STYLEGUIDE.md:236 already
|
||||
forbids result verbs without result data, and a failed attach is not result data.
|
||||
- `TerminalRemoteRuntimeReconnectBanner` → `TerminalPaneDisconnectedBanner`; it
|
||||
now serves any transport. Existing i18n key strings kept verbatim so no shipped
|
||||
translation breaks; the SSH copy is additive.
|
||||
- Landed: `5e1b57bb2f9` (+70 production lines)
|
||||
|
||||
---
|
||||
|
||||
## Global goalposts
|
||||
|
||||
### G1 — Net production code is negative · **NOT MET (+83 this phase)**
|
||||
|
||||
Counted from the pre-work merge base, production only. Reported as a miss rather
|
||||
than reframed until it passes.
|
||||
|
||||
| Step | Net production |
|
||||
| --- | --- |
|
||||
| S3 — collapse the fabricated exit | **−21** |
|
||||
| S4 — one partition | +9 |
|
||||
| S5 — one bind producer | **+60** |
|
||||
| S8 — delete the dead grant | **−32** |
|
||||
| D1 affordance (new product surface) | **+70** |
|
||||
| Banner rename (pure move, no behaviour) | +36 of the above, not real growth |
|
||||
| **Correctness work only (S3–S8)** | **+13** |
|
||||
| **Total this phase** | **+83** |
|
||||
|
||||
Why it missed. The target assumed the remaining steps were collapses. Three were.
|
||||
Two were not, for reasons worth stating:
|
||||
|
||||
- **S5 (+60).** One producer replacing three divergent call sites only shrinks
|
||||
the code if those sites were duplicating the whole bind. They were not — they
|
||||
each did *part* of it differently, which is exactly the defect. The producer is
|
||||
net-new; the call sites shrank by 7 lines. Collapsing the two spawn handlers'
|
||||
surrounding logic as well would buy the difference back, but it would reorder
|
||||
side effects that existing ordering assertions pin, so it was left alone.
|
||||
- **D1 affordance (+70).** A new user-facing surface the owner approved. Features
|
||||
add lines; counting it against a refactor budget would be the wrong pressure —
|
||||
the way to make this number go negative would be to delete the affordance.
|
||||
|
||||
Where the remaining slack is, if the goal is to be met later: steps P and K of
|
||||
the design (the leaf-keyed record replacing `SshRemotePtyLease`, and the lease
|
||||
readers ported onto it) are the deletions this phase deferred.
|
||||
|
||||
### G2 — No redeploy required for the user-visible fixes · **PROVEN for S1–S8**
|
||||
|
||||
Fixes must work against relays already installed on people's hosts. Every step
|
||||
that landed is client-side only:
|
||||
|
||||
- S1, S2 — proven previously.
|
||||
- S3 — deletes client-side reactions to a relay message; the relay is unchanged.
|
||||
- S4 — client-local persistence layout only; nothing crosses the wire.
|
||||
- S5 — client-local in-memory fence maps and the durable binding.
|
||||
- S8 — deletes a client-side branch.
|
||||
- D1 — renderer only.
|
||||
|
||||
**No relay redeploy is required for any of it**, which matters because the
|
||||
reported failure is happening on hosts people have already deployed.
|
||||
|
||||
### G3 — Wire compatibility · **SATISFIED — nothing on the wire changed**
|
||||
|
||||
Clients and hosts update independently. This phase added **no** RPC parameter,
|
||||
no stream opcode and no published field, so there is nothing to negotiate. The
|
||||
orphan projection that would have added one optional field belongs to step W,
|
||||
which is not in this phase.
|
||||
|
||||
One wire-adjacent behaviour change is worth naming for reviewers: `terminal.recoverPane`
|
||||
(RPC) now always refuses for a disconnected pane. The method still exists and its
|
||||
signature is unchanged, so older clients get a refusal rather than a
|
||||
`method_not_found` — and the renderer already handles refusal, because that is
|
||||
what the branch did in practice anyway.
|
||||
|
||||
### G4 — Cross-platform · **SATISFIED by construction**
|
||||
|
||||
macOS, Linux, Windows, WSL. No oracle added in this phase shells out, and none
|
||||
uses `echo $$` or `ps` — they assert against persisted state, in-memory maps and
|
||||
rendered text. No rule that landed concludes anything from a relay restart or
|
||||
from whether a shell survives a hard relay death, which is the Windows-divergent
|
||||
question the design deliberately refuses to depend on. The affordance copy holds
|
||||
on every platform precisely because it never claims the shell died.
|
||||
|
||||
### G5 — Every guard is proven live on the production route · **PROVEN for S1–S8**
|
||||
|
||||
For each new guard, an oracle must redden when the **producer** is removed — not
|
||||
only when the guard itself is removed.
|
||||
|
||||
This exists because it had failed three times here: an inert `mayCreate`, a
|
||||
keystroke fence inert on reattach, and a respawn gate on a minority path. S5 is
|
||||
the remediation of the second.
|
||||
|
||||
**It nearly failed a fourth time in this phase, and the rule caught it.** S4's
|
||||
reader guard (`durablyBoundPtyIdForPane` reading one partition) initially showed
|
||||
*no* mutation response: restoring the ssh-first hedge left all six clauses green,
|
||||
because the load fold had already erased the divergent copy at boot. The guard
|
||||
was correct and would have shipped unproven. The fix was an added clause that
|
||||
rewrites the ssh partition **mid-session**, which reddens on the hedge and pins
|
||||
the reader independently of the fold.
|
||||
|
||||
Two further near-misses found and closed the same way:
|
||||
- S5's clauses all used one tabId on both sides, so "compose the paneKey from
|
||||
the current tab" was unpinned — a producer forwarding `lease.tabId` would have
|
||||
gone green. A moved-pane clause now pins it.
|
||||
- A case in `ssh-relay-session-reconnect-incarnation` was a **latent false
|
||||
green**: it passed because a missing mock threw a TypeError that happened to
|
||||
emit the `console.error` it asserted. Now injected at the producer.
|
||||
|
||||
### G6 — No `max-lines` disables · **HOLDING**
|
||||
|
||||
Per AGENTS.md. No per-file bumps either.
|
||||
|
||||
---
|
||||
|
||||
## Definition of done
|
||||
|
||||
| | Criterion | Status |
|
||||
| --- | --- | --- |
|
||||
| 1 | S1–S5 and S7 PROVEN, or S7 deleted on S6's evidence | **MET** — S1–S6 and S8 proven; S7 deleted on S6's evidence |
|
||||
| 2 | G1–G6 satisfied, each with evidence recorded here | **PARTIAL** — G2–G6 met; **G1 missed at +83**, accounted for above |
|
||||
| 3 | The reported failures covered by an oracle that reddens without the fix | **MET.** Pane cardinality: S4's ten-reconnect clause, plus an E2E spec driving three real reconnects against a Docker relay (passes). Duplicate resume: the classification clauses, plus an E2E spec where the **host itself** records every shell launch (passes). Writing the second one is what exposed that RC2 was still live — see below |
|
||||
| 4 | No guard shipped without a producer-side mutation proving it is reachable | **MET** — and it caught one guard that would otherwise have shipped unproven (see G5) |
|
||||
|
||||
### Found after the goalposts were met
|
||||
|
||||
Two defects surfaced *after* every step goalpost was proven, and both were invisible to the unit
|
||||
oracles that had already gone green. Recorded because they are the most useful evidence in this
|
||||
phase about where these oracles stop reaching.
|
||||
|
||||
**RC2 survived S3 — found by writing the E2E test.** `7cd7fef927f`.
|
||||
The reported duplicate agent resume was not fixed. `reattachSshPtySession` maps every relay
|
||||
not-found to `SSH_SESSION_EXPIRED`, and `isProvenSshSessionGoneError` returned true for it, so the
|
||||
renderer's reattach arm still respawned. The E2E harness produced the shape directly: a stalled
|
||||
relay is superseded by a fresh one with no memory of `pty-1`, while the old shells keep running
|
||||
under the stopped process. The new relay answers not-found for a live shell.
|
||||
|
||||
A not-found proves an exit only if the relay process that minted the pty is the one answering —
|
||||
design D3 row 2, gated on `relayInstanceId`, which step E-2 never built. `SSH_SESSION_EXPIRED` is
|
||||
not independent evidence: its only producer is that same mapping, and the token's own doc comment
|
||||
claimed "the host proved the session is gone", which it never did.
|
||||
|
||||
So `isProvenSshSessionGoneError` returns false. This is also what makes the D1 affordance
|
||||
load-bearing rather than near-unreachable. The respawn tails are deliberately kept — the design
|
||||
preserves the grant as a conditional for E-2 — with a clause pinning that nothing reaches them.
|
||||
|
||||
**The fold left half a fence behind — found by adversarial review.** `994733d8b1a`.
|
||||
`persistPtyBinding` writes the binding and its incarnation into the same partition, and the
|
||||
incarnation is what its CAS compares. The fold moved only `ptyIdsByLeafId`, so after upgrade the
|
||||
guard stayed in the partition nothing reads and the CAS compared `undefined` against `undefined`.
|
||||
Not data loss and not a wrong-shell bind, but a guard silently weakened by a migration is the exact
|
||||
shape this program keeps finding.
|
||||
|
||||
### Residuals, stated rather than closed quietly
|
||||
|
||||
- **Orphan adoption still writes the `ssh:<target>` partition.**
|
||||
`adoptTerminalOrphansFromInventory` (via `tryGetWorkspaceSessionHostIdForWorktree`)
|
||||
writes pane bindings there. S4's reader ignores that partition, so supersession
|
||||
is unaffected and the guarantee holds — but the write now lands somewhere
|
||||
nothing reads. Harmless today, and a trap for whoever touches it next. Belongs
|
||||
with step W.
|
||||
- **The load fold moves leaf bindings only.** It deliberately does not touch
|
||||
`tabsByWorktree[*].ptyId` in the ssh partition: that field is a tab-level
|
||||
pointer no supersession path reads, and nulling it without a local counterpart
|
||||
to move it to would be data loss. Narrowing the fold to what the defect
|
||||
actually requires is why `reassignSshTargetId` needed no inversion.
|
||||
- **The main-side detached branch still has no renderer signal.** S3 made the
|
||||
main process stop lying; the affordance covers the renderer-initiated reattach
|
||||
arms. A relay-driven reattach failure remains silent to the user. Closing it
|
||||
needs a `pty:detached` channel — deferred deliberately, not overlooked.
|
||||
|
||||
## Deliberately out of scope
|
||||
|
||||
- The data-plane work (transport delivery guarantee, binary payloads, one credit
|
||||
ledger). Real and separately justified, but independent of these goalposts.
|
||||
- Rebuilding the authority architecture rejected earlier at +60,903 lines.
|
||||
|
||||
## Owner decisions
|
||||
|
||||
| | Decision | Status |
|
||||
| --- | --- | --- |
|
||||
| D1 | An unverifiable pane becomes visibly disconnected with two actions, instead of silently respawning | **Approved 2026-08-09** (implicit in approving the design; flagged for correction if not intended) |
|
||||
| D2 | Whether the older gate/journey framing is rescoped or retired now that this design supersedes it | **Open** |
|
||||
@@ -0,0 +1,341 @@
|
||||
# Related GitHub work
|
||||
|
||||
This is a curated overlap map, not a merge queue. States were verified against
|
||||
GitHub on 2026-08-08 UTC and must be refreshed before any rebase, merge, close,
|
||||
or implementation decision.
|
||||
|
||||
Rules:
|
||||
|
||||
- Do not stack PRs merely because they appear in the same section.
|
||||
- “Conflict” means semantic or file overlap that requires one coherent design,
|
||||
not an instruction to merge both.
|
||||
- A related PR can supply diagnosis, tests, or a small reusable primitive
|
||||
without becoming a dependency.
|
||||
- Landing a related PR does not promote G0–G7 or one of the thirteen journeys
|
||||
unless the final converged tree passes that gate's complete proof.
|
||||
- GitHub issue/PR text is context, not authority over the user's goal.
|
||||
|
||||
## Immediate incident and competing implementations
|
||||
|
||||
- [Issue #11729 — SSH remote environment not restored after login on Rocky Linux
|
||||
9.4](https://github.com/stablyai/orca/issues/11729) — **open**. This is the
|
||||
named STA-3077 customer incident. Its final acceptance must include Rocky
|
||||
Linux SSH, repeated reconnect, the same PID/process/session/transcript
|
||||
identity, restored workspace environment, visible pane count, and actual
|
||||
remote relay PTY/process count.
|
||||
- [PR #13111 — stop reconnect grafting panes, stacking leases, and respawning
|
||||
live shells](https://github.com/stablyai/orca/pull/13111) — **open**, current
|
||||
branch, green checks, no approval. It is narrow containment and leaves remote
|
||||
shell reclamation unresolved. One major Docker settle review thread remains
|
||||
[unresolved](https://github.com/stablyai/orca/pull/13111#discussion_r3739665033).
|
||||
- [PR #12264 — stop reconnect from grafting ghost terminal
|
||||
panes](https://github.com/stablyai/orca/pull/12264) — **open and conflicting**.
|
||||
It targets the same incident and overlaps persistence, PTY, SSH relay, and E2E
|
||||
files. Mine its diagnosis and discriminating artifacts; do not stack it
|
||||
wholesale with #13111.
|
||||
- [PR #12743 — contain reattach to existing panes, quarantine
|
||||
orphans](https://github.com/stablyai/orca/pull/12743) — **open and
|
||||
conflicting**. It shares `mayCreate: false` and broader relay coverage, but
|
||||
quarantine must be judged against the no-reconciliation final design.
|
||||
- [PR #12456 — reattach on `restoreRequired` instead of expiring a live
|
||||
session](https://github.com/stablyai/orca/pull/12456) — **open**. It addresses
|
||||
the #12448 branch through retry rather than cold respawn. Treat it as a
|
||||
semantic alternative or complementary recovery path only after choosing one
|
||||
exact restore protocol.
|
||||
- [PR #9820 — reap orphaned relay PTYs before session-cap
|
||||
exhaustion](https://github.com/stablyai/orca/pull/9820) — **open**. Its
|
||||
time-based remote kill conflicts with unknown-is-not-dead and #13111's
|
||||
deliberate non-kill behavior. Use it as incident evidence, not a safe default
|
||||
dependency.
|
||||
- [PR #12798 — opt-in zmx-backed durable SSH
|
||||
terminals](https://github.com/stablyai/orca/pull/12798) — **open**. This is a
|
||||
broad architectural alternative for final remote PTY ownership with direct
|
||||
file overlap. Evaluate capability-by-capability; never blend it into the
|
||||
current model accidentally.
|
||||
|
||||
## Open incident issues that should become explicit oracles
|
||||
|
||||
- [Issue #9819 — SSH relay leaks orphaned PTYs until the 50-session
|
||||
cap](https://github.com/stablyai/orca/issues/9819) — explicit close,
|
||||
disconnected close with a lost shutdown response, and worktree teardown must
|
||||
durably retry retirement of the exact lease and process. Uncertain sessions
|
||||
must survive visibly until resolved. Repeated reconnect must reclaim actual
|
||||
relay PIDs/session slots rather than accumulate toward the cap.
|
||||
- [Issue #9034 — SSH reconnect repeatedly spawns detached PTYs for one
|
||||
pane](https://github.com/stablyai/orca/issues/9034) — count actual remote PTYs
|
||||
and prove bounded one-pane ownership across repeated reconnect.
|
||||
- [Issue #11006 — transient SSH disconnect treats live relay PTYs as dead and
|
||||
duplicate-respawns agents](https://github.com/stablyai/orca/issues/11006) —
|
||||
disconnect with a live process must remain unresolved/reattachable and must
|
||||
not start another agent.
|
||||
- [Issue #12447 — closed SSH tabs resurrect and auto-resume old
|
||||
sessions](https://github.com/stablyai/orca/issues/12447) — explicit close must
|
||||
persist its pending tombstone before a kill RPC, survive a lost reply plus app
|
||||
and relay restart, retry after handshake, reclaim the exact remote PTY, and
|
||||
never recreate UI or automatic resume.
|
||||
- [Issue #12448 — `restoreRequired` is misreported as session
|
||||
expiry](https://github.com/stablyai/orca/issues/12448) — source recovery must
|
||||
replay/repaint on the same PID and binding without starting a replacement
|
||||
`--resume`; identity drift, timeout, or unknown state must not become proof of
|
||||
process death or cold resume authority.
|
||||
- [Issue #12699 — SSH sleeping-agent wake sweep forks live remote
|
||||
sessions](https://github.com/stablyai/orca/issues/12699) — all wake/resume
|
||||
paths must share the same exact liveness and identity contract.
|
||||
- [Issue #12683 — remote-runtime disconnect spends recovery on stale same-handle
|
||||
reattach](https://github.com/stablyai/orca/issues/12683) — paired/web terminal
|
||||
recovery must fence generations and retain automatic liveness.
|
||||
- [Issue #10208 — duplicate terminal tab on worktree session
|
||||
restore](https://github.com/stablyai/orca/issues/10208) — local/worktree
|
||||
restore must prove the same pane and process rather than creating a duplicate.
|
||||
|
||||
These issues are separate customer-visible symptoms. A single structural model
|
||||
should make their shared invalid transitions impossible, while each issue keeps
|
||||
its own discriminating regression journey.
|
||||
|
||||
## Daemon, relay generation, and liveness work
|
||||
|
||||
The acceptance matrix must also reproduce these open lifecycle incidents:
|
||||
|
||||
- [Issue #11904 — tab close with a broken binding orphans the daemon
|
||||
session](https://github.com/stablyai/orca/issues/11904) — close intent must be
|
||||
durable before transport work and eventually retire the exact process.
|
||||
- [Issue #8585 — detached relay generations are never
|
||||
reaped](https://github.com/stablyai/orca/issues/8585) — termination must name
|
||||
the exact relay generation; path/PID reuse or a failed connection cannot kill
|
||||
a successor.
|
||||
- [Issue #9138 — updates leave old daemon generations and sessions
|
||||
alive](https://github.com/stablyai/orca/issues/9138) and
|
||||
[issue #11342 — stale daemon/PTY generations leak across upgrade and
|
||||
close](https://github.com/stablyai/orca/issues/11342) — upgrade must converge
|
||||
without invisible sessions, guessed ownership, or unbounded retention.
|
||||
- [Issue #10415 — Windows daemon crash on unkillable PTY and protocol
|
||||
bump](https://github.com/stablyai/orca/issues/10415) — native Windows must
|
||||
preserve live old-generation sessions or expose exact unresolved recovery;
|
||||
one unkillable PTY cannot crash the daemon.
|
||||
- [Issue #8275 — worktree teardown kills unrelated split-pane
|
||||
sessions](https://github.com/stablyai/orca/issues/8275) — teardown and failure
|
||||
must remain exact-namespace/exact-binding scoped.
|
||||
|
||||
- [PR #12882 — publisher-owned daemon endpoint
|
||||
replacement](https://github.com/stablyai/orca/pull/12882) — **open and
|
||||
mergeable** when checked. It provides single-writer endpoint publication and
|
||||
three-valued liveness. Reconcile it before editing overlapping daemon paths;
|
||||
do not assume the older handoff's merge order is still current.
|
||||
- [PR #11622 — fence daemon session ownership across
|
||||
generations](https://github.com/stablyai/orca/pull/11622) — **open** and held
|
||||
behind further audit per its description. Strong explicit
|
||||
owned/unavailable/ambiguous semantics, but broad overlap makes it design input,
|
||||
not an automatic dependency.
|
||||
- [PR #9833 — report local PTY inventory
|
||||
readiness](https://github.com/stablyai/orca/pull/9833) — **open**. Its
|
||||
pending/ready/stale distinction is reusable context for unknown-is-not-dead.
|
||||
- [PR #12760 — reap only the proven relay
|
||||
generation](https://github.com/stablyai/orca/pull/12760) — **open**. It adds
|
||||
exact relay generation ownership before termination and conflicts in
|
||||
`ssh-relay-session.ts`; reconcile before designing remote shell cleanup.
|
||||
- [PR #8618 — reap a detached relay after failed
|
||||
reconnect](https://github.com/stablyai/orca/pull/8618) — **open** and an older
|
||||
competing predecessor to #12760. Do not combine both ownership schemes.
|
||||
- [PR #12749 — wait for a disconnected PTY owner's full
|
||||
grace](https://github.com/stablyai/orca/pull/12749) — **open**. Useful owner
|
||||
admission and grace context; timers may schedule retry but cannot independently
|
||||
authorize takeover or destruction.
|
||||
- [PR #12702 — stop sleeping-agent wake sweep from forking live SSH
|
||||
sessions](https://github.com/stablyai/orca/pull/12702) — **open**. Complementary
|
||||
#12699 work that must converge on the same ownership/liveness behavior; it
|
||||
need not share an internal primitive unless the reconciled design calls for
|
||||
one.
|
||||
- [PR #10118 — durable persistence write
|
||||
seam](https://github.com/stablyai/orca/pull/10118) — **open**. It overlaps
|
||||
`persistence.ts` and is relevant to durability and main-thread performance;
|
||||
reconcile before large persistence changes.
|
||||
|
||||
## Paired runtime, remote server, and E2EE lifecycle work
|
||||
|
||||
These open issues are mandatory inputs to the paired/remote/platform journeys:
|
||||
|
||||
- [Issue #11495 — paired viewer loses attachments across update/restart while
|
||||
host PTYs remain alive](https://github.com/stablyai/orca/issues/11495) — the
|
||||
reported large host population is a strong restore/scale oracle; each pane
|
||||
must return to the same host PTY/process.
|
||||
- [Issue #11265 — paired terminals stall despite live TCP
|
||||
sockets](https://github.com/stablyai/orca/issues/11265) — transport connectivity
|
||||
is not delivery liveness; recovery must resume output without an unrelated RPC
|
||||
kick.
|
||||
- [Issue #11803 — remote stop respawns tabs from a stale
|
||||
owner](https://github.com/stablyai/orca/issues/11803) — exact durable retirement
|
||||
must remain retired after host/client restart.
|
||||
- [Issue #12241 — paired-host session partitions grow without
|
||||
bound](https://github.com/stablyai/orca/issues/12241) — paired and scale
|
||||
journeys must prove bounded state and safe lost-consumer
|
||||
retirement/compaction liveness.
|
||||
- [Issue #11574 — same-server re-pairing changes host identity and strands
|
||||
sessions](https://github.com/stablyai/orca/issues/11574) — stable final-host
|
||||
identity must survive re-pairing without adopting another host.
|
||||
- [Issue #9827 — investigate WSL session
|
||||
restoration](https://github.com/stablyai/orca/issues/9827) and
|
||||
[issue #11339 — WSL commands are not restored after
|
||||
restart](https://github.com/stablyai/orca/issues/11339) — the daemon/WSL
|
||||
journey must run on physical WSL and preserve both shell commands and agent
|
||||
sessions.
|
||||
|
||||
Additional open incident inputs must be triaged into a named journey or ruled
|
||||
unrelated with explicit evidence and user acceptance:
|
||||
|
||||
| Issue | Required question |
|
||||
| ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| [#11800 — paired null-PTY terminal ghosts](https://github.com/stablyai/orca/issues/11800) | Does host topology advancement retire exact placeholders without inference or a permanent timer reconciler? |
|
||||
| [#9092 — remote runtime fails after macOS sleep](https://github.com/stablyai/orca/issues/9092) | Does generation-fenced reconnect recover after sleep with bounded resources and no identity change? |
|
||||
| [#9585 — remote ghost tabs after host restart](https://github.com/stablyai/orca/issues/9585) | Are exit/retirement outcomes durable and idempotent across host restart? |
|
||||
| [#9562 — remote host session restarts empty](https://github.com/stablyai/orca/issues/9562) | Does the complete boundary plus replay restore terminal state after server restart? |
|
||||
| [#12568 — no PTY provider after SSH relay recovery/update](https://github.com/stablyai/orca/issues/12568) | Does lazy rediscovery restore the exact host/provider generation? |
|
||||
| [#10385 — mobile remains falsely connected after RPC stalls](https://github.com/stablyai/orca/issues/10385) | Can E2EE/mobile liveness recover when transport looks connected but RPC is stalled? |
|
||||
| [#12140 — mobile pairing socket closes immediately](https://github.com/stablyai/orca/issues/12140) | Do capability, identity, and E2EE negotiation fail explicitly and recover across LAN/Tailscale? |
|
||||
| [#8129 — mobile events lost while disconnected](https://github.com/stablyai/orca/issues/8129) | Are semantic outcomes durable and replayed to a disconnected/backgrounded consumer? |
|
||||
|
||||
- [PR #11575 — preserve host identity across same-server
|
||||
re-pairing](https://github.com/stablyai/orca/pull/11575) — **open**. Direct
|
||||
identity context for the paired reconnect journey.
|
||||
- [PR #12768 — restore paired snapshots at the source
|
||||
grid](https://github.com/stablyai/orca/pull/12768) — **open**. Complementary
|
||||
host-snapshot authority work with renderer file overlap.
|
||||
- [PR #9093 — recover remote runtime streams after client
|
||||
wake](https://github.com/stablyai/orca/pull/9093) — **open**. Provides
|
||||
generation-cancelled bounded-backoff reconnect context.
|
||||
- [PR #10235 — self-heal remote subscriptions after server
|
||||
restart](https://github.com/stablyai/orca/pull/10235) — **open**. Relevant to
|
||||
E2EE session loss hidden by a live relay transport.
|
||||
- [PR #11596 — recover falsely connected mobile RPC
|
||||
sessions](https://github.com/stablyai/orca/pull/11596) — **open and
|
||||
conflicting**. Broad mobile/E2EE reconnect context; do not absorb its parallel
|
||||
lifecycle machinery without a full state-machine audit.
|
||||
- [PR #12036 — stop blocking E2EE handshake on synchronous ACL
|
||||
writes](https://github.com/stablyai/orca/pull/12036) — **open and conflicting**.
|
||||
Preserve its metadata-versus-correctness durability distinction and measured
|
||||
Windows performance constraint during identity lifecycle work.
|
||||
- [PR #12134 — refresh stale capabilities before
|
||||
pairing](https://github.com/stablyai/orca/pull/12134) — **open**. Capability
|
||||
freshness and reauthentication are required mixed-version/pairing context.
|
||||
- [PR #11804 — make remote stop retirement
|
||||
durable](https://github.com/stablyai/orca/pull/11804) — **open**. Strong
|
||||
complementary evidence for exact persisted owner routing and host-side
|
||||
retirement acknowledgement.
|
||||
- [PR #12987 — distinguish an absent worktree from an empty tab
|
||||
list](https://github.com/stablyai/orca/pull/12987) — **open**. Reusable
|
||||
unknown-versus-authoritative-empty semantics.
|
||||
- [PR #12751 — preserve tabs when a snapshot reports
|
||||
none](https://github.com/stablyai/orca/pull/12751) — **open** and a competing
|
||||
less-exact alternative to #12987; its described multi-client non-convergence
|
||||
must not become the final model.
|
||||
- [PR #12339 — stop remote workspace pulls from resurrecting closed
|
||||
tabs](https://github.com/stablyai/orca/pull/12339) — **open**. Direct topology
|
||||
reconciliation context for explicit close and stale snapshots.
|
||||
- [PR #13013 — stop stale snapshot applies from erasing fresh terminal
|
||||
tabs](https://github.com/stablyai/orca/pull/13013) — **open**. Relevant legacy
|
||||
snapshot race; decide whether its machinery is deleted by the final authority
|
||||
cutover rather than layering another permanent reconciler.
|
||||
- [PR #12903 — retry snapshot tabs whose paths resolve after
|
||||
apply](https://github.com/stablyai/orca/pull/12903) — **open**. Deferred
|
||||
hydration is relevant legacy-importer context and may become deletable after
|
||||
authoritative namespace resolution; do not retain it by default.
|
||||
|
||||
## Delivery, backpressure, and lifecycle alternatives
|
||||
|
||||
- [Draft PR #12220 — keep SSH alive when a terminal outruns a slow
|
||||
link](https://github.com/stablyai/orca/pull/12220) — **open draft**. Useful G3
|
||||
and performance evidence, but its own description has behavioral blockers and
|
||||
a stalled-consumer wedge. Do not treat it as merge-ready.
|
||||
- [PR #10744 — durable lost-worker archive and mixed-version SSH
|
||||
revive](https://github.com/stablyai/orca/pull/10744) — **open** and broad. It
|
||||
is major architectural context for exact close, outcomes, retirement, and
|
||||
mixed versions, with substantial overlap. Mine proof and semantics rather
|
||||
than stacking blindly.
|
||||
- [PR #11801 — prune stale remote tab
|
||||
ghosts](https://github.com/stablyai/orca/pull/11801) — **open**. Its snapshot
|
||||
omission plus timer policy is relevant but may conflict with the rule that
|
||||
absence/time alone cannot prove destructive cleanup.
|
||||
|
||||
## Mandatory issue-to-journey matrix
|
||||
|
||||
This table binds customer incidents to the thirteen journeys in
|
||||
[`goalposts.md`](./goalposts.md#thirteen-required-production-journeys). Closing
|
||||
an issue or merging a narrow PR does not remove its oracle. Each row must be red
|
||||
on an unfixed baseline and green on the final candidate, or the user must accept
|
||||
explicit evidence that the issue is unrelated.
|
||||
|
||||
| Open issue(s) | Journey(s) | Mandatory discriminating outcome |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [#11729](https://github.com/stablyai/orca/issues/11729) | 6, 9, 13 | Rocky Linux reconnect preserves the same workspace, transcript, pane, binding, PID, and environment without PTY growth. |
|
||||
| [#9819](https://github.com/stablyai/orca/issues/9819), [#9034](https://github.com/stablyai/orca/issues/9034) | 6, 13 | Reconnect cannot accumulate detached PTYs/session slots; explicit close and teardown reclaim the exact process, including lost-response retry. |
|
||||
| [#11006](https://github.com/stablyai/orca/issues/11006), [#12448](https://github.com/stablyai/orca/issues/12448), [#12699](https://github.com/stablyai/orca/issues/12699) | 2, 6 | Disconnect, source recovery, and wake keep the same live PID/session and never start a duplicate resume without proof of death. |
|
||||
| [#12447](https://github.com/stablyai/orca/issues/12447), [#11904](https://github.com/stablyai/orca/issues/11904) | 2, 6 | Durable close intent survives lost RPC, app/daemon/relay restart, retries exact retirement, reclaims the process, and never resurrects UI. |
|
||||
| [#10208](https://github.com/stablyai/orca/issues/10208) | 1, 9 | Worktree switch/restore preserves one exact pane/process and does not duplicate a tab. |
|
||||
| [#8585](https://github.com/stablyai/orca/issues/8585) | 2, 6, 12 | Cleanup affects only the proven relay generation across reconnect and version change; ID/path reuse cannot kill a successor. |
|
||||
| [#9138](https://github.com/stablyai/orca/issues/9138), [#11342](https://github.com/stablyai/orca/issues/11342) | 2, 12, 13 | Upgrade/restart converges old daemon generations and their PTYs without invisible sessions, leaks, or destructive guessing. |
|
||||
| [#8275](https://github.com/stablyai/orca/issues/8275) | 1, 2, 5 | Worktree teardown cannot terminate a sibling namespace or split-pane session. |
|
||||
| [#10415](https://github.com/stablyai/orca/issues/10415) | 1, 2, 5, 12 | Native Windows handles an unkillable PTY and protocol bump without daemon crash, live-session loss, or cross-session damage. |
|
||||
| [#9827](https://github.com/stablyai/orca/issues/9827), [#11339](https://github.com/stablyai/orca/issues/11339) | 2, 9 | Physical WSL restart restores the same shell command and agent session under the exact namespace/binding. |
|
||||
| [#12568](https://github.com/stablyai/orca/issues/12568) | 3, 6, 12 | Relay recovery/update lazily rediscovers the same SSH host/provider and restores only its exact sessions. |
|
||||
| [#11495](https://github.com/stablyai/orca/issues/11495), [#11574](https://github.com/stablyai/orca/issues/11574) | 4, 8, 10, 12, 13 | Paired restart/re-pair preserves stable host identity and every reported live host PTY at scale across independent updates. |
|
||||
| [#11265](https://github.com/stablyai/orca/issues/11265), [#9092](https://github.com/stablyai/orca/issues/9092) | 8, 13 | Live-but-stalled transport and sleep/wake automatically resume bounded delivery without an unrelated RPC kick or identity replacement. |
|
||||
| [#11803](https://github.com/stablyai/orca/issues/11803), [#9585](https://github.com/stablyai/orca/issues/9585), [#11800](https://github.com/stablyai/orca/issues/11800) | 3, 8 | Remote stop/host topology outcome remains durably retired after restart; no stale-owner tab or null-PTY ghost returns. |
|
||||
| [#9562](https://github.com/stablyai/orca/issues/9562), [#8129](https://github.com/stablyai/orca/issues/8129) | 3, 8, 13 | Complete boundary plus replay restores disconnected/restarted consumers without an empty session or lost semantic outcome. |
|
||||
| [#12241](https://github.com/stablyai/orca/issues/12241) | 8, 11, 13 | Paired partitions and retained-consumer state remain bounded; lost-consumer retirement safely restores compaction liveness. |
|
||||
| [#12683](https://github.com/stablyai/orca/issues/12683) | 8, 12 | Stale same-handle reconnect is generation-fenced and automatic recovery remains live across independent peer versions. |
|
||||
| [#10385](https://github.com/stablyai/orca/issues/10385), [#12140](https://github.com/stablyai/orca/issues/12140) | 8, 10, 12 | False-connected or rejected E2EE/pairing state fails explicitly and reauthenticates without stale identity, capability, or session adoption. |
|
||||
|
||||
Journeys 4, 5, 7, 10, and 11 remain mandatory even where no single issue fully
|
||||
specifies them. This matrix adds incident oracles; it never narrows the journey
|
||||
definitions.
|
||||
|
||||
## Original containment/refactor line
|
||||
|
||||
- [PR #12600 — break Activity and terminal React 185
|
||||
loops](https://github.com/stablyai/orca/pull/12600) — **merged**. It is current
|
||||
containment/background context, not the whole program and not a release gate.
|
||||
- [PR #12634 — replace React 185 timers with structural
|
||||
handoffs](https://github.com/stablyai/orca/pull/12634) — **open**. This is the
|
||||
direct structural follow-up for deleting #12600's window/timer brakes and must
|
||||
be reconciled with the broader terminal-session model.
|
||||
- [PR #13110 — sandboxed preload artifact
|
||||
guard](https://github.com/stablyai/orca/pull/13110) — **open**, independent of
|
||||
#13111, green at its head, no approval. Its unresolved major review says raw
|
||||
source matching can reject safe strings/comments; resolve or supersede that
|
||||
[finding](https://github.com/stablyai/orca/pull/13110#discussion_r3739646949)
|
||||
before calling it ready.
|
||||
|
||||
## Already merged base context
|
||||
|
||||
These were described as open in older notes but were merged when this handoff
|
||||
was written:
|
||||
|
||||
- [PR #12474 — isolate same-path folder workspace PTY
|
||||
identity](https://github.com/stablyai/orca/pull/12474)
|
||||
- [PR #12477 — stop a closed remote workspace window reading
|
||||
Ready](https://github.com/stablyai/orca/pull/12477)
|
||||
- [PR #12600 — React/terminal loop containment](https://github.com/stablyai/orca/pull/12600)
|
||||
|
||||
Refresh `main` and recalculate conflicts; do not follow the stale sequence
|
||||
`#12882 → #12474 → #12477 → #13111` as written.
|
||||
|
||||
## Required GitHub refresh before action
|
||||
|
||||
At minimum, re-query:
|
||||
|
||||
```bash
|
||||
gh pr view 13111 --repo stablyai/orca --json state,isDraft,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup,headRefOid,baseRefOid,url
|
||||
gh pr view 13110 --repo stablyai/orca --json state,isDraft,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup,headRefOid,baseRefOid,url
|
||||
gh issue view 11729 --repo stablyai/orca --json state,title,updatedAt,url
|
||||
```
|
||||
|
||||
Refresh the complete catalog in one batched request:
|
||||
|
||||
```bash
|
||||
handoff_query_text="$(tr '\n' ' ' < docs/reference/terminal-session-correctness-handoff-2026-08-07/github-overlap-query.graphql)"
|
||||
gh api graphql -f query="$handoff_query_text"
|
||||
```
|
||||
|
||||
The query includes current PR state/check summaries and unresolved-thread state
|
||||
for #13110/#13111. Read PR bodies/diffs only for the slice being changed. Do not
|
||||
close, comment, merge, push, or checkout a PR from the handoff audit without
|
||||
explicit authorization.
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,275 @@
|
||||
# Resume plan and validation record
|
||||
|
||||
This plan preserves the comprehensive objective while allowing independently
|
||||
safe stacks. It does not require reviving the 814-path construction snapshot.
|
||||
|
||||
## First safe commands
|
||||
|
||||
Run from the primary worktree before editing:
|
||||
|
||||
```bash
|
||||
pwd
|
||||
git status --short --branch --untracked-files=all
|
||||
git rev-parse HEAD origin/main
|
||||
git rev-list --left-right --count HEAD...origin/main
|
||||
git branch --list 'nwparker/react185-authority-snapshot'
|
||||
git show --stat --oneline fddb19f6977
|
||||
gh pr view 13111 --repo stablyai/orca --json state,isDraft,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup,headRefOid,baseRefOid,url
|
||||
```
|
||||
|
||||
Expected handoff checkpoint before other changes:
|
||||
|
||||
- branch `nwparker/sta-3077-reattach-pane-cardinality`;
|
||||
- HEAD `5369479be2953f45cea9ab5cfcde756dd9660548`;
|
||||
- snapshot branch and commit present;
|
||||
- only this handoff folder and its `.gitignore` allow-list are new handoff work.
|
||||
|
||||
If the branch, HEAD, or dirty paths differ, inventory and preserve them before
|
||||
continuing. Never reset, clean, overwrite, or switch away from unknown user work.
|
||||
|
||||
## Phase 0 — Reconcile reality before design or code
|
||||
|
||||
1. Refresh `origin/main` with `git fetch origin main`—never `git pull`—and
|
||||
refresh the live state/reviews of every PR in
|
||||
[`related-open-work.md`](./related-open-work.md) that touches the intended
|
||||
slice.
|
||||
2. Recalculate the candidate merge base, categorized LOC, changed-file overlap,
|
||||
and failing/obsolete tests.
|
||||
3. Freeze the program accounting baseline before implementation. The default is
|
||||
`5ed45739e94bdf6460364e033bfcec9b32c0b42a` (the base of #12600). Build an
|
||||
aggregate carry-in ledger for every program-attributable PR already merged
|
||||
after it; do not measure only the last PR in a stack.
|
||||
4. Read the final diff of #13111, #12264, and #12743 with `gh pr view` and
|
||||
`gh pr diff`, or in disposable isolated worktrees. Never use `gh pr checkout`
|
||||
or switch branches in the primary worktree. Select or design one coherent
|
||||
lease/reattach model; do not combine mutually inconsistent quarantine, kill,
|
||||
and non-kill policies.
|
||||
5. Reconcile #12882 before overlapping daemon-endpoint integration and #12760
|
||||
before overlapping relay-generation cleanup. Neither blocks unrelated SSH
|
||||
proof work by declaration.
|
||||
6. Treat the preserved snapshot as a local-only mine, not a branch to finish.
|
||||
Inspect it with `git show fddb19f6977:<path>` or in a disposable isolated
|
||||
worktree; never switch the primary worktree to it.
|
||||
|
||||
Decision output required before implementation:
|
||||
|
||||
- exact incident mechanisms being eliminated;
|
||||
- exact authority/identity semantics retained or changed;
|
||||
- explicit remote-shell lifecycle and user recovery behavior;
|
||||
- explicit lost-consumer liveness/compaction behavior;
|
||||
- PR/stack boundaries and dependency order;
|
||||
- production modules expected to be deleted; and
|
||||
- tests that will discriminate the baseline from the candidate; and
|
||||
- a predeclared performance protocol with fixed workloads, samples,
|
||||
statistical/noise bounds, deterministic counters, and raw-data locations.
|
||||
|
||||
## Phase 1 — Build discriminating journeys first
|
||||
|
||||
Before relying on a code change, create or repair oracles that reproduce the
|
||||
failure on an unfixed baseline.
|
||||
|
||||
### STA-3077 Docker SSH oracle
|
||||
|
||||
The Docker journey must:
|
||||
|
||||
- run a real OpenSSH server with `MaxSessions=1` verified from the server;
|
||||
- create a known pane and record tab, leaf, binding, relay PTY, and remote PID;
|
||||
- seed or naturally reproduce duplicate/stale lease state from the field;
|
||||
- exercise disconnect, client restart, reconnect, and authority/relay restart;
|
||||
- wait an explicit settle interval after successful reattach;
|
||||
- recensus visible panes, tabs, exact bindings, persisted leases, relay PTYs,
|
||||
and actual remote processes/PIDs;
|
||||
- prove no pane or shell is created, rebound, lost, or silently killed;
|
||||
- prove explicit unresolved recovery for state that cannot be established; and
|
||||
- exercise explicit close, lost shutdown response, reconnect retry, and
|
||||
worktree teardown; prove exact remote PID/session-slot reclamation while an
|
||||
uncertain sibling survives; and
|
||||
- fail for the intended reason on the unfixed baseline.
|
||||
|
||||
A clean reconnect that passes on both revisions is not this oracle.
|
||||
|
||||
### Duplicate-agent oracle family
|
||||
|
||||
Cover transient SSH loss, `restoreRequired`, sleeping-agent wake, worktree
|
||||
switch, and closed-tab restart. Assert process count and transcript/session
|
||||
ownership, not just pane/store state. No path may start a second `--resume`
|
||||
process while the first process is live or unknown.
|
||||
|
||||
### Daemon and remote oracles
|
||||
|
||||
Cover unknown inventory, endpoint handover, daemon restart, relay generation
|
||||
reuse, client sleep/wake, remote-runtime restart, and stale subscription repair.
|
||||
A retry limit may end an attempt or surface a failure; it may not itself prove a
|
||||
session dead or authorize destructive replacement.
|
||||
|
||||
## Phase 2 — Reconcile and approve the long-term design
|
||||
|
||||
The implementation design must explicitly settle:
|
||||
|
||||
- final-host identity versus client routing aliases;
|
||||
- canonical host-local namespace for worktree, folder, floating, drive, and UNC
|
||||
forms;
|
||||
- structural one-active-binding/lease ownership rather than optional metadata
|
||||
plus repair scans;
|
||||
- exact operation fencing across every transport;
|
||||
- explicit detach, close, retirement, orphan recovery, and process-reclamation
|
||||
operations;
|
||||
- durable outcome/replay semantics across app, renderer, host, and transport
|
||||
restarts;
|
||||
- mixed-version capability and isolated legacy behavior;
|
||||
- lost-device consumer retirement and compaction liveness;
|
||||
- one-way migration/cutover; and
|
||||
- the four-role deletion oracle and minimised, justified production-code target.
|
||||
|
||||
Reuse existing pane, PTY, incarnation, CAS, and three-valued liveness primitives
|
||||
where their semantics match. Do not equate client routing IDs with host identity
|
||||
or namespace without proving the full semantics.
|
||||
|
||||
If the chosen design differs from the preserved normative design, write an
|
||||
explicit mapping for every invariant and every journey, identify what changes,
|
||||
show equal or stronger safety/liveness, and obtain the user's approval. A
|
||||
handoff author or implementation agent cannot approve that scope change alone.
|
||||
|
||||
## Phase 3 — Implement as a convergent stack
|
||||
|
||||
Stacks are allowed for reviewability. Temporary production stopgaps are not the
|
||||
end state.
|
||||
|
||||
A sensible dependency shape is:
|
||||
|
||||
1. discriminating journey harnesses and behavior oracles;
|
||||
2. final shared identity, liveness, and exact-binding primitives;
|
||||
3. local/daemon/WSL/SSH/relay/paired/remote adapters using those primitives;
|
||||
4. durable outcome, projection, reset, and recovery lifecycle;
|
||||
5. one-way migration and capability cutover;
|
||||
6. deletion of superseded writers, ledgers, repair scans, timers used as verdicts,
|
||||
and duplicate state machines; retain `terminal-input-quarantine.ts` until a
|
||||
replacement passes its destructive-input oracle; and
|
||||
7. full platform, skew, performance, packaging, and independent review proof.
|
||||
|
||||
Each stack layer must be safe and tested, but the comprehensive status remains
|
||||
0/8 until the final integrated candidate satisfies the complete gates. Do not
|
||||
advertise the authoritative capability or claim program completion mid-stack.
|
||||
|
||||
## Phase 4 — Run the complete proof matrix
|
||||
|
||||
Use [`goalposts.md`](./goalposts.md) as the checklist. All thirteen production
|
||||
journeys and every row in the mandatory
|
||||
[`issue-to-journey matrix`](./related-open-work.md#mandatory-issue-to-journey-matrix)
|
||||
are required. At minimum the final matrix must include:
|
||||
|
||||
- real macOS, Linux floor, Windows, and WSL;
|
||||
- local, daemon, direct SSH, nested SSH, paired runtime, and remote server;
|
||||
- one Docker host with `MaxSessions=1` and two simultaneous Docker hosts;
|
||||
- git worktree, folder, floating, drive, and UNC workspaces;
|
||||
- old/new peers in both directions at every changed app/daemon, SSH relay,
|
||||
paired-runtime, remote-server, and mobile/E2EE boundary;
|
||||
- app, renderer, daemon, relay, and remote-runtime restart;
|
||||
- crash cuts around durable operations and identity reset;
|
||||
- unknown/timeout/disconnect/lost-response/concurrent-replacement cases;
|
||||
- actual remote PID and process accounting; and
|
||||
- A/B performance and memory results under the predeclared protocol, including
|
||||
raw samples, confidence intervals, deterministic counters, and leak slopes.
|
||||
|
||||
## Phase 5 — Delete, census, rebase, and review
|
||||
|
||||
1. Trace every new production module from a real entrypoint.
|
||||
2. Delete unreachable modules and fixtures in production trees.
|
||||
3. Delete all superseded legacy writers and reconciliation state machines after
|
||||
cutover.
|
||||
4. Rebase onto the actual PR base.
|
||||
5. Rerun all journeys and static/package checks on the rebased SHA.
|
||||
6. Report production, tests, docs, CI/runner, generated, and vendored LOC
|
||||
separately.
|
||||
7. Minimise aggregate production source net LOC against the frozen pre-program
|
||||
baseline, including all program-attributable carry-in and stack layers, and
|
||||
justify every net addition as required by D1.
|
||||
8. Run an independent repository review and the release-readiness checklist.
|
||||
9. Resolve every P0–P2 finding.
|
||||
10. Only then prepare the comprehensive PR and ask the user to approve any
|
||||
deliberate design change.
|
||||
|
||||
## Validation status at this handoff
|
||||
|
||||
This was a documentation and read-only audit turn. No product test command was
|
||||
rerun, so this handoff claims no fresh local product-test pass.
|
||||
|
||||
Exact validation commands run for this handoff:
|
||||
|
||||
```bash
|
||||
git status --short --branch --untracked-files=all
|
||||
git rev-parse HEAD origin/main
|
||||
git rev-list --left-right --count HEAD...origin/main
|
||||
git merge-base HEAD origin/main
|
||||
git diff --numstat --no-renames "$(git merge-base HEAD origin/main)"..HEAD
|
||||
gh pr view 13111 --repo stablyai/orca --json state,isDraft,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup,headRefOid,baseRefOid,url
|
||||
handoff_query_text="$(tr '\n' ' ' < docs/reference/terminal-session-correctness-handoff-2026-08-07/github-overlap-query.graphql)"
|
||||
gh api graphql -f query="$handoff_query_text"
|
||||
pnpm exec oxfmt --check docs/reference/terminal-session-correctness-handoff-2026-08-07/*.md docs/reference/terminal-session-correctness-handoff-2026-08-07/*.graphql
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Outcomes:
|
||||
|
||||
- repository commands exited 0 and produced the branch/SHA/distance recorded in
|
||||
`README.md`;
|
||||
- the current-HEAD numstat reproduced `+309` production, `+914` tests/E2E,
|
||||
`+146` docs, `+43` CI/config, and `+1,412` total net LOC;
|
||||
- the reproducible GraphQL catalog returned all 65 referenced items and one
|
||||
unresolved, non-outdated review thread on each of #13110 and #13111;
|
||||
- all 30 linked issues appear in the mandatory issue-to-journey matrix and the
|
||||
reusable GraphQL query;
|
||||
- #13111 was open, non-draft, mergeable/clean, with 47 successful and 5 skipped
|
||||
checks and no approval decision; and
|
||||
- formatting and whitespace checks exited 0 after documentation formatting.
|
||||
|
||||
Verified in this worktree:
|
||||
|
||||
- repository branch, HEAD, merge base, local `origin/main`, branch distance,
|
||||
and clean pre-documentation status;
|
||||
- snapshot branch/commit presence and the 814-path preservation claim;
|
||||
- current source implementation of the narrow #13111 behavior and gaps;
|
||||
- current categorized #13111 diff census;
|
||||
- original G0–G7, thirteen journeys, and PR proof contract; and
|
||||
- live GitHub PR/issue state through batched GraphQL queries.
|
||||
|
||||
Live GitHub verification at the time of writing showed:
|
||||
|
||||
- #13111: open, non-draft, mergeable/clean, checks successful, no approval, one
|
||||
unresolved major review thread;
|
||||
- #13110: open, non-draft, checks successful, no approval, one unresolved major
|
||||
review thread;
|
||||
- #12264 and #12743: open competing STA-3077 implementations;
|
||||
- #12474, #12477, and #12600: merged; and
|
||||
- the other items in `related-open-work.md`: open unless explicitly labeled
|
||||
merged or draft.
|
||||
|
||||
These states are ephemeral and must be refreshed.
|
||||
|
||||
Historical pause receipts from the 60,903-line construction snapshot remain
|
||||
construction evidence only. They do not prove the current branch, G0–G7, or any
|
||||
required journey.
|
||||
|
||||
## Stop conditions
|
||||
|
||||
Stop and return to the user before:
|
||||
|
||||
- narrowing or replacing a named goalpost;
|
||||
- accepting a correctness/performance tradeoff;
|
||||
- deciding that remote orphan shells may be killed or leaked permanently;
|
||||
- changing wire semantics without a compatibility design;
|
||||
- shipping a positive-production-LOC comprehensive stack;
|
||||
- merging, closing, commenting on, or force-updating external PRs; or
|
||||
- deleting preserved user work.
|
||||
|
||||
## Short takeover prompt
|
||||
|
||||
> Continue the comprehensive terminal-session correctness program from
|
||||
> [`README.md`](./README.md). Preserve the fixed status of **0/8 proven gates
|
||||
> and 0/13 proven journeys** until every proof in
|
||||
> [`goalposts.md`](./goalposts.md) passes on one rebased candidate. Reconcile the
|
||||
> conflicting open work in [`related-open-work.md`](./related-open-work.md),
|
||||
> build discriminating real SSH/daemon/paired/remote/cross-platform journeys,
|
||||
> remove root causes and superseded reconciliation, minimise and justify every
|
||||
> production-code addition per D1, and finish with no correctness or performance
|
||||
> regression.
|
||||
@@ -0,0 +1,277 @@
|
||||
# Terminal session correctness — test design
|
||||
|
||||
How we decide a test is worth trusting, what we built, and what we could not
|
||||
prove. Read [`design-overview.md`](./design-overview.md) first.
|
||||
|
||||
---
|
||||
|
||||
## 1. The problem with "the tests pass"
|
||||
|
||||
A green test proves nothing on its own. It might pass because the code is
|
||||
correct, or because the test never looked.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
T["Test is green"] --> Q{"Would it go red if<br/>we broke the code?"}
|
||||
Q -->|"Yes"| G["Real evidence"]
|
||||
Q -->|"No"| W["Worthless.<br/>Green either way."]
|
||||
Q -->|"Never checked"| U["Unknown.<br/>Treat as worthless."]
|
||||
|
||||
style G fill:#e6ffe6,stroke:#00aa00
|
||||
style W fill:#ffcccc,stroke:#cc0000
|
||||
style U fill:#fff4e6,stroke:#dd8800
|
||||
```
|
||||
|
||||
This is not hypothetical. **It happened to us twice:**
|
||||
|
||||
- An end-to-end test was reported as proving the reconnect fix. It passed with
|
||||
the fix **and** with the fix removed. Retracted.
|
||||
- A guard was added, every test stayed green, and the guard had **no production
|
||||
caller at all**. The capability existed; nothing used it. Tests passed the
|
||||
whole time because they called the capability directly.
|
||||
|
||||
---
|
||||
|
||||
## 2. The rule we adopted: the four-step proof
|
||||
|
||||
Nothing counts as evidence until it has been through this loop.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
S1["1. Run it<br/><b>green</b>"] --> S2["2. Delete the guard<br/>from production code"]
|
||||
S2 --> S3["3. Run it<br/><b>MUST go red</b>"]
|
||||
S3 --> S4["4. Put the guard back<br/><b>green again</b>"]
|
||||
S3 -.->|"stays green"| F["Test is worthless.<br/>Say so. Don't ship it<br/>as proof."]
|
||||
|
||||
style S3 fill:#e6ffe6,stroke:#00aa00
|
||||
style F fill:#ffcccc,stroke:#cc0000
|
||||
```
|
||||
|
||||
Three rules that make this honest rather than theatre:
|
||||
|
||||
**Delete a real guard, not the test.** The mutation goes in _production_ code. If
|
||||
you tweak the test until it fails, you have proved nothing.
|
||||
|
||||
**Prefer one clause at a time.** If a test has three claims, a good mutation
|
||||
reddens exactly one and leaves the other two green. That proves the claims
|
||||
_independently_ rather than as a bundle.
|
||||
|
||||
**Watch it fail yourself.** A report saying "it went red" is not the same as
|
||||
seeing it. Of the twelve oracles here, six were personally re-verified, and two
|
||||
of those re-checks overturned the original claim.
|
||||
|
||||
### The trap that nearly fooled us twice
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
M["Apply mutation"] --> C{"Did the mutation<br/>actually land?"}
|
||||
C -->|"No — silent no-op"| X["Test stays green.<br/>You conclude 'no teeth'.<br/><b>Wrong conclusion.</b>"]
|
||||
C -->|"Yes"| R["Now the result<br/>means something"]
|
||||
|
||||
style X fill:#ffcccc,stroke:#cc0000
|
||||
```
|
||||
|
||||
Twice a search-and-replace matched the wrong line and changed nothing. The test
|
||||
stayed green and looked like a failed oracle. **Always verify the mutation landed
|
||||
before believing the result.**
|
||||
|
||||
---
|
||||
|
||||
## 3. The three layers of test
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph L1["Unit — milliseconds"]
|
||||
U1["Pure logic.<br/>Does the rule compute<br/>the right answer?"]
|
||||
end
|
||||
subgraph L2["Integration — seconds"]
|
||||
I1["Real Electron app.<br/>Real restarts.<br/>Real shell processes."]
|
||||
end
|
||||
subgraph L3["End-to-end — minutes"]
|
||||
E1["Real Docker container<br/>running real OpenSSH.<br/>Real network faults."]
|
||||
end
|
||||
L1 --> L2 --> L3
|
||||
L1 -.->|"fast, narrow"| N1[" "]
|
||||
L3 -.->|"slow, real"| N2[" "]
|
||||
style N1 fill:none,stroke:none
|
||||
style N2 fill:none,stroke:none
|
||||
```
|
||||
|
||||
The rule of thumb: **assert the strongest thing you can actually observe.**
|
||||
|
||||
---
|
||||
|
||||
## 4. Why "same id" is not good enough
|
||||
|
||||
The most valuable lesson from this work.
|
||||
|
||||
A restart test naturally asks: _is it the same terminal?_ The easy check is
|
||||
whether the ids match. That check is **too weak**, and we can prove it.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
B["Break the quit path so it<br/>destroys shells instead of<br/>detaching from them"] --> R["Restart the app"]
|
||||
R --> C1["tab id: same ✅"]
|
||||
R --> C2["pane id: same ✅"]
|
||||
R --> C3["pty id: same ✅"]
|
||||
R --> C4["<b>OS process: DIFFERENT</b> ❌<br/>pid 13756 → 8852"]
|
||||
C3 --> W["A weak test passes here.<br/>Your shell is gone."]
|
||||
C4 --> S["Our test fails here."]
|
||||
|
||||
style W fill:#ffcccc,stroke:#cc0000
|
||||
style S fill:#e6ffe6,stroke:#00aa00
|
||||
```
|
||||
|
||||
So the test asks the shell to report its **own process id** through the real
|
||||
input path, then reads that process's **kernel start time**. Same id _and_ same
|
||||
start time means the same process really survived — a recycled pid cannot pass.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as Test
|
||||
participant A as Orca
|
||||
participant S as Shell
|
||||
participant K as Kernel
|
||||
T->>A: type "echo MARKER=$$"
|
||||
A->>S: (through the real write path)
|
||||
S-->>T: MARKER=13756
|
||||
T->>K: start time of 13756?
|
||||
K-->>T: 02:01:29.337
|
||||
Note over T: restart the app
|
||||
T->>S: ask again
|
||||
S-->>T: MARKER=13756
|
||||
T->>K: start time of 13756?
|
||||
K-->>T: 02:01:29.337 ✅ same process
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. What is covered
|
||||
|
||||
### Journey 1 — a terminal survives restart · **PROVEN**
|
||||
|
||||
Two claims: the same pane, binding and **OS process** survive a reload and an app
|
||||
restart; and a stale operation is refused.
|
||||
|
||||
| Platform | Result | Mutation A<br/>(destructive quit) | Mutation B<br/>(remove stale-write guard) |
|
||||
| ------------------ | ------ | --------------------------------- | ----------------------------------------- |
|
||||
| macOS | 2 pass | red | red |
|
||||
| Linux, native | 2 pass | red | red |
|
||||
| Windows 11, native | 2 pass | **red — only claim 1** | **red — only claim 2** |
|
||||
|
||||
The two mutations hit **different claims**, which is what proves them separately.
|
||||
|
||||
### Journey 2 — daemon and WSL · **PROVEN**
|
||||
|
||||
| Environment | Result | Mutation A<br/>(liveness can't say "unknown") | Mutation B<br/>(widen owner fallback) |
|
||||
| --------------- | ------ | --------------------------------------------- | ------------------------------------- |
|
||||
| macOS | 3 pass | red — claim 1 only | red — claim 3 only |
|
||||
| Linux, native | 3 pass | red — claim 1 only | red — claim 3 only |
|
||||
| WSL2 on Windows | 3 pass | red — claim 1 only | — |
|
||||
|
||||
> **A subtlety worth knowing.** These tests run in order, so when the first one
|
||||
> fails the others report "did not run" — that is a _skip_, not a pass. To prove
|
||||
> the mutation really only hit claim 1, we re-ran claims 2 and 3 **alone, with
|
||||
> the mutation still applied**, and watched them stay green.
|
||||
|
||||
### Other oracles that bite
|
||||
|
||||
| Oracle | Mutation that reddens it |
|
||||
| ---------------------------- | ------------------------------------------------ |
|
||||
| Reconnect grafts no pane | Remove "bind, never create" |
|
||||
| Superseded-keystroke fence | Remove the guard from the handlers |
|
||||
| Guard is actually wired up | Remove the call sites, keep the function |
|
||||
| Workspace ids don't collide | Restore the suffix-stripping comparison |
|
||||
| Wire compatibility | Restore the wrong "expired" message |
|
||||
| `rm -rf` hazard stays closed | Disable the quarantine → `cho hi; rm -rf x` runs |
|
||||
|
||||
That last one is why we did **not** delete the quarantine module.
|
||||
|
||||
---
|
||||
|
||||
## 6. What we could not prove, and why
|
||||
|
||||
This is the honest half.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
P["Property we want<br/>to prove"] --> Q{"Is there a shipped<br/>guard protecting it?"}
|
||||
Q -->|"Yes"| M["Remove it → test reddens.<br/><b>Provable</b>"]
|
||||
Q -->|"No — it's true<br/>by construction"| C["Nothing to remove.<br/><b>Not provable by mutation</b>"]
|
||||
Q -->|"No code exists<br/>at all"| N["Nothing to test.<br/><b>Not writable</b>"]
|
||||
|
||||
style M fill:#e6ffe6,stroke:#00aa00
|
||||
style C fill:#fff4e6,stroke:#dd8800
|
||||
style N fill:#ffcccc,stroke:#cc0000
|
||||
```
|
||||
|
||||
| Journey | Why not proven |
|
||||
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Two hosts don't interfere | True **by construction** — each host has its own channel, so there is no shared thing to break. Four attempts to redden it failed, correctly. |
|
||||
| Lazy host discovery | One claim has no mutation that hits it alone; removing the real guard breaks setup before the claim is reached. |
|
||||
| `MaxSessions=1` reconnect | Two claims redden. A third survived **four** different guard removals — nothing shipped protects it. |
|
||||
| Mixed versions, live | The new message **never crosses a version boundary** in production. Our in-process tests exercise a route the real app doesn't take. Withdrawn. |
|
||||
| Performance | 1 of 10 required dimensions measured. The keystroke guard costs ~14ns, but measured outside the real app. |
|
||||
| Three others | The features they test **do not exist** in the code. No test can be written. |
|
||||
|
||||
**"True by construction" is a good outcome, not a failure** — it means the
|
||||
property holds because of how the code is shaped, not because a guard is
|
||||
watching. It just can't be demonstrated by breaking something.
|
||||
|
||||
---
|
||||
|
||||
## 7. Environment traps that can fake a result
|
||||
|
||||
Real problems we hit, worth knowing before trusting any run.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph T1["Shared temp directory"]
|
||||
A1["Test harness writes a pointer<br/>to a machine-wide temp path"] --> A2["Two runs at once<br/>overwrite each other"]
|
||||
A2 --> A3["Can fake a red<br/><b>and</b> hide a real one"]
|
||||
end
|
||||
subgraph T2["Stale build"]
|
||||
B1["e2e runs a built app"] --> B2["Edit source, forget rebuild"]
|
||||
B2 --> B3["You tested the old code"]
|
||||
end
|
||||
style A3 fill:#ffcccc,stroke:#cc0000
|
||||
style B3 fill:#ffcccc,stroke:#cc0000
|
||||
```
|
||||
|
||||
So every run isolates its temp directory, and we check build timestamps against
|
||||
source edits before believing a result.
|
||||
|
||||
Two more, found by running on machines we don't develop on:
|
||||
|
||||
- A test failed on **every** Linux run. Not a product bug: it read a value once,
|
||||
immediately after a reload, where its sibling helper polls for 15 seconds.
|
||||
- A test could not run on **Windows at all** — it used `echo $$` and `ps`, which
|
||||
don't exist there. The Windows claim wasn't unproven, it was _unprovable_.
|
||||
|
||||
Both were bugs in tests we wrote, found only by running on the real platform.
|
||||
|
||||
---
|
||||
|
||||
## 8. Scoreboard
|
||||
|
||||
```mermaid
|
||||
pie showData
|
||||
title Journeys
|
||||
"Proven with discrimination" : 2
|
||||
"Oracle exists, cannot discriminate" : 5
|
||||
"No code exists to test" : 3
|
||||
"Not yet attempted" : 3
|
||||
```
|
||||
|
||||
| Measure | Value |
|
||||
| ------------------------------- | ----------- |
|
||||
| Journeys proven | **2 of 13** |
|
||||
| Gates proven | **0 of 8** |
|
||||
| Oracles that bite | 12 |
|
||||
| …personally re-verified | 6 |
|
||||
| Claims retracted after checking | 2 |
|
||||
| Production code added | +214 lines |
|
||||
|
||||
The retraction count is the number to look at. Two claims that looked green were
|
||||
withdrawn after checking. That is the process working — and the reason to trust
|
||||
the two journeys that survived it.
|
||||
@@ -0,0 +1,274 @@
|
||||
# Steady-state worktree rescan: Git-admin fingerprint gate
|
||||
|
||||
## Status
|
||||
|
||||
Adopted for the main-process worktree resolution cache
|
||||
(`OrcaRuntimeService.listRepoWorktreesForResolution`). It keeps the existing
|
||||
30-second freshness contract for externally created, removed, moved, locked, and
|
||||
re-checked-out worktrees while removing the `git worktree list` subprocess that
|
||||
previously ran for every registered repository every 30 seconds.
|
||||
|
||||
## Context
|
||||
|
||||
A production trace (10 registered repositories, 3 h 27 min) recorded 4,272
|
||||
`git worktree` invocations and 8,663 total Git subprocesses. The invocations
|
||||
arrived in full-fleet sweeps roughly every 30.5 s — one sweep per repository per
|
||||
`WORKTREE_SCAN_CACHE_TTL_MS`.
|
||||
|
||||
### What actually expires the cache
|
||||
|
||||
The originating report assumed some specific terminal/status/orchestration
|
||||
request was invalidating the 30-second cache. That is not what happens, and the
|
||||
distinction changes the fix:
|
||||
|
||||
- `resolvedWorktreeCache` (the whole-fleet snapshot) has a **1-second** TTL
|
||||
(`RESOLVED_WORKTREE_CACHE_TTL_MS`). Any caller polling faster than 1 Hz
|
||||
recomputes the snapshot.
|
||||
- `computeResolvedWorktrees` fans out over **every** registered repo
|
||||
(`src/main/runtime/orca-runtime.ts`), calling
|
||||
`listRepoWorktreesForResolution` per repo.
|
||||
- That per-repo call is backed by `worktreeScanCache` with a **30-second** TTL.
|
||||
When it expires, the next poll shells out.
|
||||
|
||||
So no request "expires" the 30-second cache. It expires on wall-clock time, and
|
||||
whichever poller arrives first afterwards pays a full-fleet `git worktree list`
|
||||
fan-out. The many high-frequency callers — `listTerminals` without a selector,
|
||||
`showTerminal`, `getWorktreePs`, `listManagedWorktrees`,
|
||||
`resolveWorktreeSelector`, orchestration authority refresh — only determine
|
||||
*who* pays, not *how often*. Steady-state subprocess volume is therefore
|
||||
`repos / 30 s`, independent of poll rate, which is exactly the observed
|
||||
~1 sweep / 30.5 s.
|
||||
|
||||
The in-Orca mutation surface is already event-driven: create, remove, rename,
|
||||
folder-rename, sparse edits, repo add/update/remove, SSH reconnect, and
|
||||
mixed-version remote invalidation all call
|
||||
`invalidateWorktreeScanCacheForRepo` / `invalidateResolvedWorktreeCache`
|
||||
(≈40 call sites). The 30-second TTL exists for exactly one reason: discovering
|
||||
worktree changes made **outside** Orca (`git worktree add/remove/move/prune`,
|
||||
`git checkout` in another worktree, `rm -rf` of a worktree directory).
|
||||
|
||||
That is a filesystem question, and the filesystem can answer it without a
|
||||
subprocess.
|
||||
|
||||
## Goals
|
||||
|
||||
- Remove the periodic all-repository `git worktree list` fan-out in steady
|
||||
state.
|
||||
- Preserve the current ≈30 s discovery latency for externally created, removed,
|
||||
moved, pruned, locked/unlocked, and re-checked-out worktrees.
|
||||
- Keep a bounded reconciliation so anything the cheap probe cannot observe still
|
||||
converges.
|
||||
- Change nothing for SSH repos, WSL-routed repos, folder workspaces, bare repos,
|
||||
or hosts where the probe cannot resolve Git's admin layout.
|
||||
- Fail open: any probe error must behave exactly like today (run the real scan).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Changing `RESOLVED_WORKTREE_CACHE_TTL_MS` or the whole-fleet snapshot shape.
|
||||
- Changing the renderer-facing `worktrees:list` / `worktrees:listAll` IPC scan
|
||||
cache in `src/main/ipc/worktrees.ts` (separate 5 s cache, invalidated by
|
||||
`registerWorktreeChangeInvalidator`, not a polling source in the trace).
|
||||
- Scoping `resolveWorktreeSelector` to a single repository. See
|
||||
"Rejected alternatives".
|
||||
- Adding a filesystem watcher per repository.
|
||||
- Any wire/RPC/persisted-schema change.
|
||||
|
||||
## Design
|
||||
|
||||
Introduce a cheap, subprocess-free **Git worktree admin fingerprint** for a
|
||||
local repository, and consult it before re-running a scan whose TTL has expired.
|
||||
|
||||
### Fingerprint inputs
|
||||
|
||||
`readRepoWorktreeAdminFingerprint(repoPath)` in
|
||||
`src/main/runtime/repo-worktree-admin-fingerprint.ts` resolves the repo's Git
|
||||
common directory without a subprocess (read `.git`; if it is a `gitdir:` file,
|
||||
follow it and then its `commondir`; if `.git` is absent, treat `repoPath` as a
|
||||
bare gitdir), then records:
|
||||
|
||||
| Input | External change it catches |
|
||||
| --- | --- |
|
||||
| sorted entry names of `<commonDir>/worktrees` | `worktree add`, `worktree remove`, `worktree prune` |
|
||||
| existence of `repoPath` | main checkout deleted |
|
||||
| `<commonDir>/packed-refs` mtime + size | a tip moved while its loose ref is packed away |
|
||||
| `<commonDir>/reftable` mtime + size | a tip moved under the reftable backend |
|
||||
| per checkout: `HEAD` contents | branch switch, detach (the detached oid is in HEAD itself) |
|
||||
| per checkout: contents of the ref HEAD names | a plain `git commit`, `reset`, or `fetch` that moves the tip |
|
||||
| per entry: `gitdir` contents | `worktree move`, `worktree repair` |
|
||||
| per entry: `locked` presence | `worktree lock` / `unlock` |
|
||||
| per entry: existence of the path named by `gitdir` | a worktree directory deleted with `rm -rf` (flips `prunable`) |
|
||||
|
||||
"per checkout" covers the main worktree and each linked worktree. Reading the
|
||||
ref HEAD names is what makes an ordinary commit visible: committing rewrites
|
||||
`refs/heads/<branch>` and leaves `HEAD` untouched, but moves the oid
|
||||
`git worktree list --porcelain` prints. A symref target is only followed when it
|
||||
is a relative path under `refs/`, so a hand-edited `HEAD` cannot steer the probe
|
||||
outside the ref store.
|
||||
|
||||
Every input is a `stat`, `readdir`, or small `readFile` on an already-hot inode,
|
||||
and the fingerprint depends on nothing but the repo path — no prior scan result
|
||||
is threaded in. Per-repo fan-out is capped at 8 concurrent linked-worktree
|
||||
probes, mirroring `SPARSE_CHECKOUT_DETECTION_CONCURRENCY`. A 10-repo fleet with
|
||||
10 worktrees each costs a few hundred filesystem calls per 30 s window versus 10
|
||||
process spawns; the spawns dominate by orders of magnitude, and process-table
|
||||
churn was the reported symptom.
|
||||
|
||||
The fingerprint is a NUL-delimited string; any read failure yields `null`, which
|
||||
the caller treats as "cannot prove unchanged".
|
||||
|
||||
### Cache decision
|
||||
|
||||
`listRepoWorktreesForResolution` gains one branch on the expired-TTL path:
|
||||
|
||||
```text
|
||||
cached entry exists, same generation + runtimeKey, TTL expired
|
||||
└─ probe eligible? (no connectionId, no wslDistro, fingerprint recorded)
|
||||
├─ no → real scan (today's behaviour)
|
||||
└─ yes → read fingerprint now
|
||||
├─ null or different → real scan
|
||||
├─ equal, last real scan < 5 min → extend TTL, no subprocess
|
||||
└─ equal, last real scan ≥ 5 min → real scan (bounded reconcile)
|
||||
```
|
||||
|
||||
`WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS` is 5 min, matching the existing
|
||||
`WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS` precedent. A cached result whose scan
|
||||
failed (`ok: false`) is never extended, so a transient Git failure still retries
|
||||
on the 30 s TTL.
|
||||
|
||||
### Git version compatibility
|
||||
|
||||
Every path the probe reads is part of Git's on-disk layout well before the 2.25
|
||||
baseline in [`git-compatibility.md`](./git-compatibility.md): `.git` as a
|
||||
directory or `gitdir:` file, `commondir`, `worktrees/<name>/{HEAD,gitdir,locked}`,
|
||||
`packed-refs`, and loose `refs/`. `reftable` arrived in 2.45; on older Git it
|
||||
simply stats as missing, which is a stable value and therefore harmless. No new
|
||||
Git command is introduced — this change only skips one.
|
||||
|
||||
Agent-scratch repos already carry a 5-minute scan TTL
|
||||
(`WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS`), which equals the reconciliation
|
||||
interval, so the gate never fires for them and their behaviour is unchanged.
|
||||
|
||||
### Ordering
|
||||
|
||||
The fingerprint stored with a scan result is captured **before** the scan runs.
|
||||
A mutation landing while the scan is in flight therefore leaves the stored
|
||||
fingerprint stale-by-construction, so the next probe sees a difference and
|
||||
rescans. Capturing after the scan would let that mutation be masked forever.
|
||||
|
||||
### Interaction with existing invalidation
|
||||
|
||||
Untouched. `invalidateWorktreeScanCacheForRepo` deletes the entry (fingerprint
|
||||
included) and bumps the generation, so every event-driven path still forces a
|
||||
real scan on the next read. The fingerprint only ever *extends* an entry that
|
||||
the TTL alone would have refreshed.
|
||||
|
||||
## Freshness budget
|
||||
|
||||
| Change | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Orca-initiated create/remove/rename/sparse/repo edit | immediate (event) | immediate (event) |
|
||||
| SSH reconnect / provider generation bump | immediate (event) | immediate (event) |
|
||||
| External `worktree add/remove/move/prune/lock` | ≤ 30 s | ≤ 30 s |
|
||||
| External `git checkout` / `commit` / `reset` in any worktree | ≤ 30 s | ≤ 30 s |
|
||||
| External `rm -rf <worktree>` | ≤ 30 s | ≤ 30 s |
|
||||
| External sparse-checkout pattern edit | ≤ 30 s | ≤ 5 min |
|
||||
| Packed/reftable tip moved within one mtime tick at an equal file size | ≤ 30 s | ≤ 5 min |
|
||||
| SSH / WSL repos, folder workspaces | unchanged | unchanged |
|
||||
|
||||
The two regressions are bounded by the reconciliation interval and are both
|
||||
changes Orca does not make itself.
|
||||
|
||||
### Main-thread cost
|
||||
|
||||
Both the scan and the probe are asynchronous, so neither "runs on the main
|
||||
thread" in the naive sense — but they are not equally free there. Measured on
|
||||
macOS with a 1 ms interval sampling event-loop lag while each ran 30 times
|
||||
against a repo with 20 linked worktrees:
|
||||
|
||||
| | wall per call | main-thread stall per call | worst single stall |
|
||||
| --- | --- | --- | --- |
|
||||
| `git worktree list` | 18.66 ms | 2.69 ms | 3.02 ms |
|
||||
| fingerprint probe | 1.66 ms | 0.01 ms | 0.04 ms |
|
||||
|
||||
`fs/promises` dispatches to libuv's threadpool, so ~99 % of the probe's latency
|
||||
is off-thread. Spawning Git does not: `uv_spawn`, fd and pipe setup, and stdout
|
||||
collection and decoding are real synchronous main-process work. Ten repos
|
||||
refreshing together therefore cost ≈27 ms of event-loop stall per sweep before
|
||||
this change and ≈0.1 ms after — this reduces main-thread pressure rather than
|
||||
adding to it, which is why moving either side onto a worker thread would not
|
||||
help.
|
||||
|
||||
### Residual risk
|
||||
|
||||
A repo registered at a UNC path (`\\wsl$\...`) but executed by the local Windows
|
||||
Git runtime is still probed, because it is not WSL-routed from Orca's point of
|
||||
view. The probe is correct there and strictly cheaper than the subprocess it
|
||||
replaces, but its filesystem calls cross the 9p boundary like the existing
|
||||
sparse-checkout probes already do.
|
||||
|
||||
## Measured effect
|
||||
|
||||
`src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts` drives the
|
||||
reported steady state — 10 idle local repos, a caller polling at 1 Hz for 30
|
||||
simulated minutes — and counts `git worktree list` invocations:
|
||||
|
||||
| | `git worktree list` per 30 min | per hour |
|
||||
| --- | --- | --- |
|
||||
| TTL only (before) | 600 | 1,200 |
|
||||
| fingerprint gate (after) | 60 | 120 |
|
||||
|
||||
A 90 % reduction, with the remainder being the bounded reconciliation. Repos
|
||||
with genuine external activity keep rescanning at the 30 s cadence because the
|
||||
fingerprint flips.
|
||||
|
||||
Extrapolating to the original trace's shape (10 repos, 3 h 27 min): 4,272
|
||||
`git worktree` invocations would become ≈427.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
**Raise `WORKTREE_SCAN_CACHE_TTL_MS` to 5 min.** One line, same subprocess
|
||||
reduction, but it degrades *every* external-change latency to 5 min, including
|
||||
the common "I ran `git worktree add` in a terminal" case. The fingerprint buys
|
||||
the same reduction without that regression.
|
||||
|
||||
**Per-repo `fs.watch` on `<commonDir>/worktrees`.** Lower latency, but adds
|
||||
persistent watcher handles per repo, inherits recursive-watch platform
|
||||
differences, and would need its own dormancy/rearm story. The existing watcher
|
||||
infrastructure is scoped to workspace files; extending it to Git admin dirs is a
|
||||
larger change with a worse risk profile for the same steady-state win.
|
||||
|
||||
**Scope `resolveWorktreeSelector` to the owning repository.** The original brief
|
||||
asks for this. `listTerminals` already avoids the fan-out for explicit worktree
|
||||
ids via `buildResolvedWorktreeFromId` +
|
||||
`listKnownResolvedWorktreesForExplicitTarget`. Extending that to
|
||||
`resolveWorktreeSelector` means splitting the highest-fan-in method in the
|
||||
runtime (38 call sites) and rebuilding lineage projection for a repo subset —
|
||||
material regression risk. Once the fingerprint gate lands, the remaining
|
||||
fan-out cost for a targeted call is a batch of stats, not a subprocess, so the
|
||||
gain no longer justifies the risk. Tracked as follow-up, not in this change.
|
||||
|
||||
## Test plan
|
||||
|
||||
`src/main/runtime/repo-worktree-admin-fingerprint.test.ts` (real temp dirs, real
|
||||
`git` binary — a mocked filesystem would only restate the assumptions):
|
||||
|
||||
- stable across repeated reads with no change
|
||||
- changes after `worktree add`, `worktree remove`, `worktree move`,
|
||||
`worktree lock`, `checkout` in a linked worktree, `checkout` in the main
|
||||
worktree, a commit in either, and `rm -rf` of a worktree directory
|
||||
- still tracks a tip whose loose ref has been packed away by `git pack-refs`
|
||||
- a linked worktree path and the main repo path produce the same fingerprint
|
||||
- a bare repo resolves through its own gitdir and still tracks `worktree add`
|
||||
- `null` for a non-Git directory and for a missing path
|
||||
|
||||
`src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts`:
|
||||
|
||||
- unchanged fingerprint suppresses the rescan past the 30 s TTL and re-arms it
|
||||
- changed fingerprint rescans at the 30 s TTL
|
||||
- the 5-minute reconciliation forces a rescan while the fingerprint is unchanged
|
||||
- `notifyBranchRenamed` (event invalidation) still forces an immediate rescan
|
||||
- a `null` fingerprint (probe failure) falls back to scanning
|
||||
- SSH repos never consult the probe
|
||||
- a failed scan is never extended
|
||||
- concurrent callers share one probe and one scan
|
||||
- the 1 Hz / 10-repo workload measurement above
|
||||
@@ -10,6 +10,11 @@ export default function MobileFilePreviewRoute() {
|
||||
source?: string | string[]
|
||||
absolutePath?: string | string[]
|
||||
grantId?: string | string[]
|
||||
terminal?: string | string[]
|
||||
pathText?: string | string[]
|
||||
cwd?: string | string[]
|
||||
nativeChatTab?: string | string[]
|
||||
nativeChatSession?: string | string[]
|
||||
line?: string | string[]
|
||||
column?: string | string[]
|
||||
name?: string | string[]
|
||||
|
||||
@@ -127,6 +127,15 @@ import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-
|
||||
import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response'
|
||||
import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply'
|
||||
import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version'
|
||||
import {
|
||||
addLegacyTerminalAttributionDisableRequest,
|
||||
MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE,
|
||||
withLegacyTerminalAttributionDisabledEnv
|
||||
} from '../../../../../src/shared/legacy-terminal-attribution-env'
|
||||
import {
|
||||
assertMobileTerminalAttributionDisableSupported,
|
||||
MOBILE_TERMINAL_CREATE_RPC_OPTIONS
|
||||
} from '../../../../src/session/mobile-terminal-attribution-compat'
|
||||
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
|
||||
import { resolveMobileTerminalInputGate } from '../../../../src/terminal/terminal-input-connection-gate'
|
||||
import {
|
||||
@@ -917,6 +926,9 @@ export default function SessionScreen() {
|
||||
} | null>(null)
|
||||
const terminalUnsubsRef = useRef<Map<string, () => void>>(new Map())
|
||||
const subscribingHandlesRef = useRef<Set<string>>(new Set())
|
||||
// Why: a lease-only subscribe never renders, so the reconciler needs to tell it apart
|
||||
// from a stream that does — an uncovered handle holding one is a blank terminal.
|
||||
const leaseOnlyHandlesRef = 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).
|
||||
@@ -1233,6 +1245,7 @@ export default function SessionScreen() {
|
||||
terminalUnsubsRef.current.get(handle)?.()
|
||||
terminalUnsubsRef.current.delete(handle)
|
||||
subscribingHandlesRef.current.delete(handle)
|
||||
leaseOnlyHandlesRef.current.delete(handle)
|
||||
terminalDiagnosticsRef.current.terminalUnsubscribed(handle)
|
||||
subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1)
|
||||
// Why: reset the high-water mark so a fresh subscription's first scrollback isn't dropped as stale.
|
||||
@@ -1265,6 +1278,7 @@ export default function SessionScreen() {
|
||||
clearNativeChatInputLease()
|
||||
terminalUnsubsRef.current.clear()
|
||||
subscribingHandlesRef.current.clear()
|
||||
leaseOnlyHandlesRef.current.clear()
|
||||
initializedHandlesRef.current.clear()
|
||||
terminalDiagnosticsRef.current.clearTerminalCache()
|
||||
viewportResubscribeBudgetRef.current.clear()
|
||||
@@ -1331,6 +1345,11 @@ export default function SessionScreen() {
|
||||
}
|
||||
|
||||
subscribingHandlesRef.current.add(handle)
|
||||
if (covered) {
|
||||
leaseOnlyHandlesRef.current.add(handle)
|
||||
} else {
|
||||
leaseOnlyHandlesRef.current.delete(handle)
|
||||
}
|
||||
const seq = (subscribeSeqRef.current.get(handle) ?? 0) + 1
|
||||
subscribeSeqRef.current.set(handle, seq)
|
||||
diagnostics.streamArmed(handle, seq, viewportRef.current)
|
||||
@@ -1519,6 +1538,7 @@ export default function SessionScreen() {
|
||||
streamRevision: coveredStreamRevision,
|
||||
subscriptionsRef: terminalUnsubsRef,
|
||||
subscribingRef: subscribingHandlesRef,
|
||||
leaseOnlyRef: leaseOnlyHandlesRef,
|
||||
webReadyRef: webReadyHandlesRef,
|
||||
initializedRef: initializedHandlesRef,
|
||||
subscribe: subscribeToTerminal,
|
||||
@@ -3085,6 +3105,10 @@ export default function SessionScreen() {
|
||||
hostId,
|
||||
worktreeId,
|
||||
worktreeName: routeWorktreeName,
|
||||
nativeChatSessionId:
|
||||
activeSessionTab?.type === 'terminal'
|
||||
? (activeSessionTab.agentStatus?.providerSession?.id ?? null)
|
||||
: null,
|
||||
activeHandleRef,
|
||||
terminalCwdRef,
|
||||
openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
|
||||
@@ -3650,20 +3674,27 @@ export default function SessionScreen() {
|
||||
.slice(2, 10)}`
|
||||
|
||||
try {
|
||||
const response = await client.sendRequest('session.tabs.createTerminal', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
afterTabId: activeSessionTabId ?? undefined,
|
||||
clientMutationId,
|
||||
...(options?.startupCommand ? { command: options.startupCommand } : {}),
|
||||
...(options?.startupCommandDelivery
|
||||
? { startupCommandDelivery: options.startupCommandDelivery }
|
||||
: {}),
|
||||
...(options?.agentPrompt ? { agentPrompt: options.agentPrompt } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
activate: false,
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
})
|
||||
const authority = await assertMobileTerminalAttributionDisableSupported(client)
|
||||
const response = await client.sendRequest(
|
||||
'session.tabs.createTerminal',
|
||||
{
|
||||
worktree: `id:${worktreeId}`,
|
||||
afterTabId: activeSessionTabId ?? undefined,
|
||||
clientMutationId,
|
||||
...(options?.startupCommand ? { command: options.startupCommand } : {}),
|
||||
...(options?.startupCommandDelivery
|
||||
? { startupCommandDelivery: options.startupCommandDelivery }
|
||||
: {}),
|
||||
env: withLegacyTerminalAttributionDisabledEnv(undefined),
|
||||
envToDelete: addLegacyTerminalAttributionDisableRequest(undefined),
|
||||
...(options?.agentPrompt ? { agentPrompt: options.agentPrompt } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
activate: false,
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
},
|
||||
{ ...MOBILE_TERMINAL_CREATE_RPC_OPTIONS, expectedRuntimeId: authority.runtimeId }
|
||||
)
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as TerminalCreateResult
|
||||
const created = result.tab
|
||||
@@ -3763,10 +3794,17 @@ export default function SessionScreen() {
|
||||
showToast(message, 1800)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const message = options?.errorToast ?? 'Failed to create terminal'
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : null
|
||||
const message =
|
||||
errorMessage === MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE
|
||||
? errorMessage
|
||||
: (options?.errorToast ?? errorMessage ?? 'Failed to create terminal')
|
||||
setCreateError(message)
|
||||
if (options?.errorToast) {
|
||||
if (
|
||||
options?.errorToast ||
|
||||
errorMessage === MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE
|
||||
) {
|
||||
triggerError()
|
||||
showToast(message, 1800)
|
||||
}
|
||||
|
||||
@@ -253,6 +253,9 @@ platform :ios do
|
||||
upload_to_testflight(
|
||||
api_key: api_key,
|
||||
app_identifier: BUNDLE_ID,
|
||||
# distribute_only means there is no local .ipa to sniff, so fastlane would
|
||||
# otherwise fall through to an interactive platform prompt and crash in CI.
|
||||
app_platform: "ios",
|
||||
app_version: version,
|
||||
build_number: build_number,
|
||||
distribute_only: true,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// viewer). We deliberately favor precision over recall: a missed path is a minor
|
||||
// annoyance, but a false positive on prose or a version number is a broken tap.
|
||||
|
||||
import { parseFileLinkLocation } from '../../../src/shared/file-link-location'
|
||||
|
||||
export type FilePathSegment =
|
||||
| { type: 'text'; value: string }
|
||||
| { type: 'file'; value: string; path: string }
|
||||
@@ -101,8 +103,6 @@ function hasMidTokenAt(candidate: string): boolean {
|
||||
return /[^\\/]@/.test(candidate)
|
||||
}
|
||||
|
||||
const LINE_SUFFIX_PATTERN = /^(.+?):([1-9]\d*)(?::([1-9]\d*))?$/
|
||||
|
||||
/**
|
||||
* Split an agent-style `path:line(:col)` citation into its parts. Windows drive
|
||||
* colons are safe: only a trailing all-digit suffix is treated as a line ref.
|
||||
@@ -112,14 +112,14 @@ export function splitFilePathLineSuffix(pathText: string): {
|
||||
line: number | null
|
||||
column: number | null
|
||||
} {
|
||||
const match = LINE_SUFFIX_PATTERN.exec(pathText)
|
||||
if (!match) {
|
||||
const parsed = parseFileLinkLocation(pathText)
|
||||
if (!parsed || (parsed.line === null && parsed.column === null)) {
|
||||
return { path: pathText, line: null, column: null }
|
||||
}
|
||||
return {
|
||||
path: match[1]!,
|
||||
line: Number.parseInt(match[2]!, 10),
|
||||
column: match[3] ? Number.parseInt(match[3], 10) : null
|
||||
path: parsed.pathText,
|
||||
line: parsed.line,
|
||||
column: parsed.column
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ function attrValue(tag: string, name: string): string {
|
||||
const valueStart = cursor
|
||||
if (quote) {
|
||||
const valueEnd = tag.indexOf(quote, cursor)
|
||||
if (valueEnd < 0) {
|
||||
if (valueEnd === -1) {
|
||||
return ''
|
||||
}
|
||||
cursor = valueEnd + 1
|
||||
|
||||
@@ -191,7 +191,7 @@ function closingMarkupTagNames(value: string): Set<string> {
|
||||
let cursor = 0
|
||||
while (cursor < value.length) {
|
||||
const start = value.indexOf('<', cursor)
|
||||
if (start < 0) {
|
||||
if (start === -1) {
|
||||
return names
|
||||
}
|
||||
const end = findMobileMarkdownMarkupTagEnd(value, start + 1)
|
||||
@@ -228,7 +228,7 @@ export function stripMobileMarkdownMarkupTags(value: string): string {
|
||||
const closingTagNames = closingMarkupTagNames(value)
|
||||
while (cursor < value.length) {
|
||||
const start = value.indexOf('<', cursor)
|
||||
if (start < 0) {
|
||||
if (start === -1) {
|
||||
return output + value.slice(cursor)
|
||||
}
|
||||
output += value.slice(cursor, start)
|
||||
@@ -261,7 +261,7 @@ export function stripMobileMarkdownMarkupTags(value: string): string {
|
||||
!/[-:]/.test(name) &&
|
||||
!knownMarkupTagNames.has(lowerName) &&
|
||||
!closingTagNames.has(lowerName)
|
||||
if (isNestedGeneric && nestedStart >= 0) {
|
||||
if (isNestedGeneric && nestedStart !== -1) {
|
||||
output += value.slice(start, nestedStart)
|
||||
cursor = nestedStart
|
||||
continue
|
||||
|
||||
@@ -39,6 +39,21 @@ describe('MobileFilePreviewScreen', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('treats native-chat artifact grants as read-only', () => {
|
||||
expect(
|
||||
isEditableMobileTerminalArtifactPreview(
|
||||
{
|
||||
status: 'ready',
|
||||
kind: 'text',
|
||||
content: '<h1>Result</h1>',
|
||||
truncated: false,
|
||||
byteLength: 15
|
||||
},
|
||||
true
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps dirty terminal artifact drafts protected while preview is waiting for reconnect', () => {
|
||||
const sourceKey = sourceKeyForPreview({
|
||||
source: 'terminalArtifact',
|
||||
|
||||
@@ -181,7 +181,8 @@ export function MobileFilePreviewScreen({ route }: Props) {
|
||||
)
|
||||
const meta = previewParams ? `${worktreeLabel} - ${displayPath}` : 'Preview'
|
||||
const isEditableTerminalArtifact =
|
||||
previewSource?.source === 'terminalArtifact' && isEditableMobileTerminalArtifactPreview(preview)
|
||||
previewSource?.source === 'terminalArtifact' &&
|
||||
isEditableMobileTerminalArtifactPreview(preview, previewSource.readOnly === true)
|
||||
const canSaveArtifact =
|
||||
isEditableTerminalArtifact &&
|
||||
draftSourceKeyRef.current === previewSourceKey &&
|
||||
|
||||
@@ -132,7 +132,7 @@ export function getDirectoryCacheState(
|
||||
): DirectoryState | undefined {
|
||||
// Why: repository paths are arbitrary object keys; inherited keys like
|
||||
// "constructor" must not masquerade as loaded directory state.
|
||||
return Object.prototype.hasOwnProperty.call(cache, relativePath) ? cache[relativePath] : undefined
|
||||
return Object.hasOwn(cache, relativePath) ? cache[relativePath] : undefined
|
||||
}
|
||||
|
||||
export function joinRelativePath(parentPath: string, name: string): string {
|
||||
|
||||
@@ -3,7 +3,13 @@ import type {
|
||||
MobileFilePreviewSource
|
||||
} from './mobile-file-preview-request'
|
||||
|
||||
export function isEditableMobileTerminalArtifactPreview(preview: MobileFilePreviewResult): boolean {
|
||||
export function isEditableMobileTerminalArtifactPreview(
|
||||
preview: MobileFilePreviewResult,
|
||||
readOnly = false
|
||||
): boolean {
|
||||
if (readOnly) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
(preview.status === 'ready' && preview.kind !== 'image' && !preview.truncated) ||
|
||||
preview.status === 'empty'
|
||||
|
||||
@@ -179,6 +179,47 @@ describe('mobile-file-preview-request', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes a native-chat artifact grant with the same transcript provenance', async () => {
|
||||
const client = clientWithResponses([
|
||||
fail('terminal_file_grant_expired'),
|
||||
ok({
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
absolutePath: '/Users/ada/orca-plans/result.html',
|
||||
grantId: 'grant-2',
|
||||
readOnly: true
|
||||
}
|
||||
}),
|
||||
ok({ content: '<h1>Result</h1>', truncated: false, byteLength: 15 })
|
||||
])
|
||||
|
||||
const onTerminalArtifactSourceRefreshed = vi.fn()
|
||||
await loadMobileFilePreview(
|
||||
client,
|
||||
{
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/Users/ada/orca-plans/result.html',
|
||||
grantId: 'grant-1',
|
||||
pathText: '~/orca-plans/result.html',
|
||||
nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' }
|
||||
},
|
||||
undefined,
|
||||
{ onTerminalArtifactSourceRefreshed }
|
||||
)
|
||||
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'files.resolveTerminalPath', {
|
||||
worktree: 'id:wt-1',
|
||||
pathText: '~/orca-plans/result.html',
|
||||
nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' }
|
||||
})
|
||||
expect(onTerminalArtifactSourceRefreshed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ grantId: 'grant-2', readOnly: true })
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['terminal_file_grant_expired'],
|
||||
['terminal_file_grant_mismatch'],
|
||||
@@ -242,6 +283,25 @@ describe('mobile-file-preview-request', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not send writes for read-only native-chat artifacts', async () => {
|
||||
const client = clientWith(ok({ ok: true }))
|
||||
|
||||
await expect(
|
||||
saveMobileTerminalArtifactPreview(
|
||||
client,
|
||||
{
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/Users/ada/orca-plans/result.html',
|
||||
grantId: 'grant-1',
|
||||
readOnly: true
|
||||
},
|
||||
'<h1>Changed</h1>'
|
||||
)
|
||||
).resolves.toMatchObject({ status: 'error' })
|
||||
expect(client.sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not refresh and retry a failed terminal artifact save without a base content check', async () => {
|
||||
const client = clientWithResponses([fail('terminal_file_grant_mismatch')])
|
||||
|
||||
|
||||
@@ -116,6 +116,9 @@ export async function saveMobileTerminalArtifactPreview(
|
||||
content: string,
|
||||
options: TerminalArtifactSaveOptions = {}
|
||||
): Promise<MobileFilePreviewResult | { status: 'saved' }> {
|
||||
if (source.readOnly) {
|
||||
return previewError('This file is read-only')
|
||||
}
|
||||
let writeSource = source
|
||||
if (typeof options.baseContent === 'string') {
|
||||
const verified = await verifyTerminalArtifactBaseContent(
|
||||
|
||||
@@ -65,6 +65,8 @@ describe('mobile-file-preview-route', () => {
|
||||
terminal: 'term-1',
|
||||
pathText: 'result.json',
|
||||
cwd: '/tmp/run',
|
||||
nativeChatTab: 'tab-1',
|
||||
nativeChatSession: 'session-1',
|
||||
line: '12',
|
||||
column: '3'
|
||||
})
|
||||
@@ -80,6 +82,8 @@ describe('mobile-file-preview-route', () => {
|
||||
terminal: 'term-1',
|
||||
pathText: 'result.json',
|
||||
cwd: '/tmp/run',
|
||||
nativeChatTab: 'tab-1',
|
||||
nativeChatSession: 'session-1',
|
||||
line: '12',
|
||||
column: '3'
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ export type MobileFilePreviewRouteParams = {
|
||||
terminal?: string
|
||||
pathText?: string
|
||||
cwd?: string
|
||||
nativeChatTab?: string
|
||||
nativeChatSession?: string
|
||||
line?: string
|
||||
column?: string
|
||||
name?: string
|
||||
@@ -35,6 +37,8 @@ type RawPreviewRouteParams = {
|
||||
terminal?: MobileFilePreviewParamValue
|
||||
pathText?: MobileFilePreviewParamValue
|
||||
cwd?: MobileFilePreviewParamValue
|
||||
nativeChatTab?: MobileFilePreviewParamValue
|
||||
nativeChatSession?: MobileFilePreviewParamValue
|
||||
line?: MobileFilePreviewParamValue
|
||||
column?: MobileFilePreviewParamValue
|
||||
name?: MobileFilePreviewParamValue
|
||||
@@ -76,6 +80,8 @@ export function normalizeMobileFilePreviewRouteParams(
|
||||
terminal: optionalSingleParam(params.terminal),
|
||||
pathText: optionalSingleParam(params.pathText),
|
||||
cwd: optionalSingleParam(params.cwd),
|
||||
nativeChatTab: optionalSingleParam(params.nativeChatTab),
|
||||
nativeChatSession: optionalSingleParam(params.nativeChatSession),
|
||||
line: optionalSingleParam(params.line),
|
||||
column: optionalSingleParam(params.column),
|
||||
name: optionalSingleParam(params.name),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sourceKeyForPreview } from './mobile-file-preview-source'
|
||||
import { previewSourceFromRoute, sourceKeyForPreview } from './mobile-file-preview-source'
|
||||
|
||||
describe('mobile-file-preview-source', () => {
|
||||
it('uses structured preview keys so colons in paths cannot collide', () => {
|
||||
@@ -25,4 +25,21 @@ describe('mobile-file-preview-source', () => {
|
||||
sourceKeyForPreview({ source: 'worktree', worktreeId: 'wt:1', relativePath: 'a:b.ts' })
|
||||
).toBe(JSON.stringify(['worktree', 'wt:1', 'a:b.ts']))
|
||||
})
|
||||
|
||||
it('makes native-chat artifact previews read-only', () => {
|
||||
expect(
|
||||
previewSourceFromRoute({
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
source: 'terminalArtifact',
|
||||
absolutePath: '/tmp/result.html',
|
||||
grantId: 'grant-1',
|
||||
nativeChatTab: 'tab-1',
|
||||
nativeChatSession: 'session-1'
|
||||
})
|
||||
).toMatchObject({
|
||||
nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' },
|
||||
readOnly: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,16 @@ export function previewSourceFromRoute(
|
||||
grantId: params.grantId,
|
||||
...(params.terminal ? { terminalHandle: params.terminal } : {}),
|
||||
...(params.pathText ? { pathText: params.pathText } : {}),
|
||||
...(params.cwd ? { cwd: params.cwd } : {})
|
||||
...(params.cwd ? { cwd: params.cwd } : {}),
|
||||
...(params.nativeChatTab && params.nativeChatSession
|
||||
? {
|
||||
nativeChatContext: {
|
||||
tabId: params.nativeChatTab,
|
||||
sessionId: params.nativeChatSession
|
||||
},
|
||||
readOnly: true as const
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
if (!params.relativePath) {
|
||||
@@ -28,12 +37,12 @@ export function sourceKeyForPreview(source: MobileFilePreviewSource | null): str
|
||||
if (!source) {
|
||||
return null
|
||||
}
|
||||
return source.source === 'terminalArtifact'
|
||||
? JSON.stringify([
|
||||
'terminal',
|
||||
source.worktreeId,
|
||||
source.absolutePath,
|
||||
source.terminalHandle ?? ''
|
||||
])
|
||||
: JSON.stringify(['worktree', source.worktreeId, source.relativePath])
|
||||
if (source.source !== 'terminalArtifact') {
|
||||
return JSON.stringify(['worktree', source.worktreeId, source.relativePath])
|
||||
}
|
||||
const key = ['terminal', source.worktreeId, source.absolutePath, source.terminalHandle ?? '']
|
||||
if (source.nativeChatContext) {
|
||||
key.push(source.nativeChatContext.tabId, source.nativeChatContext.sessionId)
|
||||
}
|
||||
return JSON.stringify(key)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RuntimeNativeChatFileContext } from '../../../src/shared/runtime-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import { isTerminalArtifactGrantError } from './terminal-artifact-grant-error'
|
||||
@@ -12,6 +13,8 @@ export type MobileTerminalArtifactPreviewSource = {
|
||||
terminalHandle?: string
|
||||
pathText?: string
|
||||
cwd?: string
|
||||
nativeChatContext?: RuntimeNativeChatFileContext
|
||||
readOnly?: true
|
||||
}
|
||||
|
||||
export type TerminalArtifactRetryOptions = {
|
||||
@@ -32,7 +35,8 @@ export async function refreshTerminalArtifactSourceAfterGrantFailure(
|
||||
worktree: `id:${source.worktreeId}`,
|
||||
pathText: source.pathText ?? source.absolutePath,
|
||||
...(source.cwd ? { cwd: source.cwd } : {}),
|
||||
...(source.terminalHandle ? { terminal: source.terminalHandle } : {})
|
||||
...(source.terminalHandle ? { terminal: source.terminalHandle } : {}),
|
||||
...(source.nativeChatContext ? { nativeChatContext: source.nativeChatContext } : {})
|
||||
})
|
||||
if (!refreshed.ok) {
|
||||
return null
|
||||
@@ -51,7 +55,9 @@ export async function refreshTerminalArtifactSourceAfterGrantFailure(
|
||||
grantId: result.openTarget.grantId,
|
||||
...(source.terminalHandle ? { terminalHandle: source.terminalHandle } : {}),
|
||||
...(source.pathText ? { pathText: source.pathText } : {}),
|
||||
...(source.cwd ? { cwd: source.cwd } : {})
|
||||
...(source.cwd ? { cwd: source.cwd } : {}),
|
||||
...(source.nativeChatContext ? { nativeChatContext: source.nativeChatContext } : {}),
|
||||
...(source.readOnly || result.openTarget.readOnly === true ? { readOnly: true as const } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +74,7 @@ function isTerminalArtifactGrantFailure(
|
||||
function isTerminalArtifactResolution(result: unknown): result is {
|
||||
exists: true
|
||||
isDirectory: false
|
||||
openTarget: { kind: 'absolute-file'; absolutePath: string; grantId: string }
|
||||
openTarget: { kind: 'absolute-file'; absolutePath: string; grantId: string; readOnly?: true }
|
||||
} {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return false
|
||||
|
||||
@@ -37,7 +37,7 @@ export function useActiveWorktreeScroll<T extends WithId, S>(
|
||||
(worktreeId: string): boolean => {
|
||||
for (let sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) {
|
||||
const itemIndex = sections[sectionIndex].data.findIndex((w) => w.worktreeId === worktreeId)
|
||||
if (itemIndex >= 0) {
|
||||
if (itemIndex !== -1) {
|
||||
sectionListRef.current?.scrollToLocation({
|
||||
sectionIndex,
|
||||
itemIndex,
|
||||
|
||||
@@ -226,6 +226,13 @@ describe('resumeAiVaultSessionInTerminal', () => {
|
||||
it('creates a fresh terminal and sends the command with Enter', async () => {
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1', title: 'Terminal' } }
|
||||
@@ -249,13 +256,20 @@ describe('resumeAiVaultSessionInTerminal', () => {
|
||||
navigation: 'caller'
|
||||
})
|
||||
).resolves.toMatchObject({ id: 'tab-1', terminal: 'pty-1' })
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(1, 'status.get', undefined, {
|
||||
timeoutMs: 30_000,
|
||||
budgetSpansConnect: true
|
||||
})
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
2,
|
||||
'session.tabs.createTerminal',
|
||||
{
|
||||
worktree: 'id:worktree-1',
|
||||
env: { ANTHROPIC_BASE_URL: 'http://localhost:3000' },
|
||||
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'],
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://localhost:3000',
|
||||
ORCA_ATTRIBUTION_BYPASS: '1'
|
||||
},
|
||||
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME', 'ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
launchConfig: {
|
||||
agentCommand: 'claude',
|
||||
agentArgs: '',
|
||||
@@ -269,10 +283,14 @@ describe('resumeAiVaultSessionInTerminal', () => {
|
||||
},
|
||||
// Why: a socket drop mid-resume must reject within the request timeout
|
||||
// instead of parking on the reconnect waiter with the spinner pinned.
|
||||
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
|
||||
{
|
||||
timeoutMs: RESUME_RPC_TIMEOUT_MS,
|
||||
budgetSpansConnect: true,
|
||||
expectedRuntimeId: 'runtime'
|
||||
}
|
||||
)
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
3,
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: 'pty-1',
|
||||
@@ -284,17 +302,35 @@ describe('resumeAiVaultSessionInTerminal', () => {
|
||||
})
|
||||
|
||||
it('throws when terminal creation fails', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { message: 'no terminal' }
|
||||
})
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { message: 'no terminal' }
|
||||
})
|
||||
await expect(
|
||||
resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { command: 'command' })
|
||||
).rejects.toThrow('no terminal')
|
||||
})
|
||||
|
||||
it('throws when the created terminal response is malformed', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValueOnce({ ok: true, result: { tab: { id: 'x' } } })
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true, result: { tab: { id: 'x' } } })
|
||||
await expect(
|
||||
resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { command: 'command' })
|
||||
).rejects.toThrow('Created terminal response was invalid')
|
||||
@@ -303,6 +339,13 @@ describe('resumeAiVaultSessionInTerminal', () => {
|
||||
it('throws when terminal send fails or is locked', async () => {
|
||||
const failedSend = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1' } }
|
||||
@@ -316,6 +359,13 @@ describe('resumeAiVaultSessionInTerminal', () => {
|
||||
|
||||
const lockedSend = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1' } }
|
||||
@@ -327,6 +377,17 @@ describe('resumeAiVaultSessionInTerminal', () => {
|
||||
})
|
||||
).rejects.toThrow('Terminal input is locked')
|
||||
})
|
||||
|
||||
it('refuses an old host before creating a resume terminal', async () => {
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: true, result: { appVersion: '1.4.89' } })
|
||||
|
||||
await expect(
|
||||
resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { command: 'command' })
|
||||
).rejects.toThrow('Update the host and try again')
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createMobileAiVaultResumeMutationRegistry', () => {
|
||||
|
||||
@@ -16,6 +16,10 @@ import { normalizeAiVaultResumeFilePath } from '../../../src/shared/ai-vault-res
|
||||
import type { TuiAgent } from '../../../src/shared/types'
|
||||
import { parseWslUncPath } from '../../../src/shared/wsl-paths'
|
||||
import { resolveWindowsShellStartupFamily } from '../../../src/shared/windows-terminal-shell'
|
||||
import {
|
||||
addLegacyTerminalAttributionDisableRequest,
|
||||
withLegacyTerminalAttributionDisabledEnv
|
||||
} from '../../../src/shared/legacy-terminal-attribution-env'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import {
|
||||
readMobileReviewCreatedTerminal,
|
||||
@@ -23,6 +27,7 @@ import {
|
||||
type MobileReviewTerminalTab
|
||||
} from './mobile-diff-review-rpc'
|
||||
import type { MobileAiVaultResumeTargetStatus } from '../agent-history/agent-history-resume-target'
|
||||
import { assertMobileTerminalAttributionDisableSupported } from './mobile-terminal-attribution-compat'
|
||||
|
||||
const NODE_PLATFORMS = new Set<NodeJS.Platform>([
|
||||
'aix',
|
||||
@@ -169,12 +174,13 @@ export async function resumeAiVaultSessionInTerminal(
|
||||
worktreeId: string,
|
||||
launch: MobileAiVaultResumeLaunch & { clientMutationId?: string }
|
||||
): Promise<MobileReviewTerminalTab> {
|
||||
const authority = await assertMobileTerminalAttributionDisableSupported(client)
|
||||
const created = await client.sendRequest(
|
||||
'session.tabs.createTerminal',
|
||||
{
|
||||
worktree: `id:${worktreeId}`,
|
||||
...(launch.env ? { env: launch.env } : {}),
|
||||
...(launch.envToDelete ? { envToDelete: launch.envToDelete } : {}),
|
||||
env: withLegacyTerminalAttributionDisabledEnv(launch.env),
|
||||
envToDelete: addLegacyTerminalAttributionDisableRequest(launch.envToDelete),
|
||||
...(launch.launchConfig ? { launchConfig: launch.launchConfig } : {}),
|
||||
...(launch.launchAgent ? { launchAgent: launch.launchAgent } : {}),
|
||||
...(launch.clientMutationId ? { clientMutationId: launch.clientMutationId } : {}),
|
||||
@@ -182,7 +188,11 @@ export async function resumeAiVaultSessionInTerminal(
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
},
|
||||
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
|
||||
{
|
||||
timeoutMs: RESUME_RPC_TIMEOUT_MS,
|
||||
budgetSpansConnect: true,
|
||||
expectedRuntimeId: authority.runtimeId
|
||||
}
|
||||
)
|
||||
if (!created.ok) {
|
||||
throw new Error(created.error?.message || 'Failed to create terminal')
|
||||
|
||||
@@ -53,6 +53,14 @@ describe('prepareMobileAiVaultSessionResume', () => {
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: false, error })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime-1',
|
||||
appVersion: '1.4.90',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1', title: 'Terminal' } }
|
||||
@@ -64,9 +72,12 @@ describe('prepareMobileAiVaultSessionResume', () => {
|
||||
await resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', launch)
|
||||
|
||||
expect(prepared).toBe(legacy)
|
||||
expect(sendRequest.mock.calls[1]?.[1]).not.toHaveProperty('envToDelete')
|
||||
expect(sendRequest.mock.calls[2]?.[1]).toMatchObject({
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION']
|
||||
})
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
4,
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: 'pty-1',
|
||||
|
||||
@@ -100,7 +100,7 @@ export function nextReviewIndexAfterMarkReviewed({
|
||||
const wrappedIndex = filteredQueue.findIndex(
|
||||
(item) => item.key !== currentItemKey && !item.isReviewed
|
||||
)
|
||||
const targetIndex = nextIndex >= 0 ? nextIndex : wrappedIndex >= 0 ? wrappedIndex : null
|
||||
const targetIndex = nextIndex !== -1 ? nextIndex : wrappedIndex !== -1 ? wrappedIndex : null
|
||||
if (targetIndex === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
RuntimeFileOpenResult,
|
||||
RuntimeNativeChatFileContext,
|
||||
RuntimeTerminalPathResolution
|
||||
} from '../../../src/shared/runtime-types'
|
||||
import { filesystemPathToFileUri } from '../../../src/shared/file-uri-path'
|
||||
@@ -22,6 +23,7 @@ export type OpenMobileFileTapOptions<T extends FileTapSessionTab> = {
|
||||
terminalHandle?: string | null
|
||||
pathText: string
|
||||
cwd?: string | null
|
||||
nativeChatContext?: RuntimeNativeChatFileContext | null
|
||||
line: number | null
|
||||
column: number | null
|
||||
pushPreviewRoute: (href: ReturnType<typeof createMobileFilePreviewHref>) => void
|
||||
@@ -80,7 +82,8 @@ async function openMobileFileTapAsync<T extends FileTapSessionTab>(
|
||||
...(options.terminalHandle && options.terminalHandle.trim().length > 0
|
||||
? { terminal: options.terminalHandle }
|
||||
: {}),
|
||||
...(options.cwd && options.cwd.trim().length > 0 ? { cwd: options.cwd } : {})
|
||||
...(options.cwd && options.cwd.trim().length > 0 ? { cwd: options.cwd } : {}),
|
||||
...(options.nativeChatContext ? { nativeChatContext: options.nativeChatContext } : {})
|
||||
},
|
||||
{ timeoutMs: 10_000 }
|
||||
)
|
||||
@@ -116,6 +119,12 @@ async function openMobileFileTapAsync<T extends FileTapSessionTab>(
|
||||
...(options.terminalHandle && options.terminalHandle.trim().length > 0
|
||||
? { terminal: options.terminalHandle }
|
||||
: {}),
|
||||
...(options.nativeChatContext
|
||||
? {
|
||||
nativeChatTab: options.nativeChatContext.tabId,
|
||||
nativeChatSession: options.nativeChatContext.sessionId
|
||||
}
|
||||
: {}),
|
||||
name: displayNameFromPath(resolved.openTarget.absolutePath),
|
||||
...(options.line !== null ? { line: String(options.line) } : {}),
|
||||
...(options.column !== null ? { column: String(options.column) } : {}),
|
||||
|
||||
@@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import {
|
||||
findLandedImagePreviewEchoes,
|
||||
findLandedUnconfirmedSends,
|
||||
migrateImagePreviewMessageIds,
|
||||
type PendingImagePreviewEcho
|
||||
type PendingImagePreviewEcho,
|
||||
type UnconfirmedSend
|
||||
} from './mobile-native-chat-draft-reconcile'
|
||||
|
||||
function userText(id: string, text: string): NativeChatMessage {
|
||||
@@ -21,6 +23,67 @@ function pending(id: string, images: string[], expectedOccurrence = 1): PendingI
|
||||
}
|
||||
|
||||
describe('mobile native chat image preview reconciliation', () => {
|
||||
it('reconciles a trailing-marker echo and hands its preview to that echo', () => {
|
||||
const messages = [
|
||||
userText('source', '[Image: source: /tmp/a.png]'),
|
||||
userText('prompt', 'look at this[Image #1]')
|
||||
]
|
||||
const preview = {
|
||||
...pending('pending', ['file:///a.jpg']),
|
||||
text: 'look at this'
|
||||
}
|
||||
const unconfirmed: UnconfirmedSend = {
|
||||
draftKey: 'draft',
|
||||
pendingKey: 'pending-key',
|
||||
text: 'look at this',
|
||||
normalizedText: 'look at this',
|
||||
baselineTailMessageId: null,
|
||||
deadline: null
|
||||
}
|
||||
|
||||
expect(findLandedUnconfirmedSends(messages, [unconfirmed])).toEqual([unconfirmed])
|
||||
expect(findLandedImagePreviewEchoes(messages, [preview])).toEqual([
|
||||
{ pendingId: 'pending', messageId: 'prompt', images: ['file:///a.jpg'] }
|
||||
])
|
||||
})
|
||||
|
||||
it('reconciles a middle-marker echo without changing its rendered whitespace', () => {
|
||||
const messages = [
|
||||
userText('source', '[Image: source: /tmp/a.png]'),
|
||||
userText('prompt', 'look [Image #1] here')
|
||||
]
|
||||
const preview = { ...pending('pending', ['file:///a.jpg']), text: 'look here' }
|
||||
const unconfirmed: UnconfirmedSend = {
|
||||
draftKey: 'draft',
|
||||
pendingKey: 'pending-key',
|
||||
text: 'look here',
|
||||
normalizedText: 'look here',
|
||||
baselineTailMessageId: null,
|
||||
deadline: null
|
||||
}
|
||||
|
||||
expect(findLandedUnconfirmedSends(messages, [unconfirmed])).toEqual([unconfirmed])
|
||||
expect(findLandedImagePreviewEchoes(messages, [preview])).toEqual([
|
||||
{ pendingId: 'pending', messageId: 'prompt', images: ['file:///a.jpg'] }
|
||||
])
|
||||
})
|
||||
|
||||
it('reconciles multiple transcript text blocks with desktop separators', () => {
|
||||
const prompt: NativeChatMessage = {
|
||||
...userText('prompt', 'unused'),
|
||||
blocks: [
|
||||
{ type: 'text', text: 'look' },
|
||||
{ type: 'image-ref', path: '/tmp/a.png' },
|
||||
{ type: 'text', text: '[Image #1] here' }
|
||||
]
|
||||
}
|
||||
const preview = { ...pending('pending', ['file:///a.jpg']), text: 'look here' }
|
||||
|
||||
expect(findLandedImagePreviewEchoes([prompt], [preview])).toEqual([
|
||||
{ pendingId: 'pending', messageId: 'prompt', images: ['file:///a.jpg'] }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps separate adjacent image-only sends independently reconcilable', () => {
|
||||
const landed = findLandedImagePreviewEchoes(
|
||||
[
|
||||
@@ -69,4 +132,36 @@ describe('mobile native chat image preview reconciliation', () => {
|
||||
[sessionKey]: { prompt: ['file:///a.jpg'] }
|
||||
})
|
||||
})
|
||||
|
||||
it('moves an early standalone preview to a trailing-marker prompt id', () => {
|
||||
const sessionKey = 'host\0worktree\0tab\0session'
|
||||
const previous = { [sessionKey]: { source: ['file:///a.jpg'] } }
|
||||
const messages = [
|
||||
userText('source', '[Image: source: /tmp/a.png]'),
|
||||
userText('prompt', 'look[Image #1]')
|
||||
]
|
||||
|
||||
expect(migrateImagePreviewMessageIds(previous, sessionKey, messages)).toEqual({
|
||||
[sessionKey]: { prompt: ['file:///a.jpg'] }
|
||||
})
|
||||
})
|
||||
|
||||
it('moves a preview when the prompt marker is in a later text block', () => {
|
||||
const sessionKey = 'host\0worktree\0tab\0session'
|
||||
const previous = { [sessionKey]: { source: ['file:///a.jpg'] } }
|
||||
const prompt: NativeChatMessage = {
|
||||
...userText('prompt', 'unused'),
|
||||
blocks: [
|
||||
{ type: 'text', text: 'look' },
|
||||
{ type: 'text', text: '[Image #1] here' }
|
||||
]
|
||||
}
|
||||
|
||||
expect(
|
||||
migrateImagePreviewMessageIds(previous, sessionKey, [
|
||||
userText('source', '[Image: source: /tmp/a.png]'),
|
||||
prompt
|
||||
])
|
||||
).toEqual({ [sessionKey]: { prompt: ['file:///a.jpg'] } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import {
|
||||
hasImagePromptMarker,
|
||||
isImageSourceUserTurn,
|
||||
normalizeImageTranscriptMessages,
|
||||
stripImagePromptMarker
|
||||
normalizeNativeChatUserText,
|
||||
normalizedNativeChatUserMessageText
|
||||
} from './mobile-native-chat-image-transcript-markers'
|
||||
export { normalizeNativeChatUserText as normalizeReconcileText } from './mobile-native-chat-image-transcript-markers'
|
||||
|
||||
/** An ack-lost ('unknown' outcome) send held until its transcript echo lands or
|
||||
* the deadline surfaces the uncertainty. */
|
||||
@@ -17,17 +20,7 @@ export type UnconfirmedSend = {
|
||||
}
|
||||
|
||||
export function normalizedUserText(message: NativeChatMessage): string | null {
|
||||
if (message.role !== 'user') {
|
||||
return null
|
||||
}
|
||||
const text = message.blocks
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => (block.type === 'text' ? block.text : ''))
|
||||
.join('')
|
||||
// Claude echoes a captioned image send as `[Image #1] caption` — the sent
|
||||
// text must still match its echo, so strip the marker before comparing.
|
||||
const stripped = stripImagePromptMarker(text).trim()
|
||||
return stripped || null
|
||||
return normalizedNativeChatUserMessageText(message)
|
||||
}
|
||||
|
||||
export function countUserTextOccurrences(
|
||||
@@ -87,7 +80,7 @@ export function mergeLandedImagePreviewEchoes(
|
||||
const entries = Object.entries(previous[sessionKey] ?? {})
|
||||
for (const preview of landed) {
|
||||
const existingIndex = entries.findIndex(([messageId]) => messageId === preview.messageId)
|
||||
if (existingIndex >= 0) {
|
||||
if (existingIndex !== -1) {
|
||||
entries.splice(existingIndex, 1)
|
||||
}
|
||||
entries.push([preview.messageId, preview.images])
|
||||
@@ -117,17 +110,13 @@ function imagePreviewReplacementMessageId(
|
||||
nextIndex++
|
||||
}
|
||||
const prompt = messages[nextIndex]
|
||||
const firstText = prompt?.blocks.find((block) => block.type === 'text')
|
||||
return prompt?.role === 'user' &&
|
||||
prompt.source === source.source &&
|
||||
firstText?.type === 'text' &&
|
||||
stripImagePromptMarker(firstText.text) !== firstText.text
|
||||
return prompt?.role === 'user' && prompt.source === source.source && hasImagePromptMarker(prompt)
|
||||
? prompt.id
|
||||
: null
|
||||
}
|
||||
|
||||
/** Moves previews forward when a progressive source-only transcript frame later
|
||||
* folds into the marker-prefixed prompt with a different authoritative id. */
|
||||
* folds into the marker-bearing prompt with a different authoritative id. */
|
||||
export function migrateImagePreviewMessageIds(
|
||||
previous: Record<string, Record<string, string[]>>,
|
||||
sessionKey: string,
|
||||
@@ -171,7 +160,7 @@ export function findLandedImagePreviewEchoes(
|
||||
if (!entry.images?.length) {
|
||||
continue
|
||||
}
|
||||
const targetText = entry.text.trim()
|
||||
const targetText = normalizeNativeChatUserText(entry.text)
|
||||
const candidates = normalized.filter((message) => {
|
||||
if (message.role !== 'user') {
|
||||
return false
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
// Single-sources the marker logic (pure functions over shared types):
|
||||
// Claude records an attached image as `[Image: source: /path]` (+ `[Image #N]`
|
||||
// prefix on the caption turn), and both render and echo reconciliation must
|
||||
// on the caption turn), and both render and echo reconciliation must
|
||||
// agree with desktop on how those marker turns are interpreted.
|
||||
export {
|
||||
imageSourcePathFromText,
|
||||
hasImagePromptMarker,
|
||||
isImageSourceUserTurn,
|
||||
normalizeImageTranscriptMessages,
|
||||
normalizeNativeChatUserText,
|
||||
normalizedNativeChatUserMessageText,
|
||||
stripImagePromptMarker
|
||||
} from '../../../src/shared/native-chat-image-transcript-markers'
|
||||
import { imageSourcePathFromText } from '../../../src/shared/native-chat-image-transcript-markers'
|
||||
import { isTextBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
|
||||
/** A raw (un-normalized) transcript user turn that is an image-source marker —
|
||||
* the echo shape of an image riding along on a send. */
|
||||
export function isImageSourceUserTurn(message: NativeChatMessage): boolean {
|
||||
if (message.role !== 'user' || message.blocks.length !== 1) {
|
||||
return false
|
||||
}
|
||||
const block = message.blocks[0]
|
||||
return block !== undefined && isTextBlock(block) && imageSourcePathFromText(block.text) !== null
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user