Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
Neil
2026-09-02 13:13:07 -07:00
423 changed files with 23738 additions and 4261 deletions
+3
View File
@@ -897,6 +897,9 @@ jobs:
contents: read
uses: ./.github/workflows/e2e.yml
with:
# The synthetic pull-request merge ref can disappear while this reusable
# workflow is queued. The head SHA is immutable and works for every PR.
ref: ${{ github.event.pull_request.head.sha }}
test_files: ${{ needs.e2e-paths.outputs.test_files }}
ssh_source_changed: ${{ needs.e2e-paths.outputs.ssh_source_changed }}
@@ -114,6 +114,7 @@ describe('PR E2E gate contract', () => {
expect(prWorkflow.jobs['e2e-paths'].outputs.test_files).toBe(
'${{ steps.filter.outputs.test_files }}'
)
expect(prWorkflow.jobs.e2e.with.ref).toBe('${{ github.event.pull_request.head.sha }}')
expect(prWorkflow.jobs.e2e.with.test_files).toBe('${{ needs.e2e-paths.outputs.test_files }}')
})
+1
View File
@@ -71,6 +71,7 @@ const result = spawnSync(
'tests/e2e/ssh-ai-vault-session-history.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts',
'tests/e2e/ssh-docker-quick-open-large-listing.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts',
'tests/e2e/ssh-external-image-preview.spec.ts',
+15
View File
@@ -357,6 +357,21 @@ the command:
This disables a security boundary. Prefer a dedicated unprivileged service
user, especially when the listener is reachable beyond localhost.
The Linux CLI is named `orca-ide`, not `orca`, so it never shadows the GNOME
Orca screen reader at `/usr/bin/orca`. The `.deb` and `.rpm` packages put
`orca-ide` on `PATH` themselves at install time; with the AppImage it arrives
as `~/.local/bin/orca-ide` when the CLI is registered.
A packaged `orca serve` start also writes a bare `orca` into `~/.local/bin`
that execs the same launcher, which is why the skills commands below can be
typed as `orca`. It writes it while starting, so it is never the command that
starts the server — the first launch is `orca-ide serve`, or the AppImage
invoked directly as above. The write is best-effort: it is gated on a packaged
build, it is skipped when no bundled launcher resolves, and it is skipped when
a file Orca does not own already holds that name (ownership is a marker on the
second line of the file). A host that really does run the screen reader keeps
its own `orca`.
## Pairing troubleshooting
- A pairing offer is a capability containing a device credential and E2EE
+45 -19
View File
@@ -72,12 +72,20 @@ behavioural engine can be expected to score it low.
### Every process gets a handle, on a timer
`src/main/windows/windows-process-table.ts` takes a Toolhelp32 snapshot under one
of two flag sets. Identity (`None | CreationTime`) answers pid/ppid/name from the
snapshot alone and opens nothing; the detailed set adds `CommandLine`, which
costs one `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)` per process. `Memory`
is retired — it took a second handle carrying `PROCESS_VM_READ` and never read
through it.
`src/main/windows/windows-process-table.ts` takes a Toolhelp32 snapshot under
**one** flag set, `CommandLine | CreationTime`, shared by every caller. pid, ppid
and name come out of the snapshot itself and open nothing. `CommandLine` is what
opens a handle: the addon calls `GetProcessCommandLine` per process, which opens
`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` and walks the PEB with three
`ReadProcessMemory` calls (`src/process_commandline.cc:32,41-47` in the vendored
`@vscode/windows-process-tree` 0.8.0 source that `config/patches/` patches).
`Memory` is retired as of this change, and that is a real reduction: it made
`GetProcessMemoryUsage` open a **second** `PROCESS_QUERY_INFORMATION |
PROCESS_VM_READ` handle per process for a `GetProcessMemoryInfo` call whose
result no caller read (`src/process.cc:47-63`). Dropping it halves the handles
opened per snapshot. It does not remove the remote memory read, because the
command line still performs one.
It exists because seven independent readers used to fork `powershell.exe` for a
`Get-CimInstance Win32_Process` scan. That cost, measured: a PowerShell
@@ -90,23 +98,41 @@ panes multiplied it (#15036). The native snapshot answers the same question in
See
[`windows-process-enumeration.md`](./windows-process-enumeration.md).
Asking for fewer fields is cheaper, and since the split the module does: one
cache per flag set, so teardown identity and the session owner probe open no
handle at all (6.3 ms p50) while only the callers that read a command line pay
for one (12.3 ms p50, at 492 processes). Each cache still single-flights within
itself, and one gate serializes the native reads because the vendored wrapper
coalesces the flags of two overlapping calls.
Asking for fewer fields is cheaper, and the module now asks for the smallest set
that still answers every caller. There is **no** per-flag-set cache split: one
TTL-cached snapshot serves everyone, deliberately, because a split would restore
the per-pane fan-out the cache exists to remove — a 32-wide teardown has to
collapse into one scan. So the cheap identity-only read is not something any
caller can select; every read pays for `CommandLine`. An earlier revision of this
file described a two-cache design with 6.3 ms / 12.3 ms p50 figures at 492
processes. That design is not in the tree and those numbers describe no code
path here; the figures that do apply are the module's own, in
[`windows-process-enumeration.md`](./windows-process-enumeration.md).
**How an EDR reads it:** a cross-process handle plus a remote memory read against
every process on the box, repeating on a cadence, is the read half of the
telemetry that credential dumping and process injection produce. MDE surfaced it
as "suspicious memory activity". The memory read is gone: the command line now
comes from the kernel, through `NtQueryInformationProcess`'s
`ProcessCommandLineInformation` class, which needs only
`PROCESS_QUERY_LIMITED_INFORMATION`. `ReadProcessMemory` is absent from the
compiled addon, asserted against the binary's import table because the published
prebuild loads fine and emits byte-identical strings. What is left to declare to
administrators is the per-process handle itself.
as "suspicious memory activity".
**That signal is still present.** An earlier revision of this file claimed the
command line "now comes from the kernel" through `NtQueryInformationProcess`'s
`ProcessCommandLineInformation` class, needing only
`PROCESS_QUERY_LIMITED_INFORMATION`, and that `ReadProcessMemory` was absent from
the compiled addon. None of that is true of the code we ship.
`process_commandline.cc` calls `NtQueryInformationProcess` with
`ProcessBasicInformation` only — to locate the PEB — and then issues three
`ReadProcessMemory` calls against a `PROCESS_VM_READ` handle to read the PEB, the
`RTL_USER_PROCESS_PARAMETERS`, and the command-line buffer. Nothing asserts an
import table, and no such assertion would pass.
What this change did remove is the `Memory` flag's second handle and its
`GetProcessMemoryInfo` call, so the per-process handle count per snapshot halves.
What remains to declare to administrators is unchanged in kind: one
`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` handle and a PEB read against every
process on the box, at the shared snapshot's cadence. Moving to
`ProcessCommandLineInformation` (Windows 8.1+, `PROCESS_QUERY_LIMITED_INFORMATION`
only) would genuinely retire the remote read, but it is an addon patch nobody has
written; treat it as unclaimed work, not as shipped.
### Encoded, policy-bypassing PowerShell
+13 -2
View File
@@ -40,6 +40,15 @@ Measured on Windows 11 with 1050 processes (p50 / p95):
| + memory + command line | 30.6 ms | 33.7 ms |
| `Get-CimInstance` via PowerShell | 706 ms | 723 ms |
Those are the module's published figures. The flag set this module actually
requests is `CommandLine | CreationTime` — **not** `Memory`, which cost a second
`OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ)` plus
`GetProcessMemoryInfo` per process (`src/process.cc:47-63`) for a value nothing
read. Dropping it halves the handles a snapshot opens. The remaining set sits
between the two rows above and has not been measured separately; on a real
Windows host, `Get-Counter '\Process(Orca)\Handle Count'` sampled across a
snapshot cadence is the check.
Those CIM numbers are from a 1050-process host. The scan scales with process
count: on a 1486-process Windows SSH host it measured **1.36 s** and produced
**4.8 MiB** of JSON, against the fallback's 3 s and 8 MiB limits. Both limits
@@ -220,11 +229,13 @@ ownership, and CPU accounting in the memory collector — still reads it through
its own query. Those callers are not migrated.
Committed private bytes have no equivalent either, and the one memory value the
snapshot does carry is unusable for the sizes Orca now sees: `process.cc` stores
snapshot _can_ carry is unusable for the sizes Orca now sees: `process.cc` stores
`pmc.WorkingSetSize` into a `DWORD`, so anything above 4 GB wraps. That is the
second reason `windows-process-resource-collector.ts` still runs its own
`Get-CimInstance` sweep — it needs `PageFileUsage` (commit) and the CPU-time
counters in the same pass. Migrating it to the native table would cost both.
counters in the same pass. Migrating it to the native table would cost both, and
it is why this module no longer sets the `Memory` flag at all: the field had no
reader, and asking for it opened a handle per process on every snapshot.
Start time is a proxy for identity, not identity. The durable answer for the
process trees Orca itself spawns is an inherited handle: a job object names the
@@ -35,11 +35,11 @@ silent, and indistinguishable from the real thing.
Three instances so far:
| Where | What the user saw | Status |
| --- | --- | --- |
| Preflight CLI probes | Caching the result would have pinned "git not installed" until relaunch | Bounded entry ([#17350](https://github.com/stablyai/orca/pull/17350)) |
| `glab auth status` fallback into WSL | Idle VM woken repeatedly for users who never touch GitLab | Open ([#8941](https://github.com/stablyai/orca/issues/8941)) |
| `listRunningWslDistrosAsync` | Fails closed to `[]` with no last-known-good, polled every 2s — a persistently broken `wsl.exe` makes every WSL session vanish app-wide | Open (PR #17072 review) |
| Where | What the user saw | Status |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| Preflight CLI probes | Caching the result would have pinned "git not installed" until relaunch | Bounded entry ([#17350](https://github.com/stablyai/orca/pull/17350)) |
| `glab auth status` fallback into WSL | Idle VM woken repeatedly for users who never touch GitLab | Open ([#8941](https://github.com/stablyai/orca/issues/8941)) |
| `listRunningWslDistrosAsync` | Fails closed to `[]` with no last-known-good, polled every 2s — a persistently broken `wsl.exe` makes every WSL session vanish app-wide | Open (PR #17072 review) |
## What to do instead
+1 -1
View File
@@ -14,7 +14,7 @@ import { Callout } from '@/components/docs/prose'
The Orca CLI is the `orca` command-line interface for scripting a running Orca editor from any shell. Use it to create and inspect worktrees, drive agent terminals, open files and diffs, automate the built-in browser, run scheduled automations, share HTML/Markdown artifacts, and control Orca-native tools from scripts or AI agents.
It ships with the desktop app; register it under [Settings → General → Orca CLI](/docs/settings).
It ships with the desktop app; register it under [Settings → General → Orca CLI](/docs/settings). On Linux the command is `orca-ide`, because GNOME Orca's screen reader already owns `/usr/bin/orca` — see [Install → Linux](/docs/install#linux).
Agents can install the matching Orca CLI skill with:
+17 -1
View File
@@ -9,13 +9,29 @@ The `orca` CLI talks to a running Orca runtime. Use it when a shell script or ag
## Verify the runtime
Register the CLI under [Settings → General → Orca CLI](/docs/settings), then check that it can reach Orca:
Register the CLI under [Settings → General → Orca CLI](/docs/settings), then check that it can reach Orca.
<Callout title="On Linux the command is orca-ide">
GNOME Orca — the screen reader that ships with most GNOME desktops — already owns `/usr/bin/orca`,
so Orca's Linux CLI installs as `orca-ide`. Do not check for it with `command -v orca`: that
succeeds on a GNOME desktop and resolves to the screen reader, not to Orca. This page writes
`orca` throughout — read it as `orca-ide` on Linux. See [Install → Linux](/docs/install#linux).
</Callout>
On macOS and Windows:
```bash
command -v orca
orca status --json
```
On Linux:
```bash
command -v orca-ide
orca-ide status --json
```
If Orca is not already running:
```bash
+53 -3
View File
@@ -31,8 +31,11 @@ import { Callout } from '@/components/docs/prose'
</li>
<li>
**Linux:**
[AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) ·
[.deb](https://github.com/stablyai/orca/releases)
AppImage
[x64](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) ·
[arm64](https://github.com/stablyai/orca/releases/latest/download/orca-linux-arm64.AppImage) ·
[.deb](https://github.com/stablyai/orca/releases) ·
[.rpm](https://github.com/stablyai/orca/releases) — see [Linux](#linux) for which to pick
</li>
<li>Older versions: [GitHub Releases](https://github.com/stablyai/orca/releases).</li>
</ul>
@@ -59,6 +62,8 @@ On first launch Orca will:
Orca auto-updates by default, tracking the **stable** channel. Stable releases are vetted; **RC (release candidate)** builds ship new features first, often daily.
On Linux, whether Orca can apply an update itself depends on which package you installed. See [Linux](#linux) before you pick one.
There is no permanent in-app opt-in for the RC channel. Modifier clicks on **Check for Updates** ([Settings → General → Updates](/docs/settings), or the app / Help menu):
| Modifier | Effect |
@@ -87,4 +92,49 @@ The default shell can be set to PowerShell or CMD under [Settings → Terminal](
### Linux
AppImage and `.deb` builds are available. See the Releases page for details.
Each published release ships three Linux packages — an **AppImage**, a **`.deb`**, and an **`.rpm`** — for both x64 and arm64. They contain the same app. What differs is how updates reach you, so pick on that.
| Package | Pick it when | Updates |
| ------------ | --------------------------------------------------------- | ----------------------------------------------------------------- |
| **AppImage** | You want Orca to update itself, like on macOS and Windows | Orca downloads and applies the update in place |
| **`.deb`** | You manage software with `apt` on Debian or Ubuntu | Orca tells you a version is out and hands you the install command |
| **`.rpm`** | You manage software with `dnf`, `yum`, or `zypper` | Same as `.deb` |
The AppImage has a stable download link per architecture — [`orca-linux.AppImage`](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) for x64 and [`orca-linux-arm64.AppImage`](https://github.com/stablyai/orca/releases/latest/download/orca-linux-arm64.AppImage) for arm64 — and needs `chmod +x` before its first run, because GitHub release assets carry no permission bits. The `.deb` and `.rpm` filenames carry the version and architecture, and the two formats spell architecture differently (`orca-ide_<version>_amd64.deb` or `_arm64.deb`; `orca-ide-<version>.x86_64.rpm` or `.aarch64.rpm`), so take those from the [Releases page](https://github.com/stablyai/orca/releases) rather than a fixed URL.
#### How updating works
**The AppImage self-updates.** Choose it if you want automatic updates. Orca checks for a new release, you click **Update**, and it replaces the AppImage in place — the same flow as macOS and Windows.
**The `.deb` and `.rpm` do not self-update.** Orca still notices the new version and downloads the package, then gives you a **Copy Install Command** button. Copy it rather than retyping it: Orca resolves every program to an absolute path in a trusted system directory and single-quotes the package path, so what you paste looks like this:
```
/usr/bin/sudo /usr/bin/apt install -- '/home/you/.cache/orca-updater/pending/orca-ide_1.4.194_amd64.deb'
```
Which package manager appears depends on what your system actually has: `apt`, else `dpkg -i`, for a `.deb`; `zypper`, `dnf`, `yum`, then `rpm -Uvh` for an `.rpm`. The download directory follows `XDG_CACHE_HOME` when that is set and falls back to `~/.cache` when it is not.
**Quit Orca before you run the command**, then reopen it once the install finishes. You are replacing the files of a running application, and the package manager cannot swap them safely underneath a live process. Orca deliberately never escalates privileges to do this for you: installing a system package needs root, `orca serve` runs as an unprivileged user, and a headless machine has no authentication agent to prompt. VS Code and Signal make the same call on `.deb`.
**A distro-managed build is left alone.** If you are running a repackaged Orca — an AUR build, a Nix derivation — Orca sees that no package manager it can drive owns this install and stops offering a download it could never apply. It still reports that a new version exists, so you can update the way you normally would.
<Callout title="Planned: a signed apt/yum repository">
[#18086](https://github.com/stablyai/orca/issues/18086) tracks publishing a signed repository so
your OS package manager owns Orca updates the way it owns everything else. It does not exist yet —
today, `.deb` and `.rpm` updates are the manual step described above.
</Callout>
#### The CLI command is `orca-ide`
On Linux the [Orca CLI](/docs/cli/reference) installs as **`orca-ide`**, not `orca`. GNOME Orca — the screen reader that ships by default on Ubuntu and other GNOME desktops — already owns `/usr/bin/orca`, and Orca will not shadow it. The `.deb` and `.rpm` packages are named `orca-ide` for the same reason.
- The `.deb` and `.rpm` put `orca-ide` on your `PATH` at install time, as `/usr/bin/orca-ide`.
- With the AppImage, register the CLI from [Settings → General → Orca CLI](/docs/settings). That installs `~/.local/bin/orca-ide`.
- Inside Orca's own terminals, bare `orca` works. Orca puts a shim on the `PATH` of the terminals it manages, so agents and scripts running there use the same command as on macOS and Windows.
- On a headless host, a packaged `orca serve` writes a bare `orca` into `~/.local/bin` as it starts, unless a file it does not own already holds that name. It writes that *during* startup, so it is never what starts the server — the first launch is always [`orca-ide serve`](/docs/remote-servers).
Do not verify with `command -v orca`: on a GNOME desktop that succeeds and resolves to the screen reader. Use `orca-ide` in your own shell and `orca` inside Orca. If you want the short name everywhere and you do not use the screen reader, link it yourself:
```
ln -s "$(command -v orca-ide)" ~/.local/bin/orca
```
@@ -126,6 +126,14 @@ Use `orca serve` when the host should run without the desktop window—for examp
Install Orca and its bundled CLI on the server, then run:
<Callout title="On Linux, start it with orca-ide serve">
The Linux CLI is named `orca-ide`, because GNOME Orca's screen reader already owns
`/usr/bin/orca`. A packaged `orca serve` does write a bare `orca` into `~/.local/bin`, but only
while it is starting, so that shim can never be the command that starts the server. Read
`orca serve` as `orca-ide serve` throughout this page when the host is Linux. See
[Install → Linux](/docs/install#linux).
</Callout>
```bash
orca serve --pairing-address <server-tailscale-ip-or-hostname>
```
+2 -2
View File
@@ -52,10 +52,10 @@ Keep Orca running on a machine you control—an old laptop, Mac mini, home serve
**Easiest setup:** install Orca and Tailscale on both computers. On the server, open **Settings → Remote Orca Servers → Advertise this app as a server → New Link**, choose its Tailscale address, and generate an access link. On the client, choose **Add Server** and paste that link.
For a headless Linux server or service-managed VM, use `orca serve` as the alternative:
For a headless Linux server or service-managed VM, use `orca serve` as the alternative. On Linux the CLI is named `orca-ide`, so the first launch is:
```bash
orca serve --pairing-address <reachable-tailscale-ip-or-hostname>
orca-ide serve --pairing-address <reachable-tailscale-ip-or-hostname>
```
Full detail: [Remote Orca Servers](/docs/remote-servers).
@@ -205,6 +205,144 @@ describe('AgentHookServer listener replay', () => {
expect(listener).toHaveBeenNthCalledWith(4, [])
})
it('evicts only the matching persisted status identity', () => {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
providerSession: { key: 'session_id', id: 'resume-me' },
payload: { state: 'done', prompt: 'old run', agentType: 'claude' }
},
'conn-1'
)
const old = server.getStatusSnapshot()[0]
expect(old).toBeDefined()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'new run', agentType: 'claude' }
},
'conn-1'
)
server.dropPersistedStatusEntry({
paneKey: old!.paneKey,
receivedAt: old!.receivedAt,
stateStartedAt: old!.stateStartedAt
})
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', prompt: 'new run' })
// A matching eviction follows ordinary dismissal semantics, including
// preserving a resumable provider session for the still-live TUI.
const resumed = new AgentHookServer()
resumed.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
providerSession: { key: 'session_id', id: 'resume-me' },
payload: { state: 'done', prompt: 'old run', agentType: 'claude' }
},
'conn-1'
)
const resumedIdentity = resumed.getStatusSnapshot()[0]!
expect(
resumed.dropPersistedStatusEntry({
paneKey: resumedIdentity.paneKey,
receivedAt: resumedIdentity.receivedAt,
stateStartedAt: resumedIdentity.stateStartedAt
})
).toBe(true)
expect(resumed.getStatusSnapshot()[0]).toMatchObject({
providerSessionOnly: true,
providerSession: { id: 'resume-me' }
})
})
it('evicts when the renderer identity was stamped after receipt but pins the same turn', () => {
// Runtime-sync and recovery entries stamp updatedAt with Date.now()/capturedAt, which is
// at or after main's receivedAt; the eviction must still land for those rows.
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'done', prompt: 'run', agentType: 'claude' }
},
'conn-1'
)
const entry = server.getStatusSnapshot()[0]!
expect(
server.dropPersistedStatusEntry({
paneKey: entry.paneKey,
receivedAt: entry.receivedAt + 5_000,
stateStartedAt: entry.stateStartedAt
})
).toBe(true)
// A different turn never matches, whatever the receivedAt relationship.
const other = new AgentHookServer()
other.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'done', prompt: 'run', agentType: 'claude' }
},
'conn-1'
)
const otherEntry = other.getStatusSnapshot()[0]!
expect(
other.dropPersistedStatusEntry({
paneKey: otherEntry.paneKey,
receivedAt: otherEntry.receivedAt + 5_000,
stateStartedAt: otherEntry.stateStartedAt + 1
})
).toBe(false)
})
it('evicts a batch of persisted identities with one status-change notification', () => {
const server = new AgentHookServer()
const otherPane = makePaneKey('tab-2', '22222222-2222-4222-8222-222222222222')
for (const paneKey of [PANE, otherPane]) {
server.ingestRemote(
{
paneKey,
tabId: paneKey.split(':')[0]!,
worktreeId: 'wt-1',
payload: { state: 'done', prompt: 'run', agentType: 'claude' }
},
'conn-1'
)
}
const listener = vi.fn()
server.subscribeStatusChanges(listener)
const dropped: string[] = []
server.subscribeStatusDrop((paneKey) => dropped.push(paneKey))
const identities = server.getStatusSnapshot().map((entry) => ({
paneKey: entry.paneKey,
receivedAt: entry.receivedAt,
stateStartedAt: entry.stateStartedAt
}))
const evicted = server.dropPersistedStatusEntries([
...identities,
// A stale identity never matches and never blocks the rest of the batch.
{ ...identities[0]!, stateStartedAt: identities[0]!.stateStartedAt + 1 }
])
expect(evicted.sort()).toEqual([PANE, otherPane].sort())
expect(dropped.sort()).toEqual([PANE, otherPane].sort())
expect(listener).toHaveBeenCalledTimes(1)
expect(server.getStatusSnapshot()).toEqual([])
})
it('notifies pane-status-clear listener when pane teardown evicts a cached status', () => {
const server = new AgentHookServer()
const listener = vi.fn()
@@ -1,4 +1,5 @@
import { paneHasStateClaims } from '../../../shared/agent-hook-listener/listener-state'
import type { AgentStatusCacheIdentity } from '../../../shared/agent-status-types'
import type { EnrichedAgentHookEventPayload } from './server-types'
import { AgentHookServerAuthorityFences } from './server-authority-fences'
@@ -35,6 +36,51 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen
this.emitStatusDropped(deleted.paneKey)
}
/** Evict a UI-cleared status only if no newer status has replaced it. */
dropPersistedStatusEntry(identity: AgentStatusCacheIdentity): boolean {
return this.dropPersistedStatusEntries([identity]).length > 0
}
/** Batch form: one persist and one listener notification for the whole set. Returns the
* pane keys that were actually evicted. */
dropPersistedStatusEntries(identities: readonly AgentStatusCacheIdentity[]): string[] {
const evicted: string[] = []
for (const identity of identities) {
const resolvedPaneKey = this.resolvePaneKeyAlias(identity.paneKey)
const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as
| EnrichedAgentHookEventPayload
| undefined
// Why: stateStartedAt pins the turn; the renderer's updatedAt is stamped at or after this
// receivedAt (runtime-sync and recovery paths use Date.now()/capturedAt), so a strictly
// newer cached event is the only replacement worth protecting.
if (
!existing ||
existing.stateStartedAt !== identity.stateStartedAt ||
existing.receivedAt > identity.receivedAt
) {
continue
}
const deleted = this.deleteStatusEntry(resolvedPaneKey, { preserveAuthority: true })
if (!deleted) {
continue
}
const retained = this.toRetainedProviderSessionRow(deleted)
if (retained) {
this.state.lastStatusByPaneKey.set(deleted.paneKey, retained)
}
evicted.push(deleted.paneKey)
}
if (evicted.length === 0) {
return evicted
}
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
for (const paneKey of evicted) {
this.emitStatusDropped(paneKey)
}
return evicted
}
/** Retire panes whose owning process is certifiably dead.
*
* The ordinary teardown already does this: every attributable PTY exit reaches
+7 -2
View File
@@ -187,10 +187,15 @@ export async function removeStaleDurableWriteTempFiles(
}
/** Synchronous counterpart for quit and crash paths that cannot await. */
export function writeFileDurableSync(tmpPath: string, finalPath: string, payload: string): void {
export function writeFileDurableSync(
tmpPath: string,
finalPath: string,
payload: string | Uint8Array
): void {
let renamed = false
try {
writeFileSync(tmpPath, payload, 'utf-8')
// A Uint8Array payload is written verbatim; a string still defaults to UTF-8.
writeFileSync(tmpPath, payload)
const fd = openSync(tmpPath, 'r+')
try {
fsyncSync(fd)
+5 -1
View File
@@ -157,7 +157,11 @@ export async function probeAnyExactRefBatched(
} catch {
return { found: false, unknown: true }
}
const lines = stdout.split('\n').filter((line) => line.trim().length > 0)
// Trim per line so a CRLF-translating host's `\r` does not become part of the type.
const lines = stdout
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
// One line per input, in order; a short read means the batch never answered for the rest.
if (lines.length !== safeRefs.length) {
return { found: false, unknown: true }
+59
View File
@@ -193,6 +193,17 @@ describe('git remote operations', () => {
if (args[0] === 'remote' && args[1] === 'get-url' && args[2] === 'pr-pynickle-orca') {
return { stdout: 'https://github.com/pynickle/orca.git\n', stderr: '' }
}
if (args[0] === 'remote' && args[1] === '-v') {
return {
stdout: [
'origin\thttps://github.com/stablyai/orca.git (fetch)',
'origin\thttps://github.com/stablyai/orca.git (push)',
'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (fetch)',
'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (push)'
].join('\n'),
stderr: ''
}
}
if (args[0] === 'remote') {
return { stdout: 'origin\npr-pynickle-orca\n', stderr: '' }
}
@@ -207,6 +218,54 @@ describe('git remote operations', () => {
)
})
// Regression: normalizing a URL-valued push remote used to run `git remote` and then a
// serial `git remote get-url` per remote -- 59 subprocesses on a 58-remote repo.
it('normalizes a URL-valued push remote from one remote table read at 58 remotes', async () => {
const remotes = [
{ name: 'origin', url: 'https://github.com/stablyai/orca.git' },
...Array.from({ length: 56 }, (_, index) => ({
name: `pr-user${index}-orca`,
url: `https://github.com/user${index}/orca.git`
})),
{ name: 'pr-pynickle-orca', url: 'https://github.com/pynickle/orca.git' }
]
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'imp/chinese-translation\n', stderr: '' }
}
if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.remote')) {
return { stdout: 'https://github.com/pynickle/orca.git\n', stderr: '' }
}
if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.merge')) {
return { stdout: 'refs/heads/imp/chinese-translation\n', stderr: '' }
}
if (args[0] === 'config') {
throw new Error(`config key is not set: ${args.join(' ')}`)
}
if (args[0] === 'remote' && args[1] === '-v') {
return {
stdout: remotes
.flatMap(({ name, url }) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`])
.join('\n'),
stderr: ''
}
}
if (args[0] === 'remote') {
throw new Error(`unexpected remote scan: ${args.join(' ')}`)
}
return { stdout: '', stderr: '' }
})
await gitPush('/repo', false)
const remoteReads = gitExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'remote')
expect(remoteReads.map(([args]) => args)).toEqual([['remote', '-v']])
expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith(
['push', '--set-upstream', 'pr-pynickle-orca', 'HEAD:imp/chinese-translation'],
{ cwd: '/repo' }
)
})
it('uses an explicit push target even when it differs from the local branch name', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '', stderr: '' })
+15 -23
View File
@@ -4,6 +4,7 @@ import {
} from '../../shared/git-remote-error'
import { resolveEffectiveGitUpstream } from '../../shared/git-effective-upstream'
import { gitRefTargetsBranchOnRemote } from '../../shared/git-remote-branch-name'
import { findGitRemoteNameByFetchUrl } from '../../shared/git-remote-url-index'
import type { GitPushTarget } from '../../shared/worktree/types'
import type { GitRuntimeOptions } from './git-runtime-options'
import { gitOptionsForWorktree } from './git-runtime-options'
@@ -84,6 +85,8 @@ type ConfiguredPushRemote = {
branchRemote: string | null
}
// One `git remote -v` instead of `git remote` plus a serial `git remote get-url`
// per remote; both print the same insteadOf-expanded fetch URL.
async function findRemoteNameForUrl(
worktreePath: string,
remoteUrl: string,
@@ -91,30 +94,13 @@ async function findRemoteNameForUrl(
): Promise<string | null> {
try {
const { stdout } = await gitExecFileAsync(
['remote'],
['remote', '-v'],
gitOptionsForWorktree(worktreePath, options)
)
const remotes = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
for (const remoteName of remotes) {
try {
const { stdout: urlStdout } = await gitExecFileAsync(
['remote', 'get-url', remoteName],
gitOptionsForWorktree(worktreePath, options)
)
if (urlStdout.trim() === remoteUrl) {
return remoteName
}
} catch {
// Ignore a remote that disappeared or has no fetch URL.
}
}
return findGitRemoteNameByFetchUrl(stdout, (candidateUrl) => candidateUrl === remoteUrl)
} catch {
return null
}
return null
}
async function normalizePushRemote(
@@ -141,11 +127,17 @@ async function getConfiguredPushRemote(
if (!remote) {
return null
}
const normalizedRemote = await normalizePushRemote(worktreePath, remote, options)
// The two usually name the same URL; resolving it twice reads the remote table twice.
if (!branchRemote) {
return { remote: normalizedRemote, branchRemote: null }
}
return {
remote: await normalizePushRemote(worktreePath, remote, options),
branchRemote: branchRemote
? await normalizePushRemote(worktreePath, branchRemote, options)
: null
remote: normalizedRemote,
branchRemote:
branchRemote === remote
? normalizedRemote
: await normalizePushRemote(worktreePath, branchRemote, options)
}
}
@@ -0,0 +1,102 @@
// Why: the batched `cat-file --batch-check` conflict probe decides from stdout, so a
// WSL login-shell fallback that prints the distro banner onto that stream desynchronizes
// the one-line-per-ref contract. Every batch then came back undecided and fell through to
// one `show-ref` subprocess per remote -- the cost the batch exists to remove. These tests
// pin the fence request and the resulting subprocess count at 58 remotes.
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() }))
vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
import { getBranchConflictKind } from './repo-branch-conflict'
const REMOTES = Array.from({ length: 58 }, (_, index) => `r${index}`)
const BRANCH = 'user/feature'
const WSL_BANNER =
'Welcome to Ubuntu 24.04.1 LTS (GNU/Linux 5.15.167.4-microsoft-standard-WSL2 x86_64)\n' +
'To run a command as administrator (user "root"), use "sudo <command>".\n'
type GitExecOptions = { stdin?: string; captureWslLoginShellOutput?: boolean }
/**
* Stand-in for a WSL-routed runner: the login shell prepends its banner to stdout unless
* the caller asked for the fenced form, which slices the payload back out.
*/
function installLoginShellRunner(): { argv: string[][] } {
const argv: string[][] = []
gitExecFileAsyncMock.mockImplementation(async (args: string[], options: GitExecOptions = {}) => {
argv.push(args)
if (args[0] === 'rev-parse') {
throw new Error('local branch is absent')
}
if (args[0] === 'remote') {
return { stdout: `${WSL_BANNER}${REMOTES.join('\n')}\n`, stderr: '' }
}
if (args[0] === 'show-ref') {
throw Object.assign(new Error('missing ref'), { code: 1, stderr: '' })
}
if (args[0] === 'cat-file') {
const payload = `${(options.stdin ?? '')
.split('\n')
.filter(Boolean)
.map((ref) => `${ref} missing`)
.join('\n')}\n`
return {
stdout: options.captureWslLoginShellOutput ? payload : `${WSL_BANNER}${payload}`,
stderr: ''
}
}
throw new Error(`unexpected git command: ${args.join(' ')}`)
})
return { argv }
}
function countSubcommand(argv: readonly string[][], subcommand: string): number {
return argv.filter((args) => args[0] === subcommand).length
}
describe('getBranchConflictKind batched remote probe', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
})
it('asks the WSL login shell to fence the batch payload it parses', async () => {
installLoginShellRunner()
await getBranchConflictKind('/repo', BRANCH)
const batchCall = gitExecFileAsyncMock.mock.calls.find(([args]) => args[0] === 'cat-file')
expect(batchCall?.[1]).toMatchObject({ captureWslLoginShellOutput: true })
})
it('answers from one batched subprocess instead of one show-ref per remote', async () => {
const { argv } = installLoginShellRunner()
await expect(getBranchConflictKind('/repo', BRANCH)).resolves.toBeNull()
expect(countSubcommand(argv, 'cat-file')).toBe(1)
expect(countSubcommand(argv, 'show-ref')).toBe(0)
})
it('still falls back to per-ref probes when the batch itself fails', async () => {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'rev-parse') {
throw new Error('local branch is absent')
}
if (args[0] === 'remote') {
return { stdout: `${REMOTES.join('\n')}\n`, stderr: '' }
}
if (args[0] === 'cat-file') {
throw new Error('cat-file is unavailable on this host')
}
if (args[0] === 'show-ref') {
return { stdout: '', stderr: '' }
}
throw new Error(`unexpected git command: ${args.join(' ')}`)
})
await expect(getBranchConflictKind('/repo', BRANCH)).resolves.toBe('remote')
})
})
+10 -2
View File
@@ -147,10 +147,12 @@ export function getBranchConflictKind(
const execOptions = gitExecOptions(path, options)
const runLocalGit = (
argv: string[],
commandOptions?: ExactRefProbeExecOptions & { stdin?: string }
commandOptions?: ExactRefProbeExecOptions & { stdin?: string },
captureWslLoginShellOutput = false
): Promise<{ stdout: string }> =>
gitExecFileAsync(argv, {
...execOptions,
...(captureWslLoginShellOutput ? { captureWslLoginShellOutput: true } : {}),
...(commandOptions?.maxBuffer === undefined ? {} : { maxBuffer: commandOptions.maxBuffer }),
...(commandOptions?.timeoutMs === undefined ? {} : { timeout: commandOptions.timeoutMs }),
...(commandOptions?.stdin === undefined ? {} : { stdin: commandOptions.stdin })
@@ -160,7 +162,13 @@ export function getBranchConflictKind(
branchName,
allowedBaseRef,
{},
(argv, commandOptions) => runLocalGit(argv, commandOptions)
// Why fenced: the batch decides from stdout, and a WSL login-shell fallback writes
// the distro's rc/motd banner to that same stream. The extra lines break the
// one-line-per-ref contract, so every batch came back undecided and fell through to
// one `show-ref` subprocess per remote -- the exact cost the batch exists to remove.
// `show-ref --verify --quiet` prints nothing and is read by exit code, so it needs
// no fence; the capture wrapper preserves the payload's exit status either way.
(argv, commandOptions) => runLocalGit(argv, commandOptions, true)
)
}
+10
View File
@@ -363,6 +363,16 @@ describe('getUpstreamStatus', () => {
if (args[0] === 'remote' && args[1] === 'get-url' && args[2] === 'pr-pynickle-orca') {
return Promise.resolve({ stdout: 'https://github.com/pynickle/orca.git\n' })
}
if (args[0] === 'remote' && args[1] === '-v') {
return Promise.resolve({
stdout: [
'origin\thttps://github.com/stablyai/orca.git (fetch)',
'origin\thttps://github.com/stablyai/orca.git (push)',
'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (fetch)',
'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (push)'
].join('\n')
})
}
if (args[0] === 'remote') {
return Promise.resolve({ stdout: 'origin\npr-pynickle-orca\n' })
}
+72
View File
@@ -8,6 +8,8 @@ import { makePaneKey } from '../../shared/stable-pane-id'
// evicts the entry.
const dropStatusEntry = vi.fn()
const dropPersistedStatusEntry = vi.fn()
const dropPersistedStatusEntries = vi.fn(() => [] as string[])
const dropStatusEntriesByTabPrefix = vi.fn()
const retirePaneAuthority = vi.fn()
const transferPaneAuthority = vi.fn()
@@ -44,6 +46,8 @@ vi.mock('../agent-hooks/server', async () => {
...actual,
agentHookServer: {
dropStatusEntry,
dropPersistedStatusEntry,
dropPersistedStatusEntries,
dropStatusEntriesByTabPrefix,
retirePaneAuthority,
transferPaneAuthority,
@@ -105,6 +109,9 @@ vi.mock('../kimi/hook-service', () => ({
beforeEach(() => {
dropStatusEntry.mockReset()
dropPersistedStatusEntry.mockReset()
dropPersistedStatusEntries.mockReset()
dropPersistedStatusEntries.mockReturnValue([])
dropStatusEntriesByTabPrefix.mockReset()
retirePaneAuthority.mockReset()
transferPaneAuthority.mockReset()
@@ -279,6 +286,71 @@ describe('agentStatus:drop IPC', () => {
})
})
describe('agentStatus:dropPersisted IPC', () => {
it('forwards a validated cache identity without clearing pane state', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = onHandlers.get('agentStatus:dropPersisted')
expect(handler).toBeDefined()
const identity = {
paneKey: PANE_KEY,
receivedAt: 2_000,
stateStartedAt: 1_000
}
handler!({}, identity)
expect(dropPersistedStatusEntry).toHaveBeenCalledWith(identity)
expect(dropStatusEntry).not.toHaveBeenCalled()
})
it('forwards a batch, keeping only valid identities, and clears migration state per evicted pane', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = onHandlers.get('agentStatus:dropPersistedBatch')
expect(handler).toBeDefined()
const good = { paneKey: PANE_KEY, receivedAt: 2_000, stateStartedAt: 1_000 }
const alsoGood = { paneKey: CHILD_PANE_KEY, receivedAt: 3_000, stateStartedAt: 2_500 }
dropPersistedStatusEntries.mockReturnValue([PANE_KEY])
handler!({}, [good, { paneKey: 'not-a-pane-key', receivedAt: 1, stateStartedAt: 1 }, alsoGood])
expect(dropPersistedStatusEntries).toHaveBeenCalledWith([good, alsoGood])
expect(clearMigrationUnsupportedPtysForPaneKey).toHaveBeenCalledWith(PANE_KEY)
expect(clearMigrationUnsupportedPtysForPaneKey).not.toHaveBeenCalledWith(CHILD_PANE_KEY)
expect(dropPersistedStatusEntry).not.toHaveBeenCalled()
})
it('ignores a batch that is not an array or is empty after validation', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = onHandlers.get('agentStatus:dropPersistedBatch')!
for (const value of [null, {}, 'x', [], [{ paneKey: PANE_KEY }]]) {
expect(() => handler({}, value)).not.toThrow()
}
expect(dropPersistedStatusEntries).not.toHaveBeenCalled()
})
it('rejects malformed cache identities', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = onHandlers.get('agentStatus:dropPersisted')!
for (const value of [
null,
undefined,
{},
{ paneKey: PANE_KEY },
{ paneKey: PANE_KEY, receivedAt: Number.NaN, stateStartedAt: 1 },
{ paneKey: PANE_KEY, receivedAt: 2, stateStartedAt: Number.POSITIVE_INFINITY },
{ paneKey: 'not-a-pane-key', receivedAt: 2, stateStartedAt: 1 },
{ paneKey: PANE_KEY, receivedAt: '2', stateStartedAt: 1 }
]) {
expect(() => handler({}, value)).not.toThrow()
}
expect(dropPersistedStatusEntry).not.toHaveBeenCalled()
})
})
describe('agentStatus:dropByTabPrefix IPC', () => {
it('forwards valid tab ids to tab-prefix cache eviction', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
+52 -1
View File
@@ -1,5 +1,6 @@
import { ipcMain } from 'electron'
import { agentHookServer, isValidPaneKey } from '../agent-hooks/server'
import type { AgentStatusCacheIdentity } from '../../shared/agent-status-types'
import {
clearMigrationUnsupportedPtysByTabPrefix,
clearMigrationUnsupportedPtysForPaneKey
@@ -7,7 +8,7 @@ import {
import { isValidAgentStatusDropTabId } from './agent-status-ipc-boundary'
/**
* The three renderer-initiated ways a status row goes away. All fire-and-forget
* The renderer-initiated ways a status row goes away. All fire-and-forget
* (`ipcRenderer.send` → `ipcMain.on`), so none round-trips a response; removing the
* listeners first keeps re-registration safe.
*
@@ -15,8 +16,13 @@ import { isValidAgentStatusDropTabId } from './agent-status-ipc-boundary'
* still be alive; a confirmed process exit must take them too, or a surviving Claude latch resolves
* the pane's next event straight back to `working`.
*/
// Why a cap: the renderer sends one batch per Clear-completed click, bounded by visible rows.
const MAX_DROP_PERSISTED_BATCH = 5_000
export function registerAgentStatusRowTeardownIpcHandlers(): void {
ipcMain.removeAllListeners('agentStatus:drop')
ipcMain.removeAllListeners('agentStatus:dropPersisted')
ipcMain.removeAllListeners('agentStatus:dropPersistedBatch')
ipcMain.removeAllListeners('agentStatus:reconcileEndedProcess')
ipcMain.removeAllListeners('agentStatus:dropByTabPrefix')
@@ -36,6 +42,36 @@ export function registerAgentStatusRowTeardownIpcHandlers(): void {
}
})
ipcMain.on('agentStatus:dropPersisted', (_event, request: unknown) => {
if (!isValidAgentStatusCacheIdentity(request)) {
return
}
try {
if (agentHookServer.dropPersistedStatusEntry(request)) {
clearMigrationUnsupportedPtysForPaneKey(request.paneKey)
}
} catch (err) {
console.warn('[agent-hooks] dropPersistedStatusEntry failed:', err)
}
})
ipcMain.on('agentStatus:dropPersistedBatch', (_event, request: unknown) => {
if (!Array.isArray(request) || request.length > MAX_DROP_PERSISTED_BATCH) {
return
}
const identities = request.filter(isValidAgentStatusCacheIdentity)
if (identities.length === 0) {
return
}
try {
for (const paneKey of agentHookServer.dropPersistedStatusEntries(identities)) {
clearMigrationUnsupportedPtysForPaneKey(paneKey)
}
} catch (err) {
console.warn('[agent-hooks] dropPersistedStatusEntries failed:', err)
}
})
ipcMain.on('agentStatus:reconcileEndedProcess', (_event, paneKey: unknown) => {
if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) {
return
@@ -67,3 +103,18 @@ export function registerAgentStatusRowTeardownIpcHandlers(): void {
}
})
}
function isValidAgentStatusCacheIdentity(value: unknown): value is AgentStatusCacheIdentity {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false
}
const request = value as Record<string, unknown>
return (
typeof request.paneKey === 'string' &&
isValidPaneKey(request.paneKey) &&
typeof request.receivedAt === 'number' &&
Number.isFinite(request.receivedAt) &&
typeof request.stateStartedAt === 'number' &&
Number.isFinite(request.stateStartedAt)
)
}
@@ -13,7 +13,7 @@ import { listWorktrees } from '../git/worktree'
import type { SshGitProvider } from '../providers/ssh-git-provider'
import type { GitPushTarget } from '../../shared/worktree/types'
import { WORKTREE_ID_SEPARATOR, worktreeIdComparisonKey } from '../../shared/worktree/id'
import { iterateProcessOutputLines } from '../../shared/process-output-field-scanner'
import { parseGitRemoteFetchUrls } from '../../shared/git-remote-url-index'
import {
findWorktreeMetaReferencingRemote,
hasBranchConfigUsingRemote,
@@ -44,26 +44,9 @@ async function listPrRemoteCandidates(
} catch {
return []
}
const candidates = new Map<string, string>()
for (const line of iterateProcessOutputLines(stdout)) {
const parsed = parseRemoteVerboseLine(line)
if (parsed?.direction === 'fetch' && isOrcaGeneratedPrRemoteName(parsed.name)) {
candidates.set(parsed.name, parsed.url)
}
}
return [...candidates.entries()].map(([name, url]) => ({ name, url }))
}
function parseRemoteVerboseLine(
line: string
): { name: string; url: string; direction: 'fetch' | 'push' } | null {
const tabIndex = line.indexOf('\t')
if (tabIndex === -1) {
return null
}
const name = line.slice(0, tabIndex)
const match = /^(.*) \((fetch|push)\)$/.exec(line.slice(tabIndex + 1).trim())
return match ? { name, url: match[1], direction: match[2] as 'fetch' | 'push' } : null
return [...parseGitRemoteFetchUrls(stdout)]
.filter(([name]) => isOrcaGeneratedPrRemoteName(name))
.map(([name, url]) => ({ name, url }))
}
async function shouldReclaimPrRemote(
@@ -0,0 +1,178 @@
// Why: `findRemoteForUrl` used to run `git remote` and then one serial
// `git remote get-url` per remote. These tests pin both halves of the fix: the
// subprocess count at 58 remotes, and result-for-result parity with the old scan
// across the remote shapes a real repo produces.
import { describe, expect, it } from 'vitest'
import { parseGitHubOwnerRepo } from '../github/gh-utils'
import { findRemoteForUrl } from './worktree-push-target-setup'
import type { GitRemoteExec } from './worktree-push-target-cleanup'
const SSH_FORK = 'git@github.com:contributor/orca.git'
const HTTPS_FORK = 'https://github.com/contributor/orca.git'
const GITLAB_FORK = 'https://gitlab.com/contributor/orca.git'
const UPSTREAM = 'https://github.com/stablyai/orca.git'
type RemoteRow = { name: string; fetchUrl: string; pushUrl?: string }
type CountingExec = GitRemoteExec & { spawns: string[][] }
function makeExec(remotes: readonly RemoteRow[]): CountingExec {
const spawns: string[][] = []
const exec: GitRemoteExec = async (args: string[]) => {
spawns.push(args)
if (args[0] === 'remote' && args.length === 1) {
return { stdout: `${remotes.map((remote) => remote.name).join('\n')}\n` }
}
if (args[0] === 'remote' && args[1] === '-v') {
return {
stdout: remotes
.flatMap((remote) => [
`${remote.name}\t${remote.fetchUrl} (fetch)`,
`${remote.name}\t${remote.pushUrl ?? remote.fetchUrl} (push)`
])
.join('\n')
}
}
if (args[0] === 'remote' && args[1] === 'get-url') {
const match = remotes.find((remote) => remote.name === args[2])
if (!match) {
throw new Error(`No such remote ${args[2]}`)
}
return { stdout: `${match.fetchUrl}\n` }
}
throw new Error(`unexpected git command: ${args.join(' ')}`)
}
return Object.assign(exec, { spawns })
}
/** The pre-fix scan, kept as the oracle the batched form must reproduce exactly. */
async function findRemoteForUrlPerRemote(
execGit: GitRemoteExec,
repoPath: string,
remoteUrl: string
): Promise<string | null> {
const target = parseGitHubOwnerRepo(remoteUrl)
try {
const { stdout } = await execGit(['remote'], repoPath)
for (const remote of stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)) {
try {
const { stdout: urlStdout } = await execGit(['remote', 'get-url', remote], repoPath)
const candidateUrl = urlStdout.trim()
const candidate = parseGitHubOwnerRepo(candidateUrl)
if (
target &&
candidate &&
target.owner.toLowerCase() === candidate.owner.toLowerCase() &&
target.repo.toLowerCase() === candidate.repo.toLowerCase()
) {
return remote
}
if (candidateUrl === remoteUrl) {
return remote
}
} catch {
// Ignore a remote that disappeared or has no fetch URL.
}
}
} catch {
return null
}
return null
}
const fiftyEightRemotes: RemoteRow[] = [
{ name: 'origin', fetchUrl: UPSTREAM },
...Array.from({ length: 56 }, (_, index) => ({
name: `pr-user${index}-orca`,
fetchUrl: `https://github.com/user${index}/orca.git`
})),
{ name: 'pr-contributor-orca', fetchUrl: SSH_FORK }
]
const matrix: { name: string; remotes: RemoteRow[]; lookupUrl: string }[] = [
{ name: 'no remotes', remotes: [], lookupUrl: SSH_FORK },
{
name: 'one matching remote',
remotes: [{ name: 'origin', fetchUrl: SSH_FORK }],
lookupUrl: SSH_FORK
},
{
name: 'one non-matching remote',
remotes: [{ name: 'origin', fetchUrl: UPSTREAM }],
lookupUrl: SSH_FORK
},
{ name: '58 remotes, match last', remotes: fiftyEightRemotes, lookupUrl: SSH_FORK },
{
name: '58 remotes, no match',
remotes: fiftyEightRemotes,
lookupUrl: 'https://github.com/nobody/other.git'
},
{
name: 'duplicate URLs on two remotes',
remotes: [
{ name: 'origin', fetchUrl: UPSTREAM },
{ name: 'fork-a', fetchUrl: SSH_FORK },
{ name: 'fork-b', fetchUrl: SSH_FORK }
],
lookupUrl: SSH_FORK
},
{
name: 'fetch and push URLs differ',
remotes: [{ name: 'split', fetchUrl: SSH_FORK, pushUrl: HTTPS_FORK }],
lookupUrl: SSH_FORK
},
{
name: 'SSH-form lookup against an HTTPS-form remote',
remotes: [
{ name: 'origin', fetchUrl: UPSTREAM },
{ name: 'fork', fetchUrl: HTTPS_FORK }
],
lookupUrl: SSH_FORK
},
{
name: 'HTTPS-form lookup against an SSH-form remote',
remotes: [
{ name: 'origin', fetchUrl: UPSTREAM },
{ name: 'fork', fetchUrl: SSH_FORK }
],
lookupUrl: HTTPS_FORK
},
{
name: 'non-GitHub provider matches only on the exact URL',
remotes: [{ name: 'gitlab-fork', fetchUrl: GITLAB_FORK }],
lookupUrl: GITLAB_FORK
},
{
name: 'non-GitHub provider with a different host does not match',
remotes: [{ name: 'gitlab-fork', fetchUrl: GITLAB_FORK }],
lookupUrl: 'https://bitbucket.org/contributor/orca.git'
}
]
describe('findRemoteForUrl', () => {
it.each(matrix)('matches the per-remote scan for $name', async ({ remotes, lookupUrl }) => {
const expected = await findRemoteForUrlPerRemote(makeExec(remotes), '/repo', lookupUrl)
await expect(findRemoteForUrl(makeExec(remotes), '/repo', lookupUrl)).resolves.toBe(expected)
})
it('answers from one subprocess at 58 remotes instead of one per remote', async () => {
const legacyExec = makeExec(fiftyEightRemotes)
await findRemoteForUrlPerRemote(legacyExec, '/repo', 'https://github.com/nobody/other.git')
expect(legacyExec.spawns).toHaveLength(fiftyEightRemotes.length + 1)
const exec = makeExec(fiftyEightRemotes)
await findRemoteForUrl(exec, '/repo', 'https://github.com/nobody/other.git')
expect(exec.spawns).toEqual([['remote', '-v']])
})
it('returns null when the remote table cannot be read', async () => {
const failing: GitRemoteExec = async () => {
throw new Error('not a git repository')
}
await expect(findRemoteForUrl(failing, '/repo', SSH_FORK)).resolves.toBeNull()
})
})
@@ -16,6 +16,13 @@ const REPO = '/repo-root'
const FORK_SSH = 'git@github.com:contributor/orca.git'
const FORK_HTTPS = 'https://github.com/contributor/orca.git'
/** Real `git remote -v` shape: a fetch row and a push row per remote, tab-separated. */
export function renderRemoteVerbose(remotes: Record<string, string>): string {
return Object.entries(remotes)
.flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`])
.join('\n')
}
// A stateful fake git: `remotes` maps name -> url. `remote add` mutates it so
// later lookups see the new remote, matching real git behavior. Defaults
// `symbolic-ref --short HEAD` to a real branch name, since a worktree's HEAD
@@ -31,6 +38,9 @@ function makeRepoExec(
if (args[0] === 'remote' && args.length === 1) {
return { stdout: Object.keys(remotes).join('\n'), stderr: '' }
}
if (args[0] === 'remote' && args[1] === '-v' && args.length === 2) {
return { stdout: renderRemoteVerbose(remotes), stderr: '' }
}
if (args[0] === 'remote' && args[1] === 'get-url') {
const url = remotes[args[2]!]
if (!url) {
+11 -42
View File
@@ -5,53 +5,33 @@
// repo. The store-aware ownership decision stays with the caller via a predicate.
import type { GitPushTarget } from '../../shared/worktree/types'
import { parseGitHubOwnerRepo } from '../github/gh-utils'
import type { GitRemoteExec } from './worktree-push-target-cleanup'
import { findGitRemoteNameByFetchUrl } from '../../shared/git-remote-url-index'
import { sameGitHubRemoteUrl, type GitRemoteExec } from './worktree-push-target-cleanup'
import {
buildNarrowForkFetchRefspec,
ensureRemoteTracksBranchNarrowly
} from '../git/fork-remote-refspec'
// One `git remote -v` replaces `git remote` plus a serial `git remote get-url` per
// remote -- 59 subprocesses at 58 remotes, on every push-target resolution (#17914).
export async function findRemoteForUrl(
execGit: GitRemoteExec,
repoPath: string,
remoteUrl: string
): Promise<string | null> {
const target = parseGitHubOwnerRepo(remoteUrl)
try {
const { stdout } = await execGit(['remote'], repoPath)
for (const remote of stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)) {
try {
const { stdout: urlStdout } = await execGit(['remote', 'get-url', remote], repoPath)
const candidateUrl = urlStdout.trim()
const candidate = parseGitHubOwnerRepo(candidateUrl)
if (
target &&
candidate &&
target.owner.toLowerCase() === candidate.owner.toLowerCase() &&
target.repo.toLowerCase() === candidate.repo.toLowerCase()
) {
return remote
}
if (candidateUrl === remoteUrl) {
return remote
}
} catch {
// Ignore a remote that disappeared or has no fetch URL.
}
}
const { stdout } = await execGit(['remote', '-v'], repoPath)
return findGitRemoteNameByFetchUrl(stdout, (candidateUrl) =>
sameGitHubRemoteUrl(candidateUrl, remoteUrl)
)
} catch {
return null
}
return null
}
// O(1) probe used before materializing on demand (push/pull/fetch/fast-forward):
// a single `remote get-url <name>` avoids the O(remotes) `findRemoteForUrl` scan
// once a fork remote already exists under its expected name (#17828).
// a single `remote get-url <name>` skips the whole-remote-table read once a fork
// remote already exists under its expected name (#17828).
export async function remoteAlreadyMatchesUrl(
execGit: GitRemoteExec,
repoPath: string,
@@ -60,18 +40,7 @@ export async function remoteAlreadyMatchesUrl(
): Promise<boolean> {
try {
const { stdout } = await execGit(['remote', 'get-url', remoteName], repoPath)
const candidateUrl = stdout.trim()
if (candidateUrl === remoteUrl) {
return true
}
const target = parseGitHubOwnerRepo(remoteUrl)
const candidate = parseGitHubOwnerRepo(candidateUrl)
return Boolean(
target &&
candidate &&
target.owner.toLowerCase() === candidate.owner.toLowerCase() &&
target.repo.toLowerCase() === candidate.repo.toLowerCase()
)
return sameGitHubRemoteUrl(stdout.trim(), remoteUrl)
} catch {
return false
}
@@ -553,6 +553,17 @@ describe('materializeWorktreePushTargetRemoteSsh', () => {
}
throw new Error('No such remote')
}
if (args[0] === 'remote' && args[1] === '-v') {
return {
stdout: [
'origin\thttps://github.com/stablyai/orca.git (fetch)',
'origin\thttps://github.com/stablyai/orca.git (push)',
`${SIBLING_REMOTE}\t${FORK_URL} (fetch)`,
`${SIBLING_REMOTE}\t${FORK_URL} (push)`
].join('\n'),
stderr: ''
}
}
if (args[0] === 'remote' && args.length === 1) {
return { stdout: `origin\n${SIBLING_REMOTE}\n`, stderr: '' }
}
@@ -62,6 +62,7 @@ export function getPersistedUI(
markdownTocPanelWidth: clampMarkdownTocPanelWidth(state.ui?.markdownTocPanelWidth),
combinedDiffFileTreeWidth: clampCombinedDiffFileTreeWidth(state.ui?.combinedDiffFileTreeWidth),
visibleWorkspaceHostIds: normalizeVisibleExecutionHostIds(state.ui?.visibleWorkspaceHostIds),
agentsVisibleHostIds: normalizeVisibleExecutionHostIds(state.ui?.agentsVisibleHostIds),
workspaceHostOrder: normalizeExecutionHostOrder(state.ui?.workspaceHostOrder),
manualRepoOrder: normalizeManualRepoOrder(state.ui?.manualRepoOrder),
browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(state.ui?.browserDefaultZoomLevel),
@@ -152,6 +152,10 @@ export function updatePersistedUI(
sanitizedUpdates.visibleWorkspaceHostIds !== undefined
? normalizeVisibleExecutionHostIds(sanitizedUpdates.visibleWorkspaceHostIds)
: normalizeVisibleExecutionHostIds(operations.state.ui?.visibleWorkspaceHostIds),
agentsVisibleHostIds:
sanitizedUpdates.agentsVisibleHostIds !== undefined
? normalizeVisibleExecutionHostIds(sanitizedUpdates.agentsVisibleHostIds)
: normalizeVisibleExecutionHostIds(operations.state.ui?.agentsVisibleHostIds),
workspaceHostOrder:
sanitizedUpdates.workspaceHostOrder !== undefined
? normalizeExecutionHostOrder(sanitizedUpdates.workspaceHostOrder)
@@ -0,0 +1,80 @@
import { homedir } from 'node:os'
import { describe, expect, it } from 'vitest'
import { getDefaultPersistedState } from '../../../shared/constants'
import { normalizeLoadedGlobalSettings } from './normalize-loaded-global-settings'
import { prepareLoadedTerminalSettings } from './prepare-loaded-terminal-settings'
import { prepareLoadedProfileSettings } from './prepare-loaded-profile-settings'
import type { GlobalSettings } from '../../../shared/global-settings-types'
import type { PersistedState } from '../../../shared/persisted-state-types'
// Simulates a profile created before the dedicated Experimental switch was persisted.
function normalizeLegacyProfile(overrides: Partial<GlobalSettings>): PersistedState['settings'] {
const defaults = getDefaultPersistedState(homedir())
const settings: Partial<GlobalSettings> = { ...defaults.settings }
delete settings.showAgentsSidebar
delete settings.experimentalActivity
delete settings.experimentalAgentDashboardPopout
Object.assign(settings, overrides)
const parsed: PersistedState = { ...defaults, settings: settings as GlobalSettings }
const noop = (): void => {}
const terminal = prepareLoadedTerminalSettings(parsed, noop)
const profile = prepareLoadedProfileSettings(parsed, defaults, noop)
return normalizeLoadedGlobalSettings(parsed, terminal, profile)
}
describe('showAgentsSidebar experimental-setting migration', () => {
it('keeps the sidebar for Agents-view opt-ins regardless of the dashboard experiment', () => {
const normalized = normalizeLegacyProfile({
experimentalActivity: true,
experimentalAgentDashboardPopout: false
})
expect(normalized.showAgentsSidebar).toBe(true)
expect(normalized.agentsSidebarMigratedFromExperimental).toBe(true)
})
it('carries the legacy Agents-view opt-in into the sidebar', () => {
expect(normalizeLegacyProfile({ experimentalActivity: true }).showAgentsSidebar).toBe(true)
})
it('does not show Agents migration copy for a dashboard-only opt-in', () => {
expect(
normalizeLegacyProfile({ experimentalAgentDashboardPopout: true })
.agentsSidebarMigratedFromExperimental
).toBe(false)
})
it('defaults profiles with no legacy signal to the sidebar', () => {
const normalized = normalizeLegacyProfile({})
expect(normalized.showAgentsSidebar).toBe(true)
expect(normalized.agentsSidebarMigratedFromExperimental).toBe(false)
})
it('does not treat a dashboard opt-out as an Agents-tab opt-out', () => {
expect(
normalizeLegacyProfile({ experimentalAgentDashboardPopout: false }).showAgentsSidebar
).toBe(true)
})
it('ignores a pre-stamp forced-default experimentalActivity true (not an opt-in)', () => {
const normalized = normalizeLegacyProfile({
experimentalActivity: true,
experimentalActivityDefaultedOffForAllUsers: undefined
})
expect(normalized.experimentalActivity).toBe(false)
expect(normalized.showAgentsSidebar).toBe(true)
expect(normalized.agentsSidebarMigratedFromExperimental).toBe(false)
})
it('preserves a stored showAgentsSidebar choice over legacy flags', () => {
expect(
normalizeLegacyProfile({ showAgentsSidebar: false, experimentalActivity: true })
.showAgentsSidebar
).toBe(false)
expect(
normalizeLegacyProfile({
showAgentsSidebar: true,
experimentalAgentDashboardPopout: false
}).showAgentsSidebar
).toBe(true)
})
})
@@ -1,4 +1,5 @@
import { getDefaultVoiceSettings } from '../../../shared/constants'
import { resolveAgentsSidebarVisible } from '../../../shared/agents-sidebar-visibility'
import { normalizePRBotAuthorOverrides } from '../../../shared/pr-bot-author-overrides'
import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands'
import { normalizeOpenInApplications } from '../../../shared/open-in-applications'
@@ -85,6 +86,16 @@ export function normalizeLoadedGlobalSettings(
...migratedTerminalTuiScrollSensitivity.settings,
experimentalActivity: migratedExperimentalActivity,
experimentalActivityDefaultedOffForAllUsers: true,
// Keep the experimental Agents tab's rollout default for older profiles while
// preserving any choice made through its dedicated Experimental setting.
showAgentsSidebar: resolveAgentsSidebarVisible({
showAgentsSidebar: parsed.settings?.showAgentsSidebar
}),
// Preserve the legacy opt-in before the experimental setting is normalized away. This
// drives the migration-specific introduction copy without changing runtime behavior.
agentsSidebarMigratedFromExperimental:
parsed.settings?.agentsSidebarMigratedFromExperimental === true ||
migratedExperimentalActivity,
// Why: compact worktree cards graduated from Experimental; preserve the old opt-in for rollout-era profiles.
compactWorktreeCards: loadedCompactWorktreeCards,
experimentalCompactWorktreeCards: undefined,
@@ -35,6 +35,8 @@ export function normalizeLoadedProfileState(
const { defaults, migratedExternalVisibility, osc52ClipboardNoticePending } = terminal
const { normalizedOnboarding, normalizedProjectGroups, loadedCompactWorktreeCards } = profile
const projectCatalog = normalizeLoadedProjectCatalog(parsed, markNeedsSave)
// Ordered: the host partitions drop the global fields this slice already owns.
const workspaceSession = normalizeLoadedLocalSession(parsed, defaults, markNeedsSave)
return {
...defaults,
@@ -69,9 +71,14 @@ export function normalizeLoadedProfileState(
markNeedsSave
),
// Why: volatile schema; zod-validate workspaceSession at read so a bad payload falls to defaults, not a renderer crash.
workspaceSession: normalizeLoadedLocalSession(parsed, defaults, markNeedsSave),
workspaceSession,
// Why: per-host session partitions, validated independently; 'local' stays in workspaceSession for downgrade compat.
workspaceSessionsByHostId: normalizeLoadedHostSessions(parsed, defaults, markNeedsSave),
workspaceSessionsByHostId: normalizeLoadedHostSessions(
parsed,
defaults,
workspaceSession,
markNeedsSave
),
sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget),
deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases)
? parsed.deletedSshConfigAliases.filter((alias): alias is string => typeof alias === 'string')
@@ -41,11 +41,13 @@ export function normalizeLoadedLocalSession(
export function normalizeLoadedHostSessions(
parsed: PersistedState,
defaults: PersistedState,
localSession: WorkspaceSessionState,
markNeedsSave: () => void
): PersistedState['workspaceSessionsByHostId'] {
const { partitions, repaired } = parseWorkspaceSessionsByHostId(
parsed.workspaceSessionsByHostId,
defaults.workspaceSession
defaults.workspaceSession,
localSession
)
if (repaired) {
// Why: salvage repairs only the in-memory partitions; without a save the corrupt entries stay on disk and get re-dropped every launch.
@@ -161,7 +161,8 @@ export async function writeToDiskAsync(owner: PrimaryStateWriteOperations): Prom
// Why: fsync before rename, then fsync the directory; see writeFileDurable.
const handle = await open(tmpFile, 'w')
try {
await handle.writeFile(payload, 'utf-8')
// Already UTF-8 bytes: passing the string here would re-encode the whole state on the main thread.
await handle.writeFile(payload)
await handle.sync()
} finally {
await handle.close()
@@ -0,0 +1,184 @@
/**
* The bar for this change is "the bytes on disk did not move". Every case below runs the exact
* loop `applySecretSentinelSubstitutions` replaced — reproduced in `previousImplementation` — and
* compares payload bytes and guard hash, because a drifting hash silently disables the no-op write
* guard and a drifting payload is corrupted persisted state.
*/
import { createHash, randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import {
applySecretSentinelSubstitutions,
type SecretSentinelSubstitution
} from './secret-sentinel-substitution'
/** Verbatim from state-serialization-secret-handling.ts before this change. */
function previousImplementation(
serialized: string,
secretSubs: readonly SecretSentinelSubstitution[],
degradedPrefix: string
): { payload: Buffer; stateHash: string } {
let payload = serialized
let hashInput = serialized
for (const { sentinel, blob, hashValue } of secretSubs) {
const escapedSentinel = JSON.stringify(sentinel).slice(1, -1)
payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1))
hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1))
}
const stateHash = createHash('sha1').update(degradedPrefix).update(hashInput).digest('hex')
// `handle.writeFile(payload, 'utf-8')` is what turned the string into bytes.
return { payload: Buffer.from(payload, 'utf8'), stateHash }
}
function expectIdenticalToPrevious(
serialized: string,
subs: readonly SecretSentinelSubstitution[],
degradedPrefix = ''
): void {
const before = previousImplementation(serialized, subs, degradedPrefix)
const after = applySecretSentinelSubstitutions(serialized, subs, degradedPrefix)
expect(after.payload.equals(before.payload)).toBe(true)
expect(after.stateHash).toBe(before.stateHash)
}
function sentinel(): string {
return `orca-secret-slot-${randomUUID()}`
}
describe('applySecretSentinelSubstitutions', () => {
it('produces bytes and a hash identical to the previous implementation', () => {
const subs: SecretSentinelSubstitution[] = [
{ sentinel: sentinel(), blob: 'djEwY2lwaGVy', hashValue: 'cookie-value' },
{
sentinel: sentinel(),
// Regex-special *and* JSON-escapable, which is the pair that breaks a naive rewrite:
// `$&` would splice the match back in under string-form replace, and the backslash and
// quote have to survive `JSON.stringify(...).slice(1, -1)` unchanged.
blob: 'A+/=$&$1$`\\x "quoted" |.*?[](){}^',
hashValue: 'http://proxy.example:8080/?a=b&c=$&'
},
{ sentinel: sentinel(), blob: '', hashValue: 'https://kagi.com/session?t=abc' }
]
const state = {
settings: { opencodeSessionCookie: subs[0].sentinel, httpProxyUrl: subs[1].sentinel },
ui: { browserKagiSessionLink: subs[2].sentinel },
// Adjacent content that must not shift: a near-miss prefix, and JSON escapes either side.
noise: ['orca-secret-slot-', 'a\\b"c\n\t', subs[0].sentinel.slice(0, -1)]
}
expectIdenticalToPrevious(JSON.stringify(state), subs)
})
it('stays identical when the state holds multi-byte and escaped characters', () => {
const subs: SecretSentinelSubstitution[] = [
{ sentinel: sentinel(), blob: 'blob-é', hashValue: 'plain-é' },
{ sentinel: sentinel(), blob: '😀', hashValue: '中文' }
]
const state = {
// Segment boundaries land next to these, so a wrong split would corrupt the encode.
before: 'é中文😀',
a: subs[0].sentinel,
between: '😀

',
b: subs[1].sentinel,
after: '😀'
}
expectIdenticalToPrevious(JSON.stringify(state), subs)
})
it('stays identical with no substitutions and with the degraded-storage prefix', () => {
const state = JSON.stringify({ settings: { httpProxyUrl: '' }, big: 'x'.repeat(4096) })
expectIdenticalToPrevious(state, [])
expectIdenticalToPrevious(state, [], 'safeStorage-degraded\0')
const subs = [{ sentinel: sentinel(), blob: 'b', hashValue: 'h' }]
expectIdenticalToPrevious(
JSON.stringify({ s: subs[0].sentinel }),
subs,
'safeStorage-degraded\0'
)
})
it('escapes regex metacharacters in the sentinel itself', () => {
// Not reachable from a UUID sentinel, but the alternation must not be able to become a pattern.
const subs = [{ sentinel: 'a.b*c(d)|e[f]', blob: 'BLOB', hashValue: 'HASH' }]
const serialized = JSON.stringify({ real: subs[0].sentinel, decoy: 'axbxxcXdX_eXfX' })
expectIdenticalToPrevious(serialized, subs)
expect(
applySecretSentinelSubstitutions(serialized, subs, '').payload.toString('utf8')
).toContain('axbxxcXdX_eXfX')
})
it('substitutes every occurrence when a sentinel repeats', () => {
// Cannot happen today (a sentinel is a UUID minted after the state is assembled, so it appears
// exactly once), but the old first-match-only `String.replace` would have written a raw
// sentinel to disk in place of a secret if it ever did. The alternation is global instead.
const subs = [{ sentinel: sentinel(), blob: 'CIPHER', hashValue: 'PLAIN' }]
const serialized = JSON.stringify({ a: subs[0].sentinel, b: subs[0].sentinel })
const { payload } = applySecretSentinelSubstitutions(serialized, subs, '')
expect(payload.toString('utf8')).toBe(JSON.stringify({ a: 'CIPHER', b: 'CIPHER' }))
expect(payload.toString('utf8')).not.toContain(subs[0].sentinel)
})
it('copies and UTF-8 encodes the full state once, not once per sentinel per side', () => {
const subs: SecretSentinelSubstitution[] = Array.from({ length: 3 }, () => ({
sentinel: sentinel(),
blob: 'CIPHERTEXT',
hashValue: 'plaintext'
}))
const serialized = JSON.stringify({
pad: 'x'.repeat(200_000),
a: subs[0].sentinel,
b: subs[1].sentinel,
c: subs[2].sentinel
})
const FULL_STATE = 100_000
// Both costs are observable at their sources: a `String.replace` whose receiver is the whole
// state allocates another copy of it, and every string handed to `Buffer.from` or `hash.update`
// is one full UTF-8 encode pass on the main thread.
const counted = (run: () => unknown): { fullStateReplaces: number; encodedChars: number } => {
const realReplace = String.prototype.replace
const realBufferFrom = Buffer.from
const hashProto = Object.getPrototypeOf(createHash('sha1')) as {
update: (...args: unknown[]) => unknown
}
const realUpdate = hashProto.update
const counts = { fullStateReplaces: 0, encodedChars: 0 }
String.prototype.replace = function (this: string, ...args: unknown[]) {
if (this.length >= FULL_STATE) {
counts.fullStateReplaces++
}
return realReplace.apply(this, args as never)
} as typeof String.prototype.replace
Buffer.from = function (...args: unknown[]) {
if (typeof args[0] === 'string') {
counts.encodedChars += args[0].length
}
return (realBufferFrom as (...a: unknown[]) => Buffer).apply(Buffer, args)
} as typeof Buffer.from
hashProto.update = function (this: unknown, ...args: unknown[]) {
if (typeof args[0] === 'string') {
counts.encodedChars += args[0].length
}
return realUpdate.apply(this, args)
}
try {
run()
} finally {
String.prototype.replace = realReplace
Buffer.from = realBufferFrom
hashProto.update = realUpdate
}
return counts
}
const before = counted(() => previousImplementation(serialized, subs, ''))
const after = counted(() => applySecretSentinelSubstitutions(serialized, subs, ''))
// Two `String.replace` calls over the whole state per sentinel — payload and hash input.
expect(before.fullStateReplaces).toBe(subs.length * 2)
expect(after.fullStateReplaces).toBe(0)
// The old path encoded the state twice: once for sha1, once for the file write.
expect(before.encodedChars).toBeGreaterThan(serialized.length * 1.9)
expect(after.encodedChars).toBeLessThan(serialized.length * 1.1)
expect(after.encodedChars).toBeGreaterThan(serialized.length * 0.9)
})
})
@@ -0,0 +1,78 @@
import { createHash } from 'node:crypto'
import { escapeRegex } from '../../../shared/string-utils'
export type SecretSentinelSubstitution = {
/** The `orca-secret-slot-<uuid>` placeholder standing in the serialized state. */
sentinel: string
/** What the on-disk payload gets: the ciphertext. */
blob: string
/** What the guard hash gets: a value stable across non-deterministic encryption. */
hashValue: string
}
/**
* Replace every secret sentinel in `serialized` in ONE pass, producing the on-disk bytes and the
* guard hash from the same encoded segments.
*
* Why not the obvious `payload.replace(...)` / `hashInput.replace(...)` loop it replaces: each
* `String.replace` returns a rope that the *next* `replace` has to flatten before it can search, so
* N sentinels cost 2N-1 flattened copies of the whole multi-MB state, plus one more per side when
* `hash.update` and the file write finally consume them. Measured on a 4.65 MB store with three
* sentinels: 7 full-state string allocations, 62 MB of V8 heap, 27 MB of it in large_object_space.
*
* Here the state is walked once, each literal run is UTF-8 encoded exactly once, and those same
* buffers feed both the payload and the hash — 1 full-state string, 1 encode.
*
* Byte-for-byte identical output to the loop: both sides read the sentinel in its JSON-escaped
* form, the replacements are the JSON-escaped `blob`/`hashValue`, and the hash sees the same byte
* sequence it saw when it was handed one concatenated string.
*/
export function applySecretSentinelSubstitutions(
serialized: string,
substitutions: readonly SecretSentinelSubstitution[],
degradedPrefix: string
): { payload: Buffer; stateHash: string } {
const hash = createHash('sha1').update(degradedPrefix)
if (substitutions.length === 0) {
const payload = Buffer.from(serialized, 'utf8')
return { payload, stateHash: hash.update(payload).digest('hex') }
}
const replacementBySentinel = new Map<string, { blob: Buffer; hashValue: Buffer }>()
const alternatives: string[] = []
for (const { sentinel, blob, hashValue } of substitutions) {
// Preserved from the loop this replaces: both the search key and the replacements are the
// JSON-escaped forms, because that is what `serialized` actually contains.
const escapedSentinel = JSON.stringify(sentinel).slice(1, -1)
if (replacementBySentinel.has(escapedSentinel)) {
continue
}
alternatives.push(escapeRegex(escapedSentinel))
replacementBySentinel.set(escapedSentinel, {
blob: Buffer.from(JSON.stringify(blob).slice(1, -1), 'utf8'),
hashValue: Buffer.from(JSON.stringify(hashValue).slice(1, -1), 'utf8')
})
}
// Global, though a sentinel is a UUID minted after the state was assembled and so occurs exactly
// once: a single pass that substitutes every occurrence cannot leave one behind on disk.
const pattern = new RegExp(alternatives.join('|'), 'g')
const chunks: Buffer[] = []
let cursor = 0
let match: RegExpExecArray | null
while ((match = pattern.exec(serialized)) !== null) {
// Non-null: the alternation is built from exactly the map's keys.
const replacement = replacementBySentinel.get(match[0])!
// A sliced substring, so this does not copy the state; the encode below is its only pass.
const literal = Buffer.from(serialized.slice(cursor, match.index), 'utf8')
chunks.push(literal, replacement.blob)
hash.update(literal)
hash.update(replacement.hashValue)
cursor = match.index + match[0].length
}
const tail = Buffer.from(serialized.slice(cursor), 'utf8')
chunks.push(tail)
hash.update(tail)
return { payload: Buffer.concat(chunks), stateHash: hash.digest('hex') }
}
@@ -1,4 +1,4 @@
import { createHash, randomUUID } from 'node:crypto'
import { randomUUID } from 'node:crypto'
import type { PersistedState } from '../../../shared/persisted-state-types'
import { collectFolderWorkspaceDiffComments } from '../../folder-workspace-diff-comments'
import {
@@ -8,6 +8,10 @@ import {
} from '../../protected-secret-persistence'
import { stripRetiredGlobalSettings } from '../applying-settings/terminal-settings-migrations'
import {
applySecretSentinelSubstitutions,
type SecretSentinelSubstitution
} from './secret-sentinel-substitution'
import type { StoreRuntimeState } from './store-runtime-state'
type StateSerializationSecretHandlingOperationsRuntime = Pick<
@@ -24,7 +28,7 @@ export class StateSerializationSecretHandlingOperations {
}
buildStateToSave(): {
payload: string
payload: Buffer
stateHash: string
protectedSecretUpdates: ProtectedSecretRetentionUpdate[]
} {
@@ -37,7 +41,7 @@ export class StateSerializationSecretHandlingOperations {
// on deterministic-IV platforms (macOS/legacy-Linux OSCrypt). A per-slot
// random UUID can't occur anywhere else in the serialized state (the user
// sets their data before it is minted), so it appears exactly once.
const secretSubs: { sentinel: string; blob: string; hashValue: string }[] = []
const secretSubs: SecretSentinelSubstitution[] = []
const protectedSecretUpdates: ProtectedSecretRetentionUpdate[] = []
let protectedStorageDegraded = false
const encryptToSentinel = (slot: string, plaintext: string): string => {
@@ -105,21 +109,14 @@ export class StateSerializationSecretHandlingOperations {
// Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant.
// One full-state stringify; secret slots currently hold sentinels.
const serialized = JSON.stringify(stateToSave)
// Substitute each unique sentinel exactly once: ciphertext for the on-disk
// payload, a stable normalized value for the guard hash. Function-form
// replacement keeps `$` inert; both sides read the sentinel as JSON-escaped
// in `serialized`, so each replace is byte-for-byte position-exact.
let payload = serialized
let hashInput = serialized
for (const { sentinel, blob, hashValue } of secretSubs) {
const escapedSentinel = JSON.stringify(sentinel).slice(1, -1)
payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1))
hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1))
}
const stateHash = createHash('sha1')
.update(protectedStorageDegraded ? 'safeStorage-degraded\0' : '')
.update(hashInput)
.digest('hex')
// Substitute each unique sentinel: ciphertext for the on-disk payload, a stable normalized
// value for the guard hash. One pass builds both, so the multi-MB state is never copied per
// sentinel and never encoded twice.
const { payload, stateHash } = applySecretSentinelSubstitutions(
serialized,
secretSubs,
protectedStorageDegraded ? 'safeStorage-degraded\0' : ''
)
return { payload, stateHash, protectedSecretUpdates }
}
}
@@ -0,0 +1,131 @@
/**
* The write path now hands the file a Buffer it built in one pass instead of a string it rebuilt
* per secret. Drives the real `Store` end to end — encrypted settings, a local session and a remote
* host partition — and reloads from the file it actually wrote, because the failure this guards
* against (a mis-sliced segment, a re-encoded payload, a dropped sentinel) is invisible until
* something reads the bytes back.
*/
import { mkdtempSync, readFileSync, realpathSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
vi.mock('electron', () => ({
app: {
getPath: () => tmpdir(),
getName: () => 'orca-test',
getVersion: () => '0.0.0-test',
isPackaged: false,
on: () => {},
whenReady: () => Promise.resolve()
},
safeStorage: {
// Encryption ON, so the secret slots really do mint sentinels and the substitution pass runs.
isEncryptionAvailable: () => true,
encryptString: (value: string) => Buffer.from(`enc:${value}`),
decryptString: (value: Buffer) => value.toString().slice(4)
},
ipcMain: { on: () => {}, handle: () => {} },
BrowserWindow: { getAllWindows: () => [] }
}))
const { Store } = await import('./store')
const HOST_ID = 'ssh:user@host'
const stores: InstanceType<typeof Store>[] = []
afterEach(() => {
for (const store of stores.splice(0)) {
store.flush()
}
vi.restoreAllMocks()
})
function openStore(dataFile: string): InstanceType<typeof Store> {
const store = new Store({ dataFile })
stores.push(store)
return store
}
function session(activeTabId: string): WorkspaceSessionState {
return {
activeRepoId: 'repo-1',
// Left null: the load path's deregistered-repo sweep nulls an active worktree whose repo is
// not registered, which would mask what this test is actually about.
activeWorktreeId: null,
activeTabId,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
// Non-ASCII on purpose: a byte-offset mistake in the encode shows up here first.
browserUrlHistory: [
{
url: 'https://example.test/é😀',
normalizedUrl: 'https://example.test/é😀',
title: '中文 title',
lastVisitedAt: 17,
visitCount: 3
}
]
} as WorkspaceSessionState
}
describe('persisted state survives a save/load round trip', () => {
it('reloads settings, secrets and both session partitions unchanged', () => {
const dataFile = join(
realpathSync(mkdtempSync(join(tmpdir(), 'orca-store-round-trip-'))),
'orca-data.json'
)
const written = openStore(dataFile)
written.updateSettings({
// Three secret slots, i.e. three sentinels in one save — the case the old loop paid 7 copies for.
opencodeSessionCookie: 'cookie-é-value',
httpProxyUrl: 'http://proxy.example:8080/?a=b&c=$&'
})
written.updateUI({ browserKagiSessionLink: 'https://kagi.com/session?t=abc' })
written.setWorkspaceSession(session('local-tab'))
written.setWorkspaceSession(session('remote-tab'), HOST_ID)
written.flush()
const before = {
settings: written.getSettings(),
ui: written.getUI(),
local: written.getWorkspaceSession(),
remote: written.getWorkspaceSession(HOST_ID)
}
// The file is valid UTF-8 JSON and holds ciphertext, not the plaintext secrets.
const bytes = readFileSync(dataFile)
const onDisk = JSON.parse(bytes.toString('utf8'))
expect(onDisk.settings.opencodeSessionCookie).not.toBe('cookie-é-value')
expect(Buffer.from(onDisk.settings.opencodeSessionCookie, 'base64').toString('utf8')).toContain(
'cookie-é-value'
)
expect(bytes.toString('utf8')).not.toContain('orca-secret-slot-')
const reloaded = openStore(dataFile)
expect(reloaded.getSettings().opencodeSessionCookie).toBe(before.settings.opencodeSessionCookie)
expect(reloaded.getSettings().httpProxyUrl).toBe(before.settings.httpProxyUrl)
expect(reloaded.getUI().browserKagiSessionLink).toBe(before.ui.browserKagiSessionLink)
// `toMatchObject`: the load path spreads session defaults over what was written, so the
// reloaded slice is a superset. Exact deep equality is asserted on the second trip below.
expect(reloaded.getWorkspaceSession()).toMatchObject(before.local)
// The remote partition keeps everything it owns; only globals local already holds are dropped,
// and `browserUrlHistory` comes back at its default from the same spread as before.
expect(reloaded.getWorkspaceSession(HOST_ID).activeTabId).toBe('remote-tab')
expect(reloaded.getWorkspaceSession(HOST_ID).browserUrlHistory).toEqual([])
// Deep equality of the whole reloaded state, taken across a second round trip so the assertion
// is not comparing against the first load's one-time settings migrations.
reloaded.flush()
const bytesAfterReload = readFileSync(dataFile)
const again = openStore(dataFile)
expect(again.getSettings()).toEqual(reloaded.getSettings())
expect(again.getUI()).toEqual(reloaded.getUI())
expect(again.getWorkspaceSession()).toEqual(reloaded.getWorkspaceSession())
expect(again.getWorkspaceSession(HOST_ID)).toEqual(reloaded.getWorkspaceSession(HOST_ID))
// ...and the bytes are stable, so a quiet app is not rewriting a 4 MB file with new content.
again.flush()
expect(readFileSync(dataFile).equals(bytesAfterReload)).toBe(true)
})
})
@@ -0,0 +1,141 @@
/**
* Global session fields live in the 'local' slice. Copies of them inside a non-local host partition
* are legacy residue: the split never writes them there and the merge never reads them from there
* unless local has nothing. These tests pin the drop to exactly that condition, keep the renderer's
* merge landing on the same value either way, and re-check the two safety gates that decide which
* global fields may be dropped at all.
*/
import { describe, expect, it } from 'vitest'
import { getDefaultWorkspaceSession } from '../../../shared/constants'
import type { BrowserHistoryEntry } from '../../../shared/browser-workspace-types'
import type { WorkspaceDocHistoryEntry } from '../../../shared/workspace-doc-history'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from '../../../shared/workspace-session-host-field-ownership'
import { WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND } from '../restoring-sessions/session-worktree-ownership'
import {
HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS,
parseWorkspaceSessionsByHostId
} from './workspace-session-partitions'
const HOST = 'ssh:target-1'
function history(url: string): BrowserHistoryEntry[] {
return [{ url, normalizedUrl: url, title: url, lastVisitedAt: 1, visitCount: 1 }]
}
function docEntry(filePath: string): WorkspaceDocHistoryEntry {
return {
docLocation: { kind: 'workspace-doc', worktreeId: 'repo-1::/tmp/a', filePath },
title: filePath,
lastVisitedAt: 2,
visitCount: 1
}
}
function localSession(overrides: Partial<WorkspaceSessionState>): WorkspaceSessionState {
return { ...getDefaultWorkspaceSession(), ...overrides }
}
function parse(
raw: Record<string, unknown>,
local?: WorkspaceSessionState
): Partial<Record<string, WorkspaceSessionState>> {
return parseWorkspaceSessionsByHostId(raw, getDefaultWorkspaceSession(), local).partitions
}
describe('HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS', () => {
it('only lists fields that are global AND that no worktree-ownership pass follows', () => {
for (const field of HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS) {
// Gate 1: the renderer's split/merge treat it as local-owned, so a non-local copy is dead.
expect(WORKSPACE_SESSION_FIELD_OWNERSHIP[field]).toBe('global')
// Gate 2: `collectPersistedSessionWorktreeOwners` and the deregistered-repo residue sweep
// walk EVERY partition through this table. Anything but 'none' means dropping the field
// could un-own a worktree and get its metadata pruned.
expect(WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND[field]).toBe('none')
}
})
})
describe('parseWorkspaceSessionsByHostId global-field residue', () => {
it('drops a non-local global field the local slice already owns', () => {
const local = localSession({ browserUrlHistory: history('https://local.test') })
const partitions = parse(
{
[HOST]: {
...getDefaultWorkspaceSession(),
browserUrlHistory: history('https://stale.test')
}
},
local
)
// Back to the default from the spread, not the 65 KB stale replica. The merge reads this field
// from local whenever local has it, so the renderer still sees `https://local.test`
// (`workspace-session-host-split.test.ts` pins that half of the contract).
expect(partitions[HOST]?.browserUrlHistory).toEqual([])
})
it('retains a non-local global field the local slice does NOT have', () => {
// `workspaceDocHistory` is optional and absent from the defaults, so local can genuinely lack
// it and the merge's fallback to another slice is live.
const local = localSession({})
expect(local.workspaceDocHistory).toBeUndefined()
const docs = [docEntry('/repo/remote.md')]
const partitions = parse(
{ [HOST]: { ...getDefaultWorkspaceSession(), workspaceDocHistory: docs } },
local
)
// Retained, so the merge's "fall back to any slice that has it" path still finds a value.
expect(partitions[HOST]?.workspaceDocHistory).toEqual(docs)
})
it('drops that same field once the local slice does have it', () => {
const localDocs = [docEntry('/repo/local.md')]
const local = localSession({ workspaceDocHistory: localDocs })
const partitions = parse(
{
[HOST]: {
...getDefaultWorkspaceSession(),
workspaceDocHistory: [docEntry('/repo/stale.md')]
}
},
local
)
expect(partitions[HOST]).not.toHaveProperty('workspaceDocHistory')
expect(local.workspaceDocHistory).toEqual(localDocs)
})
it('leaves worktree-referencing globals and host-owned fields alone', () => {
const local = localSession({
browserUrlHistory: history('https://local.test'),
activeWorktreeId: 'repo-1::/tmp/local',
activeTabId: 'local-tab'
})
const tabs = { 'repo-1::/tmp/a': [] }
const partitions = parse(
{
[HOST]: {
...getDefaultWorkspaceSession(),
// A `'direct'` worktree reference the residue sweep reads out of every partition.
activeWorktreeId: 'repo-1::/tmp/a',
// Read on a partition by the mobile terminal projection.
activeTabId: 'remote-tab',
tabsByWorktree: tabs,
terminalTopologyRevisionByRepoId: { 'repo-1': 4 }
}
},
local
)
expect(partitions[HOST]?.activeWorktreeId).toBe('repo-1::/tmp/a')
expect(partitions[HOST]?.activeTabId).toBe('remote-tab')
expect(partitions[HOST]?.tabsByWorktree).toEqual(tabs)
expect(partitions[HOST]?.terminalTopologyRevisionByRepoId).toEqual({ 'repo-1': 4 })
})
it('is a no-op when no local slice is supplied', () => {
const stale = history('https://stale.test')
const partitions = parse({
[HOST]: { ...getDefaultWorkspaceSession(), browserUrlHistory: stale }
})
expect(partitions[HOST]?.browserUrlHistory).toEqual(stale)
})
})
@@ -17,11 +17,49 @@ export function workspaceSessionSalvageLogDetails(result: {
}
}
/**
* Global fields belong to the 'local' slice: the split writes them only there and the merge reads
* them only from there. A copy inside a non-local partition is legacy residue no read can reach —
* stale `browserUrlHistory` replicas alone were 589 KB, 12.7% of a 4.65 MB store, rewritten on
* every save and reparsed on every launch.
*
* Deliberately NOT every field in `GLOBAL_WORKSPACE_SESSION_FIELDS`. Two separate gates disqualify
* the rest, and both are load-bearing:
* - `activeWorktreeId` and `activeWorkspaceKey` are `'direct'` in
* `WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND`, and both `collectPersistedSessionWorktreeOwners`
* and the deregistered-repo residue sweep read them out of EVERY partition. Dropping one
* un-owns a worktree, and an un-owned worktree gets its metadata pruned.
* - `activeTabId`, `activeConnectionIdsAtShutdown` and `activeRepoId` have live main-side readers
* on a partition: `isPersistedTerminalLeafActive` falls back to `activeTabId` for the mobile
* projection, and the runtime attach-window handoff unions `activeConnectionIdsAtShutdown`.
*
* `workspace-session-partitions.test.ts` re-checks both gates for every field listed here.
*/
export const HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS = [
'browserUrlHistory',
'workspaceDocHistory'
] as const satisfies readonly (keyof WorkspaceSessionState)[]
/** Dropped only where local already holds the field — exactly when the merge's fallback to another
* slice cannot fire. Runs before the defaults spread, so a field the type requires comes back at
* its default rather than going missing. */
function dropRedundantGlobalFields(
slice: Partial<WorkspaceSessionState>,
local: WorkspaceSessionState | undefined
): void {
for (const field of HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS) {
if (local?.[field] !== undefined) {
delete slice[field]
}
}
}
/** Normalize non-'local' host partitions; 'local' (the legacy workspaceSession blob) is dropped so the two surfaces never diverge.
* Each partition is zod-validated independently, so one corrupt host drops to defaults without taking out the others. Idempotent. */
export function parseWorkspaceSessionsByHostId(
raw: unknown,
defaults: WorkspaceSessionState
defaults: WorkspaceSessionState,
localSession?: WorkspaceSessionState
): { partitions: Partial<Record<ExecutionHostId, WorkspaceSessionState>>; repaired: boolean } {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return { partitions: {}, repaired: raw !== undefined }
@@ -50,6 +88,7 @@ export function parseWorkspaceSessionsByHostId(
)
repaired = true
}
dropRedundantGlobalFields(result.value, localSession)
partitions[hostId] = { ...defaults, ...result.value }
}
return { partitions, repaired }
@@ -15,6 +15,8 @@ import { registerPersistedPaneKeyAlias } from '../restoring-sessions/pane-alias-
import {
normalizeWorkspaceSessionPaneIdentities,
remapAcknowledgedAgentPaneKeys,
remapActivityClearedAtPaneKeys,
remapManuallyUnreadTurnPaneKeys,
remapSshRemotePtyLeaseLeafIds,
type WorkspaceSessionPaneIdentityRemap
} from '../restoring-sessions/workspace-pane-normalization'
@@ -50,10 +52,30 @@ export function setLocalWorkspaceSession(
context.runtime.state.ui?.acknowledgedAgentsByPaneKey,
normalized.leafIdByInputLeafIdByTabId
)
if (remappedAcknowledgements.changed) {
const remappedActivityCutoffs = remapActivityClearedAtPaneKeys(
context.runtime.state.ui?.activityClearedAtByPaneKey,
normalized.leafIdByInputLeafIdByTabId
)
const remappedManualUnread = remapManuallyUnreadTurnPaneKeys(
context.runtime.state.ui?.manuallyUnreadTurnsByPaneKey,
normalized.leafIdByInputLeafIdByTabId
)
if (
remappedAcknowledgements.changed ||
remappedActivityCutoffs.changed ||
remappedManualUnread.changed
) {
context.runtime.state.ui = {
...context.runtime.state.ui,
acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements
...(remappedAcknowledgements.changed
? { acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements }
: {}),
...(remappedActivityCutoffs.changed
? { activityClearedAtByPaneKey: remappedActivityCutoffs.cutoffs }
: {}),
...(remappedManualUnread.changed
? { manuallyUnreadTurnsByPaneKey: remappedManualUnread.turns }
: {})
}
}
for (const entry of normalized.legacyPaneKeyAliasEntries) {
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { makePaneKey } from '../../../shared/stable-pane-id'
import {
remapActivityClearedAtPaneKeys,
remapManuallyUnreadTurnPaneKeys
} from './pane-key-remapping'
const STABLE_LEAF_ID = '00000000-0000-4000-8000-000000000001'
describe('remapManuallyUnreadTurnPaneKeys', () => {
it('promotes legacy pane keys to the restored stable leaf like clear-completed cutoffs', () => {
const remap = new Map([['tab-1', new Map([['pane:1', STABLE_LEAF_ID]])]])
const turns = { 'tab-1:pane:1': 42, 'tab-2:pane:9': 7 }
const result = remapManuallyUnreadTurnPaneKeys(turns, remap)
expect(result.changed).toBe(true)
expect(result.turns).toEqual({ [makePaneKey('tab-1', STABLE_LEAF_ID)]: 42, 'tab-2:pane:9': 7 })
// Same remap contract as the cutoffs so the two never drift after a session restore.
expect(remapActivityClearedAtPaneKeys(turns, remap).cutoffs).toEqual(result.turns)
})
it('reports no change for empty or already-stable records', () => {
const remap = new Map([['tab-1', new Map([['pane:1', STABLE_LEAF_ID]])]])
expect(remapManuallyUnreadTurnPaneKeys(undefined, remap).changed).toBe(false)
expect(remapManuallyUnreadTurnPaneKeys({}, remap).changed).toBe(false)
const stable = { [makePaneKey('tab-1', STABLE_LEAF_ID)]: 1 }
expect(remapManuallyUnreadTurnPaneKeys(stable, remap)).toEqual({
turns: stable,
changed: false
})
})
})
@@ -0,0 +1,75 @@
import type { PersistedState } from '../../../shared/persisted-state-types'
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
type PaneLeafRemap = Map<string, Map<string, string>>
function remapPaneKeys<T extends number>(
values: Record<string, T> | undefined,
leafIdByInputLeafIdByTabId: PaneLeafRemap
): { values: Record<string, T> | undefined; changed: boolean } {
if (!values || Object.keys(values).length === 0) {
return { values, changed: false }
}
let changed = false
const next: Record<string, T> = {}
const setValue = (paneKey: string, value: T): void => {
const existing = next[paneKey]
next[paneKey] = existing === undefined ? value : (Math.max(existing, value) as T)
}
for (const [paneKey, value] of Object.entries(values)) {
const parsed = parsePaneKey(paneKey)
if (parsed) {
setValue(paneKey, value)
continue
}
const delimiter = paneKey.indexOf(':')
if (delimiter <= 0 || delimiter === paneKey.length - 1) {
setValue(paneKey, value)
continue
}
const tabId = paneKey.slice(0, delimiter)
const legacyLeafId = paneKey.slice(delimiter + 1)
const remappedLeafId = leafIdByInputLeafIdByTabId.get(tabId)?.get(legacyLeafId)
if (!remappedLeafId || !isTerminalLeafId(remappedLeafId)) {
setValue(paneKey, value)
continue
}
try {
// Carry values over when a legacy leaf is promoted to a UUID.
setValue(makePaneKey(tabId, remappedLeafId), value)
changed = true
} catch {
setValue(paneKey, value)
}
}
return { values: next, changed }
}
export function remapAcknowledgedAgentPaneKeys(
acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey'],
leafIdByInputLeafIdByTabId: PaneLeafRemap
): { acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey']; changed: boolean } {
const result = remapPaneKeys(acknowledgements, leafIdByInputLeafIdByTabId)
return { acknowledgements: result.values, changed: result.changed }
}
export function remapManuallyUnreadTurnPaneKeys(
turns: PersistedState['ui']['manuallyUnreadTurnsByPaneKey'],
leafIdByInputLeafIdByTabId: PaneLeafRemap
): { turns: PersistedState['ui']['manuallyUnreadTurnsByPaneKey']; changed: boolean } {
const result = remapPaneKeys(turns, leafIdByInputLeafIdByTabId)
return { turns: result.values, changed: result.changed }
}
export function remapActivityClearedAtPaneKeys(
cutoffs: PersistedState['ui']['activityClearedAtByPaneKey'],
leafIdByInputLeafIdByTabId: PaneLeafRemap
): { cutoffs: PersistedState['ui']['activityClearedAtByPaneKey']; changed: boolean } {
const result = remapPaneKeys(cutoffs, leafIdByInputLeafIdByTabId)
return { cutoffs: result.values, changed: result.changed }
}
@@ -8,7 +8,7 @@ import {
type ExecutionHostId
} from '../../../shared/execution-host'
import type { SshRemotePtyLease } from '../../../shared/ssh-types'
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
import { isTerminalLeafId, parsePaneKey } from '../../../shared/stable-pane-id'
import { findCrossHostPaneTabIds, withoutPaneTabIds } from './cross-host-pane-tab-ids'
import {
createLazyTerminalTabLookup,
@@ -22,6 +22,17 @@ import {
migrationUnsupportedEntriesEqual,
normalizeLegacyPaneKeyAliasEntries
} from './pane-alias-normalization'
import {
remapAcknowledgedAgentPaneKeys,
remapActivityClearedAtPaneKeys,
remapManuallyUnreadTurnPaneKeys
} from './pane-key-remapping'
export {
remapAcknowledgedAgentPaneKeys,
remapActivityClearedAtPaneKeys,
remapManuallyUnreadTurnPaneKeys
} from './pane-key-remapping'
export function normalizeWorkspaceSessionPaneIdentities(
session: WorkspaceSessionState,
@@ -220,6 +231,14 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): {
state.ui?.acknowledgedAgentsByPaneKey,
withoutPaneTabIds(acknowledgementLeafIdByInputLeafIdByTabId, crossHostTabIds)
)
const remappedActivityCutoffs = remapActivityClearedAtPaneKeys(
state.ui?.activityClearedAtByPaneKey,
withoutPaneTabIds(acknowledgementLeafIdByInputLeafIdByTabId, crossHostTabIds)
)
const remappedManualUnread = remapManuallyUnreadTurnPaneKeys(
state.ui?.manuallyUnreadTurnsByPaneKey,
withoutPaneTabIds(acknowledgementLeafIdByInputLeafIdByTabId, crossHostTabIds)
)
const migrationUnsupportedChanged = !migrationUnsupportedEntriesEqual(
state.migrationUnsupportedPtyEntries ?? [],
mergedMigrationUnsupportedEntries
@@ -234,7 +253,9 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): {
!remappedLeases.changed &&
!migrationUnsupportedChanged &&
!legacyAliasesChanged &&
!remappedAcknowledgements.changed
!remappedAcknowledgements.changed &&
!remappedActivityCutoffs.changed &&
!remappedManualUnread.changed
) {
return {
state,
@@ -251,11 +272,21 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): {
sshRemotePtyLeases: remappedLeases.leases,
migrationUnsupportedPtyEntries: mergedMigrationUnsupportedEntries,
legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries,
...(remappedAcknowledgements.changed
...(remappedAcknowledgements.changed ||
remappedActivityCutoffs.changed ||
remappedManualUnread.changed
? {
ui: {
...state.ui,
acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements
...(remappedAcknowledgements.changed
? { acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements }
: {}),
...(remappedActivityCutoffs.changed
? { activityClearedAtByPaneKey: remappedActivityCutoffs.cutoffs }
: {}),
...(remappedManualUnread.changed
? { manuallyUnreadTurnsByPaneKey: remappedManualUnread.turns }
: {})
}
}
: {})
@@ -265,50 +296,3 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): {
legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries
}
}
export function remapAcknowledgedAgentPaneKeys(
acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey'],
leafIdByInputLeafIdByTabId: Map<string, Map<string, string>>
): { acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey']; changed: boolean } {
if (!acknowledgements || Object.keys(acknowledgements).length === 0) {
return { acknowledgements, changed: false }
}
let changed = false
const next: NonNullable<PersistedState['ui']['acknowledgedAgentsByPaneKey']> = {}
const setAcknowledgement = (paneKey: string, acknowledgedAt: number): void => {
const existing = next[paneKey]
next[paneKey] = existing === undefined ? acknowledgedAt : Math.max(existing, acknowledgedAt)
}
for (const [paneKey, acknowledgedAt] of Object.entries(acknowledgements)) {
const parsed = parsePaneKey(paneKey)
if (parsed) {
setAcknowledgement(paneKey, acknowledgedAt)
continue
}
const delimiter = paneKey.indexOf(':')
if (delimiter <= 0 || delimiter === paneKey.length - 1) {
setAcknowledgement(paneKey, acknowledgedAt)
continue
}
const tabId = paneKey.slice(0, delimiter)
const legacyLeafId = paneKey.slice(delimiter + 1)
const remappedLeafId = leafIdByInputLeafIdByTabId.get(tabId)?.get(legacyLeafId)
if (!remappedLeafId || !isTerminalLeafId(remappedLeafId)) {
setAcknowledgement(paneKey, acknowledgedAt)
continue
}
try {
// Why: when a legacy leaf is promoted to a UUID, carry the read marker over so seen rows don't come back unread.
setAcknowledgement(makePaneKey(tabId, remappedLeafId), acknowledgedAt)
changed = true
} catch {
setAcknowledgement(paneKey, acknowledgedAt)
}
}
return { acknowledgements: next, changed }
}
@@ -1,16 +1,15 @@
import {
isAgentForegroundWrapperProcess,
isExpectedAgentProcess,
recognizeAgentProcessFromCommandLine
isExpectedAgentProcess
} from '../../shared/agent-process-recognition'
import { getFirstCommandToken } from '../../shared/command-token-scanner'
import { resolveOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent'
import { selectForegroundProcessCandidate } from '../../shared/foreground-process-selection'
import type { ForegroundProcessEvidence } from '../../shared/foreground-process-evidence'
import {
buildProcessTableIndex,
getStrictProcessTableSnapshot,
lookupProcessTableIndex,
scoreForegroundCandidateRow,
type ProcessTableIndex,
type ProcessTableIndexStats,
type ProcessTableRow
@@ -126,23 +125,15 @@ export function resolveAgentForegroundProcessesFromIndex(
if (wrapperFallback && candidates.length !== 1) {
return { available: true, processName: null }
}
let bestCandidate: (ProcessTableRow & { depth: number }) | null = null
let bestName: ReturnType<typeof recognizeAgentProcessFromCommandLine> = null
for (const candidate of candidates) {
const recognized = recognizeAgentProcessFromCommandLine(candidate.command)
if (
recognized &&
(bestCandidate === null ||
scoreForegroundCandidateRow(candidate) > scoreForegroundCandidateRow(bestCandidate))
) {
bestCandidate = candidate
bestName = recognized
}
}
if (bestCandidate && bestName) {
const selected = selectForegroundProcessCandidate(candidates, allCandidates)
if (selected) {
return {
available: true,
processName: resolveOuterWrapperForegroundProcess(bestName, bestCandidate, allCandidates)
processName: resolveOuterWrapperForegroundProcess(
selected.recognized,
selected.candidate,
allCandidates
)
}
}
return { available: true, processName: null }
@@ -0,0 +1,37 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { gunzipSync } from 'node:zlib'
import { describe, expect, it } from 'vitest'
import type { ProcessTableRow } from '../../shared/process-table-snapshot'
import { resolveAgentForegroundProcessFromPs } from './agent-foreground-process'
type CapturedRun = {
agent: string
shellPid: number
rows: ProcessTableRow[]
}
describe('real foreground process captures', () => {
it('resolves all six agents, including omp over its deeper vendor helpers', () => {
const captured = JSON.parse(
gunzipSync(readFileSync(join(__dirname, '__fixtures__', 'real-agent-rows.json.gz'))).toString(
'utf8'
)
) as CapturedRun[]
expect(captured).toHaveLength(6)
expect(
captured.map(({ agent, shellPid, rows }) => ({
agent,
processName: resolveAgentForegroundProcessFromPs(rows, shellPid)
}))
).toEqual([
{ agent: 'claude', processName: 'claude' },
{ agent: 'codex', processName: 'codex' },
{ agent: 'opencode', processName: 'opencode' },
{ agent: 'gemini', processName: 'gemini' },
{ agent: 'grok', processName: 'grok' },
{ agent: 'omp', processName: 'omp' }
])
})
})
+26 -51
View File
@@ -1,7 +1,9 @@
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
import { resolveOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent'
import {
collectDescendantsFromIndex,
getFreshProcessTableSnapshot,
getProcessTableIndex,
getProcessTableSnapshot,
type ProcessTableRow
} from '../../shared/process-table-snapshot'
@@ -11,6 +13,7 @@ import {
type AgentForegroundResolutionOptions
} from './windows-agent-foreground-process'
import { isShellProcess } from '../../shared/shell-process-detection'
import { selectForegroundProcessCandidate } from '../../shared/foreground-process-selection'
export type { AgentForegroundResolutionOptions } from './windows-agent-foreground-process'
export {
@@ -42,29 +45,6 @@ type ShellForegroundConfirmationOptions = {
| Promise<ReadonlySet<number> | null>
}
function collectDescendants<Row extends { pid: number; ppid: number }>(
rows: Row[],
rootPid: number
): (Row & { depth: number })[] {
const childrenByParent = new Map<number, Row[]>()
for (const row of rows) {
const children = childrenByParent.get(row.ppid) ?? []
children.push(row)
childrenByParent.set(row.ppid, children)
}
const descendants: (Row & { depth: number })[] = []
const stack = (childrenByParent.get(rootPid) ?? []).map((row) => ({ row, depth: 1 }))
while (stack.length > 0) {
const { row, depth } = stack.pop()!
descendants.push({ ...row, depth })
for (const child of childrenByParent.get(row.pid) ?? []) {
stack.push({ row: child, depth: depth + 1 })
}
}
return descendants
}
function commandExecutable(command: string): string {
const trimmed = command.trim().replace(/^[-]/, '')
if (trimmed.startsWith('"') || trimmed.startsWith("'")) {
@@ -96,12 +76,12 @@ export async function confirmShellForegroundProcess(
}
}
try {
const rows = await getFreshProcessTableSnapshot()
if (!rows.some((row) => row.pid === shellPid)) {
const index = getProcessTableIndex(await getFreshProcessTableSnapshot())
const root = index.byPid.get(shellPid)
if (!root) {
return false
}
const root = rows.find((row) => row.pid === shellPid)!
const tree = [{ ...root, depth: 0 }, ...collectDescendants(rows, shellPid)]
const tree = [{ ...root, depth: 0 }, ...collectDescendantsFromIndex(index, shellPid)]
const spawnedShellBasename = executableBasename(spawnedShellProcess)
const foregroundShell = tree
.filter(
@@ -120,13 +100,6 @@ export async function confirmShellForegroundProcess(
}
}
function candidateScore(row: ProcessTableRow & { depth: number }): number {
// Why: foreground descendants carry `+` in `ps stat` on Unix PTYs. Prefer
// them, then prefer leaf/deeper wrappers so `node /path/bin/codex` beats the
// parent shell but still lets the native child confirm the same identity.
return (row.stat.includes('+') ? 10_000 : 0) + row.depth
}
export async function resolveAgentForegroundProcess(
shellPid: number | null | undefined,
fallbackProcess: string | null,
@@ -178,7 +151,7 @@ export async function resolveAgentForegroundProcessWithAvailability(
const rows = options.fresh
? await getFreshProcessTableSnapshot()
: await getProcessTableSnapshot()
if (options.fresh && !rows.some((row) => row.pid === shellPid)) {
if (options.fresh && !getProcessTableIndex(rows).byPid.has(shellPid)) {
return { available: false, processName: fallbackProcess }
}
return {
@@ -191,30 +164,32 @@ export async function resolveAgentForegroundProcessWithAvailability(
}
}
function resolveAgentForegroundProcessFromPs(
rows: ProcessTableRow[],
export function resolveAgentForegroundProcessFromPs(
rows: readonly ProcessTableRow[],
shellPid: number
): string | null {
const shellRow = rows.find((row) => row.pid === shellPid)
const candidates = collectDescendants(rows, shellPid).sort(
(a, b) => candidateScore(b) - candidateScore(a)
)
// Memoized per snapshot identity, so the caller's own index build is reused.
const index = getProcessTableIndex(rows)
const shellRow = index.byPid.get(shellPid)
const candidates = collectDescendantsFromIndex(index, shellPid)
// Why: `+` in `ps stat` marks the process holding the terminal foreground.
// The root shell can hold it after Ctrl-Z, so use the whole PTY tree as the
// foreground gate; otherwise a stopped agent child still masquerades as live.
const foregroundIsKnown =
shellRow?.stat.includes('+') === true ||
candidates.some((candidate) => candidate.stat.includes('+'))
for (const candidate of candidates) {
if (foregroundIsKnown && !candidate.stat.includes('+')) {
continue
}
const recognized = recognizeAgentProcessFromCommandLine(candidate.command)
if (recognized) {
// Why: return the outer wrapper (omp) rather than the deeper wrapped child
// (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess.
return resolveOuterWrapperForegroundProcess(recognized, candidate, candidates)
}
const foregroundCandidates = foregroundIsKnown
? candidates.filter((candidate) => candidate.stat.includes('+'))
: candidates
// Keep the complete process tree for ancestry checks. A recognized agent can
// sit above a non-foreground helper before another recognized process; the
// helper is filtered from selection but must remain traversable.
const ancestryCandidates = shellRow ? [{ ...shellRow, depth: 0 }, ...candidates] : candidates
const selected = selectForegroundProcessCandidate(foregroundCandidates, ancestryCandidates)
if (selected) {
// Why: return the outer wrapper (omp) rather than the deeper wrapped child
// (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess.
return resolveOuterWrapperForegroundProcess(selected.recognized, selected.candidate, candidates)
}
return null
}
@@ -486,14 +486,16 @@ describe('SshFilesystemProvider', () => {
expect(result).toEqual(searchResult)
})
it('listFiles sends fs.listFiles request', async () => {
// Why #12547: a monorepo listing does not fit one control-lane frame, so the request opts into
// response streaming. An old relay ignores `__streamResponse` and answers plainly, which is the
// plain-array case each of these asserts.
it('listFiles sends a streamable fs.listFiles request', async () => {
mux.request.mockResolvedValue(['src/index.ts', 'package.json'])
const result = await provider.listFiles('/home/user/project')
expect(mux.request).toHaveBeenCalledWith(
'fs.listFiles',
{ rootPath: '/home/user/project' },
{ signal: undefined }
)
expect(mux.request).toHaveBeenCalledWith('fs.listFiles', {
rootPath: '/home/user/project',
__streamResponse: true
})
expect(result).toEqual(['src/index.ts', 'package.json'])
})
@@ -503,26 +505,22 @@ describe('SshFilesystemProvider', () => {
maxResults: 20_000,
searchQuery: 'target'
})
expect(mux.request).toHaveBeenCalledWith(
'fs.listFiles',
{
rootPath: '/home/user/project',
excludePaths: ['/home/user/project/worktrees/b'],
maxResults: 20_000,
searchQuery: 'target'
},
{ signal: undefined }
)
expect(mux.request).toHaveBeenCalledWith('fs.listFiles', {
rootPath: '/home/user/project',
excludePaths: ['/home/user/project/worktrees/b'],
maxResults: 20_000,
searchQuery: 'target',
__streamResponse: true
})
})
it('listFiles omits excludePaths when empty', async () => {
mux.request.mockResolvedValue([])
await provider.listFiles('/home/user/project', { excludePaths: [] })
expect(mux.request).toHaveBeenCalledWith(
'fs.listFiles',
{ rootPath: '/home/user/project' },
{ signal: undefined }
)
expect(mux.request).toHaveBeenCalledWith('fs.listFiles', {
rootPath: '/home/user/project',
__streamResponse: true
})
})
it('listFiles forwards the cancellation signal to the mux request (#7721)', async () => {
@@ -531,8 +529,8 @@ describe('SshFilesystemProvider', () => {
await provider.listFiles('/home/user/project', { signal: controller.signal })
expect(mux.request).toHaveBeenCalledWith(
'fs.listFiles',
{ rootPath: '/home/user/project' },
{ signal: controller.signal }
{ rootPath: '/home/user/project', __streamResponse: true },
{ signal: controller.signal, timeoutMs: undefined }
)
})
@@ -1,6 +1,7 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { isMethodNotFoundError, readFileViaStream } from '../ssh/ssh-filesystem-stream-reader'
import { uploadBuffer } from '../ssh/sftp-upload'
import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader'
import { lstatViaSftp } from './ssh-filesystem-provider-sftp'
import {
downloadFileViaSftp,
@@ -314,7 +315,11 @@ export class SshFilesystemProvider implements IFilesystemProvider {
// Why #7721: the signal lets a workspace switch send rpc.cancel so the
// relay aborts the full-tree scan instead of stacking abandoned scans
// that starve interactive fs.readDir/fs.stat on the shared SSH channel.
return (await this.mux.request('fs.listFiles', params, {
// Why streamable: a monorepo listing serializes past the relay's 1 MiB control lane, and the
// lane it demotes to is refused under unrelated producer load. Opting in moves it to the bulk
// lane in chunks; an old relay ignores the flag and answers plainly, which the reader detects
// by the sentinel marker being absent.
return (await requestGitStreamable(this.mux, 'fs.listFiles', params, {
signal: options?.signal
})) as string[]
}
@@ -0,0 +1,156 @@
// Regression guard on the per-inspection cost of Windows agent foreground
// inspection — the Windows analogue of the POSIX index memo (#6288).
//
// The shared TTL cache already collapses N panes into one Toolhelp32 snapshot
// (windows-agent-foreground-process-scan-volume.test.ts). What it never
// collapsed is the work each pane does ON that snapshot: a full
// `native.map(toProcessRow)` projection, a `childrenByPpid` Map rebuilt from
// scratch, and two linear scans. This file counts that work at a realistic
// table size and pane count, and pins the flag set the snapshot asks for.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table'
import {
queryWindowsPaneProcessInventory,
resetWindowsProcessRowsSnapshotForTests
} from './windows-foreground-process-rows'
// 1050 processes is the host measured in windows-process-enumeration.md; 11
// panes is the fan-out the shared snapshot exists to serve.
const TABLE_SIZE = 1050
const PANE_COUNT = 11
const SELF_ROW = { pid: process.pid, ppid: 0, name: 'vitest.exe', commandLine: 'vitest' }
const shellPid = (pane: number): number => 10_000 + pane * 10
const agentPid = (pane: number): number => shellPid(pane) + 1
/** A row every pane can look up, so distinct results == distinct projections. */
const PROBE_PID = 900_000 + TABLE_SIZE - 1
/** One shell + one agent child per pane, padded out to a real table size. */
function buildNativeTable(): { pid: number; ppid: number; name: string; commandLine: string }[] {
const rows = [SELF_ROW]
for (let pane = 0; pane < PANE_COUNT; pane += 1) {
rows.push({ pid: shellPid(pane), ppid: 4, name: 'cmd.exe', commandLine: 'cmd.exe' })
rows.push({
pid: agentPid(pane),
ppid: shellPid(pane),
name: 'node.exe',
commandLine: 'node C:/Users/dev/AppData/codex/bin/codex.js'
})
}
for (let filler = rows.length; filler < TABLE_SIZE; filler += 1) {
rows.push({ pid: 900_000 + filler, ppid: 4, name: 'svchost.exe', commandLine: 'svchost.exe' })
}
return rows
}
const NATIVE_TABLE = buildNativeTable()
/**
* Count `Map.prototype.set` calls — the primitive both the old per-call
* `childrenByPpid` rebuild and the shared index build are made of. Patched for
* one awaited region and restored in `finally`, so nothing else observes it.
*/
async function countMapInsertions(run: () => Promise<void>): Promise<number> {
const original = Map.prototype.set
let insertions = 0
Map.prototype.set = function patched(this: Map<unknown, unknown>, key: unknown, value: unknown) {
insertions += 1
return original.call(this, key, value)
} as typeof Map.prototype.set
try {
await run()
} finally {
Map.prototype.set = original
}
return insertions
}
describe('windows foreground inspection cost per pane', () => {
const getAllProcesses = vi.fn()
let platform: PropertyDescriptor | undefined
let flagsSeen: number[] = []
beforeEach(() => {
flagsSeen = []
getAllProcesses.mockReset()
getAllProcesses.mockImplementation((cb: (rows: unknown) => void, flags: number) => {
flagsSeen.push(flags)
cb(NATIVE_TABLE)
})
platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
__setWindowsProcessTreeLoaderForTests(() => ({
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 },
getAllProcesses
}))
resetWindowsProcessRowsSnapshotForTests()
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(0)
})
afterEach(() => {
vi.useRealTimers()
__setWindowsProcessTreeLoaderForTests()
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
})
async function sweepPanes(): Promise<(number | undefined)[]> {
const resolved: (number | undefined)[] = []
for (let pane = 0; pane < PANE_COUNT; pane += 1) {
const inventory = await queryWindowsPaneProcessInventory(shellPid(pane), {
anchorPid: agentPid(pane)
})
expect(inventory?.candidates).toHaveLength(1)
resolved.push(inventory?.candidates[0]?.pid)
}
return resolved
}
it('never sets the Memory flag on the snapshot', async () => {
await queryWindowsPaneProcessInventory(shellPid(0))
expect(flagsSeen).toHaveLength(1)
// Memory is bit 0, and it costs the addon a second OpenProcess per process
// carrying PROCESS_VM_READ (process.cc `GetProcessMemoryUsage`).
expect(flagsSeen[0]! & 1).toBe(0)
// CommandLine (2) | CreationTime (4).
expect(flagsSeen[0]).toBe(6)
})
it('projects the shared snapshot once for the whole pane fan-out', async () => {
const probeRows: unknown[] = []
for (let pane = 0; pane < PANE_COUNT; pane += 1) {
const inventory = await queryWindowsPaneProcessInventory(shellPid(pane), {
anchorPid: PROBE_PID
})
probeRows.push(inventory?.anchorRow)
}
expect(probeRows.filter(Boolean)).toHaveLength(PANE_COUNT)
// One projection produced every pane's row object. Pre-fix each pane ran
// its own `native.map(toProcessRow)` over all 1050 rows, so this set held
// PANE_COUNT distinct objects and the sweep allocated PANE_COUNT * 1050.
expect(new Set(probeRows).size).toBe(1)
})
it('indexes the shared snapshot once for the whole pane fan-out', async () => {
// Prime the TTL cache and the index so the snapshot read is not in the count.
await queryWindowsPaneProcessInventory(shellPid(0), { anchorPid: agentPid(0) })
const insertions = await countMapInsertions(async () => {
await sweepPanes()
})
// Pre-fix every pane rebuilt a whole-table `childrenByPpid`, so this was
// >= PANE_COUNT * (rows with a distinct ppid). One shared index makes the
// whole sweep cost no table-sized Map build at all.
expect(insertions).toBeLessThan(TABLE_SIZE)
})
it('resolves the same foreground child for every pane as an unshared scan would', async () => {
const resolved = await sweepPanes()
expect(resolved).toEqual(Array.from({ length: PANE_COUNT }, (_, pane) => agentPid(pane)))
})
})
@@ -1,3 +1,7 @@
import {
collectDescendantsFromIndex,
getProcessTableIndex
} from '../../shared/process-table-snapshot'
import {
readWindowsProcessTable,
readWindowsProcessTableFresh,
@@ -25,15 +29,40 @@ function toProcessRow(row: NativeWindowsProcessRow): WindowsProcessRow {
}
}
/**
* One projection per snapshot identity, mirroring `getProcessTableIndex`.
*
* The TTL cache already gives every pane the same native rows array; without
* this each of them still rebuilt ~1050 row objects, which also handed
* `getProcessTableIndex` a new array each time and defeated its memo by
* construction. Keyed weakly, so a projection dies with its snapshot. Rows are
* shared, never mutated: descendants are copied with their depth, and
* `anchorRow` is read-only to every caller.
*/
const projectedRows = new WeakMap<readonly NativeWindowsProcessRow[], WindowsProcessRow[]>()
function projectProcessRows(native: readonly NativeWindowsProcessRow[]): WindowsProcessRow[] {
const cached = projectedRows.get(native)
if (cached) {
return cached
}
const rows = native.map(toProcessRow)
projectedRows.set(native, rows)
return rows
}
/**
* Rows from a scan that starts after this call.
*
* PID-identity checks in teardown must not reuse a cached row — it can predate
* the very recycle it is meant to detect. Rejects when the table is unreadable,
* so "unavailable" stays distinguishable from "nothing is running".
*
* `readonly` because the projection is shared with every other reader of the
* same snapshot.
*/
export async function queryWindowsProcessRowsFresh(): Promise<WindowsProcessRow[]> {
return (await readWindowsProcessTableFresh()).map(toProcessRow)
export async function queryWindowsProcessRowsFresh(): Promise<readonly WindowsProcessRow[]> {
return projectProcessRows(await readWindowsProcessTableFresh())
}
export async function queryWindowsProcessDescendants(
@@ -63,21 +92,22 @@ export async function queryWindowsPaneProcessInventory(
options.fresh === true
? await readWindowsProcessTableFresh()
: await readWindowsProcessTable()
rows = native.map(toProcessRow)
rows = projectProcessRows(native)
} catch {
return null
}
// One index per snapshot, shared by every pane inspecting inside the TTL
// window: `byPid` answers both lookups that used to be linear scans, and
// `childrenByPpid` replaces a per-call Map rebuild over the whole table.
const index = getProcessTableIndex(rows)
// Why: a snapshot that omitted the PTY root may be stale or permission-
// filtered; only an observed root can authoritatively have no descendants.
if (!rows.some((row) => row.pid === rootPid)) {
if (!index.byPid.has(rootPid)) {
return null
}
return {
candidates: collectDescendants(rows, rootPid).sort((a, b) => b.depth - a.depth),
anchorRow:
options.anchorPid !== undefined
? (rows.find((row) => row.pid === options.anchorPid) ?? null)
: null
candidates: collectDescendantsFromIndex(index, rootPid).sort((a, b) => b.depth - a.depth),
anchorRow: options.anchorPid !== undefined ? (index.byPid.get(options.anchorPid) ?? null) : null
}
}
@@ -85,26 +115,3 @@ export async function queryWindowsPaneProcessInventory(
export function resetWindowsProcessRowsSnapshotForTests(): void {
resetWindowsProcessTableForTests()
}
function collectDescendants<Row extends { pid: number; ppid: number }>(
rows: Row[],
rootPid: number
): (Row & { depth: number })[] {
const childrenByParent = new Map<number, Row[]>()
for (const row of rows) {
const children = childrenByParent.get(row.ppid) ?? []
children.push(row)
childrenByParent.set(row.ppid, children)
}
const descendants: (Row & { depth: number })[] = []
const stack = (childrenByParent.get(rootPid) ?? []).map((row) => ({ row, depth: 1 }))
while (stack.length > 0) {
const { row, depth } = stack.pop()!
descendants.push({ ...row, depth })
for (const child of childrenByParent.get(row.pid) ?? []) {
stack.push({ row: child, depth: depth + 1 })
}
}
return descendants
}
@@ -144,51 +144,6 @@ describe('fetchClaudeRateLimits', () => {
expect(fetchViaPty).not.toHaveBeenCalled()
})
it('maps a scoped Fable window whose display name carries a point release', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
envPatch: { CLAUDE_CONFIG_DIR: configDir },
stripAuthEnv: false,
provenance: 'managed:account-1'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce(
JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } })
)
netFetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
five_hour: { utilization: 36 },
seven_day: { utilization: 73 },
// Discriminating: a passing scope match must beat this fallback.
fable_weekly: { utilization: 12 },
limits: [
{
kind: 'weekly_scoped',
percent: 64,
resets_at: '2026-07-17T20:00:00.099908+00:00',
is_active: true,
scope: { model: { display_name: 'Fable 5.1' } }
}
]
}),
{ status: 200 }
)
)
await expect(
fetchClaudeRateLimits({ authPreparation, allowUsagePanelSupplement: true })
).resolves.toMatchObject({
provider: 'claude',
status: 'ok',
fableWeekly: {
usedPercent: 64,
resetsAt: Date.parse('2026-07-17T20:00:00.099908+00:00')
}
})
expect(fetchViaPty).not.toHaveBeenCalled()
})
it('surfaces inactive scoped Fable usage over the legacy OAuth fallback', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
@@ -32,17 +32,13 @@ async function ensureProxyFromEnvironment(): Promise<void> {
}).catch(() => {})
}
// Why: the scope name carries the shipped version once a point release exists
// ("Fable 5.1"), so exact equality would drop the window.
const FABLE_SCOPE_RE = /^fable\b/
function mapFableWeeklyWindow(data: OAuthUsageResponse): RateLimitWindow | null {
const scoped = Array.isArray(data.limits)
? data.limits.find(
(limit) =>
limit?.kind === 'weekly_scoped' &&
Number.isFinite(limit.percent) &&
FABLE_SCOPE_RE.test(limit.scope?.model?.display_name?.trim().toLowerCase() ?? '')
limit.scope?.model?.display_name?.trim().toLowerCase() === 'fable'
)
: undefined
return (
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import {
DECORATIVE_TITLE_FACT_HEARTBEAT_MS,
shouldEmitTitleFactForFrame
} from './decorative-title-fact-emission'
const base = {
decorativeOnly: true,
staleWorkingTitleClear: false,
lastEmittedAtMs: 1_000,
nowMs: 1_000
}
describe('shouldEmitTitleFactForFrame', () => {
it('always emits a frame that is not a decorative repeat', () => {
expect(shouldEmitTitleFactForFrame({ ...base, decorativeOnly: false })).toBe(true)
})
it('emits the first frame of a pane', () => {
expect(shouldEmitTitleFactForFrame({ ...base, lastEmittedAtMs: null })).toBe(true)
})
it('suppresses a decorative repeat inside the heartbeat window', () => {
expect(
shouldEmitTitleFactForFrame({ ...base, nowMs: 1_000 + DECORATIVE_TITLE_FACT_HEARTBEAT_MS - 1 })
).toBe(false)
})
it('lets a decorative repeat through once the heartbeat window elapses', () => {
expect(
shouldEmitTitleFactForFrame({ ...base, nowMs: 1_000 + DECORATIVE_TITLE_FACT_HEARTBEAT_MS })
).toBe(true)
})
it('never throttles a timer-synthesized stale-working clear', () => {
// Why: it carries a staleWorkingTitleClear flag no earlier repeat can stand in for.
expect(shouldEmitTitleFactForFrame({ ...base, staleWorkingTitleClear: true })).toBe(true)
})
it('emits after a backwards clock step instead of parking until it catches up', () => {
expect(shouldEmitTitleFactForFrame({ ...base, nowMs: 900 })).toBe(true)
})
it('keeps at least three frames inside the renderer hook-done quiet window', () => {
// Why: observeTitle's arriving working title is what cancels a Pi/OMP milestone `done`
// scheduled with HOOK_DONE_QUIET_MS = 1500. Losing that would mint a false completion.
expect(DECORATIVE_TITLE_FACT_HEARTBEAT_MS * 3).toBeLessThanOrEqual(1_500)
})
})
@@ -0,0 +1,38 @@
/**
* Why: an agent spinner re-emits a semantically identical OSC title ~12.5x/sec (Orca's own
* synthetic frame timer, Pi/OMP, Claude Code, Grok), and main ships every frame to the renderer
* as its own `pty:sideEffect` message. Both renderer store writes already discard those frames
* via `isDecorativeAgentTitleFrameChange`, so the message is pure cross-process cost.
*
* Why a heartbeat and not a hard drop: `agentCompletionCoordinator.observeTitle` treats an
* arriving *working* title as "still working" and cancels a scheduled hook-`done` completion
* inside `HOOK_DONE_QUIET_MS` (1500ms). That is exactly how a Pi/OMP milestone `done` emitted
* mid-turn is stopped from minting a completion notification, and the frames that carry it are
* decorative repeats. 500ms keeps 3 frames inside that window.
*/
export const DECORATIVE_TITLE_FACT_HEARTBEAT_MS = 500
export type DecorativeTitleFactEmissionInput = {
/** The frame's decorative gate key matches the previous frame's. */
decorativeOnly: boolean
/** Timer-synthesized stale-working clear — carries a flag no repeat can stand in for. */
staleWorkingTitleClear: boolean
lastEmittedAtMs: number | null
nowMs: number
}
export function shouldEmitTitleFactForFrame({
decorativeOnly,
staleWorkingTitleClear,
lastEmittedAtMs,
nowMs
}: DecorativeTitleFactEmissionInput): boolean {
if (!decorativeOnly || staleWorkingTitleClear) {
return true
}
if (lastEmittedAtMs === null) {
return true
}
// A backwards clock step must not park the heartbeat until it catches up.
return nowMs < lastEmittedAtMs || nowMs - lastEmittedAtMs >= DECORATIVE_TITLE_FACT_HEARTBEAT_MS
}
@@ -1,6 +1,7 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithEmitDaemonPtyTransientFact } from './orca-runtime-emit-daemon-pty-transient-fact'
import { getDecorativeAgentTitleSignature } from '../../shared/agent-decorative-title-signature'
import { shouldEmitTitleFactForFrame } from './decorative-title-fact-emission'
import type { RuntimePtyTitleTrackerEntry } from './runtime-terminal-state-records'
import { createTerminalTitleTracker } from '../../shared/terminal-output-side-effects'
import { detectAgentStatusFromTitle } from '../../shared/agent-detection'
@@ -64,20 +65,36 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime
const tracker = createTerminalTitleTracker(
{
onTitle: (normalizedTitle, rawTitle, meta) => {
this.recordTerminalSideEffectFact(ptyId, {
kind: 'title',
normalizedTitle,
rawTitle,
...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {})
})
const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle, meta)
const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta)
const live = this.ptyTitleTrackersByPtyId.get(ptyId)
const gateKey = this.makeDecorativeTitleGateKey(rawTitle, normalizedTitle)
const decorativeOnly = live?.lastMobileTitleGateKey === gateKey
if (live) {
live.lastMobileTitleGateKey = gateKey
}
// Why: the same gate the mobile fan-out below already uses, applied one hop earlier —
// a spinner frame the renderer store discards should not cost a pty:sideEffect message
// at all. See decorative-title-fact-emission.ts for why repeats still heartbeat.
const nowMs = Date.now()
if (
shouldEmitTitleFactForFrame({
decorativeOnly,
staleWorkingTitleClear: meta?.staleWorkingTitleClear === true,
lastEmittedAtMs: live?.lastTitleFactAtMs ?? null,
nowMs
})
) {
if (live) {
live.lastTitleFactAtMs = nowMs
}
this.recordTerminalSideEffectFact(ptyId, {
kind: 'title',
normalizedTitle,
rawTitle,
...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {})
})
}
const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle, meta)
const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta)
const tracksReplicatedStatus =
live?.applyingChunk === true && this.mobileSessionTabListeners.size > 0
const titleStatus = tracksReplicatedStatus ? detectAgentStatusFromTitle(rawTitle) : null
@@ -151,6 +168,7 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime
tracker,
applyingChunk: false,
lastMobileTitleGateKey: null,
lastTitleFactAtMs: null,
chunkTouchedSessionTabs: false,
pendingFacts: [],
// Why: command-code facts exist only for the pty:sideEffect channel —
@@ -0,0 +1,110 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalSideEffectBatch } from '../../../shared/terminal-side-effect-facts'
import { syncSinglePty } from '../orca-runtime-test-fixtures.spec'
import { createSideEffectRuntime } from '../orca-runtime-test-scenario-builders.spec'
import { DECORATIVE_TITLE_FACT_HEARTBEAT_MS } from '../decorative-title-fact-emission'
// Orca's own synthetic agent spinner: one frame per pane every 80ms while an agent works.
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
const SPINNER_INTERVAL_MS = 80
const EPOCH = 1_700_000_000_000
type TitleFact = { kind: 'title'; normalizedTitle: string; rawTitle: string }
function titleFacts(batches: TerminalSideEffectBatch[]): TitleFact[] {
return batches.flatMap((batch) =>
batch.facts.filter((fact): fact is TitleFact => fact.kind === 'title')
)
}
describe('decorative title fact throttle', () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date(EPOCH))
})
afterEach(() => {
vi.useRealTimers()
})
it('collapses spinner ticks with an unchanged underlying title to the heartbeat rate', () => {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
const ticks = 125 // 10s of Orca's 80ms synthetic spinner timer
for (let tick = 0; tick < ticks; tick += 1) {
vi.setSystemTime(new Date(EPOCH + tick * SPINNER_INTERVAL_MS))
runtime.ingestSyntheticTitleFrame(
'pty-1',
`\x1b]0;${SPINNER_FRAMES[tick % SPINNER_FRAMES.length]} Claude Code\x07`
)
}
const facts = titleFacts(batches)
// Every frame carried the same underlying title, so the renderer learns nothing new past
// the heartbeat: 125 pty:sideEffect messages collapse to one per heartbeat window.
const elapsedMs = ticks * SPINNER_INTERVAL_MS
expect(facts.length).toBeLessThanOrEqual(
Math.ceil(elapsedMs / DECORATIVE_TITLE_FACT_HEARTBEAT_MS)
)
expect(facts.length).toBeLessThan(ticks / 5)
// The heartbeat must not thin out below what the renderer's 1500ms hook-done quiet window
// needs to cancel a milestone `done` — three working frames per window.
expect(facts.length).toBeGreaterThanOrEqual(Math.floor(elapsedMs / 1_500) * 3)
for (const fact of facts) {
expect(fact.normalizedTitle.endsWith('Claude Code')).toBe(true)
}
})
it('propagates a real title change on the tick it arrives, mid-heartbeat', () => {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude Code\x07')
// Two more decorative ticks — still well inside the heartbeat window, so they are dropped.
vi.setSystemTime(new Date(EPOCH + SPINNER_INTERVAL_MS))
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠙ Claude Code\x07')
vi.setSystemTime(new Date(EPOCH + 2 * SPINNER_INTERVAL_MS))
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠹ Claude Code\x07')
expect(titleFacts(batches)).toHaveLength(1)
const beforeChange = batches.length
vi.setSystemTime(new Date(EPOCH + 3 * SPINNER_INTERVAL_MS))
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;✳ Claude Code\x07')
expect(batches.length).toBeGreaterThan(beforeChange)
expect(titleFacts(batches.slice(beforeChange))).toEqual([
{ kind: 'title', normalizedTitle: '✳ Claude Code', rawTitle: '✳ Claude Code' }
])
})
it('propagates a changed working label immediately even while the spinner rotates', () => {
// Why: only the spinner glyph is decoration. Grok/Pi-style label churn is real content.
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude Code\x07')
vi.setSystemTime(new Date(EPOCH + SPINNER_INTERVAL_MS))
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠙ Reviewing diff — Claude Code\x07')
expect(titleFacts(batches).map((fact) => fact.rawTitle)).toEqual([
'⠋ Claude Code',
'⠙ Reviewing diff — Claude Code'
])
})
it('keeps main-side tracked title state current for every suppressed frame', () => {
// Why: mobile/remote snapshots read the tracked record, not the fact stream — suppressing
// the fact must not freeze what a phone or a paired client is shown.
const { runtime } = createSideEffectRuntime()
syncSinglePty(runtime)
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude Code\x07')
vi.setSystemTime(new Date(EPOCH + SPINNER_INTERVAL_MS))
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠙ Claude Code\x07')
expect(runtime.getTerminalSideEffectSnapshot('pty-1')?.facts).toEqual([
{ kind: 'title', normalizedTitle: '⠙ Claude Code', rawTitle: '⠙ Claude Code' }
])
})
})
@@ -8,6 +8,7 @@ import {
syncSinglePty
} from '../orca-runtime-test-fixtures.spec'
import { createSideEffectRuntime } from '../orca-runtime-test-scenario-builders.spec'
import { DECORATIVE_TITLE_FACT_HEARTBEAT_MS } from '../decorative-title-fact-emission'
describe('terminal side-effect fact channel', () => {
it('defers desktop-only output scanners until a headless runtime is promoted', () => {
@@ -70,53 +71,66 @@ describe('terminal side-effect fact channel', () => {
expect(events).toHaveLength(1)
})
it('bounds decorative title delivery per paired client without reducing local frames', () => {
const { runtime, batches } = createSideEffectRuntime()
const firstClientEvents: RuntimeClientEvent[] = []
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
runtime.onClientEvent((event) => firstClientEvents.push(event))
it('bounds decorative title delivery per paired client below the local heartbeat', () => {
// Why the clock steps: main throttles decorative repeats on the local fact stream, so each
// round must clear that heartbeat for the per-client gate to be what collapses them here.
vi.useFakeTimers({ toFake: ['Date'] })
try {
const { runtime, batches } = createSideEffectRuntime()
const firstClientEvents: RuntimeClientEvent[] = []
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
runtime.onClientEvent((event) => firstClientEvents.push(event))
const ptyIds = Array.from({ length: 64 }, (_, index) => `pty-remote-${index}`)
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
for (const ptyId of ptyIds) {
runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`)
}
firstClientEvents.length = 0
for (const frame of frames.slice(1)) {
for (const ptyId of ptyIds) {
runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frame} Cursor Agent\x07`)
const ptyIds = Array.from({ length: 64 }, (_, index) => `pty-remote-${index}`)
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
const stepPastHeartbeat = (): void => {
vi.setSystemTime(new Date(Date.now() + DECORATIVE_TITLE_FACT_HEARTBEAT_MS))
}
for (const ptyId of ptyIds) {
runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`)
}
firstClientEvents.length = 0
for (const frame of frames.slice(1)) {
stepPastHeartbeat()
for (const ptyId of ptyIds) {
runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frame} Cursor Agent\x07`)
}
}
expect(firstClientEvents).toEqual([])
expect(batches).toHaveLength(ptyIds.length * frames.length)
const bellChunk = `\x1b]0;${frames.at(-1)} Cursor Agent\x07\x07`
runtime.onPtyData(ptyIds[0], bellChunk, 1)
expect(firstClientEvents).toEqual([
expect.objectContaining({
type: 'terminalSideEffects',
batch: expect.objectContaining({ facts: [{ kind: 'bell' }] })
})
])
firstClientEvents.length = 0
const secondClientEvents: RuntimeClientEvent[] = []
runtime.onClientEvent((event) => secondClientEvents.push(event))
stepPastHeartbeat()
for (const ptyId of ptyIds) {
runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`)
}
expect(firstClientEvents).toEqual([])
expect(secondClientEvents).toHaveLength(ptyIds.length)
// A real title change is never throttled — no clock step needed.
for (const ptyId of ptyIds) {
runtime.ingestSyntheticTitleFrame(ptyId, '\x1b]0;Cursor ready\x07')
}
expect(firstClientEvents).toHaveLength(ptyIds.length)
expect(secondClientEvents).toHaveLength(ptyIds.length * 2)
} finally {
vi.useRealTimers()
}
expect(firstClientEvents).toEqual([])
expect(batches).toHaveLength(ptyIds.length * frames.length)
const bellChunk = `\x1b]0;${frames.at(-1)} Cursor Agent\x07\x07`
runtime.onPtyData(ptyIds[0], bellChunk, 1)
expect(firstClientEvents).toEqual([
expect.objectContaining({
type: 'terminalSideEffects',
batch: expect.objectContaining({ facts: [{ kind: 'bell' }] })
})
])
firstClientEvents.length = 0
const secondClientEvents: RuntimeClientEvent[] = []
runtime.onClientEvent((event) => secondClientEvents.push(event))
for (const ptyId of ptyIds) {
runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`)
}
expect(firstClientEvents).toEqual([])
expect(secondClientEvents).toHaveLength(ptyIds.length)
for (const ptyId of ptyIds) {
runtime.ingestSyntheticTitleFrame(ptyId, '\x1b]0;Cursor ready\x07')
}
expect(firstClientEvents).toHaveLength(ptyIds.length)
expect(secondClientEvents).toHaveLength(ptyIds.length * 2)
})
it('omits terminalSideEffects from non-consuming listeners while other events still flow', () => {
+1
View File
@@ -30,6 +30,7 @@ await import('./orca-runtime-tests/pty-title-status.spec')
await import('./orca-runtime-tests/terminal-side-effect-facts.spec')
await import('./orca-runtime-tests/terminal-side-effect-facts-part-02.spec')
await import('./orca-runtime-tests/terminal-side-effect-facts-part-03.spec')
await import('./orca-runtime-tests/decorative-title-fact-throttle.spec')
await import('./orca-runtime-tests/headless-snapshots.spec')
await import('./orca-runtime-tests/headless-snapshots-part-02.spec')
await import('./orca-runtime-tests/agent-status-and-waits.spec')
@@ -44,7 +44,13 @@ describe('client UI RPC pairing-local field seams', () => {
manualRepoOrder: [
{ hostId: 'runtime:web-11111111-2222-3333-4444-555555555555', repoId: 'repo-a' }
],
workspaceHostOrder: ['runtime:web-11111111-2222-3333-4444-555555555555', 'local']
workspaceHostOrder: ['runtime:web-11111111-2222-3333-4444-555555555555', 'local'],
agentsVisibleHostIds: ['runtime:web-11111111-2222-3333-4444-555555555555'],
agentsFilterRepoIds: ['repo-a'],
agentsShowChildAgents: true,
agentsCompactMode: false,
activityClearedAtByPaneKey: { 'tab-1:leaf-1': 123 },
manuallyUnreadTurnsByPaneKey: { 'tab-1:leaf-1': 321 }
}
it.each(PAIRING_LOCAL_UI_FIELDS.map((field) => [field] as const))(
@@ -122,6 +122,10 @@ const UiUpdateFields = z
showInactiveWorkspaces: z.boolean().optional(),
workspaceHostScope: z.string().optional(),
visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(),
agentsVisibleHostIds: z.array(z.string()).nullable().optional(),
agentsFilterRepoIds: StringArray.optional(),
agentsShowChildAgents: z.boolean().optional(),
agentsCompactMode: z.boolean().optional(),
workspaceHostOrder: z.array(z.string()).optional(),
automationHostFilter: z
.union([
@@ -171,6 +175,8 @@ const UiUpdateFields = z
updateReassuranceSeen: z.boolean().optional(),
osc52ClipboardDefaultOnNoticePending: z.boolean().optional(),
acknowledgedAgentsByPaneKey: z.record(z.string(), z.number().finite()).optional(),
activityClearedAtByPaneKey: z.record(z.string(), z.number().finite()).optional(),
manuallyUnreadTurnsByPaneKey: z.record(z.string(), z.number().finite()).optional(),
browserDefaultUrl: NullableString.optional(),
browserDefaultSearchEngine: z
.enum(['google', 'duckduckgo', 'bing', 'kagi'])
@@ -0,0 +1,53 @@
/**
* #12547: `files.listAll` did not declare `maxResults`, so "the client names its cap and a full page
* means there is more" was wired only on the Electron IPC hop. Web and mobile were saved incidentally,
* by `remoteFileContentBudget` defaulting the cap inside `listRuntimeFiles`.
*/
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { FILE_METHODS } from './files'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
describe('files.listAll page size', () => {
// Why #12547: `maxResults` was wired only on the Electron IPC hop, so "a full page means there is
// more" was true for a desktop client and incidental for web/mobile. Declaring it here is a new
// optional field (wire rule 1): an older host strips it and keeps its own default.
it('forwards a client-named page size for a selected worktree', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
listRuntimeFiles: vi.fn().mockResolvedValue(['src/index.ts'])
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('files.listAll', { worktree: 'id:wt-1', maxResults: 20_001 })
)
expect(runtime.listRuntimeFiles).toHaveBeenCalledWith('id:wt-1', {
excludePaths: undefined,
maxResults: 20_001
})
expect(response).toMatchObject({ ok: true, result: ['src/index.ts'] })
})
// Why refuse rather than fall back: no released client sends this field, so a malformed value is a
// bug in the caller, not skew — the same call `files.search` already makes for its own maxResults.
it('refuses a malformed page size instead of silently picking one', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
listRuntimeFiles: vi.fn().mockResolvedValue(['src/index.ts'])
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('files.listAll', { worktree: 'id:wt-1', maxResults: -3 })
)
expect(response).toMatchObject({ ok: false })
})
})
+7 -1
View File
@@ -93,8 +93,13 @@ const FileSearch = WorktreeSelector.extend({
maxResults: z.number().int().positive().optional()
})
// Why: `maxResults` is a new optional field (wire rule 1) — an older host strips it and keeps its
// own default. It existed only on the Electron IPC hop, so "the client names its cap and a full page
// means there is more" was true for desktop and merely incidental for web and mobile, which were
// saved by `remoteFileContentBudget` defaulting the cap inside `listRuntimeFiles`.
const FileListAll = WorktreeSelector.extend({
excludePaths: z.array(z.string()).optional()
excludePaths: z.array(z.string()).optional(),
maxResults: z.number().int().positive().optional()
})
const FileUnwatch = z.object({
@@ -236,6 +241,7 @@ export const FILE_METHODS: RpcAnyMethod[] = [
const maxContentBytes = remoteFileContentBudget(clientKind, requestId)
return runtime.listRuntimeFiles(params.worktree, {
excludePaths: params.excludePaths,
...(params.maxResults === undefined ? {} : { maxResults: params.maxResults }),
...(signal === undefined ? {} : { signal }),
...(maxContentBytes === undefined ? {} : { maxContentBytes })
})
@@ -89,6 +89,8 @@ export type RuntimePtyTitleTrackerEntry = {
tracker: TerminalTitleTracker
applyingChunk: boolean
lastMobileTitleGateKey: string | null
/** When the last title fact was emitted — throttles decorative-only repeats. */
lastTitleFactAtMs: number | null
chunkTouchedSessionTabs: boolean
pendingFacts: TerminalSideEffectFact[]
commandCodeDetector: { observe: (data: string) => boolean } | null
@@ -183,6 +183,48 @@ describe('startup ordering', () => {
)
})
it('keeps the git-environment barrier off the PTY startup services', () => {
const barrierSource = readFileSync(
join(process.cwd(), 'src/main/startup/main-process-ipc-bootstrap.ts'),
'utf8'
)
const launchSource = readFileSync(
join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'),
'utf8'
)
const gitBarrierStart = barrierSource.indexOf(
"ipcMain.handle('app:awaitGitEnvironmentStartupBarrier'"
)
const gitBarrierEnd = barrierSource.indexOf(
"'app:prepareTerminalStartupRestoration'",
gitBarrierStart
)
expect(gitBarrierStart).toBeGreaterThanOrEqual(0)
expect(gitBarrierEnd).toBeGreaterThan(gitBarrierStart)
const gitBarrier = barrierSource.slice(gitBarrierStart, gitBarrierEnd)
// The git environment fence is shell PATH + WSL registration; a daemon PTY provider or a
// hook-server bind here puts terminal startup back in front of worktree hydration.
expect(gitBarrier).toContain('state.shellPathReady')
expect(gitBarrier).toContain('state.managedWslCliStartupBarrierReady')
expect(gitBarrier).not.toContain('firstWindowStartupServicesReady')
// The published promise must be the same one the terminal startup services wait on.
expect(launchSource).toContain('state.shellPathReady = shellPathReady')
expect(launchSource.indexOf('state.shellPathReady = shellPathReady')).toBeLessThan(
launchSource.indexOf('await launchDesktopMode(')
)
// Terminal restoration itself must still fence on the first-window services.
const restorationStart = barrierSource.indexOf(
"ipcMain.handle('app:prepareTerminalStartupRestoration'"
)
const restorationEnd = barrierSource.indexOf(
"'app:recoverLegacyWorkerTerminalsForRendererStartup'",
restorationStart
)
expect(barrierSource.slice(restorationStart, restorationEnd)).toContain(
'state.firstWindowStartupServicesReady'
)
})
it('reconciles retained Codex homes after authoritative daemon inventory', () => {
const source = readFileSync(
join(process.cwd(), 'src/main/startup/main-process-pty-startup.ts'),
@@ -11,6 +11,13 @@ export function registerMainProcessIpcHandlers(): void {
state.managedWslCliStartupBarrierReady
])
})
// Why separate from the first-window barrier: host Git needs the shell-PATH
// generation and the managed WSL CLI registration, not a daemon PTY provider
// or a hook-server bind. Bundling them made worktree hydration wait on a
// terminal service it never calls.
ipcMain.handle('app:awaitGitEnvironmentStartupBarrier', async () => {
await Promise.all([state.shellPathReady, state.managedWslCliStartupBarrierReady])
})
ipcMain.handle('app:prepareTerminalStartupRestoration', async () => {
await Promise.all([
state.firstWindowStartupServicesReady,
+4
View File
@@ -24,6 +24,7 @@ import { shutdownObservability } from '../observability'
import { isQuittingForUpdate } from '../updater'
import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics'
import { stopTccPromptNotice } from '../macos-tcc-prompt-notice'
import { cancelHistoryGc } from '../terminal-history-gc'
import { shouldQuitWhenAllWindowsClosed } from './window-all-closed-quit-policy'
import { mainProcessState as state } from './main-process-state'
import { isDevParentShutdownRequested } from './configure-process'
@@ -82,6 +83,9 @@ function installBeforeQuitHandler(): void {
state.repoMaintenanceShutdown = awaitPackedRefsLockRelease()
// Why: defer PTY cleanup to will-quit so the renderer captures scrollback before PTY-exit events unmount TerminalPane (dropping its capture callbacks).
state.rateLimits?.stop()
// Why safe on a vetoed quit: background history GC is idempotent and re-scheduled next launch,
// so abandoning the walk here only costs one deferred sweep, never a half-applied prune.
cancelHistoryGc()
})
}
@@ -289,6 +289,9 @@ export async function initializeMainProcessRuntimeLaunch(
state.serveOptions = serveOptions
const runtimeRpc = installRuntimeRpc(runtime, serveOptions)
const shellPathReady = shellPathHydration.whenReady()
// Why published: the renderer's git-environment barrier must fence on the same
// generation the terminal startup services wait for, not a later re-read.
state.shellPathReady = shellPathReady
let desktopWindow: BrowserWindow | null = null
if (process.platform === 'win32' && app.isPackaged && !serveOptions) {
const desktopStartup = startWindowsDesktopBeforeShellPathReady({
+444
View File
@@ -0,0 +1,444 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
const { removeHostTreeMock } = vi.hoisted(() => ({
removeHostTreeMock: vi.fn<(dir: string) => Promise<void>>()
}))
// Why intercept rather than no-op: the tombstone path each prune produces is the decision this
// suite reads, but the drain re-queues any tombstone still on disk after a "successful" removal,
// so the stub has to really delete or the queue never terminates.
vi.mock('./host-tree-removal', () => ({
removeHostTree: removeHostTreeMock
}))
import { readHistoryMeta } from './terminal-history'
import {
cancelPendingHistoryTreeRemovalRetries,
flushPendingWorktreeHistoryDeletions
} from './terminal-history-deletion'
import { cancelHistoryGc, runHistoryGc, scheduleHistoryGc } from './terminal-history-gc'
const GC_MIN_AGE_MS = 5 * 60 * 1000
const PENDING_DELETE_DIR_NAME = '.pending-delete'
const LIVE_WORKTREE_ID = 'repo-1::/path/live-wt'
const DEAD_WORKTREE_ID = 'repo-1::/path/dead-wt'
let userDataDir: string
let historyRoot: string
let originalXdgDataHome: string | undefined
/**
* The enumeration this exercises used to be a synchronous walk. Its replacement is an async
* fixed-worker pass, so the whole safety net is that both reach the same prune decision over a
* realistic tree: over-pruning here destroys scrollback the user still expects to have.
*
* A verbatim port of the pre-change decision logic, reporting names instead of deleting.
*/
function referenceSyncPruneDecisions(root: string, liveWorktreeIds: Set<string>): string[] {
const decisions: string[] = []
if (!existsSync(root)) {
return decisions
}
const now = Date.now()
for (const entry of readdirSync(root)) {
if (entry === PENDING_DELETE_DIR_NAME) {
continue
}
const entryPath = join(root, entry)
try {
const stats = statSync(entryPath)
if (!stats.isDirectory()) {
continue
}
try {
for (const file of readdirSync(entryPath)) {
statSync(join(entryPath, file))
}
} catch {
// Skip size estimation on error.
}
if (!existsSync(join(entryPath, 'meta.json'))) {
continue
}
const meta = readHistoryMeta(entryPath)
if (!meta?.worktreeId) {
continue
}
if (!liveWorktreeIds.has(meta.worktreeId)) {
if (meta.createdAt && now - new Date(meta.createdAt).getTime() < GC_MIN_AGE_MS) {
continue
}
decisions.push(entry)
}
} catch {
// Skip individual entries that fail.
}
}
return decisions
}
function seedDir(name: string, files: Record<string, string>): string {
const dir = join(historyRoot, name)
mkdirSync(dir, { recursive: true })
for (const [file, contents] of Object.entries(files)) {
writeFileSync(join(dir, file), contents)
}
return dir
}
function meta(worktreeId: string | undefined, ageMs: number | null): string {
return JSON.stringify({
...(worktreeId === undefined ? {} : { worktreeId }),
...(ageMs === null ? {} : { createdAt: new Date(Date.now() - ageMs).toISOString() })
})
}
const OLD = GC_MIN_AGE_MS * 2
/** Every decision shape the walk has to get right, including the ones that must never prune. */
function seedDecisionMatrix(): void {
seedDir('live-old', { 'meta.json': meta(LIVE_WORKTREE_ID, OLD), zsh_history: 'a' })
seedDir('live-young', { 'meta.json': meta(LIVE_WORKTREE_ID, 0) })
seedDir('orphan-old', { 'meta.json': meta(DEAD_WORKTREE_ID, OLD), zsh_history: 'b' })
seedDir('orphan-no-createdat', { 'meta.json': meta(DEAD_WORKTREE_ID, null) })
seedDir('orphan-unparseable-createdat', {
'meta.json': JSON.stringify({ worktreeId: DEAD_WORKTREE_ID, createdAt: 'not-a-date' })
})
seedDir('orphan-young', { 'meta.json': meta(DEAD_WORKTREE_ID, 1_000) })
seedDir('no-meta', { zsh_history: 'c' })
seedDir('malformed-meta', { 'meta.json': '{ this is not json' })
seedDir('truncated-meta', { 'meta.json': `{"worktreeId":"${DEAD_WORKTREE_ID}` })
seedDir('empty-meta', { 'meta.json': '{}' })
seedDir('array-meta', { 'meta.json': `["${DEAD_WORKTREE_ID}"]` })
seedDir('null-meta', { 'meta.json': 'null' })
seedDir('no-worktree-id', { 'meta.json': meta(undefined, OLD) })
seedDir('oversize-meta', {
'meta.json': JSON.stringify({
worktreeId: DEAD_WORKTREE_ID,
createdAt: new Date(Date.now() - OLD).toISOString(),
pad: 'x'.repeat(64 * 1024)
})
})
// meta.json as a directory: stat succeeds, the read does not.
mkdirSync(join(historyRoot, 'meta-is-a-dir', 'meta.json'), { recursive: true })
seedDir('empty-dir', {})
// A plain file at the root is not a history directory.
writeFileSync(join(historyRoot, 'stray-file'), 'x')
mkdirSync(join(historyRoot, PENDING_DELETE_DIR_NAME), { recursive: true })
}
/** Enough entries to run several worker batches and cross the cooperative-yield boundary. */
function seedBulk(count: number, orphanEvery: number): void {
for (let i = 0; i < count; i++) {
const orphan = i % orphanEvery === 0
seedDir(`bulk-${i}`, {
'meta.json': meta(orphan ? `${DEAD_WORKTREE_ID}-${i}` : LIVE_WORKTREE_ID, OLD),
zsh_history: `entry-${i}`,
bash_history: `entry-${i}`
})
}
}
function survivingDirs(): Set<string> {
return new Set(
readdirSync(historyRoot).filter(
(entry) =>
entry !== PENDING_DELETE_DIR_NAME && statSync(join(historyRoot, entry)).isDirectory()
)
)
}
beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'orca-history-gc-'))
historyRoot = join(userDataDir, 'terminal-history')
mkdirSync(historyRoot, { recursive: true })
installFakeAppEnvironment({ getPath: () => userDataDir })
// Why: the fish sweep resolves a real user data dir otherwise, and would delete the
// developer's own orca fish history files while this suite runs.
originalXdgDataHome = process.env.XDG_DATA_HOME
process.env.XDG_DATA_HOME = userDataDir
removeHostTreeMock.mockReset()
removeHostTreeMock.mockImplementation(async (dir) => {
rmSync(dir, { recursive: true, force: true })
})
})
/** Tombstone paths the pass condemned, with the `.<timestamp>.<rand>` rename suffix stripped. */
function tombstonedNames(): Set<string> {
return new Set(
removeHostTreeMock.mock.calls.map(([dir]) => basename(dir).split('.').slice(0, -2).join('.'))
)
}
afterEach(async () => {
cancelHistoryGc()
vi.useRealTimers()
await flushPendingWorktreeHistoryDeletions()
cancelPendingHistoryTreeRemovalRetries()
if (originalXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME
} else {
process.env.XDG_DATA_HOME = originalXdgDataHome
}
rmSync(userDataDir, { recursive: true, force: true })
})
describe('history GC prune decisions', () => {
it('prunes exactly the set the synchronous walk chose', async () => {
seedDecisionMatrix()
seedBulk(200, 7)
const live = new Set([LIVE_WORKTREE_ID])
const before = survivingDirs()
const expected = new Set(referenceSyncPruneDecisions(historyRoot, live))
await runHistoryGc(live)
const after = survivingDirs()
const actual = new Set([...before].filter((entry) => !after.has(entry)))
expect(expected.size).toBeGreaterThan(0)
expect([...actual].sort()).toEqual([...expected].sort())
})
it('keeps every directory whose ownership cannot be established', async () => {
seedDecisionMatrix()
await runHistoryGc(new Set([LIVE_WORKTREE_ID]))
const after = survivingDirs()
for (const kept of [
'live-old',
'live-young',
'orphan-young',
'no-meta',
'malformed-meta',
'truncated-meta',
'empty-meta',
'array-meta',
'null-meta',
'no-worktree-id',
'oversize-meta',
'meta-is-a-dir',
'empty-dir'
]) {
expect(after.has(kept)).toBe(true)
}
expect(after.has('orphan-old')).toBe(false)
expect(after.has('orphan-no-createdat')).toBe(false)
expect(after.has('orphan-unparseable-createdat')).toBe(false)
expect(existsSync(join(historyRoot, 'stray-file'))).toBe(true)
})
it('refuses to prune anything when the live set is empty', async () => {
seedDecisionMatrix()
await runHistoryGc(new Set())
expect(survivingDirs().has('orphan-old')).toBe(true)
expect(readdirSync(join(historyRoot, PENDING_DELETE_DIR_NAME))).toEqual([])
})
it('does not throw when the history root does not exist', async () => {
rmSync(historyRoot, { recursive: true, force: true })
await expect(runHistoryGc(new Set([LIVE_WORKTREE_ID]))).resolves.toBeUndefined()
})
it('tombstones orphans instead of removing them on the calling thread', async () => {
seedDecisionMatrix()
await runHistoryGc(new Set([LIVE_WORKTREE_ID]))
// The recursive rm only ever sees a path already renamed into the tombstone queue.
for (const [dir] of removeHostTreeMock.mock.calls) {
expect(dir).toContain(PENDING_DELETE_DIR_NAME)
}
expect(tombstonedNames().has('orphan-old')).toBe(true)
})
it('drains pre-existing tombstones without scanning them as worktrees', async () => {
seedDecisionMatrix()
const leftover = join(historyRoot, PENDING_DELETE_DIR_NAME, 'abc123.1700000000000.deadbeef')
mkdirSync(leftover, { recursive: true })
await runHistoryGc(new Set([LIVE_WORKTREE_ID]))
expect(removeHostTreeMock).toHaveBeenCalledWith(expect.stringContaining('abc123.1700000000000'))
})
it('continues the pass after one orphan tombstone fails', async () => {
seedDir('orphan-a', { 'meta.json': meta(`${DEAD_WORKTREE_ID}-a`, OLD) })
seedDir('orphan-b', { 'meta.json': meta(`${DEAD_WORKTREE_ID}-b`, OLD) })
// A file where the tombstone root must be makes the first rename fail; mkdir cannot replace it.
writeFileSync(join(historyRoot, PENDING_DELETE_DIR_NAME), 'not a directory')
await expect(runHistoryGc(new Set([LIVE_WORKTREE_ID]))).resolves.toBeUndefined()
// Nothing could be tombstoned, and both entries survive for a later pass to reclaim.
expect(survivingDirs()).toEqual(new Set(['orphan-a', 'orphan-b']))
})
})
describe('history GC concurrency behaviour', () => {
it('joins a second call to the in-flight pass instead of walking twice', async () => {
seedDecisionMatrix()
const live = new Set([LIVE_WORKTREE_ID])
const first = runHistoryGc(live)
const second = runHistoryGc(live)
expect(second).toBe(first)
await first
// Each rename produces its own tombstone, so a second overlapping walk would condemn twice.
const orphanRemovals = removeHostTreeMock.mock.calls.filter(([dir]) =>
basename(dir).startsWith('orphan-old.')
)
expect(orphanRemovals).toHaveLength(1)
})
it('starts a fresh pass once the previous one has settled', async () => {
seedDecisionMatrix()
const live = new Set([LIVE_WORKTREE_ID])
await runHistoryGc(live)
const second = runHistoryGc(live)
await expect(second).resolves.toBeUndefined()
})
it('stops an in-flight walk on cancel without pruning', async () => {
seedDecisionMatrix()
seedBulk(300, 3)
const before = survivingDirs()
const pass = runHistoryGc(new Set([LIVE_WORKTREE_ID]))
// Cancelling before the root listing resolves means no entry is ever visited.
cancelHistoryGc()
await pass
expect(survivingDirs()).toEqual(before)
})
it('does not run a scheduled pass that was cancelled while resolving live worktrees', async () => {
seedDecisionMatrix()
vi.useFakeTimers()
let resolveLiveIds: (ids: Set<string>) => void = () => {}
scheduleHistoryGc(
() =>
new Promise<Set<string>>((resolve) => {
resolveLiveIds = resolve
})
)
await vi.advanceTimersByTimeAsync(10_000)
cancelHistoryGc()
resolveLiveIds(new Set([LIVE_WORKTREE_ID]))
await vi.advanceTimersByTimeAsync(0)
expect(survivingDirs().has('orphan-old')).toBe(true)
})
it('coalesces duplicate scheduled startup GC calls', async () => {
vi.useFakeTimers()
const getLiveWorktreeIds = vi.fn().mockResolvedValue(new Set<string>())
scheduleHistoryGc(getLiveWorktreeIds)
scheduleHistoryGc(getLiveWorktreeIds)
await vi.advanceTimersByTimeAsync(10_000)
expect(getLiveWorktreeIds).toHaveBeenCalledTimes(1)
})
})
describe('history GC races an async walk introduces', () => {
it('survives a directory removed while the walk is in flight', async () => {
seedDecisionMatrix()
seedBulk(300, 5)
const live = new Set([LIVE_WORKTREE_ID])
const vanishing = ['bulk-11', 'bulk-77', 'bulk-201']
const pass = runHistoryGc(live)
for (const name of vanishing) {
rmSync(join(historyRoot, name), { recursive: true, force: true })
}
await expect(pass).resolves.toBeUndefined()
// Every live directory the racer did not touch is still there.
expect(survivingDirs().has('live-old')).toBe(true)
expect(survivingDirs().has('bulk-1')).toBe(true)
for (const name of vanishing) {
expect(existsSync(join(historyRoot, name))).toBe(false)
}
})
it('never prunes a directory whose meta.json is half-written when the walk reads it', async () => {
seedDir('being-written', {})
seedBulk(200, 5)
const live = new Set([LIVE_WORKTREE_ID])
const pass = runHistoryGc(live)
writeFileSync(
join(historyRoot, 'being-written', 'meta.json'),
`{"worktreeId":"${DEAD_WORKTREE_ID}","created`
)
await pass
expect(survivingDirs().has('being-written')).toBe(true)
})
it('prunes a directory whose meta.json arrived after the directory did', async () => {
seedDir('late-meta', {})
writeFileSync(
join(historyRoot, 'late-meta', 'meta.json'),
meta(`${DEAD_WORKTREE_ID}-late`, OLD)
)
await runHistoryGc(new Set([LIVE_WORKTREE_ID]))
expect(survivingDirs().has('late-meta')).toBe(false)
})
})
describe('history GC main-thread occupancy', () => {
it('yields to timers throughout the walk instead of blocking on it', async () => {
seedBulk(1_200, 40)
const live = new Set([LIVE_WORKTREE_ID])
const ticks = { sync: 0, async: 0 }
let maxAsyncGapMs = 0
// The pre-change walk is the control: a synchronous pass over the same tree cannot tick at all.
const syncTimer = setInterval(() => {
ticks.sync += 1
}, 4)
referenceSyncPruneDecisions(historyRoot, live)
clearInterval(syncTimer)
let last = performance.now()
const asyncTimer = setInterval(() => {
const now = performance.now()
maxAsyncGapMs = Math.max(maxAsyncGapMs, now - last - 4)
last = now
ticks.async += 1
}, 4)
await runHistoryGc(live)
clearInterval(asyncTimer)
// A synchronous pass cannot tick at all, however long it takes.
expect(ticks.sync).toBe(0)
expect(ticks.async).toBeGreaterThan(5)
// Generous because shared CI runners stall an idle timer by tens of ms on their own; the
// failure this guards against is a whole-walk block, which is seconds.
expect(maxAsyncGapMs).toBeLessThan(2_000)
})
})
+160 -80
View File
@@ -1,5 +1,5 @@
import { join } from 'node:path'
import { existsSync, readdirSync, statSync } from 'node:fs'
import { readdir, stat } from 'node:fs/promises'
import {
getHistoryRoot,
listWslHistoryRoots,
@@ -9,9 +9,11 @@ import {
schedulePendingHistoryTreeRemovals,
scheduleWorktreeHistoryTreeDeletion
} from './terminal-history-deletion'
import { readHistoryMeta } from './terminal-history'
import { readHistoryMetaAsync } from './terminal-history'
import { resolveFishHistoryDir, sweepOrphanedFishHistoryFiles } from './fish-history-session'
import { hashWorktreeId } from './terminal-history-id'
import { forEachWithConcurrency } from '../shared/map-with-concurrency'
import { yieldToEventLoop } from '../shared/event-loop-yield'
// Why 5 minutes: GC runs ~10s after startup, and the live-worktree snapshot is
// taken just before. A worktree created between the snapshot and GC execution
@@ -20,100 +22,136 @@ import { hashWorktreeId } from './terminal-history-id'
// to cover any realistic snapshot-to-scan delay.
const GC_MIN_AGE_MS = 5 * 60 * 1000
let scheduledHistoryGcTimer: ReturnType<typeof setTimeout> | null = null
let historyGcRunning = false
// Why a fixed worker pool over a frontier and not per-entry promise fan-out: a real
// history root holds thousands of directories, and starting every one at once queues
// tens of thousands of libuv requests before the first completes. 16 is deep enough to
// keep the default 4-thread pool saturated without monopolising the disk during startup.
const HISTORY_GC_SCAN_CONCURRENCY = 16
// Why yield at all when every step already awaits I/O: a fully cached root resolves each
// await in a microtask, which never returns to the macrotask queue. This bounds that run.
const HISTORY_GC_YIELD_EVERY = 32
/** Scan a single history root directory, pruning orphaned entries.
* Returns { totalDirs, orphaned, pruned, totalSizeKB }. */
function gcScanRoot(
root: string,
liveWorktreeIds: Set<string>
): {
let scheduledHistoryGcTimer: ReturnType<typeof setTimeout> | null = null
let historyGcStarting = false
let historyGcCancelled = false
let activeHistoryGc: Promise<void> | null = null
let activeHistoryGcAbort: AbortController | null = null
type GcRootScan = {
totalDirs: number
orphaned: number
pruned: number
totalSizeKB: number
/** Every fish data dir a meta.json in this root names, for the orphan sweep. */
fishHistoryDirs: Set<string>
} {
const result = {
}
/** Inspect one history directory, tombstoning it when its worktree is gone. */
async function gcScanEntry(
root: string,
entry: string,
liveWorktreeIds: Set<string>,
now: number,
result: GcRootScan
): Promise<void> {
const entryPath = join(root, entry)
try {
const stats = await stat(entryPath)
if (!stats.isDirectory()) {
return
}
result.totalDirs++
// Estimate directory size from meta.json + history files.
// Why a local accumulator: `result.totalSizeKB += <expression containing await>`
// reads the field before suspending and writes back a stale sum once workers interleave.
let dirSizeKB = 0
try {
for (const file of await readdir(entryPath)) {
dirSizeKB += Math.ceil((await stat(join(entryPath, file))).size / 1024)
}
} catch {
// Skip size estimation on error, keeping whatever was measured first.
}
result.totalSizeKB += dirSizeKB
// A missing, truncated, oversized or malformed meta.json reads back as null, and a
// null meta is never pruned — an entry whose ownership we cannot establish is kept.
const meta = await readHistoryMetaAsync(entryPath)
if (meta?.fishHistoryDir) {
result.fishHistoryDirs.add(meta.fishHistoryDir)
}
if (!meta?.worktreeId) {
return
}
if (!liveWorktreeIds.has(meta.worktreeId)) {
// Why: avoid a TOCTOU race where a worktree is created after the
// live-ID snapshot but before GC runs. Directories younger than
// GC_MIN_AGE_MS are presumed still live and skipped.
if (meta.createdAt) {
const ageMs = now - new Date(meta.createdAt).getTime()
if (ageMs < GC_MIN_AGE_MS) {
return
}
}
result.orphaned++
// Why: a large orphaned tree recursive-rm'd here would stall the main process ~10s after
// launch — the same freeze the explicit-delete path already tombstones its way out of.
if (scheduleWorktreeHistoryTreeDeletion(entryPath, root)) {
result.pruned++
console.log(`[pty:history:gc] Pruned orphaned history: ${meta.worktreeId}`)
}
}
} catch {
// Skip individual entries that fail.
}
}
/** Scan a single history root directory, pruning orphaned entries. */
async function gcScanRoot(
root: string,
liveWorktreeIds: Set<string>,
signal: AbortSignal
): Promise<GcRootScan> {
const result: GcRootScan = {
totalDirs: 0,
orphaned: 0,
pruned: 0,
totalSizeKB: 0,
fishHistoryDirs: new Set<string>()
}
if (!existsSync(root)) {
let entries: string[]
try {
entries = await readdir(root)
} catch {
// Absent or unreadable root: nothing to collect.
return result
}
const now = Date.now()
// Why: pending-delete is a tombstone queue drained asynchronously, not a live worktree hash.
const frontier = entries.filter((entry) => entry !== PENDING_DELETE_DIR_NAME)
for (const entry of readdirSync(root)) {
// Why: pending-delete is a tombstone queue drained asynchronously, not a live worktree hash.
if (entry === PENDING_DELETE_DIR_NAME) {
continue
await forEachWithConcurrency(
frontier,
HISTORY_GC_SCAN_CONCURRENCY,
async (entry, index): Promise<void> => {
if (signal.aborted) {
return
}
await gcScanEntry(root, entry, liveWorktreeIds, now, result)
if (index % HISTORY_GC_YIELD_EVERY === HISTORY_GC_YIELD_EVERY - 1) {
await yieldToEventLoop()
}
}
const entryPath = join(root, entry)
try {
const stat = statSync(entryPath)
if (!stat.isDirectory()) {
continue
}
result.totalDirs++
// Estimate directory size from meta.json + history files.
try {
for (const file of readdirSync(entryPath)) {
result.totalSizeKB += Math.ceil(statSync(join(entryPath, file)).size / 1024)
}
} catch {
// Skip size estimation on error.
}
const metaPath = join(entryPath, 'meta.json')
if (!existsSync(metaPath)) {
// No meta.json — can't determine ownership, skip.
continue
}
const meta = readHistoryMeta(entryPath)
if (meta?.fishHistoryDir) {
result.fishHistoryDirs.add(meta.fishHistoryDir)
}
if (!meta?.worktreeId) {
continue
}
if (!liveWorktreeIds.has(meta.worktreeId)) {
// Why: avoid a TOCTOU race where a worktree is created after the
// live-ID snapshot but before GC runs. Directories younger than
// GC_MIN_AGE_MS are presumed still live and skipped.
if (meta.createdAt) {
const ageMs = now - new Date(meta.createdAt).getTime()
if (ageMs < GC_MIN_AGE_MS) {
continue
}
}
result.orphaned++
// Why: a large orphaned tree recursive-rm'd here would stall the main process ~10s after
// launch — the same freeze the explicit-delete path already tombstones its way out of.
if (scheduleWorktreeHistoryTreeDeletion(entryPath, root)) {
result.pruned++
console.log(`[pty:history:gc] Pruned orphaned history: ${meta.worktreeId}`)
}
}
} catch {
// Skip individual entries that fail.
}
}
)
return result
}
/** Run background GC to prune history directories for worktrees that are no
* longer in Orca's known live-worktree set. */
export function runHistoryGc(liveWorktreeIds: Set<string>): void {
async function executeHistoryGc(liveWorktreeIds: Set<string>, signal: AbortSignal): Promise<void> {
try {
// Why: finish tombstones left by quit mid-rm before scanning live worktree hashes.
// Safe ahead of the guard below: these entries were already condemned by a
@@ -130,14 +168,17 @@ export function runHistoryGc(liveWorktreeIds: Set<string>): void {
console.log('[pty:history:gc] Skipped: live worktree set is empty')
return
}
const main = gcScanRoot(getHistoryRoot(), liveWorktreeIds)
const main = await gcScanRoot(getHistoryRoot(), liveWorktreeIds, signal)
// Also scan WSL history directories (each distro has its own subdirectory).
const wslTotals = { totalDirs: 0, orphaned: 0, pruned: 0, totalSizeKB: 0 }
const liveFishHistoryDirs = new Set(main.fishHistoryDirs)
for (const distroRoot of listWslHistoryRoots()) {
if (signal.aborted) {
break
}
schedulePendingHistoryTreeRemovals(distroRoot)
const r = gcScanRoot(distroRoot, liveWorktreeIds)
const r = await gcScanRoot(distroRoot, liveWorktreeIds, signal)
wslTotals.totalDirs += r.totalDirs
wslTotals.orphaned += r.orphaned
wslTotals.pruned += r.pruned
@@ -147,6 +188,11 @@ export function runHistoryGc(liveWorktreeIds: Set<string>): void {
}
}
if (signal.aborted) {
console.log('[pty:history:gc] Cancelled mid-scan')
return
}
// Why a sweep on top of per-worktree deletion: a fish history file lives in
// the user's fish data dir, so it outlives the directory that names it. A
// crash between tombstone and removal, or a hand-deleted history dir, leaves
@@ -178,28 +224,62 @@ export function runHistoryGc(liveWorktreeIds: Set<string>): void {
}
}
/** Run background GC to prune history directories for worktrees that are no
* longer in Orca's known live-worktree set. Resolves when the pass finishes. */
export function runHistoryGc(liveWorktreeIds: Set<string>): Promise<void> {
// Why join instead of starting a second pass: two walks would race each other's
// tombstone renames, and the loser's `scheduleWorktreeHistoryTreeDeletion` would
// report a failure for a directory the winner already condemned.
if (activeHistoryGc) {
return activeHistoryGc
}
const controller = new AbortController()
activeHistoryGcAbort = controller
activeHistoryGc = executeHistoryGc(liveWorktreeIds, controller.signal).finally(() => {
activeHistoryGc = null
activeHistoryGcAbort = null
})
return activeHistoryGc
}
/** Drop a pending GC and stop an in-flight walk at its next entry. */
export function cancelHistoryGc(): void {
if (scheduledHistoryGcTimer !== null) {
clearTimeout(scheduledHistoryGcTimer)
scheduledHistoryGcTimer = null
}
// Why a flag as well: the timer has already fired while the live-worktree lookup is
// in flight, and there is no controller to abort until the scan itself starts.
historyGcCancelled = true
activeHistoryGcAbort?.abort()
}
/** Schedule GC after a delay so it runs after workspace hydration completes.
* `getLiveWorktreeIds` should use already-known IDs, not probe repo paths. */
export function scheduleHistoryGc(getLiveWorktreeIds: () => Promise<Set<string>>): void {
// Why: main-window services can reattach during reload/reactivation; one
// pending/running disk GC is enough and avoids duplicate startup I/O.
if (scheduledHistoryGcTimer !== null || historyGcRunning) {
if (scheduledHistoryGcTimer !== null || historyGcStarting || activeHistoryGc !== null) {
return
}
historyGcCancelled = false
// Why 10s: avoids competing with startup-critical I/O while still running
// early enough to clean up before the user notices disk usage (§7.6).
scheduledHistoryGcTimer = setTimeout(async () => {
scheduledHistoryGcTimer = null
historyGcRunning = true
historyGcStarting = true
try {
const liveIds = await getLiveWorktreeIds()
runHistoryGc(liveIds)
if (historyGcCancelled) {
return
}
await runHistoryGc(liveIds)
} catch (err) {
console.warn(
`[pty:history:gc] Failed to enumerate live worktrees for GC: ${err instanceof Error ? err.message : String(err)}`
)
} finally {
historyGcRunning = false
historyGcStarting = false
}
}, 10_000)
}
-175
View File
@@ -96,8 +96,6 @@ import {
flushPendingWorktreeHistoryDeletions
} from './terminal-history-deletion'
import { runHistoryGc, scheduleHistoryGc } from './terminal-history-gc'
const OTHER_WORKTREE_HASH = hashWorktreeId('repo-1::/path/other-wt')
describe('terminal-history', () => {
@@ -688,179 +686,6 @@ describe('terminal-history', () => {
})
})
describe('runHistoryGc', () => {
it('coalesces duplicate scheduled startup GC calls', async () => {
vi.useFakeTimers()
existsSyncMock.mockReturnValue(false)
const getLiveWorktreeIds = vi.fn().mockResolvedValue(new Set<string>())
scheduleHistoryGc(getLiveWorktreeIds)
scheduleHistoryGc(getLiveWorktreeIds)
await vi.advanceTimersByTimeAsync(10_000)
expect(getLiveWorktreeIds).toHaveBeenCalledTimes(1)
})
it('prunes orphaned directories', () => {
existsSyncMock.mockImplementation((p: string) => {
// WSL root doesn't exist, so GC skips it
if (p.includes('terminal-history-wsl')) {
return false
}
return true
})
readdirSyncMock.mockImplementation((dir: string) => {
if (dir.endsWith('terminal-history')) {
return ['dir1', 'dir2']
}
return ['meta.json']
})
statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 })
readFileSyncMock.mockImplementation((p: string) => {
// Use a createdAt old enough to pass the GC age threshold
const oldDate = new Date(Date.now() - 10 * 60 * 1000).toISOString()
if (p.includes('dir1')) {
return JSON.stringify({ worktreeId: 'live-wt', createdAt: oldDate })
}
return JSON.stringify({ worktreeId: 'dead-wt', createdAt: oldDate })
})
const liveIds = new Set(['live-wt'])
runHistoryGc(liveIds)
// Should only prune dir2 (dead-wt), not dir1 (live-wt), and never recursive-rm on the main thread.
expect(rmSyncMock).not.toHaveBeenCalled()
expect(renameSyncMock).toHaveBeenCalledTimes(1)
expect(renameSyncMock).toHaveBeenCalledWith(
expect.stringContaining('dir2'),
expect.stringContaining(`.pending-delete${sep}dir2.`)
)
expect(rmAsyncMock).toHaveBeenCalledWith(
expect.stringContaining(`.pending-delete${sep}dir2.`),
expect.objectContaining({ recursive: true, force: true })
)
})
// Why: an empty live set is what a store that fell back to default state
// looks like, and it is indistinguishable from a user with no worktrees —
// who has no history to collect either. Treating it as "everything is
// orphaned" turns a recoverable bad load into deleted shell history.
it('refuses to prune anything when the live set is empty', () => {
existsSyncMock.mockImplementation((p: string) => !p.includes('terminal-history-wsl'))
readdirSyncMock.mockImplementation((dir: string) => {
if (dir.endsWith('.pending-delete')) {
return []
}
if (dir.endsWith('terminal-history')) {
return ['dir1', 'dir2']
}
return ['meta.json']
})
statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 })
readFileSyncMock.mockReturnValue(
JSON.stringify({
worktreeId: 'some-wt',
createdAt: new Date(Date.now() - 10 * 60 * 1000).toISOString()
})
)
runHistoryGc(new Set())
expect(renameSyncMock).not.toHaveBeenCalled()
expect(rmSyncMock).not.toHaveBeenCalled()
expect(rmAsyncMock).not.toHaveBeenCalled()
})
it('continues GC after one orphan tombstone fails', async () => {
existsSyncMock.mockImplementation((path: string) => !path.includes('terminal-history-wsl'))
readdirSyncMock.mockImplementation((dir: string) => {
if (dir.endsWith('.pending-delete')) {
return []
}
if (dir.endsWith('terminal-history')) {
return ['broken', 'healthy']
}
return ['meta.json']
})
statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 })
readFileSyncMock.mockReturnValue(
JSON.stringify({
worktreeId: 'orphan',
createdAt: new Date(Date.now() - 10 * 60 * 1000).toISOString()
})
)
renameSyncMock.mockImplementationOnce(() => {
throw new Error('busy')
})
expect(() => runHistoryGc(new Set(['live-wt']))).not.toThrow()
expect(renameSyncMock).toHaveBeenCalledTimes(2)
expect(rmAsyncMock).toHaveBeenCalledTimes(1)
await flushPendingWorktreeHistoryDeletions()
})
it('skips recently-created directories to avoid TOCTOU race', () => {
existsSyncMock.mockImplementation((p: string) => {
if (p.includes('terminal-history-wsl')) {
return false
}
return true
})
readdirSyncMock.mockImplementation((dir: string) => {
if (dir.endsWith('terminal-history')) {
return ['fresh-dir']
}
return ['meta.json']
})
statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 })
// createdAt is just now — younger than the 5-minute GC threshold
readFileSyncMock.mockReturnValue(
JSON.stringify({ worktreeId: 'unknown-wt', createdAt: new Date().toISOString() })
)
runHistoryGc(new Set(['live-wt']))
// Should NOT prune because the directory is too young
expect(rmSyncMock).not.toHaveBeenCalled()
expect(renameSyncMock).not.toHaveBeenCalled()
})
it('does not throw when history root does not exist', () => {
existsSyncMock.mockReturnValue(false)
expect(() => runHistoryGc(new Set(['live-wt']))).not.toThrow()
expect(readdirSyncMock).not.toHaveBeenCalledWith('/fake/userData/terminal-history')
})
it('drains delete tombstones asynchronously instead of scanning them as worktrees', async () => {
let tombstonePresent = true
existsSyncMock.mockImplementation((p: string) => !String(p).includes('terminal-history-wsl'))
readdirSyncMock.mockImplementation((dir: string) => {
if (String(dir).endsWith('.pending-delete')) {
return tombstonePresent ? ['abc123.1700000000000.deadbeef'] : []
}
if (String(dir).endsWith('terminal-history')) {
return ['.pending-delete']
}
return ['meta.json']
})
statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 })
rmAsyncMock.mockImplementation(async () => {
tombstonePresent = false
})
runHistoryGc(new Set(['live-wt']))
// The tombstone queue is drained off-thread; GC must never rmSync it or count it as a worktree.
expect(rmSyncMock).not.toHaveBeenCalled()
expect(rmAsyncMock).toHaveBeenCalledWith(
expect.stringContaining('abc123.1700000000000.deadbeef'),
expect.objectContaining({ recursive: true, force: true })
)
await flushPendingWorktreeHistoryDeletions()
})
})
describe('WSL path conversion', () => {
it('converts HISTFILE to Linux path for WSL cwd', () => {
const originalPlatform = process.platform
+24 -1
View File
@@ -1,5 +1,6 @@
import { join, basename } from 'node:path'
import { mkdirSync, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { readFile, stat } from 'node:fs/promises'
import {
dropInheritedOrcaFishHistory,
fishHistorySessionName,
@@ -131,7 +132,29 @@ export function readHistoryMeta(dir: string): HistoryDirMeta | null {
if (statSync(metaPath).size > MAX_HISTORY_META_BYTES) {
return null
}
const raw: unknown = JSON.parse(readFileSync(metaPath, 'utf-8'))
return parseHistoryMeta(dir, readFileSync(metaPath, 'utf-8'))
} catch {
return null
}
}
/** `readHistoryMeta` off the main thread, for scans that walk thousands of directories. */
export async function readHistoryMetaAsync(dir: string): Promise<HistoryDirMeta | null> {
try {
const metaPath = join(dir, 'meta.json')
// Why stat before read: the cap must reject an oversized meta.json without loading it.
if ((await stat(metaPath)).size > MAX_HISTORY_META_BYTES) {
return null
}
return parseHistoryMeta(dir, await readFile(metaPath, 'utf-8'))
} catch {
return null
}
}
function parseHistoryMeta(dir: string, contents: string): HistoryDirMeta | null {
try {
const raw: unknown = JSON.parse(contents)
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return null
}
@@ -75,8 +75,6 @@ export function parseWindowsCimProcessRows(stdout: string): WindowsProcessRow[]
return []
}
const name = fieldAsString(row.Name)
// memoryBytes stays undefined: Win32_Process reports WorkingSetSize, but no
// caller reads it off this table and asking widens an already costly scan.
return [{ pid, ppid, name, command: fieldAsString(row.CommandLine) || name }]
})
}
+13 -10
View File
@@ -52,21 +52,24 @@ describe('windows process table', () => {
it('maps native rows, defaulting an unreadable command line to empty', async () => {
const rows = await readWindowsProcessTableFresh()
expect(rows).toEqual([
{ pid: process.pid, ppid: 0, name: 'vitest.exe', command: '', memoryBytes: undefined },
{ pid: process.pid, ppid: 0, name: 'vitest.exe', command: '' },
{
pid: 100,
ppid: 4,
name: 'orca.exe',
command: '"C:/a b/orca.exe" --x',
memoryBytes: 4096,
creationTimeMs: 1_700_000_000_000
}
])
})
it('requests memory and command line together', async () => {
it('requests the command line and creation time, never memory', async () => {
await readWindowsProcessTableFresh()
expect(getAllProcesses.mock.calls[0]?.[1]).toBe(7)
// CommandLine (2) | CreationTime (4). The Memory bit (1) stays clear: the
// addon opens a second PROCESS_VM_READ handle per process to serve it and
// nothing reads a working set off this table.
expect(getAllProcesses.mock.calls[0]?.[1]).toBe(6)
expect((getAllProcesses.mock.calls[0]?.[1] as number) & 1).toBe(0)
})
it('only advertises PID-safe ownership when the native creation-time field exists', () => {
@@ -400,20 +403,19 @@ describe('resolving the native reader', () => {
})
const rows = await readWindowsProcessTableFresh()
expect(rows).toEqual([
{ pid: process.pid, ppid: 0, name: 'vitest.exe', command: '', memoryBytes: undefined },
{ pid: process.pid, ppid: 0, name: 'vitest.exe', command: '' },
{
pid: 100,
ppid: 4,
name: 'orca.exe',
command: '"C:/a b/orca.exe" --x',
memoryBytes: 4096,
creationTimeMs: 1_700_000_000_000
}
])
expect(isWindowsProcessTableAvailable()).toBe(true)
})
it('asks the addon for memory and command line, as the package path does', async () => {
it('asks the addon for the command line but not memory, as the package path does', async () => {
const addon = addonReturning(NATIVE)
__setWindowsProcessTreeRequireForTests((specifier: string) => {
if (specifier === ADDON_SPECIFIER) {
@@ -422,9 +424,10 @@ describe('resolving the native reader', () => {
throw new Error('MODULE_NOT_FOUND')
})
await readWindowsProcessTableFresh()
// Memory | CommandLine. A bare snapshot would silently drop the command
// line every agent-recognition caller matches on first.
expect(addon.getProcessList).toHaveBeenCalledWith(expect.any(Function), 3)
// CommandLine only: a bare snapshot would silently drop the command line
// every agent-recognition caller matches on first, and the relay addon
// exposes no CreationTime bit to add.
expect(addon.getProcessList).toHaveBeenCalledWith(expect.any(Function), 2)
})
it('reaches the CIM scan when neither the package nor the addon is present', async () => {
+18 -16
View File
@@ -23,6 +23,10 @@ import { readWindowsProcessRowsWithCim } from './windows-process-table-cim-scan'
* pid+ppid+name 15.9 / 17.5 ms
* +memory +commandLine 30.6 / 33.7 ms
* PowerShell CIM 706 / 723 ms
*
* Those are the module's published figures for both extra fields together; the
* only flag set this module asks for is `CommandLine` (+ `CreationTime`, free),
* which sits between the two rows and has not been separately measured.
*/
export type WindowsProcessRow = {
@@ -31,8 +35,6 @@ export type WindowsProcessRow = {
name: string
/** Full command line. Empty when the process denied a query handle. */
command: string
/** Working set in bytes, or undefined when not requested/queryable. */
memoryBytes?: number
/** Process creation time in Unix milliseconds, when the native snapshot provides it. */
creationTimeMs?: number
}
@@ -41,7 +43,6 @@ type NativeProcessInfo = {
pid: number
ppid: number
name: string
memory?: number
commandLine?: string
creationTimeMs?: number
}
@@ -49,7 +50,6 @@ type NativeProcessInfo = {
type WindowsProcessTreeModule = {
ProcessDataFlag: {
None: number
Memory: number
CommandLine: number
CreationTime?: number
}
@@ -82,7 +82,10 @@ type WindowsProcessTreeAddon = {
) => void
}
/** Mirrors the package's enum; the addon takes the raw bit field. */
/**
* Mirrors the package's enum; the addon takes the raw bit field. `Memory` (1)
* is listed for completeness and is deliberately never set — see `flags` below.
*/
const PROCESS_DATA_FLAG = { None: 0, Memory: 1, CommandLine: 2 } as const
/** Staged beside the relay bundle by build-relay; see RELAY_ARTIFACTS. */
@@ -190,16 +193,16 @@ function readNativeRows(): Promise<WindowsProcessRow[]> {
}
const readId = ++readSequence
const readerEpoch = nativeReaderEpoch
// Why always both flags: each adds an OpenProcess per process (Memory a
// GetProcessMemoryInfo, CommandLine a PEB read), so asking for less would be
// cheaper -- 15.9ms p50 versus 30.6ms at 1050 processes. But every read shares
// one snapshot so a 32-wide teardown collapses into a single scan, and that
// snapshot has to satisfy every caller. Splitting the cache per field set
// would restore exactly the fan-out it exists to prevent.
const flags =
native.ProcessDataFlag.Memory |
native.ProcessDataFlag.CommandLine |
(native.ProcessDataFlag.CreationTime ?? 0)
// Why CommandLine but not Memory: each flag costs one OpenProcess per process
// inside the addon (process.cc), and every caller of this table matches on
// `command`, while nothing reads a working set off it -- the Resource Manager
// runs its own CIM sweep because it needs commit and CPU time in one pass, and
// `process.cc` truncates the working set into a DWORD anyway. Dropping Memory
// halves the per-snapshot handle count; the remaining flags stay in ONE flag
// set because every read shares one snapshot, so a 32-wide teardown collapses
// into a single scan. Splitting the cache per field set would restore exactly
// the fan-out it exists to prevent.
const flags = native.ProcessDataFlag.CommandLine | (native.ProcessDataFlag.CreationTime ?? 0)
return new Promise((resolve, reject) => {
// Hoisted so a synchronous throw from getAllProcesses can clear it. An
// orphaned timer would otherwise fire later and wedge a reader that had
@@ -241,7 +244,6 @@ function readNativeRows(): Promise<WindowsProcessRow[]> {
ppid: row.ppid,
name: row.name,
command: row.commandLine ?? '',
memoryBytes: row.memory,
...(typeof row.creationTimeMs === 'number'
? { creationTimeMs: row.creationTimeMs }
: {})
+5
View File
@@ -1,4 +1,5 @@
import type {
AgentStatusCacheIdentity,
AgentStatusClearIpcPayload,
AgentStatusIpcPayload,
MigrationUnsupportedPtyEntry
@@ -30,6 +31,10 @@ export type AgentStatusApi = {
getMigrationUnsupportedSnapshot: () => Promise<MigrationUnsupportedPtyEntry[]>
/** Drop a paneKey from the main-process hook cache and on-disk last-status file. Fire-and-forget. */
drop: (paneKey: string) => void
/** Evict a previously-cleared status only when its identity still matches the main-process cache. */
dropPersisted: (identity: AgentStatusCacheIdentity) => void
/** Same as dropPersisted for many identities in one IPC message and one listener notification. */
dropPersistedBatch?: (identities: readonly AgentStatusCacheIdentity[]) => void
/** Retire a pane whose agent process is proven gone — clears the row AND the per-pane caches a
* dismissal deliberately keeps. Not `drop`: that one is a user dismissal of a live pane's row. */
reconcileEndedProcess: (paneKey: string) => void
+7
View File
@@ -1,5 +1,6 @@
import { ipcRenderer } from 'electron'
import type {
AgentStatusCacheIdentity,
AgentStatusClearIpcPayload,
AgentStatusIpcPayload,
MigrationUnsupportedPtyEntry
@@ -66,6 +67,12 @@ export const agentStatusApi = {
drop: (paneKey: string): void => {
ipcRenderer.send('agentStatus:drop', paneKey)
},
dropPersisted: (identity: AgentStatusCacheIdentity): void => {
ipcRenderer.send('agentStatus:dropPersisted', identity)
},
dropPersistedBatch: (identities: readonly AgentStatusCacheIdentity[]): void => {
ipcRenderer.send('agentStatus:dropPersistedBatch', identities)
},
reconcileEndedProcess: (paneKey: string): void => {
ipcRenderer.send('agentStatus:reconcileEndedProcess', paneKey)
},
+3
View File
@@ -38,6 +38,9 @@ export type AppApi = {
/** Resolves when the daemon PTY provider and hook receiver have either
* started or failed open for the first BrowserWindow. */
awaitFirstWindowStartupServices: () => Promise<void>
/** Resolves when host Git can run: shell-PATH generation is published and the
* managed WSL CLI registration has reconciled. Does not wait on PTY services. */
awaitGitEnvironmentStartupBarrier: () => Promise<void>
/** Inventories retained PTYs and restores durable structured ownership before renderer adoption. */
prepareTerminalStartupRestoration: () => Promise<void>
/** Reconciles legacy worker authority around persisted terminal reconnect. */
+2
View File
@@ -43,6 +43,8 @@ export const appApi = {
awaitBeforeUnloadCheckpoint: () => awaitBeforeUnloadCheckpoint(),
awaitFirstWindowStartupServices: (): Promise<void> =>
ipcRenderer.invoke('app:awaitFirstWindowStartupServices'),
awaitGitEnvironmentStartupBarrier: (): Promise<void> =>
ipcRenderer.invoke('app:awaitGitEnvironmentStartupBarrier'),
prepareTerminalStartupRestoration: (): Promise<void> =>
ipcRenderer.invoke('app:prepareTerminalStartupRestoration'),
recoverLegacyWorkerTerminalsForRendererStartup: (): Promise<void> =>
+55
View File
@@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { platformApi } from './platform-bridge'
const mocks = vi.hoisted(() => ({ getLinuxDisplayServer: vi.fn(() => null) }))
vi.mock('../preload-runtime-support', () => ({
getLinuxDisplayServer: mocks.getLinuxDisplayServer
}))
// Electron declares getSystemVersion as required on NodeJS.Process; Node does not have it.
const mutableProcess = process as unknown as { getSystemVersion?: () => string }
async function loadPlatformApi(): Promise<typeof platformApi> {
vi.resetModules()
return (await import('./platform-bridge')).platformApi
}
describe('platformApi.get', () => {
beforeEach(() => {
mocks.getLinuxDisplayServer.mockClear()
})
afterEach(() => {
delete mutableProcess.getSystemVersion
})
it('resolves the immutable payload once and returns the identical object', async () => {
const platformApi = await loadPlatformApi()
const getSystemVersion = vi.fn(() => '25.3.0')
mutableProcess.getSystemVersion = getSystemVersion
const first = platformApi.get()
for (let index = 0; index < 100; index += 1) {
expect(platformApi.get()).toBe(first)
}
expect(getSystemVersion).toHaveBeenCalledTimes(1)
expect(mocks.getLinuxDisplayServer).toHaveBeenCalledTimes(1)
expect(first.platform).toBe(process.platform)
expect(first.arch).toBe(process.arch)
expect(first.osRelease).toBe('25.3.0')
})
it('freezes the payload so no consumer can corrupt the shared instance', async () => {
const platformApi = await loadPlatformApi()
expect(Object.isFrozen(platformApi.get())).toBe(true)
})
it('resolves nothing before the first get, keeping preload startup free', async () => {
await loadPlatformApi()
expect(mocks.getLinuxDisplayServer).not.toHaveBeenCalled()
})
})
+13 -2
View File
@@ -1,8 +1,14 @@
import { getLinuxDisplayServer } from '../preload-runtime-support'
import type { PreloadApi } from '../api-types'
export const platformApi = {
get: () => ({
type PlatformInfo = ReturnType<PreloadApi['platform']['get']>
// Why: the renderer reads this on its render cadence, and every field below is fixed
// for the process lifetime, so resolve once and hand back the same frozen payload.
let platformInfo: PlatformInfo | undefined
function resolvePlatformInfo(): PlatformInfo {
return Object.freeze({
platform: process.platform,
// Why: sandboxed preload cannot require node:os; Electron exposes the OS
// version on process.getSystemVersion when available.
@@ -14,4 +20,9 @@ export const platformApi = {
shell: process.env.SHELL?.trim() || process.env.ComSpec?.trim() || '',
displayServer: getLinuxDisplayServer()
})
}
export const platformApi = {
// Why: resolved lazily so preload startup keeps paying nothing for it.
get: () => (platformInfo ??= resolvePlatformInfo())
} satisfies PreloadApi['platform']
+22 -3
View File
@@ -25,6 +25,7 @@ import {
writeRelayFile
} from './fs-path-mutation-requests'
import { buildExcludePathPrefixes } from '../shared/quick-open-filter'
import { maybeStreamRpcResponse, type GitResponseStreamRegistry } from './git-response-stream'
import { readRelayFileContent, readRelayFileStreamMetadata } from './fs-handler-file-read'
import { readRelayFileRange } from './fs-handler-file-range'
import { FileRangeReadRequestError } from '../shared/file-range-read'
@@ -47,12 +48,19 @@ export class FsHandler {
private watchRegistry: RelayFilesystemWatchRegistry
private streamRegistry = new RelayStreamRegistry()
private listFilesScans = new ListFilesScanCoordinator()
private readonly responseStreams: GitResponseStreamRegistry | undefined
constructor(
dispatcher: RelayDispatcher,
_context: RelayContext,
watcherPool?: RelayWatcherProcessPool
watcherPool?: RelayWatcherProcessPool,
// Why passed in rather than owned: GitHandler registers the `git.responseAck` route every pump
// is credited through, and a client keys reassembly on `streamId` alone — see the header of
// git-response-stream.ts. Without one this handler answers plainly, which is the pre-streaming
// behavior rather than a stream nothing can credit.
responseStreams?: GitResponseStreamRegistry
) {
this.responseStreams = responseStreams
this.dispatcher = dispatcher
this.watchRegistry = new RelayFilesystemWatchRegistry(dispatcher, watcherPool)
this.registerHandlers()
@@ -204,7 +212,10 @@ export class FsHandler {
}
}
private listFiles(params: Record<string, unknown>, context?: RequestContext): Promise<string[]> {
private async listFiles(
params: Record<string, unknown>,
context?: RequestContext
): Promise<unknown> {
const rootPath = expandTilde(params.rootPath as string)
const maxResults =
typeof params.maxResults === 'number' &&
@@ -224,13 +235,21 @@ export class FsHandler {
// Why #7721: full-tree scans are the relay's most expensive request; the
// coordinator caps them at one per client, coalescing duplicates and
// aborting a stale scan when the workspace changes or the host cancels.
return this.listFilesScans.run({
const files = await this.listFilesScans.run({
clientId: context?.clientId ?? 0,
key: JSON.stringify([rootPath, excludePathPrefixes, maxResults, searchQuery]),
signal: context?.signal,
start: (signal) =>
runListFilesScan(rootPath, excludePathPrefixes, signal, maxResults, searchQuery)
})
// Why: a full listing of a real monorepo serializes past the 1 MiB control lane — Orca's own
// checkout is 22.6k paths averaging 58 characters, so a 20,001-row page is ~1.2MB — and the
// legacy-response lane it demotes to is refused under unrelated producer load. Streaming makes
// size stop being a correctness question instead of picking a row or byte ceiling to refuse at.
// A client that did not opt in still gets the plain array, exactly as before.
return this.responseStreams
? maybeStreamRpcResponse(files, params, context, this.responseStreams, this.dispatcher)
: files
}
private async workspaceSpaceScan(params: Record<string, unknown>, context: RequestContext) {
@@ -0,0 +1,130 @@
/**
* #12547: a full `fs.listFiles` reply for a real monorepo does not fit the relay's control lane.
*
* Orca's own checkout is ~22.6k tracked paths averaging 58 characters, so a 20,001-row page
* serializes to ~1.2MB — past `DISPATCHER_CONTROL_QUEUE_MAX_BYTES`, which demotes it to the
* `legacy-response` lane where an unrelated producer backlog can refuse it. Refusing at a fixed row
* or byte ceiling only moves where that shows up; streaming removes it, so these run the real
* dispatcher, the real FsHandler and the real client multiplexer over an in-memory pipe and assert
* an over-budget listing arrives intact — in both wire directions.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { runListFilesScanMock } = vi.hoisted(() => ({ runListFilesScanMock: vi.fn() }))
vi.mock('./fs-list-files-fallback-chain', () => ({ runListFilesScan: runListFilesScanMock }))
vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() }))
import {
SshChannelMultiplexer,
type MultiplexerTransport
} from '../main/ssh/ssh-channel-multiplexer'
import { requestGitStreamable } from '../main/ssh/ssh-git-response-stream-reader'
import { RelayContext } from './context'
import { RelayDispatcher } from './dispatcher'
import { DISPATCHER_CONTROL_QUEUE_MAX_BYTES } from './dispatcher-writer-admission'
import { FsHandler } from './fs-handler'
import { GitHandler } from './git-handler'
import { GitResponseStreamRegistry } from './git-response-stream'
import { QUICK_OPEN_LISTING_MAX_RESULTS } from '../shared/quick-open-listing-limits'
/** Shaped like this repository: `packages/<name>/src/...`, ~58 characters. */
function monorepoPaths(count: number): string[] {
return Array.from(
{ length: count },
(_, index) =>
`packages/pkg-${String(index % 64).padStart(2, '0')}/src/renderer/components/entry-${String(index).padStart(6, '0')}.tsx`
)
}
describe('Integration: an over-budget fs.listFiles reply (#12547)', () => {
let mux: SshChannelMultiplexer
let dispatcher: RelayDispatcher
let fsHandler: FsHandler
let gitHandler: GitHandler
let writtenFrames: number[]
beforeEach(() => {
runListFilesScanMock.mockReset()
writtenFrames = []
let relayFeed: (data: Buffer) => void
const clientDataCallbacks: ((data: Buffer) => void)[] = []
const clientTransport: MultiplexerTransport = {
write: (data: Buffer) => {
setImmediate(() => relayFeed?.(data))
},
onData: (cb) => {
clientDataCallbacks.push(cb)
},
onClose: () => {}
}
dispatcher = new RelayDispatcher((data: Buffer) => {
writtenFrames.push(data.length)
setImmediate(() => {
for (const cb of clientDataCallbacks) {
cb(data)
}
})
return true
})
relayFeed = (data: Buffer) => dispatcher.feed(data)
// Why: the same single registry production wires, so `git.responseAck` — registered by
// GitHandler — credits the pump an fs.listFiles stream parks on.
const responseStreams = new GitResponseStreamRegistry()
const context = new RelayContext()
fsHandler = new FsHandler(dispatcher, context, undefined, responseStreams)
gitHandler = new GitHandler(dispatcher, context, undefined, responseStreams)
mux = new SshChannelMultiplexer(clientTransport)
})
afterEach(() => {
mux.dispose()
dispatcher.dispose()
fsHandler.dispose()
gitHandler.dispose()
})
it('delivers a page too large for the control lane, in chunks no frame has to carry', async () => {
const files = monorepoPaths(QUICK_OPEN_LISTING_MAX_RESULTS)
// Precondition, measured from the payload rather than asserted between two constants: this is
// the listing that does not fit, which is what makes the rest of the test mean anything.
expect(Buffer.byteLength(JSON.stringify(files), 'utf8')).toBeGreaterThan(
DISPATCHER_CONTROL_QUEUE_MAX_BYTES
)
runListFilesScanMock.mockResolvedValue(files)
const received = await requestGitStreamable(mux, 'fs.listFiles', {
rootPath: '/remote/root',
maxResults: QUICK_OPEN_LISTING_MAX_RESULTS
})
expect(received).toEqual(files)
expect(Math.max(...writtenFrames)).toBeLessThan(DISPATCHER_CONTROL_QUEUE_MAX_BYTES)
})
it('still answers a client that never opts into streaming, with the whole array', async () => {
const files = monorepoPaths(QUICK_OPEN_LISTING_MAX_RESULTS)
runListFilesScanMock.mockResolvedValue(files)
// Why: an old client sends neither `__streamResponse` nor `maxResults`. It gets one plain frame
// on the legacy-response lane, as it did before this call ever learned to stream.
const received = await mux.request('fs.listFiles', { rootPath: '/remote/root' })
expect(received).toEqual(files)
expect(Math.max(...writtenFrames)).toBeGreaterThan(DISPATCHER_CONTROL_QUEUE_MAX_BYTES)
})
it('leaves a reply that fits on the plain response path', async () => {
const files = monorepoPaths(100)
runListFilesScanMock.mockResolvedValue(files)
const received = await requestGitStreamable(mux, 'fs.listFiles', {
rootPath: '/remote/root',
maxResults: 100
})
expect(received).toEqual(files)
expect(Math.max(...writtenFrames)).toBeLessThan(DISPATCHER_CONTROL_QUEUE_MAX_BYTES)
})
})
+11
View File
@@ -46,6 +46,17 @@ function gitForConfig(config: {
}
return { stdout: `${config.base ?? ''}\n`, stderr: '' }
}
if (args[0] === 'remote' && args[1] === '-v') {
return {
stdout: (config.remotes ?? [])
.flatMap((name) => {
const url = config.remoteUrls?.[name] ?? ''
return [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]
})
.join('\n'),
stderr: ''
}
}
if (args[0] === 'remote' && args.length === 1) {
return { stdout: `${config.remotes?.join('\n') ?? ''}\n`, stderr: '' }
}
+15 -18
View File
@@ -1,5 +1,6 @@
import { assertGitPushTargetShape } from '../shared/git-push-target-validation'
import { gitRefTargetsBranchOnRemote } from '../shared/git-remote-branch-name'
import { findGitRemoteNameByFetchUrl } from '../shared/git-remote-url-index'
import type { GitPushTarget } from '../shared/worktree/types'
type RelayGit = (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }>
@@ -67,31 +68,19 @@ type ConfiguredPushRemote = {
branchRemote: string | null
}
// Host-side twin of `src/main/git/remote.ts`: one `git remote -v` instead of
// `git remote` plus a serial `git remote get-url` per remote.
async function findRemoteNameForUrl(
git: RelayGit,
worktreePath: string,
remoteUrl: string
): Promise<string | null> {
try {
const { stdout } = await git(['remote'], worktreePath)
const remotes = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
for (const remoteName of remotes) {
try {
const { stdout: urlStdout } = await git(['remote', 'get-url', remoteName], worktreePath)
if (urlStdout.trim() === remoteUrl) {
return remoteName
}
} catch {
// Ignore a remote that disappeared or has no fetch URL.
}
}
const { stdout } = await git(['remote', '-v'], worktreePath)
return findGitRemoteNameByFetchUrl(stdout, (candidateUrl) => candidateUrl === remoteUrl)
} catch {
return null
}
return null
}
async function normalizePushRemote(
@@ -120,9 +109,17 @@ async function getConfiguredPushRemote(
if (!remote) {
return null
}
const normalizedRemote = await normalizePushRemote(git, worktreePath, remote)
// The two usually name the same URL; resolving it twice reads the remote table twice.
if (!branchRemote) {
return { remote: normalizedRemote, branchRemote: null }
}
return {
remote: await normalizePushRemote(git, worktreePath, remote),
branchRemote: branchRemote ? await normalizePushRemote(git, worktreePath, branchRemote) : null
remote: normalizedRemote,
branchRemote:
branchRemote === remote
? normalizedRemote
: await normalizePushRemote(git, worktreePath, branchRemote)
}
}
+8 -14
View File
@@ -10,8 +10,7 @@ import {
createSubmodulePathsCache,
type SubmodulePathsCache
} from './git-handler-submodule-ops'
import { GitResponseStreamRegistry } from './git-response-stream'
import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol'
import { GitResponseStreamRegistry, maybeStreamRpcResponse } from './git-response-stream'
import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache'
import { invalidateGitBranchLineTotalInFlight } from '../shared/git-branch-line-total'
import { buildRelayGitEnv, buildRelayUnattendedGitEnv } from './relay-command-env'
@@ -68,9 +67,6 @@ export class GitHandler {
private dispatcher: RelayDispatcher
private readonly gitDiffReadDedupe = new InFlightPromiseDedupe<unknown>()
private readonly gitCapabilities = new GitCapabilityCache()
// Why: use the bulk lane so large responses do not block interactive PTY echo.
private readonly responseStreams = new GitResponseStreamRegistry()
// Why: cache .gitmodules per instance to avoid SSH reads and test leakage.
private submodulePathsCache: SubmodulePathsCache = createSubmodulePathsCache()
@@ -78,7 +74,12 @@ export class GitHandler {
constructor(
dispatcher: RelayDispatcher,
_context: RelayContext,
private readonly watcherRegistry?: GitHandlerWatcherRegistry
private readonly watcherRegistry?: GitHandlerWatcherRegistry,
// Why: use the bulk lane so large responses do not block interactive PTY echo. This handler
// registers the `git.responseAck` route below, so in production it takes the relay's single
// registry and FsHandler is handed the same one — see the header of git-response-stream.ts for
// why a second registry both collides on stream ids and stalls on credit.
private readonly responseStreams: GitResponseStreamRegistry = new GitResponseStreamRegistry()
) {
this.dispatcher = dispatcher
const handlers = createGitHandlerOperationSet({
@@ -132,14 +133,7 @@ export class GitHandler {
params: Record<string, unknown>,
context: RequestContext | undefined
): unknown {
if (params.__streamResponse !== true || !context) {
return result
}
const payload = Buffer.from(JSON.stringify(result ?? null), 'utf-8')
if (payload.length <= GIT_RESPONSE_STREAM_THRESHOLD) {
return result
}
return this.responseStreams.startStream(payload, this.dispatcher, context)
return maybeStreamRpcResponse(result, params, context, this.responseStreams, this.dispatcher)
}
private clearGitMutationReadCaches(): void {
+42 -5
View File
@@ -1,11 +1,22 @@
// Streams large git RPC responses (diff family + exec) onto the bulk lane in
// chunks instead of one JSON-RPC frame, so a big diff cannot head-of-line-block
// interactive pty.data echo on the shared SSH channel. Mirrors the fs
// read-stream credit-window pattern (see fs-handler-file-read.ts) but the
// payload is an in-memory serialized string rather than a file handle.
// Streams large RPC responses onto the bulk lane in chunks instead of one
// JSON-RPC frame, so a big reply cannot head-of-line-block interactive pty.data
// echo on the shared SSH channel. Mirrors the fs read-stream credit-window
// pattern (see fs-handler-file-read.ts) but the payload is an in-memory
// serialized string rather than a file handle.
//
// ONE REGISTRY PER RELAY. The `git.*` method names below are the shipped wire
// spelling and are permanent, the way an opcode number is, so a second handler
// that needs streaming (`fs.listFiles` is the first) shares this instance rather
// than minting its own. A second registry is not an option: a client keys
// reassembly on `streamId` alone, so two would hand out the same id and
// cross-feed each other's chunks, and only the handler that registers
// `git.responseAck` can credit the ack window a pump parks on — the other's
// streams would stall at STREAM_ACK_WINDOW_CHUNKS forever. See
// `relay-runtime-services.ts` for the wiring.
import type { RelayDispatcher, RequestContext } from './dispatcher'
import {
GIT_RESPONSE_CHUNK_SIZE,
GIT_RESPONSE_STREAM_THRESHOLD,
STREAM_ACK_WINDOW_CHUNKS,
STREAM_ACK_STALL_RECHECK_MS,
type GitResponseStreamMarker
@@ -220,3 +231,29 @@ export class GitResponseStreamRegistry {
this.streams.clear()
}
}
/**
* Opt-in response streaming, shared by every handler that can answer with a
* payload too large for one control-lane frame.
*
* `__streamResponse` is its own negotiation in both directions: an old client
* never sends it and gets the plain result, and an old relay ignores it and
* answers plainly, which the client detects by the sentinel marker being absent.
* So there is no new method and no capability to advertise.
*/
export function maybeStreamRpcResponse(
result: unknown,
params: Record<string, unknown>,
context: RequestContext | undefined,
registry: GitResponseStreamRegistry,
dispatcher: RelayDispatcher
): unknown {
if (params.__streamResponse !== true || !context) {
return result
}
const payload = Buffer.from(JSON.stringify(result ?? null), 'utf-8')
if (payload.length <= GIT_RESPONSE_STREAM_THRESHOLD) {
return result
}
return registry.startStream(payload, dispatcher, context)
}
+9 -13
View File
@@ -6,17 +6,16 @@ import { promisify } from 'node:util'
import {
isAgentForegroundWrapperProcess,
isExpectedAgentProcess,
recognizeAgentProcess,
recognizeAgentProcessFromCommandLine
recognizeAgentProcess
} from '../shared/agent-process-recognition'
import { getFirstCommandToken } from '../shared/command-token-scanner'
import {
getProcessTableIndex,
getProcessTableSnapshot,
scoreForegroundCandidateRow,
type ProcessTableIndex,
type ProcessTableRow
} from '../shared/process-table-snapshot'
import { selectForegroundProcessCandidate } from '../shared/foreground-process-selection'
import {
resolveOuterWrapperForegroundProcess,
shouldInspectOuterWrapperForegroundProcess
@@ -240,9 +239,7 @@ function getForegroundProcessNameFromProcessTable(
// snapshot no longer each rebuild the parent/child map over every row.
const index = getProcessTableIndex(rows)
const root = index.byPid.get(pid)
const candidates = collectDescendants(index, pid).sort(
(a, b) => scoreForegroundCandidateRow(b) - scoreForegroundCandidateRow(a)
)
const candidates = collectDescendants(index, pid)
// Why: SSH relays do not have the daemon's async wrapper cache. Inspect the
// remote process tree so node/python agent entrypoints become real agents.
const foregroundIsKnown =
@@ -264,13 +261,12 @@ function getForegroundProcessNameFromProcessTable(
) {
return null
}
for (const candidate of inspectionCandidates) {
const recognized = recognizeAgentProcessFromCommandLine(candidate.command)
if (recognized) {
// Why: return the outer wrapper (omp) rather than the deeper wrapped child
// (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess.
return resolveOuterWrapperForegroundProcess(recognized, candidate, candidates)
}
const ancestryCandidates = root ? [{ ...root, depth: 0 }, ...candidates] : candidates
const selected = selectForegroundProcessCandidate(inspectionCandidates, ancestryCandidates)
if (selected) {
// Why: return the outer wrapper (omp) rather than a deeper recognized helper
// in the same process lineage.
return resolveOuterWrapperForegroundProcess(selected.recognized, selected.candidate, candidates)
}
return null
}
+7 -2
View File
@@ -6,6 +6,7 @@ import { RelayContext, expandTilde } from './context'
import { PtyHandler } from './pty-handler'
import { FsHandler } from './fs-handler'
import { GitHandler } from './git-handler'
import { GitResponseStreamRegistry } from './git-response-stream'
import { PreflightHandler } from './preflight-handler'
import { ExternalAutomationsHandler } from './external-automations-handler'
import { PortScanHandler } from './port-scan-handler'
@@ -51,13 +52,17 @@ export class RelayRuntimeServices {
)
this.ptyHandler.setSourcePublication(this.ptySourcePublication)
this.fsHandler = new FsHandler(dispatcher, context)
// Why one instance for both handlers: a client reassembles a streamed reply by `streamId` alone,
// so two registries would hand out the same id, and only GitHandler routes the `git.responseAck`
// credit every pump waits on. A second registry is not an option — see git-response-stream.ts.
const responseStreams = new GitResponseStreamRegistry()
this.fsHandler = new FsHandler(dispatcher, context, undefined, responseStreams)
const watchRegistry = this.fsHandler.getWatchRegistry()
this.ptyHandler.setWorktreeRemovalCoordinator(watchRegistry)
watchRegistry.setWorktreePtyTeardown((rootPath) =>
this.ptyHandler.shutdownForWorktreePath(rootPath)
)
this.gitHandler = new GitHandler(dispatcher, context, watchRegistry)
this.gitHandler = new GitHandler(dispatcher, context, watchRegistry, responseStreams)
const preflightHandler = new PreflightHandler(dispatcher)
this.skillInstallHandler = new SkillInstallHandler(dispatcher)
const externalAutomationsHandler = new ExternalAutomationsHandler(dispatcher)
@@ -2,13 +2,14 @@ import { describe, expect, it, vi } from 'vitest'
import { reconcileHydratedWorkspaceTabModels } from './reconcile-hydrated-workspace-tab-models'
describe('reconcileHydratedWorkspaceTabModels', () => {
it('reconciles every workspace the session hydrated, in session order', () => {
it('reconciles every workspace the session hydrated, in session order, in one call', () => {
const reconcile = vi.fn()
const reconciled = reconcileHydratedWorkspaceTabModels(
{ tabsByWorktree: { 'wt-a': [], 'wt-b': [], 'wt-c': [] } },
reconcile
)
expect(reconcile.mock.calls.map((call) => call[0])).toEqual(['wt-a', 'wt-b', 'wt-c'])
expect(reconcile).toHaveBeenCalledTimes(1)
expect(reconcile.mock.calls[0]?.[0]).toEqual(['wt-a', 'wt-b', 'wt-c'])
expect(reconciled).toEqual(['wt-a', 'wt-b', 'wt-c'])
})
@@ -3,12 +3,13 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-st
/** Reconcile every workspace loaded during boot so stale unified-tab subsets converge. */
export function reconcileHydratedWorkspaceTabModels(
session: Pick<WorkspaceSessionState, 'tabsByWorktree'>,
reconcileWorktreeTabModel: (worktreeId: string) => unknown
// Why batched: one store write for the whole session instead of one per
// workspace, each fanning out to every non-React store subscriber.
reconcileWorktreeTabModels: (worktreeIds: readonly string[]) => void
): string[] {
const reconciled: string[] = []
for (const worktreeId of Object.keys(session.tabsByWorktree)) {
reconcileWorktreeTabModel(worktreeId)
reconciled.push(worktreeId)
const reconciled = Object.keys(session.tabsByWorktree)
if (reconciled.length > 0) {
reconcileWorktreeTabModels(reconciled)
}
return reconciled
}
@@ -30,6 +30,7 @@ function makeActions(): StartupActions {
reconnectPersistedTerminals: vi.fn(),
setTerminalStartupRestorationReady: vi.fn(),
setDeferredSshReconnectTargets: vi.fn(),
removeDeferredSshReconnectTarget: vi.fn(),
setSshConnectionState: vi.fn(),
hydratePersistedUI: vi.fn(),
setHydrationSucceeded: vi.fn(),
@@ -22,6 +22,7 @@ export type StartupActions = Pick<
| 'reconnectPersistedTerminals'
| 'setTerminalStartupRestorationReady'
| 'setDeferredSshReconnectTargets'
| 'removeDeferredSshReconnectTarget'
| 'setSshConnectionState'
| 'hydratePersistedUI'
| 'setHydrationSucceeded'
@@ -59,6 +60,8 @@ export function selectStartupActions(state: StartupActions): StartupActions {
cachedStartupActions.setTerminalStartupRestorationReady ===
state.setTerminalStartupRestorationReady &&
cachedStartupActions.setDeferredSshReconnectTargets === state.setDeferredSshReconnectTargets &&
cachedStartupActions.removeDeferredSshReconnectTarget ===
state.removeDeferredSshReconnectTarget &&
cachedStartupActions.setSshConnectionState === state.setSshConnectionState &&
cachedStartupActions.hydratePersistedUI === state.hydratePersistedUI &&
cachedStartupActions.setHydrationSucceeded === state.setHydrationSucceeded &&
@@ -91,6 +94,7 @@ export function selectStartupActions(state: StartupActions): StartupActions {
reconnectPersistedTerminals: state.reconnectPersistedTerminals,
setTerminalStartupRestorationReady: state.setTerminalStartupRestorationReady,
setDeferredSshReconnectTargets: state.setDeferredSshReconnectTargets,
removeDeferredSshReconnectTarget: state.removeDeferredSshReconnectTarget,
setSshConnectionState: state.setSshConnectionState,
hydratePersistedUI: state.hydratePersistedUI,
setHydrationSucceeded: state.setHydrationSucceeded,
@@ -19,6 +19,7 @@ import {
} from '../startup/startup-diagnostics'
import { recoverFromDegradedStartup } from '../startup/startup-degraded-recovery'
import { restoreSshConnectionsForStartup } from '../startup/startup-ssh-connection-restore'
import { collectActiveWorkspaceSshTargetIds } from '../startup/active-workspace-ssh-targets'
import { publishTerminalViewAttributesAtAppStart } from '../components/terminal-pane/terminal-appearance'
import { getSystemPrefersDark } from '../lib/terminal-theme'
import {
@@ -155,9 +156,12 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
// Why: disconnected SSH repos hydrate from local metadata; only runtime-owned repos use placeholders.
parseExecutionHostId(getRepoExecutionHostId(repo))?.kind !== 'runtime'
)
// Why: worktree refresh can spawn host Git; wait for main's shell-PATH generation fence first.
await timeRendererStartupStep('first-window-services-await', () =>
window.api.app.awaitFirstWindowStartupServices()
// Why this barrier and not the first-window one: worktree refresh can spawn host Git,
// which needs the shell-PATH generation and the managed WSL CLI registration. It never
// needs the daemon PTY provider or the hook-server bind, and `prepare-terminal-startup-restoration`
// below still fences those before any terminal is restored.
await timeRendererStartupStep('git-environment-barrier-await', () =>
window.api.app.awaitGitEnvironmentStartupBarrier()
)
await timeRendererStartupStep('fetch-hydration-worktrees', () =>
mapWithConcurrency(hydrationRepos, WORKTREE_REFRESH_CONCURRENCY, (repo) =>
@@ -198,7 +202,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
actions.hydrateBrowserSession(sessionRead.session, sessionHydrationOptions)
reconcileHydratedWorkspaceTabModels(
sessionRead.session,
useAppStore.getState().reconcileWorktreeTabModel
useAppStore.getState().reconcileWorktreeTabModels
)
})
await timeRendererStartupStep('prepare-terminal-startup-restoration', () =>
@@ -213,9 +217,14 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
actions.pruneLastVisitedTimestamps()
actions.seedActiveWorktreeLastVisitedIfMissing()
})
await timeRendererStartupStep('fetch-browser-session-profiles', () =>
// Why started here but not awaited: on a remote runtime this is an RPC with a 15s
// timeout, and nothing between here and terminal restoration reads the profile list —
// awaiting it put that timeout on the terminal-restoration gate. Starting it at the
// original point keeps the profiles landing no later than they did before; the action
// swallows its own failures, so the `.catch` only marks the timing wrapper handled.
void timeRendererStartupStep('fetch-browser-session-profiles', () =>
actions.fetchBrowserSessionProfiles()
)
).catch(() => {})
const onboardingState = await onboardingPromise
if (!cancelled) {
onOnboardingLoadedRef.current(onboardingState)
@@ -228,9 +237,17 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
)
if (connectionIds.length > 0) {
try {
// Why scoped: an unreachable host used to hold every restored terminal — local ones
// included — for the full reconnect timeout. Only the targets whose panes mount as
// soon as the gate opens are worth waiting for; the rest reattach on tab focus.
const blockingConnectionIds = collectActiveWorkspaceSshTargetIds(
useAppStore.getState()
)
await restoreSshConnectionsForStartup({
connectionIds,
blockingConnectionIds,
setDeferredSshReconnectTargets: actions.setDeferredSshReconnectTargets,
removeDeferredSshReconnectTarget: actions.removeDeferredSshReconnectTarget,
publishSshConnectionState: actions.setSshConnectionState
})
} catch (err) {
@@ -240,7 +257,8 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
logRendererStartupDiagnostic('ssh-reconnect-skipped', { connectionIds: 0 })
}
// first-window-services-await already fenced worktree hydration; terminal recovery reuses that ready state.
// Why no explicit barrier here: prepare-terminal-startup-restoration above already awaited
// the first-window services, and main re-awaits them inside this handler anyway.
await timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect', () =>
window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()
)
@@ -269,6 +287,16 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
// Why (issue #1158): unlock the session writer only after hydration and all dependent steps succeeded, so a mid-startup throw can't serialize partially-mutated state to disk.
actions.setHydrationSucceeded(true)
actions.setTerminalStartupRestorationReady(true)
// Why the explicit opt-in: unconditional seeding hijacks every empty dev
// profile's active workspace, making onboarding/empty-state flows untestable.
if (
import.meta.env.DEV &&
String(import.meta.env.VITE_ACTIVITY_DEV_FIXTURE).toLowerCase() === 'true'
) {
const { seedDevActivityFixture } =
await import('../components/activity/dev-activity-fixture')
seedDevActivityFixture()
}
logRendererStartupDiagnostic('startup-hydration-done', {
durationMs: Math.round(performance.now() - startupStartedAt)
})
@@ -167,7 +167,11 @@ export function usePersistedUIWriter(): void {
// paths in agent-status.ts (close/dismiss) flow to disk through map identity changes.
// Without persisting, agent rows that survive restart come back bold even when the
// user had already visited them.
acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey
acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey,
// Why: "Clear completed" must survive restart, or cleared done/interrupted rows return.
activityClearedAtByPaneKey: s.activityClearedAtByPaneKey,
// Why: an explicit "mark unread" must survive restart, or the row comes back read.
manuallyUnreadTurnsByPaneKey: s.manuallyUnreadTurnsByPaneKey
}))
)
useEffect(() => {
+20 -5
View File
@@ -68,8 +68,11 @@ describe('renderer startup runtime routing', () => {
const hydrationWorktreesIndex = source.indexOf(
"timeRendererStartupStep('fetch-hydration-worktrees'"
)
const servicesIndex = source.indexOf(
"timeRendererStartupStep('first-window-services-await'",
// Why this barrier: worktree hydration can spawn host Git, so it must sit behind the
// shell-PATH + managed-WSL fence. On packaged Windows the window opens before
// shellPathReady resolves, so this really is the fence, not a formality.
const gitEnvironmentBarrierIndex = source.indexOf(
"timeRendererStartupStep('git-environment-barrier-await'",
sessionIndex
)
const fullWorktreesIndex = source.indexOf('await actions.fetchAllWorktrees()')
@@ -89,8 +92,11 @@ describe('renderer startup runtime routing', () => {
expect(localReposIndex).toBeLessThan(localGroupsIndex)
expect(localGroupsIndex).toBeLessThan(localFoldersIndex)
expect(localReposIndex).toBeLessThan(sessionIndex)
expect(sessionIndex).toBeLessThan(servicesIndex)
expect(servicesIndex).toBeLessThan(hydrationWorktreesIndex)
expect(sessionIndex).toBeLessThan(gitEnvironmentBarrierIndex)
expect(gitEnvironmentBarrierIndex).toBeLessThan(hydrationWorktreesIndex)
expect(source.slice(gitEnvironmentBarrierIndex, hydrationWorktreesIndex)).toContain(
'window.api.app.awaitGitEnvironmentStartupBarrier()'
)
const hydrationWorktreeBlock = source.slice(
hydrationWorktreesIndex,
source.indexOf('await keybindingsPromise')
@@ -180,7 +186,13 @@ describe('renderer startup runtime routing', () => {
it('waits for first-window startup services before terminal reconnect', () => {
const source = readSource(STARTUP_HYDRATION_PATH)
const servicesIndex = source.indexOf("timeRendererStartupStep('first-window-services-await'")
// Why this step: `app:prepareTerminalStartupRestoration` awaits
// firstWindowStartupServicesReady + managedWslCliStartupBarrierReady in main before it
// does anything else, so it is the renderer-side position of that fence.
// `desktop-startup-ordering.test.ts` pins the main-side await itself.
const servicesIndex = source.indexOf(
"timeRendererStartupStep('prepare-terminal-startup-restoration'"
)
const preReconnectRecoveryIndex = source.indexOf(
"timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect'"
)
@@ -193,6 +205,9 @@ describe('renderer startup runtime routing', () => {
)
expect(servicesIndex).toBeGreaterThanOrEqual(0)
expect(source.slice(servicesIndex)).toContain(
'window.api.app.prepareTerminalStartupRestoration()'
)
expect(preReconnectRecoveryIndex).toBeGreaterThan(servicesIndex)
expect(capabilityRefreshIndex).toBeGreaterThan(preReconnectRecoveryIndex)
expect(reconnectIndex).toBeGreaterThan(capabilityRefreshIndex)
+3 -1
View File
@@ -1956,7 +1956,9 @@ html.native-shell .app-layout {
transform 120ms cubic-bezier(0.2, 0.8, 0.2, 1),
width 120ms cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 80ms ease-out;
will-change: transform, width, opacity;
/* Why no `width`: it is not compositable, so hinting it only pins a layer that
has to be re-rastered every frame of the transition anyway. */
will-change: transform, opacity;
}
[data-workspace-board-card-drop-indicator='true']::before,
@@ -399,7 +399,7 @@ describe('buildActivityEvents', () => {
expect(threads[0].events[0].entry.prompt).toBe('Retained prior run')
})
it('groups visible threads by current status order', () => {
it('groups visible threads with attention states before working and done', () => {
const repo = makeRepo()
const worktree = makeWorktree()
const workingTab = makeTab()
@@ -442,11 +442,49 @@ describe('buildActivityEvents', () => {
})
)
expect(groups.map((group) => group.id)).toEqual(['working', 'blocked', 'done'])
expect(groups.map((group) => group.id)).toEqual(['blocked', 'working', 'done'])
expect(groups.map((group) => group.threads.map((thread) => thread.paneKey))).toEqual([
[PANE_KEY],
[PANE_KEY_2],
[PANE_KEY],
[PANE_KEY_3]
])
})
it('merges runtime orchestration context into activity events and entries', () => {
const repo = makeRepo()
const worktree = makeWorktree()
const tab1 = makeTabWithIds('tab-1', worktree.id)
const tab2 = makeTabWithIds('tab-2', worktree.id)
const result = buildActivityEvents({
agentStatusByPaneKey: {
[PANE_KEY]: makeWorkingEntryWithoutHistory(),
[PANE_KEY_2]: {
...makeWorkingEntryWithoutHistory(),
paneKey: PANE_KEY_2,
terminalHandle: 'terminal-child'
}
},
runtimeAgentOrchestrationByPaneKey: {
[PANE_KEY_2]: {
parentPaneKey: PANE_KEY,
parentTerminalHandle: 'terminal-parent',
taskId: 'task-counsel',
dispatchId: 'ctx-counsel'
}
},
retainedAgentsByPaneKey: {},
tabsByWorktree: {
[worktree.id]: [tab1, tab2]
},
worktreeMap: new Map([[worktree.id, worktree]]),
repoMap: new Map([[repo.id, repo]]),
acknowledgedAgentsByPaneKey: {},
now: 5_000
})
expect(result.liveAgentByPaneKey[PANE_KEY_2].entry.orchestration?.parentPaneKey).toBe(PANE_KEY)
expect(result.liveAgentByPaneKey[PANE_KEY_2].entry.orchestration?.parentTerminalHandle).toBe(
'terminal-parent'
)
})
})

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