mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
fix(worktree): send name provenance and stop gating Create on the fetch
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.
This commit is contained in:
@@ -189,8 +189,8 @@ function NewWorktreeModalContent({
|
||||
const [selectedRepo, setSelectedRepo] = useState<Repo | null>(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 =
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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<boolean>
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
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) {
|
||||
|
||||
@@ -34,7 +34,11 @@ export function useRetiredWorktreeNames(
|
||||
| { retiredNamesByRepo?: Record<string, string[]> }
|
||||
| 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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -135,6 +135,7 @@ async function executeWorktreeCreation(
|
||||
preparedRequest.linkedGiteaPR,
|
||||
preparedRequest.compareBaseRef,
|
||||
{
|
||||
...(preparedRequest.nameWasGenerated ? { nameWasGenerated: true } : {}),
|
||||
...(preparedRequest.linkedWorkItem !== undefined
|
||||
? { linkedWorkItem: preparedRequest.linkedWorkItem }
|
||||
: {}),
|
||||
|
||||
@@ -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<CreateWorktreeResult>
|
||||
/** Register an in-flight background creation and make it the active surface. */
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -3945,6 +3945,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
const createArgs = {
|
||||
repoId,
|
||||
name: candidateName,
|
||||
...(options?.nameWasGenerated ? { nameWasGenerated: true } : {}),
|
||||
baseBranch,
|
||||
...(compareBaseRef ? { compareBaseRef } : {}),
|
||||
...(candidateBranchNameOverride
|
||||
|
||||
@@ -1772,10 +1772,20 @@ function createWorktreesApi(): NonNullable<Partial<PreloadApi>['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<string, string[]> }>(
|
||||
'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) => {
|
||||
|
||||
Reference in New Issue
Block a user