From ef06e49a64d37c7b706233b5ce62ad5402e70993 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:43:56 -0700 Subject: [PATCH] fix(worktree): send name provenance and stop gating Create on the fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop and mobile now mark a create as generated-name only when the user typed nothing and the composer fell back to the suggestion, so the host knows which names it may retire. Remove the retired-names loading gate from every create path. The host already skips retired candidates before doing any git work, so the client gate bought nothing while it could disable Create for the length of a full mobile reconnect ladder (the wait had no timeout) and blank the desktop button between queued creates. The suggestion still waits; the button never does. Also make the web client call worktree.listRetiredNames instead of hardcoding an empty list — the method is registered and mobile-allowlisted, so the comment claiming no wire call existed was wrong — and filter the mobile response to strings so a malformed row cannot throw during normalization. --- mobile/src/components/NewWorktreeModal.tsx | 8 ++-- .../src/tasks/blank-workspace-create.test.ts | 24 ++++++++++ mobile/src/tasks/blank-workspace-create.ts | 4 ++ .../worktree/use-retired-worktree-names.ts | 6 ++- src/renderer/src/hooks/useComposerState.ts | 22 +++++---- .../src/lib/pending-worktree-creation.ts | 2 + .../src/lib/worktree-creation-flow.ts | 1 + .../src/store/slices/worktree-helpers.ts | 2 + .../src/store/slices/worktrees.test.ts | 45 +++++++++++++++++++ src/renderer/src/store/slices/worktrees.ts | 1 + src/renderer/src/web/web-preload-api.ts | 18 ++++++-- 11 files changed, 115 insertions(+), 18 deletions(-) diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 644a6f27184..0da42f35f21 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -189,8 +189,8 @@ function NewWorktreeModalContent({ const [selectedRepo, setSelectedRepo] = useState(null) // Why: a deleted workspace's directory can still hold agent conversation state keyed by cwd, so // its name must never be suggested again. Fetched per selected repo while the sheet is open. - const { names: retiredWorktreeNames, loading: retiredWorktreeNamesLoading } = - useRetiredWorktreeNames(client, selectedRepo?.id) + // Create is never gated on it: the host skips retired candidates before doing any git work. + const { names: retiredWorktreeNames } = useRetiredWorktreeNames(client, selectedRepo?.id) const { drawerView, formSheetVisible, formSheetInteractive, transitionDrawer, openSourceDrawer } = useNewWorktreeDrawerNavigation(visible) const createInFlightRef = useRef(false) @@ -565,7 +565,7 @@ function NewWorktreeModalContent({ } async function handleCreate(options: CreateOptions = {}) { - if (!client || !selectedRepo || retiredWorktreeNamesLoading || createInFlightRef.current) { + if (!client || !selectedRepo || createInFlightRef.current) { return } createInFlightRef.current = true @@ -662,6 +662,7 @@ function NewWorktreeModalContent({ client, repoId: selectedRepo.id, baseName, + nameWasGenerated: !trimmedName, createdWithAgentId, comment: trimmedNote, setupDecision, @@ -685,7 +686,6 @@ function NewWorktreeModalContent({ const canCreate = selectedRepo != null && !creating && - !retiredWorktreeNamesLoading && !sshGate.requiresConnection && (!needsSetupChoice || setupDecisionChoice != null) const visibleAgentOptions = diff --git a/mobile/src/tasks/blank-workspace-create.test.ts b/mobile/src/tasks/blank-workspace-create.test.ts index b1b391c55a4..32e9e162ef4 100644 --- a/mobile/src/tasks/blank-workspace-create.test.ts +++ b/mobile/src/tasks/blank-workspace-create.test.ts @@ -34,6 +34,7 @@ describe('createBlankWorkspace', () => { createdWithAgentId: undefined, comment: undefined, setupDecision: 'inherit', + nameWasGenerated: false, supportsIdempotentCutoverRetry: true }) @@ -56,6 +57,25 @@ describe('createBlankWorkspace', () => { expect('comment' in params).toBe(false) }) + it('marks the name as generated only when the user typed nothing', async () => { + // Why: the host retires generated names permanently; a name the user chose must stay reusable. + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-3' } }), calls) + + await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + createdWithAgentId: undefined, + comment: undefined, + setupDecision: 'inherit', + nameWasGenerated: true, + supportsIdempotentCutoverRetry: true + }) + + expect(calls[0]?.params).toMatchObject({ nameWasGenerated: true }) + }) + it('sends startupAgent (not a pre-built command) so the host resolves launch args', async () => { // Why: regression — the modal used to send a bare startupCommand ('claude') // that skipped the host's default `--dangerously-skip-permissions`. @@ -69,6 +89,7 @@ describe('createBlankWorkspace', () => { createdWithAgentId: 'claude', comment: 'spike', setupDecision: 'run', + nameWasGenerated: false, supportsIdempotentCutoverRetry: true }) @@ -100,6 +121,7 @@ describe('createBlankWorkspace', () => { createdWithAgentId: undefined, comment: undefined, setupDecision: 'inherit', + nameWasGenerated: false, supportsIdempotentCutoverRetry: true }) @@ -125,6 +147,7 @@ describe('createBlankWorkspace', () => { createdWithAgentId: undefined, comment: undefined, setupDecision: 'inherit', + nameWasGenerated: false, supportsIdempotentCutoverRetry: true }) @@ -143,6 +166,7 @@ describe('createBlankWorkspace', () => { createdWithAgentId: undefined, comment: undefined, setupDecision: 'skip', + nameWasGenerated: false, supportsIdempotentCutoverRetry: true }) diff --git a/mobile/src/tasks/blank-workspace-create.ts b/mobile/src/tasks/blank-workspace-create.ts index 2cbed698276..14c095a777e 100644 --- a/mobile/src/tasks/blank-workspace-create.ts +++ b/mobile/src/tasks/blank-workspace-create.ts @@ -16,6 +16,9 @@ export async function createBlankWorkspace(args: { createdWithAgentId: TuiAgent | undefined comment: string | undefined setupDecision: WorkspaceCreateSetupDecision + /** True when `baseName` is a generated creature name rather than one the user typed; only then + * may the host retire it. */ + nameWasGenerated: boolean supportsIdempotentCutoverRetry: boolean | Promise }): Promise { return createWorktreeWithNameRetry({ @@ -27,6 +30,7 @@ export async function createBlankWorkspace(args: { repo: `id:${args.repoId}`, setupDecision: args.setupDecision, name, + ...(args.nameWasGenerated ? { nameWasGenerated: true } : {}), ...agentLaunchCreateFields(args.createdWithAgentId) } if (args.comment) { diff --git a/mobile/src/worktree/use-retired-worktree-names.ts b/mobile/src/worktree/use-retired-worktree-names.ts index c875f2ca0c1..a641b3f9d4b 100644 --- a/mobile/src/worktree/use-retired-worktree-names.ts +++ b/mobile/src/worktree/use-retired-worktree-names.ts @@ -34,7 +34,11 @@ export function useRetiredWorktreeNames( | { retiredNamesByRepo?: Record } | undefined const names = result?.retiredNamesByRepo?.[repoId] - setLoaded({ repoId, names: Array.isArray(names) ? names : [] }) + setLoaded({ + repoId, + // A host can answer with a malformed row; a non-string would throw when normalized. + names: Array.isArray(names) ? names.filter((name) => typeof name === 'string') : [] + }) }) .catch(() => { if (!cancelled) { diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index b4941cc26c6..dc43f47ae06 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -1601,8 +1601,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS .join('\0'), [repoId, worktreesByRepo] ) - const { names: retiredWorktreeNames, loading: retiredWorktreeNamesLoading } = - useRetiredWorktreeNames(repoId, retiredNamesRefreshKey) + // Create is never gated on this load: the host skips retired candidates before any git work, so + // a pending fetch can only cost a suggestion, never correctness. + const { names: retiredWorktreeNames } = useRetiredWorktreeNames(repoId, retiredNamesRefreshKey) const fallbackCreatureName = useMemo( () => getSuggestedCreatureName(worktreesByRepo, undefined, retiredWorktreeNames), [worktreesByRepo, retiredWorktreeNames] @@ -3580,7 +3581,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } if ( !workspaceSeedName || - retiredWorktreeNamesLoading || selectedRepoRequiresConnection || shouldWaitForSetupCheck || shouldWaitForIssueAutomationCheck || @@ -3648,6 +3648,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS if (!workspaceName) { return } + // Why: only a name Orca generated may be retired — the creature pool contains ordinary words + // ("orca", "runner", "molly") a user can type deliberately and expect to reuse. + const nameWasGenerated = !name.trim() && workspaceName === fallbackCreatureName const submitBaseBranch = smartGitHubResolution.kind === 'pr-start-point' ? smartGitHubResolution.baseBranch @@ -3898,6 +3901,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS { linkedWorkItem: toFolderWorkspaceLinkedTask(submitLinkedWorkItem), linkedTaskSourceContext: taskSourceContext, + nameWasGenerated, ...(!backendStartup && startupPlan?.draftPrompt ? { startupDraft: startupPlan.draftPrompt } : {}) @@ -4044,9 +4048,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS shouldWaitForIssueAutomationCheck, shouldWaitForSetupCheck, sourceIntentBlocksCreate, - retiredWorktreeNamesLoading, taskSourceContext, workspaceSeedName, + fallbackCreatureName, isProjectGroupTarget, submitFolderTarget ]) @@ -4095,7 +4099,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } if ( !workspaceNameSeed || - retiredWorktreeNamesLoading || sourceIntentBlocksCreate || selectedRepoRequiresConnection || (requiresExplicitSetupChoice && !setupDecision) || @@ -4184,6 +4187,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS if (!workspaceName) { return } + // Why: only a name Orca generated may be retired — see the full-composer submit path. + const nameWasGenerated = !name.trim() && workspaceName === fallbackCreatureName const smartSubmitBaseBranch = smartGitHubResolution.kind === 'pr-start-point' ? smartGitHubResolution.baseBranch @@ -4512,6 +4517,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS linkedTaskSourceContext: taskSourceContext, ...(workspaceRunContext ? { workspaceRunContext } : {}), name: workspaceName, + ...(nameWasGenerated ? { nameWasGenerated: true } : {}), ...(createDisplayName ? { displayName: createDisplayName } : {}), ...(selectedRepoIsGit && submitBaseBranch ? { baseBranch: submitBaseBranch } : {}), ...(selectedRepoIsGit && submitCompareBaseRef @@ -4632,7 +4638,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS settings?.openAgentTabsInChatByDefault, smartNameMode, sourceIntentBlocksCreate, - retiredWorktreeNamesLoading, disabledTuiAgents, setupDecision, sparseEnabled, @@ -4666,10 +4671,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS sparseError } const repoCreateDisabled = - retiredWorktreeNamesLoading || - (createGateMode === 'quick' + createGateMode === 'quick' ? getQuickComposerCreateDisabled(createGateInput) - : getFullComposerCreateDisabled(createGateInput)) + : getFullComposerCreateDisabled(createGateInput) const createDisabled = isProjectGroupTarget ? folderCreateDisabled : repoCreateDisabled const cardProps: ComposerCardProps = { eligibleRepos: isProjectGroupTarget ? folderSourceRepos : eligibleRepos, diff --git a/src/renderer/src/lib/pending-worktree-creation.ts b/src/renderer/src/lib/pending-worktree-creation.ts index d635e82555a..b1c45e9adc2 100644 --- a/src/renderer/src/lib/pending-worktree-creation.ts +++ b/src/renderer/src/lib/pending-worktree-creation.ts @@ -56,6 +56,8 @@ export type WorktreeCreationRequest = { * local-vs-runtime progress behavior even if the focused runtime changes. */ worktreeCreateProgressMode?: WorktreeCreationProgressMode name: string + /** True only when `name` came from the creature-name generator; gates host-side retirement. */ + nameWasGenerated?: boolean displayName?: string baseBranch?: string compareBaseRef?: string diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index 132f4825dc8..c822bed55ab 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -135,6 +135,7 @@ async function executeWorktreeCreation( preparedRequest.linkedGiteaPR, preparedRequest.compareBaseRef, { + ...(preparedRequest.nameWasGenerated ? { nameWasGenerated: true } : {}), ...(preparedRequest.linkedWorkItem !== undefined ? { linkedWorkItem: preparedRequest.linkedWorkItem } : {}), diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index c2980357ee8..995ca3bd8aa 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -209,6 +209,8 @@ export type WorktreeSlice = { linkedTaskSourceContext?: TaskSourceContext | null /** Lets the owning runtime launch and prefill a task agent without first creating an idle shell. */ startupDraft?: string + /** True only when `name` came from the creature-name generator; gates host-side retirement. */ + nameWasGenerated?: boolean } ) => Promise /** Register an in-flight background creation and make it the active surface. */ diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 6077204ba51..7349b9a35e7 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -4867,6 +4867,51 @@ describe('createWorktree base status merge', () => { expect(mockApi.worktrees.prefetchCreateBase).not.toHaveBeenCalled() }) + it('marks the create payload as a generated name only when the caller says so', async () => { + // Why: the host retires generated names permanently, and the creature pool contains ordinary + // words ("orca", "runner", "molly"). A name the user typed must stay reusable. + const store = createTestStore() + mockApi.worktrees.create.mockResolvedValue({ + worktree: makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) + }) + + await store.getState().createWorktree('repo1', 'nautilus', 'origin/main') + expect(mockApi.worktrees.create.mock.calls[0][0]).not.toHaveProperty('nameWasGenerated') + + mockApi.worktrees.create.mockClear() + await store + .getState() + .createWorktree( + 'repo1', + 'nautilus', + 'origin/main', + 'inherit', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { nameWasGenerated: true } + ) + expect(mockApi.worktrees.create.mock.calls[0][0]).toMatchObject({ nameWasGenerated: true }) + }) + it('passes linked work item and creation agent metadata through the create IPC payload', async () => { const store = createTestStore() const wt = makeWorktree({ diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 00e421cb7df..4fd22ab07f7 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -3945,6 +3945,7 @@ export const createWorktreeSlice: StateCreator const createArgs = { repoId, name: candidateName, + ...(options?.nameWasGenerated ? { nameWasGenerated: true } : {}), baseBranch, ...(compareBaseRef ? { compareBaseRef } : {}), ...(candidateBranchNameOverride diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index a67258618b9..8c10342a1f3 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1772,10 +1772,20 @@ function createWorktreesApi(): NonNullable['worktrees']> { withRuntimeWorktreeOwner(worktree, owned.hostId) ) }, - // Why: retirement is host-owned state with no wire call, and paired hosts update independently - // of clients. Returning empty degrades a web-client suggestion to the pre-existing behavior - // (a spent name can be offered) rather than inventing an RPC an older host cannot answer. - listRetiredNames: async () => [], + // Why the catch: a host predating this method answers `method_not_found`, and an empty list + // degrades the suggestion to the pre-existing behavior rather than blocking workspace create. + listRetiredNames: async ({ repoId }) => { + try { + const result = await callRuntimeResult<{ retiredNamesByRepo?: Record }>( + 'worktree.listRetiredNames', + { repo: repoId } + ) + const names = result.retiredNamesByRepo?.[repoId] + return Array.isArray(names) ? names.filter((name) => typeof name === 'string') : [] + } catch { + return [] + } + }, listDetected: async ({ repoId }) => callRuntimeDetectedWorktrees(repoId), listAll: () => listAllRuntimeWorktrees(), create: async (args) => {