mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
Merge origin/main into entrypoint routing recovery
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 |
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Generated
+3
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user