mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/mobile-i18n-audit
# Conflicts: # mobile/app/h/[hostId]/index.tsx # mobile/app/index.tsx # mobile/pnpm-lock.yaml # mobile/src/components/MobileHostCard.tsx # mobile/src/components/MobileMarkdown.tsx # mobile/src/components/pr-sidebar/MermaidDiagram.tsx # mobile/src/session/MobileNativeChatComposer.tsx # mobile/src/session/MobileNativeChatMessage.tsx # mobile/src/session/mobile-native-chat-permission-send.ts # mobile/src/session/mobile-native-chat-render-data.ts # mobile/src/session/use-mobile-diff-review-controller.ts # mobile/src/session/use-mobile-native-chat-answer-send.ts # mobile/src/session/use-mobile-native-chat-image-attachments.ts # mobile/src/session/use-mobile-native-chat-message-send.ts # mobile/src/terminal/terminal-webview-notification-dispatch.ts # mobile/src/transport/mobile-endpoint-supervisor.ts
This commit is contained in:
@@ -33,6 +33,13 @@ name: Adhoc macOS Dev Build
|
||||
# Contents: Read and write. The secret names below are historical — one App, one
|
||||
# private key, both dev-channel repos — and rotating it stays a single operation.
|
||||
# Provision with `bash config/scripts/setup-hourly-release-token.sh`.
|
||||
#
|
||||
# The requested ref is vetted before checkout: it must be a branch or tag of this
|
||||
# repo, or a commit reachable from one. PR refs are refused outright — this
|
||||
# workflow runs the checked-out code next to MAC_CERTS and the notary password,
|
||||
# so "just build that community PR" must not become a way to hand fork code the
|
||||
# release identity. A branch here always belongs to someone with write access,
|
||||
# which is the same trust the dispatch button itself already requires.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -45,7 +52,7 @@ on:
|
||||
# than the one the workflow file itself is read from — normally leave the
|
||||
# picker on main and name your branch here, so a stale copy of this
|
||||
# workflow on an old branch is not what runs.
|
||||
description: 'Branch, tag, or SHA to build (default: the branch selected above)'
|
||||
description: 'Branch, tag, or SHA to build — must live in stablyai/orca; PR refs are refused (default: the branch selected above)'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
@@ -77,6 +84,14 @@ env:
|
||||
jobs:
|
||||
build-adhoc-mac:
|
||||
if: github.repository == 'stablyai/orca'
|
||||
# Why an environment: it gives the signing/notary/App secrets somewhere to
|
||||
# live that a stale copy of this workflow on an old branch cannot reach.
|
||||
# Referencing it is a no-op until repo settings give it teeth; the intended
|
||||
# follow-up is to move MAC_CERTS, MAC_CERTS_PASSWORD, APPLE_ID,
|
||||
# APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID, HOURLY_RELEASE_APP_ID, and
|
||||
# HOURLY_RELEASE_APP_PRIVATE_KEY into it, then pin its deployment branch
|
||||
# policy to main so only main's copy of this file can read them.
|
||||
environment: adhoc-mac-build
|
||||
runs-on: blacksmith-6vcpu-macos-15
|
||||
# Why 150: it must exceed the worst case the retry budgets below can produce
|
||||
# (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or
|
||||
@@ -85,6 +100,57 @@ jobs:
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
steps:
|
||||
# Why vet before checkout: everything after this step runs the checked-out
|
||||
# code with release signing credentials in reach. Branches and tags of this
|
||||
# repo are the intended audience; refs/pull/* would smuggle in fork code,
|
||||
# and a raw SHA is only accepted when some branch or tag of this repo can
|
||||
# actually reach it. Resolving to a pinned SHA here also means the commit
|
||||
# that was vetted is the commit that gets checked out — a push to the
|
||||
# branch between the two steps cannot swap it.
|
||||
- name: Vet the requested ref
|
||||
id: vetted
|
||||
shell: bash
|
||||
env:
|
||||
REQUESTED_REF: ${{ inputs.ref || github.ref_name }}
|
||||
REPO_URL: https://github.com/${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$REQUESTED_REF" in
|
||||
refs/pull/*|pull/*)
|
||||
echo "::error::Refusing to build PR ref '$REQUESTED_REF': this workflow signs with release credentials, so it only builds branches, tags, or commits of stablyai/orca. Push the code to a branch of this repo instead."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# Bare: a work-tree repo refuses to fetch over its own checked-out
|
||||
# branch. tree:0 keeps the fetch to the commit graph — no trees, no
|
||||
# blobs — so this stays cheap next to the build it fronts.
|
||||
scratch="$RUNNER_TEMP/vet-requested-ref"
|
||||
git init -q --bare "$scratch"
|
||||
git -C "$scratch" fetch -q --filter=tree:0 "$REPO_URL" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
|
||||
# Branch first to keep actions/checkout's old tie-break: bare
|
||||
# rev-parse would prefer the tag when a branch shares its name.
|
||||
sha=""
|
||||
for cand in "refs/heads/$REQUESTED_REF" "refs/tags/$REQUESTED_REF" "$REQUESTED_REF"; do
|
||||
if sha="$(git -C "$scratch" rev-parse --verify --quiet "$cand^{commit}")"; then
|
||||
break
|
||||
fi
|
||||
sha=""
|
||||
done
|
||||
if [[ -z "$sha" ]]; then
|
||||
echo "::error::'$REQUESTED_REF' does not resolve to a branch, tag, or commit of stablyai/orca."
|
||||
exit 1
|
||||
fi
|
||||
# The object resolving locally is not proof a branch or tag reaches
|
||||
# it: a partial clone can lazily fetch a bare SHA on demand, and
|
||||
# GitHub serves PR-only commits by SHA. Reachability is the actual
|
||||
# trust test.
|
||||
if [[ -z "$(git -C "$scratch" for-each-ref --contains "$sha" refs/heads refs/tags | head -1)" ]]; then
|
||||
echo "::error::Commit $REQUESTED_REF is not reachable from any branch or tag of stablyai/orca; refusing to build it with release credentials."
|
||||
exit 1
|
||||
fi
|
||||
echo "Vetted $REQUESTED_REF -> $sha"
|
||||
echo "sha=$sha" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout the requested ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
@@ -92,7 +158,7 @@ jobs:
|
||||
# build code that has not landed, and the workflow definition itself
|
||||
# always comes from the dispatch ref — naming the branch here instead
|
||||
# applies main's current copy of this file to an arbitrary branch.
|
||||
ref: ${{ inputs.ref || github.ref_name }}
|
||||
ref: ${{ steps.vetted.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
# This job only reads stablyai/orca and never pushes; every write goes
|
||||
# to the adhoc repo through a minted App token passed by env. Not
|
||||
@@ -146,17 +212,26 @@ jobs:
|
||||
env:
|
||||
REF: ${{ inputs.ref || github.ref_name }}
|
||||
LABEL: ${{ inputs.label }}
|
||||
MAIN_REPO_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Why this check: the version script is read from the branch being built,
|
||||
# Why this check: the version scripts are read from the branch being built,
|
||||
# not from main, so a branch cut before the adhoc channel landed has no
|
||||
# copy of it. Say that plainly instead of failing with a module-not-found.
|
||||
if [[ ! -f config/scripts/adhoc-build-version.mjs ]]; then
|
||||
echo "::error::$REF has no config/scripts/adhoc-build-version.mjs; rebase it onto a main that has the adhoc channel."
|
||||
exit 1
|
||||
fi
|
||||
# copy of them. Say that plainly instead of failing with a module-not-found.
|
||||
for script in adhoc-build-version dev-channel-base-version; do
|
||||
if [[ ! -f "config/scripts/$script.mjs" ]]; then
|
||||
echo "::error::$REF has no config/scripts/$script.mjs; rebase it onto a main that has the adhoc channel."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "head_sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT"
|
||||
ORCA_ADHOC_LABEL="${LABEL:-$REF}" node config/scripts/adhoc-build-version.mjs \
|
||||
# Why the main repo's tags: package.json on a branch is as stale as the
|
||||
# main it forked from, and stable patches never merge back into it.
|
||||
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \
|
||||
--repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \
|
||||
--json tagName --jq '.[].tagName' || true)"
|
||||
ORCA_PUBLISHED_VERSIONS="$published" ORCA_ADHOC_LABEL="${LABEL:-$REF}" \
|
||||
node config/scripts/adhoc-build-version.mjs \
|
||||
>"$RUNNER_TEMP/adhoc-identity.txt"
|
||||
if ! grep -q '^name=' "$RUNNER_TEMP/adhoc-identity.txt"; then
|
||||
echo "::error::adhoc-build-version.mjs emitted no release name; $REF's copy of the script is out of sync with this workflow."
|
||||
|
||||
@@ -26,7 +26,7 @@ on:
|
||||
# Node, so any change to the daemon bundle graph or the main build must
|
||||
# re-run it (the v1.4.129-rc.1 daemon outage shipped with green CI).
|
||||
- 'electron.vite.config.ts'
|
||||
- 'build-plugins/**'
|
||||
- 'config/build-plugins/**'
|
||||
- 'src/main/daemon/**'
|
||||
- 'native/computer-use-macos/**'
|
||||
- 'native/computer-use-linux/**'
|
||||
|
||||
@@ -179,24 +179,33 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
MAIN_REPO_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Why read the highest number off existing titles rather than counting
|
||||
# releases: the prune step trims to HOURLY_RETAIN_COUNT, so a count would
|
||||
# roll backwards after three days and reissue numbers already in use. The
|
||||
# maximum is always among the retained builds, because pruning drops the
|
||||
# oldest. `capture(...)?` swallows the non-match on legacy titles, which
|
||||
# were the raw tag, so the series simply starts at 01.
|
||||
# Existing titles, which carry the build number this series continues
|
||||
# from. The script picks the number, because it restarts per base version
|
||||
# and only the script knows which base this build resolved to.
|
||||
#
|
||||
# Why drafts count here but not in the freshness check: that check asks
|
||||
# "did this commit ship", where a draft is a no. This one asks "is the
|
||||
# number free", where a stranded draft still holds one.
|
||||
last_number="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json name,isDraft \
|
||||
--jq '[.[] | (.name // "") | capture(" • (?<n>[0-9]+) • ")? | .n | tonumber] | max // 0')"
|
||||
build_number=$(( last_number + 1 ))
|
||||
echo "Hourly build number $build_number (previous high: $last_number)"
|
||||
ORCA_HOURLY_BUILD_NUMBER="$build_number" node config/scripts/hourly-build-version.mjs \
|
||||
names="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json name \
|
||||
--jq '.[].name // empty')"
|
||||
# Why the main repo's tags decide the base version rather than
|
||||
# package.json: main's version only moves on `release:` commits, and
|
||||
# stable patches are cut from release branches that never merge back, so
|
||||
# package.json can sit several patches behind what users are running. A
|
||||
# separate token because GH_TOKEN above is the App's, scoped to the
|
||||
# hourly repo. Empty on failure — the script then falls back to
|
||||
# package.json, which is stale but never wrong enough to fail a build.
|
||||
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \
|
||||
--repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \
|
||||
--json tagName --jq '.[].tagName' || true)"
|
||||
echo "Highest published tag seen: $(head -1 <<<"$published")"
|
||||
ORCA_PUBLISHED_VERSIONS="$published" ORCA_HOURLY_RELEASE_NAMES="$names" \
|
||||
node config/scripts/hourly-build-version.mjs \
|
||||
>"$RUNNER_TEMP/hourly-identity.txt"
|
||||
grep -E '^(version|build_number)=' "$RUNNER_TEMP/hourly-identity.txt"
|
||||
# Why check rather than trust: the checkout above pins `ref: main`, but a
|
||||
# workflow_dispatch runs this file from whatever branch was dispatched. A
|
||||
# branch that edits this step while main still has the old script yields
|
||||
|
||||
@@ -236,8 +236,32 @@ jobs:
|
||||
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
--exclude=src/main/pty/omp-shell-wrapper.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 }}
|
||||
|
||||
cross-version-wire:
|
||||
name: cross-version wire compatibility
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
# Why fetch-depth 0: the harness extracts the newest release tag to skew
|
||||
# current code against it. The default shallow clone has no tags, which is
|
||||
# why this cannot ride along in the sharded `test` job.
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
|
||||
# A path filter that matches nothing exits 1 ("No test files found"), so this
|
||||
# lane cannot report success while running zero tests.
|
||||
- name: Old/new client and server terminal journey
|
||||
run: pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
|
||||
|
||||
package:
|
||||
name: package
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
'!config/reliability-gates.jsonc',
|
||||
'!config/max-lines-baseline.txt',
|
||||
'!config/vitest.config.ts',
|
||||
'build-plugins/**',
|
||||
'config/build-plugins/**',
|
||||
'native/**',
|
||||
'resources/**',
|
||||
'electron.vite.config.ts',
|
||||
|
||||
@@ -100,6 +100,8 @@ docs/**
|
||||
!docs/reference/headless-linux-server.md
|
||||
!docs/reference/linux-glibc-compatibility.md
|
||||
!docs/reference/relay-grace-time-reconfiguration.md
|
||||
!docs/reference/remote-wire-compatibility.md
|
||||
!docs/reference/windows-setup-shell.md
|
||||
|
||||
# Stably CLI (only docs/ are tracked)
|
||||
.stably/*
|
||||
@@ -135,3 +137,6 @@ mobile/src/i18n/locales/.*-catalog-cache.json
|
||||
|
||||
# Bench result JSONs are working artifacts
|
||||
tests/tools/benchmarks/results/terminal-pipeline-*.json
|
||||
|
||||
# Old release trees the cross-version wire harness extracts on demand
|
||||
tests/e2e/.cross-version-checkouts/
|
||||
|
||||
+1
-1
@@ -150,5 +150,5 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/out"]
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/out", "tests/e2e/.cross-version-checkouts"]
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
|
||||
- **Keyboard shortcuts**: Never hardcode `e.metaKey`. Use a platform check (`navigator.userAgent.includes('Mac')`) to pick `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`.
|
||||
- **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms.
|
||||
- **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`.
|
||||
- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md).
|
||||
- **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc.
|
||||
|
||||
## SSH Use Case
|
||||
@@ -39,6 +40,10 @@ All changes must consider the SSH use case. Don't assume local-only execution.
|
||||
|
||||
All changes must consider folder workspaces as well as git worktrees. Don't assume every workspace is a git worktree.
|
||||
|
||||
## Remote Wire Compatibility
|
||||
|
||||
Clients and remote Orca servers update independently, so mixed versions are the normal state. Before changing anything a paired client and host exchange — RPC params, stream frames, or the content either side publishes over them — follow [`docs/reference/remote-wire-compatibility.md`](./docs/reference/remote-wire-compatibility.md). A new optional field is safe; a new stream opcode must be capability-negotiated because decoders drop unknown opcodes silently; and changing what the host publishes reaches old clients even with no wire change.
|
||||
|
||||
## Git Binary Compatibility
|
||||
|
||||
Orca runs the user's Git binary on native, WSL, and SSH hosts, which may all have different versions. Treat Git 2.25 as the core-workflow baseline and follow [`docs/reference/git-compatibility.md`](./docs/reference/git-compatibility.md).
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/app-release.apk) · [Docs →](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.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Download APK 0.0.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/app-release.apk)
|
||||
- **Android:** [Download APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
@@ -238,10 +238,9 @@ Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
|
||||
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
|
||||
- **WeChat:** If group 5 is full, you can join group 6.
|
||||
- **WeChat:** Scan to join the Orca community WeChat group 7.
|
||||
|
||||
<img src="docs/assets/wechat-qr.jpg" alt="WeChat group 5 QR code for the Orca community" width="160" style="margin-right: 16px;" />
|
||||
<img src="docs/assets/wechat-qr-group6.jpg" alt="WeChat group 6 QR code for the Orca community" width="160" />
|
||||
<img src="docs/assets/wechat-qr-group7.jpg" alt="WeChat group 7 QR code for the Orca community" width="160" />
|
||||
|
||||
- **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
|
||||
- **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../src/main/startup/bootstrap-fatal-exit-guard'
|
||||
import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../../src/main/startup/bootstrap-fatal-exit-guard'
|
||||
export const BOOTSTRAP_FATAL_LOG_ENV_VAR = 'ORCA_BOOTSTRAP_FATAL_LOG'
|
||||
export const BOOTSTRAP_FATAL_LOG_FILE_NAME = 'bootstrap-fatal.log'
|
||||
const BOOTSTRAP_FATAL_LOG_MAX_BYTES = 262_144
|
||||
+28
-3
@@ -26,6 +26,23 @@ const PLAIN_NODE_ENTRY_NAMES = [
|
||||
'codex/codex-app-server-grant-entry'
|
||||
] as const
|
||||
|
||||
// Entries executed as worker threads of the main process. Electron's module is
|
||||
// not registered on worker threads, so require("electron") throws
|
||||
// "Cannot find module 'electron'" there too (verified on Electron 43) and kills
|
||||
// the worker at startup. These carry hand-written "must stay electron-free"
|
||||
// comments, which is convention, not enforcement — and the port-scan worker in
|
||||
// particular sits one import away from a client module that deliberately does
|
||||
// require electron.
|
||||
const WORKER_THREAD_ENTRY_NAMES = [
|
||||
'stt-worker',
|
||||
'warp-theme-parser-worker',
|
||||
'session-scanner-opencode-sqlite-worker-entry',
|
||||
'main-thread-hang-watchdog-entry',
|
||||
'port-scan-command-worker-entry'
|
||||
] as const
|
||||
|
||||
type EntryRuntime = 'plain-Node process' | 'worker thread'
|
||||
|
||||
const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/
|
||||
|
||||
function collectReachableChunks(
|
||||
@@ -56,13 +73,14 @@ function collectReachableChunks(
|
||||
function assertNoElectronRequire(
|
||||
entryName: string,
|
||||
entry: OutputChunk,
|
||||
byFileName: Map<string, OutputChunk>
|
||||
byFileName: Map<string, OutputChunk>,
|
||||
runtime: EntryRuntime = 'plain-Node process'
|
||||
): void {
|
||||
for (const chunk of collectReachableChunks(entry, byFileName)) {
|
||||
if (ELECTRON_REQUIRE_RE.test(chunk.code)) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` +
|
||||
`requires electron. "${entryName}" runs as a plain-Node process, where ` +
|
||||
`requires electron. "${entryName}" runs as a ${runtime}, where ` +
|
||||
`require("electron") throws MODULE_NOT_FOUND and kills it at startup (the ` +
|
||||
`v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.`
|
||||
)
|
||||
@@ -125,7 +143,14 @@ export function createPlainNodeEntryGuardPlugin(): Plugin {
|
||||
for (const entryName of PLAIN_NODE_ENTRY_NAMES) {
|
||||
const entry = entryByName.get(entryName)
|
||||
if (entry) {
|
||||
assertNoElectronRequire(entryName, entry, byFileName)
|
||||
assertNoElectronRequire(entryName, entry, byFileName, 'plain-Node process')
|
||||
}
|
||||
}
|
||||
|
||||
for (const entryName of WORKER_THREAD_ENTRY_NAMES) {
|
||||
const entry = entryByName.get(entryName)
|
||||
if (entry) {
|
||||
assertNoElectronRequire(entryName, entry, byFileName, 'worker thread')
|
||||
}
|
||||
}
|
||||
|
||||
+7
-2
@@ -10,6 +10,7 @@
|
||||
"src/main/speech/stt-worker.ts",
|
||||
"src/main/warp-themes/warp-theme-parser-worker.ts",
|
||||
"src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts",
|
||||
"src/main/ports/port-scan-command-worker-entry.ts",
|
||||
"src/main/ipc/parcel-watcher-process-entry.ts",
|
||||
"src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts",
|
||||
"src/main/codex/codex-app-server-grant-entry.ts",
|
||||
@@ -23,7 +24,7 @@
|
||||
"src/relay/relay.ts",
|
||||
"src/relay/wsl-agent-hook-relay.ts",
|
||||
"config/scripts/**/*.{mjs,cjs,js,ts}",
|
||||
"build-plugins/**/*.ts",
|
||||
"config/build-plugins/**/*.ts",
|
||||
"electron.vite.config.ts",
|
||||
"vite.web.config.ts",
|
||||
"config/vitest.config.ts",
|
||||
@@ -33,7 +34,11 @@
|
||||
"tests/**/*.{ts,tsx,mjs}",
|
||||
"**/*.test.{ts,tsx}"
|
||||
],
|
||||
"project": ["src/**/*.{ts,tsx}", "config/scripts/**/*.{mjs,cjs,js,ts}", "build-plugins/**/*.ts"],
|
||||
"project": [
|
||||
"src/**/*.{ts,tsx}",
|
||||
"config/scripts/**/*.{mjs,cjs,js,ts}",
|
||||
"config/build-plugins/**/*.ts"
|
||||
],
|
||||
"ignore": ["mobile/**", "out/**", "dist/**", "node_modules/**", "resources/**"],
|
||||
"ignoreDependencies": ["electron", "@types/*"],
|
||||
"ignoreExportsUsedInFile": true,
|
||||
|
||||
@@ -33,5 +33,26 @@
|
||||
"text": "MB",
|
||||
"dynamic": true,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "mobile/src/host-route-notice.ts",
|
||||
"kind": "user-visible-return",
|
||||
"text": "/h/",
|
||||
"dynamic": true,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "mobile/src/host-route-notice.ts",
|
||||
"kind": "user-visible-return",
|
||||
"text": "?notice=",
|
||||
"dynamic": true,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "mobile/src/transport/mobile-relay-recovery-log.ts",
|
||||
"kind": "object-property:message",
|
||||
"text": "Relay:",
|
||||
"dynamic": true,
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
|
||||
@@ -44,6 +44,14 @@ const PARCEL_WATCHER_PLATFORM_PREFIX_BY_PLATFORM = {
|
||||
linux: 'watcher-linux',
|
||||
win32: 'watcher-win32'
|
||||
}
|
||||
const ELECTRON_ARCHITECTURE_BY_ENUM = {
|
||||
0: 'ia32',
|
||||
1: 'x64',
|
||||
2: 'arm',
|
||||
3: 'arm64',
|
||||
4: 'universal'
|
||||
}
|
||||
const PACKAGED_NATIVE_ARCHITECTURES = new Set(['ia32', 'x64', 'arm', 'arm64'])
|
||||
const TYPE_DECLARATION_ARTIFACT_RE = /\.d\.(?:c|m)?ts(?:\.map)?$/
|
||||
const VERSIONED_ONNXRUNTIME_DYLIB_RE = /^libonnxruntime\.\d[\d.]*\.dylib$/
|
||||
|
||||
@@ -170,7 +178,7 @@ function collectPackagedRuntimePackages(electronPlatformName = process.platform)
|
||||
// optionalDependency (e.g. @parcel/watcher-linux-x64-glibc) that the
|
||||
// dependencies graph above never reaches. Include the ones installed for the
|
||||
// build's supported architectures; afterPack pruning trims non-target
|
||||
// platforms. Without this the packaged main bundle's import of
|
||||
// platform/architecture variants. Without this the packaged main bundle's import of
|
||||
// '@parcel/watcher' resolves at runtime but throws loading its binary.
|
||||
const parcelWatcherDir = packages.get('@parcel/watcher')
|
||||
if (parcelWatcherDir) {
|
||||
@@ -246,13 +254,44 @@ function verifyPackagedMainRuntimeDeps(resourcesDir, asar = require('@electron/a
|
||||
}
|
||||
|
||||
function normalizeNodePtyWindowsArch(electronArch) {
|
||||
if (electronArch === 'x64' || electronArch === 1) {
|
||||
return 'x64'
|
||||
const architecture = normalizeElectronArchitecture(electronArch)
|
||||
if (architecture !== 'x64' && architecture !== 'arm64') {
|
||||
throw new Error(`Unsupported packaged node-pty Windows architecture: ${architecture}`)
|
||||
}
|
||||
if (electronArch === 'arm64' || electronArch === 3) {
|
||||
return 'arm64'
|
||||
return architecture
|
||||
}
|
||||
|
||||
function normalizeElectronArchitecture(electronArch) {
|
||||
const architecture =
|
||||
typeof electronArch === 'number'
|
||||
? ELECTRON_ARCHITECTURE_BY_ENUM[electronArch]
|
||||
: electronArch === 'armv7l'
|
||||
? 'arm'
|
||||
: electronArch
|
||||
if (!PACKAGED_NATIVE_ARCHITECTURES.has(architecture)) {
|
||||
throw new Error(`Unsupported packaged runtime architecture: ${String(electronArch)}`)
|
||||
}
|
||||
return architecture
|
||||
}
|
||||
|
||||
function pruneNodePtyNativeDirectories(directory, platformPrefix, electronArch, allowsSuffix) {
|
||||
if (!existsSync(directory)) {
|
||||
return
|
||||
}
|
||||
const architecture = normalizeElectronArchitecture(electronArch)
|
||||
const targetPrefix = `${platformPrefix}${architecture}`
|
||||
const platformPrefixes = Object.values(NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM)
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || !platformPrefixes.some((prefix) => entry.name.startsWith(prefix))) {
|
||||
continue
|
||||
}
|
||||
const matchesTarget =
|
||||
entry.name.startsWith(platformPrefix) &&
|
||||
(entry.name === targetPrefix || (allowsSuffix && entry.name.startsWith(`${targetPrefix}-`)))
|
||||
if (!matchesTarget) {
|
||||
rmSync(join(directory, entry.name), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
return process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
}
|
||||
|
||||
function findNodePtyConptySourceDir(nodePtyDir, windowsArch) {
|
||||
@@ -308,14 +347,19 @@ function prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch)
|
||||
|
||||
const allowedPrebuildPrefix = NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM[electronPlatformName]
|
||||
if (allowedPrebuildPrefix) {
|
||||
const prebuildsDir = join(nodePtyDir, 'prebuilds')
|
||||
if (existsSync(prebuildsDir)) {
|
||||
for (const entry of readdirSync(prebuildsDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && !entry.name.startsWith(allowedPrebuildPrefix)) {
|
||||
rmSync(join(prebuildsDir, entry.name), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
pruneNodePtyNativeDirectories(
|
||||
join(nodePtyDir, 'prebuilds'),
|
||||
allowedPrebuildPrefix,
|
||||
electronArch,
|
||||
false
|
||||
)
|
||||
// Why: sequential cross-arch rebuilds accumulate ABI-tagged outputs here.
|
||||
pruneNodePtyNativeDirectories(
|
||||
join(nodePtyDir, 'bin'),
|
||||
allowedPrebuildPrefix,
|
||||
electronArch,
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
if (electronPlatformName === 'win32') {
|
||||
@@ -328,7 +372,7 @@ function prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch)
|
||||
}
|
||||
}
|
||||
|
||||
function prunePackagedParcelWatcher(resourcesDir, electronPlatformName) {
|
||||
function prunePackagedParcelWatcher(resourcesDir, electronPlatformName, electronArch) {
|
||||
const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
|
||||
if (!existsSync(parcelDir)) {
|
||||
return
|
||||
@@ -336,9 +380,11 @@ function prunePackagedParcelWatcher(resourcesDir, electronPlatformName) {
|
||||
|
||||
// Why: we package every installed @parcel/watcher-<platform> optional
|
||||
// subpackage (supportedArchitectures fetches all), but each build only needs
|
||||
// its own platform's binary. Keep the core package and the matching platform
|
||||
// subpackages; drop the rest so a Linux serve doesn't ship macOS/Windows .node.
|
||||
// its own platform/architecture binaries. Keep the core package and matching
|
||||
// native variants; drop the rest.
|
||||
const keepPrefix = PARCEL_WATCHER_PLATFORM_PREFIX_BY_PLATFORM[electronPlatformName]
|
||||
const architecture = normalizeElectronArchitecture(electronArch)
|
||||
const targetPrefix = keepPrefix ? `${keepPrefix}-${architecture}` : null
|
||||
for (const entry of readdirSync(parcelDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || entry.name === 'watcher') {
|
||||
continue
|
||||
@@ -348,7 +394,11 @@ function prunePackagedParcelWatcher(resourcesDir, electronPlatformName) {
|
||||
if (!entry.name.startsWith('watcher-')) {
|
||||
continue
|
||||
}
|
||||
if (keepPrefix && entry.name.startsWith(keepPrefix)) {
|
||||
if (
|
||||
keepPrefix &&
|
||||
entry.name.startsWith(keepPrefix) &&
|
||||
(entry.name === targetPrefix || entry.name.startsWith(`${targetPrefix}-`))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
rmSync(join(parcelDir, entry.name), { recursive: true, force: true })
|
||||
@@ -395,8 +445,9 @@ function prunePackagedZodSources(resourcesDir) {
|
||||
}
|
||||
|
||||
function prunePackagedRuntimeNodeModules(resourcesDir, electronPlatformName, electronArch) {
|
||||
prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch)
|
||||
prunePackagedParcelWatcher(resourcesDir, electronPlatformName)
|
||||
const architecture = normalizeElectronArchitecture(electronArch)
|
||||
prunePackagedNodePty(resourcesDir, electronPlatformName, architecture)
|
||||
prunePackagedParcelWatcher(resourcesDir, electronPlatformName, architecture)
|
||||
prunePackagedRuntimeTypeDeclarations(resourcesDir)
|
||||
prunePackagedSherpaOnnx(resourcesDir, electronPlatformName)
|
||||
prunePackagedZodSources(resourcesDir)
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+932
-43
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,10 @@ import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs'
|
||||
import {
|
||||
readPublishedVersionsFromEnv,
|
||||
resolveDevChannelBaseVersion
|
||||
} from './dev-channel-base-version.mjs'
|
||||
|
||||
/** Long enough to name a feature, short enough that a picker row stays readable. */
|
||||
export const ADHOC_LABEL_MAX_LENGTH = 32
|
||||
@@ -67,7 +71,7 @@ export function normalizeAdhocLabel(label) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `1.4.163 • wasm-terminal • 08-01 14:25 • abc1234` — the human-facing release
|
||||
* `1.4.163 • wasm-terminal • Aug 1, 2:25PM • abc1234` — the human-facing release
|
||||
* title, shown verbatim in both the GitHub releases list and the build picker.
|
||||
*
|
||||
* Why the label sits where hourly puts its build number: several adhoc builds
|
||||
@@ -83,12 +87,13 @@ export function formatAdhocReleaseName(version, label, commit, date) {
|
||||
].join(' • ')
|
||||
}
|
||||
|
||||
export function getAdhocBuildIdentity(now = new Date(), label = '') {
|
||||
export function getAdhocBuildIdentity(now = new Date(), label = '', publishedVersions = []) {
|
||||
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
|
||||
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
const version = createAdhocBuildVersion(packageJson.version, now)
|
||||
const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions)
|
||||
const version = createAdhocBuildVersion(base, now)
|
||||
return {
|
||||
commit,
|
||||
version,
|
||||
@@ -98,7 +103,11 @@ export function getAdhocBuildIdentity(now = new Date(), label = '') {
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
|
||||
const identity = getAdhocBuildIdentity(new Date(), process.env.ORCA_ADHOC_LABEL ?? '')
|
||||
const identity = getAdhocBuildIdentity(
|
||||
new Date(),
|
||||
process.env.ORCA_ADHOC_LABEL ?? '',
|
||||
readPublishedVersionsFromEnv()
|
||||
)
|
||||
// Consumed by the workflow via $GITHUB_OUTPUT.
|
||||
process.stdout.write(
|
||||
`version=${identity.version}\ncommit=${identity.commit}\nlabel=${identity.label}\nname=${identity.name}\n`
|
||||
|
||||
@@ -88,20 +88,20 @@ describe('formatAdhocReleaseName', () => {
|
||||
formatAdhocReleaseName('1.4.163-adhoc.x', label, commit, new Date(iso))
|
||||
|
||||
it('renders version, label, Pacific timestamp, and short sha', () => {
|
||||
expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • wasm-terminal • 07-31 13:54 • e698241')
|
||||
expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • wasm-terminal • Jul 31, 1:54PM • e698241')
|
||||
})
|
||||
|
||||
// Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so
|
||||
// the offset between them is not a constant. A test pinned to one season would
|
||||
// pass all summer and start failing in November.
|
||||
it('follows the Pacific offset across DST', () => {
|
||||
expect(name('2026-01-15T02:30:00Z')).toContain(' 01-14 18:30 ')
|
||||
expect(name('2026-07-31T07:00:00Z')).toContain(' 07-31 00:00 ')
|
||||
expect(name('2026-01-15T02:30:00Z')).toContain(' Jan 14, 6:30PM ')
|
||||
expect(name('2026-07-31T07:00:00Z')).toContain(' Jul 31, 12:00AM ')
|
||||
})
|
||||
|
||||
it('sanitizes the label before it reaches the title', () => {
|
||||
expect(name('2026-07-31T20:54:00Z', 'refs/heads/fix • now')).toBe(
|
||||
'1.4.163 • fix-now • 07-31 13:54 • e698241'
|
||||
'1.4.163 • fix-now • Jul 31, 1:54PM • e698241'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
@@ -6,6 +14,21 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
const itCrossHost = process.platform === 'win32' ? it.skip : it
|
||||
const projectRoot = resolve(import.meta.dirname, '../..')
|
||||
const WINDOWS_LOCK_CODES = ['EBUSY', 'ENOTEMPTY', 'EPERM']
|
||||
|
||||
// Why: Windows releases the image handle on a just-executed exe (and finishes the
|
||||
// AV scan of the freshly compiled one) after the process exits, so tearing down the
|
||||
// fixture races those locks. Retry, then leave the temp tree rather than reporting a
|
||||
// teardown lock as a launcher failure.
|
||||
function removeFixtureTree(path) {
|
||||
try {
|
||||
rmSync(path, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
|
||||
} catch (error) {
|
||||
if (process.platform !== 'win32' || !WINDOWS_LOCK_CODES.includes(error?.code)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
// Why: cold csc.exe startup exceeds Vitest's 5s unit budget on hosted Windows;
|
||||
// keep the larger allowance scoped to the real compiler integration test.
|
||||
function itWindows(name, test) {
|
||||
@@ -27,10 +50,24 @@ describe('Windows CLI launcher', () => {
|
||||
expect(result.stderr).toContain('Windows CLI launcher')
|
||||
expect(result.stderr).toContain('Windows host')
|
||||
} finally {
|
||||
rmSync(outputRoot, { recursive: true, force: true })
|
||||
removeFixtureTree(outputRoot)
|
||||
}
|
||||
})
|
||||
|
||||
itCrossHost('never materializes the child environment block from ProcessStartInfo', () => {
|
||||
// Why: both ProcessStartInfo env properties copy the process block into a case-insensitive
|
||||
// dictionary that throws when the inherited block holds PATH and Path (stablyai/orca#12046).
|
||||
const source = readFileSync(
|
||||
join(projectRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs'),
|
||||
'utf8'
|
||||
)
|
||||
const code = source.replace(/^\s*\/\/.*$/gm, '')
|
||||
|
||||
expect(code).not.toContain('EnvironmentVariables')
|
||||
expect(code).not.toContain('startInfo.Environment')
|
||||
expect(code).toContain('Environment.SetEnvironmentVariable')
|
||||
})
|
||||
|
||||
itWindows('preserves a multiline argument from PowerShell through the native launcher', () => {
|
||||
const appRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher '))
|
||||
try {
|
||||
@@ -86,7 +123,73 @@ describe('Windows CLI launcher', () => {
|
||||
orcaNodeOptions: '--no-warnings'
|
||||
})
|
||||
} finally {
|
||||
rmSync(appRoot, { recursive: true, force: true })
|
||||
removeFixtureTree(appRoot)
|
||||
}
|
||||
})
|
||||
|
||||
itWindows('survives an inherited environment block containing PATH and Path', () => {
|
||||
const appRoot = mkdtempSync(join(tmpdir(), 'orca duplicate path launcher '))
|
||||
try {
|
||||
const resourcesPath = join(appRoot, 'resources')
|
||||
const launcherPath = join(resourcesPath, 'bin', 'orca.exe')
|
||||
const cliPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js')
|
||||
const outputPath = join(appRoot, 'child-result.json')
|
||||
const harnessSourcePath = join(
|
||||
projectRoot,
|
||||
'config',
|
||||
'scripts',
|
||||
'fixtures',
|
||||
'DuplicatePathProcessLauncher.cs'
|
||||
)
|
||||
const harnessPath = join(appRoot, 'DuplicatePathLauncher.exe')
|
||||
mkdirSync(dirname(launcherPath), { recursive: true })
|
||||
mkdirSync(dirname(cliPath), { recursive: true })
|
||||
copyFileSync(process.execPath, join(appRoot, 'Orca.exe'))
|
||||
writeFileSync(
|
||||
cliPath,
|
||||
`require('node:fs').writeFileSync(process.env.ORCA_TEST_OUTPUT, JSON.stringify({
|
||||
electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE,
|
||||
pathKeys: Object.keys(process.env).filter((key) => key.toLowerCase() === 'path')
|
||||
}))\n`,
|
||||
'utf8'
|
||||
)
|
||||
const build = spawnSync(
|
||||
process.execPath,
|
||||
['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath],
|
||||
{ cwd: projectRoot, encoding: 'utf8' }
|
||||
)
|
||||
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0)
|
||||
|
||||
const compiler = findFrameworkCompiler()
|
||||
expect(compiler).not.toBeNull()
|
||||
const compileHarness = spawnSync(
|
||||
compiler,
|
||||
['/nologo', '/target:exe', `/out:${harnessPath}`, harnessSourcePath],
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
expect(compileHarness.status, `${compileHarness.stdout}\n${compileHarness.stderr}`).toBe(0)
|
||||
|
||||
const launch = spawnSync(harnessPath, [launcherPath, outputPath], { encoding: 'utf8' })
|
||||
expect(launch.status, `${launch.stdout}\n${launch.stderr}`).toBe(0)
|
||||
expect(JSON.parse(readFileSync(outputPath, 'utf8'))).toEqual({
|
||||
electronRunAsNode: '1',
|
||||
pathKeys: ['PATH', 'Path']
|
||||
})
|
||||
} finally {
|
||||
removeFixtureTree(appRoot)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function findFrameworkCompiler() {
|
||||
const windowsDirectory = process.env.WINDIR ?? process.env.SystemRoot
|
||||
if (!windowsDirectory) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
[
|
||||
join(windowsDirectory, 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe'),
|
||||
join(windowsDirectory, 'Microsoft.NET', 'Framework', 'v4.0.30319', 'csc.exe')
|
||||
].find((candidate) => existsSync(candidate)) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ describe('computer-use e2e workflow', () => {
|
||||
'config/scripts/daemon-boot-smoke.mjs',
|
||||
'config/scripts/windows-daemon-workspace-close-repro.mjs',
|
||||
'electron.vite.config.ts',
|
||||
'build-plugins/**',
|
||||
'config/build-plugins/**',
|
||||
'src/main/daemon/**'
|
||||
])
|
||||
)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
|
||||
|
||||
function parseVersionTriple(value) {
|
||||
const match = SEMVER.exec(String(value ?? '').trim())
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3]),
|
||||
prerelease: match[4] ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function compareTriples(a, b) {
|
||||
return a.major - b.major || a.minor - b.minor || a.patch - b.patch
|
||||
}
|
||||
|
||||
/**
|
||||
* The base `X.Y.Z` an hourly or adhoc build should carry.
|
||||
*
|
||||
* Why not package.json alone: main's version only moves on `release:` commits, and
|
||||
* stable patches are cut from release branches that never merge back. On
|
||||
* 2026-08-03 main read `1.4.165-rc.0` for twenty hours while 1.4.165, 1.4.166 and
|
||||
* 1.4.167 all shipped — so hourlies built from that main claimed 1.4.165 while
|
||||
* carrying code newer than 1.4.167, and sorted *below* the stable their user was
|
||||
* already running. Published tags are the only honest answer to "what number is
|
||||
* taken"; package.json is a floor, not a source of truth.
|
||||
*/
|
||||
export function resolveDevChannelBaseVersion(packageVersion, publishedVersions = []) {
|
||||
const fromPackage = parseVersionTriple(packageVersion)
|
||||
if (!fromPackage) {
|
||||
throw new Error(`Package version is not valid semver: ${packageVersion}`)
|
||||
}
|
||||
|
||||
// Unparseable tags are skipped rather than fatal: the main repo carries old tags
|
||||
// that predate the current scheme, and one of them must not fail every build.
|
||||
const published = publishedVersions.map(parseVersionTriple).filter(Boolean)
|
||||
|
||||
let base = fromPackage
|
||||
if (published.length > 0) {
|
||||
const highest = published.reduce((best, entry) =>
|
||||
compareTriples(entry, best) > 0 ? entry : best
|
||||
)
|
||||
// A shipped stable owns its number, so the next dev build belongs on the patch
|
||||
// above it. A bare prerelease does not — rc.1 of 1.4.168 means 1.4.168 is still
|
||||
// the version being worked toward, which is exactly what main is building.
|
||||
const shipped = published.some(
|
||||
(entry) => !entry.prerelease && compareTriples(entry, highest) === 0
|
||||
)
|
||||
const next = shipped ? { ...highest, patch: highest.patch + 1 } : highest
|
||||
if (compareTriples(next, base) > 0) {
|
||||
base = next
|
||||
}
|
||||
}
|
||||
|
||||
return `${base.major}.${base.minor}.${base.patch}`
|
||||
}
|
||||
|
||||
/** Tag list the workflow reads out of the main repo, newline separated. */
|
||||
export function readPublishedVersionsFromEnv(value = process.env.ORCA_PUBLISHED_VERSIONS) {
|
||||
return String(value ?? '')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
readPublishedVersionsFromEnv,
|
||||
resolveDevChannelBaseVersion
|
||||
} from './dev-channel-base-version.mjs'
|
||||
|
||||
describe('dev channel base version', () => {
|
||||
it('falls back to package.json when no tags are supplied', () => {
|
||||
expect(resolveDevChannelBaseVersion('1.4.168-rc.1')).toBe('1.4.168')
|
||||
expect(resolveDevChannelBaseVersion('1.4.168', [])).toBe('1.4.168')
|
||||
})
|
||||
|
||||
// The bug this exists for: main sat at 1.4.165-rc.0 for twenty hours while three
|
||||
// stables shipped, so hourlies claimed a version their users had already passed.
|
||||
it('climbs past stables that shipped while main stood still', () => {
|
||||
expect(
|
||||
resolveDevChannelBaseVersion('1.4.165-rc.0', [
|
||||
'v1.4.165',
|
||||
'v1.4.166',
|
||||
'v1.4.167',
|
||||
'v1.4.165-rc.0'
|
||||
])
|
||||
).toBe('1.4.168')
|
||||
})
|
||||
|
||||
// Why +1 on a stable but not on a prerelease: 1.4.167 is spent, so the next dev
|
||||
// build is 1.4.168. But 1.4.168-rc.1 means 1.4.168 is still being built toward,
|
||||
// which is what main holds — claiming 1.4.169 would jump a release nobody cut.
|
||||
it('takes the patch above a shipped stable and holds at an open prerelease', () => {
|
||||
expect(resolveDevChannelBaseVersion('1.4.167', ['v1.4.167'])).toBe('1.4.168')
|
||||
expect(resolveDevChannelBaseVersion('1.4.168-rc.1', ['v1.4.168-rc.1'])).toBe('1.4.168')
|
||||
})
|
||||
|
||||
// Why max and not most-recent: a hotfix on an old line published today would
|
||||
// otherwise drag every subsequent hourly backwards.
|
||||
it('reads the highest tag, not the last one listed', () => {
|
||||
expect(resolveDevChannelBaseVersion('1.4.160', ['v1.4.167', 'v1.3.99', 'v1.4.120'])).toBe(
|
||||
'1.4.168'
|
||||
)
|
||||
})
|
||||
|
||||
it('treats package.json as a floor when it leads the tags', () => {
|
||||
expect(resolveDevChannelBaseVersion('1.5.0-rc.0', ['v1.4.167'])).toBe('1.5.0')
|
||||
})
|
||||
|
||||
// Why skipped rather than fatal: the main repo carries legacy tags, and one
|
||||
// unparseable entry must not fail every hourly build.
|
||||
it('ignores tags it cannot parse', () => {
|
||||
expect(resolveDevChannelBaseVersion('1.4.160', ['nightly', '', 'v1.4.167', 'latest'])).toBe(
|
||||
'1.4.168'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a package version that is not semver', () => {
|
||||
expect(() => resolveDevChannelBaseVersion('not-a-version')).toThrow(/not valid semver/)
|
||||
})
|
||||
|
||||
it('carries major and minor rollovers through the bump', () => {
|
||||
expect(resolveDevChannelBaseVersion('1.4.0', ['v2.0.0'])).toBe('2.0.1')
|
||||
})
|
||||
|
||||
it('splits an env tag list on any whitespace', () => {
|
||||
expect(readPublishedVersionsFromEnv('v1.4.167\nv1.4.166\n')).toEqual(['v1.4.167', 'v1.4.166'])
|
||||
expect(readPublishedVersionsFromEnv('')).toEqual([])
|
||||
expect(readPublishedVersionsFromEnv(undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -420,40 +420,42 @@ describe('electron-builder config', () => {
|
||||
expect(findAsarEntry(['/out/main/index.js'], 'out/main/index.js')).toBe('/out/main/index.js')
|
||||
})
|
||||
|
||||
it('prunes non-target node-pty prebuilds from packaged runtime resources', async () => {
|
||||
it('prunes non-target node-pty architecture outputs from packaged runtime resources', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-node-pty-prune-'))
|
||||
try {
|
||||
const prebuildsDir = join(resourcesDir, 'node_modules', 'node-pty', 'prebuilds')
|
||||
const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
|
||||
const prebuildsDir = join(nodePtyDir, 'prebuilds')
|
||||
const binDir = join(nodePtyDir, 'bin')
|
||||
await mkdir(join(prebuildsDir, 'darwin-arm64'), { recursive: true })
|
||||
await mkdir(join(prebuildsDir, 'darwin-x64'), { recursive: true })
|
||||
await mkdir(join(prebuildsDir, 'linux-x64'), { recursive: true })
|
||||
await mkdir(join(prebuildsDir, 'win32-x64'), { recursive: true })
|
||||
await mkdir(join(resourcesDir, 'node_modules', 'node-pty', 'third_party', 'conpty'), {
|
||||
recursive: true
|
||||
})
|
||||
await mkdir(join(resourcesDir, 'node_modules', 'node-pty', 'deps', 'winpty'), {
|
||||
await mkdir(join(binDir, 'darwin-arm64-148'), { recursive: true })
|
||||
await mkdir(join(binDir, 'darwin-x64-148'), { recursive: true })
|
||||
await mkdir(join(nodePtyDir, 'third_party', 'conpty'), {
|
||||
recursive: true
|
||||
})
|
||||
await mkdir(join(nodePtyDir, 'deps', 'winpty'), { recursive: true })
|
||||
|
||||
prunePackagedNodePty(resourcesDir, 'darwin')
|
||||
prunePackagedNodePty(resourcesDir, 'darwin', 3)
|
||||
|
||||
await expect(readdir(prebuildsDir).then((entries) => entries.sort())).resolves.toEqual([
|
||||
'darwin-arm64',
|
||||
'darwin-x64'
|
||||
])
|
||||
await expect(
|
||||
readdir(join(resourcesDir, 'node_modules', 'node-pty', 'third_party'))
|
||||
).resolves.toEqual([])
|
||||
await expect(
|
||||
readdir(join(resourcesDir, 'node_modules', 'node-pty', 'deps'))
|
||||
).resolves.toEqual([])
|
||||
await expect(readdir(prebuildsDir)).resolves.toEqual(['darwin-arm64'])
|
||||
await expect(readdir(binDir)).resolves.toEqual(['darwin-arm64-148'])
|
||||
await expect(readdir(join(nodePtyDir, 'third_party'))).resolves.toEqual([])
|
||||
await expect(readdir(join(nodePtyDir, 'deps'))).resolves.toEqual([])
|
||||
expect(() => prunePackagedNodePty(resourcesDir, 'darwin', 4)).toThrow(
|
||||
'Unsupported packaged runtime architecture: 4'
|
||||
)
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('copies the Windows node-pty ConPTY runtime beside the rebuilt addon', async () => {
|
||||
for (const arch of ['x64', 'arm64']) {
|
||||
for (const [arch, electronArch] of [
|
||||
['x64', 1],
|
||||
['arm64', 3]
|
||||
]) {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), `orca-node-pty-conpty-${arch}-`))
|
||||
try {
|
||||
const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
|
||||
@@ -472,7 +474,7 @@ describe('electron-builder config', () => {
|
||||
)
|
||||
}
|
||||
|
||||
prunePackagedNodePty(resourcesDir, 'win32', arch)
|
||||
prunePackagedNodePty(resourcesDir, 'win32', electronArch)
|
||||
|
||||
await expect(readFile(join(releaseDir, 'conpty', 'conpty.dll'), 'utf8')).resolves.toBe(
|
||||
`dll payload ${arch}`
|
||||
@@ -500,7 +502,7 @@ describe('electron-builder config', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('prunes non-target @parcel/watcher platform subpackages from packaged runtime resources', async () => {
|
||||
it('prunes non-target @parcel/watcher architecture subpackages', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-'))
|
||||
try {
|
||||
const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
|
||||
@@ -511,13 +513,15 @@ describe('electron-builder config', () => {
|
||||
await mkdir(join(parcelDir, 'watcher-linux-arm64-glibc'), { recursive: true })
|
||||
await mkdir(join(parcelDir, 'watcher-win32-x64'), { recursive: true })
|
||||
|
||||
prunePackagedParcelWatcher(resourcesDir, 'linux')
|
||||
prunePackagedParcelWatcher(resourcesDir, 'linux', 'arm64')
|
||||
|
||||
await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
|
||||
'watcher',
|
||||
'watcher-linux-arm64-glibc',
|
||||
'watcher-linux-x64-glibc'
|
||||
'watcher-linux-arm64-glibc'
|
||||
])
|
||||
expect(() => prunePackagedParcelWatcher(resourcesDir, 'linux', 'universal')).toThrow(
|
||||
'Unsupported packaged runtime architecture: universal'
|
||||
)
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -533,7 +537,7 @@ describe('electron-builder config', () => {
|
||||
// A hypothetical future @parcel/* runtime dep that is NOT a watcher subpackage.
|
||||
await mkdir(join(parcelDir, 'transformer-js'), { recursive: true })
|
||||
|
||||
prunePackagedParcelWatcher(resourcesDir, 'linux')
|
||||
prunePackagedParcelWatcher(resourcesDir, 'linux', 1)
|
||||
|
||||
await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
|
||||
'transformer-js',
|
||||
@@ -653,7 +657,8 @@ describe('electron-builder config', () => {
|
||||
|
||||
await electronBuilderConfig.afterPack({
|
||||
appOutDir: join(root, 'linux-unpacked'),
|
||||
electronPlatformName: 'linux'
|
||||
electronPlatformName: 'linux',
|
||||
arch: 1
|
||||
})
|
||||
|
||||
expect((await stat(launcherPath)).mode & 0o111).not.toBe(0)
|
||||
@@ -662,4 +667,39 @@ describe('electron-builder config', () => {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Why: the .deb/.rpm update-recovery path keys entirely off the resources/package-type marker that
|
||||
// app-builder-lib's FpmTarget writes. If packaging silently stops shipping an fpm target, or adds
|
||||
// one the recovery path does not cover, getLinuxRootPackageType() returns null, autoInstallOnAppQuit
|
||||
// quietly goes back to true, and no unit test notices.
|
||||
describe('linux root-package update recovery contract', () => {
|
||||
// FpmTarget writes resources/package-type only for targets it supports auto-update for.
|
||||
const MARKER_TARGETS = new Set(['deb', 'rpm', 'pacman'])
|
||||
const RECOVERABLE_TARGETS = new Set(['deb', 'rpm'])
|
||||
const linuxTargets = electronBuilderConfig.linux.target.map((entry) =>
|
||||
typeof entry === 'string' ? entry : entry.target
|
||||
)
|
||||
|
||||
it('still ships an AppImage plus at least one root-package target', () => {
|
||||
expect(linuxTargets).toContain('AppImage')
|
||||
expect(linuxTargets.some((target) => MARKER_TARGETS.has(target))).toBe(true)
|
||||
})
|
||||
|
||||
it('ships no root-package target the recovery path cannot recover', () => {
|
||||
const unrecoverable = linuxTargets.filter(
|
||||
(target) => MARKER_TARGETS.has(target) && !RECOVERABLE_TARGETS.has(target)
|
||||
)
|
||||
expect(unrecoverable).toEqual([])
|
||||
})
|
||||
|
||||
it('accepts exactly the markers electron-updater maps to a root-package updater', async () => {
|
||||
const source = await readFile(
|
||||
new URL('../../src/main/linux-update-package-type.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
for (const target of linuxTargets.filter((entry) => RECOVERABLE_TARGETS.has(entry))) {
|
||||
expect(source).toContain(`value === '${target}'`)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
BOOTSTRAP_FATAL_LOG_ENV_VAR,
|
||||
BOOTSTRAP_FATAL_LOG_FILE_NAME,
|
||||
createBootstrapFatalExitBanner
|
||||
} from '../../build-plugins/bootstrap-fatal-exit-banner'
|
||||
} from '../build-plugins/bootstrap-fatal-exit-banner'
|
||||
import { electronViteConfig } from '../../electron.vite.config'
|
||||
import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../../src/main/startup/bootstrap-fatal-exit-guard'
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('Electron Vite output contract', () => {
|
||||
expect(output.chunkFileNames).toBe('chunks/[name]-[hash].js')
|
||||
})
|
||||
|
||||
it('externalizes packaged dependencies but bundles the daemon xterm graph', () => {
|
||||
it('externalizes packaged dependencies but bundles self-contained main dependencies', () => {
|
||||
const external = electronViteConfig.main?.build?.rollupOptions?.external
|
||||
if (typeof external !== 'function') {
|
||||
throw new Error('Expected main-process external predicate')
|
||||
@@ -91,7 +91,9 @@ describe('Electron Vite output contract', () => {
|
||||
expect(external('node:fs', undefined, false)).toBe(true)
|
||||
expect(external('@xterm/headless', undefined, false)).toBe(false)
|
||||
expect(external('@xterm/addon-serialize', undefined, false)).toBe(false)
|
||||
expect(external('psl', undefined, false)).toBe(false)
|
||||
expect(external('zod', undefined, false)).toBe(false)
|
||||
expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('psl')
|
||||
expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod')
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
internal static class DuplicatePathProcessLauncher
|
||||
{
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct StartupInfo
|
||||
{
|
||||
public int cb;
|
||||
public string lpReserved;
|
||||
public string lpDesktop;
|
||||
public string lpTitle;
|
||||
public int dwX;
|
||||
public int dwY;
|
||||
public int dwXSize;
|
||||
public int dwYSize;
|
||||
public int dwXCountChars;
|
||||
public int dwYCountChars;
|
||||
public int dwFillAttribute;
|
||||
public int dwFlags;
|
||||
public short wShowWindow;
|
||||
public short cbReserved2;
|
||||
public IntPtr lpReserved2;
|
||||
public IntPtr hStdInput;
|
||||
public IntPtr hStdOutput;
|
||||
public IntPtr hStdError;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct ProcessInformation
|
||||
{
|
||||
public IntPtr hProcess;
|
||||
public IntPtr hThread;
|
||||
public int dwProcessId;
|
||||
public int dwThreadId;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern bool CreateProcess(
|
||||
string applicationName,
|
||||
StringBuilder commandLine,
|
||||
IntPtr processAttributes,
|
||||
IntPtr threadAttributes,
|
||||
bool inheritHandles,
|
||||
uint creationFlags,
|
||||
IntPtr environment,
|
||||
string currentDirectory,
|
||||
ref StartupInfo startupInfo,
|
||||
out ProcessInformation processInformation
|
||||
);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern bool CloseHandle(IntPtr handle);
|
||||
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
List<string> entries = new List<string>();
|
||||
foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables())
|
||||
{
|
||||
if (!String.Equals((string)entry.Key, "PATH", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
entries.Add((string)entry.Key + "=" + (string)entry.Value);
|
||||
}
|
||||
}
|
||||
entries.Add("PATH=C:\\live");
|
||||
entries.Add("Path=C:\\shadowed");
|
||||
entries.Add("ORCA_TEST_OUTPUT=" + args[1]);
|
||||
|
||||
IntPtr environment = Marshal.StringToHGlobalUni(String.Join("\0", entries.ToArray()) + "\0\0");
|
||||
StartupInfo startupInfo = new StartupInfo();
|
||||
startupInfo.cb = Marshal.SizeOf(startupInfo);
|
||||
ProcessInformation processInformation;
|
||||
try
|
||||
{
|
||||
bool started = CreateProcess(
|
||||
args[0],
|
||||
new StringBuilder("\"" + args[0] + "\""),
|
||||
IntPtr.Zero,
|
||||
IntPtr.Zero,
|
||||
false,
|
||||
0x00000400,
|
||||
environment,
|
||||
null,
|
||||
ref startupInfo,
|
||||
out processInformation
|
||||
);
|
||||
if (!started)
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(environment);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
WaitForSingleObject(processInformation.hProcess, 0xffffffff);
|
||||
uint exitCode;
|
||||
if (!GetExitCodeProcess(processInformation.hProcess, out exitCode))
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
return (int)exitCode;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseHandle(processInformation.hThread);
|
||||
CloseHandle(processInformation.hProcess);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
installHappyDomMutationObserverRetention,
|
||||
retainedMutationCallbackCount
|
||||
} from './happy-dom-mutation-observer-retention'
|
||||
|
||||
function readListenerCallbacks(target: Node): unknown[] {
|
||||
const listenersSymbol = Object.getOwnPropertySymbols(target).find(
|
||||
(candidate) => candidate.description === 'mutationListeners'
|
||||
)
|
||||
const listeners = listenersSymbol
|
||||
? (target as unknown as Record<symbol, unknown>)[listenersSymbol]
|
||||
: []
|
||||
if (!Array.isArray(listeners)) {
|
||||
return []
|
||||
}
|
||||
return listeners.map((listener: { callback?: { deref: () => unknown } }) =>
|
||||
listener.callback?.deref()
|
||||
)
|
||||
}
|
||||
|
||||
describe('happy-dom MutationObserver retention', () => {
|
||||
it('pins the internal callback that happy-dom only holds weakly', () => {
|
||||
expect(installHappyDomMutationObserverRetention()).toBe(true)
|
||||
const target = document.createElement('div')
|
||||
document.body.append(target)
|
||||
const observer = new MutationObserver(() => {})
|
||||
observer.observe(target, { childList: true, subtree: true })
|
||||
|
||||
const callbacks = readListenerCallbacks(target)
|
||||
expect(callbacks.length).toBe(1)
|
||||
expect(callbacks[0]).toBeTypeOf('function')
|
||||
expect(retainedMutationCallbackCount(observer)).toBe(1)
|
||||
|
||||
observer.disconnect()
|
||||
expect(retainedMutationCallbackCount(observer)).toBe(0)
|
||||
target.remove()
|
||||
})
|
||||
|
||||
it('keeps delivering records after the weak callback would have been collected', async () => {
|
||||
installHappyDomMutationObserverRetention()
|
||||
const target = document.createElement('div')
|
||||
document.body.append(target)
|
||||
let deliveries = 0
|
||||
const observer = new MutationObserver(() => {
|
||||
deliveries += 1
|
||||
})
|
||||
observer.observe(target, { childList: true, subtree: true })
|
||||
|
||||
target.replaceChildren(document.createElement('div'))
|
||||
await Promise.resolve()
|
||||
expect(deliveries).toBe(1)
|
||||
|
||||
const collectGarbage = (globalThis as { gc?: () => void }).gc
|
||||
if (collectGarbage) {
|
||||
for (let round = 0; round < 5; round += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1))
|
||||
collectGarbage()
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1))
|
||||
}
|
||||
|
||||
target.replaceChildren(document.createElement('span'))
|
||||
await Promise.resolve()
|
||||
expect(deliveries).toBe(2)
|
||||
|
||||
observer.disconnect()
|
||||
target.remove()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
// Why: happy-dom stores each MutationObserver's internal callback in a WeakRef, so any GC pause
|
||||
// under parallel test load silently and permanently stops a still-connected observer. Browsers
|
||||
// keep that callback reachable for as long as the observer observes; mirror that lifetime here so
|
||||
// DOM-driven tests never lose mutation records mid-run.
|
||||
|
||||
type HappyDomMutationListener = {
|
||||
callback?: { deref: () => unknown }
|
||||
}
|
||||
|
||||
type PatchableMutationObserver = {
|
||||
observe: (target: Node, options?: MutationObserverInit) => void
|
||||
disconnect: () => void
|
||||
}
|
||||
|
||||
const MUTATION_LISTENERS_SYMBOL_DESCRIPTION = 'mutationListeners'
|
||||
const RETENTION_INSTALLED = Symbol.for('orca.happyDomMutationObserverRetention')
|
||||
|
||||
const retainedCallbacks = new WeakMap<object, Set<unknown>>()
|
||||
|
||||
function readMutationListeners(target: Node): HappyDomMutationListener[] {
|
||||
const listenersSymbol = Object.getOwnPropertySymbols(target).find(
|
||||
(candidate) => candidate.description === MUTATION_LISTENERS_SYMBOL_DESCRIPTION
|
||||
)
|
||||
if (!listenersSymbol) {
|
||||
return []
|
||||
}
|
||||
const listeners = (target as unknown as Record<symbol, unknown>)[listenersSymbol]
|
||||
return Array.isArray(listeners) ? (listeners as HappyDomMutationListener[]) : []
|
||||
}
|
||||
|
||||
/** Number of internal callbacks pinned for `observer`; drops to 0 once it disconnects. */
|
||||
export function retainedMutationCallbackCount(observer: MutationObserver): number {
|
||||
return retainedCallbacks.get(observer)?.size ?? 0
|
||||
}
|
||||
|
||||
export function installHappyDomMutationObserverRetention(): boolean {
|
||||
const observerClass = (globalThis as { MutationObserver?: typeof MutationObserver })
|
||||
.MutationObserver
|
||||
if (!observerClass) {
|
||||
return false
|
||||
}
|
||||
const prototype = observerClass.prototype as unknown as PatchableMutationObserver &
|
||||
Record<symbol, unknown>
|
||||
if (prototype[RETENTION_INSTALLED] === true) {
|
||||
return true
|
||||
}
|
||||
const observe = prototype.observe
|
||||
const disconnect = prototype.disconnect
|
||||
|
||||
prototype.observe = function patchedObserve(
|
||||
this: object,
|
||||
target: Node,
|
||||
options?: MutationObserverInit
|
||||
): void {
|
||||
const existing = new Set(readMutationListeners(target))
|
||||
observe.call(this as unknown as PatchableMutationObserver, target, options)
|
||||
const pinned = retainedCallbacks.get(this) ?? new Set<unknown>()
|
||||
for (const listener of readMutationListeners(target)) {
|
||||
if (existing.has(listener)) {
|
||||
continue
|
||||
}
|
||||
const callback = listener.callback?.deref()
|
||||
if (callback) {
|
||||
pinned.add(callback)
|
||||
}
|
||||
}
|
||||
if (pinned.size > 0) {
|
||||
retainedCallbacks.set(this, pinned)
|
||||
}
|
||||
}
|
||||
|
||||
prototype.disconnect = function patchedDisconnect(this: object): void {
|
||||
disconnect.call(this as unknown as PatchableMutationObserver)
|
||||
retainedCallbacks.delete(this)
|
||||
}
|
||||
|
||||
prototype[RETENTION_INSTALLED] = true
|
||||
return true
|
||||
}
|
||||
|
||||
installHappyDomMutationObserverRetention()
|
||||
@@ -2,6 +2,10 @@ import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs'
|
||||
import {
|
||||
readPublishedVersionsFromEnv,
|
||||
resolveDevChannelBaseVersion
|
||||
} from './dev-channel-base-version.mjs'
|
||||
|
||||
/** `1.4.160-hourly.202607281400` — UTC to the minute, so tags sort chronologically
|
||||
* by semver and every build is uniquely versioned. */
|
||||
@@ -30,8 +34,34 @@ export function createHourlyBuildVersion(baseVersion, date) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `1.4.163 • 01 • 07-31 13:54 • e698241` — the human-facing release title, shown
|
||||
* verbatim in both the GitHub releases list and the in-app build picker.
|
||||
* The next build number for `baseVersion`, counting from the titles of existing
|
||||
* hourly releases.
|
||||
*
|
||||
* Why the series restarts at 01 on every base version: the number answers "which
|
||||
* build of 1.4.163 is this", so a counter shared across versions makes it
|
||||
* meaningless — 1.4.164 would open at 38 for no reason a reader can see.
|
||||
*
|
||||
* Why the maximum rather than a count: the prune step trims to
|
||||
* HOURLY_RETAIN_COUNT, so a count would roll backwards and reissue a number
|
||||
* already in use. Titles that predate this naming simply do not match, which is
|
||||
* how the first build of a version lands on 01.
|
||||
*/
|
||||
export function nextHourlyBuildNumber(baseVersion, releaseNames = []) {
|
||||
const prefix = `${baseVersion} • `
|
||||
const highest = releaseNames.reduce((max, entry) => {
|
||||
const name = String(entry ?? '')
|
||||
if (!name.startsWith(prefix)) {
|
||||
return max
|
||||
}
|
||||
const match = /^(\d+) • /.exec(name.slice(prefix.length))
|
||||
return match ? Math.max(max, Number(match[1])) : max
|
||||
}, 0)
|
||||
return highest + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* `1.4.163 • 01 • Jul 31, 1:54PM • e698241` — the human-facing release title,
|
||||
* shown verbatim in both the GitHub releases list and the in-app build picker.
|
||||
*/
|
||||
export function formatHourlyReleaseName(version, buildNumber, commit, date) {
|
||||
if (!Number.isInteger(buildNumber) || buildNumber < 1) {
|
||||
@@ -45,20 +75,35 @@ export function formatHourlyReleaseName(version, buildNumber, commit, date) {
|
||||
].join(' • ')
|
||||
}
|
||||
|
||||
export function getHourlyBuildIdentity(now = new Date(), buildNumber = 1) {
|
||||
// Why the number is derived here rather than passed in: it counts builds of the
|
||||
// base version, and the base is only known once the published tags have been
|
||||
// resolved just above. Computing it outside meant numbering against whatever
|
||||
// version the caller guessed.
|
||||
export function getHourlyBuildIdentity(now = new Date(), { publishedVersions, releaseNames } = {}) {
|
||||
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
|
||||
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
const version = createHourlyBuildVersion(packageJson.version, now)
|
||||
return { commit, version, name: formatHourlyReleaseName(version, buildNumber, commit, now) }
|
||||
const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions ?? [])
|
||||
const version = createHourlyBuildVersion(base, now)
|
||||
const buildNumber = nextHourlyBuildNumber(base, releaseNames ?? [])
|
||||
return {
|
||||
commit,
|
||||
version,
|
||||
buildNumber,
|
||||
name: formatHourlyReleaseName(version, buildNumber, commit, now)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
|
||||
const buildNumber = Number(process.env.ORCA_HOURLY_BUILD_NUMBER ?? '1')
|
||||
const identity = getHourlyBuildIdentity(new Date(), buildNumber)
|
||||
const identity = getHourlyBuildIdentity(new Date(), {
|
||||
publishedVersions: readPublishedVersionsFromEnv(),
|
||||
// Titles are newline separated and contain spaces, so this cannot reuse the
|
||||
// whitespace split the version list gets.
|
||||
releaseNames: (process.env.ORCA_HOURLY_RELEASE_NAMES ?? '').split('\n').filter(Boolean)
|
||||
})
|
||||
// Consumed by the workflow via $GITHUB_OUTPUT.
|
||||
process.stdout.write(
|
||||
`version=${identity.version}\ncommit=${identity.commit}\nname=${identity.name}\n`
|
||||
`version=${identity.version}\ncommit=${identity.commit}\nbuild_number=${identity.buildNumber}\nname=${identity.name}\n`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createHourlyBuildVersion, formatHourlyReleaseName } from './hourly-build-version.mjs'
|
||||
import {
|
||||
createHourlyBuildVersion,
|
||||
formatHourlyReleaseName,
|
||||
nextHourlyBuildNumber
|
||||
} from './hourly-build-version.mjs'
|
||||
import { compareAppVersions } from '../../src/shared/app-version'
|
||||
|
||||
describe('createHourlyBuildVersion', () => {
|
||||
@@ -34,21 +38,28 @@ describe('formatHourlyReleaseName', () => {
|
||||
formatHourlyReleaseName('1.4.163-hourly.x', buildNumber, commit, new Date(iso))
|
||||
|
||||
it('renders version, number, Pacific timestamp, and short sha', () => {
|
||||
expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • 01 • 07-31 13:54 • e698241')
|
||||
expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • 01 • Jul 31, 1:54PM • e698241')
|
||||
})
|
||||
|
||||
// Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so
|
||||
// the offset between them is not a constant. A test pinned to one season would
|
||||
// pass all summer and start failing in November.
|
||||
it('follows the Pacific offset across DST', () => {
|
||||
expect(name('2026-01-15T02:30:00Z')).toBe('1.4.163 • 01 • 01-14 18:30 • e698241')
|
||||
expect(name('2026-07-31T07:00:00Z')).toBe('1.4.163 • 01 • 07-31 00:00 • e698241')
|
||||
expect(name('2026-01-15T02:30:00Z')).toBe('1.4.163 • 01 • Jan 14, 6:30PM • e698241')
|
||||
expect(name('2026-07-31T07:00:00Z')).toBe('1.4.163 • 01 • Jul 31, 12:00AM • e698241')
|
||||
})
|
||||
|
||||
// Why: hour12: false renders midnight as "24" on some ICU builds, which would
|
||||
// read as an hour that does not exist and sort oddly beside 00:xx.
|
||||
it('renders midnight as 00, never 24', () => {
|
||||
expect(name('2026-07-31T07:00:00Z')).toContain(' 00:00 ')
|
||||
// Why pinned: 12-hour clocks are where off-by-twelve bugs live, and midnight in
|
||||
// particular renders as 0:00 or 24:00 on a formatter set up carelessly.
|
||||
it('renders both noon and midnight as 12', () => {
|
||||
expect(name('2026-07-31T07:00:00Z')).toContain('12:00AM')
|
||||
expect(name('2026-07-31T19:00:00Z')).toContain('12:00PM')
|
||||
})
|
||||
|
||||
// Recent ICU puts U+202F before AM/PM; the title must carry no separator at all.
|
||||
it('joins the meridiem with no whitespace of any kind', () => {
|
||||
expect(name('2026-07-31T20:54:00Z')).toMatch(/\d:\d{2}(AM|PM) •/)
|
||||
expect(name('2026-07-31T20:54:00Z')).not.toMatch(/\s[AP]M/u)
|
||||
})
|
||||
|
||||
it('pads to two digits and grows past them', () => {
|
||||
@@ -70,3 +81,42 @@ describe('formatHourlyReleaseName', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nextHourlyBuildNumber', () => {
|
||||
const titles = [
|
||||
'1.4.163 • 01 • Jul 31, 1:54PM • e698241',
|
||||
'1.4.163 • 02 • Jul 31, 2:54PM • aaaaaaa',
|
||||
'1.4.163 • 37 • Aug 01, 9:00AM • bbbbbbb'
|
||||
]
|
||||
|
||||
it('continues the series for the version being built', () => {
|
||||
expect(nextHourlyBuildNumber('1.4.163', titles)).toBe(38)
|
||||
})
|
||||
|
||||
// The point of the change: 1.4.164 opens its own series at 01 rather than
|
||||
// inheriting 1.4.163's count, which said nothing about 1.4.164.
|
||||
it('restarts at 1 when the base version moves', () => {
|
||||
expect(nextHourlyBuildNumber('1.4.164', titles)).toBe(1)
|
||||
expect(
|
||||
nextHourlyBuildNumber('1.4.164', [...titles, '1.4.164 • 01 • Aug 02, 8:00AM • ccccccc'])
|
||||
).toBe(2)
|
||||
})
|
||||
|
||||
// Why max and not count: pruning trims to HOURLY_RETAIN_COUNT, so counting
|
||||
// would roll backwards and reissue a number already used.
|
||||
it('takes the highest number, not the count', () => {
|
||||
expect(nextHourlyBuildNumber('1.4.163', ['1.4.163 • 09 • Jul 31, 1:54PM • e698241'])).toBe(10)
|
||||
})
|
||||
|
||||
it('starts at 1 with no history at all', () => {
|
||||
expect(nextHourlyBuildNumber('1.4.163')).toBe(1)
|
||||
expect(nextHourlyBuildNumber('1.4.163', [])).toBe(1)
|
||||
})
|
||||
|
||||
// Legacy titles were the raw tag, and a prefix match must not treat 1.4.16 as
|
||||
// a prefix of 1.4.163's series.
|
||||
it('ignores titles that are not this version', () => {
|
||||
expect(nextHourlyBuildNumber('1.4.16', ['1.4.163 • 37 • Aug 01, 9:00AM • bbbbbbb'])).toBe(1)
|
||||
expect(nextHourlyBuildNumber('1.4.163', ['v1.4.163-hourly.202607311354', null, ''])).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
export class BoundedLiveFreezeHistory {
|
||||
#entries = []
|
||||
#limit
|
||||
#nextIndex = 0
|
||||
#totalCount = 0
|
||||
|
||||
constructor(limit) {
|
||||
if (!Number.isInteger(limit) || limit <= 0) {
|
||||
throw new Error(`History limit must be a positive integer, got ${limit}`)
|
||||
}
|
||||
this.#limit = limit
|
||||
}
|
||||
|
||||
add(entry) {
|
||||
this.#totalCount += 1
|
||||
if (this.#entries.length < this.#limit) {
|
||||
this.#entries.push(entry)
|
||||
return
|
||||
}
|
||||
this.#entries[this.#nextIndex] = entry
|
||||
this.#nextIndex = (this.#nextIndex + 1) % this.#limit
|
||||
}
|
||||
|
||||
get retainedCount() {
|
||||
return this.#entries.length
|
||||
}
|
||||
|
||||
get totalCount() {
|
||||
return this.#totalCount
|
||||
}
|
||||
|
||||
values() {
|
||||
if (this.#entries.length < this.#limit || this.#nextIndex === 0) {
|
||||
return [...this.#entries]
|
||||
}
|
||||
return [...this.#entries.slice(this.#nextIndex), ...this.#entries.slice(0, this.#nextIndex)]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BoundedLiveFreezeHistory } from './live-freeze-bounded-history.mjs'
|
||||
|
||||
describe('BoundedLiveFreezeHistory', () => {
|
||||
it('retains the newest entries in insertion order and counts the full run', () => {
|
||||
const history = new BoundedLiveFreezeHistory(3)
|
||||
|
||||
for (let value = 1; value <= 7; value += 1) {
|
||||
history.add(value)
|
||||
}
|
||||
|
||||
expect(history.values()).toEqual([5, 6, 7])
|
||||
expect(history.retainedCount).toBe(3)
|
||||
expect(history.totalCount).toBe(7)
|
||||
})
|
||||
|
||||
it('rejects invalid retention limits', () => {
|
||||
expect(() => new BoundedLiveFreezeHistory(0)).toThrow('positive integer')
|
||||
expect(() => new BoundedLiveFreezeHistory(1.5)).toThrow('positive integer')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Pure metrics helpers for the live remote bulk-open freeze harness.
|
||||
* Kept separate so unit tests can drive the same code the repro uses.
|
||||
*/
|
||||
|
||||
export const DEFAULT_SOFT_MS = 2000
|
||||
export const DEFAULT_HARD_MS = 5000
|
||||
|
||||
export function readFreezeNumberEnv(name, fallback) {
|
||||
const raw = process.env[name]
|
||||
if (raw == null || raw.trim() === '') {
|
||||
return fallback
|
||||
}
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new Error(`Invalid ${name}: expected a finite number, got ${JSON.stringify(raw)}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function extractTerminalHandle(result) {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return null
|
||||
}
|
||||
const candidates = [
|
||||
result.handle,
|
||||
result.terminalHandle,
|
||||
result.agentTerminalHandle,
|
||||
typeof result.terminal === 'string' ? result.terminal : result.terminal?.handle,
|
||||
result.startupTerminal?.handle,
|
||||
result.tab?.terminal,
|
||||
result.tab?.handle
|
||||
]
|
||||
for (const value of candidates) {
|
||||
if (typeof value === 'string' && value.startsWith('term_')) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
for (const value of Object.values(result)) {
|
||||
if (typeof value === 'string' && value.startsWith('term_')) {
|
||||
return value
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
for (const nested of Object.values(value)) {
|
||||
if (typeof nested === 'string' && nested.startsWith('term_')) {
|
||||
return nested
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function worktreeSelector(wt) {
|
||||
if (typeof wt?.id === 'string' && wt.id.length > 0) {
|
||||
return `id:${wt.id}`
|
||||
}
|
||||
if (typeof wt?.path === 'string' && wt.path.length > 0) {
|
||||
return `path:${wt.path}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Peak stall across individual switch latency and concurrent batch wall.
|
||||
* Hard freeze when peak >= hardMs (default 5000).
|
||||
*/
|
||||
export function evaluateFreezeSignals({
|
||||
maxSwitchMs = 0,
|
||||
maxBatchWallMs = 0,
|
||||
statusProbeMs = 0,
|
||||
memoryProbeMs = null,
|
||||
softMs = DEFAULT_SOFT_MS,
|
||||
hardMs = DEFAULT_HARD_MS
|
||||
}) {
|
||||
const peakLatencyMs = Math.max(maxSwitchMs, maxBatchWallMs)
|
||||
const softFreeze =
|
||||
peakLatencyMs >= softMs ||
|
||||
statusProbeMs >= softMs ||
|
||||
(memoryProbeMs != null && memoryProbeMs >= softMs)
|
||||
const hardFreeze =
|
||||
peakLatencyMs >= hardMs ||
|
||||
statusProbeMs >= hardMs ||
|
||||
(memoryProbeMs != null && memoryProbeMs >= hardMs)
|
||||
return { peakLatencyMs, softFreeze, hardFreeze }
|
||||
}
|
||||
|
||||
export function shouldCapSwitchTargets(maxSwitchTargets) {
|
||||
return Number.isFinite(maxSwitchTargets) && maxSwitchTargets > 0
|
||||
}
|
||||
|
||||
export function applySwitchTargetCap(targets, maxSwitchTargets) {
|
||||
if (!shouldCapSwitchTargets(maxSwitchTargets)) {
|
||||
return targets
|
||||
}
|
||||
return targets.slice(0, maxSwitchTargets)
|
||||
}
|
||||
|
||||
/** Scenarios that model real user recovery, not concurrent CLI pileup. */
|
||||
export const REALISTIC_SCENARIOS = [
|
||||
'idle-backlog-open',
|
||||
'idle-backlog-reconnect-open',
|
||||
'restart-proxy',
|
||||
/** Idle + flood + reconnect storm overlapped with concurrent open fan-out. */
|
||||
'lockup-storm'
|
||||
]
|
||||
|
||||
/**
|
||||
* Permanent lockup: app/host stops making progress — not a single recovered timeout.
|
||||
* Distinct from multi-second hard stall that still recovers (status answers, most opens ok).
|
||||
*/
|
||||
export function evaluatePermanentLockup({
|
||||
timedOutOps = 0,
|
||||
statusHangMs = 0,
|
||||
consecutiveSwitchFailures = 0,
|
||||
openFailed = 0,
|
||||
openTotal = 0,
|
||||
permanentTimeoutMs = 60_000,
|
||||
/** Fraction of opens that must fail to count as lockup without status hang. */
|
||||
failRateThreshold = 0.25,
|
||||
minTimedOutOps = 3
|
||||
}) {
|
||||
const failRate = openTotal > 0 ? openFailed / openTotal : 0
|
||||
const permanentLockup =
|
||||
statusHangMs >= permanentTimeoutMs ||
|
||||
timedOutOps >= minTimedOutOps ||
|
||||
consecutiveSwitchFailures >= 5 ||
|
||||
(openTotal >= 8 && failRate >= failRateThreshold)
|
||||
return {
|
||||
permanentLockup,
|
||||
timedOutOps,
|
||||
statusHangMs,
|
||||
consecutiveSwitchFailures,
|
||||
failRate,
|
||||
recoveredHardStallCandidate:
|
||||
!permanentLockup && timedOutOps < minTimedOutOps && statusHangMs < permanentTimeoutMs
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Peak across open latencies + optional reconnect-refresh wall + probes.
|
||||
* Used by the naturalistic harness (no parallel switch amp).
|
||||
*/
|
||||
export function evaluateRealisticFreezeSignals({
|
||||
maxOpenMs = 0,
|
||||
firstOpenMs = 0,
|
||||
reconnectRefreshMs = 0,
|
||||
statusProbeMs = 0,
|
||||
memoryProbeMs = null,
|
||||
softMs = DEFAULT_SOFT_MS,
|
||||
hardMs = DEFAULT_HARD_MS
|
||||
}) {
|
||||
const peakLatencyMs = Math.max(maxOpenMs, firstOpenMs, reconnectRefreshMs)
|
||||
return evaluateFreezeSignals({
|
||||
maxSwitchMs: peakLatencyMs,
|
||||
maxBatchWallMs: 0,
|
||||
statusProbeMs,
|
||||
memoryProbeMs,
|
||||
softMs,
|
||||
hardMs
|
||||
})
|
||||
}
|
||||
|
||||
export function humanPaceDelayMs(baseMs, jitterMs = 0) {
|
||||
const base = Math.max(0, baseMs)
|
||||
const jitter = Math.max(0, jitterMs)
|
||||
if (jitter === 0) {
|
||||
return base
|
||||
}
|
||||
return base + Math.floor(Math.random() * (jitter + 1))
|
||||
}
|
||||
|
||||
/** Full-app forever freeze: host RPC dead for a continuous window, not a recovered stall. */
|
||||
export const DEFAULT_FOREVER_WINDOW_MS = 30_000
|
||||
export const DEFAULT_STATUS_SLOW_MS = 15_000
|
||||
|
||||
/**
|
||||
* Analyze mid-storm status samples for a continuous unhealthy window.
|
||||
* Sample: { tMs, ms, ok, hang }
|
||||
*/
|
||||
export function evaluateFullAppFreeze({
|
||||
statusSamples = [],
|
||||
statusSummary = {},
|
||||
foreverWindowMs = DEFAULT_FOREVER_WINDOW_MS,
|
||||
statusSlowMs = DEFAULT_STATUS_SLOW_MS,
|
||||
killOnlyRecovery = false
|
||||
}) {
|
||||
const infrastructureErrors = statusSamples.filter((sample) => sample.infrastructureError)
|
||||
const infrastructureErrorCount = Math.max(
|
||||
infrastructureErrors.length,
|
||||
statusSummary.infrastructureErrorCount ?? 0
|
||||
)
|
||||
const maxStatusMs = Math.max(
|
||||
0,
|
||||
...statusSamples.map((s) => s.ms || 0),
|
||||
statusSummary.maxStatusMs ?? 0
|
||||
)
|
||||
if (killOnlyRecovery) {
|
||||
return {
|
||||
foreverUiLockupObserved: true,
|
||||
longestUnhealthyWindowMs: foreverWindowMs,
|
||||
maxStatusMs,
|
||||
unhealthySampleCount: statusSummary.sampleCount ?? statusSamples.length,
|
||||
infrastructureErrorCount,
|
||||
reason: 'kill-only recovery documented'
|
||||
}
|
||||
}
|
||||
|
||||
const unhealthy = statusSamples.map((s) => {
|
||||
const hang = !s.infrastructureError && (Boolean(s.hang) || s.ok === false)
|
||||
const slow = !s.infrastructureError && (s.ms || 0) >= statusSlowMs
|
||||
return { ...s, unhealthy: hang || slow }
|
||||
})
|
||||
|
||||
let longest = statusSummary.longestUnhealthyWindowMs ?? 0
|
||||
let runStart = null
|
||||
for (const s of unhealthy) {
|
||||
if (s.unhealthy) {
|
||||
runStart ??= s.tMs ?? 0
|
||||
const end = (s.tMs ?? 0) + (s.ms || 0)
|
||||
longest = Math.max(longest, end - runStart)
|
||||
} else {
|
||||
runStart = null
|
||||
}
|
||||
}
|
||||
|
||||
// If timestamps missing, fall back to consecutive unhealthy count * assumed interval.
|
||||
if (longest === 0 && unhealthy.some((s) => s.unhealthy)) {
|
||||
let run = 0
|
||||
for (const s of unhealthy) {
|
||||
if (s.unhealthy) {
|
||||
run += 1
|
||||
longest = Math.max(longest, run)
|
||||
} else {
|
||||
run = 0
|
||||
}
|
||||
}
|
||||
// Without wall clock, consecutive count alone is not a ms window.
|
||||
longest = 0
|
||||
}
|
||||
|
||||
const foreverUiLockupObserved = longest >= foreverWindowMs
|
||||
const unhealthySampleCount = Math.max(
|
||||
unhealthy.filter((s) => s.unhealthy).length,
|
||||
statusSummary.unhealthySampleCount ?? 0
|
||||
)
|
||||
|
||||
return {
|
||||
foreverUiLockupObserved,
|
||||
longestUnhealthyWindowMs: longest,
|
||||
maxStatusMs,
|
||||
unhealthySampleCount,
|
||||
infrastructureErrorCount,
|
||||
reason: foreverUiLockupObserved
|
||||
? `status unhealthy ≥${foreverWindowMs}ms continuous`
|
||||
: infrastructureErrorCount > 0
|
||||
? `status watchdog infrastructure errors: ${infrastructureErrorCount}`
|
||||
: maxStatusMs >= statusSlowMs
|
||||
? `status slow peak ${maxStatusMs}ms but no ≥${foreverWindowMs}ms window`
|
||||
: 'status remained healthy through storm'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applySwitchTargetCap,
|
||||
evaluateFreezeSignals,
|
||||
evaluateFullAppFreeze,
|
||||
evaluatePermanentLockup,
|
||||
evaluateRealisticFreezeSignals,
|
||||
extractTerminalHandle,
|
||||
humanPaceDelayMs,
|
||||
readFreezeNumberEnv,
|
||||
REALISTIC_SCENARIOS,
|
||||
shouldCapSwitchTargets,
|
||||
worktreeSelector
|
||||
} from './live-remote-bulk-open-freeze-metrics.mjs'
|
||||
|
||||
describe('live-remote-bulk-open-freeze-metrics', () => {
|
||||
it('extracts term_ handles from nested create payloads', () => {
|
||||
expect(extractTerminalHandle({ handle: 'term_abc' })).toBe('term_abc')
|
||||
expect(extractTerminalHandle({ terminal: { handle: 'term_nested' } })).toBe('term_nested')
|
||||
expect(extractTerminalHandle({ tab: { terminal: 'term_tab' } })).toBe('term_tab')
|
||||
expect(extractTerminalHandle({ startupTerminal: { handle: 'term_start' } })).toBe('term_start')
|
||||
expect(extractTerminalHandle({ junk: { deep: 'term_deep' } })).toBe('term_deep')
|
||||
expect(extractTerminalHandle({ handle: 'not-a-term' })).toBeNull()
|
||||
expect(extractTerminalHandle(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('builds worktree selectors from id/path', () => {
|
||||
expect(
|
||||
worktreeSelector({ id: 'repo::C:/Users/neil/orca/orca', path: 'C:/Users/neil/orca/orca' })
|
||||
).toBe('id:repo::C:/Users/neil/orca/orca')
|
||||
expect(worktreeSelector({ path: '/tmp/x' })).toBe('path:/tmp/x')
|
||||
expect(worktreeSelector({})).toBeNull()
|
||||
})
|
||||
|
||||
it('does not cap switch targets when max is 0 (regression for Math.max(2,0) bug)', () => {
|
||||
expect(shouldCapSwitchTargets(0)).toBe(false)
|
||||
expect(shouldCapSwitchTargets(-1)).toBe(false)
|
||||
expect(shouldCapSwitchTargets(2)).toBe(true)
|
||||
const many = Array.from({ length: 111 }, (_, i) => `term_${i}`)
|
||||
expect(applySwitchTargetCap(many, 0)).toHaveLength(111)
|
||||
expect(applySwitchTargetCap(many, 2)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('classifies hard freeze at >=5000ms peak (individual or batch wall)', () => {
|
||||
expect(evaluateFreezeSignals({ maxSwitchMs: 3874, maxBatchWallMs: 3874 }).hardFreeze).toBe(
|
||||
false
|
||||
)
|
||||
expect(evaluateFreezeSignals({ maxSwitchMs: 3874, maxBatchWallMs: 3874 }).softFreeze).toBe(true)
|
||||
|
||||
const hardIndividual = evaluateFreezeSignals({ maxSwitchMs: 19954, maxBatchWallMs: 1000 })
|
||||
expect(hardIndividual.hardFreeze).toBe(true)
|
||||
expect(hardIndividual.peakLatencyMs).toBe(19954)
|
||||
|
||||
const hardBatch = evaluateFreezeSignals({ maxSwitchMs: 900, maxBatchWallMs: 20201 })
|
||||
expect(hardBatch.hardFreeze).toBe(true)
|
||||
expect(hardBatch.peakLatencyMs).toBe(20201)
|
||||
})
|
||||
|
||||
it('evaluates naturalistic peaks without requiring parallel batch amp', () => {
|
||||
expect(REALISTIC_SCENARIOS).toContain('idle-backlog-open')
|
||||
expect(REALISTIC_SCENARIOS).toContain('idle-backlog-reconnect-open')
|
||||
expect(REALISTIC_SCENARIOS).toContain('lockup-storm')
|
||||
const soft = evaluateRealisticFreezeSignals({
|
||||
maxOpenMs: 3200,
|
||||
firstOpenMs: 2800,
|
||||
reconnectRefreshMs: 900
|
||||
})
|
||||
expect(soft.softFreeze).toBe(true)
|
||||
expect(soft.hardFreeze).toBe(false)
|
||||
expect(soft.peakLatencyMs).toBe(3200)
|
||||
|
||||
const hardFromReconnect = evaluateRealisticFreezeSignals({
|
||||
maxOpenMs: 800,
|
||||
firstOpenMs: 700,
|
||||
reconnectRefreshMs: 6200
|
||||
})
|
||||
expect(hardFromReconnect.hardFreeze).toBe(true)
|
||||
expect(hardFromReconnect.peakLatencyMs).toBe(6200)
|
||||
})
|
||||
|
||||
it('flags full-app freeze only for continuous unhealthy status window ≥30s', () => {
|
||||
const healthy = evaluateFullAppFreeze({
|
||||
statusSamples: [
|
||||
{ tMs: 0, ms: 150, ok: true },
|
||||
{ tMs: 2000, ms: 180, ok: true },
|
||||
{ tMs: 4000, ms: 140, ok: true }
|
||||
],
|
||||
foreverWindowMs: 30_000,
|
||||
statusSlowMs: 15_000
|
||||
})
|
||||
expect(healthy.foreverUiLockupObserved).toBe(false)
|
||||
|
||||
const forever = evaluateFullAppFreeze({
|
||||
statusSamples: [
|
||||
{ tMs: 0, ms: 16_000, ok: true },
|
||||
{ tMs: 16_000, ms: 16_000, ok: true },
|
||||
{ tMs: 32_000, ms: 16_000, ok: false, hang: true }
|
||||
],
|
||||
foreverWindowMs: 30_000,
|
||||
statusSlowMs: 15_000
|
||||
})
|
||||
expect(forever.foreverUiLockupObserved).toBe(true)
|
||||
expect(forever.longestUnhealthyWindowMs).toBeGreaterThanOrEqual(30_000)
|
||||
|
||||
expect(
|
||||
evaluateFullAppFreeze({ statusSamples: [], killOnlyRecovery: true }).foreverUiLockupObserved
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not classify watchdog infrastructure errors as an app freeze', () => {
|
||||
const result = evaluateFullAppFreeze({
|
||||
statusSamples: Array.from({ length: 25 }, (_, index) => ({
|
||||
tMs: index * 1500,
|
||||
ms: 1,
|
||||
ok: false,
|
||||
infrastructureError: true,
|
||||
error: 'spawn ENOENT'
|
||||
}))
|
||||
})
|
||||
|
||||
expect(result.foreverUiLockupObserved).toBe(false)
|
||||
expect(result.unhealthySampleCount).toBe(0)
|
||||
expect(result.infrastructureErrorCount).toBe(25)
|
||||
})
|
||||
|
||||
it('preserves full-run watchdog peaks after sample retention rotates', () => {
|
||||
const result = evaluateFullAppFreeze({
|
||||
statusSamples: [{ tMs: 60_000, ms: 100, ok: true }],
|
||||
statusSummary: {
|
||||
sampleCount: 40,
|
||||
maxStatusMs: 16_000,
|
||||
unhealthySampleCount: 3,
|
||||
infrastructureErrorCount: 2,
|
||||
longestUnhealthyWindowMs: 32_000
|
||||
},
|
||||
foreverWindowMs: 30_000,
|
||||
statusSlowMs: 15_000
|
||||
})
|
||||
|
||||
expect(result.foreverUiLockupObserved).toBe(true)
|
||||
expect(result.longestUnhealthyWindowMs).toBe(32_000)
|
||||
expect(result.maxStatusMs).toBe(16_000)
|
||||
expect(result.unhealthySampleCount).toBe(3)
|
||||
expect(result.infrastructureErrorCount).toBe(2)
|
||||
})
|
||||
|
||||
it('rejects invalid numeric environment values', () => {
|
||||
process.env.ORCA_FREEZE_TEST_NUMBER = 'not-a-number'
|
||||
expect(() => readFreezeNumberEnv('ORCA_FREEZE_TEST_NUMBER', 5)).toThrow(
|
||||
'Invalid ORCA_FREEZE_TEST_NUMBER'
|
||||
)
|
||||
delete process.env.ORCA_FREEZE_TEST_NUMBER
|
||||
expect(readFreezeNumberEnv('ORCA_FREEZE_TEST_NUMBER', 5)).toBe(5)
|
||||
})
|
||||
|
||||
it('distinguishes recovered hard stall from permanent lockup', () => {
|
||||
// Single reveal timeout with healthy status is NOT permanent app lockup.
|
||||
expect(
|
||||
evaluatePermanentLockup({
|
||||
timedOutOps: 1,
|
||||
statusHangMs: 0,
|
||||
consecutiveSwitchFailures: 1,
|
||||
openFailed: 1,
|
||||
openTotal: 64
|
||||
}).permanentLockup
|
||||
).toBe(false)
|
||||
expect(
|
||||
evaluatePermanentLockup({
|
||||
timedOutOps: 3,
|
||||
statusHangMs: 0,
|
||||
consecutiveSwitchFailures: 0,
|
||||
openFailed: 3,
|
||||
openTotal: 64
|
||||
}).permanentLockup
|
||||
).toBe(true)
|
||||
expect(
|
||||
evaluatePermanentLockup({
|
||||
timedOutOps: 0,
|
||||
statusHangMs: 60_000,
|
||||
consecutiveSwitchFailures: 0,
|
||||
permanentTimeoutMs: 60_000
|
||||
}).permanentLockup
|
||||
).toBe(true)
|
||||
expect(
|
||||
evaluatePermanentLockup({
|
||||
timedOutOps: 0,
|
||||
statusHangMs: 0,
|
||||
consecutiveSwitchFailures: 5
|
||||
}).permanentLockup
|
||||
).toBe(true)
|
||||
expect(
|
||||
evaluatePermanentLockup({
|
||||
timedOutOps: 0,
|
||||
openFailed: 20,
|
||||
openTotal: 40
|
||||
}).permanentLockup
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('human pace delay stays within base+jitter', () => {
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
const d = humanPaceDelayMs(250, 150)
|
||||
expect(d).toBeGreaterThanOrEqual(250)
|
||||
expect(d).toBeLessThanOrEqual(400)
|
||||
}
|
||||
expect(humanPaceDelayMs(100, 0)).toBe(100)
|
||||
})
|
||||
|
||||
it('reads the real hard-freeze lab report when present', async () => {
|
||||
const { readdirSync, readFileSync, existsSync } = await import('node:fs')
|
||||
const { resolve } = await import('node:path')
|
||||
const reportDir = resolve(process.cwd(), 'test-results/freeze-repro')
|
||||
if (!existsSync(reportDir)) {
|
||||
// Local clones without lab artifacts still pass pure metrics tests above.
|
||||
return
|
||||
}
|
||||
const reportName = readdirSync(reportDir).find(
|
||||
(name) => name.startsWith('live-bulk-open-freeze-') && name.endsWith('.json')
|
||||
)
|
||||
if (reportName == null) {
|
||||
return
|
||||
}
|
||||
const report = JSON.parse(readFileSync(resolve(reportDir, reportName), 'utf8'))
|
||||
const evaluated = evaluateFreezeSignals({
|
||||
maxSwitchMs: report.maxSwitchMs,
|
||||
maxBatchWallMs: report.maxBatchWallMs ?? 0,
|
||||
statusProbeMs: report.statusProbeMs ?? 0,
|
||||
memoryProbeMs: report.memoryProbeMs,
|
||||
softMs: report.softMs,
|
||||
hardMs: report.hardMs
|
||||
})
|
||||
expect(evaluated.hardFreeze).toBe(report.hardFreeze)
|
||||
expect(evaluated.peakLatencyMs).toBeGreaterThanOrEqual(5000)
|
||||
expect(typeof report.environment).toBe('string')
|
||||
expect(report.environment.length).toBeGreaterThan(0)
|
||||
expect(report.switchTargets).toBeGreaterThan(50)
|
||||
expect(report.parallel).toBeGreaterThanOrEqual(8)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,358 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Live freeze repro against a running Orca desktop + paired remote runtime.
|
||||
*
|
||||
* Models bulk-open of remote sessions under multi-worktree load.
|
||||
*
|
||||
* Usage:
|
||||
* node config/scripts/live-remote-bulk-open-freeze-repro.mjs
|
||||
* ORCA_FREEZE_ENV=paired-remote ORCA_FREEZE_CREATE=12 ORCA_FREEZE_SWITCH_PASSES=5 \
|
||||
* ORCA_FREEZE_PARALLEL=8 node config/scripts/live-remote-bulk-open-freeze-repro.mjs
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdirSync, writeFileSync, copyFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { BoundedLiveFreezeHistory } from './live-freeze-bounded-history.mjs'
|
||||
import {
|
||||
applySwitchTargetCap,
|
||||
DEFAULT_HARD_MS,
|
||||
DEFAULT_SOFT_MS,
|
||||
evaluateFreezeSignals,
|
||||
extractTerminalHandle,
|
||||
readFreezeNumberEnv,
|
||||
shouldCapSwitchTargets,
|
||||
worktreeSelector
|
||||
} from './live-remote-bulk-open-freeze-metrics.mjs'
|
||||
import { createOrcaRpc } from './live-remote-freeze-rpc.mjs'
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..')
|
||||
const reportDir = path.join(root, 'test-results', 'freeze-repro')
|
||||
const envName = process.env.ORCA_FREEZE_ENV || 'paired-remote'
|
||||
const createCount = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_CREATE', 0))
|
||||
const switchPasses = Math.max(1, readFreezeNumberEnv('ORCA_FREEZE_SWITCH_PASSES', 3))
|
||||
const parallel = Math.max(1, readFreezeNumberEnv('ORCA_FREEZE_PARALLEL', 1))
|
||||
// 0 = no cap (use all live terminals). Only positive env values limit targets.
|
||||
const maxSwitchTargets = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_MAX_SWITCH_TARGETS', 0))
|
||||
const softMs = readFreezeNumberEnv('ORCA_FREEZE_SOFT_MS', DEFAULT_SOFT_MS)
|
||||
const hardMs = readFreezeNumberEnv('ORCA_FREEZE_HARD_MS', DEFAULT_HARD_MS)
|
||||
const createWorktreeSpan = Math.max(1, readFreezeNumberEnv('ORCA_FREEZE_CREATE_WT_SPAN', 16))
|
||||
const preFloodMs = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_PRE_FLOOD_MS', 3000))
|
||||
const scratchDir = process.env.ORCA_FREEZE_SCRATCH || ''
|
||||
|
||||
const { orcaJsonSync, orcaJsonAsync } = createOrcaRpc({ envName })
|
||||
|
||||
async function mapPool(items, concurrency, worker) {
|
||||
const results = Array.from({ length: items.length })
|
||||
let next = 0
|
||||
async function run() {
|
||||
while (next < items.length) {
|
||||
const index = next
|
||||
next += 1
|
||||
results[index] = await worker(items[index], index)
|
||||
}
|
||||
}
|
||||
const runners = Array.from({ length: Math.min(concurrency, items.length) }, () => run())
|
||||
await Promise.all(runners)
|
||||
return results
|
||||
}
|
||||
|
||||
function sampleOrcaIfPossible() {
|
||||
if (process.platform !== 'darwin') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const status = orcaJsonSync(['status'], { local: true }).result
|
||||
const pid = status?.app?.pid
|
||||
if (!pid) {
|
||||
return null
|
||||
}
|
||||
const out = path.join(reportDir, `orca-sample-${Date.now()}.txt`)
|
||||
const sampled = spawnSync('sample', [String(pid), '5', '-file', out], {
|
||||
timeout: 20_000,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
return sampled.status === 0 ? out : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function floodCommand(marker) {
|
||||
// Continuous 2KB frames @ ~8ms — agent-like remote output.
|
||||
const script =
|
||||
"const m=process.argv[1];process.stdout.write('READY:'+m+'\\n');let f=0;const c='A'.repeat(2048);setInterval(()=>{f++;process.stdout.write('BG:'+m+':'+f+':'+c+'\\n')},8);process.stdin.resume()"
|
||||
return `node -e ${JSON.stringify(script)} ${JSON.stringify(marker)}`
|
||||
}
|
||||
|
||||
async function main() {
|
||||
mkdirSync(reportDir, { recursive: true })
|
||||
const notes = []
|
||||
const timings = new BoundedLiveFreezeHistory(120)
|
||||
const amplificationSteps = []
|
||||
|
||||
console.log(
|
||||
`[live-freeze] env=${envName} create=${createCount} passes=${switchPasses} parallel=${parallel}`
|
||||
)
|
||||
|
||||
const status = orcaJsonSync(['status'])
|
||||
notes.push(
|
||||
`remote version=${status.result?.runtime?.appVersion} state=${status.result?.runtime?.state}`
|
||||
)
|
||||
const local = orcaJsonSync(['status'], { local: true })
|
||||
notes.push(`local version=${local.result?.runtime?.appVersion} pid=${local.result?.app?.pid}`)
|
||||
|
||||
const worktrees = orcaJsonSync(['worktree', 'list']).result
|
||||
const wtList = worktrees?.worktrees || worktrees?.items || worktrees || []
|
||||
if (!Array.isArray(wtList) || wtList.length === 0) {
|
||||
throw new Error(`No worktrees on environment ${envName}`)
|
||||
}
|
||||
notes.push(`remote worktrees=${wtList.length}`)
|
||||
amplificationSteps.push(`baseline worktrees=${wtList.length}`)
|
||||
|
||||
const targets = wtList.slice(0, Math.min(createWorktreeSpan, wtList.length))
|
||||
const created = []
|
||||
|
||||
// Parallel flood-terminal creates across many worktrees.
|
||||
if (createCount > 0) {
|
||||
amplificationSteps.push(`create=${createCount} parallel=${Math.min(parallel, createCount)}`)
|
||||
const createJobs = Array.from({ length: createCount }, (_, i) => i)
|
||||
await mapPool(createJobs, Math.min(parallel, createCount), async (i) => {
|
||||
const wt = targets[i % targets.length]
|
||||
const selector = worktreeSelector(wt)
|
||||
if (!selector) {
|
||||
notes.push(`create ${i} skipped: no selector`)
|
||||
return
|
||||
}
|
||||
const marker = `LIVE_BULK_${Date.now()}_${i}`
|
||||
try {
|
||||
const createdTerm = await orcaJsonAsync(
|
||||
[
|
||||
'terminal',
|
||||
'create',
|
||||
'--worktree',
|
||||
selector,
|
||||
'--title',
|
||||
`freeze-repro-${i}`,
|
||||
'--command',
|
||||
floodCommand(marker)
|
||||
],
|
||||
{ timeoutMs: 180_000 }
|
||||
)
|
||||
timings.add({ op: 'terminal.create', ms: createdTerm.elapsedMs, ok: true, index: i })
|
||||
const handle = extractTerminalHandle(createdTerm.result)
|
||||
if (handle) {
|
||||
created.push({ handle, marker, worktree: selector })
|
||||
console.log(
|
||||
`[live-freeze] created ${handle} on ${selector} in ${createdTerm.elapsedMs.toFixed(0)}ms`
|
||||
)
|
||||
} else {
|
||||
notes.push(
|
||||
`create ${i} missing handle: ${JSON.stringify(createdTerm.result).slice(0, 400)}`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
timings.add({ op: 'terminal.create', ms: null, ok: false, error: String(error), index: i })
|
||||
notes.push(`create ${i} failed: ${String(error).slice(0, 300)}`)
|
||||
console.warn(`[live-freeze] create failed: ${String(error)}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (preFloodMs > 0 && created.length > 0) {
|
||||
amplificationSteps.push(`preFloodMs=${preFloodMs}`)
|
||||
await new Promise((r) => setTimeout(r, preFloodMs))
|
||||
}
|
||||
|
||||
let live = []
|
||||
try {
|
||||
const listed = orcaJsonSync(['terminal', 'list'])
|
||||
const terms = listed.result?.terminals || []
|
||||
live = terms
|
||||
.filter(
|
||||
(t) => typeof t.handle === 'string' && t.handle.startsWith('term_') && t.connected !== false
|
||||
)
|
||||
.map((t) => ({ handle: t.handle, title: t.title, worktreeId: t.worktreeId }))
|
||||
notes.push(`live terminals listed=${live.length}`)
|
||||
} catch (error) {
|
||||
notes.push(`terminal list failed: ${String(error).slice(0, 200)}`)
|
||||
}
|
||||
|
||||
let switchTargets = [...created.map((c) => c.handle), ...live.map((t) => t.handle)].filter(
|
||||
(v, i, a) => typeof v === 'string' && a.indexOf(v) === i
|
||||
)
|
||||
|
||||
if (shouldCapSwitchTargets(maxSwitchTargets) && switchTargets.length > maxSwitchTargets) {
|
||||
switchTargets = applySwitchTargetCap(switchTargets, maxSwitchTargets)
|
||||
amplificationSteps.push(`capped switchTargets=${maxSwitchTargets}`)
|
||||
}
|
||||
|
||||
if (switchTargets.length < 2) {
|
||||
throw new Error(
|
||||
`Need ≥2 terminals to bulk-switch; got ${switchTargets.length}. notes=${notes.join('; ')}`
|
||||
)
|
||||
}
|
||||
|
||||
amplificationSteps.push(
|
||||
`switchTargets=${switchTargets.length} passes=${switchPasses} parallel=${parallel}`
|
||||
)
|
||||
console.log(
|
||||
`[live-freeze] bulk-switching ${switchTargets.length} terminals × ${switchPasses} passes (parallel=${parallel})`
|
||||
)
|
||||
|
||||
let maxSwitchMs = 0
|
||||
let maxBatchWallMs = 0
|
||||
let sumSwitchMs = 0
|
||||
let switchCount = 0
|
||||
const switchStarted = performance.now()
|
||||
|
||||
for (let pass = 0; pass < switchPasses; pass += 1) {
|
||||
// Chunk targets into concurrent batches — piles load onto client/UI path.
|
||||
for (let offset = 0; offset < switchTargets.length; offset += parallel) {
|
||||
const batch = switchTargets.slice(offset, offset + parallel)
|
||||
const batchStarted = performance.now()
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (handle) => {
|
||||
try {
|
||||
const sw = await orcaJsonAsync(['terminal', 'switch', '--terminal', handle], {
|
||||
timeoutMs: 90_000
|
||||
})
|
||||
return { handle, ms: sw.elapsedMs, ok: true }
|
||||
} catch (error) {
|
||||
return { handle, error: String(error), ok: false }
|
||||
}
|
||||
})
|
||||
)
|
||||
const batchWall = performance.now() - batchStarted
|
||||
maxBatchWallMs = Math.max(maxBatchWallMs, batchWall)
|
||||
for (const item of batchResults) {
|
||||
if (item.ok) {
|
||||
maxSwitchMs = Math.max(maxSwitchMs, item.ms)
|
||||
sumSwitchMs += item.ms
|
||||
switchCount += 1
|
||||
timings.add({ op: 'terminal.switch', handle: item.handle, ms: item.ms, batchWall })
|
||||
if (item.ms >= softMs) {
|
||||
console.warn(`[live-freeze] SOFT lag on switch ${item.handle}: ${item.ms.toFixed(0)}ms`)
|
||||
}
|
||||
if (item.ms >= hardMs) {
|
||||
console.warn(`[live-freeze] HARD lag on switch ${item.handle}: ${item.ms.toFixed(0)}ms`)
|
||||
}
|
||||
} else {
|
||||
timings.add({ op: 'terminal.switch', handle: item.handle, error: item.error })
|
||||
notes.push(`switch ${item.handle} failed: ${String(item.error).slice(0, 200)}`)
|
||||
}
|
||||
}
|
||||
if (batchWall >= softMs) {
|
||||
console.warn(`[live-freeze] SOFT batch wall=${batchWall.toFixed(0)}ms size=${batch.length}`)
|
||||
}
|
||||
if (batchWall >= hardMs) {
|
||||
console.warn(`[live-freeze] HARD batch wall=${batchWall.toFixed(0)}ms size=${batch.length}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bulkWallMs = performance.now() - switchStarted
|
||||
const avgSwitchMs = switchCount ? sumSwitchMs / switchCount : 0
|
||||
|
||||
const statusProbe = orcaJsonSync(['status'], { local: true })
|
||||
let memoryProbeMs = null
|
||||
try {
|
||||
const mem = orcaJsonSync(['diagnostics', 'memory'], { local: true, timeoutMs: 120_000 })
|
||||
memoryProbeMs = mem.elapsedMs
|
||||
notes.push(`memory diagnostic ms=${mem.elapsedMs.toFixed(0)}`)
|
||||
} catch (error) {
|
||||
notes.push(`memory diagnostic failed: ${String(error).slice(0, 200)}`)
|
||||
}
|
||||
|
||||
const { peakLatencyMs, softFreeze, hardFreeze } = evaluateFreezeSignals({
|
||||
maxSwitchMs,
|
||||
maxBatchWallMs,
|
||||
statusProbeMs: statusProbe.elapsedMs,
|
||||
memoryProbeMs,
|
||||
softMs,
|
||||
hardMs
|
||||
})
|
||||
|
||||
let samplePath = null
|
||||
if (softFreeze || hardFreeze) {
|
||||
samplePath = sampleOrcaIfPossible()
|
||||
if (samplePath) {
|
||||
notes.push(`sample=${samplePath}`)
|
||||
} else {
|
||||
notes.push('sample unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
const report = {
|
||||
topology: 'live-paired-remote',
|
||||
environment: envName,
|
||||
localVersion: local.result?.runtime?.appVersion,
|
||||
remoteVersion: status.result?.runtime?.appVersion,
|
||||
remoteWorktreeCount: wtList.length,
|
||||
createdTerminals: created.length,
|
||||
switchTargets: switchTargets.length,
|
||||
switchPasses,
|
||||
parallel,
|
||||
maxSwitchMs,
|
||||
maxBatchWallMs,
|
||||
peakLatencyMs,
|
||||
avgSwitchMs,
|
||||
bulkWallMs,
|
||||
statusProbeMs: statusProbe.elapsedMs,
|
||||
memoryProbeMs,
|
||||
softFreeze,
|
||||
hardFreeze,
|
||||
softMs,
|
||||
hardMs,
|
||||
amplificationSteps,
|
||||
notes,
|
||||
timingCount: timings.totalCount,
|
||||
timings: timings.values()
|
||||
}
|
||||
|
||||
const outPath = path.join(reportDir, `live-bulk-open-freeze-${envName}.json`)
|
||||
writeFileSync(outPath, `${JSON.stringify(report, null, 2)}\n`)
|
||||
// Also write a stamped peak report so amplification runs don't overwrite history.
|
||||
const stamped = path.join(reportDir, `live-bulk-open-freeze-${envName}-peak-${Date.now()}.json`)
|
||||
writeFileSync(stamped, `${JSON.stringify(report, null, 2)}\n`)
|
||||
console.log(`[live-freeze] report ${outPath}`)
|
||||
console.log(`[live-freeze] stamped ${stamped}`)
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
|
||||
if (scratchDir) {
|
||||
try {
|
||||
mkdirSync(scratchDir, { recursive: true })
|
||||
copyFileSync(outPath, path.join(scratchDir, 'live-bulk-open-freeze-report.json'))
|
||||
writeFileSync(
|
||||
path.join(scratchDir, 'live-freeze-amplify-summary.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
peakLatencyMs,
|
||||
hardFreeze,
|
||||
softFreeze,
|
||||
amplificationSteps,
|
||||
stamped
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
)
|
||||
} catch (error) {
|
||||
notes.push(`scratch copy failed: ${String(error).slice(0, 200)}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (hardFreeze) {
|
||||
process.exitCode = 2
|
||||
console.error('[live-freeze] HARD FREEZE SIGNAL')
|
||||
} else if (softFreeze) {
|
||||
process.exitCode = 1
|
||||
console.error('[live-freeze] SOFT FREEZE SIGNAL')
|
||||
} else {
|
||||
console.log('[live-freeze] no freeze signal under thresholds')
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[live-freeze] failed', error)
|
||||
process.exit(3)
|
||||
})
|
||||
@@ -0,0 +1,241 @@
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
|
||||
export const MAX_ORCA_RPC_OUTPUT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
export function appendOrcaRpcOutput(output, chunk, bytes, limit = MAX_ORCA_RPC_OUTPUT_BYTES) {
|
||||
const nextBytes = bytes + Buffer.byteLength(chunk)
|
||||
return {
|
||||
output: nextBytes > limit ? output : output + chunk,
|
||||
bytes: nextBytes,
|
||||
exceeded: nextBytes > limit
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveOrcaCliCommand({ env = process.env, platform = process.platform } = {}) {
|
||||
if (env.ORCA_CLI_COMMAND?.trim()) {
|
||||
return env.ORCA_CLI_COMMAND.trim()
|
||||
}
|
||||
if (env.ORCA_DEV_REPO_ROOT) {
|
||||
return 'orca-dev'
|
||||
}
|
||||
return platform === 'linux' ? 'orca-ide' : 'orca'
|
||||
}
|
||||
|
||||
export function resolveOrcaCliInvocation({
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
nodeExecutable = process.execPath
|
||||
} = {}) {
|
||||
const command = resolveOrcaCliCommand({ env, platform })
|
||||
const commandName = platform === 'win32' ? path.win32.basename(command).toLowerCase() : command
|
||||
if (
|
||||
platform === 'win32' &&
|
||||
env.ORCA_DEV_REPO_ROOT &&
|
||||
(commandName === 'orca-dev' || commandName === 'orca-dev.cmd')
|
||||
) {
|
||||
const defaultUserDataPath = path.win32.join(
|
||||
env.APPDATA ?? path.win32.join(env.USERPROFILE ?? '', 'AppData', 'Roaming'),
|
||||
'orca-dev'
|
||||
)
|
||||
return {
|
||||
command: nodeExecutable,
|
||||
prefixArgs: [path.win32.join(env.ORCA_DEV_REPO_ROOT, 'out', 'cli', 'index.js')],
|
||||
env: {
|
||||
...env,
|
||||
ORCA_USER_DATA_PATH:
|
||||
env.ORCA_USER_DATA_PATH ?? env.ORCA_DEV_USER_DATA_PATH ?? defaultUserDataPath,
|
||||
ORCA_DEV_CLI_INVOCATION: '1',
|
||||
ORCA_APP_EXECUTABLE:
|
||||
env.ORCA_APP_EXECUTABLE ??
|
||||
path.win32.join(
|
||||
env.ORCA_DEV_REPO_ROOT,
|
||||
'node_modules',
|
||||
'electron',
|
||||
'dist',
|
||||
'electron.exe'
|
||||
),
|
||||
ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT: '1'
|
||||
}
|
||||
}
|
||||
}
|
||||
return { command, prefixArgs: [] }
|
||||
}
|
||||
|
||||
export function createOrcaRpc({
|
||||
envName,
|
||||
cliCommand,
|
||||
env = process.env,
|
||||
platform = process.platform
|
||||
}) {
|
||||
const cliInvocation = cliCommand
|
||||
? { command: cliCommand, prefixArgs: [] }
|
||||
: resolveOrcaCliInvocation({ env, platform })
|
||||
const commandLabel = cliCommand ?? resolveOrcaCliCommand({ env, platform })
|
||||
const commandArgs = (args, local) => [
|
||||
...cliInvocation.prefixArgs,
|
||||
...args,
|
||||
...(local ? [] : ['--environment', envName]),
|
||||
'--json'
|
||||
]
|
||||
|
||||
function orcaJsonSync(args, opts = {}) {
|
||||
const started = performance.now()
|
||||
const result = spawnSync(cliInvocation.command, commandArgs(args, opts.local), {
|
||||
encoding: 'utf8',
|
||||
env: cliInvocation.env,
|
||||
maxBuffer: MAX_ORCA_RPC_OUTPUT_BYTES,
|
||||
timeout: opts.timeoutMs ?? 120_000
|
||||
})
|
||||
const elapsedMs = performance.now() - started
|
||||
if (result.error) {
|
||||
throw new Error(`${commandLabel} ${args.join(' ')} failed to start: ${String(result.error)}`)
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`${commandLabel} ${args.join(' ')} failed (${result.status}): ${result.stderr || result.stdout}`
|
||||
)
|
||||
}
|
||||
const parsed = JSON.parse(result.stdout)
|
||||
if (parsed.ok === false) {
|
||||
throw new Error(`${commandLabel} ${args.join(' ')} ok=false: ${JSON.stringify(parsed)}`)
|
||||
}
|
||||
return { parsed, elapsedMs, result: parsed.result }
|
||||
}
|
||||
|
||||
function orcaJsonAsync(args, opts = {}) {
|
||||
const started = performance.now()
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cliInvocation.command, commandArgs(args, opts.local), {
|
||||
env: cliInvocation.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let outputBytes = 0
|
||||
let settled = false
|
||||
let timer
|
||||
const fail = (error) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
}
|
||||
const append = (stream, chunk) => {
|
||||
if (settled) {
|
||||
return stream
|
||||
}
|
||||
const appended = appendOrcaRpcOutput(stream, chunk, outputBytes)
|
||||
outputBytes = appended.bytes
|
||||
if (appended.exceeded) {
|
||||
child.kill('SIGKILL')
|
||||
fail(new Error(`${commandLabel} ${args.join(' ')} exceeded 20 MiB output limit`))
|
||||
return stream
|
||||
}
|
||||
return appended.output
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
fail(
|
||||
new Error(
|
||||
`${commandLabel} ${args.join(' ')} timed out after ${opts.timeoutMs ?? 120_000}ms`
|
||||
)
|
||||
)
|
||||
}, opts.timeoutMs ?? 120_000)
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout = append(stdout, chunk)
|
||||
})
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr = append(stderr, chunk)
|
||||
})
|
||||
child.on('error', fail)
|
||||
child.on('close', (code) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
clearTimeout(timer)
|
||||
const elapsedMs = performance.now() - started
|
||||
if (code !== 0) {
|
||||
fail(
|
||||
new Error(
|
||||
`${commandLabel} ${args.join(' ')} failed (${code}): ${stderr || stdout}`.slice(
|
||||
0,
|
||||
800
|
||||
)
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(stdout)
|
||||
if (parsed.ok === false) {
|
||||
fail(
|
||||
new Error(
|
||||
`${commandLabel} ${args.join(' ')} ok=false: ${JSON.stringify(parsed)}`.slice(
|
||||
0,
|
||||
800
|
||||
)
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
resolve({ parsed, elapsedMs, result: parsed.result })
|
||||
} catch (error) {
|
||||
fail(
|
||||
new Error(
|
||||
`${commandLabel} parse failed: ${String(error)}; stdout=${stdout.slice(0, 400)}`
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function runReconnectRefreshStorm(notes) {
|
||||
const started = performance.now()
|
||||
const jobs = [
|
||||
() => orcaJsonAsync(['status'], { timeoutMs: 90_000 }),
|
||||
() => orcaJsonAsync(['worktree', 'list'], { timeoutMs: 120_000 }),
|
||||
() => orcaJsonAsync(['terminal', 'list'], { timeoutMs: 120_000 }),
|
||||
() => orcaJsonAsync(['status'], { local: true, timeoutMs: 60_000 }),
|
||||
() => orcaJsonAsync(['worktree', 'list'], { timeoutMs: 120_000 }),
|
||||
() => orcaJsonAsync(['terminal', 'list'], { timeoutMs: 120_000 })
|
||||
]
|
||||
const results = await Promise.all(
|
||||
jobs.map(async (job, index) => {
|
||||
try {
|
||||
const result = await job()
|
||||
return { index, ok: true, ms: result.elapsedMs }
|
||||
} catch (error) {
|
||||
notes.push(`reconnect-refresh job ${index} failed: ${String(error).slice(0, 200)}`)
|
||||
return { index, ok: false, ms: null, error: String(error) }
|
||||
}
|
||||
})
|
||||
)
|
||||
const wallMs = performance.now() - started
|
||||
const maxJobMs = Math.max(0, ...results.map((result) => result.ms || 0))
|
||||
notes.push(
|
||||
`reconnect-refresh wall=${wallMs.toFixed(0)}ms maxJob=${maxJobMs.toFixed(0)}ms ok=${results.filter((result) => result.ok).length}/${results.length}`
|
||||
)
|
||||
return { wallMs, maxJobMs, results }
|
||||
}
|
||||
|
||||
async function runRestartProxy(notes) {
|
||||
const started = performance.now()
|
||||
try {
|
||||
const opened = await orcaJsonAsync(['open'], { local: true, timeoutMs: 120_000 })
|
||||
notes.push(`orca open ms=${opened.elapsedMs.toFixed(0)}`)
|
||||
} catch (error) {
|
||||
notes.push(`orca open failed: ${String(error).slice(0, 200)}`)
|
||||
}
|
||||
const storm = await runReconnectRefreshStorm(notes)
|
||||
return { wallMs: performance.now() - started, storm }
|
||||
}
|
||||
|
||||
return { orcaJsonSync, orcaJsonAsync, runReconnectRefreshStorm, runRestartProxy }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendOrcaRpcOutput,
|
||||
resolveOrcaCliCommand,
|
||||
resolveOrcaCliInvocation
|
||||
} from './live-remote-freeze-rpc.mjs'
|
||||
|
||||
describe('live remote freeze RPC', () => {
|
||||
it('resolves the Orca CLI for managed, dev, Linux, and default runtimes', () => {
|
||||
expect(resolveOrcaCliCommand({ env: { ORCA_CLI_COMMAND: 'custom-orca' } })).toBe('custom-orca')
|
||||
expect(resolveOrcaCliCommand({ env: { ORCA_DEV_REPO_ROOT: '/repo' } })).toBe('orca-dev')
|
||||
expect(resolveOrcaCliCommand({ env: {}, platform: 'linux' })).toBe('orca-ide')
|
||||
expect(resolveOrcaCliCommand({ env: {}, platform: 'win32' })).toBe('orca')
|
||||
})
|
||||
|
||||
it('bypasses the Windows dev cmd shim with the built Node CLI', () => {
|
||||
const invocation = resolveOrcaCliInvocation({
|
||||
env: {
|
||||
APPDATA: 'C:\\Users\\dev\\AppData\\Roaming',
|
||||
ORCA_CLI_COMMAND: 'C:\\repo\\out\\bin\\orca-dev.cmd',
|
||||
ORCA_DEV_REPO_ROOT: 'C:\\repo'
|
||||
},
|
||||
platform: 'win32',
|
||||
nodeExecutable: 'C:\\Program Files\\nodejs\\node.exe'
|
||||
})
|
||||
|
||||
expect(invocation).toMatchObject({
|
||||
command: 'C:\\Program Files\\nodejs\\node.exe',
|
||||
prefixArgs: ['C:\\repo\\out\\cli\\index.js'],
|
||||
env: {
|
||||
ORCA_USER_DATA_PATH: 'C:\\Users\\dev\\AppData\\Roaming\\orca-dev',
|
||||
ORCA_DEV_CLI_INVOCATION: '1',
|
||||
ORCA_APP_EXECUTABLE: 'C:\\repo\\node_modules\\electron\\dist\\electron.exe',
|
||||
ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT: '1'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('caps combined asynchronous output before retaining the overflow chunk', () => {
|
||||
const first = appendOrcaRpcOutput('', '1234', 0, 5)
|
||||
expect(first).toEqual({ output: '1234', bytes: 4, exceeded: false })
|
||||
|
||||
const overflow = appendOrcaRpcOutput(first.output, '67', first.bytes, 5)
|
||||
expect(overflow).toEqual({ output: '1234', bytes: 6, exceeded: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,645 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Naturalistic freeze repro — idle/reconnect recovery stories on large remotes.
|
||||
*
|
||||
* Unlike the bulk parallel-switch amplifier, this models:
|
||||
* 1) agents streaming on remote while user is idle (backlog builds)
|
||||
* 2) user returns and opens sessions one-by-one (or after reconnect refresh)
|
||||
*
|
||||
* Scenarios:
|
||||
* idle-backlog-open — idle with flood, then human-paced sequential open
|
||||
* idle-backlog-reconnect-open — same + wake-like metadata refresh storm, then open
|
||||
* restart-proxy — idle, then orca open + status/list storm + open
|
||||
* (does NOT kill the desktop; proxies restore work)
|
||||
*
|
||||
* Usage:
|
||||
* ORCA_FREEZE_ENV=paired-remote ORCA_FREEZE_SCENARIO=idle-backlog-open \
|
||||
* node config/scripts/live-remote-realistic-freeze-repro.mjs
|
||||
*
|
||||
* pnpm run repro:live-remote-realistic-freeze
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { createOrcaRpc } from './live-remote-freeze-rpc.mjs'
|
||||
import { startStatusWatchdog } from './live-remote-status-watchdog.mjs'
|
||||
import { BoundedLiveFreezeHistory } from './live-freeze-bounded-history.mjs'
|
||||
import {
|
||||
DEFAULT_FOREVER_WINDOW_MS,
|
||||
DEFAULT_HARD_MS,
|
||||
DEFAULT_SOFT_MS,
|
||||
DEFAULT_STATUS_SLOW_MS,
|
||||
evaluateFullAppFreeze,
|
||||
evaluatePermanentLockup,
|
||||
evaluateRealisticFreezeSignals,
|
||||
extractTerminalHandle,
|
||||
humanPaceDelayMs,
|
||||
readFreezeNumberEnv,
|
||||
REALISTIC_SCENARIOS,
|
||||
worktreeSelector
|
||||
} from './live-remote-bulk-open-freeze-metrics.mjs'
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..')
|
||||
const reportDir = path.join(root, 'test-results', 'freeze-repro')
|
||||
const envName = process.env.ORCA_FREEZE_ENV || 'paired-remote'
|
||||
const scenario = process.env.ORCA_FREEZE_SCENARIO || 'idle-backlog-open'
|
||||
const createCount = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_CREATE', 0))
|
||||
const openCount = Math.max(2, readFreezeNumberEnv('ORCA_FREEZE_OPEN_COUNT', 20))
|
||||
const idleMs = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_IDLE_MS', 45_000))
|
||||
const paceMs = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_PACE_MS', 250))
|
||||
const paceJitterMs = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_PACE_JITTER_MS', 150))
|
||||
const createWorktreeSpan = Math.max(1, readFreezeNumberEnv('ORCA_FREEZE_CREATE_WT_SPAN', 12))
|
||||
const softMs = readFreezeNumberEnv('ORCA_FREEZE_SOFT_MS', DEFAULT_SOFT_MS)
|
||||
const hardMs = readFreezeNumberEnv('ORCA_FREEZE_HARD_MS', DEFAULT_HARD_MS)
|
||||
/** Concurrent opens during lockup-storm (wake refresh overlaps fan-out). */
|
||||
const stormParallel = Math.max(1, readFreezeNumberEnv('ORCA_FREEZE_STORM_PARALLEL', 16))
|
||||
/** Kill a switch if it exceeds this — counts toward permanent lockup. */
|
||||
const opTimeoutMs = Math.max(10_000, readFreezeNumberEnv('ORCA_FREEZE_OP_TIMEOUT_MS', 60_000))
|
||||
const permanentTimeoutMs = Math.max(15_000, readFreezeNumberEnv('ORCA_FREEZE_PERMANENT_MS', 60_000))
|
||||
const foreverWindowMs = Math.max(
|
||||
10_000,
|
||||
readFreezeNumberEnv('ORCA_FREEZE_FOREVER_WINDOW_MS', DEFAULT_FOREVER_WINDOW_MS)
|
||||
)
|
||||
const statusSlowMs = Math.max(
|
||||
5_000,
|
||||
readFreezeNumberEnv('ORCA_FREEZE_STATUS_SLOW_MS', DEFAULT_STATUS_SLOW_MS)
|
||||
)
|
||||
const watchdogIntervalMs = Math.max(
|
||||
500,
|
||||
readFreezeNumberEnv('ORCA_FREEZE_WATCHDOG_INTERVAL_MS', 1500)
|
||||
)
|
||||
const scratchDir = process.env.ORCA_FREEZE_SCRATCH || ''
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
const rpc = createOrcaRpc({ envName })
|
||||
const { orcaJsonSync, orcaJsonAsync, runReconnectRefreshStorm, runRestartProxy } = rpc
|
||||
|
||||
async function mapPool(items, concurrency, worker) {
|
||||
const results = Array.from({ length: items.length })
|
||||
let next = 0
|
||||
async function run() {
|
||||
while (next < items.length) {
|
||||
const index = next
|
||||
next += 1
|
||||
results[index] = await worker(items[index], index)
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(concurrency, Math.max(items.length, 1)) }, () => run())
|
||||
)
|
||||
return results
|
||||
}
|
||||
|
||||
function floodCommand(marker) {
|
||||
const script =
|
||||
"const m=process.argv[1];process.stdout.write('READY:'+m+'\n');let f=0;const c='A'.repeat(2048);setInterval(()=>{f++;process.stdout.write('BG:'+m+':'+f+':'+c+'\n')},8);process.stdin.resume()"
|
||||
return `node -e ${JSON.stringify(script)} ${JSON.stringify(marker)}`
|
||||
}
|
||||
|
||||
function sampleOrcaIfPossible() {
|
||||
if (process.platform !== 'darwin') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const status = orcaJsonSync(['status'], { local: true }).result
|
||||
const pid = status?.app?.pid
|
||||
if (!pid) {
|
||||
return null
|
||||
}
|
||||
const out = path.join(reportDir, `orca-sample-realistic-${Date.now()}.txt`)
|
||||
const sampled = spawnSync('sample', [String(pid), '5', '-file', out], {
|
||||
timeout: 20_000,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
return sampled.status === 0 ? out : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function listLiveTerminalHandles() {
|
||||
const listed = orcaJsonSync(['terminal', 'list'])
|
||||
const terms = listed.result?.terminals || []
|
||||
return terms
|
||||
.filter((t) => typeof t.handle === 'string' && t.handle.startsWith('term_'))
|
||||
.map((t) => ({
|
||||
handle: t.handle,
|
||||
title: t.title,
|
||||
worktreeId: t.worktreeId,
|
||||
connected: t.connected
|
||||
}))
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!REALISTIC_SCENARIOS.includes(scenario)) {
|
||||
throw new Error(
|
||||
`Unknown ORCA_FREEZE_SCENARIO=${scenario}. Expected one of: ${REALISTIC_SCENARIOS.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
mkdirSync(reportDir, { recursive: true })
|
||||
const notes = []
|
||||
const phases = []
|
||||
const openTimings = new BoundedLiveFreezeHistory(100)
|
||||
|
||||
console.log(
|
||||
`[realistic-freeze] scenario=${scenario} env=${envName} create=${createCount} idleMs=${idleMs} openCount=${openCount} paceMs=${paceMs}`
|
||||
)
|
||||
|
||||
const local = orcaJsonSync(['status'], { local: true })
|
||||
const remote = orcaJsonSync(['status'])
|
||||
notes.push(
|
||||
`local version=${local.result?.runtime?.appVersion} pid=${local.result?.app?.pid}`,
|
||||
`remote version=${remote.result?.runtime?.appVersion} state=${remote.result?.runtime?.state}`
|
||||
)
|
||||
|
||||
const worktrees = orcaJsonSync(['worktree', 'list']).result
|
||||
const wtList = worktrees?.worktrees || worktrees?.items || worktrees || []
|
||||
if (!Array.isArray(wtList) || wtList.length === 0) {
|
||||
throw new Error(`No worktrees on environment ${envName}`)
|
||||
}
|
||||
notes.push(`remote worktrees=${wtList.length}`)
|
||||
phases.push({ phase: 'baseline', worktrees: wtList.length })
|
||||
|
||||
// --- Phase: seed flood terminals (agent-like backlog sources) ---
|
||||
const created = []
|
||||
if (createCount > 0) {
|
||||
const targets = wtList.slice(0, Math.min(createWorktreeSpan, wtList.length))
|
||||
await mapPool(
|
||||
Array.from({ length: createCount }, (_, i) => i),
|
||||
Math.min(4, createCount),
|
||||
async (i) => {
|
||||
const wt = targets[i % targets.length]
|
||||
const selector = worktreeSelector(wt)
|
||||
if (!selector) {
|
||||
return
|
||||
}
|
||||
const marker = `REALISTIC_${Date.now()}_${i}`
|
||||
try {
|
||||
const createdTerm = await orcaJsonAsync(
|
||||
[
|
||||
'terminal',
|
||||
'create',
|
||||
'--worktree',
|
||||
selector,
|
||||
'--title',
|
||||
`realistic-freeze-${i}`,
|
||||
'--command',
|
||||
floodCommand(marker)
|
||||
],
|
||||
{ timeoutMs: 180_000 }
|
||||
)
|
||||
const handle = extractTerminalHandle(createdTerm.result)
|
||||
if (handle) {
|
||||
created.push({ handle, marker, worktree: selector })
|
||||
console.log(
|
||||
`[realistic-freeze] flood terminal ${handle} (${createdTerm.elapsedMs.toFixed(0)}ms)`
|
||||
)
|
||||
} else {
|
||||
notes.push(
|
||||
`create ${i} missing handle: ${JSON.stringify(createdTerm.result).slice(0, 300)}`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
notes.push(`create ${i} failed: ${String(error).slice(0, 250)}`)
|
||||
console.warn(`[realistic-freeze] create failed: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
)
|
||||
phases.push({ phase: 'seed-flood', created: created.length })
|
||||
}
|
||||
|
||||
// Prefer created floods for open pass; fill with existing live terminals.
|
||||
let live = []
|
||||
try {
|
||||
live = listLiveTerminalHandles()
|
||||
notes.push(`live terminals listed=${live.length}`)
|
||||
} catch (error) {
|
||||
notes.push(`terminal list failed: ${String(error).slice(0, 200)}`)
|
||||
}
|
||||
|
||||
const openTargets = [...created.map((c) => c.handle), ...live.map((t) => t.handle)].filter(
|
||||
(v, i, a) => typeof v === 'string' && a.indexOf(v) === i
|
||||
)
|
||||
|
||||
if (openTargets.length < 2) {
|
||||
throw new Error(`Need ≥2 terminals; got ${openTargets.length}. ${notes.join('; ')}`)
|
||||
}
|
||||
|
||||
const openList = openTargets.slice(0, Math.min(openCount, openTargets.length))
|
||||
|
||||
// --- Phase: park — leave one session focused, rest accumulate flood while "away" ---
|
||||
try {
|
||||
const parkHandle = openList[0]
|
||||
const parked = await orcaJsonAsync(['terminal', 'switch', '--terminal', parkHandle], {
|
||||
timeoutMs: 60_000
|
||||
})
|
||||
notes.push(`park switch ms=${parked.elapsedMs.toFixed(0)} handle=${parkHandle}`)
|
||||
} catch (error) {
|
||||
notes.push(`park switch failed: ${String(error).slice(0, 200)}`)
|
||||
}
|
||||
|
||||
console.log(`[realistic-freeze] idle ${idleMs}ms while remotes stream (user away / asleep)`)
|
||||
const idleStarted = performance.now()
|
||||
await sleep(idleMs)
|
||||
phases.push({ phase: 'idle', idleMs, actualMs: performance.now() - idleStarted })
|
||||
|
||||
// --- Phase: recovery trigger ---
|
||||
let reconnectRefreshMs = 0
|
||||
let timedOutOps = 0
|
||||
let consecutiveSwitchFailures = 0
|
||||
let maxConsecutiveSwitchFailures = 0
|
||||
|
||||
if (scenario === 'idle-backlog-reconnect-open' || scenario === 'lockup-storm') {
|
||||
console.log(
|
||||
'[realistic-freeze] wake/reconnect proxy: parallel status/worktree/terminal refresh'
|
||||
)
|
||||
const storm = await runReconnectRefreshStorm(notes)
|
||||
reconnectRefreshMs = Math.max(storm.wallMs, storm.maxJobMs)
|
||||
phases.push({
|
||||
phase: 'reconnect-refresh',
|
||||
wallMs: storm.wallMs,
|
||||
maxJobMs: storm.maxJobMs
|
||||
})
|
||||
} else if (scenario === 'restart-proxy') {
|
||||
console.log('[realistic-freeze] restart proxy: orca open + refresh storm (no process kill)')
|
||||
const restart = await runRestartProxy(notes)
|
||||
reconnectRefreshMs = Math.max(restart.wallMs, restart.storm.wallMs, restart.storm.maxJobMs)
|
||||
phases.push({
|
||||
phase: 'restart-proxy',
|
||||
wallMs: restart.wallMs,
|
||||
reconnectWallMs: restart.storm.wallMs
|
||||
})
|
||||
}
|
||||
|
||||
// --- Phase: open sessions ---
|
||||
// lockup-storm: overlap a second reconnect storm with concurrent switch fan-out
|
||||
// (models wake + bulk session restore, not human serial clicks).
|
||||
let maxOpenMs = 0
|
||||
let firstOpenMs = 0
|
||||
let sumOpenMs = 0
|
||||
let openOk = 0
|
||||
let maxBatchWallMs = 0
|
||||
const openStarted = performance.now()
|
||||
|
||||
let statusWatch = null
|
||||
if (scenario === 'lockup-storm') {
|
||||
console.log(
|
||||
`[realistic-freeze] LOCKUP STORM: concurrent open parallel=${stormParallel} + overlapping reconnect refresh (timeout=${opTimeoutMs}ms); mid-storm status watchdog every ${watchdogIntervalMs}ms`
|
||||
)
|
||||
statusWatch = startStatusWatchdog({
|
||||
intervalMs: watchdogIntervalMs,
|
||||
timeoutMs: Math.min(permanentTimeoutMs, foreverWindowMs),
|
||||
statusSlowMs
|
||||
})
|
||||
// Fire reconnect storm again concurrently with first open wave.
|
||||
const overlapStormPromise = runReconnectRefreshStorm(notes)
|
||||
for (let offset = 0; offset < openList.length; offset += stormParallel) {
|
||||
const batch = openList.slice(offset, offset + stormParallel)
|
||||
const batchStarted = performance.now()
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (handle, batchIndex) => {
|
||||
const index = offset + batchIndex
|
||||
try {
|
||||
const sw = await orcaJsonAsync(['terminal', 'switch', '--terminal', handle], {
|
||||
timeoutMs: opTimeoutMs
|
||||
})
|
||||
return { handle, index, ms: sw.elapsedMs, ok: true, timedOut: false }
|
||||
} catch (error) {
|
||||
const msg = String(error)
|
||||
const timedOut = /timed out/i.test(msg)
|
||||
return { handle, index, error: msg, ok: false, timedOut }
|
||||
}
|
||||
})
|
||||
)
|
||||
const batchWall = performance.now() - batchStarted
|
||||
maxBatchWallMs = Math.max(maxBatchWallMs, batchWall)
|
||||
for (const item of batchResults) {
|
||||
if (item.ok) {
|
||||
openOk += 1
|
||||
sumOpenMs += item.ms
|
||||
maxOpenMs = Math.max(maxOpenMs, item.ms)
|
||||
if (item.index === 0 || firstOpenMs === 0) {
|
||||
firstOpenMs = item.ms
|
||||
}
|
||||
consecutiveSwitchFailures = 0
|
||||
openTimings.add({
|
||||
handle: item.handle,
|
||||
ms: item.ms,
|
||||
index: item.index,
|
||||
batchWall
|
||||
})
|
||||
if (item.ms >= hardMs) {
|
||||
console.warn(
|
||||
`[realistic-freeze] HARD open #${item.index} ${item.handle}: ${item.ms.toFixed(0)}ms`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (item.timedOut) {
|
||||
timedOutOps += 1
|
||||
}
|
||||
consecutiveSwitchFailures += 1
|
||||
maxConsecutiveSwitchFailures = Math.max(
|
||||
maxConsecutiveSwitchFailures,
|
||||
consecutiveSwitchFailures
|
||||
)
|
||||
openTimings.add({
|
||||
handle: item.handle,
|
||||
error: item.error,
|
||||
index: item.index,
|
||||
timedOut: item.timedOut
|
||||
})
|
||||
notes.push(
|
||||
`open ${item.handle} failed${item.timedOut ? ' (TIMEOUT)' : ''}: ${String(item.error).slice(0, 160)}`
|
||||
)
|
||||
console.warn(
|
||||
`[realistic-freeze] open FAIL #${item.index}${item.timedOut ? ' TIMEOUT' : ''}: ${item.handle}`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (batchWall >= hardMs) {
|
||||
console.warn(
|
||||
`[realistic-freeze] HARD batch wall=${batchWall.toFixed(0)}ms size=${batch.length}`
|
||||
)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const overlap = await overlapStormPromise
|
||||
reconnectRefreshMs = Math.max(reconnectRefreshMs, overlap.wallMs, overlap.maxJobMs)
|
||||
phases.push({
|
||||
phase: 'overlap-reconnect-refresh',
|
||||
wallMs: overlap.wallMs,
|
||||
maxJobMs: overlap.maxJobMs
|
||||
})
|
||||
} catch (error) {
|
||||
notes.push(`overlap reconnect failed: ${String(error).slice(0, 200)}`)
|
||||
}
|
||||
phases.push({
|
||||
phase: 'lockup-storm-open',
|
||||
count: openList.length,
|
||||
ok: openOk,
|
||||
maxOpenMs,
|
||||
firstOpenMs,
|
||||
maxBatchWallMs,
|
||||
timedOutOps,
|
||||
parallel: stormParallel
|
||||
})
|
||||
} else {
|
||||
console.log(
|
||||
`[realistic-freeze] human-paced open of ${openList.length} sessions (pace≈${paceMs}ms + jitter)`
|
||||
)
|
||||
for (let i = 0; i < openList.length; i += 1) {
|
||||
const handle = openList[i]
|
||||
try {
|
||||
const sw = await orcaJsonAsync(['terminal', 'switch', '--terminal', handle], {
|
||||
timeoutMs: opTimeoutMs
|
||||
})
|
||||
openOk += 1
|
||||
sumOpenMs += sw.elapsedMs
|
||||
maxOpenMs = Math.max(maxOpenMs, sw.elapsedMs)
|
||||
if (i === 0) {
|
||||
firstOpenMs = sw.elapsedMs
|
||||
}
|
||||
consecutiveSwitchFailures = 0
|
||||
openTimings.add({ handle, ms: sw.elapsedMs, index: i })
|
||||
if (sw.elapsedMs >= softMs) {
|
||||
console.warn(`[realistic-freeze] SOFT open #${i} ${handle}: ${sw.elapsedMs.toFixed(0)}ms`)
|
||||
}
|
||||
if (sw.elapsedMs >= hardMs) {
|
||||
console.warn(`[realistic-freeze] HARD open #${i} ${handle}: ${sw.elapsedMs.toFixed(0)}ms`)
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = String(error)
|
||||
const timedOut = /timed out/i.test(msg)
|
||||
if (timedOut) {
|
||||
timedOutOps += 1
|
||||
}
|
||||
consecutiveSwitchFailures += 1
|
||||
maxConsecutiveSwitchFailures = Math.max(
|
||||
maxConsecutiveSwitchFailures,
|
||||
consecutiveSwitchFailures
|
||||
)
|
||||
openTimings.add({ handle, error: msg, index: i, timedOut })
|
||||
notes.push(`open ${handle} failed${timedOut ? ' (TIMEOUT)' : ''}: ${msg.slice(0, 200)}`)
|
||||
}
|
||||
if (i < openList.length - 1) {
|
||||
await sleep(humanPaceDelayMs(paceMs, paceJitterMs))
|
||||
}
|
||||
}
|
||||
phases.push({
|
||||
phase: 'human-paced-open',
|
||||
count: openList.length,
|
||||
ok: openOk,
|
||||
maxOpenMs,
|
||||
firstOpenMs,
|
||||
openWallMs: performance.now() - openStarted
|
||||
})
|
||||
}
|
||||
|
||||
const openWallMs = performance.now() - openStarted
|
||||
|
||||
let midStormWatch = {
|
||||
samples: [],
|
||||
durationMs: 0,
|
||||
sampleCount: 0,
|
||||
maxStatusMs: 0,
|
||||
unhealthySampleCount: 0,
|
||||
infrastructureErrorCount: 0,
|
||||
longestUnhealthyWindowMs: 0
|
||||
}
|
||||
if (statusWatch) {
|
||||
midStormWatch = await statusWatch.stop()
|
||||
notes.push(
|
||||
`mid-storm status samples=${midStormWatch.sampleCount} durationMs=${midStormWatch.durationMs.toFixed(0)}`
|
||||
)
|
||||
phases.push({
|
||||
phase: 'mid-storm-status-watchdog',
|
||||
samples: midStormWatch.sampleCount,
|
||||
durationMs: midStormWatch.durationMs,
|
||||
maxStatusMs: midStormWatch.maxStatusMs
|
||||
})
|
||||
}
|
||||
|
||||
// Post-storm health: does local status still answer?
|
||||
let statusProbeMs = null
|
||||
let statusHangMs = 0
|
||||
const statusStarted = performance.now()
|
||||
try {
|
||||
const statusProbe = await orcaJsonAsync(['status'], {
|
||||
local: true,
|
||||
timeoutMs: permanentTimeoutMs
|
||||
})
|
||||
statusProbeMs = statusProbe.elapsedMs
|
||||
} catch (error) {
|
||||
statusHangMs = performance.now() - statusStarted
|
||||
notes.push(
|
||||
`status probe FAILED after ${statusHangMs.toFixed(0)}ms: ${String(error).slice(0, 200)}`
|
||||
)
|
||||
console.error(`[realistic-freeze] status probe failed — possible permanent lockup`)
|
||||
}
|
||||
|
||||
let memoryProbeMs = null
|
||||
try {
|
||||
const mem = await orcaJsonAsync(['diagnostics', 'memory'], {
|
||||
local: true,
|
||||
timeoutMs: permanentTimeoutMs
|
||||
})
|
||||
memoryProbeMs = mem.elapsedMs
|
||||
notes.push(`memory diagnostic ms=${mem.elapsedMs.toFixed(0)}`)
|
||||
} catch (error) {
|
||||
notes.push(`memory diagnostic failed: ${String(error).slice(0, 200)}`)
|
||||
}
|
||||
|
||||
const peakForSignals = Math.max(maxOpenMs, firstOpenMs, maxBatchWallMs)
|
||||
const signals = evaluateRealisticFreezeSignals({
|
||||
maxOpenMs: peakForSignals,
|
||||
firstOpenMs,
|
||||
reconnectRefreshMs,
|
||||
statusProbeMs: statusProbeMs ?? 0,
|
||||
memoryProbeMs,
|
||||
softMs,
|
||||
hardMs
|
||||
})
|
||||
|
||||
const lockup = evaluatePermanentLockup({
|
||||
timedOutOps,
|
||||
statusHangMs,
|
||||
consecutiveSwitchFailures: maxConsecutiveSwitchFailures,
|
||||
openFailed: openList.length - openOk,
|
||||
openTotal: openList.length,
|
||||
permanentTimeoutMs
|
||||
})
|
||||
|
||||
const fullApp = evaluateFullAppFreeze({
|
||||
statusSamples: midStormWatch.samples,
|
||||
statusSummary: midStormWatch,
|
||||
foreverWindowMs,
|
||||
statusSlowMs
|
||||
})
|
||||
const watchdogInfrastructureErrorCount = midStormWatch.infrastructureErrorCount
|
||||
if (statusHangMs >= foreverWindowMs) {
|
||||
fullApp.foreverUiLockupObserved = true
|
||||
fullApp.longestUnhealthyWindowMs = Math.max(fullApp.longestUnhealthyWindowMs, statusHangMs)
|
||||
fullApp.reason = `post-storm status hang ${statusHangMs.toFixed(0)}ms`
|
||||
}
|
||||
|
||||
const recoveredHardStall = signals.hardFreeze && !fullApp.foreverUiLockupObserved && openOk > 0
|
||||
|
||||
let samplePath = null
|
||||
if (signals.softFreeze || signals.hardFreeze || fullApp.foreverUiLockupObserved) {
|
||||
samplePath = sampleOrcaIfPossible()
|
||||
if (samplePath) {
|
||||
notes.push(`sample=${samplePath}`)
|
||||
} else {
|
||||
notes.push('sample unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
const storyByScenario = {
|
||||
'idle-backlog-open': 'User away while remotes stream; returns and opens sessions one-by-one.',
|
||||
'idle-backlog-reconnect-open':
|
||||
'User away; wake-like reconnect metadata storm; then opens sessions.',
|
||||
'restart-proxy': 'User away; restart-proxy discovery; then opens sessions.',
|
||||
'lockup-storm':
|
||||
'Idle flood + reconnect refresh + concurrent open + mid-storm status watchdog (full-app freeze bar).'
|
||||
}
|
||||
|
||||
const report = {
|
||||
topology: 'live-paired-remote-realistic',
|
||||
scenario,
|
||||
story: storyByScenario[scenario] || scenario,
|
||||
environment: envName,
|
||||
localVersion: local.result?.runtime?.appVersion,
|
||||
remoteVersion: remote.result?.runtime?.appVersion,
|
||||
remoteWorktreeCount: wtList.length,
|
||||
createdFloodTerminals: created.length,
|
||||
openTargets: openList.length,
|
||||
idleMs,
|
||||
paceMs,
|
||||
paceJitterMs,
|
||||
stormParallel: scenario === 'lockup-storm' ? stormParallel : 1,
|
||||
firstOpenMs,
|
||||
maxOpenMs,
|
||||
maxBatchWallMs,
|
||||
avgOpenMs: openOk ? sumOpenMs / openOk : 0,
|
||||
openWallMs,
|
||||
openOk,
|
||||
openFailed: openList.length - openOk,
|
||||
reconnectRefreshMs,
|
||||
peakLatencyMs: Math.max(signals.peakLatencyMs, maxBatchWallMs),
|
||||
statusProbeMs,
|
||||
statusHangMs,
|
||||
memoryProbeMs,
|
||||
softFreeze: signals.softFreeze,
|
||||
hardFreeze: signals.hardFreeze,
|
||||
recoveredHardStall,
|
||||
permanentLockup: lockup.permanentLockup,
|
||||
foreverUiLockupObserved: fullApp.foreverUiLockupObserved,
|
||||
foreverFreeze: fullApp,
|
||||
midStormStatusSamples: midStormWatch.samples,
|
||||
midStormStatusSampleCount: midStormWatch.sampleCount,
|
||||
watchdogInfrastructureErrorCount,
|
||||
timedOutOps,
|
||||
maxConsecutiveSwitchFailures,
|
||||
softMs,
|
||||
hardMs,
|
||||
foreverWindowMs,
|
||||
statusSlowMs,
|
||||
permanentTimeoutMs,
|
||||
opTimeoutMs,
|
||||
phases,
|
||||
notes,
|
||||
openTimingCount: openTimings.totalCount,
|
||||
openTimings: openTimings.values()
|
||||
}
|
||||
|
||||
const outPath = path.join(reportDir, `live-realistic-freeze-${envName}-${scenario}.json`)
|
||||
const stamped = path.join(
|
||||
reportDir,
|
||||
`live-realistic-freeze-${envName}-${scenario}-peak-${Date.now()}.json`
|
||||
)
|
||||
writeFileSync(outPath, `${JSON.stringify(report, null, 2)}\n`)
|
||||
writeFileSync(stamped, `${JSON.stringify(report, null, 2)}\n`)
|
||||
console.log(`[realistic-freeze] report ${outPath}`)
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
|
||||
if (scratchDir) {
|
||||
try {
|
||||
mkdirSync(scratchDir, { recursive: true })
|
||||
copyFileSync(outPath, path.join(scratchDir, 'live-realistic-freeze-report.json'))
|
||||
} catch (error) {
|
||||
console.warn(`[realistic-freeze] scratch copy failed: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (watchdogInfrastructureErrorCount > 0) {
|
||||
process.exitCode = 3
|
||||
console.error('[realistic-freeze] WATCHDOG INFRASTRUCTURE FAILURE')
|
||||
} else if (fullApp.foreverUiLockupObserved) {
|
||||
process.exitCode = 5
|
||||
console.error('[realistic-freeze] FULL-APP FOREVER FREEZE (status unhealthy ≥ forever window)')
|
||||
} else if (lockup.permanentLockup) {
|
||||
process.exitCode = 4
|
||||
console.error(
|
||||
'[realistic-freeze] PERMANENT LOCKUP HEURISTIC (timeouts/fail-rate) — check foreverUiLockupObserved'
|
||||
)
|
||||
} else if (signals.hardFreeze) {
|
||||
process.exitCode = 2
|
||||
console.error(
|
||||
'[realistic-freeze] HARD FREEZE SIGNAL (recovered multi-second stall — not forever lockup)'
|
||||
)
|
||||
} else if (signals.softFreeze) {
|
||||
process.exitCode = 1
|
||||
console.error('[realistic-freeze] SOFT FREEZE SIGNAL')
|
||||
} else {
|
||||
console.log('[realistic-freeze] no freeze signal under thresholds')
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[realistic-freeze] failed', error)
|
||||
process.exit(3)
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Mid-storm host health samples for forever-freeze detection.
|
||||
* Polls `orca status --json` on an interval while a load storm runs.
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
import { BoundedLiveFreezeHistory } from './live-freeze-bounded-history.mjs'
|
||||
import { resolveOrcaCliInvocation } from './live-remote-freeze-rpc.mjs'
|
||||
|
||||
/**
|
||||
* @param {{ intervalMs?: number, timeoutMs?: number, cliCommand?: string, sampleHistoryLimit?: number, statusSlowMs?: number }} opts
|
||||
*/
|
||||
export function startStatusWatchdog(opts = {}) {
|
||||
const intervalMs = opts.intervalMs ?? 2000
|
||||
const timeoutMs = opts.timeoutMs ?? 30_000
|
||||
const cliInvocation = opts.cliCommand
|
||||
? { command: opts.cliCommand, prefixArgs: [] }
|
||||
: resolveOrcaCliInvocation()
|
||||
const samples = new BoundedLiveFreezeHistory(opts.sampleHistoryLimit ?? 240)
|
||||
const statusSlowMs = opts.statusSlowMs ?? 15_000
|
||||
let stopped = false
|
||||
let inFlight = false
|
||||
let infrastructureErrorCount = 0
|
||||
let longestUnhealthyWindowMs = 0
|
||||
let maxStatusMs = 0
|
||||
let runStartMs = null
|
||||
let unhealthySampleCount = 0
|
||||
const startedAt = performance.now()
|
||||
|
||||
const record = (sample) => {
|
||||
samples.add(sample)
|
||||
maxStatusMs = Math.max(maxStatusMs, sample.ms || 0)
|
||||
if (sample.infrastructureError) {
|
||||
infrastructureErrorCount += 1
|
||||
}
|
||||
const unhealthy =
|
||||
!sample.infrastructureError &&
|
||||
(Boolean(sample.hang) || sample.ok === false || (sample.ms || 0) >= statusSlowMs)
|
||||
if (!unhealthy) {
|
||||
runStartMs = null
|
||||
return
|
||||
}
|
||||
unhealthySampleCount += 1
|
||||
runStartMs ??= sample.tMs ?? 0
|
||||
longestUnhealthyWindowMs = Math.max(
|
||||
longestUnhealthyWindowMs,
|
||||
(sample.tMs ?? 0) + (sample.ms || 0) - runStartMs
|
||||
)
|
||||
}
|
||||
|
||||
const summary = () => ({
|
||||
sampleCount: samples.totalCount,
|
||||
maxStatusMs,
|
||||
unhealthySampleCount,
|
||||
infrastructureErrorCount,
|
||||
longestUnhealthyWindowMs
|
||||
})
|
||||
|
||||
const probe = () =>
|
||||
new Promise((resolve) => {
|
||||
const t0 = performance.now()
|
||||
const child = spawn(
|
||||
cliInvocation.command,
|
||||
[...cliInvocation.prefixArgs, 'status', '--json'],
|
||||
{
|
||||
env: cliInvocation.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}
|
||||
)
|
||||
let settled = false
|
||||
const finish = (result) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
resolve(result)
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
finish({
|
||||
tMs: t0 - startedAt,
|
||||
ms: performance.now() - t0,
|
||||
ok: false,
|
||||
hang: true
|
||||
})
|
||||
}, timeoutMs)
|
||||
child.stdout.on('data', () => {})
|
||||
child.stderr.on('data', () => {})
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer)
|
||||
finish({
|
||||
tMs: t0 - startedAt,
|
||||
ms: performance.now() - t0,
|
||||
ok: false,
|
||||
hang: false,
|
||||
infrastructureError: true,
|
||||
error: String(error)
|
||||
})
|
||||
})
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer)
|
||||
finish({
|
||||
tMs: t0 - startedAt,
|
||||
ms: performance.now() - t0,
|
||||
ok: code === 0,
|
||||
hang: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const tick = async ({ force = false } = {}) => {
|
||||
if ((!force && stopped) || inFlight) {
|
||||
return
|
||||
}
|
||||
inFlight = true
|
||||
try {
|
||||
const sample = await probe()
|
||||
record(sample)
|
||||
} finally {
|
||||
inFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
void tick()
|
||||
}, intervalMs)
|
||||
void tick()
|
||||
|
||||
return {
|
||||
stop: async () => {
|
||||
stopped = true
|
||||
clearInterval(interval)
|
||||
// Wait for in-flight probe, then force one final sample.
|
||||
const deadline = performance.now() + timeoutMs + 1000
|
||||
while (inFlight && performance.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
}
|
||||
await tick({ force: true })
|
||||
return {
|
||||
samples: samples.values(),
|
||||
...summary(),
|
||||
durationMs: performance.now() - startedAt
|
||||
}
|
||||
},
|
||||
getSamples: () => samples.values(),
|
||||
getSummary: summary
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { startStatusWatchdog } from './live-remote-status-watchdog.mjs'
|
||||
|
||||
describe('startStatusWatchdog', () => {
|
||||
it('collects status samples and stops cleanly', async () => {
|
||||
// Real path: actually invokes `orca status --json` (must be available in CI/dev with Orca or fail soft).
|
||||
const watch = startStatusWatchdog({ intervalMs: 50, timeoutMs: 5_000 })
|
||||
await new Promise((r) => setTimeout(r, 180))
|
||||
const result = await watch.stop()
|
||||
expect(result.samples.length).toBeGreaterThanOrEqual(1)
|
||||
expect(result.durationMs).toBeGreaterThan(0)
|
||||
for (const s of result.samples) {
|
||||
expect(typeof s.ms).toBe('number')
|
||||
expect(typeof s.ok).toBe('boolean')
|
||||
expect(typeof s.hang).toBe('boolean')
|
||||
}
|
||||
})
|
||||
|
||||
it('marks CLI spawn failures as infrastructure errors', async () => {
|
||||
const watch = startStatusWatchdog({
|
||||
intervalMs: 50,
|
||||
timeoutMs: 1000,
|
||||
cliCommand: 'orca-freeze-watchdog-missing-command'
|
||||
})
|
||||
const result = await watch.stop()
|
||||
|
||||
expect(result.samples.length).toBeGreaterThanOrEqual(1)
|
||||
expect(result.samples.every((sample) => sample.infrastructureError === true)).toBe(true)
|
||||
expect(result.samples.every((sample) => sample.hang === false)).toBe(true)
|
||||
})
|
||||
|
||||
it('bounds retained samples while preserving full-run counters', async () => {
|
||||
const watch = startStatusWatchdog({
|
||||
intervalMs: 50,
|
||||
timeoutMs: 1000,
|
||||
cliCommand: 'orca-freeze-watchdog-missing-command',
|
||||
sampleHistoryLimit: 1
|
||||
})
|
||||
const result = await watch.stop()
|
||||
|
||||
expect(result.samples).toHaveLength(1)
|
||||
expect(result.sampleCount).toBeGreaterThan(result.samples.length)
|
||||
expect(result.infrastructureErrorCount).toBe(result.sampleCount)
|
||||
expect(result.maxStatusMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
@@ -134,5 +134,30 @@ export const CROSS_LOCALE_KEY_OVERRIDES = {
|
||||
'auto.hooks.useSettingsNavigationMetadata.40d80bad8a': {
|
||||
zh: '测试版',
|
||||
ja: 'ベータ'
|
||||
},
|
||||
// Issue/PR state picker; it sits beside 已关闭, so 进行中 is a different state.
|
||||
'auto.components.PullRequestPage.7b8f6bf6d8': {
|
||||
zh: '开放'
|
||||
},
|
||||
// This "Open" is the button that opens the config file, not an issue state.
|
||||
'auto.components.settings.McpConfigFileRow.e720c139cd': {
|
||||
zh: '打开'
|
||||
},
|
||||
// Terminal cursor-color group: the on-screen cursor, not the Cursor editor.
|
||||
'auto.components.settings.TerminalWindowSection.c9e1fdf42f': {
|
||||
ko: '커서',
|
||||
zh: '光标'
|
||||
},
|
||||
'auto.components.onboarding.ThemeStep.ab2a583a97': {
|
||||
ko: '커서',
|
||||
zh: '光标'
|
||||
},
|
||||
// Tailwind swatch labels: bare 天空 (the sky), 锌 and 空 (the metal, the sky) are not colors.
|
||||
'auto.components.sidebar.workspace.status.6437a8c253': {
|
||||
zh: '天蓝',
|
||||
ja: '空色'
|
||||
},
|
||||
'auto.components.sidebar.workspace.status.caabd5ca85': {
|
||||
zh: '锌灰'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Ordinary UI vocabulary that reads like a brand but is not one. Every locale should translate
|
||||
// these, so the policy must neither pin the whole value to English nor revert the translation
|
||||
// inside a sentence. The product-name homonyms (Continue.dev, the agent entries) stay Latin
|
||||
// through ENGLISH_ONLY_KEY_PREFIXES, which matches on the catalog key rather than the word.
|
||||
|
||||
// Renderings a locale is expected to use. They are translations, never machine-translation
|
||||
// errors, so a brand revert must leave them alone. Nonsense forms that share the same English
|
||||
// term (zh 回购 "repurchase agreement", ja 端子 "electrical connector", es "Comprometerse")
|
||||
// stay in BRAND_MISTRANSLATIONS and keep getting reverted.
|
||||
const GENERIC_TERM_FAMILIES = [
|
||||
{
|
||||
terms: ['Agent', 'Agents', 'agent', 'agents'],
|
||||
renderings: {
|
||||
ko: ['에이전트'],
|
||||
ja: ['エージェント'],
|
||||
zh: ['代理', '智能体'],
|
||||
es: ['Agente', 'agente', 'Agentes', 'agentes']
|
||||
}
|
||||
},
|
||||
{
|
||||
terms: ['Commit', 'Commits', 'commit', 'commits'],
|
||||
renderings: {
|
||||
ko: ['커밋'],
|
||||
ja: ['コミット'],
|
||||
zh: ['提交'],
|
||||
es: [
|
||||
'Confirmación',
|
||||
'confirmación',
|
||||
'Confirmaciones',
|
||||
'confirmaciones',
|
||||
'Confirmar',
|
||||
'confirmar'
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
terms: ['Continue'],
|
||||
renderings: {
|
||||
ko: ['계속하다', '계속'],
|
||||
ja: ['続ける', '続行'],
|
||||
zh: ['继续'],
|
||||
es: ['Continuar', 'continuar']
|
||||
}
|
||||
},
|
||||
{
|
||||
terms: ['Repo', 'Repos', 'repo', 'repos'],
|
||||
renderings: {
|
||||
ko: ['저장소', '레포'],
|
||||
ja: ['リポジトリ', 'リポ'],
|
||||
zh: ['存储库', '仓库'],
|
||||
es: ['Repositorio', 'repositorio', 'Repositorios', 'repositorios']
|
||||
}
|
||||
},
|
||||
{
|
||||
terms: ['Terminal', 'Terminals', 'terminal', 'terminals'],
|
||||
renderings: {
|
||||
ko: ['터미널'],
|
||||
ja: ['ターミナル'],
|
||||
zh: ['终端'],
|
||||
es: ['Terminales', 'terminales']
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
export const LOCALIZABLE_GENERIC_TERMS = new Set(
|
||||
GENERIC_TERM_FAMILIES.flatMap((family) => family.terms)
|
||||
)
|
||||
|
||||
const RENDERINGS_BY_TERM = new Map(
|
||||
GENERIC_TERM_FAMILIES.flatMap((family) => family.terms.map((term) => [term, family.renderings]))
|
||||
)
|
||||
|
||||
export function isLocalizableGenericTerm(term) {
|
||||
return LOCALIZABLE_GENERIC_TERMS.has(term)
|
||||
}
|
||||
|
||||
export function isCanonicalGenericRendering(term, locale, form) {
|
||||
return RENDERINGS_BY_TERM.get(term)?.[locale]?.includes(form) ?? false
|
||||
}
|
||||
|
||||
export function canonicalGenericRenderings(locale) {
|
||||
return GENERIC_TERM_FAMILIES.flatMap((family) =>
|
||||
(family.renderings[locale] ?? []).map((form) => ({ form, terms: family.terms }))
|
||||
)
|
||||
}
|
||||
|
||||
function spansOf(value, needle) {
|
||||
const spans = []
|
||||
for (let at = value.indexOf(needle); at !== -1; at = value.indexOf(needle, at + 1)) {
|
||||
spans.push([at, at + needle.length])
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
// Why: zh 终端子进程 ("terminal sub-process") contains 端子, so a blind revert of the
|
||||
// electrical-connector mistranslation would cut a valid word in half.
|
||||
export function overlapsCanonicalRendering(term, locale, value, start, end) {
|
||||
const renderings = RENDERINGS_BY_TERM.get(term)?.[locale] ?? []
|
||||
return renderings.some((form) =>
|
||||
spansOf(value, form).some(([from, to]) => start < to && from < end)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { LOCALIZABLE_GENERIC_TERMS } from './locale-generic-ui-terms.mjs'
|
||||
import { NEVER_TRANSLATE_VALUES, repairTranslatedValue } from './locale-translation-policy.mjs'
|
||||
|
||||
// Why: #12113 — repair-locale-catalog rewrote ~2000 translated values back to English because
|
||||
// generic UI words (agent, terminal, commit, repo, Continue) were treated as brands. These pin
|
||||
// the boundary: generic words stay translated, real brand names stay Latin.
|
||||
describe('locale generic UI terms', () => {
|
||||
it('keeps a translated generic term when the whole value is that term', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.AppearancePane.terminalTitle',
|
||||
enValue: 'Terminal',
|
||||
localeValue: '터미널',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('터미널')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.QuickCommandsPane.4ccc63da87',
|
||||
enValue: 'Agent',
|
||||
localeValue: '에이전트',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('에이전트')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.mobile.MobileHero.a8fb43cf1c',
|
||||
enValue: 'Continue',
|
||||
localeValue: '继续',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('继续')
|
||||
})
|
||||
|
||||
it('keeps a translated generic term inside a sentence', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.EditorFontSection.matchTerminal',
|
||||
enValue: 'Match terminal font',
|
||||
localeValue: '터미널 글꼴과 동일',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('터미널 글꼴과 동일')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.ExperimentalPane.agentDashboard.toggleLabel',
|
||||
enValue: 'Toggle Agent Dashboard',
|
||||
localeValue: '에이전트 대시보드 전환',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('에이전트 대시보드 전환')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'components.agentSessionContinuation.dialogTitle',
|
||||
enValue: 'Continue in New Session',
|
||||
localeValue: '新しいセッションで続ける',
|
||||
locale: 'ja'
|
||||
})
|
||||
// 新しい → 新規 is an unrelated phrase fix; 続ける is what must survive.
|
||||
).toBe('新規セッションで続ける')
|
||||
})
|
||||
|
||||
it('still reverts real brand names that machine translation localized', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.stats.StatsPane.7d26110cea',
|
||||
enValue: 'Codex',
|
||||
localeValue: '사본',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('Codex')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.appearance.search.9ae151b26b',
|
||||
enValue: 'linear',
|
||||
localeValue: '线性',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Linear')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.NetworkPane.tailscale',
|
||||
enValue: 'Connect over Tailscale',
|
||||
localeValue: '通过尾鳞连接',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('通过 Tailscale 连接')
|
||||
})
|
||||
|
||||
it('still reverts nonsense renderings of a generic term', () => {
|
||||
// 端子 is an electrical connector, 回购 a repurchase agreement, Comprometerse "to pledge".
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.agent.AgentCombobox.986f946354',
|
||||
enValue: 'Blank Terminal',
|
||||
localeValue: '空白端子',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('空白 Terminal')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
|
||||
enValue: 'Commit',
|
||||
localeValue: 'Comprometerse',
|
||||
locale: 'es'
|
||||
})
|
||||
).toBe('Commit')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
|
||||
enValue: 'Markdown',
|
||||
localeValue: '가격 인하',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('Markdown')
|
||||
})
|
||||
|
||||
it('does not cut a valid word that contains a nonsense rendering', () => {
|
||||
// 终端子进程 is "terminal sub-process" — 端子 sits inside 终端 + 子.
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.AdvancedNetworkSettingsSection.823e0f15b1',
|
||||
enValue: 'Proxy URL for Orca network requests and local terminal subprocesses.',
|
||||
localeValue: '用于 Orca 网络请求和本地终端子进程的代理 URL。',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('用于 Orca 网络请求和本地终端子进程的代理 URL。')
|
||||
})
|
||||
|
||||
it('still fills in a translation when the catalog holds English', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab',
|
||||
enValue: 'Terminal 1',
|
||||
localeValue: 'Terminal 1',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('터미널 1')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.store.slices.worktrees.889487d8bb',
|
||||
enValue: 'Dismiss',
|
||||
localeValue: '해고하다',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('닫기')
|
||||
})
|
||||
|
||||
it('keeps agent catalog product names in English by key, not by word', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.lib.agent.catalog.9e2a9bb87b',
|
||||
enValue: 'Continue',
|
||||
localeValue: '继续',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Continue')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.lib.agent.catalog.760bc6883d',
|
||||
enValue: 'Codex',
|
||||
localeValue: '사본',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('Codex')
|
||||
})
|
||||
|
||||
it('does not list a generic term as never-translatable', () => {
|
||||
const pinned = [...LOCALIZABLE_GENERIC_TERMS].filter((term) => NEVER_TRANSLATE_VALUES.has(term))
|
||||
expect(pinned).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -94,7 +94,7 @@ export const JA_VALUE_OVERRIDES = {
|
||||
'Launch plan': '起動プラン',
|
||||
'Launch agent': 'エージェントを起動',
|
||||
'Launch {{value0}} in a new terminal': '新規ターミナルで {{value0}} を起動',
|
||||
Play: 'Play',
|
||||
Play: '再生',
|
||||
Action: '操作',
|
||||
Actions: '操作',
|
||||
action: '操作',
|
||||
@@ -135,7 +135,7 @@ export const JA_VALUE_OVERRIDES = {
|
||||
'Pick a base branch below': '以下のベースブランチを選択',
|
||||
'Choose floating workspace directory': 'フローティング ワークスペース ディレクトリを選択',
|
||||
'Local project, Git repo, or folder with many repos':
|
||||
'ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー',
|
||||
'ローカルプロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー',
|
||||
'Enter passphrase': 'パスフレーズを入力',
|
||||
'Enter password': 'パスワードを入力',
|
||||
'Enter the passphrase for': 'のパスフレーズを入力',
|
||||
|
||||
@@ -162,8 +162,8 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
ja: 'クローズ'
|
||||
},
|
||||
'auto.components.GitHubItemDialog.dc1ca081a8': {
|
||||
ko: '진행 중',
|
||||
zh: '进行中',
|
||||
ko: '열림',
|
||||
zh: '开放',
|
||||
ja: 'オープン'
|
||||
},
|
||||
'auto.components.tab.bar.TabBarCreateEntry.b27864279e': {
|
||||
@@ -176,10 +176,11 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
zh: '打开 Linear 任务',
|
||||
ja: 'Linear タスクを開く'
|
||||
},
|
||||
// Onboarding pill beside Orca Mobile: "New" marks a new feature, not a create action.
|
||||
'auto.components.sidebar.SidebarNav.c86d83b5c3': {
|
||||
ko: '새로 만들기',
|
||||
zh: '新建',
|
||||
ja: '新規'
|
||||
ko: '신규',
|
||||
zh: '新功能',
|
||||
ja: '新機能'
|
||||
},
|
||||
'auto.components.sidebar.SidebarSettingsHelpMenu.eb9884e55b': {
|
||||
ko: 'Discord',
|
||||
|
||||
@@ -752,6 +752,21 @@
|
||||
"auto.components.contextual.tours.ContextualTourProgressDots.dcd6e6b03e": {
|
||||
"ko": "{{value1}}단계 중 {{value0}}단계"
|
||||
},
|
||||
"auto.components.contextual.tours.ContextualTourOverlaySurface.complete": {
|
||||
"ko": "완료"
|
||||
},
|
||||
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.body": {
|
||||
"ko": "자동화는 일정에 따라 agent 작업을 실행합니다. 이 버튼을 눌러 자동화를 추가하세요."
|
||||
},
|
||||
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.title": {
|
||||
"ko": "자동화란 무엇인가요?"
|
||||
},
|
||||
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.body": {
|
||||
"ko": "실행 내역에서 자동화가 언제 실행되었는지, 어떤 일이 발생했는지, 출력을 어디서 확인할 수 있는지 볼 수 있습니다."
|
||||
},
|
||||
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.title": {
|
||||
"ko": "결과 확인"
|
||||
},
|
||||
"auto.components.crash.report.CrashReportDialog.88fea8e84e": {
|
||||
"ko": "보내지 않음"
|
||||
},
|
||||
@@ -1868,6 +1883,9 @@
|
||||
"auto.components.right.sidebar.SourceControl.3a231c845b": {
|
||||
"ko": "알 수 없음"
|
||||
},
|
||||
"auto.components.right.sidebar.SourceControl.6d7f2a47e5": {
|
||||
"ko": "폴더의 변경 사항 취소"
|
||||
},
|
||||
"auto.components.right.sidebar.SourceControl.812cb992ee": {
|
||||
"ko": "{{value0}}에서 열기"
|
||||
},
|
||||
@@ -1880,6 +1898,9 @@
|
||||
"auto.components.right.sidebar.SourceControl.9bb062a886": {
|
||||
"ko": "commit 되지 않음"
|
||||
},
|
||||
"auto.components.right.sidebar.SourceControl.a5e5a11090": {
|
||||
"ko": "모든 변경 사항 취소 실패 — 취소 전에 파일의 스테이징을 해제하지 못했습니다."
|
||||
},
|
||||
"auto.components.right.sidebar.SourceControl.aaf1451654": {
|
||||
"ko": "초안 {{value0}} 만들기"
|
||||
},
|
||||
@@ -1994,6 +2015,9 @@
|
||||
"auto.components.right.sidebar.source.control.discard.confirmation.2ae5a785b3": {
|
||||
"ko": "스테이징되지 않은 변경 사항을 모두 삭제하시겠습니까?"
|
||||
},
|
||||
"auto.components.right.sidebar.source.control.discard.confirmation.40e9357b2a": {
|
||||
"ko": "이렇게 하면 HEAD에서 파일을 복원하고 파일 삭제를 취소합니다. 이 작업은 취소할 수 없습니다."
|
||||
},
|
||||
"auto.components.right.sidebar.source.control.discard.confirmation.5ddd8cac7f": {
|
||||
"ko": "스테이징된 변경 사항을 모두 삭제하시겠습니까?"
|
||||
},
|
||||
@@ -2015,6 +2039,9 @@
|
||||
"auto.components.right.sidebar.source.control.primary.action.1d47e850cf": {
|
||||
"ko": "연결된 리뷰 브랜치에 업데이트 푸시"
|
||||
},
|
||||
"auto.components.right.sidebar.source.control.primary.action.5a477d80cb": {
|
||||
"ko": "모든 변경 사항 스테이징"
|
||||
},
|
||||
"auto.components.right.sidebar.source.control.primary.action.a5d1f7b036": {
|
||||
"ko": "{{value0}}을(를) 만들기 전에 commits을 게시하세요."
|
||||
},
|
||||
@@ -2162,6 +2189,9 @@
|
||||
"auto.components.settings.AutoRenameBranchFromWorkSetting.a4fa380b67": {
|
||||
"ko": "{assistantMessage}"
|
||||
},
|
||||
"auto.components.settings.AutoRenameBranchFromWorkSetting.d9b65054ef": {
|
||||
"ko": ") 작업을 요약하는 짧은 이름으로 변경됩니다. Orca가 직접 이름 붙인 브랜치만 이름을 바꾸며, 푸시된 후에는 이름을 바꾸지 않습니다."
|
||||
},
|
||||
"auto.components.settings.AutoRenameBranchPromptEditor.ebb942a2ec": {
|
||||
"ko": "fix-login-flow"
|
||||
},
|
||||
@@ -2228,6 +2258,9 @@
|
||||
"auto.components.settings.DefaultWindowsProjectRuntimeSetting.wslUnavailable": {
|
||||
"ko": "WSL을 사용할 수 없습니다. WSL을 상속하는 프로젝트는 복구가 필요합니다."
|
||||
},
|
||||
"auto.components.settings.DevToolsPane.orcaCloudDescription": {
|
||||
"ko": "자사 클라우드 로그인의 개발자 전용 미리보기. 프로덕션에서는 숨겨집니다. 개발 환경에서는 ORCA_CLOUD_API_URL과 ORCA_CLOUD_CLIENT_ID가 설정되면 사이드바 계정 전환기에도 표시됩니다."
|
||||
},
|
||||
"auto.components.settings.DeveloperPermissionsPane.16381e040a": {
|
||||
"ko": "마이크"
|
||||
},
|
||||
@@ -2255,6 +2288,12 @@
|
||||
"auto.components.settings.ExperimentalPane.newWorktreeCardStyle.description": {
|
||||
"ko": "업데이트된 워크트리 카드 레이아웃, 메타데이터 배치, 카드 표시 메뉴 옵션 및 상태 표시를 미리 봅니다."
|
||||
},
|
||||
"auto.components.settings.EphemeralVmsPane.recipes": {
|
||||
"ko": "레시피"
|
||||
},
|
||||
"auto.components.settings.EphemeralVmsPane.whatTitle": {
|
||||
"ko": "이 스킬로 함께 하는 작업"
|
||||
},
|
||||
"auto.components.settings.GeneralEditorSettingsSection.45c6e85c4d": {
|
||||
"ko": "편집기"
|
||||
},
|
||||
@@ -2727,7 +2766,7 @@
|
||||
"ko": "이 SSH 대상의 원격 릴레이를 강제로 중지합니다. 이 대상의 활성 원격 terminals과 포트 포워딩이 종료됩니다."
|
||||
},
|
||||
"auto.components.settings.SshTargetForm.137e88ce8d": {
|
||||
"ko": "연결 해제 후 릴레이가 terminals을 활성 상태로 유지하는 기간입니다. 기본값: 10800(3시간). 최대:"
|
||||
"ko": "Orca가 이 호스트에서 연결 해제된 뒤에도 원격 터미널은 계속 실행됩니다."
|
||||
},
|
||||
"auto.components.settings.SshTargetForm.2ee9bcd2e8": {
|
||||
"ko": "server, deploy@server:2222, ssh://server"
|
||||
@@ -3710,6 +3749,9 @@
|
||||
"auto.components.settings.ssh.search.f9493b80c0": {
|
||||
"ko": "서버"
|
||||
},
|
||||
"auto.components.settings.source.control.action.recipe.options.commitMessage": {
|
||||
"ko": "스테이징된 변경 사항에서 commit 메시지를 생성합니다."
|
||||
},
|
||||
"auto.components.settings.task.tracker.integration.cards.1a12e33fe5": {
|
||||
"ko": "Linear 액세스 추가"
|
||||
},
|
||||
@@ -4683,7 +4725,7 @@
|
||||
"ko": "중지 중"
|
||||
},
|
||||
"auto.components.status.bar.WorkspaceSpaceCompactPanel.8ff597593d": {
|
||||
"ko": "Space"
|
||||
"ko": "저장 공간"
|
||||
},
|
||||
"auto.components.status.bar.WorkspaceSpaceCompactPanel.9be86c46a0": {
|
||||
"ko": "확보 가능"
|
||||
@@ -4953,7 +4995,7 @@
|
||||
"ko": "선택한 리포지토리와 일치하는 비활성 워크스페이스가 없습니다."
|
||||
},
|
||||
"auto.components.workspace.space.WorkspaceSpacePage.45f6302dbc": {
|
||||
"ko": "Space"
|
||||
"ko": "저장 공간"
|
||||
},
|
||||
"auto.components.workspace.space.WorkspaceSpacePage.8d0048e1cb": {
|
||||
"ko": "워크스페이스 디스크 사용량과 회수 가능한 워크트리 저장 공간."
|
||||
|
||||
@@ -209,7 +209,7 @@ export const KO_VALUE_OVERRIDES = {
|
||||
'Install the Orca skill so agents know to use the Orca CLI.':
|
||||
'에이전트가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.',
|
||||
'Local project, Git repo, or folder with many repos':
|
||||
'로컬 프로젝트, Git repo 또는 repos가 많은 폴더',
|
||||
'로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더',
|
||||
'Linear, GitLab, Bitbucket, Azure DevOps, Gitea, and Jira live in Settings > Integrations.':
|
||||
'Linear, GitLab, Bitbucket, Azure DevOps, Gitea 및 Jira는 설정 > 연동에 있습니다.',
|
||||
'changed since you last approved. Re-review before it runs':
|
||||
|
||||
@@ -371,9 +371,10 @@ export const LOCALE_PHRASE_FIXES = {
|
||||
{ pattern: /指挥进展/g, replacement: 'Conductor 进度', whenEnIncludes: 'Conductor Progress' },
|
||||
{ pattern: /指挥评论/g, replacement: 'Conductor 评审', whenEnIncludes: 'Conductor Review' },
|
||||
{ pattern: /指挥完成/g, replacement: 'Conductor 完成', whenEnIncludes: 'Conductor Done' },
|
||||
{ pattern: /琥珀色/g, replacement: 'Amber', whenEnIncludes: 'Amber' },
|
||||
{ pattern: /蓝色的/g, replacement: 'Blue', whenEnIncludes: 'Blue' },
|
||||
{ pattern: /中性的/g, replacement: 'Neutral', whenEnIncludes: 'Neutral' },
|
||||
// Swatch labels want the bare color noun, not the adjectival 的 form — and not English.
|
||||
{ pattern: /琥珀色/g, replacement: '琥珀', whenEnIncludes: 'Amber' },
|
||||
{ pattern: /蓝色的/g, replacement: '蓝色', whenEnIncludes: 'Blue' },
|
||||
{ pattern: /中性的/g, replacement: '中性', whenEnIncludes: 'Neutral' },
|
||||
{ pattern: /破坏性的/g, replacement: 'destructive', whenEnIncludes: 'destructive' },
|
||||
{ pattern: /注解/g, replacement: '批注', whenEnIncludes: 'Annotation' },
|
||||
...ZH_PHRASE_FIXES_ROUND5
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
const LOCALIZED_PROSE_TERM_KEYS = new Set([
|
||||
'auto.hooks.useMacosTccPromptNotice.description',
|
||||
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8'
|
||||
])
|
||||
|
||||
const LOCALIZABLE_PROSE_TERMS = new Set([
|
||||
'Agent',
|
||||
'Agents',
|
||||
'agent',
|
||||
'agents',
|
||||
'Terminal',
|
||||
'Terminals',
|
||||
'terminal',
|
||||
'terminals'
|
||||
])
|
||||
|
||||
export function isLocalizedProseTermContext(term, key) {
|
||||
return LOCALIZED_PROSE_TERM_KEYS.has(key) && LOCALIZABLE_PROSE_TERMS.has(term)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { repairCatalog } from './locale-translation-policy.mjs'
|
||||
|
||||
// Regression: en.json routinely carries keys a locale catalog has not been bootstrapped with yet
|
||||
// (~190 per locale at the time of writing), which crashed the whole repair run before it did any work.
|
||||
describe('repairCatalog with un-bootstrapped keys', () => {
|
||||
const enCatalog = {
|
||||
auto: {
|
||||
lib: { agent: { catalog: { '760bc6883d': 'Codex' } } },
|
||||
components: { untranslated: 'Continue', nested: { alsoMissing: 'Refresh' } }
|
||||
}
|
||||
}
|
||||
|
||||
const translatedOnly = () => ({ auto: { lib: { agent: { catalog: { '760bc6883d': '사본' } } } } })
|
||||
|
||||
it('skips leaves the locale catalog is missing instead of throwing', () => {
|
||||
for (const locale of ['ko', 'ja', 'zh', 'es']) {
|
||||
const localeCatalog = translatedOnly()
|
||||
expect(() => repairCatalog(enCatalog, localeCatalog, locale), locale).not.toThrow()
|
||||
expect(localeCatalog.auto.components, locale).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('still repairs the leaves that are present', () => {
|
||||
const localeCatalog = translatedOnly()
|
||||
expect(repairCatalog(enCatalog, localeCatalog, 'ko')).toBe(1)
|
||||
expect(localeCatalog.auto.lib.agent.catalog['760bc6883d']).toBe('Codex')
|
||||
})
|
||||
})
|
||||
@@ -45,7 +45,7 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
localeValue: '에이전트가 Orca CLI 사용 방법을 알 수 있도록 Orca 기술을 설치합니다.',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('agents가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.')
|
||||
).toBe('에이전트가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.editor.MarkdownPreview.322afab6ff',
|
||||
@@ -92,7 +92,8 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
).toBe('파이프라인')
|
||||
})
|
||||
|
||||
it('keeps protected workflow terms in English', () => {
|
||||
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Korean.
|
||||
it('keeps brand names English and generic workflow terms translated', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab',
|
||||
@@ -100,7 +101,7 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
localeValue: '터미널 1',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('Terminal 1')
|
||||
).toBe('터미널 1')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.skills.SkillsPage.38e0951c3a',
|
||||
@@ -108,7 +109,7 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
localeValue: '에이전트 스킬',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('Agent 스킬')
|
||||
).toBe('에이전트 스킬')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
|
||||
@@ -124,7 +125,7 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
localeValue: '푸시되지 않은 커밋',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('푸시되지 않은 commits')
|
||||
).toBe('푸시되지 않은 커밋')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a',
|
||||
@@ -132,7 +133,7 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
localeValue: '레포',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('Repo')
|
||||
).toBe('레포')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
|
||||
@@ -140,7 +141,7 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
localeValue: '로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('로컬 프로젝트, Git repo 또는 repos가 많은 폴더')
|
||||
).toBe('로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더')
|
||||
})
|
||||
|
||||
it('re-glues Korean particles after Latin terms without gluing content words', () => {
|
||||
|
||||
@@ -3,7 +3,8 @@ import { describe, expect, it } from 'vitest'
|
||||
import { repairTranslatedValue } from './locale-translation-policy.mjs'
|
||||
|
||||
describe('locale-translation-policy es round 5', () => {
|
||||
it('keeps protected workflow terms in English', () => {
|
||||
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Spanish.
|
||||
it('keeps brand names English and generic workflow terms translated', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
|
||||
@@ -43,7 +44,7 @@ describe('locale-translation-policy es round 5', () => {
|
||||
localeValue: 'mensaje de confirmación',
|
||||
locale: 'es'
|
||||
})
|
||||
).toBe('mensaje de Commit')
|
||||
).toBe('mensaje de confirmación')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a',
|
||||
@@ -51,7 +52,7 @@ describe('locale-translation-policy es round 5', () => {
|
||||
localeValue: 'repositorio',
|
||||
locale: 'es'
|
||||
})
|
||||
).toBe('Repo')
|
||||
).toBe('repositorio')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
|
||||
@@ -59,6 +60,6 @@ describe('locale-translation-policy es round 5', () => {
|
||||
localeValue: 'Proyecto local, repositorio de Git o carpeta con muchos repositorios',
|
||||
locale: 'es'
|
||||
})
|
||||
).toBe('Proyecto local, repo de Git o carpeta con muchos repos')
|
||||
).toBe('Proyecto local, repositorio de Git o carpeta con muchos repositorios')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
localeValue: '遊ぶ',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('Play')
|
||||
).toBe('再生')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.TaskPage.8396825a14',
|
||||
@@ -102,7 +102,8 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
).toBe('まずプロジェクトを追加')
|
||||
})
|
||||
|
||||
it('keeps protected workflow terms in English', () => {
|
||||
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Japanese.
|
||||
it('keeps brand names English and generic workflow terms translated', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.status.bar.WorkspaceSpaceManagerPanel.e9528a89b3',
|
||||
@@ -118,7 +119,7 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
localeValue: 'エージェントのスキル',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('Agent のスキル')
|
||||
).toBe('エージェントのスキル')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.tab.bar.TabBar.3d5d6c960d',
|
||||
@@ -134,7 +135,7 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
localeValue: 'コミット',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('commits')
|
||||
).toBe('コミット')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.mobile.slides.WorktreeListSlide.22971156df',
|
||||
@@ -142,7 +143,7 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
localeValue: 'リポ',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('Repo')
|
||||
).toBe('リポ')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
|
||||
@@ -151,6 +152,6 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
'ローカル プロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー')
|
||||
).toBe('ローカルプロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { CJK_LATIN_SPACED_TERMS } from './locale-cjk-latin-spaced-terms.mjs'
|
||||
import { isLocalizedProseTermContext } from './locale-prose-term-exemptions.mjs'
|
||||
import {
|
||||
isCanonicalGenericRendering,
|
||||
overlapsCanonicalRendering
|
||||
} from './locale-generic-ui-terms.mjs'
|
||||
import { isScreenCursorContext } from './locale-screen-cursor-exemptions.mjs'
|
||||
import { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs'
|
||||
import { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs'
|
||||
@@ -17,9 +20,10 @@ const OPEN_IN_APP_CATALOG_PREFIX = 'auto.lib.open.in.app.catalog.'
|
||||
// Why: product names and agent labels stay Latin — MT reads them as common words (Codex→copy, Gemini→zodiac).
|
||||
export const ENGLISH_ONLY_KEY_PREFIXES = [AGENT_CATALOG_PREFIX, OPEN_IN_APP_CATALOG_PREFIX]
|
||||
|
||||
// Only genuine brand, product, and code tokens belong here. Ordinary UI words that happen to
|
||||
// name a product (agent, terminal, commit, repo, Continue) live in locale-generic-ui-terms.mjs
|
||||
// and are translated; their product sense is pinned by ENGLISH_ONLY_KEY_PREFIXES instead.
|
||||
export const NEVER_TRANSLATE_VALUES = new Set([
|
||||
'Agent',
|
||||
'Agents',
|
||||
'Aider',
|
||||
'Amp',
|
||||
'Android',
|
||||
@@ -33,7 +37,6 @@ export const NEVER_TRANSLATE_VALUES = new Set([
|
||||
'Codebuff',
|
||||
'Codex',
|
||||
'Command Code',
|
||||
'Continue',
|
||||
'Cursor',
|
||||
'Droid',
|
||||
'Devin',
|
||||
@@ -60,30 +63,16 @@ export const NEVER_TRANSLATE_VALUES = new Set([
|
||||
'Pi',
|
||||
'PostHog',
|
||||
'Qwen Code',
|
||||
'Repo',
|
||||
'Repos',
|
||||
'Rovo Dev',
|
||||
'Commit',
|
||||
'Commits',
|
||||
'Markdown',
|
||||
'Terminal',
|
||||
'Terminals',
|
||||
'VS Code',
|
||||
'Warp',
|
||||
'Zed',
|
||||
'agent',
|
||||
'agents',
|
||||
'android',
|
||||
'codex',
|
||||
'commit',
|
||||
'commits',
|
||||
'gemini',
|
||||
'claude',
|
||||
'markdown',
|
||||
'repo',
|
||||
'repos',
|
||||
'terminal',
|
||||
'terminals',
|
||||
'gh',
|
||||
'idle',
|
||||
'anthropic',
|
||||
@@ -368,6 +357,18 @@ function includesPreservedLatinTerm(value, term) {
|
||||
return new RegExp(`(^|[^A-Za-z_])${escapeRegExp(term)}($|[^A-Za-z_])`).test(value)
|
||||
}
|
||||
|
||||
function replaceMistranslatedForm(value, wrong, brand, locale) {
|
||||
let result = ''
|
||||
let cursor = 0
|
||||
for (let at = value.indexOf(wrong); at !== -1; at = value.indexOf(wrong, cursor)) {
|
||||
const end = at + wrong.length
|
||||
result += value.slice(cursor, at)
|
||||
result += overlapsCanonicalRendering(brand, locale, value, at, end) ? wrong : brand
|
||||
cursor = end
|
||||
}
|
||||
return result + value.slice(cursor)
|
||||
}
|
||||
|
||||
function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
|
||||
let result = localeValue
|
||||
const mistranslations = BRAND_MISTRANSLATIONS[locale] ?? {}
|
||||
@@ -383,9 +384,6 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
|
||||
if (isScreenCursorContext(brand, enValue, key)) {
|
||||
continue
|
||||
}
|
||||
if (isLocalizedProseTermContext(brand, key)) {
|
||||
continue
|
||||
}
|
||||
if (includesPreservedLatinTerm(result, brand)) {
|
||||
continue
|
||||
}
|
||||
@@ -393,11 +391,16 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
|
||||
if (!result.includes(wrong)) {
|
||||
continue
|
||||
}
|
||||
// Why: #12113 — a generic term's correct translation is not a mistranslation; reverting it
|
||||
// rewrote ~2000 translated values back to English on every repair run.
|
||||
if (isCanonicalGenericRendering(brand, locale, wrong)) {
|
||||
continue
|
||||
}
|
||||
// Why: "Copy identifier" legitimately uses 사본/复制 — only swap when English names the brand.
|
||||
if (brand === 'Codex' && /\bCopy\b/i.test(enValue)) {
|
||||
continue
|
||||
}
|
||||
result = result.replaceAll(wrong, brand)
|
||||
result = replaceMistranslatedForm(result, wrong, brand, locale)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,6 +551,11 @@ export function repairCatalog(enCatalog, localeCatalog, locale) {
|
||||
|
||||
for (const leaf of leaves) {
|
||||
const current = leaf.key.split('.').reduce((cursor, part) => cursor?.[part], localeCatalog)
|
||||
// Why: en.json carries keys the locale catalog has not been bootstrapped with yet; repair only
|
||||
// rewrites values that already exist, so skip instead of crashing on undefined.
|
||||
if (typeof current !== 'string') {
|
||||
continue
|
||||
}
|
||||
const next = repairTranslatedValue({
|
||||
key: leaf.key,
|
||||
enValue: leaf.value,
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '壊れた小切手に対して AI エージェントを開始しました。',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('失敗したチェックに対して AI agent を開始しました。')
|
||||
).toBe('失敗したチェックに対して AI エージェントを開始しました。')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.hooks.useSettingsNavigationMetadata.95a1886d94',
|
||||
@@ -88,7 +88,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '電話機からターミナルとエージェントを制御します。',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('スマートフォンから terminals と agents を操作')
|
||||
).toBe('スマートフォンからターミナルとエージェントを操作')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.GitHubItemDialog.934add88b6',
|
||||
@@ -267,7 +267,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '未已检测代理',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('未检测到 agents')
|
||||
).toBe('未检测到代理')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.skills.SkillsPage.38e0951c3a',
|
||||
@@ -275,7 +275,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '代理技巧',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Agent 技能')
|
||||
).toBe('代理技能')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.appearance.search.9ae151b26b',
|
||||
@@ -358,7 +358,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: 'コミットするものは何もありません。 PR はすでに統合されています。',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('commit するものはありません。PR はすでにマージされています。')
|
||||
).toBe('コミットするものはありません。PR はすでにマージされています。')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.integrations.search.581844769a',
|
||||
@@ -546,7 +546,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '玩',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Play')
|
||||
).toBe('播放')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.right.sidebar.SourceControlAgentActionDialogForm.1bc0bdbb5e',
|
||||
|
||||
@@ -86,7 +86,9 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
).toBe('集成')
|
||||
})
|
||||
|
||||
it('keeps Terminal as a product surface term', () => {
|
||||
// Why: #12113 — 终端 is the correct rendering and survives; only the 端子 ("electrical
|
||||
// connector") mistranslation still reverts to Latin.
|
||||
it('keeps terminal translated but reverts the 端子 mistranslation', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.Settings.3de4bbb841',
|
||||
@@ -94,7 +96,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '终端',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Terminal')
|
||||
).toBe('终端')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab',
|
||||
@@ -102,7 +104,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '终端 1',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Terminal 1')
|
||||
).toBe('终端 1')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.agent.AgentCombobox.986f946354',
|
||||
@@ -118,7 +120,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '分体式端子右',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('向右拆分 Terminal')
|
||||
).toBe('向右拆分终端')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.TerminalAppearanceSection.abcb4dd019',
|
||||
@@ -126,7 +128,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '终端Cursor',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Terminal Cursor')
|
||||
).toBe('终端 Cursor')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.terminal.FloatingTerminalPanel.3215fc73e9',
|
||||
@@ -137,7 +139,8 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
).toBe('新 Terminal')
|
||||
})
|
||||
|
||||
it('keeps workflow terms in English', () => {
|
||||
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Chinese.
|
||||
it('keeps brand names English and generic workflow terms translated', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.sidebar.SidebarNav.9c95e1ce91',
|
||||
@@ -145,7 +148,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '代理',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Agents')
|
||||
).toBe('代理')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.GitHubItemDialog.28986b3747',
|
||||
@@ -153,7 +156,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '已启动 AI 代理处理失败的检查。',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('已启动 AI agent 处理失败的检查。')
|
||||
).toBe('已启动 AI 代理处理失败的检查。')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
|
||||
@@ -177,7 +180,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '次提交',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('commits')
|
||||
).toBe('次提交')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.store.slices.worktrees.d1d78a7baa',
|
||||
@@ -187,7 +190,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
'Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地提交。',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地 commits。')
|
||||
).toBe('Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地提交。')
|
||||
})
|
||||
|
||||
it('does not confuse proxy copy with Agent terminology', () => {
|
||||
@@ -234,7 +237,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '本地项目、Git 存储库或包含多个存储库的文件夹',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('本地项目、Git repo 或包含多个 repos 的文件夹')
|
||||
).toBe('本地项目、Git 仓库或包含多个仓库的文件夹')
|
||||
})
|
||||
|
||||
it('keeps product, provider, code, and shell tokens untranslated', () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ export const LOCALE_VALUE_OVERRIDES = {
|
||||
'OpenCode Go': 'OpenCode Go',
|
||||
'Open in Cursor': 'Abrir en Cursor',
|
||||
'Local project, Git repo, or folder with many repos':
|
||||
'Proyecto local, repo de Git o carpeta con muchos repos'
|
||||
'Proyecto local, repositorio de Git o carpeta con muchos repositorios'
|
||||
},
|
||||
ko: {
|
||||
Save: '저장',
|
||||
@@ -305,7 +305,8 @@ export const LOCALE_VALUE_OVERRIDES = {
|
||||
teams: '团队',
|
||||
passing: '通过',
|
||||
'opened this issue': '创建了此议题',
|
||||
Open: '进行中',
|
||||
// No blanket "Open" override: it is a verb on buttons (打开) and a state next to 已关闭 (开放),
|
||||
// so a single value-wide mapping is wrong for half the call sites. Per-key catalog values decide.
|
||||
'Join Discord': '加入 Discord',
|
||||
'Pull request merged': '拉取请求已合并',
|
||||
'Agent Skills': '代理技能',
|
||||
|
||||
@@ -67,8 +67,8 @@ export const ZH_VALUE_OVERRIDES = {
|
||||
Inline: '内联',
|
||||
thread: '帖子串',
|
||||
destructive: 'destructive',
|
||||
sheet: 'sheet',
|
||||
page: 'page',
|
||||
sheet: '面板',
|
||||
page: '页面',
|
||||
Annotation: '批注',
|
||||
'check #': '检查 #',
|
||||
by: '由',
|
||||
@@ -92,16 +92,18 @@ export const ZH_VALUE_OVERRIDES = {
|
||||
'仓库操作方案。在此仓库自定义之前,将使用全局设置。',
|
||||
'Adds action recipes for Source Control commit, pull request, branch-name, and fix actions.':
|
||||
'为源代码管理的提交、拉取请求、分支命名和修复操作添加操作方案。',
|
||||
Play: 'Play',
|
||||
Flag: 'Flag',
|
||||
Zinc: 'Zinc',
|
||||
Rose: 'Rose',
|
||||
Emerald: 'Emerald',
|
||||
Amber: 'Amber',
|
||||
Violet: 'Violet',
|
||||
Sky: 'Sky',
|
||||
Blue: 'Blue',
|
||||
Neutral: 'Neutral',
|
||||
// Workspace status icon and color swatch labels; siblings below are translated, so English
|
||||
// pins here would leave the picker half-Chinese on the next catalog regeneration.
|
||||
Play: '播放',
|
||||
Flag: '标记',
|
||||
Zinc: '锌灰',
|
||||
Rose: '玫瑰',
|
||||
Emerald: '翡翠',
|
||||
Amber: '琥珀',
|
||||
Violet: '紫罗兰',
|
||||
Sky: '天蓝',
|
||||
Blue: '蓝色',
|
||||
Neutral: '中性',
|
||||
Dashed: '虚线',
|
||||
Dot: '圆点',
|
||||
Circle: '圆形',
|
||||
@@ -201,8 +203,7 @@ export const ZH_VALUE_OVERRIDES = {
|
||||
'每个已连接的 Linear 工作区都有一个由活动运行时存储的密钥。全权限密钥可覆盖密钥所有者可访问的所有团队;受限密钥可随时更换。',
|
||||
'Show Linear in the Tasks source picker and sidebar shortcuts.':
|
||||
'在任务源选择器和侧边栏快捷方式中显示 Linear。',
|
||||
'Local project, Git repo, or folder with many repos':
|
||||
'本地项目、Git repo 或包含多个 repos 的文件夹',
|
||||
'Local project, Git repo, or folder with many repos': '本地项目、Git 仓库或包含多个仓库的文件夹',
|
||||
'Staged Changes': '已暂存的更改',
|
||||
Changes: '更改',
|
||||
'Untracked Files': '未跟踪文件',
|
||||
@@ -210,8 +211,8 @@ export const ZH_VALUE_OVERRIDES = {
|
||||
'Split Down': '向下拆分',
|
||||
'Split Left': '向左拆分',
|
||||
'Split Right': '向右拆分',
|
||||
'Split Terminal Down': '向下拆分 Terminal',
|
||||
'Split Terminal Right': '向右拆分 Terminal',
|
||||
'Split Terminal Down': '向下拆分终端',
|
||||
'Split Terminal Right': '向右拆分终端',
|
||||
'Optional account switching for Claude while preserving shared chat context.':
|
||||
'Claude 的可选账户切换,同时保留共享聊天上下文。',
|
||||
'Countdown timer showing time until prompt cache expires (Claude agents).':
|
||||
|
||||
@@ -227,12 +227,79 @@ describe('orchestration skill guidance', () => {
|
||||
|
||||
expect(agentGuidance).toContain('After sending `worker_done`, end your turn')
|
||||
expect(agentGuidance).toContain('idle at the agent prompt')
|
||||
expect(agentGuidance).toContain('Do not poll or keep calling `orca orchestration check`')
|
||||
expect(agentGuidance).toContain(
|
||||
'do not start more work, poll, or attempt to close the terminal yourself'
|
||||
)
|
||||
expect(agentGuidance).toContain('fresh preamble + TASK block delivered as new terminal input')
|
||||
expect(skill).not.toContain('post-completion polling messages')
|
||||
expect(skill).not.toContain('every 2 minutes')
|
||||
})
|
||||
|
||||
it('makes settled worker terminal release an explicit coordinator step', () => {
|
||||
const skill = readSkill()
|
||||
const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop')
|
||||
const agentGuidance = getSection(skill, 'Agent Guidance')
|
||||
const nextAction = getSection(skill, 'Next Action')
|
||||
|
||||
expect(workerLoop).toContain(
|
||||
'# Process every message. For each accepted worker_done that is not immediately reused:\n' +
|
||||
'orca orchestration worker-release --dispatch <dispatch_id> --json'
|
||||
)
|
||||
expect(workerLoop).toContain(
|
||||
'Acknowledge only after every message and required release decision is handled'
|
||||
)
|
||||
expect(workerLoop).toContain(
|
||||
'read the `worker.agent_terminal_handle` field of `worker-show --dispatch <dispatch_id> --json`'
|
||||
)
|
||||
expect(workerLoop).toContain(
|
||||
'orca orchestration worker-start --task <next_task_id> --terminal <handle> --json` so Orca ' +
|
||||
'transfers cleanup ownership to the new Dispatch'
|
||||
)
|
||||
expect(workerLoop).toContain(
|
||||
'Run `worker-release` after both succeeded and failed `worker_done` reports unless the user ' +
|
||||
'explicitly asked to keep that worker live.'
|
||||
)
|
||||
expect(workerLoop).toContain('Release is post-completion cleanup, not cancellation')
|
||||
expect(workerLoop).toContain('orca orchestration worker-retain --dispatch <dispatch_id> --json')
|
||||
expect(workerLoop).toContain(
|
||||
'the same Dispatch can be passed to `worker-release`, which clears the requested retention'
|
||||
)
|
||||
expect(agentGuidance).toContain(
|
||||
'Coordinators must account for every settled worker terminal before waiting again or ending ' +
|
||||
'the turn'
|
||||
)
|
||||
expect(agentGuidance).toContain('released workers remain readable through `worker-read`')
|
||||
expect(nextAction).toContain(
|
||||
'After every accepted `worker_done`, either transfer the exact terminal to an immediate ' +
|
||||
'follow-up Dispatch or run `worker-release` before the next wait.'
|
||||
)
|
||||
})
|
||||
|
||||
it('never authorizes release from idle, timeout, or worker-side triggers', () => {
|
||||
const skill = readSkill()
|
||||
const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop')
|
||||
const agentGuidance = getSection(skill, 'Agent Guidance')
|
||||
|
||||
// The prohibition sentence is the guard the negative patterns below rely on.
|
||||
expect(workerLoop).toContain(
|
||||
'Do not release a worker because of a timeout, TUI idle state, heartbeat, status, question, ' +
|
||||
'escalation, or rejected/stale `worker_done`.'
|
||||
)
|
||||
expect(workerLoop).toContain(
|
||||
'do not substitute `terminal close`; follow the exact recovery action in the receipt'
|
||||
)
|
||||
expect(skill).not.toMatch(
|
||||
/release[^.]*\bon (?:a |the )?(?:tui-?idle|idle|timeout|heartbeat|question|escalation)\b/iu
|
||||
)
|
||||
expect(skill).not.toMatch(
|
||||
/\b(?:after|on|upon) (?:a |the )?(?:tui-?idle|idle state|timeout|heartbeat)\b[^.]*\brelease/iu
|
||||
)
|
||||
expect(agentGuidance).toContain(
|
||||
'do not start more work, poll, or attempt to close the terminal yourself'
|
||||
)
|
||||
expect(agentGuidance).not.toMatch(/worker-release[^.]*\byourself\b/iu)
|
||||
})
|
||||
|
||||
it('documents @grok in the Messaging group address list', () => {
|
||||
const skill = readSkill()
|
||||
const messaging = getSection(skill, 'Messaging')
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Plugin, Rollup } from 'vite'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createPlainNodeEntryGuardPlugin } from '../../build-plugins/plain-node-entry-guard'
|
||||
import { createPlainNodeEntryGuardPlugin } from '../build-plugins/plain-node-entry-guard'
|
||||
|
||||
let outputDir: string | undefined
|
||||
|
||||
@@ -85,3 +85,91 @@ describe('plain Node entry guard', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Why (#11161): Electron's module is not registered on worker threads either —
|
||||
// require("electron") throws "Cannot find module 'electron'" inside a
|
||||
// main-process worker and kills it at startup. The worker entries carried only
|
||||
// hand-written "must stay electron-free" comments, and the port-scan worker sits
|
||||
// one import away from a client that deliberately does require electron.
|
||||
describe('worker thread entry guard', () => {
|
||||
function runWorkerWriteBundle(plugin: Plugin, bundle: Rollup.OutputBundle): void {
|
||||
const hook = plugin.writeBundle
|
||||
if (typeof hook !== 'function') {
|
||||
throw new Error('Expected writeBundle hook')
|
||||
}
|
||||
hook.call(
|
||||
{ meta: { watchMode: false } } as never,
|
||||
{ dir: createOutputDir() } as Rollup.NormalizedOutputOptions,
|
||||
bundle
|
||||
)
|
||||
}
|
||||
|
||||
function workerChunk(name: string, code: string, imports: string[] = []): Rollup.OutputChunk {
|
||||
return {
|
||||
type: 'chunk',
|
||||
code,
|
||||
dynamicImports: [],
|
||||
fileName: `${name}.js`,
|
||||
imports,
|
||||
isEntry: true,
|
||||
name
|
||||
} as Rollup.OutputChunk
|
||||
}
|
||||
|
||||
it('rejects an Electron require reachable from a worker entry', () => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
const bundle = {
|
||||
'port-scan-command-worker-entry.js': workerChunk(
|
||||
'port-scan-command-worker-entry',
|
||||
'require("electron")'
|
||||
)
|
||||
} as Rollup.OutputBundle
|
||||
|
||||
expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('requires electron')
|
||||
})
|
||||
|
||||
it('names the worker-thread runtime so the failure is actionable', () => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
const bundle = {
|
||||
'stt-worker.js': workerChunk('stt-worker', 'require("electron")')
|
||||
} as Rollup.OutputBundle
|
||||
|
||||
expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('runs as a worker thread')
|
||||
})
|
||||
|
||||
// The real risk is transitive: a worker entry importing a shared chunk that
|
||||
// reaches the electron-requiring client, not a direct import anyone would spot.
|
||||
it('follows shared chunks out of a worker entry', () => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
const bundle = {
|
||||
'session-scanner-opencode-sqlite-worker-entry.js': workerChunk(
|
||||
'session-scanner-opencode-sqlite-worker-entry',
|
||||
'require("./chunks/shared.js")',
|
||||
['chunks/shared.js']
|
||||
),
|
||||
'chunks/shared.js': {
|
||||
type: 'chunk',
|
||||
code: 'require("electron")',
|
||||
dynamicImports: [],
|
||||
fileName: 'chunks/shared.js',
|
||||
imports: [],
|
||||
isEntry: false,
|
||||
name: 'shared'
|
||||
} as Rollup.OutputChunk
|
||||
} as Rollup.OutputBundle
|
||||
|
||||
expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('chunks/shared.js')
|
||||
})
|
||||
|
||||
it('passes a clean worker entry', () => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
const bundle = {
|
||||
'warp-theme-parser-worker.js': workerChunk(
|
||||
'warp-theme-parser-worker',
|
||||
'require("node:worker_threads")'
|
||||
)
|
||||
} as Rollup.OutputBundle
|
||||
|
||||
expect(() => runWorkerWriteBundle(plugin, bundle)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { build } from 'esbuild'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { createRequire } from 'node:module'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
@@ -9,6 +10,12 @@ import { dirname, join, resolve } from 'node:path'
|
||||
const WAIT_TIMEOUT_MS = 20_000
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
// Why: endpoint credentials are 32–256 base64url chars; openClient requires an authenticated
|
||||
// transport, and #12746 admits pty.data only after a consumer grant.
|
||||
function writeEndpointCredential(path) {
|
||||
return writeFile(path, randomBytes(32).toString('base64url'), 'utf8')
|
||||
}
|
||||
|
||||
function withTimeout(promise, label, stderr) {
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -68,14 +75,59 @@ async function loadProtocol(bundleDir) {
|
||||
return require(outfile)
|
||||
}
|
||||
|
||||
function attachProcessStreams(proc) {
|
||||
let stderr = ''
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
stderr = `${stderr}${String(chunk)}`.slice(-8_000)
|
||||
})
|
||||
return {
|
||||
stderr: () => stderr
|
||||
}
|
||||
}
|
||||
|
||||
function waitForStdoutSentinel(proc, protocol, stderr) {
|
||||
let stdoutBuffer = Buffer.alloc(0)
|
||||
return withTimeout(
|
||||
new Promise((resolvePromise, rejectPromise) => {
|
||||
let settled = false
|
||||
const onData = (chunk) => {
|
||||
stdoutBuffer = Buffer.concat([stdoutBuffer, chunk])
|
||||
const sentinel = Buffer.from(protocol.RELAY_SENTINEL)
|
||||
const index = stdoutBuffer.indexOf(sentinel)
|
||||
if (index < 0) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
proc.stdout.off('data', onData)
|
||||
proc.off('exit', onExit)
|
||||
resolvePromise(stdoutBuffer.subarray(index + sentinel.length))
|
||||
}
|
||||
const onExit = (code, signal) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
proc.stdout.off('data', onData)
|
||||
rejectPromise(
|
||||
new Error(`process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}`)
|
||||
)
|
||||
}
|
||||
proc.stdout.on('data', onData)
|
||||
proc.once('exit', onExit)
|
||||
}),
|
||||
'relay sentinel',
|
||||
stderr
|
||||
)
|
||||
}
|
||||
|
||||
function createRelayClient(entryPath, args, env, protocol) {
|
||||
const proc = spawn(process.execPath, [entryPath, ...args], {
|
||||
cwd: dirname(entryPath),
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
const streams = attachProcessStreams(proc)
|
||||
const messages = []
|
||||
let stderr = ''
|
||||
let nextSequence = 1
|
||||
let stdoutBuffer = Buffer.alloc(0)
|
||||
let ready = false
|
||||
@@ -88,9 +140,6 @@ function createRelayClient(entryPath, args, env, protocol) {
|
||||
messages.push(protocol.parseJsonRpcMessage(frame.payload))
|
||||
}
|
||||
})
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
stderr = `${stderr}${String(chunk)}`.slice(-8_000)
|
||||
})
|
||||
proc.stdout.on('data', (chunk) => {
|
||||
if (ready) {
|
||||
decoder.feed(chunk)
|
||||
@@ -111,11 +160,7 @@ function createRelayClient(entryPath, args, env, protocol) {
|
||||
})
|
||||
|
||||
const waitForMessage = (startIndex, predicate, label) =>
|
||||
pollUntil(
|
||||
() => messages.slice(startIndex).find(predicate),
|
||||
label,
|
||||
() => stderr
|
||||
)
|
||||
pollUntil(() => messages.slice(startIndex).find(predicate), label, streams.stderr)
|
||||
|
||||
const request = async (method, params = {}) => {
|
||||
const id = nextSequence++
|
||||
@@ -141,7 +186,7 @@ function createRelayClient(entryPath, args, env, protocol) {
|
||||
proc,
|
||||
request,
|
||||
notify,
|
||||
sentinelReceived: withTimeout(sentinelReceived, 'relay sentinel', () => stderr),
|
||||
sentinelReceived: withTimeout(sentinelReceived, 'relay sentinel', streams.stderr),
|
||||
messageCount: () => messages.length,
|
||||
waitForNotification: (startIndex, method, predicate = () => true) =>
|
||||
waitForMessage(
|
||||
@@ -149,7 +194,20 @@ function createRelayClient(entryPath, args, env, protocol) {
|
||||
(message) => message.method === method && predicate(message.params ?? {}),
|
||||
`${method} notification`
|
||||
),
|
||||
stderr: () => stderr
|
||||
stderr: streams.stderr
|
||||
}
|
||||
}
|
||||
|
||||
async function stopProcess(proc, stderr, label) {
|
||||
if (!proc || proc.exitCode !== null || proc.signalCode !== null) {
|
||||
return
|
||||
}
|
||||
proc.kill('SIGTERM')
|
||||
try {
|
||||
await withTimeout(waitForExit(proc), `${label} shutdown`, stderr)
|
||||
} catch {
|
||||
proc.kill('SIGKILL')
|
||||
await withTimeout(waitForExit(proc), `forced ${label} shutdown`, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,24 +242,64 @@ async function main() {
|
||||
}
|
||||
|
||||
let tempRoot
|
||||
let daemon
|
||||
let daemonStreams
|
||||
let relay
|
||||
try {
|
||||
tempRoot = await mkdtemp(join(tmpdir(), 'orca-relay-watcher-fault-'))
|
||||
const watchRoot = await realpath(tempRoot)
|
||||
const pidFile = join(tempRoot, 'watcher.pid')
|
||||
const credentialFile = join(tempRoot, 'endpoint.credential')
|
||||
const protocol = await loadProtocol(tempRoot)
|
||||
const socketPath =
|
||||
process.platform === 'win32'
|
||||
? `\\\\.\\pipe\\orca-relay-watcher-fault-${process.pid}-${Date.now()}`
|
||||
: join(tempRoot, 'relay.sock')
|
||||
await writeEndpointCredential(credentialFile)
|
||||
|
||||
// Why detached + --connect: the daemon primary stdio is unproved, so it cannot open a consumer
|
||||
// session; only an endpoint-credential socket client is admitted for pty.data after #12746.
|
||||
daemon = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
relayEntry,
|
||||
'--detached',
|
||||
'--grace-time',
|
||||
'0',
|
||||
'--sock-path',
|
||||
socketPath,
|
||||
'--endpoint-dir',
|
||||
join(tempRoot, 'agent-hooks'),
|
||||
'--credential-file',
|
||||
credentialFile
|
||||
],
|
||||
{
|
||||
cwd: dirname(relayEntry),
|
||||
env: { ...process.env, ORCA_WATCHER_CHILD_PID_FILE: pidFile },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}
|
||||
)
|
||||
daemonStreams = attachProcessStreams(daemon)
|
||||
await waitForStdoutSentinel(daemon, protocol, daemonStreams.stderr)
|
||||
|
||||
relay = createRelayClient(
|
||||
relayEntry,
|
||||
['--sock-path', socketPath, '--endpoint-dir', join(tempRoot, 'agent-hooks')],
|
||||
{ ...process.env, ORCA_WATCHER_CHILD_PID_FILE: pidFile },
|
||||
['--connect', '--sock-path', socketPath, '--credential-file', credentialFile],
|
||||
process.env,
|
||||
protocol
|
||||
)
|
||||
await relay.sentinelReceived
|
||||
|
||||
// Why no outputFlowControl: legacy owner grant admits plain pty.data without delivery tokens.
|
||||
const grant = await relay.request('pty.openClient', {
|
||||
protocolVersion: 1,
|
||||
clientInstanceId: `relay-watcher-fault-${process.pid}`,
|
||||
requestedRole: 'session-owner'
|
||||
})
|
||||
if (grant?.role !== 'session-owner') {
|
||||
throw new Error(`expected session-owner grant, got ${JSON.stringify(grant)}`)
|
||||
}
|
||||
|
||||
const spawned = await relay.request('pty.spawn', { cols: 80, rows: 24, cwd: watchRoot })
|
||||
const beforePtyMarker = `ORCA_PTY_BEFORE_${Date.now()}`
|
||||
let startIndex = relay.messageCount()
|
||||
@@ -213,7 +311,11 @@ async function main() {
|
||||
)
|
||||
|
||||
await relay.request('fs.watch', { rootPath: watchRoot })
|
||||
const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, relay.stderr)
|
||||
const firstWatcherPid = await waitForWatcherPid(
|
||||
pidFile,
|
||||
undefined,
|
||||
() => `${daemonStreams.stderr()}\n${relay.stderr()}`
|
||||
)
|
||||
const beforePath = join(watchRoot, 'before.txt')
|
||||
startIndex = relay.messageCount()
|
||||
await writeFile(beforePath, 'before')
|
||||
@@ -224,7 +326,11 @@ async function main() {
|
||||
const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV'
|
||||
startIndex = relay.messageCount()
|
||||
process.kill(firstWatcherPid, faultSignal)
|
||||
const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, relay.stderr)
|
||||
const replacementWatcherPid = await waitForWatcherPid(
|
||||
pidFile,
|
||||
firstWatcherPid,
|
||||
() => `${daemonStreams.stderr()}\n${relay.stderr()}`
|
||||
)
|
||||
await relay.waitForNotification(startIndex, 'fs.changed', (params) =>
|
||||
Array.isArray(params.events)
|
||||
? params.events.some(
|
||||
@@ -234,7 +340,7 @@ async function main() {
|
||||
)
|
||||
|
||||
const status = await relay.request('relay.status')
|
||||
if (status.pid !== relay.proc.pid) {
|
||||
if (status.pid !== daemon.pid) {
|
||||
throw new Error('relay.status did not come from the original surviving relay process')
|
||||
}
|
||||
const afterPtyMarker = `ORCA_PTY_AFTER_${Date.now()}`
|
||||
@@ -257,7 +363,7 @@ async function main() {
|
||||
await relay.request('pty.shutdown', { id: spawned.id })
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
relayPid: relay.proc.pid,
|
||||
relayPid: daemon.pid,
|
||||
killedWatcherPid: firstWatcherPid,
|
||||
replacementWatcherPid,
|
||||
faultSignal,
|
||||
@@ -268,15 +374,8 @@ async function main() {
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
if (relay && relay.proc.exitCode === null && relay.proc.signalCode === null) {
|
||||
relay.proc.kill('SIGTERM')
|
||||
try {
|
||||
await withTimeout(waitForExit(relay.proc), 'relay shutdown', relay.stderr)
|
||||
} catch {
|
||||
relay.proc.kill('SIGKILL')
|
||||
await withTimeout(waitForExit(relay.proc), 'forced relay shutdown', relay.stderr)
|
||||
}
|
||||
}
|
||||
await stopProcess(relay?.proc, relay?.stderr ?? (() => ''), 'connect bridge')
|
||||
await stopProcess(daemon, daemonStreams?.stderr ?? (() => ''), 'relay daemon')
|
||||
if (tempRoot) {
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const RELEASE_NAME_TIME_ZONE = 'America/Los_Angeles'
|
||||
|
||||
/**
|
||||
* `07-31 13:54` — the timestamp segment of a dev build's release title, shown
|
||||
* `Jul 31, 8:10PM` — the timestamp segment of a dev build's release title, shown
|
||||
* verbatim in both the GitHub releases list and the in-app build picker.
|
||||
*
|
||||
* Why Pacific while the tag's own stamp stays UTC: that stamp is a sort key, and
|
||||
@@ -16,15 +16,17 @@ export function formatReleaseTitleTimestamp(date) {
|
||||
const parts = Object.fromEntries(
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: RELEASE_NAME_TIME_ZONE,
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
// Why h23 rather than hour12: false: some ICU builds render midnight as 24.
|
||||
hourCycle: 'h23'
|
||||
hour12: true
|
||||
})
|
||||
.formatToParts(date)
|
||||
.map((part) => [part.type, part.value])
|
||||
)
|
||||
return `${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`
|
||||
// Assembled from parts rather than by string-editing the formatted output:
|
||||
// recent ICU separates the time from AM/PM with U+202F, not a plain space, so
|
||||
// a naive replace(' ', '') leaves the gap on some runtimes and not others.
|
||||
return `${parts.month} ${parts.day}, ${parts.hour}:${parts.minute}${parts.dayPeriod.toUpperCase()}`
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ const RESTRICTED_ENV_KEYS = [
|
||||
'HOMEPATH',
|
||||
'CODEX_HOME',
|
||||
'ORCA_CODEX_HOME',
|
||||
'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME',
|
||||
'ORCA_E2E_HOME_DIR',
|
||||
'ORCA_E2E_USER_DATA_DIR',
|
||||
'ORCA_USER_DATA_PATH',
|
||||
@@ -69,7 +68,7 @@ async function resolveRealPath(candidate) {
|
||||
}
|
||||
}
|
||||
|
||||
export function createValidationEnv(inheritedEnv, layout, options = {}) {
|
||||
export function createValidationEnv(inheritedEnv, layout) {
|
||||
const env = { ...inheritedEnv }
|
||||
for (const key of RESTRICTED_ENV_KEYS) {
|
||||
delete env[key]
|
||||
@@ -81,12 +80,7 @@ export function createValidationEnv(inheritedEnv, layout, options = {}) {
|
||||
NODE_ENV: 'development',
|
||||
ORCA_E2E_HOME_DIR: layout.homeDir,
|
||||
ORCA_E2E_USER_DATA_DIR: layout.userDataDir,
|
||||
ORCA_USER_DATA_PATH: layout.userDataDir,
|
||||
// Why: flag OFF pins every codex spawn to an explicit managed CODEX_HOME,
|
||||
// so native codex never resolves the OS profile — the only Windows
|
||||
// configuration where strict zero-event containment is reachable. It also
|
||||
// exercises the emergency kill-switch lane users fall back to.
|
||||
ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: options.systemDefaultRealHome === 'off' ? '0' : '1'
|
||||
ORCA_USER_DATA_PATH: layout.userDataDir
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,8 +262,7 @@ function parseArgs(argv) {
|
||||
primaryHome: os.homedir(),
|
||||
configTemplate: null,
|
||||
tempParent: null,
|
||||
laneAwareContainment: false,
|
||||
systemDefaultRealHome: 'on'
|
||||
laneAwareContainment: false
|
||||
}
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
@@ -299,14 +292,8 @@ function parseArgs(argv) {
|
||||
options.skipBuild = true
|
||||
} else if (arg === '--keep') {
|
||||
options.keep = true
|
||||
} else if (arg === '--system-default-real-home') {
|
||||
const value = readValue()
|
||||
if (value !== 'on' && value !== 'off') {
|
||||
throw new Error('--system-default-real-home must be "on" or "off"')
|
||||
}
|
||||
options.systemDefaultRealHome = value
|
||||
} else if (arg === '--lane-aware-containment') {
|
||||
// Why: on Windows the flag-ON system-default lane cannot be env-sandboxed
|
||||
// Why: on Windows the system-default real-home lane cannot be env-sandboxed
|
||||
// (native codex ignores USERPROFILE), so strict zero-event containment is
|
||||
// structurally unreachable there. This mode records codex's designed
|
||||
// volatile churn without aborting while every other real-home write stays
|
||||
@@ -314,7 +301,7 @@ function parseArgs(argv) {
|
||||
options.laneAwareContainment = true
|
||||
} else if (arg === '--help') {
|
||||
console.log(
|
||||
'Usage: node config/scripts/run-codex-real-account-validation.mjs [--scenario mixed|managed-only|codex-lb] [--config-template <path>] [--temp-parent <dir>] [--skip-build] [--dry-run] [--close-after-launch] [--keep] [--lane-aware-containment] [--system-default-real-home on|off] [--report <path>]'
|
||||
'Usage: node config/scripts/run-codex-real-account-validation.mjs [--scenario mixed|managed-only|codex-lb] [--config-template <path>] [--temp-parent <dir>] [--skip-build] [--dry-run] [--close-after-launch] [--keep] [--lane-aware-containment] [--report <path>]'
|
||||
)
|
||||
process.exit(0)
|
||||
} else {
|
||||
@@ -480,7 +467,7 @@ async function main() {
|
||||
const reportPath =
|
||||
options.reportPath ??
|
||||
path.join(os.tmpdir(), `orca-codex-real-account-${options.scenario}-${Date.now()}.json`)
|
||||
const launchEnv = createValidationEnv(process.env, layout, options)
|
||||
const launchEnv = createValidationEnv(process.env, layout)
|
||||
let app = null
|
||||
let tripwire = null
|
||||
const abortController = new AbortController()
|
||||
|
||||
@@ -46,29 +46,9 @@ describe('Codex real-account validation harness', () => {
|
||||
expect(env.CODEX_HOME).toBeUndefined()
|
||||
expect(env.ORCA_CODEX_HOME).toBeUndefined()
|
||||
expect(env.ZDOTDIR).toBeUndefined()
|
||||
expect(env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME).toBe('1')
|
||||
expect(env.SAFE_VALUE).toBe('preserved')
|
||||
})
|
||||
|
||||
it('pins the real-home flag off when the system-default lane is disabled', async () => {
|
||||
const primaryHome = path.join(os.tmpdir(), 'orca-primary-home-sentinel')
|
||||
const { layout, env } = runValidationModule<{
|
||||
layout: { tempRoot: string }
|
||||
env: Record<string, string | undefined>
|
||||
}>(
|
||||
`
|
||||
const { createValidationEnv, createValidationLayout } = await import(process.argv[1])
|
||||
const layout = await createValidationLayout({ primaryHome: process.argv[2] })
|
||||
const env = createValidationEnv({}, layout, { systemDefaultRealHome: 'off' })
|
||||
console.log(JSON.stringify({ layout, env }))
|
||||
`,
|
||||
[primaryHome]
|
||||
)
|
||||
cleanupPaths.push(layout.tempRoot)
|
||||
|
||||
expect(env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME).toBe('0')
|
||||
})
|
||||
|
||||
it('records only fingerprints for system-default and managed auth', async () => {
|
||||
const { layout, snapshot } = runValidationModule<{
|
||||
layout: { tempRoot: string }
|
||||
|
||||
@@ -483,7 +483,6 @@ async function main() {
|
||||
HOME: isolatedHome,
|
||||
USERPROFILE: isolatedHome,
|
||||
ORCA_E2E_HOME_DIR: isolatedHome,
|
||||
ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0',
|
||||
...(options.headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const extraArgs = process.argv.slice(2)
|
||||
const pnpmEntry = process.env.npm_execpath
|
||||
if (!pnpmEntry) {
|
||||
throw new Error('npm_execpath is required; run this harness through pnpm')
|
||||
}
|
||||
const env = {
|
||||
...process.env,
|
||||
ORCA_E2E_SSH_DOCKER: '1'
|
||||
}
|
||||
|
||||
const runtime = spawnSync(process.execPath, [pnpmEntry, 'run', 'ensure:electron-runtime'], {
|
||||
stdio: 'inherit',
|
||||
env
|
||||
})
|
||||
|
||||
if (runtime.status !== 0) {
|
||||
process.exit(runtime.status ?? 1)
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
pnpmEntry,
|
||||
'exec',
|
||||
'playwright',
|
||||
'test',
|
||||
'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--workers=1',
|
||||
...extraArgs
|
||||
],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
env
|
||||
}
|
||||
)
|
||||
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -55,7 +55,6 @@ Object.assign(childEnv, {
|
||||
ORCA_DEV_USER_DATA_PATH: profileDir,
|
||||
HOME: isolatedHome,
|
||||
USERPROFILE: isolatedHome,
|
||||
ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0',
|
||||
...(process.platform === 'linux'
|
||||
? { ELECTRON_DISABLE_SANDBOX: process.env.ELECTRON_DISABLE_SANDBOX ?? '1' }
|
||||
: {})
|
||||
|
||||
@@ -39,14 +39,23 @@ else
|
||||
# Why the features are off: this repo holds releases and nothing else. Leaving
|
||||
# issues open invites bug reports against a branch build in a repo nobody
|
||||
# watches, where they are simply lost.
|
||||
#
|
||||
# Why --add-readme in a repo with no source: publishing a release creates a tag,
|
||||
# and a tag needs a commit. Empty repo = "Repository is empty" 25 minutes in.
|
||||
gh repo create "$ADHOC_REPO" \
|
||||
--public \
|
||||
--description "Adhoc macOS dev builds of Orca, cut from unlanded branches. Not a source repo." \
|
||||
--add-readme \
|
||||
--disable-issues \
|
||||
--disable-wiki ||
|
||||
fail "Could not create $ADHOC_REPO."
|
||||
fi
|
||||
|
||||
# Also checked outside the create branch: a repo made before --add-readme is here.
|
||||
if ! gh api "repos/$ADHOC_REPO/commits" --jq 'length' >/dev/null 2>&1; then
|
||||
fail "$ADHOC_REPO has no commits — releases cannot be tagged. Add any file to it first."
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Granting $APP_SLUG access to $ADHOC_REPO..."
|
||||
|
||||
|
||||
@@ -219,7 +219,6 @@ async function runValidation(mode) {
|
||||
ORCA_DEV_USER_DATA_PATH: userDataPath,
|
||||
HOME: isolatedHome,
|
||||
USERPROFILE: isolatedHome,
|
||||
ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0',
|
||||
ELECTRON_ENABLE_LOGGING: '1',
|
||||
ELECTRON_ENABLE_STACK_DUMPING: '1',
|
||||
ELECTRON_OZONE_PLATFORM_HINT: 'wayland',
|
||||
|
||||
@@ -6,6 +6,9 @@ import process from 'node:process'
|
||||
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
|
||||
import ts from 'typescript-api'
|
||||
|
||||
import { canonicalGenericRenderings } from './locale-generic-ui-terms.mjs'
|
||||
import { repairTranslatedValue } from './locale-translation-policy.mjs'
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
|
||||
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
|
||||
const LOCALIZATION_FUNCTION_NAMES = new Set(['t', 'translate', 'translateMain'])
|
||||
@@ -282,9 +285,51 @@ function applyMissingEnglishEntries(catalog, missing) {
|
||||
return changed
|
||||
}
|
||||
|
||||
// Why: #12113 — parity checks pass while repair-locale-catalog rewrites translated generic terms
|
||||
// back to English, so drift only surfaces when someone regenerates the catalog.
|
||||
export function collectGenericTermRegressions(enEntries, localeEntries, localeName) {
|
||||
const renderings = canonicalGenericRenderings(localeName)
|
||||
if (renderings.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const regressions = []
|
||||
for (const [key, enValue] of enEntries) {
|
||||
const localeValue = localeEntries.get(key)
|
||||
if (typeof enValue !== 'string' || typeof localeValue !== 'string') {
|
||||
continue
|
||||
}
|
||||
const repaired = repairTranslatedValue({ key, enValue, localeValue, locale: localeName })
|
||||
if (repaired === localeValue) {
|
||||
continue
|
||||
}
|
||||
// Why: {{agent}} is an interpolation name, not English copy the reader sees.
|
||||
const repairedCopy = repaired.replace(PLACEHOLDER_RE, '')
|
||||
for (const { form, terms } of renderings) {
|
||||
if (!localeValue.includes(form) || repaired.includes(form)) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
terms.some((term) => new RegExp(`(^|[^A-Za-z])${term}($|[^A-Za-z])`).test(repairedCopy))
|
||||
) {
|
||||
regressions.push({ key, form, localeValue, repaired })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return regressions
|
||||
}
|
||||
|
||||
function formatGenericTermRegressions(regressions) {
|
||||
return regressions
|
||||
.map((entry) => `${entry.key}: ${entry.form} -> English (${entry.repaired})`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) {
|
||||
const { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
|
||||
collectLocaleParityIssues(enCatalog, localeCatalog)
|
||||
const genericTermRegressions = collectGenericTermRegressions(enEntries, localeEntries, localeName)
|
||||
|
||||
// Why: feature PRs own English declarations; absent target leaves deliberately
|
||||
// use i18next's existing English fallback until a localization PR supplies them.
|
||||
@@ -292,7 +337,11 @@ function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) {
|
||||
`${localeName}.json coverage: ${enEntries.size - missingInLocale.length}/${enEntries.size} translated, ${missingInLocale.length} missing.`
|
||||
)
|
||||
|
||||
if (extraInLocale.length > 0 || interpolationMismatches.length > 0) {
|
||||
if (
|
||||
extraInLocale.length > 0 ||
|
||||
interpolationMismatches.length > 0 ||
|
||||
genericTermRegressions.length > 0
|
||||
) {
|
||||
console.error(`Locale catalog validation failed for ${localeName}.json.`)
|
||||
if (extraInLocale.length > 0) {
|
||||
console.error('')
|
||||
@@ -310,6 +359,18 @@ function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) {
|
||||
console.error(`...and ${interpolationMismatches.length - 20} more interpolation mismatches`)
|
||||
}
|
||||
}
|
||||
if (genericTermRegressions.length > 0) {
|
||||
console.error('')
|
||||
console.error(
|
||||
'repair-locale-catalog would rewrite these translated terms back to English.',
|
||||
'Treat the term as generic in config/scripts/locale-generic-ui-terms.mjs',
|
||||
'instead of listing its translation as a mistranslation.'
|
||||
)
|
||||
console.error(formatGenericTermRegressions(genericTermRegressions.slice(0, 20)))
|
||||
if (genericTermRegressions.length > 20) {
|
||||
console.error(`...and ${genericTermRegressions.length - 20} more generic term regressions`)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ import path from 'node:path'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { main as verifyLocalizationCatalog } from './verify-localization-catalog.mjs'
|
||||
import {
|
||||
collectGenericTermRegressions,
|
||||
main as verifyLocalizationCatalog
|
||||
} from './verify-localization-catalog.mjs'
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
||||
@@ -123,4 +126,47 @@ describe('verify-localization-catalog', () => {
|
||||
report.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
// Why: #12113 — parity checks passed while the repair policy rewrote translated terms to English.
|
||||
it('flags catalog values the repair policy would rewrite back to English', () => {
|
||||
const enEntries = new Map([['auto.example.commitLabel', 'Commit message']])
|
||||
|
||||
expect(
|
||||
collectGenericTermRegressions(
|
||||
enEntries,
|
||||
new Map([['auto.example.commitLabel', 'mensaje de confirmación']]),
|
||||
'es'
|
||||
)
|
||||
).toEqual([])
|
||||
|
||||
// A locale whose committed value is the English term is stable, not a regression.
|
||||
expect(
|
||||
collectGenericTermRegressions(
|
||||
enEntries,
|
||||
new Map([['auto.example.commitLabel', 'mensaje de Commit']]),
|
||||
'es'
|
||||
)
|
||||
).toEqual([])
|
||||
|
||||
// 'Comprometerse' is a real mistranslation, so reverting it to Latin is expected.
|
||||
expect(
|
||||
collectGenericTermRegressions(
|
||||
enEntries,
|
||||
new Map([['auto.example.commitLabel', 'mensaje de Comprometerse']]),
|
||||
'es'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores interpolation names when looking for English rewrites', () => {
|
||||
expect(
|
||||
collectGenericTermRegressions(
|
||||
new Map([
|
||||
['components.agentSessionContinuation.originalAgent', 'Original agent: {{agent}}']
|
||||
]),
|
||||
new Map([['components.agentSessionContinuation.originalAgent', '原智能体:{{agent}}']]),
|
||||
'zh'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,7 +63,6 @@ export function launchDevApp({ cdpPort, userDataDir }) {
|
||||
ORCA_DEV_USER_DATA_PATH: userDataDir,
|
||||
HOME: isolatedHome,
|
||||
USERPROFILE: isolatedHome,
|
||||
ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0',
|
||||
ORCA_SKIP_DEV_WEB_PREPARE: '1',
|
||||
ORCA_STARTUP_DIAGNOSTICS: '1',
|
||||
REMOTE_DEBUGGING_PORT: String(cdpPort),
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"../src/main/droid/hook-service.ts",
|
||||
"../src/main/gemini/hook-service.ts",
|
||||
"../src/main/grok/hook-service.ts",
|
||||
"../src/main/grok/windows-grok-hook-script.ts",
|
||||
"../src/main/devin/hook-settings.ts",
|
||||
"../src/main/devin/hook-service.ts",
|
||||
"../src/main/devin/hook-config-json.ts",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
"include": [
|
||||
"../electron.vite.config.*",
|
||||
"../build-plugins/**/*",
|
||||
"./build-plugins/**/*",
|
||||
"../src/main/**/*",
|
||||
"../src/renderer/src/lib/skill-freshness-display-status.ts",
|
||||
"../src/preload/**/*",
|
||||
|
||||
@@ -14,7 +14,12 @@
|
||||
"../src/main/ipc/worktree-path-comparison.ts",
|
||||
"../src/main/wsl-distro-list-output.ts",
|
||||
"../src/main/wsl-distro-retry.ts",
|
||||
"../src/main/wsl.ts"
|
||||
"../src/main/wsl.ts",
|
||||
"../src/main/startup/serve-desktop-activation.ts",
|
||||
"../src/main/startup/single-instance-lock.ts",
|
||||
"../src/main/startup/startup-diagnostics.ts",
|
||||
"../src/main/window/focus-existing-window.ts",
|
||||
"../src/main/window/macos-app-activation.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
|
||||
@@ -17,6 +17,8 @@ export default defineConfig({
|
||||
environment: 'node',
|
||||
// Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's.
|
||||
execArgv: ['--no-experimental-webstorage'],
|
||||
// Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does.
|
||||
setupFiles: [resolve('config/scripts/happy-dom-mutation-observer-retention.ts')],
|
||||
include: [
|
||||
'src/**/*.test.ts',
|
||||
'src/**/*.test.tsx',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 13m">
|
||||
<title>downloads: 13m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 15m">
|
||||
<title>downloads: 15m</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">13m</text>
|
||||
<text x="90" y="14">13m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">15m</text>
|
||||
<text x="90" y="14">15m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
Binary file not shown.
|
Before Width: | Height: | Size: 380 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 371 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 399 KiB |
@@ -0,0 +1,442 @@
|
||||
# Mobile Relay UX — Investigation Findings & Fix Plan
|
||||
|
||||
Scope: phone-side presentation/state-machine issues behind three reported symptoms on Android over
|
||||
the cloud relay. The relay protocol and server-side assignment are healthy; nothing here changes
|
||||
desktop or relay-server code. All file references are in `mobile/` of this worktree.
|
||||
|
||||
## 1. Symptom → root-cause summary
|
||||
|
||||
| # | Symptom | Root cause (verified) |
|
||||
|---|---------|----------------------|
|
||||
| S1 | Resume lands on an empty "Host" page, grey dot | Bare cross-stack `router.push` into a cold nested host navigator resolves to the host index route **without the `hostId` param**; every screen below then runs with `hostId: undefined` |
|
||||
| S2 | Tapping a healthy relay host shows grey 1–2s before green | Every screen focus funnels into the network-handoff recovery path, which **suspends the healthy relay session** (publishes `disconnected`) and re-dials; the re-dial is invisible because `migrateTo` binds new-session state only after authentication |
|
||||
| S3 | Relay-forced pairing looks dead ~5–10s | The pairing relay path has **no log sink** (only direct-path entries reach the "Pairing log"), and post-pairing the app dials the unreachable LAN endpoint for up to 12s before relay recovery is even eligible |
|
||||
|
||||
## 2. Verified end-to-end causal chains
|
||||
|
||||
### S1 — Resume dead-ends on the host index page
|
||||
|
||||
1. Home renders the Resume card only once `hostStates[lastVisited.hostId] === 'connected'`
|
||||
(`app/index.tsx:488`); over relay that is seconds after the host list paints, and the card
|
||||
inserts **above** the Tasks card in the same footer (`app/index.tsx:733-780`) — a layout shift
|
||||
under the thumb.
|
||||
2. Tap → bare `router.push(createMobileSessionHref(...))` (`app/index.tsx:740-746`) targeting
|
||||
`/h/[hostId]/session/[worktreeId]`.
|
||||
3. With the `h` group cold (cold start, or host never visited this session), Expo Router resolves
|
||||
the push to the host stack's **index route with no `hostId` param**. This exact failure mode is
|
||||
documented twice in-repo ("cold Expo deep links resolve to index" —
|
||||
`src/transport/host-edit-navigation.ts:52`, `src/tasks/mobile-task-navigation.ts:90`) and is the
|
||||
root cause named by PR #12001.
|
||||
4. `app/h/_layout.tsx:64` reads `hostId` via `useGlobalSearchParams` → `undefined`.
|
||||
`HostProtocolGate` gets `hostId: undefined`; `useHostClient(undefined)` returns
|
||||
`state: 'disconnected'` (`src/transport/client-context.tsx:344`) → **grey dot**.
|
||||
5. The host index screen renders the fallback title `'Host'` (`app/h/[hostId]/index.tsx:821`), and
|
||||
every fetch no-ops on `!client || connState !== 'connected'`
|
||||
(`app/h/[hostId]/index.tsx:298,364,418,520`) → **empty list**. The Filter/Recent/Repo chips are
|
||||
static toolbar UI, so the page looks "real" but dead.
|
||||
6. "Sometimes": a warm host stack resolves the same push correctly, so the bug is intermittent by
|
||||
navigation history.
|
||||
|
||||
Corrections to the preliminary sweep: the empty page is primarily the missing `hostId` param, not
|
||||
the connection-gated fetches or the cold 30s worktree cache (those matter only when landing *with*
|
||||
a valid `hostId`, e.g. the mistap-strand case). Also, the Resume target "validation" is weaker than
|
||||
it looks: `getCachedWorktrees` is seeded from the persisted home snapshot at hydration
|
||||
(`app/index.tsx:264-277`) and the 30s TTL is stamped at seed time (`src/cache/worktree-cache.ts:20`),
|
||||
so a worktree deleted while the phone was off still passes until a live `worktree.ps` overwrites it.
|
||||
|
||||
**Fix**: PR #12001 ("open the Resume workspace through a mounted host stack") routes Resume through
|
||||
the same mount-then-replace mechanism Tasks uses, extracted to `src/navigation/host-stack-navigation.ts`.
|
||||
Reviewed and validated per Jinwoo; **merged to main as `7948e46db855`** after final validation
|
||||
(see §4, F0). Residual S1 items it does not cover: bare notification/accounts/deep-link pushes (F4),
|
||||
catalog validation + not-found bounce (F7), Resume-card layout shift (F8), gate unmount hazard (F9).
|
||||
|
||||
### S2 — grey blink when focusing a healthy relay host
|
||||
|
||||
1. Every focus of the host screen fires `notifyForeground()`
|
||||
(`app/h/[hostId]/index.tsx:512-517`, deliberately empty deps).
|
||||
2. `openHostLogicalClient` wraps that into `endpointLifecycle.setForeground(true)`
|
||||
(`src/transport/host-logical-client.ts:31-33`); the lifecycle forwards without dedupe
|
||||
(`src/transport/mobile-endpoint-lifecycle.ts:62-64`).
|
||||
3. `MobileEndpointSupervisor.setForeground(true)` computes `wasForeground = true` and calls
|
||||
`RelayReconnectController.handleForeground` (`src/transport/mobile-endpoint-supervisor.ts:115-119`).
|
||||
4. `handleForeground` with `wasForeground && state === 'connected'` **suspends the healthy session**
|
||||
(`src/transport/mobile-relay-reconnect-controller.ts:53-60`). `suspendActiveRelay` early-returns
|
||||
unless the active path is `'relay'` (`:77-84`) — which is why LAN hosts never blink.
|
||||
5. `suspendActiveSession` closes the physical session, disposes all subscriptions, and publishes
|
||||
`'disconnected'` (`src/transport/stable-logical-rpc-client.ts:164-180`) → grey dot
|
||||
(`src/components/StatusDot.tsx:11`), worktree queries blocked.
|
||||
6. `onRetry()` → `recoverRelay()` → `openRelay` + `migrateTo`. During the dial the logical state
|
||||
**stays 'disconnected'**: `migrateTo` only binds the new session's state after
|
||||
`waitForAuthenticated` resolves (`src/transport/stable-logical-rpc-client.ts:188,213`), and the
|
||||
dialing session's own `connecting`/`handshaking` publishes (`src/transport/mobile-relay-rpc-session.ts:43,74`)
|
||||
fire with no listeners attached. Grey persists the full 1–2s (happy path has no artificial
|
||||
delays; any failure adds ≥250ms full-jitter backoff, `src/transport/mobile-relay-retry-delays.ts:3-5`).
|
||||
7. `migrateTo` completes → `'connected'` → green; subscriptions replay; gated fetches rerun.
|
||||
|
||||
Second trigger for the same path: OS network-revival nudges call `notifyForeground()` on every live
|
||||
client (`src/transport/client-context.tsx:286-292`, `src/transport/connection-revival-triggers.ts`)
|
||||
— any Wi-Fi↔cellular transition or came-online event grey-blinks every connected relay host.
|
||||
|
||||
Design context: the suspend-on-repeat-foreground is pinned by the supervisor test as the
|
||||
network-handoff half-open case (`src/transport/mobile-endpoint-supervisor.test.ts:~160-197`). The
|
||||
asymmetry is that **direct sockets probe instead of tearing down** — `notifyForeground` on a
|
||||
connected direct client runs an activity probe that detects a half-open socket in ≤8s
|
||||
(`src/transport/rpc-client.ts:1119-1124`) — while relay sessions have a no-op `notifyForeground`
|
||||
(`src/transport/mobile-relay-rpc-session.ts:106`) and the supervisor's only tool is
|
||||
suspend-then-redial. There is also an in-repo make-before-break precedent: lease rotation calls
|
||||
`recoverRelay(forceReplacement = true)` and migrates a **live** session with zero visible blink
|
||||
(`src/transport/mobile-endpoint-supervisor.ts:56-59,146,249`).
|
||||
|
||||
Divergent mount defaults (secondary): home renders `hostStates[id] ?? 'connecting'` (amber,
|
||||
`app/index.tsx:707`) while `getState()`/`useHostClient` return `'disconnected'` (grey) for a
|
||||
missing store entry (`src/transport/client-context.tsx:221,344`) — so host screens flash grey
|
||||
during the async client acquire (Keychain read) that home never shows.
|
||||
|
||||
### S3 — silent 5–10s relay-forced pairing
|
||||
|
||||
Pairing phase:
|
||||
|
||||
1. `pair-confirm.tsx` / `pair-scan.tsx` pass `connectOptions.onLog` into `startPreProfilePairing`
|
||||
(`app/pair-confirm.tsx:91-99`, `app/pair-scan.tsx:135-143`).
|
||||
2. The coordinator threads it **only to the direct candidate**
|
||||
(`src/transport/pre-profile-pairing-coordinator.ts:152-157`). The relay candidate
|
||||
(`:161-187`) gets nothing: `connectMobileRelayForPairing` has no log parameter at all
|
||||
(`src/transport/mobile-relay-physical-client.ts:22-30`), nor do the director resolution,
|
||||
journal writes, or the recovery loop in `src/transport/pairing-relay-candidate.ts`.
|
||||
3. With LAN unreachable, the visible "Pairing log" shows only the direct dial stalling toward its
|
||||
12s connect timeout while the relay path does the real work silently: cell WebSocket + E2EE
|
||||
handshake + `pairing.provisionRelay` + `pairing.getEndpoints` + credential-bundle write
|
||||
(`pre-profile-pairing-coordinator.ts:206-233`). The error copy even says "see log below for
|
||||
where it stalled" (`app/pair-confirm.tsx:138`) — the log cannot show it.
|
||||
4. Un-logged waits in the relay recovery loop: each of up to 3 attempts wraps a 5s director
|
||||
resolution (`src/transport/mobile-relay-invite-director.ts:16`) plus full-jitter sleeps capped
|
||||
at 100/200/400ms (`src/transport/pairing-relay-candidate.ts:58-59,70-71`) — worst case ~15s of
|
||||
silence. (Correction: the preliminary "~3×2s of backoff" was wrong; the sleeps are small, the
|
||||
director resolves dominate.) The relay E2EE layer itself has **no timers**: a pairing relay
|
||||
request is unbounded except the screen's 25s cap (`app/pair-confirm.tsx:27`).
|
||||
5. Pairing logs also never reach `connectionLogStore` (single producer:
|
||||
`src/transport/client-context.tsx:132`), so the Connection Log screen shows nothing about a
|
||||
pairing that just failed.
|
||||
|
||||
Post-pairing phase:
|
||||
|
||||
6. `pair-confirm` calls `closeHost(hostId)` then replaces to `/h/<id>`
|
||||
(`app/pair-confirm.tsx:118-123`).
|
||||
7. The destination re-acquires a client asynchronously (grey `'disconnected'` default during the
|
||||
Keychain read, `src/transport/client-context.tsx:221`), then dials the **LAN endpoint first**
|
||||
(`src/transport/host-logical-client.ts:12`) — amber for up to `CONNECT_TIMEOUT_MS = 12s`
|
||||
(`src/transport/rpc-client.ts:126`) on a black-holed LAN.
|
||||
8. Relay recovery cannot start earlier: `needsRecovery` treats `connecting`/`handshaking` as live
|
||||
progress (`src/transport/mobile-relay-reconnect-controller.ts:73-75`), checked at supervisor
|
||||
start and on every retry (`src/transport/mobile-endpoint-supervisor.ts:106,146`).
|
||||
9. When the direct dial finally fails, the relay dial runs invisibly (same `migrateTo` mechanism
|
||||
as S2) → green. Worst case with a director resolution failure and grace-credential retry:
|
||||
~29–58s under the old session's labels.
|
||||
10. The "Orca Relay" path label only renders once `state === 'connected'`
|
||||
(`src/components/MobileHostCard.tsx:23,47`) — the user learns the phone is using relay only
|
||||
after the wait ends, and `classifyConnection` has no relay-aware branch
|
||||
(`src/transport/connection-health.ts:45-100`).
|
||||
|
||||
## 3. Anti-pattern sweep
|
||||
|
||||
### (a) Uncoordinated deep pushes into `/h` from outside the host stack
|
||||
|
||||
Coordinated today (mount-then-replace): host edit (`src/transport/host-edit-navigation.ts`) and
|
||||
Tasks (`src/tasks/mobile-task-navigation.ts`). Note host-edit's predicate is weaker — it checks the
|
||||
root route only and can fire its `replace` while the nested stack is still gated/unmounted; Tasks
|
||||
proves the nested stack exists (`mountedHostStack`, `mobile-task-navigation.ts:53-70`).
|
||||
|
||||
Bare pushes remaining (host stack plausibly cold at each):
|
||||
|
||||
| Call site | Target | Cold scenario |
|
||||
|---|---|---|
|
||||
| `app/_layout.tsx:127` via `src/notifications/notification-routing.ts:58,64` | `/h/<id>/session/<wt>` or `/h/<id>` | **Coldest path** — `getLastNotificationResponse()` after launch from a killed app, plus the warm listener |
|
||||
| `app/index.tsx:740-746` (Resume) | `/h/[hostId]/session/[worktreeId]` | Fixed by PR #12001 |
|
||||
| `app/index.tsx:835` (Account-usage card) | `/h/<id>/accounts` | Home is the root route |
|
||||
| `orca://` deep links (scheme in `app.json:9`, no linking config) | any `/h/...` | Default filesystem linking, zero coordination; `app/_layout.tsx:52-58` only intercepts pairing codes |
|
||||
| `app/h/[hostId]/history/[worktreeId].tsx:15`, `pr/[worktreeId].tsx:16` | redirect to source-control | A cold deep link to these hits the same cold-navigator resolution first |
|
||||
|
||||
Shallow index-only pushes (`app/index.tsx:723,803`, onboarding/pair flows) don't need coordination.
|
||||
No `<Link>`, `navigationRef`, or `router.navigate` anywhere in `mobile/`.
|
||||
|
||||
Related hazard: `HostProtocolGate` **unmounts the mounted HostStack mid-connect** for a first-visit
|
||||
host — stack mounts while `connecting`, is replaced by a spinner when `status.get` goes in flight
|
||||
(`statusPending` true only when connected: `src/transport/host-status-gates.ts:111`), then remounts
|
||||
(`src/components/HostProtocolGate.tsx:34-44`). A deep navigation that resolved into the first mount
|
||||
can be destroyed by the gate cycle.
|
||||
|
||||
### (b) Surfaces that render grey 'disconnected' during expected transients
|
||||
|
||||
Store defaults: every read API on the canonical store defaults to `'disconnected'` for a missing
|
||||
entry — `getState` (`src/transport/client-context.tsx:221`), `useHostClient` seed/re-seed/unbound
|
||||
fallback (`:343-345,377,393`). Exactly one call site defaults to amber instead: the home screen's
|
||||
`hostStates[id] ?? 'connecting'` (`app/index.tsx:707,903`), whose reconciliation effect also
|
||||
refuses to write `'disconnected'` for a never-tracked host (`:378-394`) — home already solved
|
||||
locally what every other surface gets wrong.
|
||||
|
||||
The grey window is not one frame: `openEntry` awaits `loadHosts()` (a Keychain/SecureStore pass)
|
||||
**before** inserting the store entry (`client-context.tsx:87-161`, insert at `:153`), so a cold
|
||||
start or deep link into `/h/[hostId]` shows grey for the whole Keychain latency. The physical
|
||||
client is not the cause — it already reports `'connecting'` synchronously by the time `connect()`
|
||||
returns (`rpc-client.ts:302,967`). Additionally, `forceReconnect` deletes the entry then awaits the
|
||||
async reopen (`client-context.tsx:201-218`), so **every Retry button drives the UI grey before
|
||||
amber**.
|
||||
|
||||
Surfaces that show grey / "disconnected" copy for a healthy host during these transients (all via
|
||||
`useHostClient`): host header dot (`app/h/[hostId]/index.tsx:819`); host toolbar + FAB disabled
|
||||
(`:850-860,941-1000,1063-1078,1209`); the workspace list body renders **nothing at all** for
|
||||
`disconnected` — `selectHostWorkspaceListState` falls through to `null`, not even a spinner
|
||||
(`src/worktree/host-workspace-list-state.ts:17-24`); tasks header dot + "Connect to a host" empty
|
||||
state (`app/h/[hostId]/tasks.tsx:8681,8661-8663`); session dot (no `verdict` prop at all,
|
||||
`session/[worktreeId].tsx:4434`) and the literal "Disconnected" chip (`:4246-4255`); native-chat
|
||||
composer lock (`src/session/MobileNativeChatView.tsx:427-432`); source-control / git-history /
|
||||
diff-review / file-explorer / agent-history "Waiting for desktop…" states; the connection-log
|
||||
screen prints the raw enum (`app/connection-log.tsx:113-117`); home's Resume/Accounts/Tasks/Quick
|
||||
Action gates all read `=== 'connected'`; voice settings goes fully inert
|
||||
(`app/voice-settings.tsx:50-55`). Counter-examples that behave well: home host card, the accounts
|
||||
screen ("Connecting to {host}…" + cached snapshot, `app/h/[hostId]/accounts.tsx:366-370`).
|
||||
|
||||
Deliberate transients that publish `'disconnected'` while healthy work proceeds: relay suspend on
|
||||
focus/network nudges (S2); background suspend (`src/transport/mobile-endpoint-supervisor.ts:123` —
|
||||
correct per billing, but state stays grey through the entire foreground re-dial rather than
|
||||
flipping to `'connecting'`); post-migration cleanup (`:251-253`); `closeHost` during the
|
||||
pair-confirm handoff (`src/transport/client-context.tsx:83`); three open-failure paths
|
||||
(`:107,114,135`).
|
||||
|
||||
Destructive companion pattern — state flips don't just recolor, they **wipe loaded data**:
|
||||
`host-status-gates.ts:32,100-112` wipes cached host capabilities on every disconnect;
|
||||
`tasks.tsx:2774-2800` resets the whole screen's hydration and force-closes ~15 sheets;
|
||||
`session/[worktreeId].tsx:2043,2432-2439,3713-3716` clears diff comments/capability flags/agent
|
||||
lists; the PR sidebar hides entirely (`src/session/use-mobile-pr-branch-context.ts:59-66` →
|
||||
`use-mobile-pr-sidebar-controller.ts:113-118`); git history blanks rows on the **reconnect** branch
|
||||
(`src/source-control/MobileGitHistoryList.tsx:63-68`); the repo cache survives disconnect but is
|
||||
wiped by the rejected in-flight call (`NewWorktreeModal.tsx:330-333`); the worktree cache is read
|
||||
only at mount/hostId change (`app/h/[hostId]/index.tsx:129,330`), never on reconnect, so a >30s
|
||||
entry means an empty remount.
|
||||
|
||||
Same bug class in a second enum: `workspaceSshStatusLabel` defaults a `null` SSH status to
|
||||
"Disconnected" (`src/tasks/workspace-ssh-gate.ts:14-37`, rendered in `NewWorktreeModal.tsx:863`
|
||||
and `tasks.tsx:10970`).
|
||||
|
||||
### (c) Invisible relay establishment phases
|
||||
|
||||
- `connectMobileRelayRpcSession` (normal relay connects) has **no onLog** — the entire relay
|
||||
session lifecycle emits nothing (`src/transport/mobile-relay-rpc-session.ts:30-39`); the
|
||||
supervisor logs only coarse post-hoc lines (`mobile-endpoint-supervisor.ts:185-188,261`).
|
||||
- `migrateTo` structurally discards the dialing session's `connecting`/`handshaking` states
|
||||
(`src/transport/stable-logical-rpc-client.ts:182-223,267-299`).
|
||||
- Direct→relay upgrade path has no sink at all (`src/transport/mobile-endpoint-lifecycle.ts:49-58`,
|
||||
`mobile-relay-direct-upgrade-controller.ts:19`).
|
||||
- Pairing relay path fully silent (S3 above); pairing logs never reach `connectionLogStore`.
|
||||
- Path label ("Orca Relay") gated on `connected` (`src/components/MobileHostCard.tsx:47`);
|
||||
`classifyConnection` collapses `connecting`/`handshaking`/`reconnecting` and has no relay branch.
|
||||
- Regression suite for connect-label stalls exists for the direct path only
|
||||
(`src/transport/cellular-connecting-label-stall.test.ts`); no relay equivalent.
|
||||
|
||||
## 4. Fix plan
|
||||
|
||||
Ordered by felt-flakiness-removed per unit risk. All fixes are phone-local; none change the wire
|
||||
protocol, so every old/new phone × old/new desktop pairing keeps working unless noted.
|
||||
|
||||
### F0 (S1, quick win) — land PR #12001 ✅ MERGED
|
||||
|
||||
Squash-merged to main as `7948e46db855` (2026-08-04) after validation: CI fully green; drift check
|
||||
against current main clean (only overlap, #12575, touches different regions of `app/index.tsx` and
|
||||
auto-merges); full mobile suite (411 files, 3110 tests) passed on a local merge of main into the PR
|
||||
branch. The PR routes Resume through the shared mount-then-replace mechanism
|
||||
(`src/navigation/host-stack-navigation.ts`) and adds a source-guard test against reintroducing the
|
||||
bare push. This branch has since been fast-forwarded onto that merge, and F4 builds on the
|
||||
extracted module.
|
||||
Backward compat: navigation-only, none.
|
||||
Residuals tracked as F4/F7/F8/F9.
|
||||
|
||||
### F1 (S2, quick win) — stop suspending a healthy relay on focus ✅ IMPLEMENTED (this branch)
|
||||
|
||||
Approach: split the nudge reasons that today all funnel into `setForeground(true)`:
|
||||
|
||||
- Screen-focus nudge (`app/h/[hostId]/index.tsx:515`): must not suspend. For the relay path, either
|
||||
no-op (state changes already drive the UI) or run a cheap liveness probe (an RPC with a short
|
||||
budget) and only enter recovery on failure — mirroring the direct path's activity probe.
|
||||
- Network-change / app-resume nudges: keep half-open protection, but **verify by replacement**
|
||||
instead of break-before-make: call the existing `recoverRelay(forceReplacement = true)` path
|
||||
(proven by lease rotation) so `migrateTo` swaps sessions with the dot staying green; only if the
|
||||
replacement dial fails, fall back to `suspendActiveRelay` so a genuinely dead link stops lying
|
||||
green and the retry loop re-arms (plain `recoverRelay` early-returns while the stale state is
|
||||
still `'connected'`, so the fallback suspend is required for convergence).
|
||||
|
||||
Files: `src/transport/mobile-relay-reconnect-controller.ts` (`handleForeground`),
|
||||
`src/transport/mobile-endpoint-supervisor.ts` (thread a nudge reason; failure-path suspend),
|
||||
`src/transport/mobile-endpoint-lifecycle.ts`, `src/transport/host-logical-client.ts` (reason-tagged
|
||||
`notifyForeground`), optionally `src/transport/rpc-client.ts` type for the reason parameter.
|
||||
Risk: PEER_DROPPED/LIMIT_EXCEEDED churn if replacement dials overlap — reuse the existing
|
||||
`shouldDefer` cooldown; billed duplicate socket for the overlap window (lease rotation already
|
||||
accepts this). Half-open regression risk is covered by the fallback suspend.
|
||||
Tests: split `mobile-endpoint-supervisor.test.ts:~160-197` into (focus nudge → no suspend, dot
|
||||
stays green) and (network handoff → replacement dial; failure → suspend + cooldown). Keep the
|
||||
background-suspend test unchanged.
|
||||
|
||||
### F2 (S2/S3, quick win) — unify mount defaults to 'connecting' ✅ IMPLEMENTED (this branch)
|
||||
|
||||
Approach: `getState(hostId)` returns `'connecting'` when the host is known (primed profile or
|
||||
pending open) and no entry exists yet; `'disconnected'` only for unknown/closed hosts. Aligns every
|
||||
host screen with home's `?? 'connecting'`. Two companion changes in the same class:
|
||||
- `forceReconnect` should notify `'connecting'` (or insert a placeholder entry) instead of leaving
|
||||
the deleted-entry window grey (`src/transport/client-context.tsx:201-218`) — every Retry button
|
||||
currently drives the UI grey before amber.
|
||||
- Optionally have `openEntry` insert a `'connecting'` placeholder before the Keychain read so the
|
||||
cold-start gap (`client-context.tsx:87-153`) is amber too.
|
||||
|
||||
**Required interaction fix**: `app/h/[hostId]/index.tsx:738-741` falls back to
|
||||
`lastKnownWorktrees` only for `disconnected | reconnecting | auth-failed`; `connecting`/
|
||||
`handshaking` fall through to the live (empty on fresh mount) array. Flipping the default without
|
||||
extending that predicate would silently disable the stale-list fallback and blank the list —
|
||||
extend it to every not-connected state (or key it on "no live fetch has succeeded this mount").
|
||||
Files: `src/transport/client-context.tsx` (`getState`, `useHostClient`, `forceReconnect`,
|
||||
`openEntry`), `app/h/[hostId]/index.tsx` (fallback predicate),
|
||||
`src/worktree/host-workspace-list-state.ts` (render a spinner for the not-connected states instead
|
||||
of `null`).
|
||||
Risk: a permanently unreachable host now shows amber briefly before the verdict system escalates —
|
||||
acceptable; `classifyConnection` already owns escalation. Audit the §3(b) "wipe" sites for any that
|
||||
key on `'disconnected'` specifically.
|
||||
Tests: `client-context.test.ts` known-vs-unknown host defaults + forceReconnect state sequence;
|
||||
host screen test for the stale-list fallback under `'connecting'`.
|
||||
|
||||
### F3 (S3, quick win) — give the pairing relay path a log sink ✅ IMPLEMENTED (this branch)
|
||||
|
||||
Approach: add an optional `onLog` to `connectMobileRelayForPairing`,
|
||||
`createRecoveringPairingRelayCandidate`, and `resolvePairingInviteThroughDirector`; thread
|
||||
`connectOptions.onLog` from the coordinator to the relay candidate; emit phase lines ("relay:
|
||||
resolving director…", "relay: cell connected", "relay: E2EE handshake…", "relay: authenticated",
|
||||
"relay: installing credential…"). Optionally also append pairing logs into `connectionLogStore`
|
||||
under the resolved host id so the Connection Log screen has a record post-pairing.
|
||||
Files: `src/transport/mobile-relay-physical-client.ts`, `pairing-relay-candidate.ts`,
|
||||
`mobile-relay-invite-director.ts`, `pre-profile-pairing-coordinator.ts`.
|
||||
Risk: none (additive, phone-local). Old desktops: unaffected — logging only.
|
||||
Tests: coordinator test asserting relay-path log entries arrive through `connectOptions.onLog`;
|
||||
extend `pairing-relay-candidate.test.ts` for per-attempt lines.
|
||||
|
||||
### F4 (S1 class, quick win after F0) — coordinate the remaining bare deep pushes
|
||||
|
||||
Approach: route notification taps (`app/_layout.tsx:127` + `src/notifications/notification-routing.ts`)
|
||||
and the Account-usage card (`app/index.tsx:835`) through `src/navigation/host-stack-navigation.ts`
|
||||
once #12001 lands; migrate host-edit onto the same stricter mechanism (#12001's own noted
|
||||
follow-up). `orca://` deep links can follow later via a route-level guard.
|
||||
Risk: notification cold-start ordering (push before root nav ready) — the mechanism already
|
||||
tolerates that by waiting for state commits.
|
||||
Tests: reuse the `host-stack-navigation.test.ts` harness for a notification-shaped target.
|
||||
|
||||
### F5 (S2/S3, deeper) — make relay dials visible through `migrateTo`
|
||||
|
||||
Approach: while the logical client is `suspended`/`'disconnected'`, have `migrateTo` forward the
|
||||
dialing session's state publishes (`connecting`/`handshaking`) to `publishState`, unbinding on
|
||||
success (normal bind takes over) or failure (restore `'disconnected'`). Guard: never downgrade a
|
||||
still-`'connected'` previous session (make-before-break migrations must stay green). Follow-on UI:
|
||||
show the path being dialed ("Connecting via Orca Relay…") by exposing the pending path, and let
|
||||
`MobileHostCard`/`classifyConnection` render it while not yet connected.
|
||||
Files: `src/transport/stable-logical-rpc-client.ts` (+ its test), `src/transport/connection-health.ts`,
|
||||
`src/components/MobileHostCard.tsx`, `src/transport/mobile-connection-path-label.ts`.
|
||||
Risk: state-ordering regressions in the pinned stable-client and connecting-label suites; keep the
|
||||
forwarding strictly gated on suspended/disconnected.
|
||||
Tests: add a relay-path analog of `cellular-connecting-label-stall.test.ts`; stable-client cases:
|
||||
forwarded states during suspended dial, no forwarding during live-session replacement, failure
|
||||
restores `'disconnected'`.
|
||||
|
||||
### F6 (S3, deeper) — happy-eyeballs relay start post-pairing
|
||||
|
||||
Approach: when a relay credential bundle exists and the direct dial has not authenticated within a
|
||||
short grace (2–3s), start the relay dial in parallel instead of waiting for the 12s direct failure;
|
||||
first authenticated path wins via the existing `migrateTo`/hysteresis machinery. Scope initially to
|
||||
the first connect after pairing (or hosts whose last success was relay) to avoid pointless relay
|
||||
sockets on healthy LANs.
|
||||
Files: `src/transport/mobile-endpoint-supervisor.ts` (start/needsRecovery gating), possibly a
|
||||
phone-local `HostProfile` hint field (no protocol impact; old desktops never present `relay`, so
|
||||
the path is naturally guarded).
|
||||
Risk: racing direct is exactly what `needsRecovery`'s design avoids — needs the mutex
|
||||
(`operationInFlight`) audit and dwell/hysteresis respect; billed relay data on LANs if scoped too
|
||||
broadly.
|
||||
Tests: supervisor fake-timer cases: black-holed LAN converges in ~3-5s; healthy LAN never opens a
|
||||
relay socket; relay loser closed after direct wins.
|
||||
|
||||
### F7 (S1, deeper) — catalog-validate resume targets + not-found bounce
|
||||
|
||||
Approach: (1) on Resume tap with the host connected, validate the target against the freshest
|
||||
`worktree.ps` result (not the snapshot-seeded cache); if absent, open the host index instead.
|
||||
(2) In the session screen, once connected and the catalog is known, bounce unknown `worktreeId`s
|
||||
(exempting `folder:` and floating-workspace sentinels, `app/h/[hostId]/session/[worktreeId].tsx:852-854`)
|
||||
to the host index with a notice. (3) Use the validating reader in
|
||||
`src/worktree/last-visited-worktree-repo.ts` on home instead of the raw `JSON.parse`
|
||||
(`app/index.tsx:315-322`), and import the storage-key constant at both literal call sites.
|
||||
Risk: false bounces during slow catalog loads — only bounce on a *confirmed* fresh catalog miss.
|
||||
Tests: repo tests for the validating reader on home; session-screen bounce cases incl. sentinel
|
||||
exemptions.
|
||||
|
||||
### F8 (S1 aggravator, cheap) — stop the Resume/Tasks layout shift
|
||||
|
||||
Approach: reserve the Resume card's slot (fixed-height placeholder or render-below-Tasks) so its
|
||||
late arrival cannot move the Tasks card under the thumb; alternatively render the card immediately
|
||||
from the snapshot in a disabled state until the host connects.
|
||||
Files: `app/index.tsx` footer.
|
||||
Risk: none.
|
||||
Tests: render test asserting footer order/height stability across `resumeWorktree` arrival.
|
||||
|
||||
### F9 (S1 class, deeper) — HostProtocolGate should not unmount a mounted stack
|
||||
|
||||
Approach: once the HostStack has mounted for a host, keep it mounted and overlay the pending
|
||||
spinner instead of replacing children, preserving in-flight nested navigation; keep the hard
|
||||
replace only for the `blocked` verdict.
|
||||
Files: `src/components/HostProtocolGate.tsx`.
|
||||
Risk: the gate exists so child routes don't call too-new RPCs while compatibility is unknown — an
|
||||
overlay must still block interaction until resolved; verify child mount effects don't fire gated
|
||||
RPCs pre-verdict before choosing overlay vs. current behavior.
|
||||
Tests: gate test asserting no unmount across `statusPending` for an already-mounted host.
|
||||
|
||||
### F10 (S2 class, deeper) — stop wiping loaded data on transient state flips
|
||||
|
||||
Approach: audit the §3(b) destructive-clear sites and make each preserve data across a
|
||||
not-`'connected'` blip, clearing only on host change or explicit sign-out. Top offenders by felt
|
||||
impact: git history blanking rows on the reconnect branch
|
||||
(`src/source-control/MobileGitHistoryList.tsx:63-68` — refetch without `setRows(null)`); the repo
|
||||
cache wiped by the rejected in-flight call (`NewWorktreeModal.tsx:330-333` — keep last-good on
|
||||
error); the diff-review "ready-state preserved" branch that is dead code because it sits after the
|
||||
early return (`src/session/use-mobile-diff-review-controller.ts:85-89`); host capability wipe
|
||||
(`src/transport/host-status-gates.ts:32`); the tasks-screen full re-hydration
|
||||
(`app/h/[hostId]/tasks.tsx:2774-2800`); worktree-cache re-read on reconnect, not only at mount
|
||||
(`app/h/[hostId]/index.tsx:129,330`).
|
||||
Risk: showing stale data as if live — pair each preservation with the existing staleness verdicts
|
||||
rather than inventing new indicators. The in-repo reference pattern is
|
||||
`src/worktree/home-worktree-info.ts:27-47`: counts older than a 10min TTL render as
|
||||
"Last known: N worktrees" instead of being dropped, and `markHomeWorktreeCatalogUnavailable`
|
||||
preserves proven counts across a failed refresh, flagging only `staleCounts`.
|
||||
Tests: per-surface "data survives disconnect→reconnect" cases; F1 largely removes the *trigger*
|
||||
(suspend blips), so this is hardening, not the primary fix.
|
||||
|
||||
## 5. Backward compatibility (old/new phone × old/new desktop)
|
||||
|
||||
- Every fix above is phone-app-local; no RPC methods, close codes, credential formats, or pairing
|
||||
steps change. Old phones against any desktop are untouched (they don't have the code).
|
||||
- New phone + old desktop without relay support: `host.relay` is absent → F1/F5/F6 relay paths
|
||||
never activate; pairing keeps the existing `method_not_found` downgrade
|
||||
(`src/transport/pre-profile-pairing-coordinator.ts:210-217`); F3 logging is inert (no relay
|
||||
candidate is created).
|
||||
- New phone + old desktop with relay: all paths use existing RPCs (`status.get`,
|
||||
`pairing.provisionRelay`, resume confirm) — no new calls introduced. F1's replacement dial reuses
|
||||
the same resume-credential flow lease rotation already exercises against production desktops.
|
||||
- F6's profile hint (if added) is a phone-local persisted field; absent values behave as today.
|
||||
|
||||
## 6. Constants appendix (verified)
|
||||
|
||||
| Constant | Value | Where |
|
||||
|---|---|---|
|
||||
| Direct connect timeout | 12s | `src/transport/rpc-client.ts:126` |
|
||||
| Direct handshake timeout | 5s | `rpc-client.ts:127` |
|
||||
| Direct reconnect ladder | 0.5→60s, give up 12, trickle 90s | `rpc-client.ts:113-117` |
|
||||
| `migrateTo` auth timeout | 12s | `src/transport/stable-logical-rpc-client.ts:182` |
|
||||
| Relay backoff | 250ms floor, 500ms base, 30s ceiling, full jitter | `src/transport/mobile-relay-retry-delays.ts:3-5` |
|
||||
| Host-offline relay retry | 5–15s | `mobile-relay-retry-delays.ts:7-8` |
|
||||
| Gate reprobe cadence | 60s→15min | `mobile-relay-retry-delays.ts:13-14` |
|
||||
| Director resolve timeout | 5s (invite & resume) | `mobile-relay-invite-director.ts:16`, `mobile-relay-resume-director.ts:21` |
|
||||
| Pairing relay recovery | ≤3 attempts × (5s director + ≤100/200/400ms jitter) | `src/transport/pairing-relay-candidate.ts:42,58-71` |
|
||||
| Pairing overall cap | 25s | `app/pair-confirm.tsx:27` |
|
||||
| Relay E2EE layer timers | none | `mobile-relay-e2ee-link.ts`, `mobile-e2ee-v2-*.ts` |
|
||||
| Worktree cache TTL | 30s from write/seed | `src/cache/worktree-cache.ts:12` |
|
||||
| Direct activity probe (foreground) | detects half-open ≤8s | `rpc-client.ts:1119-1124` |
|
||||
@@ -243,10 +243,9 @@ Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votr
|
||||
|
||||
- **Discord :** Rejoignez la communauté sur **[Discord](https://discord.gg/fzjDKHxv8Q)**.
|
||||
- **Twitter / X :** Suivez **[@orca_build](https://x.com/orca_build)** pour les news et annonces.
|
||||
- **WeChat :** Si le groupe 5 est complet, vous pouvez rejoindre le groupe 6.
|
||||
- **WeChat :** Scannez pour rejoindre le groupe WeChat 7 de la communauté Orca.
|
||||
|
||||
<img src="../assets/wechat-qr.jpg" alt="QR code WeChat groupe 5 de la communauté Orca" width="160" style="margin-right: 16px;" />
|
||||
<img src="../assets/wechat-qr-group6.jpg" alt="QR code WeChat groupe 6 de la communauté Orca" width="160" />
|
||||
<img src="../assets/wechat-qr-group7.jpg" alt="QR code WeChat groupe 7 de la communauté Orca" width="160" />
|
||||
|
||||
- **Feedback & idées :** On ship vite. Il manque quelque chose ? [Demandez une feature](https://github.com/stablyai/orca/issues).
|
||||
- **Confidentialité :** Voir la [doc confidentialité & télémétrie](https://www.onorca.dev/docs/telemetry) pour ce qu'Orca collecte en anonyme et comment désactiver la télémétrie.
|
||||
|
||||
@@ -235,10 +235,9 @@ yay -S stably-orca-bin
|
||||
|
||||
- **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。
|
||||
- **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。
|
||||
- **微信:** 如果第 5 群已满,可加入第 6 群。
|
||||
- **微信:** 扫码加入 Orca 社区微信第 7 群。
|
||||
|
||||
<img src="../assets/wechat-qr.jpg" alt="Orca 社区微信第 5 群二维码" width="160" style="margin-right: 16px;" />
|
||||
<img src="../assets/wechat-qr-group6.jpg" alt="Orca 社区微信第 6 群二维码" width="160" />
|
||||
<img src="../assets/wechat-qr-group7.jpg" alt="Orca 社区微信第 7 群二维码" width="160" />
|
||||
|
||||
- **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。
|
||||
- **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。
|
||||
|
||||
@@ -155,6 +155,8 @@ display `:99` when no display exists:
|
||||
Description=Orca runtime server
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=300
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
@@ -165,6 +167,7 @@ ExecStart=/opt/orca/orca-linux.AppImage serve --port 6768 --pairing-address 100.
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
Restart=on-failure
|
||||
RestartPreventExitStatus=3
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
@@ -174,6 +177,19 @@ WantedBy=multi-user.target
|
||||
Replace `100.64.1.20` with the LAN, Tailscale, tunnel, or public hostname that
|
||||
clients should use.
|
||||
|
||||
Exit status `3` means another process already owns this userData profile, so
|
||||
`RestartPreventExitStatus=3` stops the unit instead of retrying a launch that
|
||||
cannot succeed. Any other permanent startup fault is capped at 5 starts per
|
||||
5 minutes; systemd's defaults (10s window, 5 starts) can never trip at
|
||||
`RestartSec=5`, which is how one bad launch could restart thousands of times.
|
||||
The start limit counts operator-initiated starts too, so once it trips systemd
|
||||
refuses a plain `systemctl start` until the 5-minute window rolls over. Run
|
||||
`sudo systemctl reset-failed orca-serve.service` first to clear it — the
|
||||
[Upgrade](#upgrade-steps) and [Roll back](#roll-back) scripts already do.
|
||||
On systemd older than 230 those two directives are spelled
|
||||
`StartLimitInterval=`/`StartLimitBurst=` and belong in `[Service]`; Ubuntu
|
||||
20.04, Orca's oldest supported base, ships systemd 245.
|
||||
|
||||
Enable the service:
|
||||
|
||||
```bash
|
||||
@@ -228,6 +244,8 @@ Then add the display dependency to the Orca service:
|
||||
Description=Orca runtime server
|
||||
After=network-online.target orca-xvfb.service
|
||||
Wants=network-online.target orca-xvfb.service
|
||||
StartLimitIntervalSec=300
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
@@ -237,6 +255,7 @@ Environment=DISPLAY=:99
|
||||
Environment=LIBGL_ALWAYS_SOFTWARE=1
|
||||
ExecStart=/opt/orca/orca-linux.AppImage serve --port 6768 --pairing-address 100.64.1.20
|
||||
Restart=on-failure
|
||||
RestartPreventExitStatus=3
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
@@ -415,6 +434,8 @@ recover_failed_upgrade() {
|
||||
sudo rm -f /opt/orca/orca-linux.AppImage.recovering \
|
||||
/opt/orca/VERSION.recovering
|
||||
if ((recovery_ok)); then
|
||||
# A tripped StartLimitBurst refuses a plain start
|
||||
sudo systemctl reset-failed orca-serve.service || true
|
||||
sudo systemctl start orca-serve.service || true
|
||||
else
|
||||
echo 'Upgrade recovery failed; service remains stopped' >&2
|
||||
@@ -482,6 +503,8 @@ sudo mv "$ORCA_ROLLBACK_NEW" "$ORCA_ROLLBACK"
|
||||
ORCA_BINARY_PROMOTED=1
|
||||
sudo mv -f /opt/orca/orca-linux.AppImage.new /opt/orca/orca-linux.AppImage
|
||||
sudo mv -f /opt/orca/VERSION.new /opt/orca/VERSION
|
||||
# Clears a start-limit hit left by the version being replaced
|
||||
sudo systemctl reset-failed orca-serve.service
|
||||
sudo systemctl start orca-serve.service
|
||||
ORCA_SERVICE_STOPPED=0
|
||||
trap - EXIT
|
||||
@@ -614,6 +637,8 @@ restart_after_rollback_error() {
|
||||
fi
|
||||
fi
|
||||
if ((recovery_ok)); then
|
||||
# A tripped StartLimitBurst refuses a plain start
|
||||
sudo systemctl reset-failed orca-serve.service || true
|
||||
sudo systemctl start orca-serve.service || true
|
||||
else
|
||||
echo 'Rollback recovery failed; service remains stopped' >&2
|
||||
@@ -714,6 +739,8 @@ if ((ORCA_ROLLBACK_HAS_VERSION)); then
|
||||
else
|
||||
sudo rm -f /opt/orca/VERSION
|
||||
fi
|
||||
# The crash-looping build you are rolling back from tripped StartLimitBurst
|
||||
sudo systemctl reset-failed orca-serve.service
|
||||
sudo systemctl start orca-serve.service
|
||||
ORCA_SERVICE_STOPPED=0
|
||||
sudo rm -rf -- "$ORCA_RESTORE"
|
||||
@@ -805,9 +832,24 @@ refuse to run there and print the command to run on the machine you want.
|
||||
`orca` user and that `/opt/orca` is readable by that user.
|
||||
- Clients cannot connect: make sure `--pairing-address` is an address reachable
|
||||
from the client, and make sure firewalls allow the selected `--port`.
|
||||
- Journal shows `Another Orca instance is already running for this userData
|
||||
profile` and the unit exits `3`: another process already owns the profile, so
|
||||
`RestartPreventExitStatus=3` leaves the unit `failed` on purpose. Find the
|
||||
owner with `systemctl status orca-serve` and `pgrep -af orca`. Stop it (or
|
||||
keep it and leave the unit down), then run
|
||||
`sudo systemctl reset-failed orca-serve && sudo systemctl start orca-serve` —
|
||||
`reset-failed` clears the failed state and any start-limit counter. If no owner
|
||||
exists, the lock is stale (Chromium recorded a pid that
|
||||
has since been reused): remove `SingletonLock` and `SingletonSocket` from the
|
||||
userData directory and start again. If an earlier crash-loop already leaked
|
||||
AppImage mounts, list them with `findmnt -rn -t fuse.orca-linux.AppImage` and
|
||||
release only the ones with no live owner using `fusermount -uz <target>` (or
|
||||
`umount -l <target>`), leaving the running instance's mount alone.
|
||||
- Service crash-loops right after an upgrade: use [Roll back](#roll-back) with
|
||||
the pre-upgrade `.ready` bundle. Do not rerun the upgrade first; doing so would
|
||||
make the crashing version the next rollback binary.
|
||||
make the crashing version the next rollback binary. The loop trips
|
||||
`StartLimitBurst`, so any manual `systemctl start` outside that script needs
|
||||
`sudo systemctl reset-failed orca-serve.service` first.
|
||||
- Diagnosing other missing libraries: extract the AppImage without launching it
|
||||
with `./orca-linux.AppImage --appimage-extract`, then run
|
||||
`ldd squashfs-root/orca` to list any shared libraries the host is missing.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Remote wire compatibility
|
||||
|
||||
Orca's remote-server feature pairs a desktop client to a remote Orca runtime, and
|
||||
users update the two independently. **Mixed versions are the normal state**, not an
|
||||
edge case. This page is the contract for changing anything a paired client and host
|
||||
exchange: the runtime RPC envelope, the terminal binary stream, and the content
|
||||
either side publishes over them.
|
||||
|
||||
`src/shared/protocol-version.ts` says when to bump `RUNTIME_PROTOCOL_VERSION`. This
|
||||
page covers the changes that do _not_ bump it and are therefore easy to get wrong.
|
||||
|
||||
## Rule 1 — a new optional JSON field on an existing frame is safe
|
||||
|
||||
Every JSON payload is parsed with a decoder that ignores unknown keys (zod `.strip()`
|
||||
on RPC params, `JSON.parse` on stream frames). An older peer that has never heard of
|
||||
the field simply does not read it.
|
||||
|
||||
Safe:
|
||||
|
||||
```ts
|
||||
// host adds a field; older clients ignore it
|
||||
encodeTerminalStreamJson({ kind, cols, rows, hiddenOutputReason })
|
||||
```
|
||||
|
||||
**The field is safe only for as long as every reader treats it as optional.** The
|
||||
moment a newer client _requires_ it, that client is broken against every host that
|
||||
predates the field — which is the same defect as removing a field, just discovered
|
||||
later. If new behavior depends on the field being present, that is Rule 2: negotiate
|
||||
it, or make the reader fall back.
|
||||
|
||||
## Rule 2 — a new stream opcode is NOT safe; negotiate it
|
||||
|
||||
`decodeTerminalStreamFrame` returns `null` for an opcode it does not know, and
|
||||
`runtime-rpc.ts` drops that frame without an error:
|
||||
|
||||
```ts
|
||||
const frame = decodeTerminalStreamFrame(bytes)
|
||||
if (!frame) {
|
||||
return // silently dropped — the sender never learns
|
||||
}
|
||||
```
|
||||
|
||||
So a new opcode sent to an older peer does not fail loudly. It vanishes, and the
|
||||
feature behind it appears to hang. Input sent under a new opcode is swallowed.
|
||||
|
||||
A new opcode must be announced in the subscribe handshake and sent only after the
|
||||
peer confirms it. The existing pattern is `SetOutputPaused` (opcode 16):
|
||||
|
||||
- the client advertises support in the `Subscribe` frame's `capabilities`;
|
||||
- the host echoes `capabilities: { outputPause: 1 }` on the `subscribed` event;
|
||||
- the client sends opcode 16 only after that echo (`stream.supportsOutputPause`);
|
||||
- the host only acts on opcode 16 when it negotiated it (`stream.supportsOutputPause`).
|
||||
|
||||
Reuse an existing opcode with a new optional payload field (Rule 1) whenever that
|
||||
expresses the change; reach for a new opcode only when framing genuinely differs.
|
||||
|
||||
Opcode numbers are permanent. See the `Ack = 13` and `ClaimViewport = 14` comments
|
||||
in `src/shared/terminal-stream-protocol.ts` for why a shipped number cannot be
|
||||
reused even if the feature behind it is removed.
|
||||
|
||||
## Rule 3 — changing what the host publishes breaks old clients with no wire change
|
||||
|
||||
The frame shape can be untouched and the skew still real, because clients react to
|
||||
frame _content_. PR #12641 is the worked example: the host stopped synthesizing a
|
||||
finished agent status, and clients running older code saw different content in an
|
||||
identical frame.
|
||||
|
||||
Treat these as wire changes even though nothing in the codec moves:
|
||||
|
||||
- a field the host stops populating (an old client reading it now sees `undefined`);
|
||||
- a value whose meaning, units, or nullability changes;
|
||||
- content the host stops synthesizing, trims, or starts deriving from a new source;
|
||||
- a frame the host stops sending, or starts sending, on an existing path.
|
||||
|
||||
If old clients cannot interpret the new projection correctly, gate it behind a
|
||||
runtime capability the same way Rule 2 gates an opcode.
|
||||
|
||||
## Enforcement
|
||||
|
||||
`tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts` runs the real
|
||||
host RPC methods and the real renderer multiplexer from two builds against each
|
||||
other — current working tree against the newest release tag, in both skew
|
||||
directions — over one scripted terminal journey (subscribe, input, hide/reveal
|
||||
snapshot, drop, reconnect).
|
||||
|
||||
Run it with:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
|
||||
```
|
||||
|
||||
It fails when a frame is refused by the receiving build's decoder (Rule 2), when the
|
||||
observed frame sequence changes (Rule 3), or when published snapshot content or
|
||||
negotiated capabilities differ from the contract. Adding an optional field keeps it
|
||||
green (Rule 1); making a client depend on that field turns the new-client/old-host
|
||||
pairing red.
|
||||
|
||||
The harness covers the terminal stream only. It does **not** cover the session-tab
|
||||
sync channel, agent-session publications, file or Git RPCs, mobile/E2EE framing, or
|
||||
the relay transport. A change on those paths still needs its own reasoning against
|
||||
the three rules above.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Windows setup-runner shell
|
||||
|
||||
On native Windows, Orca writes the `orca.yaml` setup script (and the issue command) to a generated
|
||||
runner file and types a launch command into a terminal. The runner is a **`.cmd` batch file by
|
||||
default**, exactly as it has been since setup hooks shipped.
|
||||
|
||||
A script opts into bash by starting with a `#!` interpreter line:
|
||||
|
||||
```yaml
|
||||
scripts:
|
||||
setup: |
|
||||
#!/usr/bin/env bash
|
||||
[ -f .env ] || cp .env.example .env
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Without that line the script keeps running under `cmd.exe`:
|
||||
|
||||
```yaml
|
||||
scripts:
|
||||
setup: |
|
||||
copy .env.example .env
|
||||
xcopy /E assets dist
|
||||
```
|
||||
|
||||
## Why the script declares it, not the terminal preference
|
||||
|
||||
`terminalWindowsShell` says which shell *interactive terminals* open in. It says nothing about the
|
||||
language a project's setup script is written in. Deriving the runner from it had two consequences:
|
||||
|
||||
- Windows users with batch-syntax setup scripts silently switched to bash on upgrade, so `copy`,
|
||||
`xcopy`, `set VAR=value`, and `if errorlevel 1` stopped working.
|
||||
- Two people on the same repo got different interpreters for the same `orca.yaml`, so no project
|
||||
could write a setup script that worked for all of its Windows contributors.
|
||||
|
||||
A `#!` line is per-project, explicit, and identical for everyone who checks the repo out.
|
||||
|
||||
The same rule applies to the per-user setup command in **Settings → repository hooks**
|
||||
(`repo.hookSettings.scripts.setup`): it is merged into the same script that reaches the runner, so a
|
||||
POSIX one-liner stored there needs its own `#!` line to run under bash on Windows.
|
||||
|
||||
## What the `#!` line does and does not select
|
||||
|
||||
The generated runner is always executed by bash (`bash <runner>`; Git Bash on native Windows), on
|
||||
every platform. The `#!` line therefore does two things:
|
||||
|
||||
- It declares the script is written for a POSIX shell, which is what selects the bash runner.
|
||||
- Its option flags are replayed with `set`, so `#!/usr/bin/env -S bash -euo pipefail` really does
|
||||
get `pipefail`. Without that replay the flags would be silently dropped, because `bash <runner>`
|
||||
never parses the interpreter line. Only the flags `set` itself accepts
|
||||
(`[--abefhkmnptuvxBCHP] [-o option]`) are replayed; invocation-only ones such as `-l` are
|
||||
dropped, because `set -l` exits 2 and would abort the runner before its first line.
|
||||
|
||||
The interpreter name itself is not honored beyond "is this a POSIX shell": `#!/bin/sh` and
|
||||
`#!/bin/zsh` scripts run under bash, exactly as they already did on macOS and Linux.
|
||||
|
||||
## Requirements for the bash runner
|
||||
|
||||
A `#!` line only takes effect when Orca can actually launch bash from the configured terminal — the
|
||||
terminal shell must resolve to Git Bash (`resolveWindowsGitBashShellPath`). The generated runner
|
||||
uses MSYS `/c/...` paths, which Cygwin and the WSL shim do not accept, and the launch command is
|
||||
typed into whatever shell the terminal opened with.
|
||||
|
||||
When bash is not available (a PowerShell/cmd terminal, or an SSH-to-Windows host, which always uses
|
||||
the remote's `.cmd` runner) the `#!` script is **not** executed under cmd. The generated `.cmd`
|
||||
runner prints why and exits 1, because running the interpreter-agnostic prefix of a bash script
|
||||
(`pnpm install`, `git submodule update`) and only failing at the first bash-only line leaves a
|
||||
half-set-up worktree that looks finished.
|
||||
|
||||
## Launching a `.cmd` runner from a Git Bash terminal
|
||||
|
||||
The runner format and the shell that types the launch command are independent: a Git Bash terminal
|
||||
with a batch-syntax setup script gets a `.cmd` runner launched from a bash pane. `cmd.exe /c
|
||||
"C:\..."` cannot be used there — MSYS rewrites the bare `/c` switch into a drive path, so cmd opens
|
||||
interactively and the runner never executes (issue #6896). Those launches reuse the PowerShell
|
||||
`ProcessStartInfo` launcher (`buildWindowsCmdRunnerDelayedLaunchCommand`), which carries the switch
|
||||
and the runner path outside the command line. `WorktreeSetupLaunch.shell` therefore describes the
|
||||
launching pane; the runner file's `.cmd`/`.sh` extension describes the format.
|
||||
|
||||
The `wait-for-setup` gate follows the same split. The pane types the gate and already quoted the
|
||||
agent startup command for itself, so a `.cmd` runner launched from a Git Bash pane still gets the
|
||||
bash gate — PowerShell's `Invoke-Expression` cannot parse POSIX `'\''` escaping. The gate wraps the
|
||||
same `ProcessStartInfo` launcher, so the batch runner is never handed to bash.
|
||||
|
||||
WSL worktrees and non-Windows platforms are unaffected: they always use the bash runner. SSH hosts
|
||||
choose their runner from the remote path format, never from local Windows preferences.
|
||||
@@ -3,13 +3,14 @@ import { resolve } from 'node:path'
|
||||
import { defineConfig, type UserConfig } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { createBootstrapFatalExitBanner } from './build-plugins/bootstrap-fatal-exit-banner'
|
||||
import { createPlainNodeEntryGuardPlugin } from './build-plugins/plain-node-entry-guard'
|
||||
import { createBootstrapFatalExitBanner } from './config/build-plugins/bootstrap-fatal-exit-banner'
|
||||
import { createPlainNodeEntryGuardPlugin } from './config/build-plugins/plain-node-entry-guard'
|
||||
import packageJson from './package.json' with { type: 'json' }
|
||||
|
||||
const BUNDLED_MAIN_DEPENDENCIES = new Set([
|
||||
'@xterm/headless',
|
||||
'@xterm/addon-serialize',
|
||||
'psl',
|
||||
// Why: Windows NSIS deploys app.asar before external resources; bootstrap must
|
||||
// not race the later resources/node_modules copy.
|
||||
'zod'
|
||||
@@ -216,6 +217,11 @@ export const electronViteConfig: UserConfig = {
|
||||
'session-scanner-opencode-sqlite-worker-entry': resolve(
|
||||
'src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts'
|
||||
),
|
||||
// Why: libuv spawns processes inline on the calling loop, so the port
|
||||
// scan's probe commands run on a worker thread instead of the UI one.
|
||||
'port-scan-command-worker-entry': resolve(
|
||||
'src/main/ports/port-scan-command-worker-entry.ts'
|
||||
),
|
||||
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
|
||||
// can't take down the main process (issue #7547).
|
||||
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
src/terminal/terminal-webview-engine.generated.ts
|
||||
src/components/pr-sidebar/mermaid-webview-engine.generated.ts
|
||||
.expo/
|
||||
dist/
|
||||
/android/
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Orca",
|
||||
"slug": "orca-mobile",
|
||||
"version": "0.0.36",
|
||||
"version": "0.0.37",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
@@ -83,7 +83,7 @@
|
||||
"allowBackup": false,
|
||||
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
|
||||
"package": "com.stably.orca.mobile",
|
||||
"versionCode": 9
|
||||
"versionCode": 10
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
|
||||
+11
-7
@@ -8,7 +8,8 @@ import * as Linking from 'expo-linking'
|
||||
import { colors } from '../src/theme/mobile-theme'
|
||||
import { OrcaLogo } from '../src/components/OrcaLogo'
|
||||
import { RpcClientProvider } from '../src/transport/client-context'
|
||||
import { getNotificationNavigationPath } from '../src/notifications/notification-routing'
|
||||
import { getNotificationNavigationTarget } from '../src/notifications/notification-routing'
|
||||
import { useOpenNotificationRoute } from '../src/notifications/use-open-notification-route'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
|
||||
import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery'
|
||||
@@ -35,6 +36,7 @@ Notifications.setNotificationHandler({
|
||||
|
||||
export default function RootLayout() {
|
||||
const router = useRouter()
|
||||
const openNotificationRoute = useOpenNotificationRoute()
|
||||
const handledNotificationIdsRef = useRef<Set<string>>(new Set())
|
||||
useMobileLocaleReload()
|
||||
|
||||
@@ -70,6 +72,7 @@ export default function RootLayout() {
|
||||
return () => sub.remove()
|
||||
}, [router])
|
||||
|
||||
// ─── Notification tap routing ───
|
||||
// Why: iOS delivers local notification taps through expo-notifications,
|
||||
// not Linking. Route both cold-start and warm-start responses to the host
|
||||
// and worktree that scheduled the notification.
|
||||
@@ -93,9 +96,9 @@ export default function RootLayout() {
|
||||
}
|
||||
}
|
||||
|
||||
async function getNavigationPath(data: unknown): Promise<string | null> {
|
||||
async function getNavigationTarget(data: unknown) {
|
||||
const hosts = await loadHosts().catch(() => null)
|
||||
return getNotificationNavigationPath(data, {
|
||||
return getNotificationNavigationTarget(data, {
|
||||
knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined
|
||||
})
|
||||
}
|
||||
@@ -120,13 +123,13 @@ export default function RootLayout() {
|
||||
}
|
||||
}
|
||||
|
||||
const path = await getNavigationPath(response.notification.request.content.data)
|
||||
const target = await getNavigationTarget(response.notification.request.content.data)
|
||||
clearLastNotificationResponse()
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
if (path) {
|
||||
router.push(path)
|
||||
if (target) {
|
||||
openNotificationRoute(target)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +145,8 @@ export default function RootLayout() {
|
||||
disposed = true
|
||||
sub.remove()
|
||||
}
|
||||
}, [router])
|
||||
}, [openNotificationRoute])
|
||||
// ─── End notification tap routing ───
|
||||
|
||||
// Why: hide the native splash only once the navigation Stack has been laid
|
||||
// out — this is the earliest moment the user will see actual app content.
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SectionList,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
RefreshControl
|
||||
} from 'react-native'
|
||||
import { View, Text, StyleSheet, SectionList, Pressable, Alert, RefreshControl } from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router'
|
||||
import {
|
||||
@@ -64,6 +55,8 @@ import { ConfirmModal } from '../../../src/components/ConfirmModal'
|
||||
import { BottomDrawer } from '../../../src/components/BottomDrawer'
|
||||
import { useHostProtocolGates } from '../../../src/components/HostProtocolGate'
|
||||
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
|
||||
import { HostRouteNoticeBanner } from '../../../src/components/HostRouteNoticeBanner'
|
||||
import { visibleHostRouteNotice } from '../../../src/host-route-notice'
|
||||
import { MobileSearchField } from '../../../src/components/MobileSearchField'
|
||||
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
|
||||
import { getCachedWorktrees, setCachedWorktrees } from '../../../src/cache/worktree-cache'
|
||||
@@ -95,6 +88,7 @@ import { useWorkspaceSections } from '../../../src/worktree/use-workspace-sectio
|
||||
import { getMobileWorkspaceLineageGroupKey } from '../../../src/worktree/mobile-workspace-lineage'
|
||||
import { areWorktreeListsEqual } from '../../../src/worktree/worktree-list-snapshot'
|
||||
import { WorktreeCatalogSnapshotClient } from '../../../src/worktree/worktree-catalog-snapshot-client'
|
||||
import { HostWorkspaceListStates } from '../../../src/worktree/host-workspace-list-states'
|
||||
import { repoColor } from '../../../src/worktree/repo-color'
|
||||
import {
|
||||
getWorkspaceGroupOptions,
|
||||
@@ -117,9 +111,12 @@ export function HostScreen({
|
||||
action: actionProp,
|
||||
onHideSidebar
|
||||
}: MobileHostScreenProps = {}) {
|
||||
const params = useLocalSearchParams<{ hostId: string; action?: string }>()
|
||||
const params = useLocalSearchParams<{ hostId: string; action?: string; notice?: string }>()
|
||||
const hostId = hostIdProp ?? params.hostId
|
||||
const action = actionProp ?? params.action
|
||||
const [dismissedNotice, setDismissedNotice] = useState<string | null>(null)
|
||||
const noticeParam = params.notice?.trim()
|
||||
const routeNotice = visibleHostRouteNotice(embedded, noticeParam, dismissedNotice)
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const insets = useSafeAreaInsets()
|
||||
@@ -146,6 +143,9 @@ export function HostScreen({
|
||||
const forceReconnectHost = useForceReconnect()
|
||||
const [worktrees, setWorktrees] = useState<Worktree[]>(initialCache ?? [])
|
||||
const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null)
|
||||
// Why (STA-3123): error code of the last failed worktree.ps, so a broken catalog
|
||||
// path renders as a failure instead of an empty host. Cleared on the next success.
|
||||
const [catalogError, setCatalogError] = useState<string | null>(null)
|
||||
// Why: track the locally-opened worktree so the active-row highlight moves instantly instead of waiting for the next poll.
|
||||
const [optimisticActiveWorktreeId, setOptimisticActiveWorktreeId] = useState<string | null>(null)
|
||||
// One tick drives every visible agent row's relative timestamp.
|
||||
@@ -161,7 +161,8 @@ export function HostScreen({
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
filterRepoIds: new Set(),
|
||||
hideSleeping: false,
|
||||
hideDefaultBranch: false
|
||||
hideDefaultBranch: false,
|
||||
alwaysShowDefaultBranch: true
|
||||
})
|
||||
const [groupMode, setGroupMode] = useState<MobileGroupMode>('repo')
|
||||
const [workspaceStatuses, setWorkspaceStatuses] = useState<readonly WorkspaceStatusDefinition[]>(
|
||||
@@ -192,6 +193,7 @@ export function HostScreen({
|
||||
sortMode: 'recent',
|
||||
hideSleeping: false,
|
||||
hideDefaultBranch: false,
|
||||
alwaysShowDefaultBranch: true,
|
||||
filterRepoIds: [],
|
||||
collapsedGroups: [],
|
||||
workspaceStatuses: getDefaultMobileWorkspaceStatuses()
|
||||
@@ -203,6 +205,7 @@ export function HostScreen({
|
||||
sortMode,
|
||||
hideSleeping: filters.hideSleeping,
|
||||
hideDefaultBranch: filters.hideDefaultBranch,
|
||||
alwaysShowDefaultBranch: filters.alwaysShowDefaultBranch !== false,
|
||||
filterRepoIds: [...filters.filterRepoIds],
|
||||
collapsedGroups: [...collapsedGroups],
|
||||
workspaceStatuses
|
||||
@@ -219,7 +222,8 @@ export function HostScreen({
|
||||
setFilters({
|
||||
filterRepoIds: new Set(next.filterRepoIds),
|
||||
hideSleeping: next.hideSleeping,
|
||||
hideDefaultBranch: next.hideDefaultBranch
|
||||
hideDefaultBranch: next.hideDefaultBranch,
|
||||
alwaysShowDefaultBranch: next.alwaysShowDefaultBranch
|
||||
})
|
||||
}, [])
|
||||
|
||||
@@ -231,6 +235,9 @@ export function HostScreen({
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
// alwaysShowDefaultBranchWorkspace is deliberately absent: mobile reads it
|
||||
// but has no toggle, so echoing its local default would silently revert a
|
||||
// desktop opt-out on the first filter tap before ui.get lands (#8873).
|
||||
const payload: WorkspaceViewSettings = {
|
||||
groupBy: groupModeToDesktop(next.groupMode),
|
||||
sortBy: next.sortMode,
|
||||
@@ -318,6 +325,7 @@ export function HostScreen({
|
||||
repoMetadataFetchedAtRef.current = 0
|
||||
// Why: useState initializer runs only on first mount, so re-seed the cache when Expo Router reuses this screen for a new hostId.
|
||||
const freshCache = hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null
|
||||
setCatalogError(null)
|
||||
if (freshCache) {
|
||||
setWorktrees(freshCache)
|
||||
setLastKnownWorktrees(freshCache)
|
||||
@@ -419,17 +427,27 @@ export function HostScreen({
|
||||
const requestHostId = hostId
|
||||
|
||||
try {
|
||||
const pendingCatalog = await worktreeCatalogRef.current.fetch(requestClient, requestHostId)
|
||||
const fetched = await worktreeCatalogRef.current.fetch(requestClient, requestHostId)
|
||||
if (clientRef.current !== requestClient || hostId !== requestHostId) {
|
||||
return
|
||||
}
|
||||
if (!options.allowDuringModal && newWorktreeModalVisibleRef.current) {
|
||||
return
|
||||
}
|
||||
// Why (STA-3123): a failed catalog request must not pass for "0 worktrees";
|
||||
// surface it so a broken remote host is diagnosable instead of looking empty.
|
||||
if (fetched.kind === 'request_failed') {
|
||||
setCatalogError(fetched.code)
|
||||
return
|
||||
}
|
||||
if (fetched.pending.admission.kind === 'invalid') {
|
||||
setCatalogError('invalid_response')
|
||||
}
|
||||
// Why: unchanged responses still yield the confirmed rows, so every poll reasserts
|
||||
// host truth over optimistic local edits regardless of payload size.
|
||||
const confirmed = worktreeCatalogRef.current.admit(pendingCatalog)
|
||||
const confirmed = worktreeCatalogRef.current.admit(fetched.pending)
|
||||
if (confirmed) {
|
||||
setCatalogError(null)
|
||||
// Why: reuse the existing array on identical snapshots to keep SectionList/sort rebuilds off the tap path.
|
||||
setWorktrees((current) =>
|
||||
areWorktreeListsEqual(current, confirmed) ? current : confirmed
|
||||
@@ -440,7 +458,7 @@ export function HostScreen({
|
||||
setWorktreesLoaded(true)
|
||||
// Why (#8498): overwrite the home-written cache with the confirmed snapshot so a reconnect/remount can't serve a stale list.
|
||||
if (hostId) {
|
||||
setCachedWorktrees(hostId, confirmed)
|
||||
setCachedWorktrees(hostId, confirmed, { proven: true })
|
||||
}
|
||||
// Drop the optimistic active override once the host reports it active, so later desktop changes win.
|
||||
setOptimisticActiveWorktreeId((pending) =>
|
||||
@@ -478,6 +496,9 @@ export function HostScreen({
|
||||
}
|
||||
} catch {
|
||||
// Will retry on reconnect
|
||||
if (clientRef.current === requestClient && hostId === requestHostId) {
|
||||
setCatalogError('network_error')
|
||||
}
|
||||
} finally {
|
||||
fetchWorktreesInFlightRef.current = false
|
||||
}
|
||||
@@ -488,7 +509,8 @@ export function HostScreen({
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
// Why: focus nudges reconnect and probes a possibly half-open socket; empty deps fire per focus, not per state flip (which defeats backoff).
|
||||
clientRef.current?.notifyForeground()
|
||||
// 'focus' keeps a healthy relay green — probe, never suspend (S2 grey blink).
|
||||
clientRef.current?.notifyForeground('focus')
|
||||
}, [])
|
||||
)
|
||||
|
||||
@@ -710,10 +732,9 @@ export function HostScreen({
|
||||
)
|
||||
|
||||
const displayWorktrees = useMemo(() => {
|
||||
const base =
|
||||
connState === 'disconnected' || connState === 'reconnecting' || connState === 'auth-failed'
|
||||
? lastKnownWorktrees
|
||||
: worktrees
|
||||
// Why: live `worktrees` is authoritative only while connected; under the amber
|
||||
// mount default, connecting/handshaking must keep the pre-reconnect list too.
|
||||
const base = connState === 'connected' ? worktrees : lastKnownWorktrees
|
||||
if (sleptIds.size === 0 && optimisticActiveWorktreeId === null) {
|
||||
return base
|
||||
}
|
||||
@@ -1086,6 +1107,14 @@ export function HostScreen({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Why a bounced route landed here (e.g. the workspace was deleted on the desktop). */}
|
||||
{routeNotice && (
|
||||
<HostRouteNoticeBanner
|
||||
message={routeNotice}
|
||||
onDismiss={() => setDismissedNotice(noticeParam ?? null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Search bar */}
|
||||
{showSearch && (
|
||||
<View style={styles.searchBar}>
|
||||
@@ -1101,27 +1130,15 @@ export function HostScreen({
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{((connState === 'connecting' || connState === 'reconnecting') &&
|
||||
displayWorktrees.length === 0) ||
|
||||
(connState === 'connected' && !worktreesLoaded && displayWorktrees.length === 0) ? (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Empty state */}
|
||||
{connState === 'connected' && worktreesLoaded && sections.length === 0 && (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.emptyText}>
|
||||
{search
|
||||
? tr('noMatching')
|
||||
: activeFilterCount > 0
|
||||
? tr('noWorktreesMatch')
|
||||
: tr('noWorktrees')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<HostWorkspaceListStates
|
||||
connState={connState}
|
||||
worktreesLoaded={worktreesLoaded}
|
||||
displayCount={displayWorktrees.length}
|
||||
sectionCount={sections.length}
|
||||
catalogError={catalogError}
|
||||
search={search}
|
||||
activeFilterCount={activeFilterCount}
|
||||
/>
|
||||
|
||||
{sections.length > 0 && (
|
||||
<SectionList
|
||||
@@ -1411,9 +1428,7 @@ export default function HostWorktreeRoute() {
|
||||
return <HostScreen />
|
||||
}
|
||||
|
||||
function ListSeparator() {
|
||||
return <View style={styles.separator} />
|
||||
}
|
||||
const ListSeparator = () => <View style={styles.separator} />
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
@@ -1582,10 +1597,6 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
emptyText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize
|
||||
},
|
||||
errorText: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.bodySize
|
||||
|
||||
@@ -107,6 +107,7 @@ import type {
|
||||
TerminalWebViewHandle
|
||||
} from '../../../../src/terminal/terminal-webview-contract'
|
||||
import { isTerminalOscLinkRanges } from '../../../../src/terminal/terminal-osc-link-ranges'
|
||||
import { computeActiveTerminalKeyboardLift } from '../../../../src/terminal/terminal-keyboard-avoidance-lift'
|
||||
import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit'
|
||||
import {
|
||||
getDefaultTerminalAccessoryBuiltInIds,
|
||||
@@ -196,8 +197,11 @@ import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTer
|
||||
import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions'
|
||||
import { resolveMobileFileTabDoc } from '../../../../src/files/mobile-file-tab-doc'
|
||||
import { captureMobileFileMutationOwnership } from '../../../../src/files/mobile-file-mutation-ownership'
|
||||
import { openMobileTerminalFileTap } from '../../../../src/session/mobile-terminal-file-tap-open'
|
||||
import { useMobileFileTapHandlers } from '../../../../src/session/use-mobile-file-tap-handlers'
|
||||
import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name'
|
||||
import { useMissingWorktreeBounce } from '../../../../src/session/use-missing-worktree-bounce'
|
||||
import { hostRouteWithNotice } from '../../../../src/host-route-notice'
|
||||
import { LAST_VISITED_WORKTREE_STORAGE_KEY } from '../../../../src/worktree/last-visited-worktree-repo'
|
||||
import {
|
||||
acceptSessionSnapshot,
|
||||
applyClosedTabTombstones,
|
||||
@@ -236,6 +240,11 @@ import {
|
||||
} from '../../../../src/session/mobile-terminal-prune-decision'
|
||||
import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream'
|
||||
import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe'
|
||||
import {
|
||||
TerminalViewportResubscribeBudget,
|
||||
readTerminalViewportDims,
|
||||
runTerminalViewportFitPass
|
||||
} from '../../../../src/session/mobile-terminal-viewport-resubscribe'
|
||||
import { activateMobileSessionTab } from '../../../../src/session/mobile-session-tab-activation'
|
||||
import { MobileTerminalDiagnostics } from '../../../../src/session/mobile-terminal-diagnostics'
|
||||
import { runAcceptedMobileSessionTabsEffects } from '../../../../src/session/mobile-session-tabs-accepted-effects'
|
||||
@@ -847,12 +856,19 @@ export default function SessionScreen() {
|
||||
const reconnectAttempts = useReconnectAttempt(hostId)
|
||||
const lastConnectedAt = useLastConnectedAt(hostId)
|
||||
const forceReconnectHost = useForceReconnect()
|
||||
const worktreeName = useLiveWorktreeName({
|
||||
const { name: worktreeName, resolution: worktreeResolution } = useLiveWorktreeName({
|
||||
client,
|
||||
connState,
|
||||
routeName: routeWorktreeName,
|
||||
worktreeId
|
||||
})
|
||||
// Why: a workspace deleted on the desktop leaves every RPC on this route failing forever.
|
||||
useMissingWorktreeBounce({
|
||||
hostId,
|
||||
worktreeId,
|
||||
resolution: worktreeResolution,
|
||||
bounce: (id) => router.replace(hostRouteWithNotice(id, 'worktree-missing'))
|
||||
})
|
||||
// Master-detail state: wide layouts dock a tapped panel beside the session; narrow keeps it null and pushes full-screen routes.
|
||||
const { isWideLayout } = useResponsiveLayout()
|
||||
const [activePanel, setActivePanel] = useState<ActivePanel>(null)
|
||||
@@ -1024,6 +1040,8 @@ export default function SessionScreen() {
|
||||
const subscribingHandlesRef = useRef<Set<string>>(new Set())
|
||||
const initializedHandlesRef = useRef<Set<string>>(new Set())
|
||||
const terminalDiagnosticsRef = useRef(new MobileTerminalDiagnostics())
|
||||
// Why: bounds the scrollback→resubscribe fit loop per handle (STA-3337).
|
||||
const viewportResubscribeBudgetRef = useRef(new TerminalViewportResubscribeBudget())
|
||||
// Why: don't subscribe until the WebView fires web-ready — iOS may defer JS in hidden WebViews and init() messages would queue unrendered.
|
||||
const webReadyHandlesRef = useRef<Set<string>>(new Set())
|
||||
const activeHandleRef = useRef<string | null>(null)
|
||||
@@ -1370,6 +1388,7 @@ export default function SessionScreen() {
|
||||
subscribingHandlesRef.current.clear()
|
||||
initializedHandlesRef.current.clear()
|
||||
terminalDiagnosticsRef.current.clearTerminalCache()
|
||||
viewportResubscribeBudgetRef.current.clear()
|
||||
webReadyHandlesRef.current.clear()
|
||||
subscribeSeqRef.current.clear()
|
||||
layoutSeqRef.current.clear()
|
||||
@@ -1499,10 +1518,11 @@ export default function SessionScreen() {
|
||||
return
|
||||
}
|
||||
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
|
||||
const cols = (data.cols as number) || 80
|
||||
const rows = (data.rows as number) || 24
|
||||
const scrollbackCols = cols
|
||||
const scrollbackRows = rows
|
||||
const { hostCols, hostRows } = readTerminalViewportDims(data)
|
||||
// Why: absent host dims must not be coerced into a comparable size — 80x24
|
||||
// never equals a phone viewport and armed a zero-delay resubscribe loop (STA-3337).
|
||||
const cols = hostCols ?? viewportRef.current?.cols ?? 80
|
||||
const rows = hostRows ?? viewportRef.current?.rows ?? 24
|
||||
const initialData =
|
||||
typeof data.serialized === 'string' && data.serialized.length > 0
|
||||
? data.serialized
|
||||
@@ -1521,46 +1541,36 @@ export default function SessionScreen() {
|
||||
ref.init(cols, rows, initialData, false, oscLinks)
|
||||
initializedHandlesRef.current.add(handle)
|
||||
if (data.displayMode) {
|
||||
const displayMode = data.displayMode as MobileDisplayMode
|
||||
// Why: same-mode frames must keep the Map identity, or every stream pass re-renders the whole route.
|
||||
setTerminalModes((prev) =>
|
||||
new Map(prev).set(handle, data.displayMode as MobileDisplayMode)
|
||||
prev.get(handle) === displayMode ? prev : new Map(prev).set(handle, displayMode)
|
||||
)
|
||||
}
|
||||
// Why: cold-start refit — init()'s fit can run against a transient scrollWidth, so re-fire against a settled DOM.
|
||||
scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200)
|
||||
// Why: first subscribe has no viewport (xterm not loaded yet), so measure after init and resubscribe so the server can phone-fit.
|
||||
const needsResubscribe =
|
||||
!viewportMeasuredRef.current ||
|
||||
(viewportRef.current != null &&
|
||||
(scrollbackCols !== viewportRef.current.cols ||
|
||||
scrollbackRows !== viewportRef.current.rows))
|
||||
if (needsResubscribe) {
|
||||
void (async () => {
|
||||
// Why: wait for init()'s rAF chain before measuring, else the measure races ahead and returns null (log dump 2026-05-06).
|
||||
await getTerminalRef(handle)?.awaitReady()
|
||||
if (subscribeSeqRef.current.get(handle) !== seq) {
|
||||
return
|
||||
}
|
||||
const dims = await getTerminalRef(handle)?.measureFitDimensions(
|
||||
terminalFrameHeightRef.current || undefined
|
||||
)
|
||||
// Why: re-check seq — the awaits may have let a newer subscribe cycle arm; tearing it down would resubscribe a stale generation.
|
||||
if (subscribeSeqRef.current.get(handle) !== seq) {
|
||||
return
|
||||
}
|
||||
if (!getTerminalRef(handle)) {
|
||||
return
|
||||
}
|
||||
// Why: scrollback came back at cols=80 (server's null-viewport fallback), so this subscriber record has no viewport — resubscribe so the server stores it.
|
||||
if (dims) {
|
||||
diagnostics.streamResubscribing(handle, seq, dims)
|
||||
viewportRef.current = dims
|
||||
viewportMeasuredRef.current = true
|
||||
unsubscribeTerminal(handle)
|
||||
initializedHandlesRef.current.delete(handle)
|
||||
subscribeToTerminal(handle)
|
||||
}
|
||||
})()
|
||||
}
|
||||
// Why: first subscribe has no viewport (xterm not loaded yet), so measure after init
|
||||
// and resubscribe so the server can phone-fit — bounded per handle so a
|
||||
// non-converging host degrades visibly instead of hot-looping (STA-3337).
|
||||
runTerminalViewportFitPass({
|
||||
handle,
|
||||
seq,
|
||||
hostCols,
|
||||
hostRows,
|
||||
budget: viewportResubscribeBudgetRef.current,
|
||||
diagnostics,
|
||||
viewportRef,
|
||||
viewportMeasuredRef,
|
||||
subscribeSeqRef,
|
||||
initializedHandlesRef,
|
||||
terminalUnsubsRef,
|
||||
terminalFrameHeightRef,
|
||||
getTerminalRef,
|
||||
unsubscribeTerminal,
|
||||
subscribeToTerminal,
|
||||
scheduleDelayedAction,
|
||||
showToast
|
||||
})
|
||||
} else if (data.type === 'metadata') {
|
||||
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
|
||||
} else if (data.type === 'data') {
|
||||
@@ -1585,8 +1595,12 @@ export default function SessionScreen() {
|
||||
} else if (data.type === 'resized') {
|
||||
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
|
||||
// Server resize: reinit xterm on a full-buffer snapshot (width reflow rewraps scrollback), else just resize geometry.
|
||||
const cols = (data.cols as number) || 80
|
||||
const rows = (data.rows as number) || 24
|
||||
const viewport = viewportMeasuredRef.current ? viewportRef.current : null
|
||||
const [cols, rows] = viewportResubscribeBudgetRef.current.observeResize(
|
||||
handle,
|
||||
data,
|
||||
viewport
|
||||
)
|
||||
const serialized = typeof data.serialized === 'string' ? data.serialized : null
|
||||
diagnostics.streamResized(handle, seq, eventSeq, data, getTerminalRef(handle) != null)
|
||||
const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined
|
||||
@@ -1596,8 +1610,10 @@ export default function SessionScreen() {
|
||||
getTerminalRef(handle)?.resize(cols, rows)
|
||||
}
|
||||
if (data.displayMode) {
|
||||
const displayMode = data.displayMode as MobileDisplayMode
|
||||
// Why: same-mode frames must keep the Map identity, or every stream pass re-renders the whole route.
|
||||
setTerminalModes((prev) =>
|
||||
new Map(prev).set(handle, data.displayMode as MobileDisplayMode)
|
||||
prev.get(handle) === displayMode ? prev : new Map(prev).set(handle, displayMode)
|
||||
)
|
||||
}
|
||||
scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200)
|
||||
@@ -1613,7 +1629,7 @@ export default function SessionScreen() {
|
||||
}
|
||||
subscribingHandlesRef.current.delete(handle)
|
||||
},
|
||||
[client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction]
|
||||
[client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction, showToast]
|
||||
)
|
||||
|
||||
const nativeChatStream = useMobileNativeChatTerminalStream({
|
||||
@@ -1681,7 +1697,8 @@ export default function SessionScreen() {
|
||||
|
||||
try {
|
||||
const response = await client.sendRequest('terminal.list', {
|
||||
worktree: `id:${worktreeId}`
|
||||
worktree: `id:${worktreeId}`,
|
||||
includeVisualLayouts: false
|
||||
})
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as { terminals: Terminal[] }
|
||||
@@ -1714,12 +1731,16 @@ export default function SessionScreen() {
|
||||
unsubscribeTerminal(handle)
|
||||
terminalRefs.current.delete(handle)
|
||||
initializedHandlesRef.current.delete(handle)
|
||||
viewportResubscribeBudgetRef.current.forget(handle)
|
||||
clearTerminalLiveInputDefault(handle)
|
||||
}
|
||||
setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune))
|
||||
// Why: a chat-covered handle the host reports again refills its rearm budget,
|
||||
// so an exhausted rearm can't lock the composer until leave-chat.
|
||||
nativeChatStream.notifyListedHandles(liveHandles)
|
||||
// Why: same absence-gated refill for the viewport-fit budget — a handle that
|
||||
// left the list and returned may converge now, so it earns fresh attempts.
|
||||
viewportResubscribeBudgetRef.current.notifyListedHandles(liveHandles)
|
||||
lastKnownTerminalCountRef.current = result.terminals.length
|
||||
// Why: dedupe duplicate handles (rename/split race) to avoid a React duplicate-key throw; keep first for tab-strip order.
|
||||
const seen = new Set<string>()
|
||||
@@ -2627,7 +2648,7 @@ export default function SessionScreen() {
|
||||
useEffect(() => {
|
||||
if (hostId && worktreeId) {
|
||||
void AsyncStorage.setItem(
|
||||
'orca:last-visited-worktree',
|
||||
LAST_VISITED_WORKTREE_STORAGE_KEY,
|
||||
JSON.stringify({ hostId, worktreeId })
|
||||
)
|
||||
}
|
||||
@@ -2856,7 +2877,8 @@ export default function SessionScreen() {
|
||||
worktree: `id:${worktreeId}`,
|
||||
tabId: matchingTab.id,
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
navigation: 'caller',
|
||||
intent: 'user'
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
@@ -2896,7 +2918,8 @@ export default function SessionScreen() {
|
||||
worktree: `id:${worktreeId}`,
|
||||
tabId: tab.id,
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
navigation: 'caller',
|
||||
intent: 'user'
|
||||
}).catch(() => {})
|
||||
}
|
||||
return
|
||||
@@ -2920,7 +2943,8 @@ export default function SessionScreen() {
|
||||
worktree: `id:${worktreeId}`,
|
||||
tabId: tab.id,
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
navigation: 'caller',
|
||||
intent: 'user'
|
||||
}).catch(() => {})
|
||||
}
|
||||
if (tab.type === 'browser') {
|
||||
@@ -3177,44 +3201,23 @@ export default function SessionScreen() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Tap a terminal file path → resolve on host, open as file tab (mirrors desktop Cmd/Ctrl-click); silent on a miss.
|
||||
const handleFileTapActivationSeqRef = useRef(0)
|
||||
const handleFileTap = useCallback(
|
||||
(handle: string, pathText: string, line: number | null, column: number | null) => {
|
||||
if (handle !== activeHandleRef.current || !client) {
|
||||
return
|
||||
}
|
||||
const activationSeq = ++handleFileTapActivationSeqRef.current
|
||||
openMobileTerminalFileTap<MobileSessionTab>({
|
||||
client,
|
||||
hostId,
|
||||
worktreeId,
|
||||
worktreeName: routeWorktreeName,
|
||||
terminalHandle: handle,
|
||||
pathText,
|
||||
cwd: terminalCwdRef.current.get(handle) ?? null,
|
||||
line,
|
||||
column,
|
||||
pushPreviewRoute: (href) => router.push(href),
|
||||
openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
|
||||
triggerOpenFeedback: triggerSelection,
|
||||
fetchSessionTabs,
|
||||
getSessionTabs: () => sessionTabsRef.current,
|
||||
getActiveSessionTabId: () => activeSessionTabIdRef.current,
|
||||
getActivationState: (activated) => ({
|
||||
activated,
|
||||
activationSeq,
|
||||
latestActivationSeq: handleFileTapActivationSeqRef.current,
|
||||
sourceTerminalHandle: handle,
|
||||
activeTerminalHandle: activeHandleRef.current,
|
||||
activeTabType: activeSessionTabTypeRef.current
|
||||
}),
|
||||
switchSessionTab: (tab) => switchSessionTabRef.current?.(tab),
|
||||
scheduleDelayedAction
|
||||
})
|
||||
},
|
||||
[client, fetchSessionTabs, hostId, routeWorktreeName, router, scheduleDelayedAction, worktreeId]
|
||||
)
|
||||
// Tap a terminal or chat file path → resolve on host, open as file tab/preview.
|
||||
const { handleFileTap, handleNativeChatFileTap } = useMobileFileTapHandlers<MobileSessionTab>({
|
||||
client,
|
||||
hostId,
|
||||
worktreeId,
|
||||
worktreeName: routeWorktreeName,
|
||||
activeHandleRef,
|
||||
terminalCwdRef,
|
||||
openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
|
||||
fetchSessionTabs,
|
||||
getSessionTabs: () => sessionTabsRef.current,
|
||||
getActiveSessionTabId: () => activeSessionTabIdRef.current,
|
||||
getActiveSessionTabType: () => activeSessionTabTypeRef.current,
|
||||
switchSessionTab: (tab) => switchSessionTabRef.current?.(tab),
|
||||
scheduleDelayedAction,
|
||||
reportChatTapFailure: nativeChatSendError.show
|
||||
})
|
||||
|
||||
const handleOpenedFileDiffActivationSeqRef = useRef(0)
|
||||
// Capture active tab at tap time; reading it after openDiff would misread a mid-RPC switch and let the retry steal focus.
|
||||
@@ -3585,6 +3588,7 @@ export default function SessionScreen() {
|
||||
if (
|
||||
current &&
|
||||
current.cursorY === metrics.cursorY &&
|
||||
current.contentBottomRow === metrics.contentBottomRow &&
|
||||
current.rows === metrics.rows &&
|
||||
current.altScreen === metrics.altScreen
|
||||
) {
|
||||
@@ -4187,7 +4191,10 @@ export default function SessionScreen() {
|
||||
tabId: activePendingTerminalTab.id,
|
||||
leafId: activePendingTerminalTab.leafId,
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
navigation: 'caller',
|
||||
// Why: this only ever runs for the tab the user is looking at, so it is the
|
||||
// tail of their tap — the gesture that materializes a parked pane.
|
||||
intent: 'user'
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
@@ -4266,24 +4273,11 @@ export default function SessionScreen() {
|
||||
? Math.max(0, keyboardHeight - insets.bottom)
|
||||
: keyboardHeight
|
||||
: 0
|
||||
const activeTerminalKeyboardLift = (() => {
|
||||
if (keyboardLift <= 0 || !activeHandle) {
|
||||
return 0
|
||||
}
|
||||
const metrics = terminalKeyboardMetrics.get(activeHandle)
|
||||
if (!metrics || metrics.rows <= 0 || terminalFrameHeightRef.current <= 0) {
|
||||
return keyboardLift
|
||||
}
|
||||
if (metrics.altScreen) {
|
||||
return keyboardLift
|
||||
}
|
||||
const rowHeight = terminalFrameHeightRef.current / metrics.rows
|
||||
const cursorBottom = (metrics.cursorY + 1) * rowHeight
|
||||
const dockTop = terminalFrameHeightRef.current - keyboardLift
|
||||
const margin = rowHeight
|
||||
// Why: only move the terminal when the cursor would sit under the raised input dock; short top output stays put.
|
||||
return Math.min(keyboardLift, Math.max(0, cursorBottom + margin - dockTop))
|
||||
})()
|
||||
const activeTerminalKeyboardLift = computeActiveTerminalKeyboardLift({
|
||||
keyboardLift,
|
||||
metrics: activeHandle ? terminalKeyboardMetrics.get(activeHandle) : undefined,
|
||||
terminalFrameHeight: terminalFrameHeightRef.current
|
||||
})
|
||||
const toastAnimatedStyle = {
|
||||
opacity: toastOpacityRef.current,
|
||||
transform: [{ translateY: -keyboardLift }]
|
||||
@@ -4747,6 +4741,7 @@ export default function SessionScreen() {
|
||||
))}
|
||||
<MobileNativeChatOverlay
|
||||
controller={nativeChatController}
|
||||
onOpenFile={handleNativeChatFileTap}
|
||||
images={nativeChatImages}
|
||||
onMicPress={handleDictationToggle}
|
||||
micActive={dictation.isRecording}
|
||||
|
||||
+161
-214
@@ -16,18 +16,16 @@ import {
|
||||
} from '../src/components/AccountUsage'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import { navigateToMobileHostEdit } from '../src/transport/host-edit-navigation'
|
||||
import { useOpenMobileHostEdit } from '../src/transport/use-open-mobile-host-edit'
|
||||
import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle'
|
||||
import { pickResumeWorktree } from '../src/worktree/resume-worktree'
|
||||
import { WORKTREE_PS_FULL_LIMIT } from '../src/worktree/worktree-catalog-snapshot-client'
|
||||
import { fetchHomeHostWorktreeInfo } from '../src/worktree/home-host-worktree-fetch'
|
||||
import { totalHomeStats, type HomeStatsSummary } from '../src/stats/home-stats-total'
|
||||
import type { HomeWorktreeSummary, HostWorktreeInfo } from '../src/worktree/home-worktree-info'
|
||||
import type { RpcClient } from '../src/transport/rpc-client'
|
||||
import { createHostConnectRefetchGate } from '../src/transport/host-connect-refetch-gate'
|
||||
import { sendSingleFlightRequest } from '../src/transport/request-single-flight'
|
||||
import {
|
||||
useAllHostClients,
|
||||
useCloseHost,
|
||||
useForceReconnect,
|
||||
usePrimeHosts
|
||||
} from '../src/transport/client-context'
|
||||
import { useCloseHost, useForceReconnect, usePrimeHosts } from '../src/transport/client-context'
|
||||
import { useAllHostClients } from '../src/transport/use-all-host-clients'
|
||||
import { classifyConnection } from '../src/transport/connection-health'
|
||||
import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications'
|
||||
import {
|
||||
@@ -42,7 +40,15 @@ import { TaskProviderLogo } from '../src/components/TaskProviderLogo'
|
||||
import { ActionSheetModal } from '../src/components/ActionSheetModal'
|
||||
import { getHostListActionSheetActions } from '../src/host-list-action-sheet-actions'
|
||||
import { ConfirmModal } from '../src/components/ConfirmModal'
|
||||
import { setCachedWorktrees, getCachedWorktrees } from '../src/cache/worktree-cache'
|
||||
import {
|
||||
setCachedWorktrees,
|
||||
getCachedWorktrees,
|
||||
getProvenCachedWorktrees
|
||||
} from '../src/cache/worktree-cache'
|
||||
import {
|
||||
LAST_VISITED_WORKTREE_STORAGE_KEY,
|
||||
readLastVisitedWorktreeRecord
|
||||
} from '../src/worktree/last-visited-worktree-repo'
|
||||
import { loadHomeSnapshot, saveHomeSnapshot } from '../src/cache/home-snapshot-cache'
|
||||
import { colors, spacing, radii } from '../src/theme/mobile-theme'
|
||||
import {
|
||||
@@ -53,7 +59,14 @@ import {
|
||||
import { useOpenMobileTasks } from '../src/tasks/use-open-mobile-tasks'
|
||||
import { useResponsiveLayout } from '../src/layout/responsive-layout'
|
||||
import { t } from '@/i18n/mobile-i18n'
|
||||
import { createMobileSessionHref } from '../src/session/mobile-session-route'
|
||||
import { useOpenMobileSession } from '../src/session/use-open-mobile-session'
|
||||
import { useOpenMobileAccounts } from '../src/accounts/use-open-mobile-accounts'
|
||||
import {
|
||||
isResumeTargetConfirmedMissing,
|
||||
selectHomeResumeCard,
|
||||
type HomeResumeCard
|
||||
} from '../src/worktree/home-resume-card'
|
||||
import { hostRouteWithNotice } from '../src/host-route-notice'
|
||||
|
||||
function endpointLabel(endpoint: string): string {
|
||||
try {
|
||||
@@ -64,33 +77,6 @@ function endpointLabel(endpoint: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
type StatsSummary = {
|
||||
totalAgentsSpawned: number
|
||||
totalPRsCreated: number
|
||||
totalAgentTimeMs: number
|
||||
firstEventAt: number | null
|
||||
}
|
||||
|
||||
type WorktreeSummary = {
|
||||
worktreeId: string
|
||||
repo: string
|
||||
branch: string
|
||||
displayName: string
|
||||
liveTerminalCount: number
|
||||
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
|
||||
// The worktree the desktop currently has focused (exactly one is true).
|
||||
isActive?: boolean
|
||||
// Last terminal-output time (ms); breaks ties when nothing is focused.
|
||||
lastOutputAt?: number
|
||||
}
|
||||
|
||||
type HostWorktreeInfo = {
|
||||
hostId: string
|
||||
totalWorktrees: number
|
||||
activeCount: number
|
||||
lastActiveWorktree: WorktreeSummary | null
|
||||
}
|
||||
|
||||
type HomeTaskSettings = {
|
||||
visibleTaskProviders?: unknown
|
||||
}
|
||||
@@ -141,7 +127,9 @@ function clientKey(client: RpcClient): number {
|
||||
function fetchStats(
|
||||
client: RpcClient,
|
||||
hostId: string,
|
||||
setStats: (s: StatsSummary) => void,
|
||||
setStats: (
|
||||
updater: (prev: Record<string, HomeStatsSummary>) => Record<string, HomeStatsSummary>
|
||||
) => void,
|
||||
disposed: () => boolean
|
||||
) {
|
||||
sendSingleFlightRequest(client, hostId, 'stats.summary')
|
||||
@@ -150,71 +138,13 @@ function fetchStats(
|
||||
return
|
||||
}
|
||||
if (response.ok) {
|
||||
setStats(response.result as StatsSummary)
|
||||
// Keyed by host: the header totals every desktop instead of showing whoever replied last.
|
||||
setStats((prev) => ({ ...prev, [hostId]: response.result as HomeStatsSummary }))
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
function fetchWorktreeInfo(
|
||||
client: RpcClient,
|
||||
hostId: string,
|
||||
setInfo: (
|
||||
updater: (prev: Record<string, HostWorktreeInfo>) => Record<string, HostWorktreeInfo>
|
||||
) => void,
|
||||
disposed: () => boolean
|
||||
) {
|
||||
// Why: only seed a zeroed entry when the host has no prior info; keep cached data on transient failure so counts don't flip to 0 during reconnects.
|
||||
const markLoadedIfMissing = () => {
|
||||
setInfo((prev) => {
|
||||
if (prev[hostId]) {
|
||||
return prev
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[hostId]: {
|
||||
hostId,
|
||||
totalWorktrees: 0,
|
||||
activeCount: 0,
|
||||
lastActiveWorktree: null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
sendSingleFlightRequest(client, hostId, 'worktree.ps', { limit: WORKTREE_PS_FULL_LIMIT })
|
||||
.then((response) => {
|
||||
if (disposed()) {
|
||||
return
|
||||
}
|
||||
if (response.ok) {
|
||||
const result = response.result as { worktrees: WorktreeSummary[] }
|
||||
const worktrees = result.worktrees ?? []
|
||||
setCachedWorktrees(hostId, worktrees)
|
||||
const activeStatuses = new Set(['working', 'active', 'permission'])
|
||||
const active = worktrees.filter((w) => w.status && activeStatuses.has(w.status))
|
||||
// Mirror the desktop's focused workspace (see pickResumeWorktree).
|
||||
const lastActive = pickResumeWorktree(worktrees)
|
||||
setInfo((prev) => ({
|
||||
...prev,
|
||||
[hostId]: {
|
||||
hostId,
|
||||
totalWorktrees: worktrees.length,
|
||||
activeCount: active.length,
|
||||
lastActiveWorktree: lastActive
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
markLoadedIfMissing()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!disposed()) {
|
||||
markLoadedIfMissing()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function fetchAccountsSnapshot(
|
||||
client: RpcClient,
|
||||
hostId: string,
|
||||
@@ -290,7 +220,10 @@ function repoColor(name: string): string {
|
||||
|
||||
export default function HomeScreen() {
|
||||
const router = useRouter()
|
||||
const openMobileHostEdit = useOpenMobileHostEdit()
|
||||
const openMobileTasks = useOpenMobileTasks()
|
||||
const openMobileSession = useOpenMobileSession()
|
||||
const openMobileAccounts = useOpenMobileAccounts()
|
||||
const insets = useSafeAreaInsets()
|
||||
// Why: cap/center content on wide/tablet canvases so cards don't stretch edge-to-edge on iPad.
|
||||
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
|
||||
@@ -300,7 +233,7 @@ export default function HomeScreen() {
|
||||
const [hostStates, setHostStates] = useState<Record<string, ConnectionState>>({})
|
||||
const [hostAttempts, setHostAttempts] = useState<Record<string, number>>({})
|
||||
const [hostLastConnected, setHostLastConnected] = useState<Record<string, number | null>>({})
|
||||
const [stats, setStats] = useState<StatsSummary | null>(null)
|
||||
const [statsByHost, setStatsByHost] = useState<Record<string, HomeStatsSummary>>({})
|
||||
const [worktreeInfo, setWorktreeInfo] = useState<Record<string, HostWorktreeInfo>>({})
|
||||
const [accountsByHost, setAccountsByHost] = useState<Record<string, AccountsSnapshot>>({})
|
||||
const [taskProvidersByHost, setTaskProvidersByHost] = useState<Record<string, TaskProvider[]>>({})
|
||||
@@ -313,6 +246,8 @@ export default function HomeScreen() {
|
||||
|
||||
// Why: shared clients from the per-host store, not N independent WebSockets. See docs/mobile-shared-client-per-host.md.
|
||||
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
|
||||
// Why: scoped to the paired hosts so an unpaired desktop's cached reply leaves the header total.
|
||||
const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds])
|
||||
const allClients = useAllHostClients(hostIds)
|
||||
const hostPaths = useMemo(
|
||||
() => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])),
|
||||
@@ -393,18 +328,18 @@ export default function HomeScreen() {
|
||||
router.replace(mobileOnboardingDestination(onboardingSteps))
|
||||
}
|
||||
})
|
||||
void AsyncStorage.getItem('orca:last-visited-worktree').then((raw) => {
|
||||
if (stale || !raw) {
|
||||
void AsyncStorage.getItem(LAST_VISITED_WORKTREE_STORAGE_KEY).then((raw) => {
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
setLastVisited(JSON.parse(raw))
|
||||
} catch {}
|
||||
// Why the validating reader: this record becomes the Resume card's navigation target,
|
||||
// so a malformed or older-shaped payload must read as no history, not a broken route.
|
||||
setLastVisited(readLastVisitedWorktreeRecord(raw))
|
||||
})
|
||||
for (const entry of allClientsRef.current) {
|
||||
if (entry.client.getState() === 'connected') {
|
||||
fetchStats(entry.client, entry.hostId, setStats, () => stale)
|
||||
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale)
|
||||
fetchStats(entry.client, entry.hostId, setStatsByHost, () => stale)
|
||||
void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale)
|
||||
fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale)
|
||||
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => stale)
|
||||
}
|
||||
@@ -485,60 +420,70 @@ export default function HomeScreen() {
|
||||
})
|
||||
}, [allClients, hosts])
|
||||
|
||||
// Per-host notif/accounts subs + one-shot stats on 'connected'; re-runs per (hostId, client) pair, socket stays open so it's cheap.
|
||||
useEffect(() => {
|
||||
const cleanups: Array<() => void> = []
|
||||
for (const entry of allClients) {
|
||||
let unsubNotif: (() => void) | null = null
|
||||
let unsubAccounts: (() => void) | null = null
|
||||
let statsFetched = false
|
||||
const wireUp = (state: ConnectionState) => {
|
||||
if (state === 'connected') {
|
||||
if (!unsubNotif) {
|
||||
unsubNotif = subscribeToDesktopNotifications(entry.client, entry.hostId)
|
||||
}
|
||||
if (!unsubAccounts) {
|
||||
unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return
|
||||
// Notif/accounts subs + a snapshot read per connect for one host. Lives outside the effect body
|
||||
// because react-doctor's effect-needs-cleanup false-positives on `subscribe` inside one; the
|
||||
// returned disposer owns every handle allocated here.
|
||||
const wireHostSubscriptions = (entry: {
|
||||
hostId: string
|
||||
client: RpcClient
|
||||
state: ConnectionState
|
||||
}) => {
|
||||
let unsubNotif: (() => void) | null = null
|
||||
let unsubAccounts: (() => void) | null = null
|
||||
const refetchGate = createHostConnectRefetchGate()
|
||||
const wireUp = (state: ConnectionState) => {
|
||||
const reconnected = refetchGate.observe(state)
|
||||
if (state === 'connected') {
|
||||
if (!unsubNotif) {
|
||||
unsubNotif = subscribeToDesktopNotifications(entry.client, entry.hostId)
|
||||
}
|
||||
if (!unsubAccounts) {
|
||||
unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return
|
||||
}
|
||||
const evt = payload as { type?: string; snapshot?: unknown }
|
||||
if (evt.type === 'ready' || evt.type === 'snapshot') {
|
||||
try {
|
||||
const snapshot = decodeAccountsSnapshot(evt.snapshot)
|
||||
setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: snapshot }))
|
||||
} catch {
|
||||
// Keep the last proven snapshot; malformed remote data must
|
||||
// not enter render state or crash the home host cards.
|
||||
}
|
||||
const evt = payload as { type?: string; snapshot?: unknown }
|
||||
if (evt.type === 'ready' || evt.type === 'snapshot') {
|
||||
try {
|
||||
const snapshot = decodeAccountsSnapshot(evt.snapshot)
|
||||
setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: snapshot }))
|
||||
} catch {
|
||||
// Keep the last proven snapshot; malformed remote data must
|
||||
// not enter render state or crash the home host cards.
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
if (!statsFetched) {
|
||||
statsFetched = true
|
||||
fetchStats(entry.client, entry.hostId, setStats, () => false)
|
||||
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false)
|
||||
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false)
|
||||
}
|
||||
} else {
|
||||
if (unsubNotif) {
|
||||
unsubNotif()
|
||||
unsubNotif = null
|
||||
}
|
||||
if (unsubAccounts) {
|
||||
unsubAccounts()
|
||||
unsubAccounts = null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
// Why: the socket survives backgrounding/handoffs by reconnecting, so re-read the host
|
||||
// snapshot on every reconnect — a one-shot latch left the card on stale data forever.
|
||||
if (reconnected) {
|
||||
fetchStats(entry.client, entry.hostId, setStatsByHost, () => false)
|
||||
void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false)
|
||||
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false)
|
||||
}
|
||||
} else {
|
||||
if (unsubNotif) {
|
||||
unsubNotif()
|
||||
unsubNotif = null
|
||||
}
|
||||
if (unsubAccounts) {
|
||||
unsubAccounts()
|
||||
unsubAccounts = null
|
||||
}
|
||||
}
|
||||
wireUp(entry.state)
|
||||
const unsubState = entry.client.onStateChange(wireUp)
|
||||
cleanups.push(() => {
|
||||
unsubState()
|
||||
unsubNotif?.()
|
||||
unsubAccounts?.()
|
||||
})
|
||||
}
|
||||
wireUp(entry.state)
|
||||
const unsubState = entry.client.onStateChange(wireUp)
|
||||
return () => {
|
||||
unsubState()
|
||||
unsubNotif?.()
|
||||
unsubAccounts?.()
|
||||
}
|
||||
}
|
||||
|
||||
// Re-runs per (hostId, client) pair; the socket stays open so it's cheap.
|
||||
useEffect(() => {
|
||||
const cleanups = allClients.map((entry) => wireHostSubscriptions(entry))
|
||||
return () => {
|
||||
for (const c of cleanups) {
|
||||
c()
|
||||
@@ -552,28 +497,43 @@ export default function HomeScreen() {
|
||||
.join(',')
|
||||
])
|
||||
|
||||
// Why: prefer the worktree last opened on this device so Resume reflects mobile session history.
|
||||
// Why: don't gate on 'connected' so the card doesn't flash empty for ~1s on cold-start; cached data holds until fresh RPC lands.
|
||||
const resumeWorktree = useMemo(() => {
|
||||
// Why: only surface Resume for connected hosts; a stale worktree taps into a route that can't load.
|
||||
if (lastVisited && hostStates[lastVisited.hostId] === 'connected') {
|
||||
const cached = getCachedWorktrees(lastVisited.hostId) as WorktreeSummary[] | null
|
||||
const match = cached?.find((w) => w.worktreeId === lastVisited.worktreeId)
|
||||
if (match) {
|
||||
return { hostId: lastVisited.hostId, worktree: match }
|
||||
// Why: the card renders from cached/snapshot data the moment a candidate exists — see
|
||||
// selectHomeResumeCard for why its slot must not wait for the host to connect.
|
||||
const resumeCard = useMemo(
|
||||
() =>
|
||||
selectHomeResumeCard({
|
||||
hosts: sortedHosts,
|
||||
hostStates,
|
||||
worktreeInfo,
|
||||
lastVisited,
|
||||
cachedWorktrees: (hostId) => getCachedWorktrees(hostId) as HomeWorktreeSummary[] | null
|
||||
}),
|
||||
[sortedHosts, hostStates, worktreeInfo, lastVisited]
|
||||
)
|
||||
|
||||
// Why: the card is drawn from a snapshot that can name a workspace the desktop has since
|
||||
// deleted. When the host has proven otherwise, open its workspace list rather than a session
|
||||
// screen whose every RPC would fail. An unproven catalog is not evidence — that tap goes
|
||||
// through and the session screen bounces once the host answers (F7).
|
||||
const openResume = useCallback(
|
||||
(card: HomeResumeCard) => {
|
||||
if (
|
||||
isResumeTargetConfirmedMissing(
|
||||
card,
|
||||
getProvenCachedWorktrees(card.hostId) as HomeWorktreeSummary[] | null
|
||||
)
|
||||
) {
|
||||
router.push(hostRouteWithNotice(card.hostId, 'worktree-missing'))
|
||||
return
|
||||
}
|
||||
}
|
||||
for (const host of sortedHosts) {
|
||||
if (hostStates[host.id] !== 'connected') {
|
||||
continue
|
||||
}
|
||||
const info = worktreeInfo[host.id]
|
||||
if (info?.lastActiveWorktree) {
|
||||
return { hostId: host.id, worktree: info.lastActiveWorktree }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [sortedHosts, hostStates, worktreeInfo, lastVisited])
|
||||
openMobileSession({
|
||||
hostId: card.hostId,
|
||||
worktreeId: card.worktree.worktreeId,
|
||||
name: card.worktree.displayName || card.worktree.repo
|
||||
})
|
||||
},
|
||||
[openMobileSession, router]
|
||||
)
|
||||
|
||||
// Why: only show Account usage for connected hosts; stale cached usage would imply live data.
|
||||
const accountsHosts = useMemo(() => {
|
||||
@@ -615,7 +575,7 @@ export default function HomeScreen() {
|
||||
disabled={!primaryConnectedHost}
|
||||
style={({ pressed }) => [
|
||||
styles.taskHomeCard,
|
||||
!primaryConnectedHost && styles.quickActionDisabled,
|
||||
!primaryConnectedHost && styles.cardDisabled,
|
||||
pressed && styles.hostCardPressed
|
||||
]}
|
||||
onPress={() => {
|
||||
@@ -775,7 +735,6 @@ export default function HomeScreen() {
|
||||
const state = hostStates[item.id] ?? 'connecting'
|
||||
const attempts = hostAttempts[item.id] ?? 0
|
||||
const lastConnectedAt = hostLastConnected[item.id] ?? null
|
||||
const info = worktreeInfo[item.id]
|
||||
const verdict = classifyConnection({
|
||||
state,
|
||||
reconnectAttempts: attempts,
|
||||
@@ -788,9 +747,7 @@ export default function HomeScreen() {
|
||||
state={state}
|
||||
verdict={verdict}
|
||||
path={hostPaths[item.id] ?? 'lan'}
|
||||
worktreeCounts={
|
||||
info ? { total: info.totalWorktrees, active: info.activeCount } : undefined
|
||||
}
|
||||
worktreeInfo={worktreeInfo[item.id]}
|
||||
onPress={() => router.push(`/h/${item.id}`)}
|
||||
onLongPress={() => {
|
||||
triggerMediumImpact()
|
||||
@@ -802,59 +759,49 @@ export default function HomeScreen() {
|
||||
ListFooterComponent={
|
||||
<View>
|
||||
{/* ─── Resume card ─── */}
|
||||
{resumeWorktree ? (
|
||||
{resumeCard ? (
|
||||
<>
|
||||
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>
|
||||
{t('home.resume')}
|
||||
</Text>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.resumeCard, pressed && styles.hostCardPressed]}
|
||||
onPress={() =>
|
||||
router.push(
|
||||
createMobileSessionHref({
|
||||
hostId: resumeWorktree.hostId,
|
||||
worktreeId: resumeWorktree.worktree.worktreeId,
|
||||
name: resumeWorktree.worktree.displayName || resumeWorktree.worktree.repo
|
||||
})
|
||||
)
|
||||
}
|
||||
disabled={!resumeCard.actionable}
|
||||
style={({ pressed }) => [
|
||||
styles.resumeCard,
|
||||
!resumeCard.actionable && styles.cardDisabled,
|
||||
pressed && styles.hostCardPressed
|
||||
]}
|
||||
onPress={() => openResume(resumeCard)}
|
||||
>
|
||||
<View style={styles.resumeIcon}>
|
||||
<Terminal size={18} color={colors.textSecondary} />
|
||||
</View>
|
||||
<View style={styles.resumeMain}>
|
||||
<Text style={styles.resumeTitle} numberOfLines={1}>
|
||||
{resumeWorktree.worktree.displayName}
|
||||
{resumeCard.worktree.displayName}
|
||||
</Text>
|
||||
<View style={styles.resumeSub}>
|
||||
<View
|
||||
style={[
|
||||
styles.repoDot,
|
||||
{ backgroundColor: repoColor(resumeWorktree.worktree.repo) }
|
||||
{ backgroundColor: repoColor(resumeCard.worktree.repo) }
|
||||
]}
|
||||
/>
|
||||
<Text style={styles.resumeSubText} numberOfLines={1}>
|
||||
{resumeWorktree.worktree.repo}
|
||||
{resumeCard.worktree.repo}
|
||||
{' · '}
|
||||
{resumeWorktree.worktree.branch}
|
||||
{resumeCard.worktree.branch}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>
|
||||
{t('home.tasks')}
|
||||
</Text>
|
||||
{renderTaskHomeCard()}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>
|
||||
{t('home.tasks')}
|
||||
</Text>
|
||||
{renderTaskHomeCard()}
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>
|
||||
{t('home.tasks')}
|
||||
</Text>
|
||||
{renderTaskHomeCard()}
|
||||
|
||||
{/* ─── Quick actions ─── */}
|
||||
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>
|
||||
@@ -876,7 +823,7 @@ export default function HomeScreen() {
|
||||
disabled={!primaryConnectedHost}
|
||||
style={({ pressed }) => [
|
||||
styles.quickAction,
|
||||
!primaryConnectedHost && styles.quickActionDisabled,
|
||||
!primaryConnectedHost && styles.cardDisabled,
|
||||
pressed && styles.hostCardPressed
|
||||
]}
|
||||
onPress={() => {
|
||||
@@ -915,7 +862,7 @@ export default function HomeScreen() {
|
||||
styles.accountsCard,
|
||||
pressed && styles.hostCardPressed
|
||||
]}
|
||||
onPress={() => router.push(`/h/${host.id}/accounts`)}
|
||||
onPress={() => openMobileAccounts(host.id)}
|
||||
>
|
||||
{showHostName ? (
|
||||
<Text style={styles.accountsHostLabel} numberOfLines={1}>
|
||||
@@ -997,7 +944,7 @@ export default function HomeScreen() {
|
||||
onDismiss: () => setActionTarget(null),
|
||||
onReconnect: (hostId) => void forceReconnectHost(hostId),
|
||||
onDisconnect: closeHostClient,
|
||||
onEdit: (hostId) => navigateToMobileHostEdit(router, hostId),
|
||||
onEdit: openMobileHostEdit,
|
||||
onRemove: setConfirmRemove
|
||||
})}
|
||||
onClose={() => setActionTarget(null)}
|
||||
@@ -1317,7 +1264,7 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
gap: 10
|
||||
},
|
||||
quickActionDisabled: {
|
||||
cardDisabled: {
|
||||
opacity: 0.45
|
||||
},
|
||||
quickActionIcon: {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native
|
||||
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import type { HostProfile } from '../src/transport/types'
|
||||
import { useAllHostClients } from '../src/transport/client-context'
|
||||
import { useAllHostClients } from '../src/transport/use-all-host-clients'
|
||||
import type { RpcClient } from '../src/transport/rpc-client'
|
||||
import { PickerModal, type PickerOption } from '../src/components/PickerModal'
|
||||
import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings'
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ChevronLeft, ChevronRight } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import type { HostProfile } from '../src/transport/types'
|
||||
import { useAllHostClients } from '../src/transport/client-context'
|
||||
import { useAllHostClients } from '../src/transport/use-all-host-clients'
|
||||
import type { RpcClient } from '../src/transport/rpc-client'
|
||||
import { BottomDrawer } from '../src/components/BottomDrawer'
|
||||
import { VoiceModelList } from '../src/components/VoiceModelList'
|
||||
|
||||
+2
-1
@@ -7,7 +7,7 @@
|
||||
"start": "node scripts/start-expo.mjs",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"postinstall": "node scripts/build-terminal-webview-engine.mjs",
|
||||
"postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "oxlint",
|
||||
@@ -50,6 +50,7 @@
|
||||
"i18next": "^26.3.6",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react-native": "^1.14.0",
|
||||
"mermaid": "11.16.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "19.2.6",
|
||||
"react-native": "^0.83.9",
|
||||
|
||||
Generated
+792
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const scriptDir = import.meta.dirname
|
||||
const mobileRoot = path.resolve(scriptDir, '..')
|
||||
const outputPath = path.join(
|
||||
mobileRoot,
|
||||
'src',
|
||||
'components',
|
||||
'pr-sidebar',
|
||||
'mermaid-webview-engine.generated.ts'
|
||||
)
|
||||
|
||||
// Why: unlike the terminal engine (esbuilt from source for old-WebView shims),
|
||||
// mermaid's prebuilt UMD bundle is embedded verbatim — it is the exact artifact
|
||||
// the diagram WebView previously fetched from a CDN, now pinned and
|
||||
// integrity-checked through the lockfile and available offline.
|
||||
async function main() {
|
||||
const packageJsonPath = require.resolve('mermaid/package.json')
|
||||
const [packageJson, bundleJs] = await Promise.all([
|
||||
readFile(packageJsonPath, 'utf8').then(JSON.parse),
|
||||
readFile(path.join(path.dirname(packageJsonPath), 'dist', 'mermaid.min.js'), 'utf8')
|
||||
])
|
||||
|
||||
// A literal </script> inside the bundle would close the inline script tag it
|
||||
// is embedded in; \/ is identical to / in JS strings, regexes, and comments.
|
||||
const inlineSafeJs = bundleJs.replace(/<\/script/gi, '<\\/script')
|
||||
|
||||
const source = [
|
||||
'// Generated by scripts/build-mermaid-webview-engine.mjs.',
|
||||
`// Package: mermaid@${packageJson.version} (dist/mermaid.min.js, embedded verbatim).`,
|
||||
'// Do not edit by hand; regenerate via pnpm postinstall.',
|
||||
`export const MERMAID_ENGINE_JS = ${JSON.stringify(inlineSafeJs)}`,
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
await writeFile(outputPath, source)
|
||||
}
|
||||
|
||||
await main()
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# Repro driver for "mobile sits on Connecting… after leaving and re-entering the
|
||||
# app" (Slack P0, iOS 0.0.39(2)).
|
||||
#
|
||||
# Two things the iOS Simulator will not do on its own, and how this works around
|
||||
# them:
|
||||
#
|
||||
# * It never suspends an app the way a device does, so the JS runtime keeps
|
||||
# servicing timers while backgrounded and the bug hides. SIGSTOP on the app
|
||||
# process reproduces real suspension: timers frozen, socket left dangling.
|
||||
# * SIGSTOP on the desktop runtime makes its port blackhole — the kernel still
|
||||
# completes the TCP handshake from the listen backlog, but no WebSocket
|
||||
# upgrade ever comes back, which is what a wedged Tailscale tunnel or a relay
|
||||
# with nothing behind it looks like to the phone.
|
||||
#
|
||||
# Everything is driven with xcrun simctl rather than `orca emulator`, because the
|
||||
# emulator CLI routes through the desktop runtime that this script freezes.
|
||||
#
|
||||
# Setup:
|
||||
# node scripts/start-emulator.mjs --device "iPhone 17 Pro" --wait-for-ready \
|
||||
# > /tmp/orca-emulator-boot.log 2>&1 &
|
||||
#
|
||||
# Usage: repro-mobile-foreground-stall.sh <udid> <paired-host-port> [log]
|
||||
set -euo pipefail
|
||||
|
||||
UDID="${1:?simulator udid}"
|
||||
PORT="${2:?port of the paired host, from the [net] logs}"
|
||||
LOG="${3:-/tmp/orca-emulator-boot.log}"
|
||||
BUNDLE_ID=com.stably.orca.mobile
|
||||
# Long enough for the tiered backoff to reach its 30s/60s tail.
|
||||
ESCALATE_SECONDS=200
|
||||
|
||||
APP=$(pgrep -f "CoreSimulator.*Orca.app/Orca" | head -1)
|
||||
DESK=$(pgrep -f "serve-mobile-pairing" | head -1)
|
||||
: "${APP:?mobile app is not running in the simulator}"
|
||||
: "${DESK:?headless desktop runtime is not running}"
|
||||
|
||||
strip() { sed 's/\x1b\[[0-9;]*m//g'; }
|
||||
since() { sed -n "$(( $1 + 1 )),\$p" "$LOG" | strip; }
|
||||
step() { echo "$(date +%T) $*"; }
|
||||
|
||||
echo "### app=$APP desktop=$DESK port=$PORT"
|
||||
|
||||
step "[1] desktop goes unreachable"
|
||||
kill -STOP "$DESK"
|
||||
sleep "$ESCALATE_SECONDS"
|
||||
step "[2] backoff at $(since 0 | grep "$PORT" | grep -o '"attempt": [0-9]*' | tail -1)"
|
||||
|
||||
MARK=$(wc -l < "$LOG")
|
||||
while true; do
|
||||
since "$MARK" | grep "$PORT" | grep -q '"to": "connecting"' && break
|
||||
sleep 0.3
|
||||
done
|
||||
step "[3] connect window open — user switches away"
|
||||
xcrun simctl launch "$UDID" com.apple.mobilesafari >/dev/null 2>&1
|
||||
sleep 1
|
||||
kill -STOP "$APP"
|
||||
step "[4] phone suspended mid-dial"
|
||||
sleep 5
|
||||
|
||||
MARK=$(wc -l < "$LOG")
|
||||
kill -CONT "$APP"
|
||||
sleep 0.3
|
||||
xcrun simctl launch "$UDID" "$BUNDLE_ID" >/dev/null 2>&1
|
||||
T0=$(date +%s)
|
||||
step "[5] user returns to Orca <-- t0, desktop still down"
|
||||
|
||||
# The desktop is deliberately still unreachable here: the only question is
|
||||
# whether returning to the app abandons the dead dial or waits it out.
|
||||
sleep 6
|
||||
echo "--- first 6s after t0 ---"
|
||||
since "$MARK" | grep -E "$PORT|foreground" \
|
||||
| grep -E 'state |foreground|scheduleReconnect|openConnection' | head -8
|
||||
|
||||
kill -CONT "$DESK"
|
||||
step "[6] desktop healthy again"
|
||||
for _ in $(seq 1 45); do
|
||||
if since "$MARK" | grep "$PORT" | grep -q '"to": "connected"'; then
|
||||
step ">>> connected $(( $(date +%s) - T0 ))s after t0"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
step ">>> STILL STUCK 90s after t0"
|
||||
exit 1
|
||||
@@ -139,7 +139,7 @@ async function chooseWorktree(ws: WebSocket): Promise<string> {
|
||||
}
|
||||
|
||||
async function chooseTerminal(ws: WebSocket, worktree: string): Promise<string> {
|
||||
const list = await send(ws, 'terminal.list', { worktree })
|
||||
const list = await send(ws, 'terminal.list', { worktree, includeVisualLayouts: false })
|
||||
if (!list.ok) {
|
||||
throw new Error(`terminal.list failed: ${formatResponse(list)}`)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user