From 50da78aa852070d9df15cb1c7029981ca223ef7f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:24:31 -0700 Subject: [PATCH] fix(mobile): detect New Workspace agents in the selected repo runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass the selected repository to New Workspace agent detection through the host-authoritative runtime resolver, including WSL paths and Windows project preferences. Preserve old-client request shape, SSH routing, and strict ownership/refusal behavior. This merge credits @welldoingk’s original WSL agent-detection investigation and community PR #20101, adapted to the current host-resolved contract. Co-authored-by: welldoingk Fixes #20060 --- ...ce-ssh-local-preflight.detectagents-1.json | 574 +++++++++--------- .../tw-workspace-ssh-local-agents.json | 78 +-- mobile/rpc-foundation/pilot-scenarios.json | 2 +- .../src/components/NewWorktreeModal.test.tsx | 2 +- mobile/src/components/NewWorktreeModal.tsx | 1 + ...se-new-workspace-execution-target.test.tsx | 161 +++++ .../use-new-workspace-execution-target.ts | 21 +- ...ile-tasks-workspace-create-server.test.tsx | 12 +- .../use-mobile-tasks-workspace-ssh-state.tsx | 7 +- .../agent-detection-host-resolution.test.ts | 164 ++++- .../runtime/rpc/methods/preflight.test.ts | 56 +- src/main/runtime/rpc/methods/preflight.ts | 4 +- .../runtime/runtime-agent-detection-host.ts | 75 +++ .../terminal-orchestration-authority.ts | 52 +- src/shared/rpc-contract/preflight-params.ts | 3 +- 15 files changed, 829 insertions(+), 383 deletions(-) create mode 100644 mobile/src/components/use-new-workspace-execution-target.test.tsx create mode 100644 src/main/runtime/runtime-agent-detection-host.ts diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json index a7297c87178..c8e7a1bd8a4 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json @@ -4,12 +4,12 @@ "family": "tasks.workspace-ssh-local", "namedDeltas": [], "values": { - "11405363c62d": { - "name": "workspaceAgent", - "ordinal": 1, - "value": "claude" + "057cfe2200b5": { + "name": "preflight.detectAgents#1", + "ordinal": 5, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\",\"params\":{\"repoId\":\"repo-1\"}}" }, - "1feb12bfbf62": { + "110ef4b3eb5b": { "name": "preflight.detectAgents#1", "ordinal": 4, "args": [ @@ -19,9 +19,239 @@ }, { "name": "params", + "value": { + "repoId": "repo-1" + } + }, + { + "name": "options", "value": { "$rpc": "absent" } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "11405363c62d": { + "name": "workspaceAgent", + "ordinal": 1, + "value": "claude" + }, + "12f65d55aa1a": { + "name": "preflight.detectAgents#1", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "repoId": "repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-1", + "ok": false + } + } + }, + "34cbfeeb5c49": { + "name": "preflight.detectAgents#1", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "repoId": "repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "43111f57966a": { + "name": "preflight.detectAgents#1", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "repoId": "repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "5786215332fb": { + "name": "preflight.detectAgents#1", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "repoId": "repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "630e2f27face": { + "name": "workspaceDetectedAgentIds", + "ordinal": 6, + "value": [] + }, + "63751bc69233": { + "name": "preflight.detectAgents#1", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "repoId": "repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-1", + "ok": false + } + } + }, + "71225024ccf5": { + "agent": "claude", + "connecting": false, + "detected": [], + "setup": "unresolved", + "ssh": { + "$rpc": "null" + } + }, + "7400f4eebe66": { + "agent": "claude", + "connecting": false, + "detected": ["codex", "claude"], + "setup": "unresolved", + "ssh": { + "$rpc": "null" + } + }, + "8172e74bc69e": { + "name": "preflight.detectAgents#1", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "repoId": "repo-1" + } }, { "name": "options", @@ -44,7 +274,7 @@ } } }, - "24a0184fc2f5": { + "8f4b7427ef27": { "name": "preflight.detectAgents#1", "ordinal": 4, "args": [ @@ -55,7 +285,7 @@ { "name": "params", "value": { - "$rpc": "absent" + "repoId": "repo-1" } }, { @@ -81,35 +311,14 @@ } } }, - "5da628eecd2f": { - "name": "preflight.detectAgents#1", - "ordinal": 5, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\"}" - }, - "630e2f27face": { + "9637248df9cb": { "name": "workspaceDetectedAgentIds", - "ordinal": 6, - "value": [] - }, - "71225024ccf5": { - "agent": "claude", - "connecting": false, - "detected": [], - "setup": "unresolved", - "ssh": { + "ordinal": 3, + "value": { "$rpc": "null" } }, - "7400f4eebe66": { - "agent": "claude", - "connecting": false, - "detected": ["codex", "claude"], - "setup": "unresolved", - "ssh": { - "$rpc": "null" - } - }, - "78870501ce99": { + "a3447a38f180": { "name": "preflight.detectAgents#1", "ordinal": 4, "args": [ @@ -120,7 +329,7 @@ { "name": "params", "value": { - "$rpc": "absent" + "repoId": "repo-1" } }, { @@ -144,7 +353,12 @@ } } }, - "90ac803be31a": { + "a9ec92743b9e": { + "name": "workspaceAgentOverridden", + "ordinal": 2, + "value": false + }, + "c988ed31a0bf": { "name": "preflight.detectAgents#1", "ordinal": 4, "args": [ @@ -155,78 +369,7 @@ { "name": "params", "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "transport failure", - "isRpcDeliveryUnknown": true - } - } - }, - "9637248df9cb": { - "name": "workspaceDetectedAgentIds", - "ordinal": 3, - "value": { - "$rpc": "null" - } - }, - "9a93035b19e1": { - "name": "preflight.detectAgents#1", - "ordinal": 4, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "", - "isRpcDeliveryUnknown": true - } - } - }, - "9bee832e6c84": { - "name": "preflight.detectAgents#1", - "ordinal": 4, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" + "repoId": "repo-1" } }, { @@ -241,16 +384,25 @@ "startedAt": 0, "settledAt": 0, "value": { - "error": { - "code": "refused", - "message": "" - }, "id": "frame-1", - "ok": false + "ok": true } } }, - "9fdd4a87eb0b": { + "d68cba3b57e1": { + "name": "workspaceDetectedAgentIds", + "ordinal": 6, + "value": ["codex", "claude"] + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "ebcf6c6d8d86": { "name": "preflight.detectAgents#1", "ordinal": 4, "args": [ @@ -261,7 +413,7 @@ { "name": "params", "value": { - "$rpc": "absent" + "repoId": "repo-1" } }, { @@ -281,158 +433,6 @@ "result": ["codex", "claude"] } } - }, - "a2765967bb49": { - "name": "preflight.detectAgents#1", - "ordinal": 4, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "error": "refused" - } - } - } - }, - "a9ec92743b9e": { - "name": "workspaceAgentOverridden", - "ordinal": 2, - "value": false - }, - "c1f59c7b2afa": { - "name": "preflight.detectAgents#1", - "ordinal": 4, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "$rpc": "null" - } - } - } - }, - "c8028d1dd40a": { - "name": "preflight.detectAgents#1", - "ordinal": 4, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "refused", - "message": "outer refused" - }, - "id": "frame-1", - "ok": false - } - } - }, - "d68cba3b57e1": { - "name": "workspaceDetectedAgentIds", - "ordinal": 6, - "value": ["codex", "claude"] - }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } - }, - "f8eb8a4a9917": { - "name": "preflight.detectAgents#1", - "ordinal": 4, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true - } - } } }, "recording": { @@ -441,8 +441,8 @@ { "id": "tw-workspace-ssh-local-agents.normal:local-agents-detected", "observation": { - "sender": ["9fdd4a87eb0b"], - "payloads": ["5da628eecd2f"], + "sender": ["ebcf6c6d8d86"], + "payloads": ["057cfe2200b5"], "settlements": { "mount": "eb79a9b3682a" }, @@ -453,8 +453,8 @@ { "id": "tw-workspace-ssh-local-agents.result-absent:local-agents-detected", "observation": { - "sender": ["f8eb8a4a9917"], - "payloads": ["5da628eecd2f"], + "sender": ["c988ed31a0bf"], + "payloads": ["057cfe2200b5"], "settlements": { "mount": "eb79a9b3682a" }, @@ -465,8 +465,8 @@ { "id": "tw-workspace-ssh-local-agents.result-null:local-agents-detected", "observation": { - "sender": ["c1f59c7b2afa"], - "payloads": ["5da628eecd2f"], + "sender": ["5786215332fb"], + "payloads": ["057cfe2200b5"], "settlements": { "mount": "eb79a9b3682a" }, @@ -477,8 +477,8 @@ { "id": "tw-workspace-ssh-local-agents.inner-ok-missing:local-agents-detected", "observation": { - "sender": ["a2765967bb49"], - "payloads": ["5da628eecd2f"], + "sender": ["43111f57966a"], + "payloads": ["057cfe2200b5"], "settlements": { "mount": "eb79a9b3682a" }, @@ -489,8 +489,8 @@ { "id": "tw-workspace-ssh-local-agents.inner-false-string-error:local-agents-detected", "observation": { - "sender": ["78870501ce99"], - "payloads": ["5da628eecd2f"], + "sender": ["a3447a38f180"], + "payloads": ["057cfe2200b5"], "settlements": { "mount": "eb79a9b3682a" }, @@ -501,8 +501,8 @@ { "id": "tw-workspace-ssh-local-agents.inner-false-object-error:local-agents-detected", "observation": { - "sender": ["24a0184fc2f5"], - "payloads": ["5da628eecd2f"], + "sender": ["8f4b7427ef27"], + "payloads": ["057cfe2200b5"], "settlements": { "mount": "eb79a9b3682a" }, @@ -513,8 +513,8 @@ { "id": "tw-workspace-ssh-local-agents.outer-refused:local-agents-detected", "observation": { - "sender": ["c8028d1dd40a"], - "payloads": ["5da628eecd2f"], + "sender": ["12f65d55aa1a"], + "payloads": ["057cfe2200b5"], "settlements": { "mount": "eb79a9b3682a" }, @@ -525,8 +525,8 @@ { "id": "tw-workspace-ssh-local-agents.outer-refused-no-message:local-agents-detected", "observation": { - "sender": ["9bee832e6c84"], - "payloads": ["5da628eecd2f"], + "sender": ["63751bc69233"], + "payloads": ["057cfe2200b5"], "settlements": { "mount": "eb79a9b3682a" }, @@ -537,8 +537,8 @@ { "id": "tw-workspace-ssh-local-agents.method-not-found:local-agents-detected", "observation": { - "sender": ["1feb12bfbf62"], - "payloads": ["5da628eecd2f"], + "sender": ["8172e74bc69e"], + "payloads": ["057cfe2200b5"], "settlements": { "mount": "eb79a9b3682a" }, @@ -549,8 +549,8 @@ { "id": "tw-workspace-ssh-local-agents.transport-rejection:local-agents-detected", "observation": { - "sender": ["90ac803be31a"], - "payloads": ["5da628eecd2f"], + "sender": ["110ef4b3eb5b"], + "payloads": ["057cfe2200b5"], "settlements": { "mount": "eb79a9b3682a" }, @@ -561,8 +561,8 @@ { "id": "tw-workspace-ssh-local-agents.transport-rejection-no-message:local-agents-detected", "observation": { - "sender": ["9a93035b19e1"], - "payloads": ["5da628eecd2f"], + "sender": ["34cbfeeb5c49"], + "payloads": ["057cfe2200b5"], "settlements": { "mount": "eb79a9b3682a" }, diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json index c93a98b2e4c..aac4dd49c3c 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json @@ -4,16 +4,16 @@ "family": "tasks.workspace-ssh-local", "namedDeltas": [], "values": { + "057cfe2200b5": { + "name": "preflight.detectAgents#1", + "ordinal": 5, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\",\"params\":{\"repoId\":\"repo-1\"}}" + }, "11405363c62d": { "name": "workspaceAgent", "ordinal": 1, "value": "claude" }, - "5da628eecd2f": { - "name": "preflight.detectAgents#1", - "ordinal": 5, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\"}" - }, "7400f4eebe66": { "agent": "claude", "connecting": false, @@ -30,38 +30,6 @@ "$rpc": "null" } }, - "9fdd4a87eb0b": { - "name": "preflight.detectAgents#1", - "ordinal": 4, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": ["codex", "claude"] - } - } - }, "a9ec92743b9e": { "name": "workspaceAgentOverridden", "ordinal": 2, @@ -79,6 +47,38 @@ "value": { "$rpc": "undefined" } + }, + "ebcf6c6d8d86": { + "name": "preflight.detectAgents#1", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "repoId": "repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": ["codex", "claude"] + } + } } }, "recording": { @@ -87,8 +87,8 @@ { "id": "local-agents-detected", "observation": { - "sender": ["9fdd4a87eb0b"], - "payloads": ["5da628eecd2f"], + "sender": ["ebcf6c6d8d86"], + "payloads": ["057cfe2200b5"], "settlements": { "mount": "eb79a9b3682a" }, diff --git a/mobile/rpc-foundation/pilot-scenarios.json b/mobile/rpc-foundation/pilot-scenarios.json index b634a264120..6236d137424 100644 --- a/mobile/rpc-foundation/pilot-scenarios.json +++ b/mobile/rpc-foundation/pilot-scenarios.json @@ -7219,7 +7219,7 @@ { "complete": "preflight.detectAgents#1", "params": { - "$undefined": true + "repoId": "repo-1" }, "reply": { "ok": true, diff --git a/mobile/src/components/NewWorktreeModal.test.tsx b/mobile/src/components/NewWorktreeModal.test.tsx index 4e2163ea3c3..9ba428fb40c 100644 --- a/mobile/src/components/NewWorktreeModal.test.tsx +++ b/mobile/src/components/NewWorktreeModal.test.tsx @@ -323,7 +323,7 @@ describe('NewWorktreeModal project targets', () => { await flushUpdates() expect(renderer.root.findAll((node) => node.props.label === 'Workspace name')).toHaveLength(1) - expect(sendRequest).toHaveBeenCalledWith('preflight.detectAgents') + expect(sendRequest).toHaveBeenCalledWith('preflight.detectAgents', { repoId: 'folder-1' }) expect(sendRequest).not.toHaveBeenCalledWith('ssh.getState', expect.anything()) expect(sendRequest.mock.calls.map(([method]) => method)).not.toContain( 'worktree.prefetchCreateBase' diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 02745d83868..45c87c9d076 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -100,6 +100,7 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) { const executionTarget = useNewWorkspaceExecutionTarget({ client, connectionId: selectedRepoConnectionId, + repoId: selectedRepo?.id, visible }) const setupScript = useNewWorkspaceSetupScript({ client, selectedRepo }) diff --git a/mobile/src/components/use-new-workspace-execution-target.test.tsx b/mobile/src/components/use-new-workspace-execution-target.test.tsx new file mode 100644 index 00000000000..5f9f9ed6a1b --- /dev/null +++ b/mobile/src/components/use-new-workspace-execution-target.test.tsx @@ -0,0 +1,161 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it } from 'vitest' +import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' +import { useNewWorkspaceExecutionTarget } from './use-new-workspace-execution-target' + +const mounted: ReactTestRenderer[] = [] +afterEach(() => { + for (const renderer of mounted.splice(0)) { + act(() => renderer.unmount()) + } +}) + +describe('New Workspace agent detection', () => { + it('targets the picked repo and clears the previous repo agents while switching', async () => { + const client = new FakeSession('connected') + let resolveNext: ((response: { id: string; ok: true; result: string[] }) => void) | undefined + client.sendRequest.mockImplementation(async (_method: string, params?: unknown) => { + if (JSON.stringify(params) === JSON.stringify({ repoId: 'wsl-repo' })) { + return { id: 'reply', ok: true, result: ['claude'] } + } + return new Promise((resolve) => { + resolveNext = resolve + }) + }) + let detected: Set | null = null + function Harness({ + repoId, + selectedClient = client + }: { + repoId: string + selectedClient?: RpcClient + }) { + detected = useNewWorkspaceExecutionTarget({ + client: selectedClient, + repoId, + connectionId: null, + visible: true + }).detectedAgentIds + return null + } + let renderer: ReactTestRenderer | undefined + await act(async () => { + renderer = create(createElement(Harness, { repoId: 'wsl-repo' })) + mounted.push(renderer) + }) + expect(client.sendRequest).toHaveBeenCalledWith('preflight.detectAgents', { + repoId: 'wsl-repo' + }) + expect(detected).toEqual(new Set(['claude'])) + await act(async () => { + renderer?.update(createElement(Harness, { repoId: 'host-repo' })) + }) + expect(detected).toBeNull() + expect(client.sendRequest).toHaveBeenLastCalledWith('preflight.detectAgents', { + repoId: 'host-repo' + }) + await act(async () => { + resolveNext?.({ id: 'reply', ok: true, result: ['codex'] }) + }) + expect(detected).toEqual(new Set(['codex'])) + const otherHost = new FakeSession('connected') + otherHost.sendRequest.mockImplementation( + () => + new Promise((resolve) => { + resolveNext = resolve + }) + ) + await act(async () => { + renderer?.update(createElement(Harness, { repoId: 'host-repo', selectedClient: otherHost })) + }) + expect(detected).toBeNull() + await act(async () => { + resolveNext?.({ id: 'reply', ok: true, result: ['gemini'] }) + }) + expect(detected).toEqual(new Set(['gemini'])) + }) + + it.each([ + { switchHost: false, rejectLate: false }, + { switchHost: false, rejectLate: true }, + { switchHost: true, rejectLate: false }, + { switchHost: true, rejectLate: true } + ])( + 'ignores late results after switching ($switchHost host, $rejectLate rejection)', + async ({ switchHost, rejectLate }) => { + const originalClient = new FakeSession('connected') + const currentClient = switchHost ? new FakeSession('connected') : originalClient + let settleLate: (() => void) | undefined + originalClient.sendRequest.mockImplementationOnce( + () => + new Promise((resolve, reject) => { + settleLate = () => + rejectLate + ? reject(new Error('old host disconnected')) + : resolve({ id: 'late', ok: true, result: ['claude'] }) + }) + ) + currentClient.sendRequest.mockResolvedValue({ id: 'current', ok: true, result: ['codex'] }) + let detected: Set | null = null + function Harness({ client, repoId }: { client: RpcClient; repoId: string }) { + detected = useNewWorkspaceExecutionTarget({ + client, + repoId, + connectionId: null, + visible: true + }).detectedAgentIds + return null + } + let renderer: ReactTestRenderer | undefined + await act(async () => { + renderer = create(createElement(Harness, { client: originalClient, repoId: 'repo-1' })) + mounted.push(renderer) + }) + expect(detected).toBeNull() + await act(async () => { + renderer?.update( + createElement(Harness, { + client: currentClient, + repoId: switchHost ? 'repo-1' : 'repo-2' + }) + ) + }) + expect(detected).toEqual(new Set(['codex'])) + await act(async () => settleLate?.()) + expect(detected).toEqual(new Set(['codex'])) + } + ) + + it('keeps the SSH request and connection gate', async () => { + const client = new FakeSession('connected') + client.sendRequest.mockImplementation(async (method: string) => ({ + id: 'reply', + ok: true, + result: + method === 'ssh.getState' + ? { state: { targetId: 'ssh-1', status: 'connected', error: null, reconnectAttempt: 0 } } + : ['codex'] + })) + function Harness() { + useNewWorkspaceExecutionTarget({ + client, + repoId: 'remote-repo', + connectionId: 'ssh-1', + visible: true + }) + return null + } + await act(async () => { + mounted.push(create(createElement(Harness))) + }) + expect(client.sendRequest).toHaveBeenCalledWith('preflight.detectRemoteAgents', { + connectionId: 'ssh-1' + }) + expect( + client.sendRequest.mock.calls.some(([method]) => method === 'preflight.detectAgents') + ).toBe(false) + }) +}) diff --git a/mobile/src/components/use-new-workspace-execution-target.ts b/mobile/src/components/use-new-workspace-execution-target.ts index ce7a7a3f307..f209b4906ae 100644 --- a/mobile/src/components/use-new-workspace-execution-target.ts +++ b/mobile/src/components/use-new-workspace-execution-target.ts @@ -13,7 +13,9 @@ import { } from '../tasks/workspace-ssh-gate' type DetectedAgentIdsState = { + client: RpcClient connectionId: string | null + repoId: string | null ids: Set } @@ -29,12 +31,13 @@ export function useNewWorkspaceExecutionTarget(args: { client: RpcClient | null connectionId: string | null visible: boolean + repoId?: string | null }): { sshGate: WorkspaceSshGate detectedAgentIds: Set | null connect: () => Promise } { - const { client, connectionId, visible } = args + const { client, connectionId, visible, repoId = null } = args const [sshState, setSshState] = useState(null) const [connectingTargetId, setConnectingTargetId] = useState(null) const [detectedAgentIdsState, setDetectedAgentIdsState] = useState( @@ -46,7 +49,9 @@ export function useNewWorkspaceExecutionTarget(args: { connecting: connectingTargetId === connectionId }) const detectedAgentIds = - detectedAgentIdsState?.connectionId === connectionId && + detectedAgentIdsState?.client === client && + detectedAgentIdsState.connectionId === connectionId && + detectedAgentIdsState.repoId === repoId && (connectionId === null || sshGate.status === 'connected') ? detectedAgentIdsState.ids : null @@ -92,23 +97,29 @@ export function useNewWorkspaceExecutionTarget(args: { ? remoteAgentDetectionRead.interpret( await remoteAgentDetectionRead.request(client, { connectionId }) ) - : localAgentDetectionRead.interpret(await localAgentDetectionRead.request(client)) + : localAgentDetectionRead.interpret( + await (repoId + ? localAgentDetectionRead.request(client, { repoId }) + : localAgentDetectionRead.request(client)) + ) if (!stale) { setDetectedAgentIdsState({ + client, connectionId, + repoId, ids: detected.accepted ? new Set(detected.value) : new Set() }) } } catch { if (!stale) { - setDetectedAgentIdsState({ connectionId, ids: new Set() }) + setDetectedAgentIdsState({ client, connectionId, repoId, ids: new Set() }) } } })() return () => { stale = true } - }, [client, connectionId, sshGate.status, visible]) + }, [client, connectionId, repoId, sshGate.status, visible]) async function connect(): Promise { if (!client || !connectionId) { diff --git a/mobile/src/tasks/use-mobile-tasks-workspace-create-server.test.tsx b/mobile/src/tasks/use-mobile-tasks-workspace-create-server.test.tsx index 15ac77608d6..5ac43fb66ab 100644 --- a/mobile/src/tasks/use-mobile-tasks-workspace-create-server.test.tsx +++ b/mobile/src/tasks/use-mobile-tasks-workspace-create-server.test.tsx @@ -25,7 +25,7 @@ const { useMobileTasksWorkspaceSshState } = await import('./use-mobile-tasks-wor const { useMobileTasksWorkspaceCreateActions } = await import('./use-mobile-tasks-workspace-create-actions') -type Sent = { method: string; executionHost?: string } +type Sent = { method: string; params?: unknown; executionHost?: string } const sentBy = new WeakMap() /** Records where each call ran, then answers it with `answer`. */ @@ -38,10 +38,10 @@ function recording( client.sendRequest.mockImplementation( async ( method: string, - _params?: unknown, + params?: unknown, options?: { envelope?: { executionHost?: string } } ) => { - sent.push({ method, executionHost: options?.envelope?.executionHost }) + sent.push({ method, params, executionHost: options?.envelope?.executionHost }) return answer(method, options?.envelope?.executionHost) } ) @@ -165,6 +165,9 @@ describe('creating a workspace from a task on a server repo', () => { const { client, pushed } = await createFrom(SERVER_REPO) expect(ranOn(client, 'worktree.create')).toEqual(['runtime:vm']) expect(ranOn(client, 'preflight.detectAgents')).toEqual(['runtime:vm']) + expect( + sentBy.get(client)?.find((sent) => sent.method === 'preflight.detectAgents')?.params + ).toEqual({ repoId: SERVER_REPO.id }) expect(pushed).toHaveLength(1) expect(new URL(pushed[0]!, 'orca://x').searchParams.get('executionHost')).toBe('runtime:vm') }) @@ -173,6 +176,9 @@ describe('creating a workspace from a task on a server repo', () => { const { client, pushed } = await createFrom(DESKTOP_REPO) expect(ranOn(client, 'worktree.create')).toEqual(['desktop']) expect(ranOn(client, 'preflight.detectAgents')).toEqual(['desktop']) + expect( + sentBy.get(client)?.find((sent) => sent.method === 'preflight.detectAgents')?.params + ).toEqual({ repoId: DESKTOP_REPO.id }) expect(new URL(pushed[0]!, 'orca://x').searchParams.get('executionHost')).toBeNull() }) }) diff --git a/mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx b/mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx index 0b55aa995c0..b1d36289a81 100644 --- a/mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx +++ b/mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx @@ -123,7 +123,12 @@ export function useMobileTasksWorkspaceSshState(model: WorkspaceSparseActionsMod connectionId: workspaceCreateTargetRepo.connectionId }) } - : { operation: localAgentDetectionRead, reply: localAgentDetectionRead.request(repoClient) } + : { + operation: localAgentDetectionRead, + reply: localAgentDetectionRead.request(repoClient, { + repoId: workspaceCreateTargetRepo.id + }) + } void detection.reply .then((reply) => { if (stale) { diff --git a/src/main/runtime/agent-detection-host-resolution.test.ts b/src/main/runtime/agent-detection-host-resolution.test.ts index 2148c7ef7e2..43e6630abde 100644 --- a/src/main/runtime/agent-detection-host-resolution.test.ts +++ b/src/main/runtime/agent-detection-host-resolution.test.ts @@ -18,15 +18,16 @@ vi.mock('electron', () => electronMocks) import { OrcaRuntimeService } from './orca-runtime' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' import type { Repo } from '../../shared/repo-types' +import type { Project } from '../../shared/project-types' import type { ResolvedWorktree } from './runtime-worktree-path-identity' const originalPlatform = process.platform -function makeRuntime(repos: Repo[]) { +function makeRuntime(repos: Repo[], projects: Project[] = []) { const store = { getRepo: (id: string) => repos.find((repo) => repo.id === id), getRepos: () => repos, - getProjects: () => [], + getProjects: () => projects, getSettings: () => ({ localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } }) } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: detection resolution reads only the repo, project and settings members supplied here. @@ -79,6 +80,165 @@ describe('OrcaRuntimeService.resolveAgentDetectionHost', () => { ) }) + it('resolves the selected repo before a workspace exists (#20060)', async () => { + const wslRepo: Repo = { ...local, path: '\\\\wsl.localhost\\Debian\\home\\neil\\repo' } + const { runtime, selector } = makeRuntime([wslRepo]) + expect(await runtime.resolveAgentDetectionHost(undefined, local.id)).toEqual({ + kind: 'local', + context: { + projectRuntime: { + status: 'resolved', + runtime: expect.objectContaining({ kind: 'wsl', distro: 'Debian' }) + } + } + }) + expect(selector).not.toHaveBeenCalled() + }) + + it('refuses a repository target without a store while retaining the untargeted default', async () => { + const runtime = new OrcaRuntimeService(null) + await expect(runtime.resolveAgentDetectionHost(undefined, local.id)).rejects.toThrow( + 'repo_not_found' + ) + expect(await runtime.resolveAgentDetectionHost(undefined)).toEqual({ kind: 'local' }) + expect( + await runtime.resolveAgentDetectionHost(FLOATING_TERMINAL_WORKTREE_ID, local.id) + ).toEqual({ + kind: 'local' + }) + }) + + it.each(['removed', 'path', 'owner', 'connection', 'duplicate'] as const)( + 'refuses a selected repository %s change across the selector await', + async (change) => { + const selected = { ...local, path: '\\\\wsl$\\Old\\home\\repo' } + const repos: Repo[] = [selected] + const { runtime } = makeRuntime(repos) + const pending = runtime.resolveAgentDetectionHost(undefined, local.id) + repos.splice( + 0, + 1, + ...(change === 'removed' + ? [] + : change === 'duplicate' + ? [selected, { ...selected }] + : [ + { + ...selected, + ...(change === 'path' ? { path: '\\\\wsl$\\Current\\home\\repo' } : {}), + ...(change === 'owner' ? { executionHostId: 'ssh:build-host' as const } : {}), + ...(change === 'connection' ? { connectionId: 'build-host' } : {}) + } + ]) + ) + await expect(pending).rejects.toThrow('repo_target_changed') + } + ) + + it('keeps repo detection on its configured runtime for a Windows path', async () => { + const { runtime } = makeRuntime([local]) + expect(await runtime.resolveAgentDetectionHost(undefined, local.id)).toMatchObject({ + kind: 'local', + context: { projectRuntime: { runtime: { kind: 'wsl', distro: 'Ubuntu' } } } + }) + }) + + it.each(['darwin', 'linux', 'win32'])( + 'matches existing workspace runtime resolution on %s', + async (platform) => { + Object.defineProperty(process, 'platform', { value: platform }) + const selected = { ...local, path: platform === 'win32' ? local.path : '/src/r1' } + const { runtime, selector } = makeRuntime([selected]) + selector.mockResolvedValue( + worktree({ repoId: selected.id, path: selected.path, hostId: 'local' }) + ) + const detected = await runtime.resolveAgentDetectionHost(undefined, selected.id) + expect(detected).toEqual( + await runtime.resolveAgentDetectionHost(`${selected.id}::${selected.path}`) + ) + expect(detected).toMatchObject({ + kind: 'local', + context: { + projectRuntime: { runtime: { kind: platform === 'win32' ? 'wsl' : 'local-host' } } + } + }) + } + ) + + it('keeps project overrides above a selected folder repo WSL path', async () => { + const folderRepo: Repo = { + ...local, + kind: 'folder', + path: '\\\\wsl$\\Debian\\home\\neil\\notes' + } + const project: Project = { + id: 'project-1', + displayName: 'Notes', + badgeColor: 'blue', + sourceRepoIds: [local.id], + localWindowsRuntimePreference: { kind: 'windows-host' }, + createdAt: 1, + updatedAt: 1 + } + const { runtime } = makeRuntime([folderRepo], [project]) + expect(await runtime.resolveAgentDetectionHost(undefined, local.id)).toMatchObject({ + kind: 'local', + context: { projectRuntime: { runtime: { kind: 'windows-host', reason: 'project-override' } } } + }) + }) + + it.each([{ connectionId: 'build-host' }, { executionHostId: 'ssh:build-host' }] as const)( + 'preserves selected repo SSH ownership: %j', + async (ownership) => { + const { runtime } = makeRuntime([ + { ...local, executionHostId: undefined, ...ownership, path: '/srv/r1' } + ]) + expect(await runtime.resolveAgentDetectionHost(undefined, local.id)).toEqual({ + kind: 'ssh', + connectionId: 'build-host' + }) + } + ) + + it('keeps an explicit workspace authoritative when a repo target is also sent', async () => { + const { runtime, selector } = makeRuntime([local]) + selector.mockResolvedValue(worktree({ repoId: local.id, path: local.path, hostId: 'local' })) + const workspaceId = `${local.id}::${local.path}` + expect(await runtime.resolveAgentDetectionHost(workspaceId, 'missing')).toEqual( + await runtime.resolveAgentDetectionHost(workspaceId) + ) + expect( + await runtime.resolveAgentDetectionHost(FLOATING_TERMINAL_WORKTREE_ID, 'missing') + ).toEqual({ kind: 'local' }) + }) + + it('refuses an unknown or ambiguous repo instead of probing the host default', async () => { + const { runtime } = makeRuntime([local, { ...local, executionHostId: 'runtime:other' }]) + await expect(runtime.resolveAgentDetectionHost(undefined, local.id)).rejects.toThrow( + 'selector_ambiguous' + ) + await expect(runtime.resolveAgentDetectionHost(undefined, 'missing')).rejects.toThrow( + 'repo_not_found' + ) + }) + + it.each(['runtime:%broken', 'ssh:%broken'] as const)( + 'refuses a selected repository with malformed owner %s', + async (executionHostId) => { + const { runtime } = makeRuntime([{ ...local, executionHostId }]) + await expect(runtime.resolveAgentDetectionHost(undefined, local.id)).rejects.toThrow( + 'worktree_execution_host_unresolved' + ) + } + ) + + it('refuses a repo owned by another runtime', async () => { + const { runtime } = makeRuntime([{ ...local, executionHostId: 'runtime:other' }]) + await expect(runtime.resolveAgentDetectionHost(undefined, local.id)).rejects.toThrow( + 'workspace_on_other_runtime' + ) + }) + it("routes by the host's own worktree record", async () => { const ssh: Repo = { ...local, path: '/srv/r1', executionHostId: 'ssh:build-host' } const { runtime, selector } = makeRuntime([ssh, local]) diff --git a/src/main/runtime/rpc/methods/preflight.test.ts b/src/main/runtime/rpc/methods/preflight.test.ts index f7f65ecf243..a18e0374f56 100644 --- a/src/main/runtime/rpc/methods/preflight.test.ts +++ b/src/main/runtime/rpc/methods/preflight.test.ts @@ -68,8 +68,8 @@ describe('preflight RPC methods', () => { const refreshed = await dispatcher.dispatch(makeRequest('preflight.refreshAgents', null)) // An older client sends no workspace and keeps the host-default probe. - expect(resolveAgentDetectionHost).toHaveBeenNthCalledWith(1, undefined) - expect(resolveAgentDetectionHost).toHaveBeenNthCalledWith(2, undefined) + expect(resolveAgentDetectionHost).toHaveBeenNthCalledWith(1, undefined, undefined) + expect(resolveAgentDetectionHost).toHaveBeenNthCalledWith(2, undefined, undefined) expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenCalledWith(undefined) expect(refreshShellPathAndDetectAgentsMock).toHaveBeenCalledWith(undefined) expect(detected).toMatchObject({ ok: true, result: ['codex'] }) @@ -118,6 +118,58 @@ describe('preflight RPC methods', () => { expect(ssh).toMatchObject({ ok: false, error: { message: SSH_HOST_NOT_SERVED_MESSAGE } }) }) + it('forwards a repo target before workspace creation and rejects malformed ids', async () => { + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValueOnce(['codex']) + const resolveAgentDetectionHost = vi.fn(() => ({ kind: 'local' }) as const) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher reads only these two runtime members. + const runtime = { + getRuntimeId: () => 'runtime', + resolveAgentDetectionHost + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: PREFLIGHT_METHODS }) + const response = await dispatcher.dispatch( + makeRequest('preflight.detectAgents', { + repoId: 'r1', + path: '/client-controlled', + wslDistro: 'client-controlled', + executionHostId: 'local' + }) + ) + expect(resolveAgentDetectionHost).toHaveBeenCalledWith(undefined, 'r1') + expect(response).toMatchObject({ ok: true, result: ['codex'] }) + resolveAgentDetectionHost.mockClear() + for (const repoId of ['', null, 1, { path: '/client-controlled' }]) { + const malformed = await dispatcher.dispatch(makeRequest('preflight.detectAgents', { repoId })) + expect(malformed).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + } + expect(resolveAgentDetectionHost).not.toHaveBeenCalled() + }) + + it.each(['preflight.detectAgents', 'preflight.refreshAgents'])( + '%s never probes locally after repository ownership refuses', + async (method) => { + detectInstalledAgentsWithShellPathHydrationMock.mockClear() + refreshShellPathAndDetectAgentsMock.mockClear() + const resolveAgentDetectionHost = vi + .fn() + .mockRejectedValue(new Error('workspace_on_other_runtime')) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher reads only these runtime members. + const runtime = { + getRuntimeId: () => 'runtime', + resolveAgentDetectionHost + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: PREFLIGHT_METHODS }) + const response = await dispatcher.dispatch(makeRequest(method, { repoId: 'foreign-repo' })) + expect(resolveAgentDetectionHost).toHaveBeenCalledWith(undefined, 'foreign-repo') + expect(response).toMatchObject({ + ok: false, + error: { message: 'workspace_on_other_runtime' } + }) + expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled() + expect(refreshShellPathAndDetectAgentsMock).not.toHaveBeenCalled() + } + ) + it('refuses agent detection on an SSH target this host does not serve', async () => { detectInstalledAgentsWithShellPathHydrationMock.mockClear() const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService diff --git a/src/main/runtime/rpc/methods/preflight.ts b/src/main/runtime/rpc/methods/preflight.ts index b6c241522c2..2e7c901e65b 100644 --- a/src/main/runtime/rpc/methods/preflight.ts +++ b/src/main/runtime/rpc/methods/preflight.ts @@ -27,7 +27,7 @@ export const PREFLIGHT_METHODS = [ params: PreflightAgentDetection, // Why the host resolves: only it knows the workspace's project runtime, including WSL. handler: async (params, { runtime }) => - detectAgentsOnHost(await runtime.resolveAgentDetectionHost(params.worktreeId)) + detectAgentsOnHost(await runtime.resolveAgentDetectionHost(params.worktreeId, params.repoId)) }), defineMethod({ name: 'preflight.detectRemoteAgents', @@ -47,6 +47,6 @@ export const PREFLIGHT_METHODS = [ permission: 'workspace', params: PreflightAgentDetection, handler: async (params, { runtime }) => - refreshAgentsOnHost(await runtime.resolveAgentDetectionHost(params.worktreeId)) + refreshAgentsOnHost(await runtime.resolveAgentDetectionHost(params.worktreeId, params.repoId)) }) ] diff --git a/src/main/runtime/runtime-agent-detection-host.ts b/src/main/runtime/runtime-agent-detection-host.ts new file mode 100644 index 00000000000..adeb1899335 --- /dev/null +++ b/src/main/runtime/runtime-agent-detection-host.ts @@ -0,0 +1,75 @@ +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' +import { getRepoRoutingConnectionId } from '../../shared/execution-host' +import type { Repo } from '../../shared/repo-types' +import { parseWorkspaceKey } from '../../shared/workspace-scope' +import { + HOST_DEFAULT_AGENT_DETECTION, + resolveWorkspaceAgentDetectionHost, + type AgentDetectionHost, + type WorkspaceAgentDetectionStore +} from '../preflight/workspace-agent-detection' +import type { ResolvedWorktree } from './runtime-worktree-path-identity' + +type AgentDetectionContext = { + store: WorkspaceAgentDetectionStore | null | undefined + resolveRepo: (selector: string) => Promise + resolveWorktree: (selector: string) => Promise +} + +/** Resolve from the host's records, including the repo before a workspace exists. */ +export async function resolveRuntimeAgentDetectionHost( + context: AgentDetectionContext, + worktreeId: string | null | undefined, + repoId?: string +): Promise { + const { store } = context + if (!store) { + if (!worktreeId && repoId) { + throw new Error('repo_not_found') + } + return HOST_DEFAULT_AGENT_DETECTION + } + if (!worktreeId && repoId) { + const repo = await context.resolveRepo(`id:${repoId}`) + const current = store.getRepos?.().filter((entry) => entry.id === repo.id) ?? [] + if ( + current.length !== 1 || + current[0].path !== repo.path || + current[0].executionHostId !== repo.executionHostId || + getRepoRoutingConnectionId(current[0]) !== getRepoRoutingConnectionId(repo) + ) { + throw new Error('repo_target_changed') + } + return resolveWorkspaceAgentDetectionHost(store, { + kind: 'worktree', + repoId: repo.id, + path: repo.path + }) + } + if (!worktreeId || worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + return HOST_DEFAULT_AGENT_DETECTION + } + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + return resolveWorkspaceAgentDetectionHost(store, { + kind: 'folder', + folderWorkspaceId: scope.folderWorkspaceId + }) + } + let worktree: ResolvedWorktree + try { + worktree = await context.resolveWorktree(`id:${worktreeId}`) + } catch (error) { + // Only unknown workspaces retain the legacy default; an ambiguous owner must refuse. + if (error instanceof Error && error.message === 'selector_not_found') { + return HOST_DEFAULT_AGENT_DETECTION + } + throw error + } + return resolveWorkspaceAgentDetectionHost(store, { + kind: 'worktree', + repoId: worktree.repoId, + path: worktree.path, + hostId: worktree.hostId + }) +} diff --git a/src/main/runtime/terminal/output/terminal-orchestration-authority.ts b/src/main/runtime/terminal/output/terminal-orchestration-authority.ts index bffb8537b19..4b7702900c4 100644 --- a/src/main/runtime/terminal/output/terminal-orchestration-authority.ts +++ b/src/main/runtime/terminal/output/terminal-orchestration-authority.ts @@ -14,14 +14,8 @@ import { RECENT_PTY_OUTPUT_LIMIT, RecentPtyOutputBuffer } from '../../recent-pty import { appendRecentPtyPathCandidates } from '../../terminal-output-path-candidates' import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' import { resolveLocalProjectRuntimeForWorktreeId } from '../../../local-project-runtime-resolution' -import { - HOST_DEFAULT_AGENT_DETECTION, - resolveWorkspaceAgentDetectionHost, - type AgentDetectionHost -} from '../../../preflight/workspace-agent-detection' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import { parseWorkspaceKey } from '../../../../shared/workspace-scope' -import type { ResolvedWorktree } from '../../runtime-worktree-path-identity' +import type { AgentDetectionHost } from '../../../preflight/workspace-agent-detection' +import { resolveRuntimeAgentDetectionHost } from '../../runtime-agent-detection-host' import type { RuntimePtyWorktreeRecord } from '../../runtime-terminal-state-records' import { localOrchestrationCliCommand, @@ -263,39 +257,19 @@ export abstract class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends O : undefined } - // Why the host's own worktree record: it carries the owning host and the real path, where the - // client's id alone could pick the wrong row for a repo id shared across hosts. async resolveAgentDetectionHost( - worktreeId: string | null | undefined + worktreeId: string | null | undefined, + repoId?: string ): Promise { - if (!this.store || !worktreeId || worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { - return HOST_DEFAULT_AGENT_DETECTION - } - const store = this.requireStore() - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - return resolveWorkspaceAgentDetectionHost(store, { - kind: 'folder', - folderWorkspaceId: scope.folderWorkspaceId - }) - } - let worktree: ResolvedWorktree - try { - worktree = await this.resolveWorktreeSelector(`id:${worktreeId}`) - } catch (error) { - // Only an unknown workspace keeps the answer older clients always got; an ambiguous one - // has no single host, and probing this one would list another machine's agents. - if (error instanceof Error && error.message === 'selector_not_found') { - return HOST_DEFAULT_AGENT_DETECTION - } - throw error - } - return resolveWorkspaceAgentDetectionHost(store, { - kind: 'worktree', - repoId: worktree.repoId, - path: worktree.path, - hostId: worktree.hostId - }) + return resolveRuntimeAgentDetectionHost( + { + store: this.store, + resolveRepo: (selector) => this.resolveRepoSelector(selector), + resolveWorktree: (selector) => this.resolveWorktreeSelector(selector) + }, + worktreeId, + repoId + ) } getOrchestrationFleetAgentStatusSnapshot() { diff --git a/src/shared/rpc-contract/preflight-params.ts b/src/shared/rpc-contract/preflight-params.ts index ba282ff4842..711c9de8c27 100644 --- a/src/shared/rpc-contract/preflight-params.ts +++ b/src/shared/rpc-contract/preflight-params.ts @@ -14,5 +14,6 @@ export const PreflightDetectRemoteWindowsTerminalCapabilities = z.object({ /** Why optional: a client that predates it sends nothing and keeps the host-default probe. */ export const PreflightAgentDetection = z.object({ - worktreeId: z.string().min(1).optional() + worktreeId: z.string().min(1).optional(), + repoId: z.string().min(1).optional() })