Merge origin/main into entrypoint routing recovery

This commit is contained in:
Merge Sim
2026-09-02 21:06:27 -07:00
391 changed files with 18256 additions and 2443 deletions
+77 -1
View File
@@ -138,8 +138,34 @@ index 8c4fca9022a6d6f015bca87f61625cde2278f428..0a01730616488119aa21ef441cf3c441
process.exit(0);
//# sourceMappingURL=conpty_console_list_agent.js.map
\ No newline at end of file
diff --git a/lib/terminal.js b/lib/terminal.js
index e2f9bc9131077b53ebc32d207207ad82804ff185..6c63bfaaf75128d88f9a2efece13476348780cfd 100644
--- a/lib/terminal.js
+++ b/lib/terminal.js
@@ -172,6 +172,21 @@ var Terminal = /** @class */ (function () {
this.end = function () { };
this._writable = false;
this._readable = false;
+ // Orca: libuv closes the master fd on EIO/EOF, and the kernel may hand
+ // that number straight to the next open(2). Retire it in the same block
+ // that gives up the handle so no later ioctl can address a reused fd.
+ // Inert on Windows, where `_fd` is written once and never read back.
+ // Upstream named this mechanism in microsoft/node-pty#220 ("fd number got
+ // reattached to something else"), closed 2025-12-19 as completed after
+ // only improving the error message; #827 is still open. Windows guards in
+ // windowsPtyAgent.ts, Unix does not. Orca tracking: #18109.
+ this._fd = -1;
+ // Orca: the write stream holds its own copy of that number, so retiring
+ // `_fd` alone leaves the queued and in-flight writes addressing it.
+ // Undefined on Windows and on `UnixTerminal.open()` handles.
+ if (this._writeStream) {
+ this._writeStream.dispose();
+ }
};
Terminal.prototype._parseEnv = function (env) {
var keys = Object.keys(env || {});
diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js
index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088bf1865877 100644
index 1ec12f796a822c78fba9ad7f6448c3987e325c23..d838d795ecb9ea72e3bcc31113344947c006af7e 100644
--- a/lib/unixTerminal.js
+++ b/lib/unixTerminal.js
@@ -28,8 +28,12 @@ var native = utils_1.loadNativeModule('pty');
@@ -157,6 +183,56 @@ index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088b
var DEFAULT_FILE = 'sh';
var DEFAULT_NAME = 'xterm';
var DESTROY_SOCKET_TIMEOUT_MS = 200;
@@ -234,6 +238,11 @@ var UnixTerminal = /** @class */ (function (_super) {
* Gets the name of the process.
*/
get: function () {
+ // Orca: tcgetpgrp on a retired fd would name whatever process now
+ // owns that descriptor, so a closed master reports the spawn file.
+ if (this._fd < 0) {
+ return this._file;
+ }
if (process.platform === 'darwin') {
var title = pty.process(this._fd);
return (title !== 'kernel_task') ? title : this._file;
@@ -250,6 +259,11 @@ var UnixTerminal = /** @class */ (function (_super) {
if (cols <= 0 || rows <= 0 || isNaN(cols) || isNaN(rows) || cols === Infinity || rows === Infinity) {
throw new Error('resizing must be done using positive cols and rows');
}
+ // Orca: a retired master is unreachable rather than EBADF-or-worse; cols
+ // and rows stay at the last size actually applied instead of a claim.
+ if (this._fd < 0) {
+ return;
+ }
pty.resize(this._fd, cols, rows);
this._cols = cols;
this._rows = rows;
@@ -287,8 +301,15 @@ var CustomWriteStream = /** @class */ (function () {
CustomWriteStream.prototype.dispose = function () {
clearImmediate(this._writeImmediate);
this._writeImmediate = undefined;
+ // Orca: retire this stream's own copy of the master fd and drop what has
+ // not shipped, so nothing queued here reaches a reused descriptor.
+ this._fd = -1;
+ this._writeQueue.length = 0;
};
CustomWriteStream.prototype.write = function (data) {
+ if (this._fd < 0) {
+ return;
+ }
// Writes are put in a queue and processed asynchronously in order to handle
// backpressure from the kernel buffer.
var buffer = typeof data === 'string'
@@ -304,7 +325,8 @@ var CustomWriteStream = /** @class */ (function () {
CustomWriteStream.prototype._processWriteQueue = function () {
var _this = this;
this._writeImmediate = undefined;
- if (this._writeQueue.length === 0) {
+ // Orca: an in-flight fs.write can re-enter here after dispose().
+ if (this._fd < 0 || this._writeQueue.length === 0) {
return;
}
var task = this._writeQueue[0];
diff --git a/src/conpty_console_list_agent.ts b/src/conpty_console_list_agent.ts
index 181ccabbbe9c4948a9725fb1db907a68e9de01fc..67f31facf85562b67adbfbd04ce28ddd8eeb4a79 100644
--- a/src/conpty_console_list_agent.ts
+2
View File
@@ -26,7 +26,9 @@ export const PR_E2E_SOURCE_ROUTES = [
specs: [
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-docker-half-open-link.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
'tests/e2e/ssh-docker-resource-accumulation.spec.ts',
'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts',
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
+27 -9
View File
@@ -33,15 +33,31 @@ if (runtime.status !== 0) {
// all. Recorded as a real gap, not as coverage living somewhere else.
// ssh-codex-display-artifacts-repro.spec.ts — installs a real remote codex binary that CI
// runners do not have (observed as `spawn codex ENOENT`). Runs in no CI lane at all.
// ssh-docker-bulk-open-freeze-repro.spec.ts — two reasons, both disqualifying:
// (a) it is a perf oracle, not a correctness one: SOFT_FREEZE_LAG_MS=2500 /
// HARD_FREEZE_LAG_MS=5000 measured by a renderer lag probe under a deliberate
// 5-pane output flood on a 420s budget. Same rule as ssh-docker-relay-perf above.
// (b) it is ROTTED: four call sites are out of date against terminal.ts's current
// helpers — execInTerminal gained a ptyId parameter and splitActiveTerminalPane
// gained a direction, so it cannot compile, let alone pass. Repairing it needs two
// semantic decisions (which ptyId to capture, which split direction) that change
// what the repro measures. Tracked in stablyai/orca#16764.
// ssh-docker-bulk-open-freeze-repro.spec.ts — un-rotted and now measurable, and marked
// `test.fixme` because its oracle cannot gate. Absent from this list AND skipped, so the
// two cannot drift: it is also reachable from the changed-specs lane whenever the spec
// itself is edited, and a wall-clock oracle that fails there is worth no more than one
// that fails here.
// The rot (#16764) is fixed: the stale call sites are repaired, it connects after session
// restore instead of before, and readiness keys on the repeating flood marker rather than
// a one-shot READY line the flood buries within ~16ms. It runs end to end and prints a
// measurement instead of dying on a call site.
// What it is NOT is portable. Three runs of the same measurement path:
// developer workstation: hiddenFlood 2.1ms bulkOpen 41.5ms interaction 53.6ms
// GitHub ubuntu runner A: hiddenFlood 1.5ms bulkOpen 2575.6ms interaction 3464.2ms
// GitHub ubuntu runner B: hiddenFlood 0.2ms bulkOpen 397.4ms interaction 3386.7ms
// bulkOpen swings 6.5x between two CI runs of the same code, so a fixed threshold on it is
// a coin flip; interaction sits stably ~64x over the workstation figure because it times a
// view remount, not the renderer freeze the issue reports, and only shares the budget
// constant because both are milliseconds. Every failure so far is the soft budget; hard
// has never tripped, and the relay was still streaming each time — the budget failed, not
// the product. Same rule as ssh-docker-relay-perf above. Gating needs a distribution
// first, then a host-relative oracle; a bigger constant, or a ratio picked from three
// samples, is the same arbitrary number in different clothes.
// COVERAGE GAP, recorded as such: 5 simultaneously flooding SSH panes exercise writer
// saturation, ACK/credit accounting and per-pane polling together, and nothing else covers
// that combination. Flip `test.fixme` back to `test` to run it. Tracked in
// stablyai/orca#16764.
//
// Why both projects: ssh-port-forward-lifecycle is @headful, which the headless project
// grep-inverts away.
@@ -71,8 +87,10 @@ 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-half-open-link.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-resource-accumulation.spec.ts',
'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts',
'tests/e2e/ssh-external-image-preview.spec.ts',
'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts',
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 36m">
<title>downloads: 36m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 37m">
<title>downloads: 37m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">36m</text>
<text x="90" y="14">36m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">37m</text>
<text x="90" y="14">37m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

+2 -2
View File
@@ -36,7 +36,7 @@
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk)
---
+2 -2
View File
@@ -40,7 +40,7 @@
Surveillez et pilotez vos agents depuis votre téléphone — soyez notifié quand un agent termine, et envoyez des instructions de suivi où que vous soyez.
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -235,7 +235,7 @@ yay -S stably-orca-bin
Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votre téléphone.
- **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [rejoindre TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android :** [Télécharger l'APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
- **Android :** [Télécharger l'APK 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) 또는 [TestFlight 참여](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [APK 0.0.46 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
- **Android:** [APK 0.0.47 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Baixar APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
- **Android:** [Baixar APK 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
与桌面应用配对,用手机监控并指挥你的智能体。
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk)
---
+1 -1
View File
@@ -13,7 +13,7 @@ Two consequences, both non-negotiable:
The vocabulary is fixed: **`live` / `unverifiable` / `exited`**, taken from the incumbent `UnstoppedPtyVerdict`. Do not introduce synonyms, and never collapse `unverifiable` into either neighbour. `exited` requires positive evidence of absence from the host that owns the process; a transport failure can only ever produce `unverifiable`.
Rule 1 is stated at `src/main/source-control/repo-default-branch.ts:76-78`, `src/main/repo-worktrees.ts:45-48`, `OrcaRuntimeService.probeWorktreeDrift` in `src/main/runtime/orca-runtime.ts`, and `src/renderer/src/lib/connection-context.ts:22-24`. It is enforced throughout `src/main/runtime/orca-runtime-git.ts` by the guard that throws `SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE` whenever `target.connectionId` is set and no provider is registered — grep that constant for the current call sites rather than trusting a count.
Rule 1 is stated at `src/main/source-control/repo-default-branch.ts:76-78`, `src/main/repo-worktrees.ts:45-48`, `OrcaRuntimeService.probeWorktreeDrift` in `src/main/runtime/orca-runtime.ts`, and `src/renderer/src/lib/connection-context.ts:22-24`. It is enforced throughout `src/main/runtime/orca-runtime-git.ts` by `requireRuntimeGitProvider` in `src/main/runtime/runtime-git-command-target.ts`, and throughout the runtime filesystem commands by `requireRuntimeFileProvider` in `src/main/runtime/runtime-file-command-target.ts`. Both route on the target's resolved `executionHostId` rather than on a repo row's `connectionId`: they throw the provider-unavailable message when an SSH host has no registered provider, throw `ExecutionHostNotDispatchableError` for a `runtime:` host this process does not execute, and return `null` only for `local`. Grep those names for the current call sites rather than trusting a count.
`src/main/runtime/unstopped-pty-verification.ts:12-16` is the reference implementation of rule 2: it keeps `live` / `unverifiable` / `exited` as three distinct verdicts, and treats "we could not ask" as its own answer.
+40 -1
View File
@@ -30,7 +30,9 @@ vi.mock('lucide-react-native', () => ({
ChevronDown: 'ChevronDown',
ChevronRight: 'ChevronRight',
GitBranch: 'GitBranch',
GitPullRequest: 'GitPullRequest'
GitPullRequest: 'GitPullRequest',
Monitor: 'Monitor',
Server: 'Server'
}))
vi.mock('../platform/haptics', () => ({ triggerMediumImpact: vi.fn() }))
@@ -225,4 +227,41 @@ describe('memoized worktree rows', () => {
workingMode: 'monitoring'
})
})
it('names the host with a glyph that matches the host kind', async () => {
const textNodes = (): string[] =>
renderer!.root
.findAllByType('Text' as never)
.flatMap((node) => node.props.children)
.filter((child): child is string => typeof child === 'string')
await act(async () => {
renderer = create(
createElement(ListRowHarness, {
item: { ...baseItem, hostId: 'ssh:ssh-1', hostContextLabel: 'openclaw' },
now: 2_000
})
)
})
expect(textNodes()).toContain('openclaw')
expect(renderer!.root.findAllByType('Server' as never)).toHaveLength(1)
expect(renderer!.root.findAllByType('Monitor' as never)).toHaveLength(0)
await act(async () =>
renderer!.update(
createElement(ListRowHarness, {
item: { ...baseItem, hostContextLabel: 'Local Mac' },
now: 2_000
})
)
)
expect(textNodes()).toContain('Local Mac')
expect(renderer!.root.findAllByType('Monitor' as never)).toHaveLength(1)
await act(async () =>
renderer!.update(createElement(ListRowHarness, { item: baseItem, now: 2_000 }))
)
expect(textNodes()).not.toContain('Local Mac')
expect(renderer!.root.findAllByType('Monitor' as never)).toHaveLength(0)
})
})
+36 -1
View File
@@ -1,6 +1,15 @@
import { memo } from 'react'
import { Bell, ChevronDown, ChevronRight, GitBranch, GitPullRequest } from 'lucide-react-native'
import {
Bell,
ChevronDown,
ChevronRight,
GitBranch,
GitPullRequest,
Monitor,
Server
} from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { parseExecutionHostId, type ExecutionHostId } from '../../../src/shared/execution-host'
import type { RepoIcon } from '../../../src/shared/repo-icon'
import type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
@@ -22,6 +31,11 @@ function displayBranch(branch: string): string {
export type WorktreeListRowItem = {
workspaceKind?: 'git' | 'folder-workspace'
worktreeId: string
hostId?: ExecutionHostId
/** Present only when the list spans hosts; names the host this row runs on. */
hostContextLabel?: string
/** Resolved host for the display label; present when legacy rows omit hostId. */
hostContextHostId?: ExecutionHostId
repo: string
branch: string
displayName: string
@@ -150,6 +164,20 @@ function WorktreeListRowComponent<T extends WorktreeListRowItem>({
<Text style={styles.childBadgeText}>Child</Text>
</View>
)}
{item.hostContextLabel ? (
<View style={[styles.childBadge, styles.hostBadge]}>
{/* Rows from hosts that predate hostId stamping are local: a remote row always carries one. */}
{(parseExecutionHostId(item.hostContextHostId ?? item.hostId)?.kind ?? 'local') ===
'local' ? (
<Monitor size={10} color={colors.textMuted} />
) : (
<Server size={10} color={colors.textMuted} />
)}
<Text style={[styles.childBadgeText, styles.hostBadgeText]} numberOfLines={1}>
{item.hostContextLabel}
</Text>
</View>
) : null}
{/* Repo glyph+name only when not already grouped under this repo;
MobileRepoIcon falls back to a Folder (matching desktop's default)
rather than a bare colored dot. */}
@@ -306,6 +334,13 @@ const styles = StyleSheet.create({
fontSize: 10,
color: colors.textMuted
},
hostBadge: {
flexShrink: 1,
maxWidth: 140
},
hostBadgeText: {
flexShrink: 1
},
lineageToggle: {
alignSelf: 'flex-start',
flexDirection: 'row',
@@ -1,13 +1,54 @@
import { useCallback } from 'react'
import { getRepoExecutionHostId } from '../../../src/shared/execution-host'
import { setCachedRepos } from '../cache/repo-cache'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState, RpcSuccess } from '../transport/types'
import type { RepoSummary } from '../worktree/host-worktree-rpc-types'
import { repoColor } from '../worktree/repo-color'
import {
buildHostLabelById,
buildRepoHostIdByRepoId
} from '../worktree/worktree-host-context-labels'
import type { HostScreenState } from './use-host-screen-state'
const REPO_METADATA_REFRESH_MS = 60_000
type SshTargetSummaryRow = { id: string; label: string }
async function requestResult(client: RpcClient, method: string): Promise<unknown> {
try {
const response = await client.sendRequest(method)
return response.ok ? (response as RpcSuccess).result : null
} catch {
// Best-effort: hosts that predate a method still list repos; labels degrade to host ids.
return null
}
}
function readSshTargets(result: unknown): SshTargetSummaryRow[] {
const targets = (result as { targets?: unknown } | null)?.targets
if (!Array.isArray(targets)) {
return []
}
return targets.filter(
(target): target is SshTargetSummaryRow =>
typeof target === 'object' &&
target !== null &&
typeof (target as SshTargetSummaryRow).id === 'string' &&
typeof (target as SshTargetSummaryRow).label === 'string'
)
}
function readHostPlatform(result: unknown): NodeJS.Platform | null {
const platform = (result as { platform?: unknown } | null)?.platform
return typeof platform === 'string' && platform ? (platform as NodeJS.Platform) : null
}
function readHostSettingOverrides(result: unknown): unknown {
return (result as { settings?: { hostSettingOverrides?: unknown } } | null)?.settings
?.hostSettingOverrides
}
export function useHostRepoMetadata(args: {
client: RpcClient | null
connState: ConnectionState
@@ -20,7 +61,10 @@ export function useHostRepoMetadata(args: {
fetchRepoMetadataInFlightRef,
fetchRepoMetadataPendingRef,
repoMetadataFetchedAtRef,
setHostLabelById,
setHostPlatform,
setRepoColorsByName,
setRepoHostIdByRepoId,
setRepoIconsByName,
setRepoIdsByName
} = state
@@ -69,6 +113,28 @@ export function useHostRepoMetadata(args: {
)
)
setRepoIdsByName(new Map(repoResult.repos.map((repo) => [repo.displayName, repo.id])))
setRepoHostIdByRepoId(buildRepoHostIdByRepoId(repoResult.repos))
// Why: rows only name their host when the list spans hosts, so a single-host
// catalog never pays for the label lookups. Counted over repos, not the id-keyed
// map: one repo id registered on two hosts is two hosts.
const hostIds = new Set(repoResult.repos.map((repo) => getRepoExecutionHostId(repo)))
if (hostIds.size > 1) {
const [sshTargets, hostSettings, hostPlatform] = await Promise.all([
requestResult(requestClient, 'ssh.listTargetSummaries'),
requestResult(requestClient, 'settings.get'),
requestResult(requestClient, 'host.platform')
])
if (clientRef.current !== requestClient || hostId !== requestHostId) {
return
}
setHostLabelById(
buildHostLabelById({
sshTargets: readSshTargets(sshTargets),
hostSettingOverrides: readHostSettingOverrides(hostSettings)
})
)
setHostPlatform(readHostPlatform(hostPlatform))
}
} while (fetchRepoMetadataPendingRef.current.has(requestClient))
} catch {
// Repo metadata is decorative; the next refresh can retry.
@@ -14,6 +14,7 @@ import {
useRelayRecoveryStatus
} from '../transport/client-context-connection-metrics'
import { applyWorktreeRowDisplayState } from '../worktree/worktree-host-row-identity'
import { applyWorktreeHostContextLabels } from '../worktree/worktree-host-context-labels'
import { useWorkspaceSections } from '../worktree/use-workspace-sections'
import { useHostRepoMetadata } from './use-host-repo-metadata'
import { useHostScreenIdentity } from './use-host-screen-identity'
@@ -95,17 +96,23 @@ export function useHostScreenController({
// Why: live `worktrees` is authoritative only while connected; under the amber
// mount default, connecting/handshaking must keep the pre-reconnect list too.
const base = connState === 'connected' ? state.worktrees : state.lastKnownWorktrees
return applyWorktreeRowDisplayState(
base,
state.sleptIds,
state.optimisticActiveWorktreeIdentity
return applyWorktreeHostContextLabels(
applyWorktreeRowDisplayState(base, state.sleptIds, state.optimisticActiveWorktreeIdentity),
{
repoHostIdByRepoId: state.repoHostIdByRepoId,
hostLabelById: state.hostLabelById,
hostPlatform: state.hostPlatform
}
)
}, [
connState,
state.worktrees,
state.lastKnownWorktrees,
state.sleptIds,
state.optimisticActiveWorktreeIdentity
state.optimisticActiveWorktreeIdentity,
state.repoHostIdByRepoId,
state.hostLabelById,
state.hostPlatform
])
const sectionsResult = useWorkspaceSections({
displayWorktrees,
@@ -17,10 +17,13 @@ export function useHostScreenIdentity(args: {
repoMetadataFetchedAtRef,
setCatalogError,
setError,
setHostLabelById,
setHostName,
setHostPlatform,
setLastKnownWorktrees,
setPinnedIds,
setRepoColorsByName,
setRepoHostIdByRepoId,
setRepoIconsByName,
setWorktrees,
setWorktreesLoaded
@@ -54,6 +57,9 @@ export function useHostScreenIdentity(args: {
setError('')
setRepoColorsByName(new Map())
setRepoIconsByName(new Map())
setRepoHostIdByRepoId(new Map())
setHostLabelById(new Map())
setHostPlatform(null)
repoMetadataFetchedAtRef.current = 0
// Why: useState initializer runs only on first mount, so re-seed the cache when Expo Router reuses this screen for a new hostId.
const freshCache = hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null
@@ -1,4 +1,5 @@
import { useRef, useState } from 'react'
import type { ExecutionHostId } from '../../../src/shared/execution-host'
import type { RepoIcon } from '../../../src/shared/repo-icon'
import type { WorkspaceStatusDefinition } from '../../../src/shared/worktree/types'
import { getCachedWorktrees } from '../cache/worktree-cache'
@@ -56,6 +57,12 @@ export function useHostScreenState(hostId: string | undefined, action: string |
)
// displayName → repo id: filters key on repo id, but section headers/rows key on displayName, so bridge the two.
const [repoIdsByName, setRepoIdsByName] = useState<Map<string, string>>(new Map())
// Host-label inputs for rows: repo → host, SSH/override labels, and the host's own platform.
const [repoHostIdByRepoId, setRepoHostIdByRepoId] = useState<Map<string, ExecutionHostId>>(
new Map()
)
const [hostLabelById, setHostLabelById] = useState<Map<ExecutionHostId, string>>(new Map())
const [hostPlatform, setHostPlatform] = useState<NodeJS.Platform | null>(null)
const [showSortPicker, setShowSortPicker] = useState(false)
const [showGroupPicker, setShowGroupPicker] = useState(false)
const [showFilterModal, setShowFilterModal] = useState(false)
@@ -93,13 +100,16 @@ export function useHostScreenState(hostId: string | undefined, action: string |
fetchWorktreesInFlightRef,
filters,
groupMode,
hostLabelById,
hostName,
hostPlatform,
lastKnownWorktrees,
newWorktreeModalRef,
newWorktreeModalVisibleRef,
optimisticActiveWorktreeIdentity,
pinnedIds,
repoColorsByName,
repoHostIdByRepoId,
repoIconsByName,
repoIdsByName,
repoMetadataFetchedAtRef,
@@ -113,11 +123,14 @@ export function useHostScreenState(hostId: string | undefined, action: string |
setError,
setFilters,
setGroupMode,
setHostLabelById,
setHostName,
setHostPlatform,
setLastKnownWorktrees,
setOptimisticActiveWorktreeIdentity,
setPinnedIds,
setRepoColorsByName,
setRepoHostIdByRepoId,
setRepoIconsByName,
setRepoIdsByName,
setRouteActionState,
@@ -9,6 +9,10 @@ export type Worktree = {
repoId: string
hostId?: ExecutionHostId
terminalPlatform?: NodeJS.Platform
/** Display-only; set when the list spans hosts, so rows say which host they run on. */
hostContextLabel?: string
/** Resolved host for the display label; present when legacy rows omit hostId. */
hostContextHostId?: ExecutionHostId
repo: string
branch: string
displayName: string
@@ -0,0 +1,164 @@
import { describe, expect, it } from 'vitest'
import type { Worktree } from './workspace-list-types'
import {
applyWorktreeHostContextLabels,
buildHostLabelById,
buildRepoHostIdByRepoId,
getWorktreeHostContextLabels,
resolveWorktreeHostId
} from './worktree-host-context-labels'
function worktree(overrides: Partial<Worktree> = {}): Worktree {
return {
workspaceKind: 'git',
worktreeId: 'repo-1::/home/me/orca',
repoId: 'repo-1',
repo: 'orca',
branch: 'main',
displayName: 'main',
path: '/home/me/orca',
liveTerminalCount: 0,
hasAttachedPty: false,
preview: '',
unread: false,
isPinned: false,
linkedPR: null,
...overrides
}
}
const sshHostId = 'ssh:ssh-1785104650217-eduhep' as const
describe('buildHostLabelById', () => {
it('labels SSH targets by their registered label and lets a display override win', () => {
const labels = buildHostLabelById({
sshTargets: [
{ id: 'ssh-1785104650217-eduhep', label: 'openclaw' },
{ id: 'ssh-blank', label: ' ' }
],
hostSettingOverrides: { [sshHostId]: { displayLabel: 'openclaw (renamed)' } }
})
expect(labels.get(sshHostId)).toBe('openclaw (renamed)')
expect(labels.has('ssh:ssh-blank')).toBe(false)
})
it('normalizes legacy raw SSH ids used by persisted display overrides', () => {
const labels = buildHostLabelById({
sshTargets: [],
hostSettingOverrides: { 'ssh-1785104650217-eduhep': { displayLabel: 'openclaw' } }
})
expect(labels.get(sshHostId)).toBe('openclaw')
})
it('accepts canonical SSH host ids from newer target-summary payloads', () => {
const labels = buildHostLabelById({
sshTargets: [{ id: sshHostId, label: 'openclaw' }],
hostSettingOverrides: undefined
})
expect(labels.get(sshHostId)).toBe('openclaw')
expect(labels.has('ssh:ssh:ssh-1785104650217-eduhep')).toBe(false)
})
it('tolerates a malformed settings payload', () => {
expect(buildHostLabelById({ sshTargets: [], hostSettingOverrides: 'nope' }).size).toBe(0)
expect(buildHostLabelById({ sshTargets: [], hostSettingOverrides: undefined }).size).toBe(0)
})
})
describe('resolveWorktreeHostId', () => {
it('prefers the row host, then the repo host, then local', () => {
const repoHosts = buildRepoHostIdByRepoId([
{ id: 'repo-1', connectionId: 'ssh-1785104650217-eduhep' },
{ id: 'repo-2', executionHostId: 'runtime:env-1' },
{ id: 'repo-3' }
])
expect(resolveWorktreeHostId(worktree({ hostId: 'local', repoId: 'repo-1' }), repoHosts)).toBe(
'local'
)
expect(resolveWorktreeHostId(worktree({ repoId: 'repo-1' }), repoHosts)).toBe(sshHostId)
expect(resolveWorktreeHostId(worktree({ repoId: 'repo-2' }), repoHosts)).toBe('runtime:env-1')
expect(resolveWorktreeHostId(worktree({ repoId: 'repo-3' }), repoHosts)).toBe('local')
expect(resolveWorktreeHostId(worktree({ repoId: 'unknown' }), repoHosts)).toBe('local')
})
})
describe('getWorktreeHostContextLabels', () => {
const sources = {
repoHostIdByRepoId: new Map(),
hostLabelById: new Map([[sshHostId, 'openclaw']]),
hostPlatform: 'darwin' as const
}
it('returns nothing for a single-host list', () => {
const rows = [worktree({ hostId: 'local' }), worktree({ hostId: 'local', worktreeId: 'b' })]
expect(getWorktreeHostContextLabels(rows, sources)).toBeUndefined()
expect(applyWorktreeHostContextLabels(rows, sources)).toBe(rows)
})
it('names every row by host once the list spans hosts', () => {
const rows = [
worktree({ hostId: 'local', worktreeId: 'a' }),
worktree({ hostId: sshHostId, worktreeId: 'b' }),
worktree({ hostId: 'ssh:unlabeled', worktreeId: 'c' }),
worktree({ hostId: 'runtime:env-1', worktreeId: 'd' })
]
const labeled = applyWorktreeHostContextLabels(rows, sources)
expect(labeled.map((row) => row.hostContextLabel)).toEqual([
'Local Mac',
'openclaw',
'unlabeled',
'env-1'
])
})
it('names the local host from the paired host platform, not the phone', () => {
const rows = [
worktree({ hostId: 'local', worktreeId: 'a' }),
worktree({ hostId: sshHostId, worktreeId: 'b' })
]
const linux = applyWorktreeHostContextLabels(rows, { ...sources, hostPlatform: 'linux' })
expect(linux[0].hostContextLabel).toBe('Local Linux')
const unknown = applyWorktreeHostContextLabels(rows, { ...sources, hostPlatform: null })
expect(unknown[0].hostContextLabel).toBe('This computer')
})
it('keys labels by host-qualified identity so a shared id on two hosts gets two labels', () => {
const rows = [
worktree({ hostId: 'local', worktreeId: 'same' }),
worktree({ hostId: sshHostId, worktreeId: 'same' })
]
const labeled = applyWorktreeHostContextLabels(rows, sources)
expect(labeled.map((row) => row.hostContextLabel)).toEqual(['Local Mac', 'openclaw'])
})
it('falls back to the repo host for rows from hosts that predate hostId stamping', () => {
const rows = [
worktree({ repoId: 'repo-local', worktreeId: 'a' }),
worktree({ repoId: 'repo-ssh', worktreeId: 'b' })
]
const labeled = applyWorktreeHostContextLabels(rows, {
...sources,
repoHostIdByRepoId: buildRepoHostIdByRepoId([
{ id: 'repo-local' },
{ id: 'repo-ssh', connectionId: 'ssh-1785104650217-eduhep' }
])
})
expect(labeled.map((row) => row.hostContextLabel)).toEqual(['Local Mac', 'openclaw'])
expect(labeled.map((row) => row.hostContextHostId)).toEqual(['local', sshHostId])
})
it('keeps labels distinct when legacy rows reuse an id across hosts', () => {
const rows = [
worktree({ repoId: 'repo-local', worktreeId: 'same' }),
worktree({ repoId: 'repo-ssh', worktreeId: 'same' })
]
const labeled = applyWorktreeHostContextLabels(rows, {
...sources,
repoHostIdByRepoId: buildRepoHostIdByRepoId([
{ id: 'repo-local' },
{ id: 'repo-ssh', connectionId: 'ssh-1785104650217-eduhep' }
])
})
expect(labeled.map((row) => row.hostContextLabel)).toEqual(['Local Mac', 'openclaw'])
})
})
@@ -0,0 +1,92 @@
import {
LOCAL_EXECUTION_HOST_ID,
getRepoExecutionHostId,
normalizeExecutionHostId,
type ExecutionHostId
} from '../../../src/shared/execution-host'
import { getMixedHostContextLabels as getSharedMixedHostContextLabels } from '../../../src/shared/worktree/host-context-labels'
import { composeWorktreeHostIdentity } from '../../../src/shared/worktree/host-qualified-identity'
export {
buildHostLabelById,
getHostContextLabel
} from '../../../src/shared/worktree/host-context-labels'
import type { RepoSummary } from './host-worktree-rpc-types'
import type { Worktree } from './workspace-list-types'
export type HostLabelSources = {
/** Host id per repo id from repo.list; rows from hosts that predate `hostId` fall back to it. */
repoHostIdByRepoId: ReadonlyMap<string, ExecutionHostId>
/** User-facing labels for non-local hosts: SSH target labels, then per-host display overrides. */
hostLabelById: ReadonlyMap<ExecutionHostId, string>
/** The paired host's own platform; the phone's platform must never name the desktop. */
hostPlatform: NodeJS.Platform | null
}
export function buildRepoHostIdByRepoId(
repos: readonly Pick<RepoSummary, 'id' | 'connectionId' | 'executionHostId'>[]
): Map<string, ExecutionHostId> {
return new Map(repos.map((repo) => [repo.id, getRepoExecutionHostId(repo)]))
}
export function resolveWorktreeHostId(
worktree: Pick<Worktree, 'hostId' | 'repoId'>,
repoHostIdByRepoId: ReadonlyMap<string, ExecutionHostId>
): ExecutionHostId {
return (
normalizeExecutionHostId(worktree.hostId) ??
repoHostIdByRepoId.get(worktree.repoId) ??
LOCAL_EXECUTION_HOST_ID
)
}
function getResolvedWorktreeRowIdentity(
worktree: Pick<Worktree, 'worktreeId' | 'hostId' | 'repoId'>,
repoHostIdByRepoId: ReadonlyMap<string, ExecutionHostId>
): string {
return composeWorktreeHostIdentity(
resolveWorktreeHostId(worktree, repoHostIdByRepoId),
worktree.worktreeId
)
}
// Kept as a local adapter so existing mobile imports remain stable.
/**
* Host label per row identity, only when the list spans more than one host — a single-host
* list gains nothing from a badge on every row. Mirrors the desktop sidebar's mixed-host rule.
*/
export function getWorktreeHostContextLabels(
worktrees: readonly Worktree[],
sources: HostLabelSources
): Map<string, string> | undefined {
return getSharedMixedHostContextLabels(worktrees, {
getHostId: (worktree) => resolveWorktreeHostId(worktree, sources.repoHostIdByRepoId),
// Legacy hosts omit row.hostId; key by the resolved repo owner so duplicate
// worktree ids from different hosts do not overwrite each other's label.
getIdentity: (worktree) => getResolvedWorktreeRowIdentity(worktree, sources.repoHostIdByRepoId),
sources
})
}
export function applyWorktreeHostContextLabels(
worktrees: Worktree[],
sources: HostLabelSources
): Worktree[] {
const labels = getWorktreeHostContextLabels(worktrees, sources)
if (!labels) {
return worktrees
}
return worktrees.map((worktree) => {
const hostContextLabel = labels.get(
getResolvedWorktreeRowIdentity(worktree, sources.repoHostIdByRepoId)
)
if (!hostContextLabel) {
return worktree
}
return {
...worktree,
hostContextLabel,
hostContextHostId: resolveWorktreeHostId(worktree, sources.repoHostIdByRepoId)
}
})
}
+3 -3
View File
@@ -115,7 +115,7 @@ patchedDependencies:
'@xterm/addon-webgl@0.20.0-beta.299': 94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e
'@xterm/xterm@6.1.0-beta.303': 98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d
lint-staged@16.4.0: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673
node-pty@1.1.0: 40b6b6b814c89a8a29c995495ea86cff2cf6766f42124b5702aedf4ce0565c0e
node-pty@1.1.0: e262847f57a1d4d3f2287a843822f7dcf3c9d8655892b07a69eba464e1317eaa
importers:
@@ -156,7 +156,7 @@ importers:
version: 3.3.1
node-pty:
specifier: ^1.1.0
version: 1.1.0(patch_hash=40b6b6b814c89a8a29c995495ea86cff2cf6766f42124b5702aedf4ce0565c0e)
version: 1.1.0(patch_hash=e262847f57a1d4d3f2287a843822f7dcf3c9d8655892b07a69eba464e1317eaa)
posthog-node:
specifier: ^5.33.3
version: 5.33.3
@@ -12194,7 +12194,7 @@ snapshots:
node-int64@0.4.0: {}
node-pty@1.1.0(patch_hash=40b6b6b814c89a8a29c995495ea86cff2cf6766f42124b5702aedf4ce0565c0e):
node-pty@1.1.0(patch_hash=e262847f57a1d4d3f2287a843822f7dcf3c9d8655892b07a69eba464e1317eaa):
dependencies:
node-addon-api: 7.1.1
+7 -3
View File
@@ -10,7 +10,7 @@ import type {
ProjectHostSetupUpdateArgs,
ProjectHostSetupUpdateResult
} from '../../shared/project-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import { getSshTargetIdForExecutionHost, type ExecutionHostId } from '../../shared/execution-host'
import type { RepoKind } from '../../shared/repo-types'
import type { CommandHandler, HandlerContext } from '../dispatch'
import {
@@ -111,10 +111,14 @@ export const PROJECT_HANDLERS: Record<string, CommandHandler> = {
},
'project setup-existing-folder': async ({ flags, client, cwd, json }) => {
const rawPath = getRequiredStringFlag(flags, 'path')
const hostId = getRequiredHostId(flags)
// An SSH host's filesystem is not the CLI's, so resolving a relative path against the client
// cwd would register a path that names the wrong machine.
const pathIsOffClient = client.isRemote || getSshTargetIdForExecutionHost(hostId) !== null
const args: ProjectHostSetupExistingFolderArgs = {
projectId: getRequiredStringFlag(flags, 'project'),
hostId: getRequiredHostId(flags),
path: resolveRepoPathArgument(rawPath, cwd, client.isRemote, 'Remote project setup'),
hostId,
path: resolveRepoPathArgument(rawPath, cwd, pathIsOffClient, 'Remote project setup'),
kind: getOptionalRepoKind(flags),
displayName: getOptionalStringFlag(flags, 'display-name')
}
+31
View File
@@ -481,6 +481,37 @@ describe('orca cli worktree awareness', () => {
process.exitCode = priorExitCode
})
it('rejects SSH project setup relative paths, which name the client filesystem', async () => {
// A local CLI reaching an `ssh:*` host is still off-client: resolving `./orca` against the
// CLI cwd would register a path that exists on the wrong machine.
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'project',
'setup-existing-folder',
'--project',
'github:stablyai/orca',
'--host',
'ssh:openclaw',
'--path',
'./orca',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Remote project setup requires --path to be an absolute path on the remote server.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects remote repo.add relative paths instead of resolving against client cwd', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -0,0 +1,216 @@
import { beforeEach, describe, expect, it } from 'vitest'
import type { FileReadResult } from '../providers/types'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { resetRemoteSessionParseCacheForTests } from './remote-session-parse-cache'
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures'
/**
* Counts whole-transcript reads, which is the cost #13753 is about. Codex's
* per-scan `session_index.jsonl` title lookup is one small file and is not part
* of the corpus term, so it is excluded rather than asserted on.
*/
class CountingRemoteProvider extends MemoryRemoteProvider {
readonly readFilePaths: string[] = []
override async readFile(filePath: string): Promise<FileReadResult> {
if (filePath.includes('/sessions/')) {
this.readFilePaths.push(filePath)
}
return await super.readFile(filePath)
}
}
function transcript(sessionId: string, title: string, timestamp: string): string {
return jsonLines([
{
timestamp,
type: 'session_meta',
payload: { id: sessionId, cwd: '/home/ada/repo' }
},
{
timestamp,
type: 'response_item',
payload: { type: 'message', role: 'user', content: [{ type: 'text', text: title }] }
}
])
}
function scan(provider: CountingRemoteProvider): ReturnType<typeof scanRemoteAiVaultSessions> {
return scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
}
describe('remote AI Vault transcript re-reads', () => {
beforeEach(() => {
resetRemoteSessionParseCacheForTests()
})
it('does not re-read an unchanged corpus on the next scan', async () => {
const provider = new CountingRemoteProvider()
for (const day of ['07/07', '07/25', '08/10']) {
provider.addFile(
`/home/ada/.codex/sessions/2026/${day}/rollout-${day.replace('/', '')}.jsonl`,
transcript(
`session-${day.replace('/', '')}`,
`Work from ${day}`,
'2026-07-07T01:00:00.000Z'
),
1_000
)
}
const first = await scan(provider)
expect(first.sessions).toHaveLength(3)
expect(provider.readFilePaths).toHaveLength(3)
provider.readFilePaths.length = 0
const second = await scan(provider)
// Historical transcripts are immutable; a second pass must cost zero reads.
expect(provider.readFilePaths).toEqual([])
expect(second.sessions.map((session) => session.title)).toEqual(
first.sessions.map((session) => session.title)
)
})
it('re-reads a transcript that actually changed', async () => {
const provider = new CountingRemoteProvider()
const path = '/home/ada/.codex/sessions/2026/08/31/rollout-live.jsonl'
provider.addFile(
path,
transcript('live-session', 'First prompt', '2026-08-31T01:00:00.000Z'),
1_000
)
await scan(provider)
provider.readFilePaths.length = 0
provider.addFile(
path,
transcript('live-session', 'Second prompt', '2026-08-31T02:00:00.000Z'),
2_000
)
const result = await scan(provider)
expect(provider.readFilePaths).toEqual([path])
expect(result.sessions[0]?.title).toBe('Second prompt')
})
it('re-reads when only the size changed under an unchanged mtime', async () => {
const provider = new CountingRemoteProvider()
const path = '/home/ada/.codex/sessions/2026/08/31/rollout-grown.jsonl'
provider.addFile(path, transcript('grown-session', 'Short', '2026-08-31T01:00:00.000Z'), 1_000)
await scan(provider)
provider.readFilePaths.length = 0
provider.addFile(
path,
transcript(
'grown-session',
'A much longer first prompt than before',
'2026-08-31T01:00:00.000Z'
),
1_000
)
const result = await scan(provider)
expect(provider.readFilePaths).toEqual([path])
expect(result.sessions[0]?.title).toBe('A much longer first prompt than before')
})
// Codex names threads in $CODEX_HOME/session_index.jsonl asynchronously, after
// the rollout's last append — so the transcript's mtime+size never changes to
// signal it. That file sits outside `sessions/`, hence outside the read count.
it('picks up a session_index title written after the transcript was cached', async () => {
const provider = new CountingRemoteProvider()
const path = '/home/ada/.codex/sessions/2026/08/31/rollout-named-later.jsonl'
provider.addFile(
path,
transcript('named-later-session', 'First prompt', '2026-08-31T01:00:00.000Z'),
1_000
)
provider.addFile(
'/home/ada/.codex/session_index.jsonl',
jsonLines([{ id: 'some-other-session', thread_name: 'Unrelated thread' }]),
1_000
)
const first = await scan(provider)
expect(first.sessions[0]?.title).toBe('First prompt')
provider.readFilePaths.length = 0
provider.addFile(
'/home/ada/.codex/session_index.jsonl',
jsonLines([
{ id: 'some-other-session', thread_name: 'Unrelated thread' },
{ id: 'named-later-session', thread_name: 'Named by Codex after the fact' }
]),
2_000
)
const second = await scan(provider)
expect(second.sessions[0]?.title).toBe('Named by Codex after the fact')
// The #13753 win is preserved: the index is read, the transcript is not.
expect(provider.readFilePaths).toEqual([])
})
it('does not serve a cached parse to a different execution host', async () => {
const provider = new CountingRemoteProvider()
const path = '/home/ada/.codex/sessions/2026/08/31/rollout-host.jsonl'
provider.addFile(
path,
transcript('host-session', 'Host scoped', '2026-08-31T01:00:00.000Z'),
1_000
)
await scan(provider)
provider.readFilePaths.length = 0
const other = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:other-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(provider.readFilePaths).toEqual([path])
expect(other.sessions[0]?.executionHostId).toBe('ssh:other-box')
})
it('does not cache a read that failed', async () => {
const provider = new CountingRemoteProvider()
const path = '/home/ada/.codex/sessions/2026/08/31/rollout-flaky.jsonl'
provider.addFile(
path,
transcript('flaky-session', 'Recovered', '2026-08-31T01:00:00.000Z'),
1_000
)
let failNextRead = true
const originalReadFile = provider.readFile.bind(provider)
provider.readFile = async (filePath: string): Promise<FileReadResult> => {
if (failNextRead && filePath === path) {
failNextRead = false
provider.readFilePaths.push(filePath)
throw new Error('EIO: transient relay read failure')
}
return await originalReadFile(filePath)
}
const failed = await scan(provider)
expect(failed.sessions).toEqual([])
expect(failed.issues).toHaveLength(1)
provider.readFilePaths.length = 0
const recovered = await scan(provider)
expect(provider.readFilePaths).toEqual([path])
expect(recovered.sessions[0]?.title).toBe('Recovered')
})
})
@@ -0,0 +1,107 @@
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { RemoteScannerContext, RemoteSessionCandidate } from './remote-session-scanner-types'
// Matches the local scanner's cap. The relay sidecar is forked with
// --max-old-space-size=384, and a retained session row is a title, a preview
// window and counters — orders of magnitude smaller than the transcript it was
// parsed from, which is what the cache stops us re-reading.
const MAX_CACHE_ENTRIES = 4096
type RemoteSessionParseCacheEntry = {
mtimeMs: number
sizeBytes: number | null
hostKey: string
session: AiVaultSession | null
}
// Module scope so it outlives one scan: the sidecar is retired only after 10
// idle minutes, so it spans many passes of a 30s cadence.
const cache = new Map<string, RemoteSessionParseCacheEntry>()
export type RemoteSessionParseStats = { reused: number; parsed: number }
export function createRemoteSessionParseStats(): RemoteSessionParseStats {
return { reused: 0, parsed: 0 }
}
export function resetRemoteSessionParseCacheForTests(): void {
cache.clear()
}
/** Identity of the host a parse result belongs to; a result is not portable across either field. */
export function remoteSessionParseHostKey(context: RemoteScannerContext): string {
return `${context.executionHostId}\u0000${context.hostPlatform.relayPlatform}`
}
function storeEntry(path: string, entry: RemoteSessionParseCacheEntry): void {
cache.delete(path)
cache.set(path, entry)
if (cache.size > MAX_CACHE_ENTRIES) {
const oldest = cache.keys().next()
if (!oldest.done) {
cache.delete(oldest.value)
}
}
}
/**
* Parse a remote transcript, reusing the previous result when the file is
* provably unchanged.
*
* Why this exists: the remote scanner had no cache of any kind, so every pass
* re-read and re-parsed the whole corpus — up to 3000 whole-file reads, GBs of
* JSONL, including July transcripts that had not changed in a month — which is
* what pegged the relay host on the renderer's 30s forced-rescan cadence
* (#13753). The local scanner has had `parseAgentSessionFileCached` for exactly
* this reason; this is its remote counterpart.
*
* `(mtimeMs, sizeBytes)` is a sound validity key here because discovery already
* folds a source's `contentDependencyPath` stat into both fields
* (remote-session-scanner-discovery.ts), so a metadata-only transcript whose
* companion file changed still looks changed. Sources whose parse reads a file
* discovery does not stat — Codex looks its title up in `session_index.jsonl` —
* are not covered by that key and pass `refreshReusedSession` to re-derive the
* uncovered part without touching the transcript.
*
* Only a completed parse is stored. A read that threw stays uncached so a
* transient filesystem failure cannot pin a wrong answer for the corpus's life.
*/
export async function parseRemoteSessionFileCached(args: {
candidate: RemoteSessionCandidate
hostKey: string
parse: () => Promise<AiVaultSession | null>
// Applied to a reused session only; must not re-read the transcript.
refreshReusedSession?: (session: AiVaultSession) => Promise<AiVaultSession>
stats?: RemoteSessionParseStats
}): Promise<AiVaultSession | null> {
const { file } = args.candidate
const entry = cache.get(file.path)
const unchanged =
entry !== undefined &&
entry.hostKey === args.hostKey &&
entry.mtimeMs === file.mtimeMs &&
(entry.sizeBytes === null || file.sizeBytes === undefined || entry.sizeBytes === file.sizeBytes)
if (unchanged) {
if (args.stats) {
args.stats.reused++
}
if (entry.session && args.refreshReusedSession) {
entry.session = await args.refreshReusedSession(entry.session)
}
// Refresh recency without re-parsing so the LRU evicts cold paths first.
storeEntry(file.path, entry)
return entry.session
}
const session = await args.parse()
if (args.stats) {
args.stats.parsed++
}
storeEntry(file.path, {
mtimeMs: file.mtimeMs,
sizeBytes: file.sizeBytes ?? null,
hostKey: args.hostKey,
session
})
return session
}
@@ -3,10 +3,31 @@ import { joinRemotePath } from '../ssh/ssh-remote-platform'
import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values'
import { remoteSessionContentLines } from './remote-session-content-lines'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types'
import type {
RemoteScannerContext,
RemoteSessionFilesystemProvider
} from './remote-session-scanner-types'
const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl'
// One index read per CODEX_HOME per scan (`context.titleCaches` is scan-scoped);
// used both by the transcript parse and by the parse cache's reuse path.
export function remoteCodexIndexedTitleReader(
codexHome: string,
context: RemoteScannerContext
): (sessionId: string) => Promise<string | null> {
return async (sessionId) =>
(
await remoteCodexIndexTitles({
provider: context.provider,
codexHome,
hostPlatform: context.hostPlatform,
titleCaches: context.titleCaches,
signal: context.signal
})
).get(sessionId) ?? null
}
export async function remoteCodexIndexTitles(args: {
provider: RemoteSessionFilesystemProvider
codexHome: string
@@ -16,7 +16,7 @@ import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-tra
import { partitionOmpSubagentTranscriptPaths } from './session-scanner-omp-subagent-transcripts'
import type { FileWithMtime } from './session-scanner-types'
import { normalizeAgentSessionsDir } from './session-scanner-values'
import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index'
import { remoteCodexIndexedTitleReader } from './remote-session-scanner-codex-index'
import { remoteClineSource } from './remote-session-scanner-cline-source'
import type {
RemoteParserOptions,
@@ -216,16 +216,7 @@ function remoteCodexSources(
executionHostId: context.executionHostId,
executionHostPlatform: context.hostPlatform.os,
signal: context.signal,
readIndexedTitle: async (sessionId) =>
(
await remoteCodexIndexTitles({
provider: context.provider,
codexHome,
hostPlatform,
titleCaches: context.titleCaches,
signal: context.signal
})
).get(sessionId) ?? null
readIndexedTitle: remoteCodexIndexedTitleReader(codexHome, context)
})
}))
}
+37 -6
View File
@@ -12,6 +12,11 @@ import {
dedupeCodexRolloutFileAliases,
dedupeCodexSessionsBySessionId
} from './codex-session-root-dedup'
import {
parseRemoteSessionFileCached,
remoteSessionParseHostKey
} from './remote-session-parse-cache'
import { remoteCodexIndexedTitleReader } from './remote-session-scanner-codex-index'
import { discoverRemoteSourceCandidates } from './remote-session-scanner-discovery'
import { remoteSessionSources } from './remote-session-scanner-sources'
import type {
@@ -25,6 +30,7 @@ import { errorMessage } from './session-scanner-values'
import { mapRemoteScanBatches } from './remote-session-scan-batching'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { recordSessionScanIssue } from './session-scan-issues'
import { refreshCodexTitleFromIndex } from './session-scanner-codex-cached-title'
import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency'
import { aiVaultScanLimit } from '../../shared/ai-vault-session-depth'
@@ -224,12 +230,21 @@ async function parseRemoteSessionCandidate(
): Promise<AiVaultSession | null> {
try {
throwIfAiVaultScanCancelled(context.signal)
const read = await context.provider.readFile(candidate.file.path)
throwIfAiVaultScanCancelled(context.signal)
if (read.isBinary) {
return null
}
const session = await candidate.source.parse(candidate.file, read.content, context)
// The read is inside the cached parse: an unchanged transcript must not be
// pulled off the remote disk at all, which is the whole cost of #13753.
const session = await parseRemoteSessionFileCached({
candidate,
hostKey: remoteSessionParseHostKey(context),
parse: async () => {
const read = await context.provider.readFile(candidate.file.path)
throwIfAiVaultScanCancelled(context.signal)
if (read.isBinary) {
return null
}
return await candidate.source.parse(candidate.file, read.content, context)
},
refreshReusedSession: reusedCodexTitleRefresh(candidate, context)
})
throwIfAiVaultScanCancelled(context.signal)
// Mirror the local rule: every session carries its sibling subagent
// transcript count (row badge; recoverable signal at zero turns). The
@@ -251,6 +266,22 @@ async function parseRemoteSessionCandidate(
}
}
// Codex thread names live in `<CODEX_HOME>/session_index.jsonl`, not the
// rollout, and are written after it — so a transcript-keyed cache hit would
// pin the fallback title forever. Local counterpart:
// session-scanner-parse-cache.ts's reuse path.
function reusedCodexTitleRefresh(
candidate: RemoteSessionCandidate,
context: RemoteScannerContext
): ((session: AiVaultSession) => Promise<AiVaultSession>) | undefined {
const codexHome = candidate.source.agent === 'codex' ? candidate.source.codexHome : undefined
if (!codexHome) {
return undefined
}
const readIndexedTitle = remoteCodexIndexedTitleReader(codexHome, context)
return (session) => refreshCodexTitleFromIndex(session, readIndexedTitle)
}
function mergeRemoteSessions(
cappedSessions: AiVaultSession[],
scopeSessions: AiVaultSession[]
@@ -2,14 +2,29 @@ import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { SessionFileCandidate } from './session-scanner-types'
import { readCodexSessionIndexTitle } from './session-scanner-codex-title-index'
export async function refreshCachedCodexTitle(
/**
* Codex names a thread in <CODEX_HOME>/session_index.jsonl asynchronously,
* after the rollout exists — often after the rollout's last append. A parse
* cache keyed on the transcript's own mtime/size therefore freezes the fallback
* title forever, so every reuse path re-derives it through here.
*
* Both caches share this: `session-scanner-parse-cache.ts` (local disk, via
* `refreshCachedCodexTitle`) and `remote-session-parse-cache.ts` (relay
* provider, whose reader lives in `remote-session-scanner-codex-index.ts`).
*/
export async function refreshCodexTitleFromIndex(
session: AiVaultSession,
readIndexedTitle: (sessionId: string) => Promise<string | null>
): Promise<AiVaultSession> {
const title = await readIndexedTitle(session.sessionId)
return title && title !== session.title ? { ...session, title } : session
}
export function refreshCachedCodexTitle(
candidate: SessionFileCandidate,
session: AiVaultSession
): Promise<AiVaultSession> {
const title = await readCodexSessionIndexTitle(
candidate.file.path,
candidate.codexHome,
session.sessionId
return refreshCodexTitleFromIndex(session, (sessionId) =>
readCodexSessionIndexTitle(candidate.file.path, candidate.codexHome, sessionId)
)
return title && title !== session.title ? { ...session, title } : session
}
@@ -209,6 +209,8 @@ export async function parseAgentSessionFileCached(
entry.session = { ...entry.session, subagentTranscriptCount }
}
}
// Codex titles come from session_index.jsonl, which mtime+size can't see.
// Remote counterpart: remote-session-scanner.ts's reusedCodexTitleRefresh.
if (entry.session && candidate.agent === 'codex') {
entry.session = await refreshCachedCodexTitle(candidate, entry.session)
}
+22 -1
View File
@@ -1,6 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'
import { SSH_MUX_REQUEST_TIMEOUT_CODE } from '../ssh/ssh-channel-multiplexer'
import {
createSshDisposalError,
SSH_MUX_REQUEST_TIMEOUT_CODE
} from '../ssh/ssh-channel-multiplexer'
const requestActiveSshAiVaultSessionList = vi.fn()
const getActiveSshAiVaultHostInfo = vi.fn()
@@ -144,6 +147,24 @@ describe('scanSshAiVaultSessions', () => {
])
})
it('reports a host issue when the relay link was declared lost on a real scan budget', async () => {
// Declaring a wedged link lost trades SSH_MUX_REQUEST_TIMEOUT for CONNECTION_LOST on this leg.
// Both are unverifiable, so both must surface as a host issue rather than falling through to a
// crawl that would publish an authoritative-looking empty list
// (docs/reference/ssh-execution-boundary.md).
requestActiveSshAiVaultSessionList.mockRejectedValue(createSshDisposalError('connection_lost'))
const result = await scanSshAiVaultSessions('dev-box', undefined, {
timeoutMs: 20_000,
relayTimeoutMs: 15_000
})
expect(scanRemoteAiVaultSessions).not.toHaveBeenCalled()
expect(result.issues).toEqual([
expect.objectContaining({ executionHostId: 'ssh:dev-box', kind: 'host' })
])
})
it('still falls back when the relay budget was too short for a fair attempt', async () => {
requestActiveSshAiVaultSessionList.mockRejectedValue(relayTimeoutError())
scanRemoteAiVaultSessions.mockResolvedValue({
+2 -2
View File
@@ -10,7 +10,7 @@ import {
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
} from '../providers/ssh-filesystem-dispatch'
import { getActiveSshAiVaultHostInfo, requestActiveSshAiVaultSessionList } from '../ipc/ssh'
import { isSshMuxRequestTimeoutError } from '../ssh/ssh-channel-multiplexer'
import { isSshRequestOutcomeUnverifiable } from '../ssh/ssh-channel-multiplexer'
import { createAiVaultScanCancelledError } from './ai-vault-scan-cancellation'
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
import { parseAiVaultListResult } from './session-list-result-validation'
@@ -84,7 +84,7 @@ async function scanOneSshHost(
throw error
}
if (
isSshMuxRequestTimeoutError(error) &&
isSshRequestOutcomeUnverifiable(error) &&
(relayTimeoutMs === undefined || relayTimeoutMs >= MEANINGFUL_RELAY_SCAN_ATTEMPT_MS)
) {
return sshScanIssueResult(executionHostId, targetId, errorMessage(error))
@@ -50,7 +50,8 @@ function createRuntimeCommands(): RuntimeFileCommands {
return new RuntimeFileCommands({
requireStore: () => store,
resolveRuntimeFileTarget: async () => ({
worktree: { id: 'wt-1', repoId: 'repo-1', path: REPO_PATH }
worktree: { id: 'wt-1', repoId: 'repo-1', path: REPO_PATH },
executionHostId: 'local'
})
} as never)
}
@@ -25,7 +25,11 @@ export async function execFileCaptureToTermination(
options: ExecFileCaptureOptions,
termination?: WslProcessGroupTermination
): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> {
const result = await runProcess({
// Why measured here: runProcess spawns inside its promise executor, which runs
// synchronously, so this brackets exactly the main-thread block execFileCapture
// reports for its own spawns.
const spawnStartedAt = performance.now()
const pending = runProcess({
program: command,
args,
cwd: typeof options.cwd === 'string' ? options.cwd : undefined,
@@ -37,10 +41,17 @@ export async function execFileCaptureToTermination(
onChildTerminated: options.onChildTerminated,
...(options.stdin === undefined ? {} : { input: options.stdin })
})
recordSubprocessSpawn(command, args, performance.now() - spawnStartedAt)
const result = await pending
const stdout = options.encoding === 'buffer' ? Buffer.from(result.stdout) : result.stdout
const cleanStderr = termination?.stripControlOutput(result.stderr) ?? result.stderr
const stderr = options.encoding === 'buffer' ? Buffer.from(cleanStderr) : cleanStderr
if (result.code === 0 && !result.timedOut && !options.signal?.aborted) {
if (
result.code === 0 &&
!result.timedOut &&
!result.outputTruncated &&
!options.signal?.aborted
) {
return { stdout, stderr }
}
const error = result.timedOut
@@ -48,7 +59,12 @@ export async function execFileCaptureToTermination(
: new Error(
options.signal?.aborted
? 'The operation was aborted.'
: cleanStderr.trim() || `${command} exited with ${result.code}.`
: result.outputTruncated
? // Why fail instead of returning the clipped text: callers parse this
// as JSON or JSONL, where a clipped answer reads as a shorter valid
// one. execFile's own maxBuffer overrun errored for the same reason.
`${command} produced more than ${options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER} bytes of output.`
: cleanStderr.trim() || `${command} exited with ${result.code}.`
)
if (options.signal?.aborted) {
error.name = 'AbortError'
@@ -2,20 +2,15 @@ import { EventEmitter } from 'node:events'
import type { ChildProcess } from 'node:child_process'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileMock, spawnMock, killSpawnedCommandTreeMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
const { spawnMock, processKillMock } = vi.hoisted(() => ({
spawnMock: vi.fn(),
killSpawnedCommandTreeMock: vi.fn().mockResolvedValue(undefined)
processKillMock: vi.fn()
}))
vi.mock('node:child_process', async (importOriginal) => ({
...(await importOriginal()),
execFile: execFileMock,
spawn: spawnMock
}))
vi.mock('./spawned-command-tree-kill', () => ({
killSpawnedCommandTree: killSpawnedCommandTreeMock
}))
import { ghExecFileAsync } from './gh-exec-file'
@@ -29,66 +24,87 @@ function mockChild(pid = 4321): ChildProcess {
return child as unknown as ChildProcess
}
function settleChild(child: ChildProcess, stdout: string): void {
child.stdout?.emit('data', Buffer.from(stdout))
child.emit('exit', 0, null)
child.emit('close', 0, null)
}
/**
* The contract the star check depends on after #18234: a `gh` that never exits
* is killed at the deadline, tree and all, rather than running forever.
* is killed at the deadline, and the kill reaches the whole chain. On the
* reporter's box `gh` was a shell wrapper calling `mise x gh`, so signalling
* only the direct child left the rest of the chain running under init.
*/
describe('gh exec deadline', () => {
beforeEach(() => {
vi.useFakeTimers()
execFileMock.mockReset()
spawnMock.mockReset()
killSpawnedCommandTreeMock.mockClear()
processKillMock.mockReset()
vi.spyOn(process, 'kill').mockImplementation(processKillMock as unknown as typeof process.kill)
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('kills the process tree and rejects when gh never exits', async () => {
const child = mockChild()
// Why never invoking the callback: this is exactly the stuck child from
// #18234 — spawned, spinning, and never reporting an exit.
execFileMock.mockReturnValue(child)
it.runIf(process.platform !== 'win32')(
'signals the whole process group, not just the child, when gh never exits',
async () => {
const child = mockChild()
// Why never emitting exit: this is exactly the stuck child from #18234 —
// spawned, spinning, and never reporting an exit.
spawnMock.mockReturnValue(child)
const pending = ghExecFileAsync(['api', '--include', 'user/starred/stablyai/orca'], {
timeout: 15_000
})
const rejection = expect(pending).rejects.toThrow('timed out')
await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledOnce())
const pending = ghExecFileAsync(['api', '--include', 'user/starred/stablyai/orca'], {
timeout: 15_000
})
const rejection = expect(pending).rejects.toThrow('timed out')
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
// Not yet: the deadline has not elapsed.
expect(killSpawnedCommandTreeMock).not.toHaveBeenCalled()
// The child must be its own group leader, or the signal below would go to
// whatever group it inherited — Orca's own.
expect(spawnMock.mock.calls[0][2].detached).toBe(true)
expect(processKillMock).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(15_000)
await rejection
await vi.advanceTimersByTimeAsync(15_000)
await vi.advanceTimersByTimeAsync(15_000)
await rejection
expect(killSpawnedCommandTreeMock).toHaveBeenCalledWith(child)
})
expect(processKillMock).toHaveBeenCalledWith(-4321, undefined)
}
)
it('spawns with hidden console and captured stdio, never an inherited or shell stdio', async () => {
const child = mockChild()
execFileMock.mockImplementation(
(
_command: string,
_args: string[],
_options: unknown,
callback: (error: Error | null, stdout: string, stderr: string) => void
) => {
callback(null, 'HTTP/2.0 204 No Content\r\n', '')
return child
}
)
spawnMock.mockImplementation(() => {
queueMicrotask(() => settleChild(child, 'HTTP/2.0 204 No Content\r\n'))
return child
})
await ghExecFileAsync(['api', '--include', 'user/starred/stablyai/orca'], { timeout: 15_000 })
const result = await ghExecFileAsync(['api', '--include', 'user/starred/stablyai/orca'], {
timeout: 15_000
})
const [command, args, options] = execFileMock.mock.calls[0]
expect(result.stdout).toContain('204 No Content')
const [command, args, options] = spawnMock.mock.calls[0]
expect(command).toBe('gh')
expect(args).toEqual(['api', '--include', 'user/starred/stablyai/orca'])
// `execFile` captures stdout/stderr over pipes and never inherits Orca's;
// `shell` is never set, and the console stays hidden on Windows.
expect(options.windowsHide).toBe(true)
expect(options.stdio).toBeUndefined()
expect(options.shell).toBeUndefined()
expect(options.stdio).toEqual(['pipe', 'pipe', 'pipe'])
expect(options.shell).toBe(false)
})
it('fails rather than returning a clipped answer when gh overruns maxBuffer', async () => {
const child = mockChild()
spawnMock.mockImplementation(() => {
queueMicrotask(() => settleChild(child, '['.padEnd(64, 'x')))
return child
})
await expect(
ghExecFileAsync(['api', 'repos/stablyai/orca/issues'], { timeout: 15_000, maxBuffer: 8 })
).rejects.toThrow('more than 8 bytes')
})
})
+20 -10
View File
@@ -19,7 +19,7 @@ import {
isHostCommandMissing,
resolveHostGitHubCli
} from './github-cli-host-fallback'
import { execFileCapture } from './exec-file-capture'
import { execFileCaptureToTermination } from './exec-file-capture'
import type { GitExecOptions } from './git-exec-options'
import { argsLookIdempotent } from './gh-idempotency'
import { applyGhHostToArgs, explicitGhHostname, explicitGhRepoHostname } from './gh-host-args'
@@ -115,15 +115,25 @@ export async function ghExecFileAsync(
let attemptedDefaultWslFallback = false
for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) {
try {
const { stdout, stderr } = await execFileCapture(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: (options.encoding ?? 'utf-8') as BufferEncoding,
maxBuffer: options.maxBuffer,
// Why: bound gh so one stuck child fails visibly instead of wedging the IPC lane.
timeout: options.timeout ?? defaultGhExecTimeoutMs(options.env),
env: nonInteractiveGhEnv(options.env),
signal: options.signal
})
// Why to-termination and not execFileCapture: `gh` on PATH is routinely a
// shim (mise, asdf, volta, a hand-written wrapper), so the deadline below
// has a chain to reap, not one process. execFileCapture's POSIX kill only
// signals the direct child, which orphans the rest to init — a wedged
// helper then outlives the timeout that was supposed to bound it (#18234).
const { stdout, stderr } = await execFileCaptureToTermination(
resolved.binary,
resolved.args,
{
cwd: resolved.cwd,
encoding: (options.encoding ?? 'utf-8') as BufferEncoding,
maxBuffer: options.maxBuffer,
// Why: bound gh so one stuck child fails visibly instead of wedging the IPC lane.
timeout: options.timeout ?? defaultGhExecTimeoutMs(options.env),
env: nonInteractiveGhEnv(options.env),
signal: options.signal
},
resolved.termination
)
return { stdout: stdout as string, stderr: stderr as string }
} catch (err) {
lastError = err
+16 -9
View File
@@ -2,7 +2,7 @@ import { addWslEnvKeys } from '../../wsl-env'
import { extractExecError, parseRetryAfterMs } from '../exec-error'
import { resolveCommand, resolveDefaultWslCli } from './wsl-command-resolution'
import { isHostCommandMissing } from './github-cli-host-fallback'
import { execFileCapture } from './exec-file-capture'
import { execFileCaptureToTermination } from './exec-file-capture'
import type { GitExecOptions } from './git-exec-options'
import { argsLookIdempotent } from './gh-idempotency'
import {
@@ -63,14 +63,21 @@ export async function glabExecFileAsync(
let attemptedDefaultWslFallback = false
for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) {
try {
const { stdout, stderr } = await execFileCapture(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: (options.encoding ?? 'utf-8') as BufferEncoding,
maxBuffer: options.maxBuffer,
timeout: options.timeout ?? DEFAULT_GLAB_EXEC_TIMEOUT_MS,
env: options.env,
signal: options.signal
})
// Why to-termination: same shim chain as gh — the deadline has to reap the
// whole tree, not just the wrapper that spawned it (#18234).
const { stdout, stderr } = await execFileCaptureToTermination(
resolved.binary,
resolved.args,
{
cwd: resolved.cwd,
encoding: (options.encoding ?? 'utf-8') as BufferEncoding,
maxBuffer: options.maxBuffer,
timeout: options.timeout ?? DEFAULT_GLAB_EXEC_TIMEOUT_MS,
env: options.env,
signal: options.signal
},
resolved.termination
)
return { stdout: stdout as string, stderr: stderr as string }
} catch (err) {
lastError = err
+110 -50
View File
@@ -46,6 +46,31 @@ function createMockChildProcess(pid: number): MockChildProcess {
return child
}
/**
* Spawn stand-in for the gh/glab deadline tests: the CLI hangs, while the `ps`
* quiescence probe the tree termination runs answers immediately.
*/
function mockWedgedCliSpawn(child: MockChildProcess): void {
spawnMock.mockImplementation((program: string) => {
if (program !== 'ps') {
return child
}
const probe = createMockChildProcess(9100)
queueMicrotask(() => probe.emit('close', 0, null))
return probe
})
}
/** Signals succeed; the existence probe reports the group already gone. */
function mockProcessGroupSignals(): ReturnType<typeof vi.spyOn> {
return vi.spyOn(process, 'kill').mockImplementation(((_pid: number, signal?: unknown) => {
if (signal === 0) {
throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' })
}
return true
}) as typeof process.kill)
}
function createMockTaskkillProcess(): MockChildProcess {
const child = createMockChildProcess(9000)
child.unref = vi.fn()
@@ -271,32 +296,46 @@ describe('runner execFile timeout handling', () => {
}
)
it('rejects gh executions that never call back using the default timeout', async () => {
// Why the group and not the child (#18234): `gh` and `glab` on PATH are often
// shims, so the deadline has a chain to reap. Signalling only the direct child
// leaves the rest of it running under init long after the deadline passed.
it('signals the whole gh process group when gh never calls back', async () => {
const child = createMockChildProcess(1234)
execFileMock.mockReturnValue(child)
mockWedgedCliSpawn(child)
const processKill = mockProcessGroupSignals()
try {
const promise = ghExecFileAsync(['api', 'repos/stablyai/orca/issues/5388'], {
cwd: '/repo'
})
const rejection = expect(promise).rejects.toThrow('gh timed out.')
await vi.advanceTimersByTimeAsync(30_000)
expect(spawnMock.mock.calls[0][2].detached).toBe(true)
await vi.advanceTimersByTimeAsync(2_000)
const promise = ghExecFileAsync(['api', 'repos/stablyai/orca/issues/5388'], {
cwd: '/repo'
})
const rejection = expect(promise).rejects.toThrow('gh timed out.')
await vi.advanceTimersByTimeAsync(30_000)
await rejection
expect(child.kill).toHaveBeenCalled()
await rejection
expect(processKill).toHaveBeenCalledWith(-1234, undefined)
} finally {
processKill.mockRestore()
}
})
it('rejects glab executions that never call back using the default timeout', async () => {
it('signals the whole glab process group when glab never calls back', async () => {
const child = createMockChildProcess(1234)
execFileMock.mockReturnValue(child)
mockWedgedCliSpawn(child)
const processKill = mockProcessGroupSignals()
try {
const promise = glabExecFileAsync(['api', 'projects/stablyai%2Forca/issues'], {
cwd: '/repo'
})
const rejection = expect(promise).rejects.toThrow('glab timed out.')
await vi.advanceTimersByTimeAsync(30_000)
await vi.advanceTimersByTimeAsync(2_000)
const promise = glabExecFileAsync(['api', 'projects/stablyai%2Forca/issues'], {
cwd: '/repo'
})
const rejection = expect(promise).rejects.toThrow('glab timed out.')
await vi.advanceTimersByTimeAsync(30_000)
await rejection
expect(child.kill).toHaveBeenCalled()
await rejection
expect(processKill).toHaveBeenCalledWith(-1234, undefined)
} finally {
processKill.mockRestore()
}
})
it('aborts glab retry backoff instead of starting another attempt', async () => {
@@ -304,9 +343,14 @@ describe('runner execFile timeout handling', () => {
const transient = Object.assign(new Error('glab failed'), {
stderr: 'HTTP 503 Service Unavailable'
})
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
callback(transient)
return createMockChildProcess(1234)
spawnMock.mockImplementationOnce(() => {
const child = createMockChildProcess(1234)
queueMicrotask(() => {
child.stderr.emit('data', Buffer.from(transient.stderr))
child.emit('exit', 1, null)
child.emit('close', 1, null)
})
return child
})
const promise = glabExecFileAsync(['api', 'projects'], {
@@ -314,52 +358,68 @@ describe('runner execFile timeout handling', () => {
signal: controller.signal
})
const rejection = expect(promise).rejects.toMatchObject({ name: 'AbortError' })
await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1))
controller.abort()
await rejection
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(spawnMock).toHaveBeenCalledTimes(1)
})
it('kills an active gh execution when its caller aborts', async () => {
const child = createMockChildProcess(1234)
execFileMock.mockReturnValue(child)
const controller = new AbortController()
const promise = ghExecFileAsync(['api', 'repos/stablyai/orca/issues/5388'], {
cwd: '/repo',
signal: controller.signal
})
const rejection = expect(promise).rejects.toMatchObject({ name: 'AbortError' })
mockWedgedCliSpawn(child)
const processKill = mockProcessGroupSignals()
try {
const controller = new AbortController()
const promise = ghExecFileAsync(['api', 'repos/stablyai/orca/issues/5388'], {
cwd: '/repo',
signal: controller.signal
})
const rejection = expect(promise).rejects.toMatchObject({ name: 'AbortError' })
controller.abort()
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
controller.abort()
await vi.advanceTimersByTimeAsync(2_000)
await rejection
expect(child.kill).toHaveBeenCalled()
await rejection
expect(processKill).toHaveBeenCalledWith(-1234, undefined)
} finally {
processKill.mockRestore()
}
})
it('honors explicit gh timeouts', async () => {
const child = createMockChildProcess(1234)
execFileMock.mockReturnValue(child)
mockWedgedCliSpawn(child)
const processKill = mockProcessGroupSignals()
try {
const promise = ghExecFileAsync(['api', 'repos/stablyai/orca/issues/5388'], {
cwd: '/repo',
timeout: 1234
})
const rejection = expect(promise).rejects.toThrow('gh timed out.')
await vi.advanceTimersByTimeAsync(1233)
expect(processKill).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
await vi.advanceTimersByTimeAsync(2_000)
const promise = ghExecFileAsync(['api', 'repos/stablyai/orca/issues/5388'], {
cwd: '/repo',
timeout: 1234
})
const rejection = expect(promise).rejects.toThrow('gh timed out.')
await vi.advanceTimersByTimeAsync(1233)
expect(child.kill).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
await rejection
expect(child.kill).toHaveBeenCalled()
await rejection
expect(processKill).toHaveBeenCalledWith(-1234, undefined)
} finally {
processKill.mockRestore()
}
})
it('runs gh non-interactively while preserving explicit env', async () => {
const child = createMockChildProcess(1234)
let capturedEnv: NodeJS.ProcessEnv | undefined
execFileMock.mockImplementation((_cmd, _args, opts, cb) => {
spawnMock.mockImplementation((_cmd, _args, opts) => {
capturedEnv = opts.env
cb(null, 'ok', '')
const child = createMockChildProcess(1234)
queueMicrotask(() => {
child.stdout.emit('data', Buffer.from('ok'))
child.emit('exit', 0, null)
child.emit('close', 0, null)
})
return child
})
@@ -12,6 +12,7 @@ vi.mock('child_process', () => ({
spawn: spawnMock
}))
import { fakeSpawnReturning } from '../../shared/child-process/__fixtures__/fake-spawned-child'
import { ghExecFileAsync } from './runner'
import {
_resetGhRateLimitBreaker,
@@ -23,25 +24,16 @@ const PRIMARY_RATE_LIMIT_STDERR =
'gh: API rate limit exceeded for user ID 1775218. Please wait. (HTTP 403)'
function mockGhFailure(stderr: string): void {
execFileMock.mockImplementation((_binary, _args, options, callback) => {
const done = typeof options === 'function' ? options : callback
queueMicrotask(() =>
done(Object.assign(new Error(`Command failed: gh\n${stderr}`), { stderr }), '', stderr)
)
return { once: vi.fn() }
})
spawnMock.mockImplementation(fakeSpawnReturning({ stderr, code: 1 }))
}
function mockGhSuccess(stdout: string): void {
execFileMock.mockImplementation((_binary, _args, options, callback) => {
const done = typeof options === 'function' ? options : callback
queueMicrotask(() => done(null, stdout, ''))
return { once: vi.fn() }
})
spawnMock.mockImplementation(fakeSpawnReturning({ stdout }))
}
beforeEach(() => {
execFileMock.mockReset()
spawnMock.mockReset()
})
afterEach(() => {
@@ -54,14 +46,14 @@ describe('ghExecFileAsync rate-limit breaker', () => {
await expect(
ghExecFileAsync(['api', '--cache', '120s', 'search/issues?q=repo:a/b&per_page=1'])
).rejects.toThrow('rate limit')
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(spawnMock).toHaveBeenCalledTimes(1)
// The 90-repo storm case: every further search-bucket call must fail fast
// without a subprocess.
await expect(
ghExecFileAsync(['api', '--cache', '120s', 'search/issues?q=repo:c/d&per_page=1'])
).rejects.toMatchObject({ ghRateLimitBlocked: true })
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(spawnMock).toHaveBeenCalledTimes(1)
})
it('keeps other buckets working while one bucket is blocked', async () => {
@@ -72,7 +64,7 @@ describe('ghExecFileAsync rate-limit breaker', () => {
stdout: '[]',
stderr: ''
})
expect(execFileMock).toHaveBeenCalledTimes(2)
expect(spawnMock).toHaveBeenCalledTimes(2)
})
it('keeps other GitHub hosts and WSL runtimes working when github.com is blocked', async () => {
@@ -105,7 +97,7 @@ describe('ghExecFileAsync rate-limit breaker', () => {
value: originalPlatform
})
}
expect(execFileMock).toHaveBeenCalledTimes(5)
expect(spawnMock).toHaveBeenCalledTimes(5)
})
it.each([
@@ -195,7 +187,7 @@ describe('ghExecFileAsync rate-limit breaker', () => {
).resolves.toMatchObject({
stdout: '{"resources":{}}'
})
expect(execFileMock).toHaveBeenCalledTimes(2)
expect(spawnMock).toHaveBeenCalledTimes(2)
})
it('does not trip the breaker on secondary rate limits', async () => {
+145 -316
View File
@@ -1,16 +1,19 @@
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createFakeSpawnedChild,
fakeSpawnDispatch,
fakeSpawnReturning
} from '../../shared/child-process/__fixtures__/fake-spawned-child'
import type * as WslModule from '../wsl'
const { execFileMock, execFileSyncMock, spawnMock, getDefaultWslDistroMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
const { execFileSyncMock, spawnMock, getDefaultWslDistroMock } = vi.hoisted(() => ({
execFileSyncMock: vi.fn(),
spawnMock: vi.fn(),
getDefaultWslDistroMock: vi.fn()
}))
vi.mock('child_process', () => ({
execFile: execFileMock,
execFile: vi.fn(),
execFileSync: execFileSyncMock,
spawn: spawnMock
}))
@@ -26,25 +29,18 @@ import { _resetGhRateLimitBreaker } from './gh-rate-limit-breaker'
const PRIMARY_RATE_LIMIT_STDERR =
'gh: API rate limit exceeded for user ID 1775218. Please wait. (HTTP 403)'
type MockChildProcess = EventEmitter & {
pid: number
kill: ReturnType<typeof vi.fn>
unref: ReturnType<typeof vi.fn>
}
// What the distro prints when the CLI is absent inside WSL but present on the host.
const WSL_GH_MISSING = 'bash: line 1: gh: command not found\n'
const TRANSIENT_502 = 'HTTP 502 Bad Gateway'
function createMockChildProcess(pid: number): MockChildProcess {
const child = new EventEmitter() as MockChildProcess
child.pid = pid
child.kill = vi.fn()
child.unref = vi.fn()
return child
function spawnEnoent(command: string): { spawnError: Error } {
return { spawnError: Object.assign(new Error(`spawn ${command} ENOENT`), { code: 'ENOENT' }) }
}
describe('ghExecFileAsync WSL fallback', () => {
const originalPlatform = process.platform
beforeEach(() => {
execFileMock.mockReset()
spawnMock.mockReset()
getDefaultWslDistroMock.mockReset()
getDefaultWslDistroMock.mockReturnValue(null)
@@ -66,21 +62,11 @@ describe('ghExecFileAsync WSL fallback', () => {
})
it('falls back to host gh for explicit-repo WSL calls when gh is missing in the distro', async () => {
execFileMock.mockImplementation((binary, _args, options, callback) => {
if (typeof options === 'function') {
callback = options
}
if (binary === 'wsl.exe') {
callback(
Object.assign(new Error('Command failed: wsl.exe'), {
stdout: '',
stderr: 'bash: line 1: gh: command not found\n'
})
)
return
}
callback(null, { stdout: '[]', stderr: '' })
})
spawnMock.mockImplementation(
fakeSpawnDispatch((program) =>
program === 'wsl.exe' ? { stderr: WSL_GH_MISSING, code: 1 } : { stdout: '[]' }
)
)
await expect(
ghExecFileAsync(['issue', 'list', '--repo', 'stablyhq/noqa', '--json', 'number,title'], {
@@ -88,7 +74,7 @@ describe('ghExecFileAsync WSL fallback', () => {
})
).resolves.toEqual({ stdout: '[]', stderr: '' })
expect(execFileMock).toHaveBeenNthCalledWith(
expect(spawnMock).toHaveBeenNthCalledWith(
1,
'wsl.exe',
[
@@ -102,53 +88,34 @@ describe('ghExecFileAsync WSL fallback', () => {
// Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit
// Orca's own cwd, a deletable WSL UNC path when it was launched from a
// worktree. The Linux directory still rides inside the command.
expect.objectContaining({ cwd: expect.any(String) }),
expect.any(Function)
expect.objectContaining({ cwd: expect.any(String) })
)
expect(execFileMock).toHaveBeenNthCalledWith(
expect(spawnMock).toHaveBeenNthCalledWith(
2,
'gh',
['issue', 'list', '--repo', 'stablyhq/noqa', '--json', 'number,title'],
expect.objectContaining({ cwd: undefined }),
expect.any(Function)
expect.objectContaining({ cwd: undefined })
)
})
it('does not fall back for repo-context gh calls without explicit repo context', async () => {
execFileMock.mockImplementation((_binary, _args, _options, callback) => {
callback(
Object.assign(new Error('Command failed: wsl.exe'), {
stdout: '',
stderr: 'bash: line 1: gh: command not found\n'
})
)
})
spawnMock.mockImplementation(fakeSpawnReturning({ stderr: WSL_GH_MISSING, code: 1 }))
await expect(
ghExecFileAsync(['issue', 'list'], {
cwd: String.raw`\\wsl.localhost\Ubuntu\home\jinwoo\stably\noqa`
})
).rejects.toThrow('Command failed: wsl.exe')
).rejects.toThrow('gh: command not found')
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(spawnMock).toHaveBeenCalledTimes(1)
})
it('falls back for short-form explicit repo flags used by gh', async () => {
execFileMock.mockImplementation((binary, _args, options, callback) => {
if (typeof options === 'function') {
callback = options
}
if (binary === 'wsl.exe') {
callback(
Object.assign(new Error('Command failed: wsl.exe'), {
stdout: '',
stderr: 'bash: line 1: gh: command not found\n'
})
)
return
}
callback(null, { stdout: '[]', stderr: '' })
})
spawnMock.mockImplementation(
fakeSpawnDispatch((program) =>
program === 'wsl.exe' ? { stderr: WSL_GH_MISSING, code: 1 } : { stdout: '[]' }
)
)
await expect(
ghExecFileAsync(['issue', 'list', '-R', 'stablyhq/noqa'], {
@@ -156,31 +123,20 @@ describe('ghExecFileAsync WSL fallback', () => {
})
).resolves.toEqual({ stdout: '[]', stderr: '' })
expect(execFileMock).toHaveBeenNthCalledWith(
expect(spawnMock).toHaveBeenNthCalledWith(
2,
'gh',
['issue', 'list', '-R', 'stablyhq/noqa'],
expect.objectContaining({ cwd: undefined }),
expect.any(Function)
expect.objectContaining({ cwd: undefined })
)
})
it('falls back for compact short-form repo flags used by gh', async () => {
execFileMock.mockImplementation((binary, _args, options, callback) => {
if (typeof options === 'function') {
callback = options
}
if (binary === 'wsl.exe') {
callback(
Object.assign(new Error('Command failed: wsl.exe'), {
stdout: '',
stderr: 'bash: line 1: gh: command not found\n'
})
)
return
}
callback(null, { stdout: '[]', stderr: '' })
})
spawnMock.mockImplementation(
fakeSpawnDispatch((program) =>
program === 'wsl.exe' ? { stderr: WSL_GH_MISSING, code: 1 } : { stdout: '[]' }
)
)
await expect(
ghExecFileAsync(['issue', 'list', '-Rstablyhq/noqa'], {
@@ -188,31 +144,20 @@ describe('ghExecFileAsync WSL fallback', () => {
})
).resolves.toEqual({ stdout: '[]', stderr: '' })
expect(execFileMock).toHaveBeenNthCalledWith(
expect(spawnMock).toHaveBeenNthCalledWith(
2,
'gh',
['issue', 'list', '-Rstablyhq/noqa'],
expect.objectContaining({ cwd: undefined }),
expect.any(Function)
expect.objectContaining({ cwd: undefined })
)
})
it('falls back for repo view with an explicit positional repository', async () => {
execFileMock.mockImplementation((binary, _args, options, callback) => {
if (typeof options === 'function') {
callback = options
}
if (binary === 'wsl.exe') {
callback(
Object.assign(new Error('Command failed: wsl.exe'), {
stdout: '',
stderr: 'bash: line 1: gh: command not found\n'
})
)
return
}
callback(null, { stdout: '{"isFork":false}', stderr: '' })
})
spawnMock.mockImplementation(
fakeSpawnDispatch((program) =>
program === 'wsl.exe' ? { stderr: WSL_GH_MISSING, code: 1 } : { stdout: '{"isFork":false}' }
)
)
await expect(
ghExecFileAsync(
@@ -224,68 +169,42 @@ describe('ghExecFileAsync WSL fallback', () => {
)
).resolves.toEqual({ stdout: '{"isFork":false}', stderr: '' })
expect(execFileMock).toHaveBeenNthCalledWith(
expect(spawnMock).toHaveBeenNthCalledWith(
2,
'gh',
['repo', 'view', 'github.acme-corp.com/stablyhq/noqa', '--json', 'isFork,parent'],
expect.objectContaining({ cwd: undefined }),
expect.any(Function)
expect.objectContaining({ cwd: undefined })
)
})
it('does not fall back for gh api calls that depend on repo-context placeholders', async () => {
execFileMock.mockImplementation((_binary, _args, _options, callback) => {
callback(
Object.assign(new Error('Command failed: wsl.exe'), {
stdout: '',
stderr: 'bash: line 1: gh: command not found\n'
})
)
})
spawnMock.mockImplementation(fakeSpawnReturning({ stderr: WSL_GH_MISSING, code: 1 }))
await expect(
ghExecFileAsync(['api', 'repos/stablyhq/noqa/branches/{branch}'], {
cwd: String.raw`\\wsl.localhost\Ubuntu\home\jinwoo\stably\noqa`
})
).rejects.toThrow('Command failed: wsl.exe')
).rejects.toThrow('gh: command not found')
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(spawnMock).toHaveBeenCalledTimes(1)
})
it('retries idempotent gh GraphQL query transient failures', async () => {
execFileMock
.mockImplementationOnce((_binary, _args, _options, callback) => {
callback(
Object.assign(new Error('HTTP 502 Bad Gateway'), {
stdout: '',
stderr: 'HTTP 502 Bad Gateway'
})
)
})
.mockImplementationOnce((_binary, _args, _options, callback) => {
callback(null, { stdout: '{"data":{}}', stderr: '' })
})
spawnMock
.mockImplementationOnce(fakeSpawnReturning({ stderr: TRANSIENT_502, code: 1 }))
.mockImplementationOnce(fakeSpawnReturning({ stdout: '{"data":{}}' }))
await expect(
ghExecFileAsync(['api', 'graphql', '-f', 'query=query { viewer { login } }'])
).resolves.toEqual({ stdout: '{"data":{}}', stderr: '' })
expect(execFileMock).toHaveBeenCalledTimes(2)
expect(spawnMock).toHaveBeenCalledTimes(2)
})
it('retries a host-pinned idempotent gh GraphQL query after host injection', async () => {
execFileMock
.mockImplementationOnce((_binary, _args, _options, callback) => {
callback(
Object.assign(new Error('HTTP 502 Bad Gateway'), {
stdout: '',
stderr: 'HTTP 502 Bad Gateway'
})
)
})
.mockImplementationOnce((_binary, _args, _options, callback) => {
callback(null, { stdout: '{"data":{}}', stderr: '' })
})
spawnMock
.mockImplementationOnce(fakeSpawnReturning({ stderr: TRANSIENT_502, code: 1 }))
.mockImplementationOnce(fakeSpawnReturning({ stdout: '{"data":{}}' }))
await expect(
ghExecFileAsync(['api', 'graphql', '-f', 'query=query { viewer { login } }'], {
@@ -293,8 +212,8 @@ describe('ghExecFileAsync WSL fallback', () => {
})
).resolves.toEqual({ stdout: '{"data":{}}', stderr: '' })
expect(execFileMock).toHaveBeenCalledTimes(2)
expect(execFileMock).toHaveBeenNthCalledWith(
expect(spawnMock).toHaveBeenCalledTimes(2)
expect(spawnMock).toHaveBeenNthCalledWith(
1,
'gh',
[
@@ -305,37 +224,22 @@ describe('ghExecFileAsync WSL fallback', () => {
'-f',
'query=query { viewer { login } }'
],
expect.any(Object),
expect.any(Function)
expect.any(Object)
)
})
it('does not retry non-idempotent gh API transient failures', async () => {
execFileMock.mockImplementation((_binary, _args, _options, callback) => {
callback(
Object.assign(new Error('HTTP 502 Bad Gateway'), {
stdout: '',
stderr: 'HTTP 502 Bad Gateway'
})
)
})
spawnMock.mockImplementation(fakeSpawnReturning({ stderr: TRANSIENT_502, code: 1 }))
await expect(
ghExecFileAsync(['api', '-X', 'POST', 'repos/stablyai/orca/issues'])
).rejects.toThrow('HTTP 502 Bad Gateway')
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(spawnMock).toHaveBeenCalledTimes(1)
})
it('does not retry gh GraphQL mutation transient failures', async () => {
execFileMock.mockImplementation((_binary, _args, _options, callback) => {
callback(
Object.assign(new Error('HTTP 502 Bad Gateway'), {
stdout: '',
stderr: 'HTTP 502 Bad Gateway'
})
)
})
spawnMock.mockImplementation(fakeSpawnReturning({ stderr: TRANSIENT_502, code: 1 }))
await expect(
ghExecFileAsync([
@@ -346,42 +250,31 @@ describe('ghExecFileAsync WSL fallback', () => {
])
).rejects.toThrow('HTTP 502 Bad Gateway')
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(spawnMock).toHaveBeenCalledTimes(1)
})
it('does not retry high-level gh edit transient failures', async () => {
execFileMock.mockImplementation((_binary, _args, _options, callback) => {
callback(
Object.assign(new Error('HTTP 502 Bad Gateway'), {
stdout: '',
stderr: 'HTTP 502 Bad Gateway'
})
)
})
spawnMock.mockImplementation(fakeSpawnReturning({ stderr: TRANSIENT_502, code: 1 }))
await expect(
ghExecFileAsync(['issue', 'edit', '5', '--repo', 'stablyai/orca'])
).rejects.toThrow('HTTP 502 Bad Gateway')
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(spawnMock).toHaveBeenCalledTimes(1)
})
it('retries cwd-less gh calls through the default WSL distro when host gh is missing', async () => {
getDefaultWslDistroMock.mockReturnValue('Ubuntu')
execFileMock
.mockImplementationOnce((_binary, _args, _options, callback) => {
callback(Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }))
})
.mockImplementationOnce((_binary, _args, _options, callback) => {
callback(null, { stdout: '{"resources":{}}', stderr: '' })
})
spawnMock
.mockImplementationOnce(fakeSpawnReturning(spawnEnoent('gh')))
.mockImplementationOnce(fakeSpawnReturning({ stdout: '{"resources":{}}' }))
await expect(ghExecFileAsync(['api', 'rate_limit'])).resolves.toEqual({
stdout: '{"resources":{}}',
stderr: ''
})
expect(execFileMock).toHaveBeenNthCalledWith(
expect(spawnMock).toHaveBeenNthCalledWith(
2,
'wsl.exe',
['-d', 'Ubuntu', '--exec', 'bash', '-c', "'gh' 'api' 'rate_limit'"],
@@ -389,53 +282,35 @@ describe('ghExecFileAsync WSL fallback', () => {
// Orca's own cwd, a deletable WSL UNC path when it was launched from a
// worktree. This global call has no repo directory at all, so nothing about
// where it runs changes.
expect.objectContaining({ cwd: expect.any(String) }),
expect.any(Function)
expect.objectContaining({ cwd: expect.any(String) })
)
})
it('checks a blocked WSL scope before repeating a native-to-WSL fallback', async () => {
getDefaultWslDistroMock.mockReturnValue('Ubuntu')
execFileMock.mockImplementation((binary, _args, _options, callback) => {
if (binary === 'gh') {
callback(Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT', stderr: '' }))
return
}
callback(
Object.assign(new Error(PRIMARY_RATE_LIMIT_STDERR), {
stdout: '',
stderr: PRIMARY_RATE_LIMIT_STDERR
})
spawnMock.mockImplementation(
fakeSpawnDispatch((program) =>
program === 'gh' ? spawnEnoent('gh') : { stderr: PRIMARY_RATE_LIMIT_STDERR, code: 1 }
)
})
)
await expect(ghExecFileAsync(['api', 'repos/acme/widgets/pulls'])).rejects.toThrow('rate limit')
await expect(ghExecFileAsync(['api', 'repos/acme/widgets/pulls'])).rejects.toMatchObject({
ghRateLimitBlocked: true
})
expect(execFileMock).toHaveBeenCalledTimes(3)
expect(execFileMock.mock.calls.map(([binary]) => binary)).toEqual(['gh', 'wsl.exe', 'gh'])
expect(spawnMock).toHaveBeenCalledTimes(3)
expect(spawnMock.mock.calls.map(([binary]) => binary)).toEqual(['gh', 'wsl.exe', 'gh'])
})
it('checks a blocked native scope before repeating a WSL-to-native fallback', async () => {
execFileMock.mockImplementation((binary, _args, _options, callback) => {
if (binary === 'wsl.exe') {
callback(
Object.assign(new Error('Command failed: wsl.exe'), {
stdout: '',
stderr: 'bash: line 1: gh: command not found\n'
})
)
return
}
callback(
Object.assign(new Error(PRIMARY_RATE_LIMIT_STDERR), {
stdout: '',
stderr: PRIMARY_RATE_LIMIT_STDERR
})
spawnMock.mockImplementation(
fakeSpawnDispatch((program) =>
program === 'wsl.exe'
? { stderr: WSL_GH_MISSING, code: 1 }
: { stderr: PRIMARY_RATE_LIMIT_STDERR, code: 1 }
)
})
)
const options = {
cwd: String.raw`\\wsl.localhost\Ubuntu\home\jinwoo\stably\noqa`
@@ -447,19 +322,12 @@ describe('ghExecFileAsync WSL fallback', () => {
ghExecFileAsync(['api', 'repos/acme/widgets/pulls'], options)
).rejects.toMatchObject({ ghRateLimitBlocked: true })
expect(execFileMock).toHaveBeenCalledTimes(3)
expect(execFileMock.mock.calls.map(([binary]) => binary)).toEqual(['wsl.exe', 'gh', 'wsl.exe'])
expect(spawnMock).toHaveBeenCalledTimes(3)
expect(spawnMock.mock.calls.map(([binary]) => binary)).toEqual(['wsl.exe', 'gh', 'wsl.exe'])
})
it('does not retry non-idempotent glab transient failures', async () => {
execFileMock.mockImplementation((_binary, _args, _options, callback) => {
callback(
Object.assign(new Error('HTTP 502 Bad Gateway'), {
stdout: '',
stderr: 'HTTP 502 Bad Gateway'
})
)
})
spawnMock.mockImplementation(fakeSpawnReturning({ stderr: TRANSIENT_502, code: 1 }))
await expect(
glabExecFileAsync(['api', '-X', 'POST', 'projects/stablyai%2Forca/issues/5/notes'], {
@@ -467,18 +335,11 @@ describe('ghExecFileAsync WSL fallback', () => {
})
).rejects.toThrow('HTTP 502 Bad Gateway')
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(spawnMock).toHaveBeenCalledTimes(1)
})
it('does not retry high-level glab update transient failures', async () => {
execFileMock.mockImplementation((_binary, _args, _options, callback) => {
callback(
Object.assign(new Error('HTTP 502 Bad Gateway'), {
stdout: '',
stderr: 'HTTP 502 Bad Gateway'
})
)
})
spawnMock.mockImplementation(fakeSpawnReturning({ stderr: TRANSIENT_502, code: 1 }))
await expect(
glabExecFileAsync(['issue', 'update', '5', '-R', 'stablyai/orca'], {
@@ -486,25 +347,21 @@ describe('ghExecFileAsync WSL fallback', () => {
})
).rejects.toThrow('HTTP 502 Bad Gateway')
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(spawnMock).toHaveBeenCalledTimes(1)
})
it('retries cwd-less glab calls through the default WSL distro when host glab is missing', async () => {
getDefaultWslDistroMock.mockReturnValue('Ubuntu')
execFileMock
.mockImplementationOnce((_binary, _args, _options, callback) => {
callback(Object.assign(new Error('spawn glab ENOENT'), { code: 'ENOENT' }))
})
.mockImplementationOnce((_binary, _args, _options, callback) => {
callback(null, { stdout: '[]', stderr: '' })
})
spawnMock
.mockImplementationOnce(fakeSpawnReturning(spawnEnoent('glab')))
.mockImplementationOnce(fakeSpawnReturning({ stdout: '[]' }))
await expect(glabExecFileAsync(['api', 'projects'])).resolves.toEqual({
stdout: '[]',
stderr: ''
})
expect(execFileMock).toHaveBeenNthCalledWith(
expect(spawnMock).toHaveBeenNthCalledWith(
2,
'wsl.exe',
['-d', 'Ubuntu', '--exec', 'bash', '-c', "'glab' 'api' 'projects'"],
@@ -512,24 +369,21 @@ describe('ghExecFileAsync WSL fallback', () => {
// Orca's own cwd, a deletable WSL UNC path when it was launched from a
// worktree. This global call has no repo directory at all, so nothing about
// where it runs changes.
expect.objectContaining({ cwd: expect.any(String) }),
expect.any(Function)
expect.objectContaining({ cwd: expect.any(String) })
)
})
it('times out the default-WSL glab fallback and waits for full tree cleanup', async () => {
vi.useFakeTimers()
getDefaultWslDistroMock.mockReturnValue('Ubuntu')
const nativeChild = createMockChildProcess(1200)
const wslChild = createMockChildProcess(2400)
const taskkill = createMockChildProcess(3600)
execFileMock
.mockImplementationOnce((_binary, _args, _options, callback) => {
callback(Object.assign(new Error('spawn glab ENOENT'), { code: 'ENOENT' }))
return nativeChild
})
.mockReturnValueOnce(wslChild)
spawnMock.mockReturnValue(taskkill)
const wslChild = createFakeSpawnedChild(2400)
const taskkill = createFakeSpawnedChild(3600)
spawnMock
.mockImplementationOnce(fakeSpawnReturning(spawnEnoent('glab')))
// Why a child that never exits: this is the wedged WSL helper the deadline
// has to reap, so nothing must settle the promise before taskkill reports.
.mockImplementationOnce(() => wslChild)
.mockImplementation(() => taskkill)
const promise = glabExecFileAsync(['auth', 'status'], { timeout: 1000 })
const rejection = expect(promise).rejects.toThrow('wsl.exe timed out.')
@@ -539,7 +393,7 @@ describe('ghExecFileAsync WSL fallback', () => {
})
await vi.advanceTimersByTimeAsync(999)
expect(spawnMock).not.toHaveBeenCalled()
expect(spawnMock).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
expect(spawnMock).toHaveBeenCalledWith(
'taskkill',
@@ -556,29 +410,24 @@ describe('ghExecFileAsync WSL fallback', () => {
it('aborts the default-WSL glab fallback with full process-tree cleanup', async () => {
getDefaultWslDistroMock.mockReturnValue('Ubuntu')
const nativeChild = createMockChildProcess(1200)
const wslChild = createMockChildProcess(2400)
const taskkill = createMockChildProcess(3600)
execFileMock
.mockImplementationOnce((_binary, _args, _options, callback) => {
callback(Object.assign(new Error('spawn glab ENOENT'), { code: 'ENOENT' }))
return nativeChild
})
.mockReturnValueOnce(wslChild)
spawnMock.mockReturnValue(taskkill)
const wslChild = createFakeSpawnedChild(2400)
const taskkill = createFakeSpawnedChild(3600)
spawnMock
.mockImplementationOnce(fakeSpawnReturning(spawnEnoent('glab')))
.mockImplementationOnce(() => wslChild)
.mockImplementation(() => taskkill)
const controller = new AbortController()
const promise = glabExecFileAsync(['auth', 'status'], { signal: controller.signal })
const rejection = expect(promise).rejects.toMatchObject({ name: 'AbortError' })
await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledTimes(2))
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2))
controller.abort()
expect(execFileMock).toHaveBeenNthCalledWith(
expect(spawnMock).toHaveBeenNthCalledWith(
2,
'wsl.exe',
['-d', 'Ubuntu', '--exec', 'bash', '-c', "'glab' 'auth' 'status'"],
expect.not.objectContaining({ signal: controller.signal }),
expect.any(Function)
expect.not.objectContaining({ signal: controller.signal })
)
expect(spawnMock).toHaveBeenCalledWith(
'taskkill',
@@ -593,40 +442,26 @@ describe('ghExecFileAsync WSL fallback', () => {
it('does not wake the default WSL distro for host-only GitLab diagnostics', async () => {
getDefaultWslDistroMock.mockReturnValue('Ubuntu')
execFileMock
.mockImplementationOnce((_binary, _args, _options, callback) => {
callback(Object.assign(new Error('spawn glab ENOENT'), { code: 'ENOENT' }))
})
.mockImplementationOnce((_binary, _args, _options, callback) => {
callback(null, { stdout: 'Logged in to gitlab.com', stderr: '' })
})
spawnMock
.mockImplementationOnce(fakeSpawnReturning(spawnEnoent('glab')))
.mockImplementationOnce(fakeSpawnReturning({ stdout: 'Logged in to gitlab.com' }))
await expect(
glabExecFileAsync(['auth', 'status'], { allowDefaultWslFallback: false })
).rejects.toThrow('spawn glab ENOENT')
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(execFileMock).toHaveBeenCalledWith(
expect(spawnMock).toHaveBeenCalledTimes(1)
expect(spawnMock).toHaveBeenCalledWith(
'glab',
['auth', 'status'],
expect.objectContaining({ cwd: undefined }),
expect.any(Function)
expect.objectContaining({ cwd: undefined })
)
})
it('still retries idempotent glab transient failures', async () => {
execFileMock
.mockImplementationOnce((_binary, _args, _options, callback) => {
callback(
Object.assign(new Error('HTTP 502 Bad Gateway'), {
stdout: '',
stderr: 'HTTP 502 Bad Gateway'
})
)
})
.mockImplementationOnce((_binary, _args, _options, callback) => {
callback(null, { stdout: '[]', stderr: '' })
})
spawnMock
.mockImplementationOnce(fakeSpawnReturning({ stderr: TRANSIENT_502, code: 1 }))
.mockImplementationOnce(fakeSpawnReturning({ stdout: '[]' }))
await expect(
glabExecFileAsync(['api', 'projects/stablyai%2Forca/issues'], {
@@ -634,7 +469,7 @@ describe('ghExecFileAsync WSL fallback', () => {
})
).resolves.toEqual({ stdout: '[]', stderr: '' })
expect(execFileMock).toHaveBeenCalledTimes(2)
expect(spawnMock).toHaveBeenCalledTimes(2)
})
it('resolves fallback to the overridden distro if configured, and falls back to default WSL distro otherwise', async () => {
@@ -642,60 +477,54 @@ describe('ghExecFileAsync WSL fallback', () => {
setDefaultWslDistroOverride('Debian')
getDefaultWslDistroMock.mockReturnValue('Ubuntu')
execFileMock
.mockImplementationOnce((_binary, _args, _options, callback) => {
callback(Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }))
})
.mockImplementationOnce((binary, args, _options, callback) => {
if (binary === 'wsl.exe' && args.includes('Debian')) {
callback(null, { stdout: 'Logged in to github.com as override', stderr: '' })
return
}
callback(new Error('Wrong distro fallback'))
})
spawnMock
.mockImplementationOnce(fakeSpawnReturning(spawnEnoent('gh')))
.mockImplementationOnce(
fakeSpawnDispatch((program, args) =>
program === 'wsl.exe' && args.includes('Debian')
? { stdout: 'Logged in to github.com as override' }
: { stderr: 'Wrong distro fallback', code: 1 }
)
)
await expect(ghExecFileAsync(['auth', 'status'])).resolves.toEqual({
stdout: 'Logged in to github.com as override',
stderr: ''
})
expect(execFileMock).toHaveBeenCalledTimes(2)
expect(execFileMock).toHaveBeenNthCalledWith(
expect(spawnMock).toHaveBeenCalledTimes(2)
expect(spawnMock).toHaveBeenNthCalledWith(
2,
'wsl.exe',
['-d', 'Debian', '--exec', 'bash', '-c', "'gh' 'auth' 'status'"],
expect.any(Object),
expect.any(Function)
expect.any(Object)
)
// 2) Test without override (should use default 'Ubuntu')
execFileMock.mockClear()
spawnMock.mockClear()
setDefaultWslDistroOverride(null)
execFileMock
.mockImplementationOnce((_binary, _args, _options, callback) => {
callback(Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }))
})
.mockImplementationOnce((binary, args, _options, callback) => {
if (binary === 'wsl.exe' && args.includes('Ubuntu')) {
callback(null, { stdout: 'Logged in to github.com as default', stderr: '' })
return
}
callback(new Error('Wrong distro fallback'))
})
spawnMock
.mockImplementationOnce(fakeSpawnReturning(spawnEnoent('gh')))
.mockImplementationOnce(
fakeSpawnDispatch((program, args) =>
program === 'wsl.exe' && args.includes('Ubuntu')
? { stdout: 'Logged in to github.com as default' }
: { stderr: 'Wrong distro fallback', code: 1 }
)
)
await expect(ghExecFileAsync(['auth', 'status'])).resolves.toEqual({
stdout: 'Logged in to github.com as default',
stderr: ''
})
expect(execFileMock).toHaveBeenCalledTimes(2)
expect(execFileMock).toHaveBeenNthCalledWith(
expect(spawnMock).toHaveBeenCalledTimes(2)
expect(spawnMock).toHaveBeenNthCalledWith(
2,
'wsl.exe',
['-d', 'Ubuntu', '--exec', 'bash', '-c', "'gh' 'auth' 'status'"],
expect.any(Object),
expect.any(Function)
expect.any(Object)
)
})
})
@@ -32,9 +32,17 @@ async function createRepo(): Promise<string> {
return repoPath
}
// Why unique across calls: an empty commit's hash covers only parent, tree, message and a
// one-second-granularity timestamp. On a fast runner the whole 100-commit build finishes inside
// one second, so a post-reset `commit 0` off the same fork point hashed identically to the first
// `commit 0` of the chain and Git handed back that same object — leaving the branch 99/0 apart
// instead of 100/1.
let emptyCommitSequence = 0
function commitEmpty(repoPath: string, count: number): void {
for (let index = 0; index < count; index += 1) {
git(repoPath, ['commit', '--quiet', '--allow-empty', '-m', `commit ${index}`])
emptyCommitSequence += 1
git(repoPath, ['commit', '--quiet', '--allow-empty', '-m', `commit ${emptyCommitSequence}`])
}
}
@@ -1,15 +1,15 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fakeSpawnDispatch } from '../../shared/child-process/__fixtures__/fake-spawned-child'
import type * as WslModule from '../wsl'
const { execFileMock, execFileSyncMock, spawnMock, getDefaultWslDistroMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
const { execFileSyncMock, spawnMock, getDefaultWslDistroMock } = vi.hoisted(() => ({
execFileSyncMock: vi.fn(),
spawnMock: vi.fn(),
getDefaultWslDistroMock: vi.fn()
}))
vi.mock('child_process', () => ({
execFile: execFileMock,
execFile: vi.fn(),
execFileSync: execFileSyncMock,
spawn: spawnMock
}))
@@ -26,17 +26,16 @@ describe('glab known-hosts probe on Windows', () => {
const originalPlatform = process.platform
const hostGlabMissingWslLoggedIn = (): void => {
execFileMock.mockImplementation((binary, _args, _options, callback) => {
if (binary === 'wsl.exe') {
callback(null, { stdout: 'Logged in to gitlab.wsl.test as user', stderr: '' })
return
}
callback(Object.assign(new Error('spawn glab ENOENT'), { code: 'ENOENT' }))
})
spawnMock.mockImplementation(
fakeSpawnDispatch((program) =>
program === 'wsl.exe'
? { stdout: 'Logged in to gitlab.wsl.test as user' }
: { spawnError: Object.assign(new Error('spawn glab ENOENT'), { code: 'ENOENT' }) }
)
)
}
beforeEach(() => {
execFileMock.mockReset()
spawnMock.mockReset()
getDefaultWslDistroMock.mockReset()
getDefaultWslDistroMock.mockReturnValue('Ubuntu')
@@ -56,12 +55,11 @@ describe('glab known-hosts probe on Windows', () => {
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com'])
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(execFileMock).toHaveBeenCalledWith(
expect(spawnMock).toHaveBeenCalledTimes(1)
expect(spawnMock).toHaveBeenCalledWith(
'glab',
['auth', 'status'],
expect.objectContaining({ cwd: undefined }),
expect.any(Function)
expect.objectContaining({ cwd: undefined })
)
})
@@ -73,15 +71,14 @@ describe('glab known-hosts probe on Windows', () => {
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual(['gitlab.com', 'gitlab.wsl.test'])
expect(execFileMock).toHaveBeenCalledWith(
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu', '--exec', 'bash', '-c', "'glab' 'auth' 'status'"],
// Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit
// Orca's own cwd, a deletable WSL UNC path when it was launched from a
// worktree. This probe has no repo directory at all, so nothing about where
// it runs changes. The native `glab` assertion above keeps `undefined`.
expect.objectContaining({ cwd: expect.any(String) }),
expect.any(Function)
expect.objectContaining({ cwd: expect.any(String) })
)
})
})
@@ -10,6 +10,7 @@ import {
import {
installRemoteWatcher,
reinstallRemoteWatchersForConnection,
scheduleDormantRemoteWatcherRearm,
scheduleRemoteWatcherRetry
} from './filesystem-watcher-remote-controller'
import { rememberDesiredRemoteWatcher } from './filesystem-watcher-remote-desired'
@@ -41,6 +42,12 @@ export function registerFilesystemWatcherHandlers(): void {
args.connectionId,
args.worktreePath
)
if (result === 'capacity') {
// Why straight to the dormant backoff: the cap is full until some other root is released,
// which a 1 Hz reinstall cannot bring about — it only adds relay load per refused root.
scheduleDormantRemoteWatcherRearm(args.connectionId, args.worktreePath)
return
}
if (result === 'unavailable') {
if (!watcherLifecycleState.loggedUnavailableRemoteWatchers.has(key)) {
watcherLifecycleState.loggedUnavailableRemoteWatchers.add(key)
@@ -36,7 +36,10 @@ export type RemoteWatcherState = {
batch: RemoteWatcherEventBatch
}
export type RemoteWatcherInstallResult = 'installed' | 'unavailable' | 'cancelled'
// Why 'capacity' is not 'unavailable': the relay refused because its watch-root cap is full, which is
// a decision, not a fault. The 1 Hz unavailable retry cannot change that answer, and a folder
// workspace whose repo count exceeds the cap turns it into a permanent per-root storm (#11196).
export type RemoteWatcherInstallResult = 'installed' | 'unavailable' | 'capacity' | 'cancelled'
export type RemoteWatcherResyncState = {
lastSentAt: number
@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { handleMock, getSshFilesystemProviderMock } = vi.hoisted(() => ({
handleMock: vi.fn(),
getSshFilesystemProviderMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: { handle: handleMock }
}))
vi.mock('fs/promises', () => ({ stat: vi.fn() }))
vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() }))
vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: vi.fn() }))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: getSshFilesystemProviderMock,
onSshFilesystemProviderRegistered: () => () => {}
}))
import { WATCH_ROOT_CAPACITY_REFUSAL_MESSAGE } from '../../shared/watch-root-capacity-refusal'
import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher'
import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state'
import { getRemoteWatcherKey } from './filesystem-watcher-paths'
type HandlerMap = Record<string, (_event: unknown, args: unknown) => unknown>
describe('remote filesystem watcher capacity refusals', () => {
const handlers: HandlerMap = {}
beforeEach(async () => {
handleMock.mockReset()
getSshFilesystemProviderMock.mockReset()
for (const key of Object.keys(handlers)) {
delete handlers[key]
}
handleMock.mockImplementation((channel, handler) => {
handlers[channel] = handler
})
registerFilesystemWatcherHandlers()
await closeAllWatchers()
})
afterEach(async () => {
for (const dormant of watcherLifecycleState.dormantRemoteWatchers.values()) {
clearTimeout(dormant.timer)
}
watcherLifecycleState.dormantRemoteWatchers.clear()
await closeAllWatchers()
vi.useRealTimers()
})
// A folder workspace with more repos than the relay's watch-root cap leaves every excess root
// permanently refused; the 1 Hz unavailable ladder then bills the relay one install per root per
// second, which is the load that pinned it (#11196).
it('does not retry a relay watch-root capacity refusal on the fast ladder', async () => {
vi.useFakeTimers()
const watchMock = vi.fn(async () => {
throw new Error(WATCH_ROOT_CAPACITY_REFUSAL_MESSAGE)
})
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 }
const args = { worktreePath: '/home/me/repos/one', connectionId: 'conn-capacity' }
await handlers['fs:watchWorktree']({ sender }, args)
const key = getRemoteWatcherKey(args.connectionId, args.worktreePath)
expect(watchMock).toHaveBeenCalledTimes(1)
expect(watcherLifecycleState.pendingRemoteWatcherRetries.has(key)).toBe(false)
expect(watcherLifecycleState.dormantRemoteWatchers.has(key)).toBe(true)
await vi.advanceTimersByTimeAsync(10_000)
expect(watchMock).toHaveBeenCalledTimes(1)
})
it('still retries an ordinary unavailable install on the fast ladder', async () => {
vi.useFakeTimers()
const watchMock = vi.fn(async () => {
throw new Error('Relay channel lost')
})
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 }
const args = { worktreePath: '/home/me/repos/two', connectionId: 'conn-unavailable' }
await handlers['fs:watchWorktree']({ sender }, args)
const key = getRemoteWatcherKey(args.connectionId, args.worktreePath)
expect(watcherLifecycleState.pendingRemoteWatcherRetries.has(key)).toBe(true)
await vi.advanceTimersByTimeAsync(2_500)
expect(watchMock.mock.calls.length).toBeGreaterThan(1)
})
})
@@ -63,7 +63,8 @@ export function reinstallRemoteWatchersForConnection(connectionId: string): void
reinstallRemoteWatchersForConnectionCore(connectionId, {
install: installRemoteWatcher,
requestResync: requestRemoteWatcherResync,
scheduleRetry: scheduleRemoteWatcherRetry
scheduleRetry: scheduleRemoteWatcherRetry,
scheduleDormant: scheduleDormantRemoteWatcherRearm
})
}
@@ -97,8 +97,8 @@ async function rearmDormantRemoteWatcher(
worktreePath,
listeners.filter((_, index) => results[index] === 'installed')
)
// Why: 'cancelled' means shutdown or the last listener left, so only 'unavailable' stays dormant.
if (results.some((result) => result === 'unavailable')) {
// Why: 'cancelled' means shutdown or the last listener left, so only a refusal stays dormant.
if (results.some((result) => result === 'unavailable' || result === 'capacity')) {
scheduleDormantRemoteWatcherRearmCore(
connectionId,
worktreePath,
@@ -1,5 +1,6 @@
import type { WebContents } from 'electron'
import type { FsChangedPayload } from '../../shared/filesystem-entry-types'
import { isWatchRootCapacityRefusal } from '../../shared/watch-root-capacity-refusal'
import {
WATCH_BATCH_MAX_WAIT_MS,
WATCH_BATCH_TRAILING_MS
@@ -202,6 +203,10 @@ async function doInstallRemoteWatcher(
if (cancelToken.cancelled || cancelToken.abortController.signal.aborted) {
return 'cancelled'
}
if (isWatchRootCapacityRefusal(err)) {
console.warn(`[filesystem-watcher] relay watch-root capacity reached for ${key}`)
return 'capacity'
}
console.warn(`[filesystem-watcher] SSH watcher unavailable for ${key}:`, err)
return 'unavailable'
} finally {
@@ -29,6 +29,7 @@ export function reinstallRemoteWatchersForConnectionCore(
install: InstallRemoteWatcher
requestResync: RequestRemoteWatcherResync
scheduleRetry: ScheduleRemoteWatcherRetry
scheduleDormant: (connectionId: string, worktreePath: string) => void
}
): void {
if (watcherLifecycleState.remoteWatchersClosed) {
@@ -90,6 +91,10 @@ export function reinstallRemoteWatchersForConnectionCore(
desired.worktreePath,
listeners.filter((_, index) => results[index] === 'installed')
)
if (results.some((result) => result === 'capacity')) {
dependencies.scheduleDormant(desired.connectionId, desired.worktreePath)
return
}
if (results.some((result) => result === 'unavailable')) {
for (const listener of listeners) {
dependencies.scheduleRetry(
@@ -9,6 +9,7 @@ import {
} from './filesystem-watcher-listener-lifecycle'
import {
installRemoteWatcher,
scheduleDormantRemoteWatcherRearm,
scheduleRemoteWatcherRetry
} from './filesystem-watcher-remote-controller'
@@ -75,7 +76,9 @@ export async function restoreRemoteWatcherAfterFailedRemoval(
continue
}
const result = await installRemoteWatcher(sender, connectionId, worktreePath)
if (result === 'unavailable') {
if (result === 'capacity') {
scheduleDormantRemoteWatcherRearm(connectionId, worktreePath)
} else if (result === 'unavailable') {
scheduleRemoteWatcherRetry(sender, connectionId, worktreePath)
}
sender.send('fs:changed', {
@@ -100,6 +100,12 @@ export function scheduleRemoteWatcherRetryCore(
listeners.filter((_, index) => results[index] === 'installed')
)
}
// Why capacity leaves the fast window: the relay is refusing on a full watch-root cap, and a
// 1 Hz reinstall per refused root is exactly the load that keeps the cap busy (#11196).
if (results.some((result) => result === 'capacity')) {
dependencies.scheduleDormant(connectionId, worktreePath)
return
}
// Why: don't re-arm on 'cancelled' (renderer stopped watching) — it would fire a stale overflow when the 60s window expires.
if (results.some((result) => result === 'unavailable')) {
for (const listener of listeners) {
@@ -223,7 +223,13 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
signal: controller?.signal
})
}
return await listQuickOpenFiles(args.rootPath, store, args.excludePaths, controller?.signal)
return await listQuickOpenFiles(
args.rootPath,
store,
args.excludePaths,
controller?.signal,
args.maxResults
)
} finally {
listFilesCancellations.finish(event, args.requestToken, controller)
}
@@ -8,6 +8,7 @@ import {
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { createDaemonActiveProviderFixtures } from './pty-ipc-daemon-provider-fixtures'
import { makePaneKey } from '../../shared/stable-pane-id'
import { SshPtyAbsentFromRelayError } from '../providers/ssh-pty-errors'
import {
registerPtyHandlers,
registerSshPtyProvider,
@@ -459,7 +460,9 @@ describe('registerPtyHandlers', () => {
})
it('marks a caller-supplied SSH session expired when remote reattach is gone', async () => {
const sshSpawn = vi.fn(async () => {
throw new Error('SSH_SESSION_EXPIRED: remote-pty')
// The class, not the message: `SSH_SESSION_EXPIRED` is also what a live PTY whose
// source stream needs restoring refuses with, and only this one is host-reported absence.
throw new SshPtyAbsentFromRelayError('SSH_SESSION_EXPIRED: remote-pty')
})
const store = {
markSshRemotePtyLease: vi.fn(),
@@ -509,10 +512,70 @@ describe('registerPtyHandlers', () => {
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'remote-pty', 'expired')
})
it('leaves the lease alone when the refusal did not observe the process', async () => {
// A `restoreRequired` reattach is refused with the SAME `SSH_SESSION_EXPIRED` text, and it
// means the opposite: the PTY is live, only its source stream could not be resumed. The
// lease and the in-memory ownership are between them this client's only record that the
// remote process exists, and #9819's sweep reads a PTY it has no record of as one it may
// SIGKILL on the next connect. Erasing them here is how a live shell gets reaped.
const sshSpawn = vi.fn(async () => {
throw new Error('SSH_SESSION_EXPIRED: remote-pty')
})
const store = {
markSshRemotePtyLease: vi.fn(),
clearSshRemotePtyKillIntent: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: sshSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
handlers.clear()
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
store as never
)
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {},
connectionId: 'ssh-1',
sessionId: 'remote-pty'
})
).rejects.toThrow('SSH_SESSION_EXPIRED: remote-pty')
// The spawn still fails; what must not happen is the destructive bookkeeping.
expect(store.markSshRemotePtyLease).not.toHaveBeenCalled()
})
it('marks a scoped SSH session expired using the raw relay lease id', async () => {
const scopedPtyId = 'ssh:ssh-1@@remote-pty'
const sshSpawn = vi.fn(async () => {
throw new Error('SSH_SESSION_EXPIRED: remote-pty')
// The class, not the message: `SSH_SESSION_EXPIRED` is also what a live PTY whose
// source stream needs restoring refuses with, and only this one is host-reported absence.
throw new SshPtyAbsentFromRelayError('SSH_SESSION_EXPIRED: remote-pty')
})
const store = {
markSshRemotePtyLease: vi.fn(),
@@ -12,6 +12,9 @@ import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import { makePaneKey } from '../../shared/stable-pane-id'
import { OrcaRuntimeService } from '../runtime/orca-runtime'
import type { RuntimeResolvedWorktreeCache } from '../runtime/runtime-resolved-worktree-cache'
import type { ResolvedWorktree } from '../runtime/runtime-worktree-path-identity'
import { getWorktreeScanMutationRevision } from '../local-worktree-scan-generation'
import {
registerPtyHandlers,
clearProviderPtyState,
@@ -359,15 +362,22 @@ describe('registerPtyHandlers', () => {
} as never)
// Why: selector resolution shells out to git for real repos; prime the
// resolved-worktree cache so this headless fixture resolves offline.
//
// Why through getSnapshot and not a hand-written `resolved` entry: the cache decides freshness
// from fields it stamps itself, so a literal that mirrors them is a second copy of that
// contract and goes stale the moment a field is added. Let the cache stamp its own entry.
const worktreeResolutionInternals = runtime as unknown as {
buildResolvedWorktreeFromId(id: string): unknown
resolvedWorktrees: object
buildResolvedWorktreeFromId(id: string): ResolvedWorktree
resolvedWorktrees: RuntimeResolvedWorktreeCache
}
Reflect.set(worktreeResolutionInternals.resolvedWorktrees, 'resolved', {
worktrees: [worktreeResolutionInternals.buildResolvedWorktreeFromId(worktreeId)],
platformByRepoId: new Map([[repo.id, process.platform]]),
expiresAt: Date.now() + 60_000
})
await worktreeResolutionInternals.resolvedWorktrees.getSnapshot(
async () => ({
worktrees: [worktreeResolutionInternals.buildResolvedWorktreeFromId(worktreeId)],
platformByRepoId: new Map([[repo.id, process.platform]])
}),
60_000,
getWorktreeScanMutationRevision()
)
setLocalPtyProvider({
spawn: vi.fn(async () => ({
id: ptyId,
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import { spawnMock, openCodeClearPtyMock, piClearPtyMock } from './pty-ipc-mock-registry'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { makePaneKey } from '../../shared/stable-pane-id'
import { SSH_SESSION_EXPIRED_ERROR } from '../providers/ssh-pty-errors'
import { SSH_SESSION_EXPIRED_ERROR, SshPtyAbsentFromRelayError } from '../providers/ssh-pty-errors'
import {
registerPtyHandlers,
registerSshPtyProvider,
@@ -446,7 +446,9 @@ describe('registerPtyHandlers', () => {
const remoteWrite = vi.fn()
registerSshPtyProvider('ssh-expired-runtime', {
spawn: vi.fn(async () => {
throw new Error(`${SSH_SESSION_EXPIRED_ERROR}: relay-pty`)
// The class, not the message: `SSH_SESSION_EXPIRED` is also what a live PTY whose source
// stream needs restoring refuses with, and only this one is host-reported absence.
throw new SshPtyAbsentFromRelayError(`${SSH_SESSION_EXPIRED_ERROR}: relay-pty`)
}),
write: remoteWrite,
resize: vi.fn(),
@@ -531,4 +533,105 @@ describe('registerPtyHandlers', () => {
unregisterSshPtyProvider('ssh-expired-runtime')
}
})
it('leaves a runtime-owned lease alone when the refusal did not observe the process', async () => {
// The `restoreRequired` twin of the case above: same `SSH_SESSION_EXPIRED` text, opposite
// meaning — the PTY is live and only its source stream needs rebuilding. Expiring the lease
// and dropping ownership erases this client's only record of a running remote process, and
// #9819's sweep reads a PTY it has no record of as one it may SIGKILL on the next connect.
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
connectionId?: string
tabId?: string
leafId?: string
sessionId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
const appPtyId = 'ssh:ssh-live-runtime@@relay-pty'
const remoteWrite = vi.fn()
registerSshPtyProvider('ssh-live-runtime', {
spawn: vi.fn(async () => {
throw new Error(`${SSH_SESSION_EXPIRED_ERROR}: relay-pty`)
}),
write: remoteWrite,
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
upsertSshRemotePtyLease: vi.fn(),
persistPtyBinding: vi.fn(),
removeSshRemotePtyLease: vi.fn(),
markSshRemotePtyLease: vi.fn(),
clearSshRemotePtyKillIntent: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
try {
setPtyOwnership(appPtyId, 'ssh-live-runtime')
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '11111111-1111-4111-8111-111111111111'
await expect(
spawnController.spawn({
cols: 80,
rows: 24,
connectionId: 'ssh-live-runtime',
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
sessionId: appPtyId,
persistHostSessionBinding: true
})
).rejects.toThrow(SSH_SESSION_EXPIRED_ERROR)
expect(store.markSshRemotePtyLease).not.toHaveBeenCalled()
expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled()
expect(store.persistPtyBinding).not.toHaveBeenCalled()
// Still routable: the client kept its handle on a process that is still running.
getPtyWriteListener()(mainWindowIpcEvent, { id: appPtyId, data: 'echo still-here' })
expect(remoteWrite).toHaveBeenCalledWith(appPtyId, 'echo still-here')
} finally {
deletePtyOwnership(appPtyId)
unregisterSshPtyProvider('ssh-live-runtime')
}
})
})
+15 -1
View File
@@ -1,6 +1,7 @@
import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach'
import {
SSH_SESSION_EXPIRED_ERROR,
isSshPtyAbsentFromRelayError,
isSshPtyIdentityMismatchError
} from '../../../providers/ssh-pty-errors'
import { classifyError } from '../../../telemetry/classify-error'
@@ -144,6 +145,15 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<void> {
Boolean(args.connectionId) &&
(spawnError.message.includes(SSH_SESSION_EXPIRED_ERROR) ||
rawMessage.includes(SSH_SESSION_EXPIRED_ERROR))
// The message alone cannot carry this decision. All three reattach refusals are minted with the
// same `SSH_SESSION_EXPIRED` text, and only one of them observed the process: `restoreRequired`
// means the PTY is LIVE and only its source stream needs rebuilding, which
// `ssh-pty-errors.ts` states outright. Expiring its lease and deleting its ownership erases
// this client's last record of a running remote process, and #9819's sweep reads a PTY it has
// no record of as one it may SIGKILL on the next connect. Only positive host-reported absence
// may reach that bookkeeping; being too strict here merely leaves a dead lease for the next
// reattach to retire on real host evidence.
const relayReportedSessionAbsent = isExpiredSshSession && isSshPtyAbsentFromRelayError(err)
const exitedBeforeSpawnReply =
ctx.rejectedRegistrationCandidate?.exitedBeforeSpawnReply === true
if (ctx.effectiveSessionAppId !== undefined) {
@@ -157,7 +167,11 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<void> {
ptySizes.delete(ctx.effectiveSessionAppId)
}
}
if (args.connectionId && ctx.effectiveSessionRelayId !== undefined && isExpiredSshSession) {
if (
args.connectionId &&
ctx.effectiveSessionRelayId !== undefined &&
relayReportedSessionAbsent
) {
// Why: expired remote reattach = relay already dropped the PTY; clear the lease so writes can't restore the stale binding.
if (ctx.effectiveSessionAppId !== undefined && !isIdentityMismatch) {
clearProviderPtyState(ctx.effectiveSessionAppId)
+15 -1
View File
@@ -13,6 +13,7 @@ import { clearProviderPtyState } from '../provider/state-cleanup'
import { isProviderAgentSessionOwnerLive, normalizeNodePtySpawnError } from '../provider/liveness'
import {
SSH_SESSION_EXPIRED_ERROR,
isSshPtyAbsentFromRelayError,
isSshPtyIdentityMismatchError
} from '../../../providers/ssh-pty-errors'
import type { RuntimePtySpawnState } from './spawn-state'
@@ -224,6 +225,15 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise
Boolean(args.connectionId) &&
(spawnError.message.includes(SSH_SESSION_EXPIRED_ERROR) ||
rawMessage.includes(SSH_SESSION_EXPIRED_ERROR))
// The message alone cannot carry this decision. All three reattach refusals are minted with the
// same `SSH_SESSION_EXPIRED` text, and only one of them observed the process: `restoreRequired`
// means the PTY is LIVE and only its source stream needs rebuilding, which
// `ssh-pty-errors.ts` states outright. Expiring its lease and deleting its ownership erases
// this client's last record of a running remote process, and #9819's sweep reads a PTY it has
// no record of as one it may SIGKILL on the next connect. Only positive host-reported absence
// may reach that bookkeeping; being too strict here merely leaves a dead lease for the next
// reattach to retire on real host evidence.
const relayReportedSessionAbsent = isExpiredSshSession && isSshPtyAbsentFromRelayError(err)
const exitedBeforeSpawnReply =
ctx.rejectedRegistrationCandidate?.exitedBeforeSpawnReply === true
if (ctx.effectiveSessionAppId !== undefined) {
@@ -237,7 +247,11 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise
ptySizes.delete(ctx.effectiveSessionAppId)
}
}
if (args.connectionId && ctx.effectiveSessionRelayId !== undefined && isExpiredSshSession) {
if (
args.connectionId &&
ctx.effectiveSessionRelayId !== undefined &&
relayReportedSessionAbsent
) {
if (ctx.effectiveSessionAppId !== undefined && !isIdentityMismatch) {
clearProviderPtyState(ctx.effectiveSessionAppId)
deletePtyOwnership(ctx.effectiveSessionAppId)
@@ -77,8 +77,11 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => {
const handlers = new Map<string, (event: unknown, args: unknown) => unknown>()
const muxByTargetId = new Map<string, { request: ReturnType<typeof vi.fn> }>()
const getRepoMock = vi.fn<Store['getRepo']>()
// Ownership resolution reads the catalog, not one id-keyed row, so the fake has to project one.
const KNOWN_REPO_IDS = ['repo-target-1', 'repo-target-2', 'repo-reset', 'repo-newer']
const store = {
getRepo: getRepoMock
getRepo: getRepoMock,
getRepos: () => KNOWN_REPO_IDS.map((repoId) => getRepoMock(repoId)).filter(Boolean)
} as unknown as Store
const target: SshTarget = {
@@ -0,0 +1,150 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Store } from '../persistence'
import {
REMOTE_WORKSPACE_STALE_NOTIFICATION,
type RemoteWorkspaceChangedEvent,
type RemoteWorkspaceSession
} from '../../shared/remote-workspace-types'
const { getActiveMultiplexerMock, getSshConnectionStoreMock } = vi.hoisted(() => ({
getActiveMultiplexerMock: vi.fn(),
getSshConnectionStoreMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: { handle: vi.fn(), removeHandler: vi.fn() }
}))
vi.mock('./ssh', () => ({
getActiveMultiplexer: getActiveMultiplexerMock,
getSshConnectionStore: getSshConnectionStoreMock
}))
vi.mock('./remote-workspace-events', () => ({
registerRemoteWorkspaceNotificationHandler: vi.fn(() => vi.fn())
}))
import {
_resetRemoteWorkspaceCachesForTests,
handleRemoteWorkspaceNotification,
registerRemoteWorkspaceHandlers
} from './remote-workspace'
function session(activeTabId: string): RemoteWorkspaceSession {
return {
activeWorktreePath: '/remote/worktree',
activeTabId,
tabsByWorktreePath: {
'/remote/worktree': [{ id: activeTabId, worktreePath: '/remote/worktree' } as never]
},
terminalLayoutsByTabId: {}
}
}
describe('workspace.stale resync', () => {
const sent: RemoteWorkspaceChangedEvent[] = []
const request = vi.fn()
const store = { getRepo: vi.fn(), getWorkspaceSession: vi.fn() } as unknown as Store
beforeEach(() => {
sent.length = 0
request.mockReset()
_resetRemoteWorkspaceCachesForTests()
getActiveMultiplexerMock.mockReset()
getActiveMultiplexerMock.mockImplementation(() => ({ request }))
getSshConnectionStoreMock.mockReset()
getSshConnectionStoreMock.mockImplementation(() => ({
getTarget: (id: string) => ({ id, host: 'example.test', username: 'dev' }),
listTargets: () => []
}))
const win = {
isDestroyed: () => false,
webContents: {
send: (_channel: string, event: RemoteWorkspaceChangedEvent) => sent.push(event)
}
}
registerRemoteWorkspaceHandlers(store, () => win as never)
})
it('re-reads the snapshot through workspace.get and publishes it to the renderer', async () => {
request.mockResolvedValue({
namespace: 'target-1',
revision: 12,
updatedAt: 5,
schemaVersion: 1,
session: session('tab-from-other-device')
})
handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, {
namespace: 'target-1'
})
await vi.waitFor(() => expect(sent).toHaveLength(1))
expect(request).toHaveBeenCalledWith('workspace.get', { namespace: expect.any(String) })
expect(sent[0].targetId).toBe('target-1')
expect(sent[0].snapshot.revision).toBe(12)
expect(sent[0].snapshot.session.activeTabId).toBe('tab-from-other-device')
// The marker names no author, so the renderer's own-echo filter must not discard the resync.
expect(sent[0].sourceClientId).toBeUndefined()
})
it('collapses a burst of markers into one extra read rather than one read per marker', async () => {
const released: ((value: unknown) => void)[] = []
request.mockImplementation(
() =>
new Promise((resolve) => {
released.push(resolve)
})
)
for (let i = 0; i < 4; i++) {
handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, {
namespace: 'target-1'
})
}
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1))
request.mockResolvedValue({
namespace: 'target-1',
revision: 3,
updatedAt: 1,
schemaVersion: 1,
session: session('tab-a')
})
released[0]?.({
namespace: 'target-1',
revision: 2,
updatedAt: 1,
schemaVersion: 1,
session: session('tab-a')
})
// Exactly one follow-up read for the markers that landed mid-flight: never zero, never four.
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2))
await Promise.resolve()
expect(request).toHaveBeenCalledTimes(2)
})
it('stays silent when the re-read finds the session it already had', async () => {
request.mockResolvedValue({
namespace: 'target-1',
revision: 4,
updatedAt: 1,
schemaVersion: 1,
session: session('tab-a')
})
handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, {
namespace: 'target-1'
})
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(sent).toHaveLength(1))
handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, {
namespace: 'target-1'
})
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2))
await Promise.resolve()
expect(sent).toHaveLength(1)
})
})
@@ -0,0 +1,62 @@
import type { RemoteWorkspaceObservedSnapshot } from '../../shared/remote-workspace-types'
import type { SshTarget } from '../../shared/ssh-types'
import { getRemoteSnapshot } from './remote-workspace-relay-sync'
import { getCachedRemoteWorkspaceSnapshot } from './remote-workspace-snapshot-cache'
import { remoteWorkspaceSessionMatchesSnapshot } from './remote-workspace-snapshot-normalization'
type PendingResync = { promise: Promise<void>; requeued: boolean }
const pendingByTargetId = new Map<string, PendingResync>()
export function _resetRemoteWorkspaceStaleResyncForTests(): void {
pendingByTargetId.clear()
}
export function isRemoteWorkspaceResyncInFlight(targetId: string): boolean {
return pendingByTargetId.has(targetId)
}
/**
* The relay told us it could not deliver a snapshot, so pull it. `workspace.get` is a response, and
* responses are admitted against the megabyte-scale control/legacy-response budget rather than the
* single ~12KB producer frame that refused the broadcast — the payload was never too big for the
* link, only for that one lane.
*/
export function resyncStaleRemoteWorkspace(
target: SshTarget,
deliver: (snapshot: RemoteWorkspaceObservedSnapshot) => void,
onError: (error: unknown) => void = () => {}
): Promise<void> {
const existing = pendingByTargetId.get(target.id)
if (existing) {
// Why: a burst of markers must collapse to one extra read, but never to zero — a marker that
// arrived while a read was already in flight may describe a revision that read did not see.
existing.requeued = true
return existing.promise
}
const pending: PendingResync = { requeued: false, promise: Promise.resolve() }
pending.promise = (async () => {
try {
do {
pending.requeued = false
const previous = getCachedRemoteWorkspaceSnapshot(target.id)
const snapshot = await getRemoteSnapshot(target)
if (!snapshot) {
return
}
// Suppress the echo: our own patch response already cached this session, and re-publishing it
// makes the renderer rehydrate a state it authored.
if (remoteWorkspaceSessionMatchesSnapshot(previous, snapshot.session)) {
continue
}
deliver(snapshot)
} while (pending.requeued)
} catch (error) {
onError(error)
} finally {
pendingByTargetId.delete(target.id)
}
})()
pendingByTargetId.set(target.id, pending)
return pending.promise
}
+2
View File
@@ -153,8 +153,10 @@ describe('remoteWorkspace:setForConnectedTargets', () => {
const muxByTargetId = new Map<string, { request: ReturnType<typeof vi.fn> }>()
const getRepoMock = vi.fn<Store['getRepo']>()
const getWorkspaceSessionMock = vi.fn<Store['getWorkspaceSession']>()
// Ownership resolution reads the catalog, not one id-keyed row, so the fake has to project one.
const store = {
getRepo: getRepoMock,
getRepos: () => [getRepoMock('repo-target-1')].filter(Boolean),
getWorkspaceSession: getWorkspaceSessionMock
} as unknown as Store
+55 -21
View File
@@ -2,14 +2,20 @@ import { ipcMain, type BrowserWindow } from 'electron'
import type { Store } from '../persistence'
import { getActiveMultiplexer, getSshConnectionStore } from './ssh'
import { exportRemoteWorkspaceSession } from '../../shared/remote-workspace-session-projection'
import type {
RemoteWorkspaceChangedEvent,
RemoteWorkspaceObservedPatchResult,
RemoteWorkspaceSession
import {
REMOTE_WORKSPACE_CHANGED_NOTIFICATION,
REMOTE_WORKSPACE_STALE_NOTIFICATION,
type RemoteWorkspaceChangedEvent,
type RemoteWorkspaceObservedPatchResult,
type RemoteWorkspaceObservedSnapshot,
type RemoteWorkspaceSession
} from '../../shared/remote-workspace-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
import { parseExecutionHostId } from '../../shared/execution-host'
import {
createRepoRowExecutionHostLookup,
resolveWorktreeExecutionHost
} from '../../shared/worktree-execution-host-resolution'
import { getRemoteWorkspaceNamespace } from './remote-workspace-namespace'
import { registerRemoteWorkspaceNotificationHandler } from './remote-workspace-events'
import { CLIENT_ID } from './remote-workspace-client-identity'
@@ -29,6 +35,10 @@ import {
rememberRemoteWorkspaceSnapshot
} from './remote-workspace-snapshot-cache'
import { normalizeSnapshot } from './remote-workspace-snapshot-normalization'
import {
_resetRemoteWorkspaceStaleResyncForTests,
resyncStaleRemoteWorkspace
} from './remote-workspace-stale-resync'
let mainWindowGetter: (() => BrowserWindow | null) | null = null
let unregisterRemoteWorkspaceNotifications: (() => void) | null = null
@@ -36,6 +46,7 @@ let unregisterRemoteWorkspaceNotifications: (() => void) | null = null
export function _resetRemoteWorkspaceCachesForTests(): void {
clearRemoteWorkspaceSnapshotCache()
clearRemoteWorkspacePatchTails()
_resetRemoteWorkspaceStaleResyncForTests()
}
export function _getRemoteWorkspaceCacheSizesForTests(): {
@@ -100,12 +111,15 @@ function targetForWorktree(
worktreeId: string,
executionHostId?: string
): string | null {
const parsedHostId = parseExecutionHostId(executionHostId)
if (parsedHostId?.kind === 'ssh') {
return parsedHostId.targetId
}
const repoId = getRepoIdFromWorktreeId(worktreeId)
return store.getRepo(repoId)?.connectionId ?? null
// Why: this decides which SSH target a workspace session is exported to. The old fallback read
// `getRepo(id)?.connectionId`, which is host-blind — the same repo id can name rows on several
// hosts, so a session could be published to a machine that never owned the worktree (#11163).
// Unresolvable ownership exports to nobody rather than guessing.
const resolution = resolveWorktreeExecutionHost(
createRepoRowExecutionHostLookup(store.getRepos()),
{ repoId: getRepoIdFromWorktreeId(worktreeId), hostId: executionHostId ?? null }
)
return resolution.kind === 'resolved' ? resolution.connectionId : null
}
function exportSessionForTarget(
@@ -119,12 +133,40 @@ function exportSessionForTarget(
})
}
function sendRemoteWorkspaceChanged(
targetId: string,
snapshot: RemoteWorkspaceObservedSnapshot,
sourceClientId: string | undefined
): void {
const event: RemoteWorkspaceChangedEvent = {
targetId,
snapshot,
...(sourceClientId !== undefined ? { sourceClientId } : {})
}
const win = mainWindowGetter?.()
if (win && !win.isDestroyed()) {
win.webContents.send('remoteWorkspace:changed', event)
}
}
export function handleRemoteWorkspaceNotification(
targetId: string,
method: string,
params: Record<string, unknown>
): void {
if (method !== 'workspace.changed') {
if (method === REMOTE_WORKSPACE_STALE_NOTIFICATION) {
const target = getSshConnectionStore()?.getTarget(targetId)
if (!target) {
return
}
// No sourceClientId on the resynced event: the marker names no author, and guessing one would
// let the renderer's own-echo filter discard another device's change.
void resyncStaleRemoteWorkspace(target, (snapshot) =>
sendRemoteWorkspaceChanged(targetId, snapshot, undefined)
)
return
}
if (method !== REMOTE_WORKSPACE_CHANGED_NOTIFICATION) {
return
}
const target = getSshConnectionStore()?.getTarget(targetId)
@@ -139,15 +181,7 @@ export function handleRemoteWorkspaceNotification(
sourceClientId === CLIENT_ID
? rememberLocallyPatchedRemoteWorkspaceSnapshot(targetId, snapshot)
: rememberRemoteWorkspaceSnapshot(targetId, snapshot)
const event: RemoteWorkspaceChangedEvent = {
targetId,
snapshot: observedSnapshot,
sourceClientId
}
const win = mainWindowGetter?.()
if (win && !win.isDestroyed()) {
win.webContents.send('remoteWorkspace:changed', event)
}
sendRemoteWorkspaceChanged(targetId, observedSnapshot, sourceClientId)
}
export function registerRemoteWorkspaceHandlers(
+1 -1
View File
@@ -1,4 +1,4 @@
import { getActiveMultiplexer } from '../ssh'
import { getActiveMultiplexer } from '../../ssh/ssh-target-registry'
export async function resolveRemoteHomePath(connectionId: string, path: string): Promise<string> {
if (path !== '~' && path !== '~/' && !path.startsWith('~/')) {
@@ -0,0 +1,124 @@
// Registration is now the runtime's SSH path too (`projectHostSetup.setupExistingFolder --host
// ssh:*`), so what it stamps decides what every downstream host resolver can read. It minted
// `connectionId`-only rows, leaving the unified spelling permanently empty, and deduped by raw
// `connectionId`, which cannot see a row stamped `executionHostId: 'ssh:*'`.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../shared/repo-types'
const getSshGitProviderMock = vi.hoisted(() => vi.fn())
vi.mock('../../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock
}))
vi.mock('../../repo-icon-autodetect', () => ({
detectRepoIconAndUpstream: vi.fn(async () => ({}))
}))
vi.mock('../../ssh/ssh-target-registry', () => ({
getActiveMultiplexer: vi.fn(() => null)
}))
vi.mock('./remote-home-path', () => ({
resolveRemoteHomePath: vi.fn(async (_connectionId: string, path: string) => path)
}))
import { addRemoteRepoFromPath } from './remote-repo-registration'
function makeStore(repos: Repo[]) {
return {
getRepos: () => repos,
getSshTarget: () => undefined,
addRepo: (repo: Repo) => {
repos.push(repo)
}
}
}
describe('addRemoteRepoFromPath', () => {
beforeEach(() => {
getSshGitProviderMock.mockReset()
getSshGitProviderMock.mockReturnValue({
isGitRepoAsync: vi.fn(async () => ({ isRepo: true, rootPath: '/srv/app' }))
})
})
it('stamps the unified execution-host spelling alongside the legacy connection id', async () => {
const repos: Repo[] = []
const result = await addRemoteRepoFromPath(makeStore(repos) as never, {
connectionId: 'm4air',
remotePath: '/srv/app'
})
expect('error' in result).toBe(false)
const repo = (result as { repo: Repo }).repo
expect(repo.connectionId).toBe('m4air')
expect(repo.executionHostId).toBe('ssh:m4air')
})
it('dedupes against a row that names the host in the unified spelling only', async () => {
const existing = {
id: 'existing',
path: '/srv/app',
displayName: 'app',
badgeColor: '#000',
addedAt: 0,
executionHostId: 'ssh:m4air'
} as Repo
const repos: Repo[] = [existing]
const result = await addRemoteRepoFromPath(makeStore(repos) as never, {
connectionId: 'm4air',
remotePath: '/srv/app'
})
expect(result).toEqual({ repo: existing, alreadyExisted: true })
expect(repos).toHaveLength(1)
})
it('does not dedupe onto a row on a different SSH host at the same path', async () => {
// Two hosts can both hold /srv/app. Matching on path alone registers one host's repo as the
// other's — the mirror image of the id-only lookup this change removes.
const repos: Repo[] = [
{
id: 'openclaw-row',
path: '/srv/app',
displayName: 'app',
badgeColor: '#000',
addedAt: 0,
connectionId: 'openclaw'
} as Repo
]
const result = await addRemoteRepoFromPath(makeStore(repos) as never, {
connectionId: 'm4air',
remotePath: '/srv/app'
})
expect((result as { alreadyExisted: boolean }).alreadyExisted).toBe(false)
expect((result as { repo: Repo }).repo.executionHostId).toBe('ssh:m4air')
expect(repos).toHaveLength(2)
})
it('does not dedupe onto a local row that carries a stale connection id', async () => {
// The pullfrog case: a row declaring itself local must not answer as an SSH host.
const repos: Repo[] = [
{
id: 'local-row',
path: '/srv/app',
displayName: 'app',
badgeColor: '#000',
addedAt: 0,
executionHostId: 'local',
connectionId: 'develop'
} as Repo
]
const result = await addRemoteRepoFromPath(makeStore(repos) as never, {
connectionId: 'develop',
remotePath: '/srv/app'
})
expect((result as { alreadyExisted: boolean }).alreadyExisted).toBe(false)
expect(repos).toHaveLength(2)
})
})
@@ -3,9 +3,10 @@ import type { Store } from '../../persistence'
import type { Repo } from '../../../shared/repo-types'
import { DEFAULT_REPO_BADGE_COLOR } from '../../../shared/constants'
import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path'
import { getRepoSshConnectionId, toSshExecutionHostId } from '../../../shared/execution-host'
import { getSshGitProvider } from '../../providers/ssh-git-dispatch'
import { detectRepoIconAndUpstream } from '../../repo-icon-autodetect'
import { getActiveMultiplexer } from '../ssh'
import { getActiveMultiplexer } from '../../ssh/ssh-target-registry'
import { resolveRemoteHomePath } from './remote-home-path'
export async function addRemoteRepoFromPath(
@@ -26,11 +27,13 @@ export async function addRemoteRepoFromPath(
let repoKind: 'git' | 'folder' = args.kind ?? 'git'
let resolvedPath = await resolveRemoteHomePath(args.connectionId, args.remotePath)
// Resolve the host: a row stamped only `executionHostId: 'ssh:*'` is the same registration, and
// missing it here registers a duplicate repo for a path the host already owns.
const existing = store
.getRepos()
.find(
(repo) =>
repo.connectionId === args.connectionId &&
getRepoSshConnectionId(repo) === args.connectionId &&
normalizeRuntimePathForComparison(repo.path) ===
normalizeRuntimePathForComparison(resolvedPath)
)
@@ -61,7 +64,7 @@ export async function addRemoteRepoFromPath(
.getRepos()
.find(
(repo) =>
repo.connectionId === args.connectionId &&
getRepoSshConnectionId(repo) === args.connectionId &&
normalizeRuntimePathForComparison(repo.path) ===
normalizeRuntimePathForComparison(resolvedPath)
)
@@ -92,6 +95,9 @@ export async function addRemoteRepoFromPath(
addedAt: Date.now(),
kind: repoKind,
connectionId: args.connectionId,
// Stamp the unified spelling at creation: this is now the runtime's SSH registration path too,
// and minting `connectionId`-only rows leaves every host-resolving reader on the legacy field.
executionHostId: toSshExecutionHostId(args.connectionId),
...(repoKind === 'git'
? {
externalWorktreeVisibilityLegacy: false,
@@ -1,5 +1,6 @@
const generationByRepoId = new Map<string, number>()
let generationSequence = 0
let mutationRevision = 0
export function getLocalWorktreeScanGeneration(repoId: string): number {
const existing = generationByRepoId.get(repoId)
@@ -13,6 +14,22 @@ export function getLocalWorktreeScanGeneration(repoId: string): number {
export function bumpLocalWorktreeScanGeneration(repoId: string): void {
generationByRepoId.set(repoId, ++generationSequence)
mutationRevision += 1
}
/**
* Advances on every event above that can change what a worktree scan would find — repo add,
* removal, update, and scan-cache invalidation — and on nothing else. A cache that must not answer
* for repos it never saw compares this in O(1) instead of walking the repo list.
*
* Why not `generationSequence`: that also advances when `getLocalWorktreeScanGeneration` mints a key
* for a repo id nothing has scanned yet, which is a read. Keying a snapshot on it would let a read
* path discard a snapshot that is still perfectly valid.
*
* Ordering-only: the value means nothing outside a same-process comparison.
*/
export function getWorktreeScanMutationRevision(): number {
return mutationRevision
}
export function isLocalWorktreeScanGenerationCurrent(repoId: string, generation: number): boolean {
@@ -21,5 +38,6 @@ export function isLocalWorktreeScanGenerationCurrent(repoId: string, generation:
export function resetLocalWorktreeScanGenerationsForTests(): void {
generationSequence += 1
mutationRevision += 1
generationByRepoId.clear()
}
@@ -22,7 +22,28 @@ describe('batched foreground process correlation', () => {
resolveAgentForegroundProcessesFromIndex(buildProcessTableIndex(rows), [
{ rootPid: 100, fallbackProcess: 'zsh' }
])
).toEqual([{ available: true, processName: 'codex' }])
).toEqual([{ available: true, processName: 'codex', shellOwnsEveryTtyProcessGroup: false }])
})
it('reports whether the shell itself owns the terminal, named process or not', () => {
// The only host-observable "nothing is running here". pid 200's own pgid owns the terminal;
// pid 300 has an unrecognized command in the foreground, which nothing else here can see.
const rows = parseStrictProcessTableRows(
[
'200 1 200 200 Ss /bin/zsh',
'300 1 300 301 Ss /bin/zsh',
'301 300 301 301 S+ vim notes.md'
].join('\n')
)
expect(
resolveAgentForegroundProcessesFromIndex(buildProcessTableIndex(rows), [
{ rootPid: 200, fallbackProcess: 'zsh' },
{ rootPid: 300, fallbackProcess: 'zsh' }
])
).toEqual([
{ available: true, processName: null, shellOwnsEveryTtyProcessGroup: true },
{ available: true, processName: null, shellOwnsEveryTtyProcessGroup: false }
])
})
it('returns unverifiable for a missing root or no controlling tty', () => {
@@ -26,6 +26,9 @@ export type BatchedForegroundProcessResult = {
available: boolean
processName: string | null
reason?: string
/** Set only when the table was readable: every process group attached to this PTY's terminal is
* the shell's own, and none of them is stopped. Left absent when we could not observe it. */
shellOwnsEveryTtyProcessGroup?: boolean
}
export type BatchedForegroundProcessOptions = {
@@ -34,6 +37,48 @@ export type BatchedForegroundProcessOptions = {
stats?: ProcessTableIndexStats
}
/** Which process groups occupy each controlling terminal, and which terminals hold a stopped
* process. */
type TtyOccupancy = {
processGroupsByTty: ReadonlyMap<number, ReadonlySet<number>>
stoppedTtys: ReadonlySet<number>
}
const ttyOccupancyByCapture = new WeakMap<readonly ProcessTableRow[], TtyOccupancy>()
/** Index the capture by controlling terminal.
*
* Keyed on `tpgid` because the snapshot carries no tty column and does not need one: a process
* group belongs to exactly one session, a session to at most one controlling terminal, so two
* rows reporting the same live `tpgid` are on the same tty. Memoized per capture, since the
* per-pane cadence poll and `pty.listProcesses` share one TTL-cached table. */
function getTtyOccupancy(rows: readonly ProcessTableRow[]): TtyOccupancy {
const cached = ttyOccupancyByCapture.get(rows)
if (cached) {
return cached
}
const processGroupsByTty = new Map<number, Set<number>>()
const stoppedTtys = new Set<number>()
for (const row of rows) {
if (row.pgid === undefined || row.tpgid === undefined || row.tpgid <= 0) {
continue
}
let groups = processGroupsByTty.get(row.tpgid)
if (!groups) {
groups = new Set<number>()
processGroupsByTty.set(row.tpgid, groups)
}
groups.add(row.pgid)
// `T` is a job-control stop (Ctrl-Z), `t` a tracing stop. Both are work the pane still holds.
if (row.stat.startsWith('T') || row.stat.startsWith('t')) {
stoppedTtys.add(row.tpgid)
}
}
const occupancy: TtyOccupancy = { processGroupsByTty, stoppedTtys }
ttyOccupancyByCapture.set(rows, occupancy)
return occupancy
}
export async function resolveAgentForegroundProcessesBatch(
requests: readonly BatchedForegroundProcessRequest[],
options: BatchedForegroundProcessOptions = {}
@@ -91,6 +136,7 @@ export function resolveAgentForegroundProcessesFromIndex(
}
}
const occupancy = getTtyOccupancy(index.rows)
return requests.map((request) => {
const root = lookupProcessTableIndex(index, (value) => value.byPid.get(request.rootPid))
if (!root) {
@@ -114,6 +160,20 @@ export function resolveAgentForegroundProcessesFromIndex(
reason: 'no_controlling_tty'
}
}
// The only host-observable "nothing is running here" signal, and it has to be read off the
// whole tty rather than off `tpgid === pgid`. A backgrounded `pnpm build &` and a Ctrl-Z'd
// editor both leave the shell owning the foreground group, byte-identical to an idle prompt;
// what separates them is a second process group attached to the pane's terminal. That is also
// exactly the blast radius of the stop this attests to — `forceKillPosixPtyProcessGroups`
// SIGKILLs every process group on the tty — so the evidence and the kill now measure the same
// thing. A reader may treat `false` as "busy" and must never treat absence as "idle".
const ttyProcessGroups = occupancy.processGroupsByTty.get(root.tpgid)
const shellOwnsEveryTtyProcessGroup =
root.tpgid === root.pgid &&
ttyProcessGroups !== undefined &&
ttyProcessGroups.size === 1 &&
ttyProcessGroups.has(root.pgid) &&
!occupancy.stoppedTtys.has(root.tpgid)
const allCandidates = rowsByOwner.get(root.pid) ?? []
const foregroundCandidates = allCandidates.filter((row) => row.pgid === root.tpgid)
const fallbackProcess = request.fallbackProcess
@@ -125,7 +185,7 @@ export function resolveAgentForegroundProcessesFromIndex(
)
: foregroundCandidates
if (wrapperFallback && candidates.length !== 1) {
return { available: true, processName: null }
return { available: true, processName: null, shellOwnsEveryTtyProcessGroup }
}
const selected = selectForegroundProcessCandidate(candidates, allCandidates)
if (selected) {
@@ -135,10 +195,11 @@ export function resolveAgentForegroundProcessesFromIndex(
selected.recognized,
selected.candidate,
allCandidates
)
),
shellOwnsEveryTtyProcessGroup
}
}
return { available: true, processName: null }
return { available: true, processName: null, shellOwnsEveryTtyProcessGroup }
})
}
@@ -147,7 +208,14 @@ export function toForegroundProcessEvidence(
metadata: { authorityGeneration: string; observationEpoch: number; capturedAgeMs: number }
): ForegroundProcessEvidence {
return result.available
? { ...metadata, verdict: 'live', processName: result.processName }
? {
...metadata,
verdict: 'live',
processName: result.processName,
...(result.shellOwnsEveryTtyProcessGroup !== undefined
? { shellOwnsEveryTtyProcessGroup: result.shellOwnsEveryTtyProcessGroup }
: {})
}
: {
...metadata,
verdict: 'unverifiable',
@@ -0,0 +1,105 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
ExecutionHostNotDispatchableError,
requireFilesystemProviderForHost,
requireGitProviderForHost,
resolveFilesystemRouteForHost,
resolveGitRouteForHost,
UnresolvableExecutionHostError
} from './execution-host-provider-dispatch'
import { registerSshGitProvider, unregisterSshGitProvider } from './ssh-git-dispatch'
import {
registerSshFilesystemProvider,
unregisterSshFilesystemProvider
} from './ssh-filesystem-dispatch'
const connectionId = 'host-dispatch-target'
const gitProvider = { listWorktrees: async () => [] } as never
const filesystemProvider = { readDir: async () => [] } as never
describe('execution host provider dispatch', () => {
afterEach(() => {
unregisterSshGitProvider(connectionId)
unregisterSshFilesystemProvider(connectionId)
})
it('routes `local` to the local entry rather than to a provider', () => {
expect(resolveGitRouteForHost('local')).toEqual({ kind: 'local', hostId: 'local' })
expect(resolveFilesystemRouteForHost('local')).toEqual({ kind: 'local', hostId: 'local' })
})
it('routes an ssh host to its registered provider', () => {
registerSshGitProvider(connectionId, gitProvider)
registerSshFilesystemProvider(connectionId, filesystemProvider)
expect(resolveGitRouteForHost(`ssh:${connectionId}`)).toEqual({
kind: 'ssh',
hostId: `ssh:${connectionId}`,
connectionId,
provider: gitProvider
})
expect(resolveFilesystemRouteForHost(`ssh:${connectionId}`)).toEqual({
kind: 'ssh',
hostId: `ssh:${connectionId}`,
connectionId,
provider: filesystemProvider
})
expect(requireGitProviderForHost(`ssh:${connectionId}`)).toBe(gitProvider)
expect(requireFilesystemProviderForHost(`ssh:${connectionId}`)).toBe(filesystemProvider)
})
it('answers `unreachable`, not `local`, for an ssh host with no registered provider', () => {
const route = resolveGitRouteForHost(`ssh:${connectionId}`)
// The distinction the old `connectionId ? ssh : local` shape could not spell.
expect(route.kind).toBe('ssh')
expect(route.kind === 'ssh' && route.provider).toBeNull()
expect(() => requireGitProviderForHost(`ssh:${connectionId}`)).toThrow(
/Remote connection dropped/
)
expect(() => requireFilesystemProviderForHost(`ssh:${connectionId}`)).toThrow(
/Remote connection dropped/
)
})
it('routes a runtime host to its own entry instead of collapsing it into local', () => {
expect(resolveGitRouteForHost('runtime:env-7')).toEqual({
kind: 'runtime',
hostId: 'runtime:env-7',
environmentId: 'env-7'
})
expect(resolveFilesystemRouteForHost('runtime:env-7')).toEqual({
kind: 'runtime',
hostId: 'runtime:env-7',
environmentId: 'env-7'
})
})
it('refuses to hand a runtime host to this process’s ssh table', () => {
// A runtime repo row carries the *server's* nested target id. Dialling it here would reach a
// same-named target in this client's namespace.
registerSshGitProvider(connectionId, gitProvider)
expect(() => requireGitProviderForHost('runtime:env-7')).toThrow(
ExecutionHostNotDispatchableError
)
expect(() => requireFilesystemProviderForHost('runtime:env-7')).toThrow(
ExecutionHostNotDispatchableError
)
})
it('refuses to serve a local host from the remote-only accessor', () => {
expect(() => requireGitProviderForHost('local')).toThrow(ExecutionHostNotDispatchableError)
expect(() => requireFilesystemProviderForHost('local')).toThrow(
ExecutionHostNotDispatchableError
)
})
it.each([null, undefined, '', 'nonsense', 'ssh:', 'runtime:', 'ssh:a|b'])(
'throws instead of answering local for the unresolvable host %p',
(hostId) => {
expect(() => resolveGitRouteForHost(hostId)).toThrow(UnresolvableExecutionHostError)
expect(() => resolveFilesystemRouteForHost(hostId)).toThrow(UnresolvableExecutionHostError)
}
)
})
@@ -0,0 +1,158 @@
/**
* Host-keyed provider dispatch: one entry per execution host kind, with `local` among them.
*
* The incumbent spelling across main is `const c = repo.connectionId; c ? sshProvider(c) : local()`,
* where `null` means *both* "resolved: this is local" and "could not resolve". Every path that
* cannot determine the host therefore answers "local" and runs remote work on the client — the
* #11163 defect class, which has produced a reproduced cross-host leak (an `ssh:` worktree
* resolving to another target) and near-misses where a transcript that exists only on a remote host
* would have been read locally. The shape also cannot express a `runtime:` host at all.
*
* This module removes that spelling. Its input is an `ExecutionHostId`, which is never null, and an
* id that names no host throws instead of degrading. `getRepoExecutionHostId` /
* `getWorktreeExecutionHostId` / `resolveWorktreeExecutionHost` are the resolution layer that feeds
* it; the last one already answers `unresolved` as a distinct verdict rather than "local".
*
* Why a route union rather than a uniform `getGitProviderForHost(): IGitProvider`, which is the
* VS Code shape (`registerProvider(Schemas.file, …)` symmetric with `Schemas.vscodeRemote`, and
* `ENOPRO` when nothing matches). Two properties of this process, not style preferences:
*
* - `local` git and filesystem work is free functions taking per-worktree execution options
* (`wslDistro`, `sharedLinkPaths`, admission tier), not an `IGitProvider`. There is no local
* provider object to register, and a stateless one would silently drop WSL routing.
* - `runtime:<env>` is not executed in this process *at all*. It is forwarded over the
* environment's transport (`runtimeEnvironments:call`) and the receiving server normalizes it to
* its own `local`. A repo row on a runtime host carries the server's *nested* SSH target in
* `connectionId`; that id is addressable only as the pair (environmentId, targetId). Handing it
* to this client's SSH table would dial a same-named target in the wrong namespace — turning a
* silent-local bug into a silent-wrong-host bug. `host-repo-catalog-snapshot` and
* `host-qualified-worktree-listing` already reject runtime hosts for the same reason.
*
* So the answer is Zed's shape — an enum on the owner (`Local { fs }` vs `Remote { … }`) — and the
* three kinds are symmetric variants of it. Callers switch exhaustively, so `runtime` can no longer
* collapse into `local` by omission.
*
* Note the deliberate second distinction inside the `ssh` variant: `provider: null` means "this host
* is remote and currently unreachable", which is not the same answer as "this host is local" and can
* no longer be spelled the same way. That mirrors the `live` / `unverifiable` / `exited` rule in
* docs/reference/ssh-execution-boundary.md — loss of contact is never evidence of locality.
*/
import {
parseExecutionHostId,
type ExecutionHostId,
type LOCAL_EXECUTION_HOST_ID,
type ParsedExecutionHost
} from '../../shared/execution-host'
import { getSshGitProvider, SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from './ssh-git-dispatch'
import type { SshGitProvider } from './ssh-git-provider'
import {
getSshFilesystemProvider,
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
} from './ssh-filesystem-dispatch'
import type { IFilesystemProvider, IGitProvider } from './types'
/** An id that names no execution host. Never degrade to local — that is the whole defect class. */
export class UnresolvableExecutionHostError extends Error {
constructor(readonly hostId: string | null | undefined) {
super(
`Cannot route work: ${JSON.stringify(hostId ?? null)} names no execution host. ` +
'Refusing to fall back to this machine.'
)
this.name = 'UnresolvableExecutionHostError'
}
}
/** Asking this process for a host it does not execute is a routing mistake, not a fallback. */
export class ExecutionHostNotDispatchableError extends Error {
constructor(readonly hostId: ExecutionHostId) {
super(`Execution host ${hostId} is not dispatched by this process.`)
this.name = 'ExecutionHostNotDispatchableError'
}
}
type LocalRoute = { kind: 'local'; hostId: typeof LOCAL_EXECUTION_HOST_ID }
type RuntimeRoute = { kind: 'runtime'; hostId: `runtime:${string}`; environmentId: string }
type SshRoute<TProvider> = {
kind: 'ssh'
hostId: `ssh:${string}`
connectionId: string
/** `null` is "remote, currently unreachable" — never "local". */
provider: TProvider | null
}
// The SSH table stores `SshGitProvider`; narrowing the route to `IGitProvider` would drop the
// remote-only methods (commit-message plans, push-target materialization) that callers need.
export type ExecutionHostGitRoute = LocalRoute | RuntimeRoute | SshRoute<SshGitProvider>
export type ExecutionHostFilesystemRoute = LocalRoute | RuntimeRoute | SshRoute<IFilesystemProvider>
// Takes an unvalidated string rather than `ExecutionHostId`: validating is the point, and host
// ids also arrive from persistence and IPC where the compiler cannot vouch for them.
function parseRoutableHost(hostId: string | null | undefined): ParsedExecutionHost {
const parsed = parseExecutionHostId(hostId)
if (!parsed) {
throw new UnresolvableExecutionHostError(hostId)
}
return parsed
}
export function resolveGitRouteForHost(hostId: string | null | undefined): ExecutionHostGitRoute {
const parsed = parseRoutableHost(hostId)
switch (parsed.kind) {
case 'local':
return { kind: 'local', hostId: parsed.id }
case 'ssh':
return {
kind: 'ssh',
hostId: parsed.id,
connectionId: parsed.targetId,
provider: getSshGitProvider(parsed.targetId) ?? null
}
case 'runtime':
return { kind: 'runtime', hostId: parsed.id, environmentId: parsed.environmentId }
}
}
export function resolveFilesystemRouteForHost(
hostId: string | null | undefined
): ExecutionHostFilesystemRoute {
const parsed = parseRoutableHost(hostId)
switch (parsed.kind) {
case 'local':
return { kind: 'local', hostId: parsed.id }
case 'ssh':
return {
kind: 'ssh',
hostId: parsed.id,
connectionId: parsed.targetId,
provider: getSshFilesystemProvider(parsed.targetId) ?? null
}
case 'runtime':
return { kind: 'runtime', hostId: parsed.id, environmentId: parsed.environmentId }
}
}
/** For call sites that are structurally remote-only: local and runtime are both routing errors. */
export function requireGitProviderForHost(hostId: string | null | undefined): IGitProvider {
const route = resolveGitRouteForHost(hostId)
if (route.kind !== 'ssh') {
throw new ExecutionHostNotDispatchableError(route.hostId)
}
if (!route.provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return route.provider
}
export function requireFilesystemProviderForHost(
hostId: string | null | undefined
): IFilesystemProvider {
const route = resolveFilesystemRouteForHost(hostId)
if (route.kind !== 'ssh') {
throw new ExecutionHostNotDispatchableError(route.hostId)
}
if (!route.provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
return route.provider
}
+9
View File
@@ -18,4 +18,13 @@ export type PtyProcessInfo = {
/** Optional host-side process evidence attached to an inventory seed. */
foregroundProcessEvidence?: ForegroundProcessEvidence
agentSessionOwners?: AgentSessionOwnerBinding[]
/** Age measured on the OWNING host's clock. Absent means the host did not measure it, which is
* not the same as "new" or "old" — a reader that needs an age must defer instead of assuming. */
hostAgeMs?: number
/** True when the host spawned this PTY for an Orca pane, false for a bare host shell. Absent from
* a host that never published it; absence is neither value. */
paneBound?: boolean
/** The client identity the OWNING host recorded as having asked it to create this PTY. Absent
* whenever the host could not attest one, and absence must never be read as "unowned". */
ownerClientInstanceId?: string
}
@@ -204,6 +204,12 @@ export type IPtyProvider = {
keepHistory?: boolean
deadlineMs?: number
expectedIncarnationId?: PtyIncarnationId
/** Ask the execution host to refuse this stop unless it recorded this exact client identity
* as the PTY's creator AND this connection still authenticates as it. Optional because a
* host that predates it ignores the field, and because most stops are ordinary teardown of a
* pane whose owner the host may never have attested (a revived PTY carries none). Set it
* wherever the caller's authority to destroy comes from that attestation. */
expectedOwnerClientInstanceId?: string
}
): Promise<void>
sendSignal(id: string, signal: string): Promise<void>
+5 -3
View File
@@ -225,15 +225,17 @@ export class SshPtyProvider implements IPtyProvider {
}
async shutdown(id: string, opts: Parameters<IPtyProvider['shutdown']>[1]): Promise<void> {
// Both fences are omitted rather than sent undefined: a host that predates either must see no
// key at all, and the owner fence in particular must never reach it as a falsy claim.
const { expectedIncarnationId, expectedOwnerClientInstanceId } = opts
await this.mux.request(
'pty.shutdown',
{
id: this.toRelayPtyId(id),
immediate: opts.immediate ?? false,
keepHistory: opts.keepHistory ?? false,
...(opts.expectedIncarnationId === undefined
? {}
: { expectedIncarnationId: opts.expectedIncarnationId })
...(expectedIncarnationId === undefined ? {} : { expectedIncarnationId }),
...(expectedOwnerClientInstanceId === undefined ? {} : { expectedOwnerClientInstanceId })
},
relayTimeoutOptions(opts.deadlineMs)
)
@@ -0,0 +1,75 @@
// Three different refusals leave `reattachSshPtySessionForSpawn` carrying the same
// `SSH_SESSION_EXPIRED` text, and only one of them observed the process. That text is therefore not
// a verdict, and a caller that tests it with `.includes()` cannot tell "the host says this PTY is
// gone" from "the PTY is fine, its source stream needs rebuilding".
//
// It matters because the callers that DO test it act destructively: `spawn-execute.ts` expires the
// lease and deletes the in-memory ownership, which between them are the client's only record that a
// remote process exists. Erase both for a live PTY and #9819's sweep finds a host-attested,
// route-less, pane-bound shell on the next connect and SIGKILLs it.
//
// So this pins the discriminator the destructive branch keys on: the type, not the message.
import { describe, expect, it, vi } from 'vitest'
import { isSshPtyAbsentFromRelayError, SSH_SESSION_EXPIRED_ERROR } from './ssh-pty-errors'
import { reattachSshPtySessionForSpawn } from './ssh-pty-session-reattach'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
const CONNECTION = 'conn-1'
const SESSION = 'pty-1'
function reattachAgainst(attach: () => Promise<unknown>): Promise<unknown> {
return reattachSshPtySessionForSpawn({
mux: { request: vi.fn(attach) } as unknown as SshChannelMultiplexer,
connectionId: CONNECTION,
sessionId: SESSION,
options: { cols: 80, rows: 24 },
exitRaceTracker: {
begin: () => 1,
didMatchingExitArrive: () => false,
finish: () => {}
} as never,
acceptLivePty: () => {}
})
}
async function refusalFrom(attach: () => Promise<unknown>): Promise<Error> {
try {
await reattachAgainst(attach)
} catch (error) {
return error as Error
}
throw new Error('expected the reattach to be refused')
}
describe('an SSH reattach refusal says whether the host observed the PTY', () => {
it('marks a relay that answered "not found" as positive evidence of absence', async () => {
const error = await refusalFrom(async () => {
throw new Error(`PTY "${SESSION}" not found`)
})
expect(error.message).toContain(SSH_SESSION_EXPIRED_ERROR)
expect(isSshPtyAbsentFromRelayError(error)).toBe(true)
})
it('does not mark a restoreRequired refusal as absence, though it reads identically', async () => {
// The PTY attached. The relay answered about it. It is running. Only the source stream could
// not be resumed — see the `restoreRequired` carve-out in ssh-pty-errors.ts.
const error = await refusalFrom(async () => ({
incarnationId: '11111111-1111-4111-8111-111111111111',
sourceRecovery: { status: 'restoreRequired', reason: 'checkpoint_unavailable' }
}))
expect(error.message).toContain(SSH_SESSION_EXPIRED_ERROR)
expect(isSshPtyAbsentFromRelayError(error)).toBe(false)
})
it('does not mark an identity mismatch as absence either', async () => {
// The id names a LIVE PTY that belongs to a different pane.
const error = await refusalFrom(async () => {
throw new Error(`PTY "${SESSION}" not found (identity mismatch)`)
})
expect(error.message).toContain(SSH_SESSION_EXPIRED_ERROR)
expect(isSshPtyAbsentFromRelayError(error)).toBe(false)
})
})
@@ -0,0 +1,194 @@
import * as pty from 'node-pty'
import { describe, expect, it } from 'vitest'
/**
* node-pty hands the master fd to libuv, which closes it on EIO/EOF, but upstream
* never invalidated `_fd`, and none of the three fd-addressed surfaces consulted
* anything: `resize()`, the `process` getter, and `CustomWriteStream`, which holds
* its own plain-number copy of the fd taken at spawn. Orca's patch retires all
* three in the same block that gives up the handle
* (config/patches/node-pty@1.1.0.patch).
*
* Scope: this narrows the window, it does not close it. libuv closes the fd
* synchronously inside `uv_close`, before the JS `'close'` that runs `_close()`,
* so callers still need their own liveness verdict for that tick — and a relay
* host installs node-pty from npm, where this patch is not applied at all.
*/
const POSIX_SHELL = '/bin/sh'
function spawnPty(command: string, cols = 80, rows = 24): pty.IPty {
return pty.spawn(POSIX_SHELL, ['-c', command], {
name: 'xterm-256color',
cols,
rows,
cwd: process.cwd(),
env: { ...process.env }
})
}
function masterFd(term: pty.IPty): number {
return (term as unknown as { fd: number }).fd
}
type CustomWriteStream = { _fd: number; _writeQueue: unknown[]; write(data: string): void }
/**
* `Terminal._close()` already shadows `terminal.write` with a no-op, so the stream
* itself is the surface that still reached the fd: a residual `_writeQueue` and an
* in-flight `fs.write` both re-enter it after the close.
*/
function writeStream(term: pty.IPty): CustomWriteStream {
return (term as unknown as { _writeStream: CustomWriteStream })._writeStream
}
/**
* Run `command` to completion and let node-pty finish giving up the master.
*
* `destroy: false` exercises only the EIO/EOF read-error path, which reaches
* `_close()` without ever calling `destroy()` — the path the exit of a shell
* actually takes, and the one the write stream was previously never told about.
*/
async function retiredPty(
command = 'exit 0',
{ destroy = true }: { destroy?: boolean } = {}
): Promise<{ term: pty.IPty; spawnFd: number }> {
const term = spawnPty(command)
const spawnFd = masterFd(term)
await new Promise<void>((resolve) => {
term.onExit(() => resolve())
})
if (destroy) {
;(term as unknown as { destroy?: () => void }).destroy?.()
}
await new Promise<void>((resolve) => setTimeout(resolve, 400))
return { term, spawnFd }
}
// Windows never reaches this code: WindowsTerminal.resize goes through the conpty
// agent and reads no fd, so the sentinel is written and never consulted there.
const describeOnPosix = process.platform === 'win32' ? describe.skip : describe
describeOnPosix('node-pty master fd retirement', () => {
it('invalidates the descriptor once it gives up the handle', async () => {
const { term, spawnFd } = await retiredPty()
expect(spawnFd).toBeGreaterThanOrEqual(0)
expect(masterFd(term)).toBe(-1)
}, 15000)
it('answers a resize past retirement without issuing the ioctl', async () => {
const { term } = await retiredPty()
// Pre-patch this threw `ioctl(2) failed, EBADF` out of whatever called it.
expect(() => term.resize(200, 50)).not.toThrow()
// Geometry stays at the last size actually applied rather than claiming one
// that no descriptor ever received.
expect([term.cols, term.rows]).toEqual([80, 24])
}, 15000)
it('retires the write stream fd on _close(), not only on destroy()', async () => {
const { term } = await retiredPty('exit 0', { destroy: false })
// The stream copied the fd number at spawn, so `Terminal._fd = -1` alone
// leaves it addressing a descriptor the kernel may already have reissued.
expect(writeStream(term)._fd).toBe(-1)
writeStream(term).write('x')
expect(writeStream(term)._writeQueue).toHaveLength(0)
}, 15000)
it('names the spawn file rather than tcgetpgrp on a retired descriptor', async () => {
const { term } = await retiredPty()
expect(term.process).toBe(POSIX_SHELL)
}, 15000)
})
// Linux frees the master synchronously enough that the very next pty is handed the
// same descriptor number every time, which makes the reuse hazard directly
// observable rather than a race to reproduce.
//
// Which is also the constraint on writing a case here: the kernel hands out the
// lowest free number, so a case that returns while its live pty is still open
// leaks that descriptor into the next case's premise as an off-by-one. Await the
// exit, never a fixed sleep.
const describeOnLinux = process.platform === 'linux' ? describe : describe.skip
describeOnLinux('node-pty master fd reuse', () => {
it('cannot resize a live pty handed the retired descriptor number', async () => {
const { term: retired, spawnFd } = await retiredPty()
const live = spawnPty('sleep 1; stty size')
let output = ''
live.onData((data) => {
output += data
})
try {
// The premise of this test: the kernel really did reissue the number. If it
// stops holding, the assertion below would pass for the wrong reason.
expect(masterFd(live)).toBe(spawnFd)
// Pre-patch this reached TIOCSWINSZ on `live`'s master and silently resized
// a terminal it has no relationship to — no error, nothing for a liveness
// probe of the retired pid to observe.
retired.resize(200, 50)
await new Promise<void>((resolve) => {
live.onExit(() => resolve())
})
expect(output.trim()).toBe('24 80')
} finally {
live.kill()
}
}, 15000)
it('cannot write into a live pty handed the retired descriptor number', async () => {
const { term: retired, spawnFd } = await retiredPty('exit 0', { destroy: false })
const live = spawnPty('sleep 1')
let output = ''
live.onData((data) => {
output += data
})
try {
expect(masterFd(live)).toBe(spawnFd)
// Pre-patch this fs.write reached `live`'s master, and the line discipline
// echoed it straight back: a retired pane's bytes landing in an unrelated
// terminal. Nothing has to read them for the leak to be observable.
writeStream(retired).write('leak\r')
// Await the exit rather than sleeping: a fixed wait leaves this descriptor
// open into the next case, whose `expect(masterFd(live)).toBe(spawnFd)`
// premise then sees the kernel hand out the lower number this pty was still
// holding. That is what broke `node 24 1/8` on Linux, where alone among the
// platforms these cases actually run.
await new Promise<void>((resolve) => {
live.onExit(() => resolve())
})
expect(output).not.toContain('leak')
} finally {
live.kill()
}
}, 15000)
it('does not name a live pty foreground process off the retired descriptor', async () => {
const { term: retired, spawnFd } = await retiredPty()
// `exec` replaces the shell, so the foreground pgrp's cmdline is distinct
// from the file this pty was spawned with.
const live = spawnPty('exec sleep 5')
try {
expect(masterFd(live)).toBe(spawnFd)
// Let the shell finish exec'ing, or its own cmdline is still the fallback.
await new Promise<void>((resolve) => setTimeout(resolve, 300))
// Pre-patch this read tcgetpgrp off `live`'s master and reported `sleep`,
// attributing an unrelated pane's process to a pty that had already exited.
expect(retired.process).toBe(POSIX_SHELL)
} finally {
live.kill()
}
}, 15000)
})
+60 -1
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { listWorktreeGraphMock, listWorktreesMock, listWorktreesStrictMock } = vi.hoisted(() => ({
listWorktreeGraphMock: vi.fn(),
@@ -19,6 +19,8 @@ import {
listRepoWorktreeGraph,
listRepoWorktrees
} from './repo-worktrees'
import { registerSshGitProvider, unregisterSshGitProvider } from './providers/ssh-git-dispatch'
import { WorktreeCatalogUnavailableError } from '../shared/worktree/worktree-catalog-availability'
describe('repo-worktrees', () => {
beforeEach(() => {
@@ -196,6 +198,63 @@ describe('repo-worktrees', () => {
expect(listWorktreesStrictMock).not.toHaveBeenCalled()
})
// #11163: a row may spell its owner only as `executionHostId`. Reading `connectionId` answers
// "local" for it and runs the listing against a same-named path on this machine.
describe('rows that spell their owner only as executionHostId', () => {
const sshOnlyRepo = {
id: 'repo-1',
path: '/srv/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
kind: 'git' as const,
executionHostId: 'ssh:host-a' as const
}
afterEach(() => {
unregisterSshGitProvider('host-a')
unregisterSshGitProvider('nested-target')
})
it('never lists an ssh-owned row with local git', async () => {
await expect(listRepoWorktrees(sshOnlyRepo)).rejects.toThrow(WorktreeCatalogUnavailableError)
expect(listWorktreesMock).not.toHaveBeenCalled()
})
it('lists an ssh-owned row through its registered provider', async () => {
const listWorktrees = vi.fn().mockResolvedValue([{ path: '/srv/repo' }])
registerSshGitProvider('host-a', { listWorktrees } as never)
await expect(listRepoWorktrees(sshOnlyRepo)).resolves.toEqual([{ path: '/srv/repo' }])
expect(listWorktrees).toHaveBeenCalledWith('/srv/repo')
expect(listWorktreesMock).not.toHaveBeenCalled()
})
it('keeps an ssh-owned root out of the local repo-root match', () => {
expect(isRepoRoot([sshOnlyRepo], '/srv/repo')).toBe(false)
})
it('rejects strict local listing for an ssh-owned row', async () => {
await expect(listLocalRepoWorktreesStrict(sshOnlyRepo)).rejects.toThrow('remote repository')
expect(listWorktreesStrictMock).not.toHaveBeenCalled()
})
it('refuses to answer a runtime-owned row from a same-named local target', async () => {
const listWorktrees = vi.fn().mockResolvedValue([{ path: '/wrong/host' }])
registerSshGitProvider('nested-target', { listWorktrees } as never)
await expect(
listRepoWorktrees({
...sshOnlyRepo,
executionHostId: 'runtime:env-7',
connectionId: 'nested-target'
})
).rejects.toThrow(WorktreeCatalogUnavailableError)
expect(listWorktrees).not.toHaveBeenCalled()
expect(listWorktreesMock).not.toHaveBeenCalled()
})
})
it('treats Windows repo root casing differences as the same local root', () => {
const repos = [
{
+29 -11
View File
@@ -2,7 +2,8 @@ import type { Repo } from '../shared/repo-types'
import type { GitWorktreeInfo } from '../shared/worktree/types'
import { listWorktreeGraph, listWorktrees, listWorktreesStrict } from './git/worktree'
import { isFolderRepo } from '../shared/repo-kind'
import { getSshGitProvider } from './providers/ssh-git-dispatch'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host'
import { resolveGitRouteForHost } from './providers/execution-host-provider-dispatch'
import { areWorktreePathsEqual } from './ipc/worktree-logic'
import { WorktreeCatalogUnavailableError } from '../shared/worktree/worktree-catalog-availability'
@@ -16,8 +17,12 @@ function hasLocalRepoWorktreeListOptions(options: LocalRepoWorktreeListOptions |
}
export function isRepoRoot(repos: Repo[], resolvedTarget: string): boolean {
// Why: `!repo.connectionId` matched a remote path against a local one for a row that spells its
// owner only as `executionHostId: 'ssh:<target>'`. Resolve the host instead of reading one field.
return repos.some(
(repo) => !repo.connectionId && areWorktreePathsEqual(repo.path, resolvedTarget)
(repo) =>
getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID &&
areWorktreePathsEqual(repo.path, resolvedTarget)
)
}
@@ -41,17 +46,24 @@ export async function listRepoWorktrees(
if (isFolderRepo(repo)) {
return [createFolderWorktree(repo)]
}
if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId)
const route = resolveGitRouteForHost(getRepoExecutionHostId(repo))
if (route.kind === 'runtime') {
// A runtime row's `connectionId` names a target in the *server's* namespace, not one this
// client may dial. Reading it here would answer from a same-named local target.
throw new WorktreeCatalogUnavailableError(
`Worktree catalog unavailable for ${repo.path}: host ${route.hostId} is not reachable from this process.`
)
}
if (route.kind === 'ssh') {
// Why: runtime worktree resolution can run before SSH providers have reattached during startup.
// Never fall back to local git against a server path, and never report the unreachable host as an
// empty catalog (#14004) — callers treat a resolved listing as authoritative.
if (!provider) {
if (!route.provider) {
throw new WorktreeCatalogUnavailableError(
`Worktree catalog unavailable for ${repo.path}: SSH connection "${repo.connectionId}" is not connected.`
`Worktree catalog unavailable for ${repo.path}: SSH connection "${route.connectionId}" is not connected.`
)
}
return await provider.listWorktrees(repo.path)
return await route.provider.listWorktrees(repo.path)
}
return hasLocalRepoWorktreeListOptions(options)
? await listWorktrees(repo.path, options)
@@ -72,9 +84,15 @@ export async function listRepoWorktreeGraph(
if (isFolderRepo(repo)) {
return [createFolderWorktree(repo)]
}
if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId)
return provider ? await provider.listWorktrees(repo.path) : []
const route = resolveGitRouteForHost(getRepoExecutionHostId(repo))
// An unreachable remote host answers `[]` here, unlike listRepoWorktrees above, which throws.
// Preserved as-is: this call site's callers treat the graph as best-effort. The inconsistency is
// real but is a separate behavior decision from resolving the host correctly.
if (route.kind === 'runtime') {
return []
}
if (route.kind === 'ssh') {
return route.provider ? await route.provider.listWorktrees(repo.path) : []
}
return hasLocalRepoWorktreeListOptions(options)
? await listWorktreeGraph(repo.path, options)
@@ -85,7 +103,7 @@ export async function listLocalRepoWorktreesStrict(
repo: Repo,
options?: LocalRepoWorktreeListOptions
): Promise<GitWorktreeInfo[]> {
if (repo.connectionId) {
if (getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) {
throw new Error('Cannot list worktrees for a remote repository')
}
if (isFolderRepo(repo)) {
@@ -0,0 +1,107 @@
// launchAgentTerminal read `store.getRepo(worktree.repoId)?.connectionId` for the trust write —
// host-blind, so the same repo id on two hosts wrote a remote path into the client's agent config
// and the agent on the host never saw the trust (#11163). Every sibling call site already passes
// the resolved `workspace.connectionId`; this was the last one that did not.
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
webContents: { fromId: vi.fn(() => null) },
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
app: { getPath: vi.fn(() => '/tmp'), isPackaged: false }
}))
import { OrcaRuntimeService } from './orca-runtime'
const REMOTE_PATH = '/srv/app-feature'
type RuntimeInternals = {
resolveWorktreeSelector: (selector: string) => Promise<unknown>
buildStartupForAgent: (repo: unknown, agent: unknown, prompt: string) => unknown
markWorkspaceTrustedForAgent: (
agent: unknown,
connectionId: string | null | undefined,
path: string
) => Promise<void>
createTerminal: (selector: string, opts: unknown) => Promise<unknown>
}
function makeRuntime(repos: readonly Record<string, unknown>[], hostId?: string) {
const store = {
getSettings: () => ({ disabledTuiAgents: [], workspaceDir: '/tmp/workspaces' }),
getProjectHostSetups: () => [],
getRepos: () => repos,
getRepo: (id: string) => repos.find((repo) => repo.id === id)
}
const runtime = new OrcaRuntimeService(store as never)
const internals = runtime as unknown as RuntimeInternals
vi.spyOn(internals, 'resolveWorktreeSelector').mockResolvedValue({
id: 'repo-shared::/srv/app-feature',
repoId: 'repo-shared',
path: REMOTE_PATH,
...(hostId ? { hostId } : {})
})
vi.spyOn(internals, 'buildStartupForAgent').mockReturnValue({
agent: 'codex',
startup: { command: 'codex', env: {}, startupCommandDelivery: 'none', telemetry: {} }
})
const markTrusted = vi.fn(async () => {})
vi.spyOn(internals, 'markWorkspaceTrustedForAgent').mockImplementation(markTrusted)
vi.spyOn(internals, 'createTerminal').mockResolvedValue({ id: 'pty-1' })
return { runtime, markTrusted }
}
describe('launchAgentTerminal trust write', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('writes trust on the host the worktree names, not on a rival row', async () => {
// Two SSH hosts publish the same repo id; the worktree is on m4air.
const { runtime, markTrusted } = makeRuntime(
[
{ id: 'repo-shared', path: '/home/me/app', connectionId: 'openclaw' },
{ id: 'repo-shared', path: '/srv/app', connectionId: 'm4air' }
],
'ssh:m4air'
)
await runtime.launchAgentTerminal('id:repo-shared::/srv/app-feature', {
agent: 'codex',
prompt: 'go'
} as never)
expect(markTrusted).toHaveBeenCalledWith('codex', 'm4air', REMOTE_PATH)
})
it('writes trust locally for a local worktree even when a remote row shares the id', async () => {
const { runtime, markTrusted } = makeRuntime(
[
{ id: 'repo-shared', path: '/srv/app', connectionId: 'm4air' },
{ id: 'repo-shared', path: '/home/me/app' }
],
'local'
)
await runtime.launchAgentTerminal('id:repo-shared::/srv/app-feature', {
agent: 'codex',
prompt: 'go'
} as never)
expect(markTrusted).toHaveBeenCalledWith('codex', null, REMOTE_PATH)
})
it('refuses rather than guessing when rival rows disagree and the worktree names no host', async () => {
const { runtime } = makeRuntime([
{ id: 'repo-shared', path: '/srv/app', connectionId: 'm4air' },
{ id: 'repo-shared', path: '/home/me/app' }
])
await expect(
runtime.launchAgentTerminal('id:repo-shared::/srv/app-feature', {
agent: 'codex',
prompt: 'go'
} as never)
).rejects.toThrow('worktree_execution_host_unresolved')
})
})
@@ -0,0 +1,155 @@
// createManagedWorktree used to pick remote-vs-local from the raw `connectionId` field, so a repo
// stamped only `executionHostId: 'ssh:*'` ran `git worktree add` on the client against a remote
// path — and the folder branch, which returns before that check, wrote agent trust locally for a
// remote workspace. Both are the #11163 shape: read the execution host, never one spelling of it.
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
webContents: { fromId: vi.fn(() => null) },
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
app: { getPath: vi.fn(() => '/tmp'), isPackaged: false }
}))
const createRuntimeFolderWorktreeMock = vi.hoisted(() => vi.fn())
vi.mock('./runtime-folder-worktree-create', () => ({
createRuntimeFolderWorktree: createRuntimeFolderWorktreeMock
}))
const createRuntimeLocalManagedWorktreeMock = vi.hoisted(() => vi.fn())
vi.mock('./runtime-local-worktree-create', () => ({
createRuntimeLocalManagedWorktree: createRuntimeLocalManagedWorktreeMock
}))
const trustMocks = vi.hoisted(() => ({
local: vi.fn(async () => {}),
remote: vi.fn(async () => {})
}))
vi.mock('./runtime-worktree-agent-startup', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
markLocalWorktreeTrusted: trustMocks.local,
markRemoteWorktreeTrusted: trustMocks.remote
}))
import { OrcaRuntimeService } from './orca-runtime'
const TARGET_ID = 'remote-1'
const REMOTE_PATH = '/srv/app'
type RuntimeInternals = {
resolveRepoSelector: (selector: string) => Promise<unknown>
createManagedRemoteWorktree: (repo: unknown, args: unknown) => Promise<unknown>
resolveLineageForWorktreeCreate: (input: unknown) => Promise<unknown>
recordCreatedWorktreeLineage: (worktree: unknown, resolution: unknown) => unknown
}
function makeRuntime(repo: Record<string, unknown>): {
runtime: OrcaRuntimeService
createRemote: ReturnType<typeof vi.fn>
} {
const store = {
getSettings: () => ({ disabledTuiAgents: [], workspaceDir: '/tmp/workspaces' }),
getProjectHostSetups: () => []
}
const runtime = new OrcaRuntimeService(store as never)
const internals = runtime as unknown as RuntimeInternals
vi.spyOn(internals, 'resolveRepoSelector').mockResolvedValue(repo)
vi.spyOn(internals, 'resolveLineageForWorktreeCreate').mockResolvedValue(null)
vi.spyOn(internals, 'recordCreatedWorktreeLineage').mockReturnValue({
lineage: null,
workspaceLineage: null,
warnings: []
})
const createRemote = vi.fn().mockResolvedValue({
worktree: { id: 'wt-1', path: '/srv/app-feature', branch: 'feature' }
})
vi.spyOn(internals, 'createManagedRemoteWorktree').mockImplementation(createRemote)
return { runtime, createRemote }
}
describe('createManagedWorktree execution-host routing', () => {
beforeEach(() => {
createRuntimeFolderWorktreeMock.mockReset()
createRuntimeFolderWorktreeMock.mockResolvedValue({ worktree: { id: 'folder-1' } })
createRuntimeLocalManagedWorktreeMock.mockReset()
// Name the defect in the failure output: reaching this mock means a remote repo was routed
// into a client-side `git worktree add`.
createRuntimeLocalManagedWorktreeMock.mockRejectedValue(
new Error('local_worktree_create_ran_for_remote_repo')
)
trustMocks.local.mockClear()
trustMocks.remote.mockClear()
})
it('creates on the SSH host for a repo stamped executionHostId only', async () => {
const { runtime, createRemote } = makeRuntime({
id: 'repo-remote',
path: REMOTE_PATH,
kind: 'git',
executionHostId: `ssh:${TARGET_ID}`
})
await runtime.createManagedWorktree({ repoSelector: 'repo-remote', name: 'feature' } as never)
// A local `git worktree add` against a remote path is the silent-substitution failure.
expect(createRuntimeLocalManagedWorktreeMock).not.toHaveBeenCalled()
expect(createRemote).toHaveBeenCalledWith(
expect.objectContaining({ id: 'repo-remote', connectionId: TARGET_ID }),
expect.anything()
)
})
it('still creates on the SSH host for a legacy connectionId-only repo', async () => {
const { runtime, createRemote } = makeRuntime({
id: 'repo-remote',
path: REMOTE_PATH,
kind: 'git',
connectionId: TARGET_ID
})
await runtime.createManagedWorktree({ repoSelector: 'repo-remote', name: 'feature' } as never)
expect(createRuntimeLocalManagedWorktreeMock).not.toHaveBeenCalled()
expect(createRemote).toHaveBeenCalledWith(
expect.objectContaining({ connectionId: TARGET_ID }),
expect.anything()
)
})
it('marks a folder workspace trusted on its SSH host, not on the client', async () => {
const { runtime } = makeRuntime({
id: 'repo-folder',
path: REMOTE_PATH,
kind: 'folder',
connectionId: TARGET_ID,
executionHostId: `ssh:${TARGET_ID}`
})
await runtime.createManagedWorktree({ repoSelector: 'repo-folder', name: 'notes' } as never)
const deps = createRuntimeFolderWorktreeMock.mock.calls[0]?.[0]?.deps
await deps.markTrusted('codex', '/srv/app')
expect(trustMocks.remote).toHaveBeenCalledWith('codex', TARGET_ID, '/srv/app')
expect(trustMocks.local).not.toHaveBeenCalled()
})
it('keeps a local folder workspace trusted on the client', async () => {
const { runtime } = makeRuntime({
id: 'repo-folder-local',
path: '/Users/me/notes',
kind: 'folder'
})
await runtime.createManagedWorktree({
repoSelector: 'repo-folder-local',
name: 'notes'
} as never)
const deps = createRuntimeFolderWorktreeMock.mock.calls[0]?.[0]?.deps
await deps.markTrusted('codex', '/Users/me/notes')
expect(trustMocks.local).toHaveBeenCalledWith('codex', '/Users/me/notes')
expect(trustMocks.remote).not.toHaveBeenCalled()
})
})
@@ -149,6 +149,41 @@ describe('agent-session create operation ledger', () => {
expect(createTerminal).toHaveBeenCalledOnce()
})
it('shapes the launch for the route it resolved, not a repo row on another host', async () => {
// `scope.repo` is display metadata and can be a row from a different host than the worktree
// names (#11163). Reading it made a locally-routed launch emit the SSH relay shim name.
const runtime = createRuntime({
supportsAgentSessionClaims: () => true,
supportsAgentSessionCreateOperations: () => true
})
const internal = runtime as unknown as {
resolveTerminalWorkspaceLaunchScope: ReturnType<typeof vi.fn>
}
internal.resolveTerminalWorkspaceLaunchScope.mockResolvedValue({
id: 'worktree-1',
path: '/repo/worktree-1',
connectionId: null,
// The rival row names openclaw while the worktree resolved to no SSH route at all.
repo: {
id: 'repo-1',
connectionId: 'openclaw',
executionHostId: null,
path: '/srv/openclaw'
},
folderWorkspace: null
})
const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal())
await runtime.createAgentSession(
request(operationId(), { agent: 'claude-agent-teams', prompt: '' })
)
expect(createTerminal).toHaveBeenCalledWith(
'id:worktree-1',
expect.objectContaining({ command: expect.stringContaining('orca-ide claude-teams') })
)
})
it('requests exact client legacy fallback before nested SSH side effects', async () => {
const runtime = createRuntime()
const internal = runtime as unknown as {
@@ -142,7 +142,7 @@ export class OrcaRuntimeWithApplyMobileSessionTabNavigation extends OrcaRuntimeW
tabs
}
this.persistHeadlessTerminalActiveLeaf(worktreeId, activeTab)
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
this.storeMobileSessionSnapshot(worktreeId, nextSnapshot)
this.emitMobileSessionTabsSnapshot(nextSnapshot)
}
@@ -107,7 +107,7 @@ export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWi
: {}),
tabs: nextTabs
}
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
this.storeMobileSessionSnapshot(worktreeId, nextSnapshot)
this.emitMobileSessionTabsSnapshot(nextSnapshot)
}
@@ -39,7 +39,7 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi
})),
tabs: nextTabs
}
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
this.storeMobileSessionSnapshot(worktreeId, nextSnapshot)
this.emitMobileSessionTabsSnapshot(nextSnapshot)
}
@@ -49,7 +49,7 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi
protected republishMobileSessionTabsSnapshot(worktreeId: string): void {
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
if (snapshot) {
this.mobileSessionTabsByWorktree.set(worktreeId, {
this.storeMobileSessionSnapshot(worktreeId, {
...snapshot,
snapshotVersion: snapshot.snapshotVersion + 1
})
@@ -161,7 +161,7 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi
})),
tabs: nextTabs
}
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
this.storeMobileSessionSnapshot(worktreeId, nextSnapshot)
this.emitMobileSessionTabsSnapshot(nextSnapshot)
return true
}
@@ -203,7 +203,7 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi
focusesHost,
publicationEpoch: `headless:${Date.now().toString(36)}`
})
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
this.storeMobileSessionSnapshot(worktreeId, nextSnapshot)
// Why: browser group membership is otherwise live-only; persist it so a
// later rebuild keeps the browser in its group instead of coalescing left.
if (placedInTargetGroup && nextSnapshot.tabGroupLayout) {
@@ -16,7 +16,6 @@ import {
AGENT_SESSION_OPERATION_PER_CLIENT_LIMIT
} from './orca-runtime-core'
import { isTuiAgentEnabled } from '../../shared/tui-agent-selection'
import { repoIsRemote } from '../../shared/agent-launch-remote'
import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell'
import {
resolveTuiAgentLaunchArgs,
@@ -152,9 +151,9 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe
throw new Error('Selected agent is disabled. Choose an enabled agent before creating.')
}
const platform = this.getAgentLaunchPlatformForWorkspace(workspace)
const isRemote = workspace.repo
? repoIsRemote(workspace.repo)
: Boolean(workspace.connectionId)
// Why: `workspace.repo` is display metadata and may be a row from another host; the launch
// shape must match the PTY route this scope already resolved.
const isRemote = Boolean(workspace.connectionId)
const shell = resolveLocalWindowsAgentStartupShell({
platform,
isRemote,
@@ -4,6 +4,7 @@ import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktre
import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
import { isTuiAgentEnabled } from '../../shared/tui-agent-selection'
import { isFolderRepo } from '../../shared/repo-kind'
import { getRepoSshConnectionId } from '../../shared/execution-host'
import { createRuntimeFolderWorktree } from './runtime-folder-worktree-create'
import { createRuntimeLocalManagedWorktree } from './runtime-local-worktree-create'
import { prepareRuntimeLocalWorktreeSetup } from './runtime-local-worktree-setup'
@@ -56,7 +57,13 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork
draftStartup?.agent ??
(requestedAgentEnabled ? requestedAgent : undefined))
const effectiveDraftPaste = args.startupDraftPaste ?? draftStartup?.draftPaste
// Resolve the execution host once: SSH ownership has two spellings, and reading the raw
// `connectionId` field routes an `executionHostId: 'ssh:*'`-only repo down the local path,
// which runs `git worktree add` on the client against a remote path.
const sshConnectionId = getRepoSshConnectionId(repo)
if (isFolderRepo(repo)) {
// A folder workspace is a registration, not a filesystem create, so it is host-agnostic —
// except for the agent trust write, which must land on the host that will run the agent.
return createRuntimeFolderWorktree({
request: args,
repo,
@@ -68,7 +75,8 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork
store: this.store,
ptySpawnAvailable: Boolean(this.ptyController?.spawn),
createTerminal: (selector, options) => this.createTerminal(selector, options),
markTrusted: (agent, path) => this.markLocalWorkspaceTrustedForAgent(agent, path),
markTrusted: (agent, path) =>
this.markWorkspaceTrustedForAgent(agent, sshConnectionId, path),
pasteDraft: (handle, draft) => this.pasteStartupDraftWhenReady(handle, draft),
sendFollowup: (handle, followup) => this.sendStartupFollowupWhenReady(handle, followup),
invalidateResolvedWorktrees: () => this.invalidateResolvedWorktreeCache(),
@@ -89,15 +97,20 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork
const lineageInput =
args.lineage || args.comment ? { ...args.lineage, comment: args.comment } : undefined
const lineageResolution = await this.resolveLineageForWorktreeCreate(lineageInput)
if (repo.connectionId) {
const result = await this.createManagedRemoteWorktree(repo, {
...args,
activate: args.activate,
...(effectiveStartup ? { startup: effectiveStartup } : {}),
...(effectiveStartupFollowup ? { startupFollowup: effectiveStartupFollowup } : {}),
...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}),
...(effectiveDraftPaste ? { startupDraftPaste: effectiveDraftPaste } : {})
})
if (sshConnectionId) {
// Why normalize the row: the remote-create pipeline reads `repo.connectionId!` at every
// depth, so hand it the connection the resolved host actually names.
const result = await this.createManagedRemoteWorktree(
{ ...repo, connectionId: sshConnectionId },
{
...args,
activate: args.activate,
...(effectiveStartup ? { startup: effectiveStartup } : {}),
...(effectiveStartupFollowup ? { startupFollowup: effectiveStartupFollowup } : {}),
...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}),
...(effectiveDraftPaste ? { startupDraftPaste: effectiveDraftPaste } : {})
}
)
const recordedLineage = this.recordCreatedWorktreeLineage(result.worktree, lineageResolution)
this.emitWorktreeLifecycle({
kind: 'created',
@@ -139,7 +139,7 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca
...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}),
tabs
}
this.mobileSessionTabsByWorktree.set(worktreeId, next)
this.storeMobileSessionSnapshot(worktreeId, next)
const result = this.toMobileSessionTabsResult(next)
const changeSequence = ++this.mobileSessionTabsChangeSequence
for (const subscription of this.mobileSessionTabListeners) {
@@ -29,8 +29,8 @@ export class OrcaRuntimeWithFileCommands extends OrcaRuntimeWithPreservedBranchC
requireStore: () => this.requireStore(),
resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector),
resolveRuntimeFileTarget: (selector) => this.resolveRuntimeFileTarget(selector),
resolveKnownWorkspaceFileTarget: (absolutePath, connectionId) =>
this.resolveKnownWorkspaceFileTarget(absolutePath, connectionId),
resolveKnownWorkspaceFileTarget: (absolutePath, executionHostId) =>
this.resolveKnownWorkspaceFileTarget(absolutePath, executionHostId),
resolveTerminalCwd: (terminalHandle) => this.resolveTerminalCwd(terminalHandle),
resolveTerminalContext: (terminalHandle) => this.resolveTerminalContext(terminalHandle),
resolveTerminalFileUriHostname: (terminalHandle) =>
@@ -154,7 +154,7 @@ describe('RuntimeFileCommands', () => {
repoId: 'repo-1',
path: '/remote/repo'
},
connectionId: 'ssh-1'
executionHostId: 'ssh:ssh-1'
}))
const { commands } = createRuntimeFileCommands({
openFile,
@@ -105,11 +105,20 @@ export const filesystemSearchGitMock = {
searchWithGitGrep: searchWithGitGrepMock
}
const SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE =
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
export const sshFilesystemDispatchMock = {
getSshFilesystemProvider: getSshFilesystemProviderMock,
requireSshFilesystemProvider: (connectionId: string) => {
const provider = getSshFilesystemProviderMock(connectionId)
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
return provider
},
onSshFilesystemProviderRegistered: () => () => undefined,
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE:
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
}
export function resetRuntimeFileMocks(): void {
@@ -75,7 +75,7 @@ describe('RuntimeFileCommands', () => {
const { commands } = createRuntimeFileCommands({
resolveRuntimeFileTarget: vi.fn(async () => ({
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' },
connectionId: 'ssh-1'
executionHostId: 'ssh:ssh-1'
}))
})
@@ -96,7 +96,7 @@ describe('RuntimeFileCommands', () => {
repoId: 'repo-1',
path: '/repo'
},
connectionId: null
executionHostId: 'local'
}))
const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget })
const child = createRuntimeSearchChild()
@@ -128,7 +128,7 @@ describe('RuntimeFileCommands', () => {
async (order) => {
const resolveRuntimeFileTarget = vi.fn(async () => ({
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' },
connectionId: null
executionHostId: 'local'
}))
const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget })
const child = createRuntimeSearchChild()
@@ -163,7 +163,7 @@ describe('RuntimeFileCommands', () => {
it("falls back when a runtime native launcher exits outside ripgrep's contract", async () => {
const resolveRuntimeFileTarget = vi.fn(async () => ({
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' },
connectionId: null
executionHostId: 'local'
}))
const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget })
const child = createRuntimeSearchChild()
@@ -191,7 +191,7 @@ describe('RuntimeFileCommands', () => {
repoId: 'repo-1',
path: 'C:\\repo'
},
connectionId: null
executionHostId: 'local'
}))
const { commands, store } = createRuntimeFileCommands({ resolveRuntimeFileTarget })
const child = createRuntimeSearchChild()
@@ -231,7 +231,7 @@ describe('RuntimeFileCommands', () => {
it('keeps the runtime WSL preflight and falls back before starting real rg', async () => {
const resolveRuntimeFileTarget = vi.fn(async () => ({
worktree: { id: 'wt-1', repoId: 'repo-1', path: 'C:\\repo' },
connectionId: null
executionHostId: 'local'
}))
const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget })
const fallback = { files: [], totalMatches: 0, truncated: false }
@@ -250,7 +250,7 @@ describe('RuntimeFileCommands', () => {
it('keeps legacy SSH Quick Open replies within the frame-sized result bound', async () => {
const resolveRuntimeFileTarget = vi.fn(async () => ({
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' },
connectionId: 'ssh-1'
executionHostId: 'ssh:ssh-1'
}))
const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget })
const listFiles = vi.fn(async () => ['src/target.ts'])
@@ -67,7 +67,7 @@ function sshCommands() {
path: '/remote/repo',
resolveRuntimeFileTarget: vi.fn(async () => ({
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/remote/repo' },
connectionId: 'ssh-1'
executionHostId: 'ssh:ssh-1'
}))
}).commands
}
@@ -62,7 +62,7 @@ function createRuntimeFileCommands(): RuntimeFileCommands {
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1', repoId: 'repo-1', path: ROOT_PATH })),
resolveRuntimeFileTarget: vi.fn(async () => ({
worktree: { id: 'wt-1', repoId: 'repo-1', path: ROOT_PATH },
connectionId: CONNECTION_ID
executionHostId: `ssh:${CONNECTION_ID}`
})),
resolveRuntimeGitTarget: vi.fn(),
openFile: vi.fn()
@@ -103,6 +103,7 @@ describe('RuntimeFileCommands', () => {
}
const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({
worktree: sibling,
executionHostId: 'local',
relativePath: 'docs/readme.md'
}))
const { commands } = createRuntimeFileCommands({
@@ -154,6 +155,7 @@ describe('RuntimeFileCommands', () => {
}
const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({
worktree: sibling,
executionHostId: 'local',
relativePath: ''
}))
const hasRecentTerminalOutputPath = vi.fn(() => true)
@@ -195,7 +197,7 @@ describe('RuntimeFileCommands', () => {
}
const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({
worktree: sibling,
connectionId: 'ssh-1',
executionHostId: 'ssh:ssh-1',
relativePath: 'docs/readme.md'
}))
const { commands, store } = createRuntimeFileCommands({
@@ -245,7 +247,7 @@ describe('RuntimeFileCommands', () => {
}
const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({
worktree: sibling,
connectionId: 'ssh-1',
executionHostId: 'ssh:ssh-1',
relativePath: ''
}))
const hasRecentTerminalOutputPath = vi.fn(() => true)
@@ -274,11 +276,14 @@ describe('RuntimeFileCommands', () => {
expect(hasRecentTerminalOutputPath).not.toHaveBeenCalled()
})
// The host was `runtime:env-a` until this process stopped dispatching runtime hosts at all
// (see runtime-file-target-execution-host.test.ts); an SSH host proves the same scoping on a
// host this process actually serves.
it('scopes sibling lookup to the selected worktree execution host', async () => {
const resolveKnownWorkspaceFileTarget = vi.fn(async () => null)
const { commands } = createRuntimeFileCommands({
path: '/repo-a',
hostId: 'runtime:env-a',
hostId: 'ssh:openclaw',
resolveKnownWorkspaceFileTarget
})
@@ -293,7 +298,7 @@ describe('RuntimeFileCommands', () => {
expect(resolveKnownWorkspaceFileTarget).toHaveBeenCalledWith(
'/repo-b/docs/readme.md',
'runtime:env-a'
'ssh:openclaw'
)
})
@@ -2,6 +2,12 @@ import { afterEach, beforeEach, vi } from 'vitest'
import { awaitRuntimeFileWatcherUnsubscribes, RuntimeFileCommands } from './orca-runtime-files'
import { resetSshConnectionGenerations } from '../ssh/ssh-connection-generation'
import { resetRuntimeFileMocks } from './orca-runtime-files-mock-registry'
import {
LOCAL_EXECUTION_HOST_ID,
normalizeExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from '../../shared/execution-host'
/** Restores the shared fs/auth/watcher mock state and fake timers around each test. */
export function useRuntimeFileCommandsLifecycle(): void {
@@ -51,6 +57,14 @@ export function createRuntimeFileCommands(options?: {
path,
...(options?.hostId ? { hostId: options.hostId } : {})
}
// Mirrors the real resolver: the worktree's own host outranks the repo row.
const runtimeFileTargetExecutionHostId = (): ExecutionHostId => {
const connectionId = store.getRepo(worktree.repoId)?.connectionId
return (
normalizeExecutionHostId(options?.hostId) ??
(connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID)
)
}
const commands = new RuntimeFileCommands({
getRuntimeId: () => 'runtime-1',
requireStore: () => store,
@@ -59,7 +73,7 @@ export function createRuntimeFileCommands(options?: {
options?.resolveRuntimeFileTarget ??
vi.fn(async () => ({
worktree,
connectionId: store.getRepo(worktree.repoId)?.connectionId
executionHostId: runtimeFileTargetExecutionHostId()
})),
...(options?.resolveKnownWorkspaceFileTarget
? { resolveKnownWorkspaceFileTarget: options.resolveKnownWorkspaceFileTarget }
@@ -87,7 +87,8 @@ function createRuntimeFileCommands(rootPath: string) {
id: 'wt-1',
repoId: 'repo-1',
path: rootPath
}
},
executionHostId: 'local'
})),
resolveRuntimeGitTarget: vi.fn(),
openFile: vi.fn()
@@ -560,7 +561,7 @@ describe('RuntimeFileCommands file watching', () => {
repoId: 'repo-1',
path: '/remote/repo'
},
connectionId: 'ssh-1'
executionHostId: 'ssh:ssh-1'
})),
resolveRuntimeGitTarget: vi.fn(),
openFile: vi.fn()
+2 -3
View File
@@ -6,8 +6,7 @@ export { WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS } from './runtime-file-com
export { awaitRuntimeFileWatcherUnsubscribes } from './runtime-file-watcher-leases'
export { _getRuntimeFileWatcherReleaseCountForTests } from './runtime-file-watcher-leases'
export { _resetRuntimeFileWatcherLeasesForTests } from './runtime-file-watcher-leases'
export type { ResolvedRuntimeFileWorktree } from './runtime-file-watcher-leases'
export type { ResolvedRuntimeFileTarget } from './runtime-file-watcher-leases'
export { getRuntimeFileTargetExecutionHostId } from './runtime-file-watcher-leases'
export type { ResolvedRuntimeFileWorktree } from './runtime-file-command-target'
export type { ResolvedRuntimeFileTarget } from './runtime-file-command-target'
export type { RuntimeFileCommandHost } from './runtime-file-command-host'
export { isSafeMobileRelativePath } from './runtime-file-command-host'
@@ -11,7 +11,6 @@ import type {
} from '../../shared/agent-session-host-authority'
import { canonicalizeAgentSessionIdentity } from './agent-session-claim-identity'
import { isTuiAgentEnabled } from '../../shared/tui-agent-selection'
import { repoIsRemote } from '../../shared/agent-launch-remote'
import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell'
import { buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup'
import {
@@ -123,7 +122,9 @@ export class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends OrcaRuntim
throw new Error('Selected agent is disabled. Choose an enabled agent before resuming.')
}
const platform = this.getAgentLaunchPlatformForWorkspace(workspace)
const isRemote = workspace.repo ? repoIsRemote(workspace.repo) : Boolean(workspace.connectionId)
// Why: `workspace.repo` is display metadata and may be a row from another host; the launch
// shape must match the PTY route this scope already resolved.
const isRemote = Boolean(workspace.connectionId)
const shell = resolveLocalWindowsAgentStartupShell({
platform,
isRemote,
@@ -45,7 +45,7 @@ describe('RuntimeGitCommands branch diff', () => {
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({
worktree: makeWorktree('/remote/repo'),
connectionId: 'conn-1'
executionHostId: 'ssh:conn-1'
}),
getRuntimeSettings: () => ({}) as GlobalSettings
})
@@ -57,7 +57,7 @@ function commands(
return new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({
worktree,
...(connectionId ? { connectionId } : {}),
executionHostId: connectionId ? (`ssh:${connectionId}` as const) : ('local' as const),
...(localGitOptions ? { localGitOptions } : {})
}),
getRuntimeSettings: () => ({}) as GlobalSettings
+25 -17
View File
@@ -86,9 +86,13 @@ function makeWorktree(path: string, linkedIssue: number | null = null): Resolved
return worktree as unknown as ResolvedRuntimeGitWorktree
}
function localTarget(worktreePath: string, linkedIssue: number | null = null) {
return { worktree: makeWorktree(worktreePath, linkedIssue), executionHostId: 'local' as const }
}
function makeCommands(worktreePath: string): RuntimeGitCommands {
return new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath) }),
resolveRuntimeGitTarget: async () => localTarget(worktreePath),
getRuntimeSettings: () => ({}) as GlobalSettings
})
}
@@ -125,6 +129,7 @@ describe('RuntimeGitCommands', () => {
mocks.getStatus.mockResolvedValue({ entries: [], conflictOperation: 'none' })
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({
executionHostId: 'local',
worktree: makeWorktree('/workspace/feature'),
repo: { path: '/workspace/repo', symlinkPaths: ['node_modules'] } as never
}),
@@ -146,7 +151,7 @@ describe('RuntimeGitCommands', () => {
resolveRuntimeGitTarget: async () => ({
worktree: makeWorktree('/remote/repo'),
repo: { path: '/remote/repo', symlinkPaths: ['node_modules'] } as never,
connectionId: 'conn-1'
executionHostId: 'ssh:conn-1'
}),
getRuntimeSettings: () => ({}) as GlobalSettings
})
@@ -162,6 +167,7 @@ describe('RuntimeGitCommands', () => {
tempDirs.push(worktreePath)
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({
executionHostId: 'local',
worktree: makeWorktree(worktreePath),
localGitOptions: { wslDistro: 'Ubuntu' }
}),
@@ -186,7 +192,7 @@ describe('RuntimeGitCommands', () => {
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({
worktree: makeWorktree('/remote/repo'),
connectionId: 'conn-1'
executionHostId: 'ssh:conn-1'
}),
getRuntimeSettings: () => ({}) as GlobalSettings
})
@@ -217,6 +223,7 @@ describe('RuntimeGitCommands', () => {
it('prioritizes a local single-file discard without losing WSL routing', async () => {
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({
executionHostId: 'local',
worktree: makeWorktree('/workspace/repo'),
localGitOptions: { wslDistro: 'Ubuntu' }
}),
@@ -237,7 +244,7 @@ describe('RuntimeGitCommands', () => {
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({
worktree: makeWorktree('/remote/repo'),
connectionId: 'conn-1'
executionHostId: 'ssh:conn-1'
}),
getRuntimeSettings: () => ({}) as GlobalSettings
})
@@ -256,7 +263,7 @@ describe('RuntimeGitCommands', () => {
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({
worktree: makeWorktree('/remote/repo'),
connectionId: 'conn-1'
executionHostId: 'ssh:conn-1'
}),
getRuntimeSettings: () => ({}) as GlobalSettings
})
@@ -299,7 +306,7 @@ describe('RuntimeGitCommands', () => {
message: 'docs: update readme'
})
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath) }),
resolveRuntimeGitTarget: async () => localTarget(worktreePath),
getRuntimeSettings: () =>
({
commitMessageAi: { enabled: true, agentId: 'codex' },
@@ -352,6 +359,7 @@ describe('RuntimeGitCommands', () => {
})
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({
executionHostId: 'local',
worktree: makeWorktree(worktreePath),
localGitOptions: { wslDistro: 'Ubuntu' }
}),
@@ -410,7 +418,7 @@ describe('RuntimeGitCommands', () => {
message: 'feat: update readme'
})
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath) }),
resolveRuntimeGitTarget: async () => localTarget(worktreePath),
getRuntimeSettings: () =>
({
sourceControlAi: {
@@ -471,7 +479,7 @@ describe('RuntimeGitCommands', () => {
}
})
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath) }),
resolveRuntimeGitTarget: async () => localTarget(worktreePath),
getRuntimeSettings: () =>
({
sourceControlAi: {
@@ -542,7 +550,7 @@ describe('RuntimeGitCommands', () => {
}
})
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath) }),
resolveRuntimeGitTarget: async () => localTarget(worktreePath),
getRuntimeSettings: () => ({}) as GlobalSettings
})
@@ -595,7 +603,7 @@ describe('RuntimeGitCommands', () => {
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({
worktree: makeWorktree(worktreePath),
connectionId: 'conn-1'
executionHostId: 'ssh:conn-1'
}),
getRuntimeSettings: () =>
({
@@ -637,7 +645,7 @@ describe('RuntimeGitCommands', () => {
mocks.getStagedCommitContext.mockResolvedValue(context)
mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'docs' })
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath, 123) }),
resolveRuntimeGitTarget: async () => localTarget(worktreePath, 123),
getRuntimeSettings: () => ({}) as GlobalSettings
})
@@ -663,7 +671,7 @@ describe('RuntimeGitCommands', () => {
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({
worktree: makeWorktree(worktreePath, 77),
connectionId: 'conn-1'
executionHostId: 'ssh:conn-1'
}),
getRuntimeSettings: () => ({}) as GlobalSettings
})
@@ -687,7 +695,7 @@ describe('RuntimeGitCommands', () => {
mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'docs' })
const getWorktreeLinkedIssue = vi.fn(() => 321)
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath, 123) }),
resolveRuntimeGitTarget: async () => localTarget(worktreePath, 123),
getRuntimeSettings: () => ({}) as GlobalSettings,
getWorktreeLinkedIssue
})
@@ -713,7 +721,7 @@ describe('RuntimeGitCommands', () => {
mocks.getStagedCommitContext.mockResolvedValue(context)
mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'docs' })
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath, 123) }),
resolveRuntimeGitTarget: async () => localTarget(worktreePath, 123),
getRuntimeSettings: () => ({}) as GlobalSettings,
getWorktreeLinkedIssue: () => null
})
@@ -734,7 +742,7 @@ describe('RuntimeGitCommands', () => {
mocks.getStagedCommitContext.mockResolvedValue(context)
mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'docs' })
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath, 123) }),
resolveRuntimeGitTarget: async () => localTarget(worktreePath, 123),
getRuntimeSettings: () => ({}) as GlobalSettings,
// Why: what the host reports when its store is not initialized yet.
getWorktreeLinkedIssue: () => undefined
@@ -765,7 +773,7 @@ describe('RuntimeGitCommands', () => {
mocks.getPullRequestDraftContext.mockResolvedValue(context)
mocks.generatePullRequestFieldsFromContext.mockResolvedValue({ success: true, fields: {} })
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath, 123) }),
resolveRuntimeGitTarget: async () => localTarget(worktreePath, 123),
getRuntimeSettings: () => ({}) as GlobalSettings,
getWorktreeLinkedIssue: () => 321
})
@@ -827,7 +835,7 @@ describe('RuntimeGitCommands', () => {
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({
worktree: makeWorktree(worktreePath, 55),
...(connectionId ? { connectionId } : {})
executionHostId: connectionId ? (`ssh:${connectionId}` as const) : ('local' as const)
}),
getRuntimeSettings: () => ({}) as GlobalSettings
})

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