From 6f8dc432883dac9c0c4c14960daef824941f9929 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 9 Oct 2026 19:00:46 -0700 Subject: [PATCH] fix(agent-hooks): route the first-work branch rename by the repo's execution host (#27080) * fix(agent-hooks): route the first-work branch rename by the repo's execution host * fix(agent-hooks): state runtime branch ownership without overclaiming server wiring --------- Co-authored-by: m4air --- .../first-work-branch-rename.test.ts | 50 ++++++++++++++++++- .../agent-hooks/first-work-branch-rename.ts | 16 ++++-- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 0adc02d7eaa..9b44e24bb9b 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -21,7 +21,7 @@ const { gitExecFileAsyncMock: vi.fn(), resolveLocalGitUsernameMock: vi.fn(async () => 'you'), getSshGitUsernameMock: vi.fn(async () => 'you'), - getSshGitProviderMock: vi.fn(() => undefined), + getSshGitProviderMock: vi.fn((_connectionId: string): unknown => undefined), generateBranchNameMock: vi.fn(), resolveTextGenerationParamsMock: vi.fn(), prepareLocalEnvMock: vi.fn(async () => ({ ok: true as const })), @@ -65,6 +65,14 @@ import { } from './first-work-branch-rename-test-harness' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +const REMOTE_REPO_BASE: Repo = { + id: REPO_ID, + path: '/repo', + displayName: 'repo', + badgeColor: '', + addedAt: 0 +} + function makeDeps(overrides: Partial = {}) { return makeBranchRenameDeps(vi.fn, overrides) } @@ -733,4 +741,44 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { expect(provider.renameCurrentBranch).toHaveBeenCalledWith('/repo/wt', 'you/fix-auth') expect(onRenamed).toHaveBeenCalledWith(REPO_ID) }) + + it('routes a row that names its SSH owner only as executionHostId to that target', async () => { + const provider = { + exec: vi.fn(gitResponder({ currentBranch: 'you/Nautilus', hasUpstream: false })), + renameCurrentBranch: vi.fn(async () => undefined), + executeCommitMessagePlan: vi.fn() + } + getSshGitProviderMock.mockReturnValue(provider) + const repo: Repo = { ...REMOTE_REPO_BASE, executionHostId: 'ssh:ssh-1' } + const { deps } = makeDeps({ getRepo: () => repo }) + + await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) + + expect(getSshGitProviderMock).toHaveBeenCalledWith('ssh-1') + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + expect(provider.renameCurrentBranch).toHaveBeenCalledWith('/repo/wt', 'you/fix-auth') + }) + + it("never dials a paired server's nested SSH target from this client", async () => { + const provider = { + exec: vi.fn(gitResponder({ currentBranch: 'you/Nautilus', hasUpstream: false })), + renameCurrentBranch: vi.fn(async () => undefined), + executeCommitMessagePlan: vi.fn() + } + getSshGitProviderMock.mockReturnValue(provider) + const repo: Repo = { + ...REMOTE_REPO_BASE, + connectionId: 'ssh-1', + executionHostId: 'runtime:env-1' + } + const { deps, onRenamed } = makeDeps({ getRepo: () => repo }) + + await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) + + expect(getSshGitProviderMock).not.toHaveBeenCalled() + expect(provider.exec).not.toHaveBeenCalled() + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + expect(generateBranchNameMock).not.toHaveBeenCalled() + expect(onRenamed).not.toHaveBeenCalled() + }) }) diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index 355a0a35dc9..ffc3eac7b22 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -14,7 +14,8 @@ import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-messa import { computeBranchName, getConfiguredBranchPrefix } from '../ipc/worktree-logic' import { gitExecFileAsync } from '../git/runner' import { getSshGitUsername, resolveLocalGitUsername } from '../git/git-username' -import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import { resolveGitRouteForHost } from '../providers/execution-host-provider-dispatch' +import { getRepoExecutionHostId } from '../../shared/execution-host' import { probeBranchUpstream, renameCurrentBranch, @@ -176,10 +177,15 @@ async function runAutoRename( } const worktreePath = parsed.worktreePath - const provider = repo.connectionId ? (getSshGitProvider(repo.connectionId) ?? null) : null - if (repo.connectionId && !provider) { + // Why: a runtime row's connectionId names the server's own SSH target; that host owns the branch, never this client. + const route = resolveGitRouteForHost(getRepoExecutionHostId(repo)) + if (route.kind === 'runtime') { + return stop(`branch is owned by ${route.hostId}`) + } + if (route.kind === 'ssh' && !route.provider) { return retry('ssh provider unavailable') } + const provider = route.kind === 'ssh' ? route.provider : null const exec: GitExec = provider ? (args) => provider.exec(args, worktreePath) : (args) => gitExecFileAsync(args, { cwd: worktreePath }) @@ -212,7 +218,9 @@ async function runAutoRename( } const settings = deps.getSettings() - const hostKey = getCommitMessageModelDiscoveryHostKey(repo.connectionId ?? null) + const hostKey = getCommitMessageModelDiscoveryHostKey( + route.kind === 'ssh' ? route.connectionId : null + ) const resolvedParams = resolveTextGenerationParams(settings, hostKey, 'branchName', repo) if (!resolvedParams.ok) { // Why: a generation-step failure (vs a benign skip) is user-actionable, so surface it on the card.