From 174ca427264ff9a511e3c72b260ccb6f2cef493d Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 31 Aug 2026 16:44:59 -0400 Subject: [PATCH] fix(workspaces): honor legacy display-name provenance --- .../src/tasks/worktree-create-retry.test.ts | 43 ++++++++++++++++++- mobile/src/tasks/worktree-create-retry.ts | 13 +++++- src/main/ipc/worktree-display-name.ts | 11 +++-- src/main/ipc/worktree-logic.test.ts | 30 +++++++++++++ src/main/ipc/worktree-remote.ts | 6 ++- ...ktrees-create-metadata-persistence.test.ts | 23 ++++++++++ .../create/folder-workspace-creation.ts | 3 +- src/main/provisioned-root-ssh-adoption.ts | 3 +- src/main/runtime/orca-runtime.ts | 6 ++- src/main/runtime/rpc/methods/worktree.test.ts | 27 ++++++++++++ 10 files changed, 152 insertions(+), 13 deletions(-) diff --git a/mobile/src/tasks/worktree-create-retry.test.ts b/mobile/src/tasks/worktree-create-retry.test.ts index b4463207ebe..beb9d463e32 100644 --- a/mobile/src/tasks/worktree-create-retry.test.ts +++ b/mobile/src/tasks/worktree-create-retry.test.ts @@ -55,7 +55,7 @@ async function flush(): Promise { // cutover). Records every call so tests can assert on the clientMutationId. function scriptedClient( outcomes: Array< - | { id: string } + | { id: string; displayName?: string } | { errorMessage: string } // takesMs models how long the ambiguity took to SURFACE — a clean close is // instant, a half-open socket waits out the liveness watchdog or the timeout. @@ -103,7 +103,12 @@ function scriptedClient( return { id: '1', ok: true, - result: { worktree: { id: outcome.id } }, + result: { + worktree: { + id: outcome.id, + ...(outcome.displayName !== undefined ? { displayName: outcome.displayName } : {}) + } + }, _meta: { runtimeId: 'r' } } } @@ -227,6 +232,40 @@ describe('createWorktreeWithNameRetry', () => { expect(attempts[1]!.params.name).toBe('topic-2') }) + it('uses the host-selected display name after a collision retry', async () => { + const attempts: Attempt[] = [] + const client = scriptedClient( + [{ errorMessage: 'already exists locally' }, { id: 'wt-host-name', displayName: 'topic-3' }], + attempts + ) + + await expect( + createWorktreeWithNameRetry({ + client, + baseName: 'topic', + buildParams: (name) => ({ repo: 'id:r', name }), + worktreeCreateIdempotency: false + }) + ).resolves.toEqual({ worktreeId: 'wt-host-name', name: 'topic-3' }) + }) + + it('falls back to the client candidate when an older host omits displayName', async () => { + const attempts: Attempt[] = [] + const client = scriptedClient( + [{ errorMessage: 'already exists locally' }, { id: 'wt-legacy' }], + attempts + ) + + await expect( + createWorktreeWithNameRetry({ + client, + baseName: 'topic', + buildParams: (name) => ({ repo: 'id:r', name }), + worktreeCreateIdempotency: false + }) + ).resolves.toEqual({ worktreeId: 'wt-legacy', name: 'topic-2' }) + }) + it('advances generated retries without nesting suffixes', async () => { const attempts: Attempt[] = [] const client = scriptedClient( diff --git a/mobile/src/tasks/worktree-create-retry.ts b/mobile/src/tasks/worktree-create-retry.ts index 971dfa759cf..a3fa8ae2e1b 100644 --- a/mobile/src/tasks/worktree-create-retry.ts +++ b/mobile/src/tasks/worktree-create-retry.ts @@ -82,8 +82,17 @@ export async function createWorktreeWithNameRetry( : candidateParams const response = await sendWorktreeCreateResilient(client, params, worktreeCreateIdempotency) if (response.ok) { - const result = (response as RpcSuccess).result as { worktree: { id: string } } - return { worktreeId: result.worktree.id, name: candidateName } + const result = (response as RpcSuccess).result as { + worktree: { id: string; displayName?: string } + } + const authoritativeName = result.worktree.displayName + return { + worktreeId: result.worktree.id, + name: + typeof authoritativeName === 'string' && authoritativeName.trim() + ? authoritativeName + : candidateName + } } lastError = response.error.message if (!isRetryableWorktreeCreateConflict(lastError ?? '')) { diff --git a/src/main/ipc/worktree-display-name.ts b/src/main/ipc/worktree-display-name.ts index 866b21bd660..72902f07b27 100644 --- a/src/main/ipc/worktree-display-name.ts +++ b/src/main/ipc/worktree-display-name.ts @@ -44,12 +44,17 @@ export function resolveWorktreeCreateDisplayNameRequest( input: string | undefined, kind: DisplayNameKind, fallbackName: string, - cliCreated: boolean + cliCreated: boolean, + nameWasGenerated = false ): { value: string | undefined; kind: DisplayNameKind } { // The CLI name is always an explicit command argument; its marker wins over a // missing or malformed kind so a future client cannot make it auto-managed. - const effectiveKind = cliCreated ? 'user' : kind - const effectiveInput = input ?? (cliCreated ? fallbackName : undefined) + // Legacy clients omitted displayNameKind: an explicit displayName was the artifact-title + // contract, while a name-only request was user-entered unless marked as generated. + const effectiveKind = cliCreated + ? 'user' + : (kind ?? (input !== undefined || nameWasGenerated ? 'generated' : 'user')) + const effectiveInput = input ?? (effectiveKind === 'user' ? fallbackName : undefined) return { value: resolveWorktreeCreateDisplayName(effectiveInput, effectiveKind), kind: effectiveKind diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index 8610034204f..27e3f9f1a69 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -145,6 +145,36 @@ describe('worktree create display-name provenance', () => { }) }) + it('recovers a legacy name-only user create without CLI provenance', () => { + expect(resolveWorktreeCreateDisplayNameRequest(undefined, undefined, 'feature', false)).toEqual( + { + value: 'feature', + kind: 'user' + } + ) + expect( + resolveWorktreeCreateDisplayNameMeta('feature', 'feature', 'user', { + requestedName: 'feature', + sanitizedName: 'feature' + }) + ).toEqual({ displayName: 'feature', displayNameIsPinned: true }) + }) + + it('keeps a legacy generated name automatic when nameWasGenerated is set', () => { + expect( + resolveWorktreeCreateDisplayNameRequest(undefined, undefined, 'nautilus', false, true) + ).toEqual({ + value: undefined, + kind: 'generated' + }) + }) + + it('keeps a legacy artifact display name generated when its kind is absent', () => { + expect( + resolveWorktreeCreateDisplayNameRequest('Issue title', undefined, 'feature', false) + ).toEqual({ value: 'Issue title', kind: 'generated' }) + }) + it('treats a CLI name as intentional even if a caller supplies generated provenance', () => { expect( resolveWorktreeCreateDisplayNameRequest('Agent label', 'generated', 'feature', true) diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 7419476cbaa..6fa40e5b1d1 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -1550,7 +1550,8 @@ export async function createRemoteWorktree( args.displayName, args.displayNameKind, args.name, - args.cliProvenance?.kind === 'created-by-cli' + args.cliProvenance?.kind === 'created-by-cli', + args.nameWasGenerated === true ) const requestedDisplayName = displayNameRequest.value @@ -2030,7 +2031,8 @@ export async function createLocalWorktree( args.displayName, args.displayNameKind, args.name, - args.cliProvenance?.kind === 'created-by-cli' + args.cliProvenance?.kind === 'created-by-cli', + args.nameWasGenerated === true ) const requestedDisplayName = displayNameRequest.value // Why: explicit branches and non-username prefix modes never consume this; skipping the probe preserves the exact generated branch name. diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index a12a6a6776c..e79df5c5738 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -333,6 +333,29 @@ describe('registerWorktreeHandlers', () => { }) }) + it('pins a legacy name-only user create when the branch matches', async () => { + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/feature', + head: 'abc123', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'feature' + }) + + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/feature', + expect.objectContaining({ displayName: 'feature', displayNameIsPinned: true }) + ) + }) + it('persists linked issue and PR metadata during local create', async () => { listWorktreesMock.mockResolvedValue([ { diff --git a/src/main/ipc/worktrees/create/folder-workspace-creation.ts b/src/main/ipc/worktrees/create/folder-workspace-creation.ts index 338a9299546..c870ef734eb 100644 --- a/src/main/ipc/worktrees/create/folder-workspace-creation.ts +++ b/src/main/ipc/worktrees/create/folder-workspace-creation.ts @@ -19,7 +19,8 @@ export function createFolderWorkspace( args.displayName, args.displayNameKind, args.name, - args.cliProvenance?.kind === 'created-by-cli' + args.cliProvenance?.kind === 'created-by-cli', + args.nameWasGenerated === true ) const meta = store.setWorktreeMeta(worktreeId, { instanceId, diff --git a/src/main/provisioned-root-ssh-adoption.ts b/src/main/provisioned-root-ssh-adoption.ts index 1b349c6ac40..c2e2dfe2c4b 100644 --- a/src/main/provisioned-root-ssh-adoption.ts +++ b/src/main/provisioned-root-ssh-adoption.ts @@ -173,7 +173,8 @@ function buildProvisionedRootMeta( args.displayName, args.displayNameKind, args.name, - false + false, + args.nameWasGenerated === true ) const displayNameMeta = resolveWorktreeCreateDisplayNameMeta( displayNameRequest.value, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 9c44d007cd4..212f7e13924 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -27054,7 +27054,8 @@ export class OrcaRuntimeService { args.displayName, args.displayNameKind, args.name, - args.cliProvenance?.kind === 'created-by-cli' + args.cliProvenance?.kind === 'created-by-cli', + args.nameWasGenerated === true ) const resolvedFolderDisplayName = displayNameRequest.value const meta = this.store.setWorktreeMeta(worktreeId, { @@ -27259,7 +27260,8 @@ export class OrcaRuntimeService { args.displayName, args.displayNameKind, args.name, - args.cliProvenance?.kind === 'created-by-cli' + args.cliProvenance?.kind === 'created-by-cli', + args.nameWasGenerated === true ) const requestedDisplayName = displayNameRequest.value const sanitizedName = sanitizeWorktreeName(args.name) diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index 79171b927a3..3b62cbfd7cd 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -147,6 +147,33 @@ describe('worktree RPC methods', () => { }) }) + it('keeps the legacy CLI name-only create shape explicit at the host boundary', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, + showRepo: vi.fn().mockResolvedValue(repo), + createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-cli' } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + await dispatcher.dispatch( + makeRequest('worktree.create', { + repo: 'repo-1', + name: 'feature', + cliProvenanceRequest: {} + }) + ) + + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'feature', + displayName: undefined, + displayNameKind: undefined, + cliProvenance: expect.objectContaining({ kind: 'created-by-cli' }) + }) + ) + }) + it('mints automation provenance from a valid dispatch request on worktree creation', async () => { const dispatchToken = createAutomationDispatchToken('automation-1', 'run-1') const runtime = {