diff --git a/.changeset/task-pinned-worktrees.md b/.changeset/task-pinned-worktrees.md new file mode 100644 index 0000000000..9414f596b9 --- /dev/null +++ b/.changeset/task-pinned-worktrees.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Pin each task to one derivable worktree directory when worktree naming is "Task ID". +category: feature +dev: `worktreeNaming: "task-id"` now enables task-pinned worktrees — a task always lives in `/` (derive → validate → reuse-or-recreate in `worktree-pinning.ts`/`worktree-acquisition.ts`), and stale/foreign `task.worktree` metadata self-corrects (audit `worktree:pin-rederived`) without consuming session retries. Task pinning and `recycleWorktrees` are mutually exclusive: enabling both is rejected at the settings-write boundary (`assertWorktreeNamingRecycleExclusive`, enforced in `store.updateSettings` + dashboard `PUT /settings`), the Settings → Worktrees UI enforces the exclusivity bidirectionally (disabling whichever control would create the conflict), and pinning applies only when recycling is off. `"random"`/`"task-title"` naming and the recycle pool are unchanged; worktrunk-managed layouts bypass pinning. diff --git a/docs/architecture.md b/docs/architecture.md index e979185f0e..7725a78027 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2014,6 +2014,7 @@ The GitHub tracking state listener now attaches to every registered project stor - Each active task runs in isolated worktree under `.worktrees/*` - Executor creates branches like `fusion/{task-id}` (`executor.ts`) - `WorktreePool` can recycle idle worktrees when enabled +- **Task-pinned worktrees (`worktreeNaming: "task-id"`)**: under task-id naming a task is pinned to exactly one derivable directory `/` for its entire lifecycle. `worktree-pinning.ts` (`isTaskPinnedWorktreeNaming`, `pinnedWorktreePathForTask`) derives the path purely from the task id (unique forever via committed reservations, so no dedup-suffixing is possible). `acquireTaskWorktree` runs a **derive → validate → reuse-or-recreate** flow in pinned mode: it re-derives the path, corrects a disagreeing `task.worktree` cache (emitting `worktree:pin-rederived`), then reuses the directory warm when it is a registered, usable worktree checked out on the task's own branch, otherwise reclaims it in place (`removeWorktree` + recreate at the SAME path — never a sibling name). Because the path is derived, the FN-7996 stale/foreign-pointer shape self-corrects at next dispatch without consuming worktree-session retries. Task pinning and `recycleWorktrees` are **mutually exclusive**: enabling both is rejected at the settings-write boundary (`assertWorktreeNamingRecycleExclusive`, enforced in `store.updateSettings` and the dashboard `PUT /settings` route), so pinning only applies when recycling is off. As a runtime backstop for legacy on-disk configs that carry both, `acquireTaskWorktree` gates pinned mode on `!recycleWorktrees` (recycling wins, pinning off). Worktrunk-managed layouts own their own path derivation, so pinning is bypassed when that backend is active. Non-pinned `"random"`/`"task-title"` naming and the pool acquire/release path (including `merger.ts` release) are byte-inert. #### WorktreeBackend abstraction - Backend contract: `WorktreeBackend` (`packages/engine/src/worktree-backend.ts`, re-exported via `packages/engine/src/worktree-pool.ts`). diff --git a/docs/settings-reference.md b/docs/settings-reference.md index e84de58b2d..b8348368e9 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -524,14 +524,14 @@ When `pushAfterMerge` is enabled, a completed direct merge first runs `git pull | `worktreeCopyFiles` | `string[]` | `[]` | Repository-root-relative regular files to copy into each newly assigned non-resume task worktree. Configure from Settings → Worktrees with editable rows or Browse (useful for `.env`-style files). Fusion copies these files after fresh creation or pooled-worktree preparation and before `worktreeInitCommand`, secrets-env materialization, and task execution. Blank/duplicate entries are ignored; absolute paths, `..` traversal, missing files, directories, and unreadable/non-regular sources are skipped as non-fatal task-log/audit diagnostics without logging file contents. Resume/existing worktrees are not overwritten. | | `testCommand` | `string` | `undefined` | Merge-time test command (hard gate). When unset, Fusion auto-detects from lockfile. | | `buildCommand` | `string` | `undefined` | Merge-time build command (hard gate). | -| `recycleWorktrees` | `boolean` | `false` | Default: off (opt-in). Reuse worktrees from a pool for faster startup. | +| `recycleWorktrees` | `boolean` | `false` | Default: off (opt-in). Reuse worktrees from a pool for faster startup. **Mutually exclusive with `worktreeNaming: "task-id"`** (task-pinned worktrees) — enabling both is rejected by the settings API/store, because pinning each task to its own directory is incompatible with the cross-task pool. Recycling is fully functional under `"random"` and `"task-title"` naming. | | `showWorktreeGrouping` | `boolean` | `false` | Default: off. When off, WIP/processing columns render plain task cards without worktree group shells or worktree-name labels in both legacy and workflow-mode boards. When on, every WIP/processing column groups tasks by worktree and shows worktree names, including workflow-mode columns flagged as counting toward WIP. | | `openTasksInRightSidebar` | `boolean` | `false` | Default: off. When off, board task-card clicks keep the existing full-panel task detail that replaces the board. When on and the right dock is active on desktop/tablet, board task-card clicks open the task detail in the right sidebar so the board stays visible; mobile or hidden/inactive right-dock states automatically fall back to the full-panel behavior. Non-board task-open paths, including list split detail, floating pop-outs, graph/plugin opens, and deep `changes`/`retries`/`workflow` opens, keep their existing behavior; ordinary right-dock Tasks-list cards are governed by `openMobileTasksInPopup` first and otherwise use embedded dock detail. | | `openMobileTasksInPopup` | `boolean` | `false` | Default: off. When off, ordinary board task-card clicks keep the existing fallback behavior: the full-panel task detail, or the right dock when `openTasksInRightSidebar` is on and the dock is active; List row/card opens keep the desktop split-detail pane or the mobile/tablet docked detail; ordinary right-dock Tasks-list clicks open embedded dock detail with the normal back-to-list controls. When on, ordinary board task-card clicks, List row/card opens, and right-dock Tasks-list clicks open the task in the existing task popup/FloatingWindow surface on desktop, tablet, and mobile so the board, List view, or dock list remains visible; this popup route takes precedence over right-dock routing for those ordinary clicks. Desktop/tablet task popups restore the last saved popup size and position across task IDs and use the board/task-detail layer rather than the global utility layer, while their Activity dropdown stays above and attached during popup drag/resize; mobile task popups remain full-screen sheets. Deep `changes`/`retries`/`workflow` opens, context-menu/refine/detail links, graph/plugin opens, nested task-detail opens, and explicit pop-out actions keep their existing behavior. | | `taskPopupsBoardListOnly` | `boolean` | `false` | Project-scoped Appearance setting. Default: off, so open task popups remain visible over every main-content view. When on, each open task-detail popup is attached to the Board or List view where it was opened: switching to Command Center, Agents, Settings, another task view, or the other Board/List view hides it without closing or clearing popup state; returning to the originating Board/List view re-renders the same popup with its shared persisted size/position. | | `showCostBadgeOnCards` | `boolean` | `false` | Default: off. When enabled from Settings → Appearance, board cards with positive recorded token usage show a read-time derived model-cost badge beside the execution-time badge. Unpriced models display `—`, and tasks with no token usage render no badge shell. | | `executorAllowSiblingBranchRename` | `boolean` | `false` | Opt back into the legacy executor behavior that silently allocates sibling branches (`fusion/-2`, `-2-2`, …) when the canonical task branch is already checked out elsewhere. When disabled (default), branch conflicts fail loudly and leave the task in `todo` with `status: "failed"` so operators can resolve conflicting branches/worktrees with git tooling before retrying. See [Task Management → Branch conflict handling](./task-management.md#branch-conflict-handling). The dashboard Settings modal exposes the same toggle with warning copy because this legacy mode is discouraged. | -| `worktreeNaming` | `"random" \| "task-id" \| "task-title"` | `"random"` | Naming mode for new worktree directories. | +| `worktreeNaming` | `"random" \| "task-id" \| "task-title"` | `"random"` | Naming mode for new worktree directories. `"random"` (adjective-noun) and `"task-title"` (slugified title) affect only the generated name. `"task-id"` additionally enables **task-pinned worktrees**: each task lives in exactly one derivable directory `/` (e.g. `.worktrees/fn-7996`) for its whole lifecycle. Acquisition derives → validates → reuses-or-recreates at that same path (never suffixing a sibling name), so a task dispatched N times (kills, requeues, engine restarts, manual Retry) only ever touches that one directory and stale/foreign `task.worktree` metadata self-corrects (emitting `worktree:pin-rederived`) without consuming worktree-session retries. Task pinning is **mutually exclusive with `recycleWorktrees`** — enabling both is rejected by the settings API/store — and is bypassed when the worktrunk backend owns layout. | #### Worktree backend settings diff --git a/packages/core/src/__tests__/worktree-naming-recycle-exclusive.test.ts b/packages/core/src/__tests__/worktree-naming-recycle-exclusive.test.ts new file mode 100644 index 0000000000..237029abe0 --- /dev/null +++ b/packages/core/src/__tests__/worktree-naming-recycle-exclusive.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { + assertWorktreeNamingRecycleExclusive, + isRecycleWorktreeNamingConflict, + RECYCLE_WORKTREE_NAMING_CONFLICT_MESSAGE, +} from "../settings-validation.js"; + +describe("worktreeNaming/recycleWorktrees mutual exclusion", () => { + it("flags only recycle + task-id together as a conflict", () => { + expect(isRecycleWorktreeNamingConflict({ recycleWorktrees: true, worktreeNaming: "task-id" })).toBe(true); + expect(isRecycleWorktreeNamingConflict({ recycleWorktrees: true, worktreeNaming: "random" })).toBe(false); + expect(isRecycleWorktreeNamingConflict({ recycleWorktrees: true, worktreeNaming: "task-title" })).toBe(false); + expect(isRecycleWorktreeNamingConflict({ recycleWorktrees: false, worktreeNaming: "task-id" })).toBe(false); + expect(isRecycleWorktreeNamingConflict({ worktreeNaming: "task-id" })).toBe(false); + expect(isRecycleWorktreeNamingConflict({})).toBe(false); + expect(isRecycleWorktreeNamingConflict(undefined)).toBe(false); + }); + + it("assert throws with the canonical message only for the conflicting combo", () => { + expect(() => assertWorktreeNamingRecycleExclusive({ recycleWorktrees: true, worktreeNaming: "task-id" })) + .toThrow(RECYCLE_WORKTREE_NAMING_CONFLICT_MESSAGE); + expect(() => assertWorktreeNamingRecycleExclusive({ recycleWorktrees: true, worktreeNaming: "random" })).not.toThrow(); + expect(() => assertWorktreeNamingRecycleExclusive({ recycleWorktrees: false, worktreeNaming: "task-id" })).not.toThrow(); + expect(() => assertWorktreeNamingRecycleExclusive({})).not.toThrow(); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 33aa2981ae..481965684d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1166,6 +1166,9 @@ export { validateMcpServersSettings, validateMcpServersSettingsDetailed, validateUnavailableNodePolicy, + assertWorktreeNamingRecycleExclusive, + isRecycleWorktreeNamingConflict, + RECYCLE_WORKTREE_NAMING_CONFLICT_MESSAGE, } from "./settings-validation.js"; export type { McpValidationError, McpValidationResult } from "./settings-validation.js"; diff --git a/packages/core/src/settings-validation.ts b/packages/core/src/settings-validation.ts index a9e050b092..c6dd6a4f18 100644 --- a/packages/core/src/settings-validation.ts +++ b/packages/core/src/settings-validation.ts @@ -15,6 +15,34 @@ import type { } from "./types.js"; import { isLocale, isMcpSecretRef } from "./types.js"; +/* +FNXC:TaskPinnedWorktrees 2026-07-16-00:00: +`recycleWorktrees` and `worktreeNaming: "task-id"` are MUTUALLY EXCLUSIVE. "task-id" naming enables +task-pinned worktrees — each task owns exactly one derivable directory `/` for its +whole lifecycle — which is fundamentally incompatible with the cross-task recycle pool (a recycled dir +belongs to a different task and carries the wrong name). The operator rule is "task-pinned worktrees only +apply when recycling is off", so the combination is rejected at every settings-write boundary +(store.updateSettings backstop + dashboard PUT /settings for a clean 400) instead of being silently resolved. +*/ +export const RECYCLE_WORKTREE_NAMING_CONFLICT_MESSAGE = + 'recycleWorktrees and worktreeNaming:"task-id" are mutually exclusive: "task-id" naming pins each task to its own worktree directory, which is incompatible with the cross-task recycle pool. Disable recycleWorktrees to use "task-id" naming, or choose "random"/"task-title" naming to keep recycling.'; + +/** True when the resolved settings enable BOTH the recycle pool and task-pinned ("task-id") naming. */ +export function isRecycleWorktreeNamingConflict( + settings: { recycleWorktrees?: boolean; worktreeNaming?: string } | undefined, +): boolean { + return settings?.recycleWorktrees === true && settings?.worktreeNaming === "task-id"; +} + +/** Throws with {@link RECYCLE_WORKTREE_NAMING_CONFLICT_MESSAGE} when both settings are enabled together. */ +export function assertWorktreeNamingRecycleExclusive( + settings: { recycleWorktrees?: boolean; worktreeNaming?: string } | undefined, +): void { + if (isRecycleWorktreeNamingConflict(settings)) { + throw new Error(RECYCLE_WORKTREE_NAMING_CONFLICT_MESSAGE); + } +} + const UNAVAILABLE_NODE_POLICIES: readonly UnavailableNodePolicy[] = ["block", "fallback-local"] as const; const DIRECT_MERGE_COMMIT_STRATEGIES: readonly DirectMergeCommitStrategy[] = ["auto", "always-squash", "always-rebase"] as const; const GITHUB_AUTH_MODES: readonly GithubAuthMode[] = ["gh-cli", "token"] as const; diff --git a/packages/core/src/task-store/settings-ops.ts b/packages/core/src/task-store/settings-ops.ts index 2a94577df7..4308abda55 100644 --- a/packages/core/src/task-store/settings-ops.ts +++ b/packages/core/src/task-store/settings-ops.ts @@ -14,7 +14,7 @@ import type {BoardConfig, Settings, GlobalSettings} from "../types.js"; import {DEFAULT_SETTINGS, isGlobalOnlySettingsKey} from "../types.js"; import {MOVED_SETTINGS_KEYS, stripMovedSettingsKeys, patchContainsMovedKey} from "../moved-settings.js"; import "../builtin-traits.js"; -import {validateLocale} from "../settings-validation.js"; +import {validateLocale, assertWorktreeNamingRecycleExclusive} from "../settings-validation.js"; import {hasSyncPassphraseConfigured} from "../secrets-sync-passphrase.js"; import {ensureMemoryFileWithBackend} from "../project-memory.js"; import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js"; @@ -96,6 +96,9 @@ export async function updateSettingsImpl(store: TaskStore, patch: Partial); const updatedMerged: Settings = { ...DEFAULT_SETTINGS, ...globalSettings, ...updatedProjectSettings } as Settings; @@ -182,6 +185,9 @@ export async function updateSettingsImpl(store: TaskStore, patch: Partial/` for its whole lifecycle. Acquisition + * derives→validates→reuses-or-recreates at that same path (never suffixed), and `task.worktree` becomes a + * self-correcting cache. Task pinning and `recycleWorktrees` are MUTUALLY EXCLUSIVE — enabling both is + * rejected at the settings-write boundary (see `assertWorktreeNamingRecycleExclusive`), because pinning + * each task to its own directory is incompatible with the cross-task recycle pool. Pinning therefore only + * applies when `recycleWorktrees` is off; the runtime also degrades a legacy config that carries both back + * to recycling. Worktrunk-managed layouts own their own path derivation, so pinning is bypassed when that + * backend is on. */ worktreeNaming?: "random" | "task-id" | "task-title"; /** Project-level worktrunk integration overrides. * Merged with global `worktrunk` field-by-field so partial project values diff --git a/packages/dashboard/app/__tests__/settings-sections.test.tsx b/packages/dashboard/app/__tests__/settings-sections.test.tsx index bb7c335dd8..7404d71a45 100644 --- a/packages/dashboard/app/__tests__/settings-sections.test.tsx +++ b/packages/dashboard/app/__tests__/settings-sections.test.tsx @@ -340,6 +340,64 @@ describe("WorktreesSection", () => { expect(screen.getByLabelText("File to copy into new worktrees")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Browse file to copy into new worktrees" })).toBeInTheDocument(); }); + + /* + FNXC:TaskPinnedWorktrees 2026-07-16-00:00: recycleWorktrees and worktreeNaming:"task-id" are mutually + exclusive; the UI enforces this bidirectionally so the conflicting state is unreachable. + */ + function renderWorktrees(form: Partial) { + return render( + , + ); + } + + it("disables the recycle toggle when worktree naming is task-id (mutually exclusive)", () => { + renderWorktrees({ recycleWorktrees: false, worktreeNaming: "task-id" }); + const recycle = screen.getByLabelText(/Recycle worktrees/i) as HTMLInputElement; + expect(recycle.disabled).toBe(true); + expect(recycle.checked).toBe(false); + // The naming select stays enabled so the operator can switch away from task-id. + const naming = screen.getByLabelText(/Worktree Naming Style/i) as HTMLSelectElement; + expect(naming.disabled).toBe(false); + expect(naming.value).toBe("task-id"); + }); + + it("disables the naming select when recycling is on (mutually exclusive)", () => { + renderWorktrees({ recycleWorktrees: true, worktreeNaming: "random" }); + const naming = screen.getByLabelText(/Worktree Naming Style/i) as HTMLSelectElement; + expect(naming.disabled).toBe(true); + const recycle = screen.getByLabelText(/Recycle worktrees/i) as HTMLInputElement; + expect(recycle.disabled).toBe(false); + expect(recycle.checked).toBe(true); + }); + + it("leaves both controls editable when neither conflicting value is set", () => { + renderWorktrees({ recycleWorktrees: false, worktreeNaming: "random" }); + expect((screen.getByLabelText(/Recycle worktrees/i) as HTMLInputElement).disabled).toBe(false); + expect((screen.getByLabelText(/Worktree Naming Style/i) as HTMLSelectElement).disabled).toBe(false); + }); + + it("legacy conflict (both set): keeps the recycle toggle enabled+checked so it can be repaired", () => { + // Runtime treats this state as recycling (pinning off); the UI mirrors that and offers an escape hatch — + // turning recycling off re-enables the naming select. Both controls must never lock together. + renderWorktrees({ recycleWorktrees: true, worktreeNaming: "task-id" }); + const recycle = screen.getByLabelText(/Recycle worktrees/i) as HTMLInputElement; + expect(recycle.disabled).toBe(false); + expect(recycle.checked).toBe(true); + const naming = screen.getByLabelText(/Worktree Naming Style/i) as HTMLSelectElement; + expect(naming.disabled).toBe(true); + }); }); describe("GlobalModelsSection", () => { diff --git a/packages/dashboard/app/components/settings/sections/WorktreesSection.tsx b/packages/dashboard/app/components/settings/sections/WorktreesSection.tsx index 90fb35ee35..0f1a677710 100644 --- a/packages/dashboard/app/components/settings/sections/WorktreesSection.tsx +++ b/packages/dashboard/app/components/settings/sections/WorktreesSection.tsx @@ -65,12 +65,31 @@ export function WorktreesSection({ form, setForm, gitRemotes, worktrunkInstall, value={form.worktreeInitCommand ?? null} onChange={(v) => setForm((f) => ({ ...f, worktreeInitCommand: v ?? "" }))} /> + {/* + FNXC:TaskPinnedWorktrees 2026-07-16-00:00: + Recycling and Task-ID naming are MUTUALLY EXCLUSIVE (the settings API/store reject the combination): + "task-id" naming pins each task to its own worktree directory, which is incompatible with the cross-task + recycle pool. The exclusivity is enforced bidirectionally in the UI so a NEW conflict is unreachable — + this toggle is disabled while naming is "task-id" AND recycling is not already on, and the naming select + below is disabled while recycling is on. Together they prevent a save that the backend would 400. + + FNXC:TaskPinnedWorktrees 2026-07-16-12:30: + Legacy-conflict escape hatch: when a stored config already carries BOTH (recycle on + "task-id"), do NOT + lock both controls — that would strand the operator (unchanged save preserves a state the runtime treats + as recycling). The runtime backstop makes recycling win in that conflict, so mirror it here: keep this + toggle ENABLED and CHECKED (its true value, un-coerced) so the operator can turn recycling off, which + then re-enables the naming select below. The toggle only greys out for the forward-prevention case + ("task-id" naming while recycling is already off). + */} setForm((f) => ({ ...f, recycleWorktrees: v === true }))} @@ -142,14 +161,17 @@ export function WorktreesSection({ form, setForm, gitRemotes, worktrunkInstall, {/* FNXC:Worktrees 2026-07-15-17:35: Recycling and naming are coupled: pooled worktrees keep the names they were created with, so the naming select is disabled while `recycleWorktrees` is on and its help swaps to explain why rather than letting the operator pick a style that would be silently ignored. + + FNXC:TaskPinnedWorktrees 2026-07-16-00:00: + "Task ID" additionally enables task-pinned worktrees (each task owns one derivable directory for its whole lifecycle), which is why it is mutually exclusive with recycling \u2014 the recycle toggle above is disabled while this is "task-id". The select stays disabled while recycling is on so the operator cannot cross into the conflicting state from this side either. */} { expect(scopedStore.updateSettings).toHaveBeenCalledTimes(1); }); + it("rejects recycleWorktrees + worktreeNaming:task-id together (mutually exclusive) with 400", async () => { + const { app, scopedStore } = createApp(); + + const res = await patchSettings(app, { recycleWorktrees: true, worktreeNaming: "task-id" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("mutually exclusive"); + expect(scopedStore.updateSettings).not.toHaveBeenCalled(); + }); + + it("rejects worktreeNaming:task-id when recycleWorktrees is already enabled in stored settings", async () => { + const { app, scopedStore } = createApp(); + // Current stored settings already have recycling on; a partial patch that only flips naming must still be rejected. + scopedStore.getSettings.mockResolvedValueOnce({ worktrunk: { enabled: false }, recycleWorktrees: true } as any); + + const res = await patchSettings(app, { worktreeNaming: "task-id" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("mutually exclusive"); + expect(scopedStore.updateSettings).not.toHaveBeenCalled(); + }); + + it("accepts worktreeNaming:task-id when recycling is off", async () => { + const { app, scopedStore } = createApp(); + + const res = await patchSettings(app, { worktreeNaming: "task-id" }); + + expect(res.status).toBe(200); + expect(scopedStore.updateSettings).toHaveBeenCalledWith({ worktreeNaming: "task-id" }); + }); + + it("maps the store backstop 'mutually exclusive' error to 400 (not 500)", async () => { + const { app, scopedStore } = createApp(); + // A patch that clears the pre-check's view (e.g. null-clear) but resolves to a conflict inside the store, + // where the mutual-exclusion backstop throws. The route must classify it as a 400 client error. + scopedStore.updateSettings.mockRejectedValueOnce( + new Error('recycleWorktrees and worktreeNaming:"task-id" are mutually exclusive: ...'), + ); + + const res = await patchSettings(app, { autoMerge: true }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("mutually exclusive"); + }); + it("passes enabled plugin skills to memory dream processing", async () => { const pluginRunner = { getPluginSkills: vi.fn(() => [ diff --git a/packages/dashboard/src/routes/register-settings-memory-routes.ts b/packages/dashboard/src/routes/register-settings-memory-routes.ts index 11866ad76f..55501cd292 100644 --- a/packages/dashboard/src/routes/register-settings-memory-routes.ts +++ b/packages/dashboard/src/routes/register-settings-memory-routes.ts @@ -28,6 +28,8 @@ import { resolveTitleSummarizerSettingsModel, resolveWorktrunkSettings, requiresWorktrunkInstallVerification, + isRecycleWorktreeNamingConflict, + RECYCLE_WORKTREE_NAMING_CONFLICT_MESSAGE, scheduleQmdProjectMemoryRefresh, searchProjectMemory, syncBackupRoutine, @@ -699,6 +701,25 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin } } + // FNXC:TaskPinnedWorktrees 2026-07-16-00:00: recycleWorktrees and worktreeNaming:"task-id" are mutually + // exclusive. Validate the RESOLVED next state (current merged with this partial patch) so a clean 400 is + // returned before the store backstop throws. Only fetch current settings when one of the two fields moves. + if ( + Object.prototype.hasOwnProperty.call(clientSettings, "recycleWorktrees") + || Object.prototype.hasOwnProperty.call(clientSettings, "worktreeNaming") + ) { + const currentForWorktreeCheck = await scopedStore.getSettings(); + const nextRecycle = Object.prototype.hasOwnProperty.call(clientSettings, "recycleWorktrees") + ? clientSettings.recycleWorktrees + : currentForWorktreeCheck.recycleWorktrees; + const nextNaming = Object.prototype.hasOwnProperty.call(clientSettings, "worktreeNaming") + ? clientSettings.worktreeNaming + : currentForWorktreeCheck.worktreeNaming; + if (isRecycleWorktreeNamingConflict({ recycleWorktrees: nextRecycle, worktreeNaming: nextNaming })) { + throw badRequest(RECYCLE_WORKTREE_NAMING_CONFLICT_MESSAGE); + } + } + if (clientSettings.worktrunk?.enabled === true) { const currentSettings = await scopedStore.getSettings(); const nextWorktrunkSettings = resolveWorktrunkSettings( @@ -744,10 +765,17 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin if (err instanceof ApiError) { throw err; } - const status = typeof (err instanceof Error ? err.message : String(err)) === "string" && ( - (err instanceof Error ? err.message : String(err)).includes("modelPresets") || (err instanceof Error ? err.message : String(err)).includes("must include both provider and modelId") + const errorMessage = err instanceof Error ? err.message : String(err); + // FNXC:TaskPinnedWorktrees 2026-07-16-12:30: the recycleWorktrees/worktreeNaming mutual-exclusion + // backstop lives in store.updateSettings, so it can fire for edge cases the route pre-check misses + // (e.g. a null-clear that resolves to a conflicting fallback). Classify it as a 400 client error here + // alongside the other validation messages so it never surfaces as a 500. + const status = ( + errorMessage.includes("modelPresets") + || errorMessage.includes("must include both provider and modelId") + || errorMessage.includes("mutually exclusive") ) ? 400 : 500; - throw new ApiError(status, err instanceof Error ? err.message : String(err)); + throw new ApiError(status, errorMessage); } }); diff --git a/packages/engine/src/__tests__/worktree-acquisition-pinned.test.ts b/packages/engine/src/__tests__/worktree-acquisition-pinned.test.ts new file mode 100644 index 0000000000..dcff19b4b2 --- /dev/null +++ b/packages/engine/src/__tests__/worktree-acquisition-pinned.test.ts @@ -0,0 +1,270 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { join } from "node:path"; +import { acquireTaskWorktree } from "../worktree-acquisition.js"; + +/* +FNXC:TaskPinnedWorktrees 2026-07-16-12:30: +The pinned-mode branch is validated in isolation with mocked git/liveness seams so the tests stay fast and +deterministic (no real-git worktree creation). classifyTaskWorktree / branch lookup / fs existence are the +observable inputs to derive→validate→reuse-or-recreate; we drive each of them. +*/ +vi.mock("../worktree-pool.js", async () => { + const actual = await vi.importActual("../worktree-pool.js"); + return { + ...actual, + classifyTaskWorktree: vi.fn().mockResolvedValue({ ok: true }), + isInsideWorktreesDir: vi.fn().mockReturnValue(true), + getRegisteredWorktreeBranches: vi.fn().mockResolvedValue([]), + canonicalizePath: (p: string) => p, + removeWorktree: vi.fn().mockResolvedValue({ removed: true, classification: "removed" }), + }; +}); + +vi.mock("../branch-conflicts.js", async () => { + const actual = await vi.importActual("../branch-conflicts.js"); + return { + ...actual, + classifyBootstrapMisbinding: vi.fn().mockResolvedValue({ + isBootstrapMisbinding: false, + ownCommitCount: 0, + foreignCommitCount: 0, + nonAttributedCount: 0, + }), + }; +}); + +vi.mock("../worktree-db-hydrate.js", () => ({ + hydrateWorktreeDb: vi.fn().mockResolvedValue({ degraded: false, tasksCopied: 0, documentsCopied: 0, artifactsCopied: 0 }), +})); + +vi.mock("../worktree-desktop-artifacts.js", () => ({ + removeDesktopBuildArtifacts: vi.fn().mockResolvedValue({ removed: [], skipped: [], failures: [] }), +})); + +vi.mock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { ...actual, existsSync: vi.fn().mockReturnValue(false) }; +}); + +import { existsSync } from "node:fs"; +import { classifyTaskWorktree, getRegisteredWorktreeBranches, removeWorktree } from "../worktree-pool.js"; + +const ROOT = "/repo"; +const PINNED = join(ROOT, ".worktrees", "fn-7996"); + +function makeStore() { + return { + updateTask: vi.fn().mockResolvedValue(undefined), + logEntry: vi.fn().mockResolvedValue(undefined), + } as any; +} + +const baseTask = { + id: "FN-7996", + title: "Task", + description: "Desc", + branch: null, + worktree: null, +} as any; + +const pinnedSettings = { worktreeNaming: "task-id" } as any; + +describe("acquireTaskWorktree — task-pinned mode", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(existsSync).mockReturnValue(false); + vi.mocked(classifyTaskWorktree).mockResolvedValue({ ok: true } as any); + vi.mocked(getRegisteredWorktreeBranches).mockResolvedValue([]); + vi.mocked(removeWorktree).mockResolvedValue({ removed: true, classification: "removed" } as any); + }); + + it("creates fresh at the derived path when absent, never suffixed", async () => { + const createWorktree = vi.fn(async (branch: string, path: string) => ({ path, branch })); + const result = await acquireTaskWorktree({ + task: baseTask, + rootDir: ROOT, + store: makeStore(), + settings: pinnedSettings, + createWorktree, + }); + + expect(result.source).toBe("fresh"); + expect(result.worktreePath).toBe(PINNED); + expect(createWorktree).toHaveBeenCalledWith("fusion/fn-7996", PINNED, "FN-7996", "main", false); + }); + + it("acceptance #2: task B's pinned acquisition yields fn-, never task A's dir", async () => { + const createWorktree = vi.fn(async (branch: string, path: string) => ({ path, branch })); + const result = await acquireTaskWorktree({ + task: { ...baseTask, id: "FN-8069" }, + rootDir: ROOT, + store: makeStore(), + settings: pinnedSettings, + // A pool is attached with recycleWorktrees on — pinned mode must ignore it entirely. + pool: { acquire: vi.fn(() => join(ROOT, ".worktrees", "grand-ridge")), prepareForTask: vi.fn(), release: vi.fn() } as any, + settingsOverride: undefined, + createWorktree, + } as any); + + expect(result.worktreePath).toBe(join(ROOT, ".worktrees", "fn-8069")); + expect(createWorktree).toHaveBeenCalledWith("fusion/fn-8069", join(ROOT, ".worktrees", "fn-8069"), "FN-8069", "main", false); + }); + + it("runtime backstop: recycle ON disables pinning (mutually exclusive) so the pool is consulted", async () => { + // recycleWorktrees + worktreeNaming:"task-id" is rejected at the settings-write boundary; if a legacy + // on-disk config still carries both, the runtime degrades safely to recycling (pinning off), so the + // pool IS consulted — pinned mode never calls pool.acquire. + const acquire = vi.fn(() => null); // empty pool → falls through to fresh + const release = vi.fn(); + const createWorktree = vi.fn(async (branch: string, path: string) => ({ path, branch })); + + const result = await acquireTaskWorktree({ + task: baseTask, + rootDir: ROOT, + store: makeStore(), + settings: { worktreeNaming: "task-id", recycleWorktrees: true } as any, + pool: { acquire, prepareForTask: vi.fn(), release } as any, + createWorktree, + }); + + expect(acquire).toHaveBeenCalledWith("FN-7996"); + // Falls through to the normal fresh path (task-id naming still derives fn-7996 for the directory name). + expect(result.worktreePath).toBe(PINNED); + }); + + it("warm-reuses the pinned dir when it is usable and on the task branch", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(classifyTaskWorktree).mockResolvedValue({ ok: true } as any); + vi.mocked(getRegisteredWorktreeBranches).mockResolvedValue([{ branch: "fusion/fn-7996", worktreePath: PINNED }]); + const createWorktree = vi.fn(); + + const result = await acquireTaskWorktree({ + task: { ...baseTask, worktree: PINNED, branch: "fusion/fn-7996" }, + rootDir: ROOT, + store: makeStore(), + settings: pinnedSettings, + createWorktree, + }); + + expect(result.source).toBe("existing"); + expect(result.isResume).toBe(true); + expect(result.worktreePath).toBe(PINNED); + expect(createWorktree).not.toHaveBeenCalled(); + expect(removeWorktree).not.toHaveBeenCalled(); + }); + + it("adopts an orphaned pinned dir (task.worktree null) and persists worktree+branch metadata", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(classifyTaskWorktree).mockResolvedValue({ ok: true } as any); + vi.mocked(getRegisteredWorktreeBranches).mockResolvedValue([{ branch: "fusion/fn-7996", worktreePath: PINNED }]); + const store = makeStore(); + const createWorktree = vi.fn(); + + const result = await acquireTaskWorktree({ + task: { ...baseTask, worktree: null, branch: null }, + rootDir: ROOT, + store, + settings: pinnedSettings, + createWorktree, + }); + + expect(result.source).toBe("existing"); + expect(result.worktreePath).toBe(PINNED); + // The successful acquisition must leave the task assigned, not orphaned. + expect(store.updateTask).toHaveBeenCalledWith("FN-7996", { worktree: PINNED, branch: "fusion/fn-7996" }); + expect(createWorktree).not.toHaveBeenCalled(); + expect(removeWorktree).not.toHaveBeenCalled(); + }); + + it("fails safe (no destructive reclaim) when the branch probe is untrustworthy (empty enumeration)", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(classifyTaskWorktree).mockResolvedValue({ ok: true } as any); + // classifyTaskWorktree proved the path is a registered usable worktree, yet the branch enumeration is + // empty — a transient `git worktree list` failure. Must throw rather than reclaim a valid warm worktree. + vi.mocked(getRegisteredWorktreeBranches).mockResolvedValue([]); + const createWorktree = vi.fn(); + + await expect( + acquireTaskWorktree({ + task: { ...baseTask, worktree: PINNED, branch: "fusion/fn-7996" }, + rootDir: ROOT, + store: makeStore(), + settings: pinnedSettings, + createWorktree, + }), + ).rejects.toThrow(/cannot confirm branch/); + + expect(removeWorktree).not.toHaveBeenCalled(); + expect(createWorktree).not.toHaveBeenCalled(); + }); + + it("acceptance #5: reclaims a same-name dir on a foreign branch in place (no suffix)", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(classifyTaskWorktree).mockResolvedValue({ ok: true } as any); + // Registered, usable — but checked out on a foreign branch. + vi.mocked(getRegisteredWorktreeBranches).mockResolvedValue([{ branch: "fusion/fn-0000", worktreePath: PINNED }]); + const createWorktree = vi.fn(async (branch: string, path: string) => ({ path, branch })); + const audit = { git: vi.fn().mockResolvedValue(undefined), filesystem: vi.fn() } as any; + + const result = await acquireTaskWorktree({ + task: { ...baseTask, worktree: PINNED, branch: "fusion/fn-7996" }, + rootDir: ROOT, + store: makeStore(), + settings: pinnedSettings, + createWorktree, + audit, + }); + + expect(removeWorktree).toHaveBeenCalledWith(expect.objectContaining({ worktreePath: PINNED })); + expect(createWorktree).toHaveBeenCalledWith("fusion/fn-7996", PINNED, "FN-7996", "main", false); + expect(result.worktreePath).toBe(PINNED); + expect(result.source).toBe("fresh"); + expect(audit.git).toHaveBeenCalledWith(expect.objectContaining({ + type: "worktree:incomplete-detected", + metadata: expect.objectContaining({ classification: "foreign-branch", source: "pinned-acquire" }), + })); + }); + + it("reclaims an unregistered same-name dir in place", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(classifyTaskWorktree).mockResolvedValue({ ok: false, classification: "unregistered", reason: "not registered" } as any); + const createWorktree = vi.fn(async (branch: string, path: string) => ({ path, branch })); + + const result = await acquireTaskWorktree({ + task: baseTask, + rootDir: ROOT, + store: makeStore(), + settings: pinnedSettings, + createWorktree, + }); + + expect(removeWorktree).toHaveBeenCalledWith(expect.objectContaining({ worktreePath: PINNED })); + expect(createWorktree).toHaveBeenCalledWith("fusion/fn-7996", PINNED, "FN-7996", "main", false); + expect(result.worktreePath).toBe(PINNED); + }); + + it("acceptance #3: self-corrects a stale/foreign task.worktree pointer and emits worktree:pin-rederived", async () => { + // FN-7996 shape: task.worktree points at a foreign, removed pool dir; pinned dir itself is absent. + vi.mocked(existsSync).mockReturnValue(false); + const createWorktree = vi.fn(async (branch: string, path: string) => ({ path, branch })); + const audit = { git: vi.fn().mockResolvedValue(undefined), filesystem: vi.fn() } as any; + const store = makeStore(); + + const result = await acquireTaskWorktree({ + task: { ...baseTask, worktree: join(ROOT, ".worktrees", "grand-ridge"), branch: "fusion/fn-7996" }, + rootDir: ROOT, + store, + settings: pinnedSettings, + createWorktree, + audit, + }); + + expect(audit.git).toHaveBeenCalledWith(expect.objectContaining({ + type: "worktree:pin-rederived", + metadata: expect.objectContaining({ taskId: "FN-7996", derived: PINNED }), + })); + expect(store.updateTask).toHaveBeenCalledWith("FN-7996", { worktree: PINNED }); + expect(result.worktreePath).toBe(PINNED); + expect(result.source).toBe("fresh"); + }); +}); diff --git a/packages/engine/src/__tests__/worktree-pinning.test.ts b/packages/engine/src/__tests__/worktree-pinning.test.ts new file mode 100644 index 0000000000..b35159d834 --- /dev/null +++ b/packages/engine/src/__tests__/worktree-pinning.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + isTaskPinnedWorktreeNaming, + pinnedWorktreeSlug, + pinnedWorktreePathForTask, +} from "../worktree-pinning.js"; + +describe("worktree-pinning", () => { + describe("isTaskPinnedWorktreeNaming", () => { + it("is true only for task-id naming", () => { + expect(isTaskPinnedWorktreeNaming({ worktreeNaming: "task-id" })).toBe(true); + expect(isTaskPinnedWorktreeNaming({ worktreeNaming: "random" })).toBe(false); + expect(isTaskPinnedWorktreeNaming({ worktreeNaming: "task-title" })).toBe(false); + expect(isTaskPinnedWorktreeNaming({})).toBe(false); + expect(isTaskPinnedWorktreeNaming(undefined)).toBe(false); + }); + }); + + describe("pinnedWorktreeSlug", () => { + it("lowercases the task id and never suffixes", () => { + expect(pinnedWorktreeSlug("FN-7996")).toBe("fn-7996"); + expect(pinnedWorktreeSlug("fn-42")).toBe("fn-42"); + }); + }); + + describe("pinnedWorktreePathForTask", () => { + it("derives /.worktrees/ by default", () => { + expect(pinnedWorktreePathForTask("FN-7996", undefined, "/repo")).toBe( + join("/repo", ".worktrees", "fn-7996"), + ); + }); + + it("respects a configured worktreesDir with {repo} token", () => { + expect( + pinnedWorktreePathForTask("FN-1", { worktreesDir: "../wt/{repo}" }, "/home/me/myrepo"), + ).toBe(join("/home/me/wt/myrepo", "fn-1")); + }); + + it("respects a ~-expanded worktreesDir", () => { + expect(pinnedWorktreePathForTask("FN-2", { worktreesDir: "~/trees" }, "/repo")).toBe( + join(homedir(), "trees", "fn-2"), + ); + }); + + it("is stable across calls (no random/dedup suffix)", () => { + const a = pinnedWorktreePathForTask("FN-9", {}, "/repo"); + const b = pinnedWorktreePathForTask("FN-9", {}, "/repo"); + expect(a).toBe(b); + }); + }); +}); diff --git a/packages/engine/src/run-audit.ts b/packages/engine/src/run-audit.ts index 6e9dea24b0..5288b4a3fc 100644 --- a/packages/engine/src/run-audit.ts +++ b/packages/engine/src/run-audit.ts @@ -96,6 +96,13 @@ export type GitMutationType = | "worktree:remove-classification-probe-failed" | "worktree:remove-leaked-registered-worktree" | "worktree:reuse" + /* + * FNXC:TaskPinnedWorktrees 2026-07-16-00:00: + * Emitted when task-pinned acquisition (`worktreeNaming: "task-id"`) corrects a `task.worktree` cache that + * disagrees with the derived `/` path (the FN-7996 stale/foreign-pointer shape). + * Metadata is ids/paths-only: `{ taskId, previous, derived, source }`. + */ + | "worktree:pin-rederived" | "worktree:incomplete-detected" | "worktree:reanchored" | "worktree:auto-recovered" diff --git a/packages/engine/src/worktree-acquisition.ts b/packages/engine/src/worktree-acquisition.ts index 51711a4962..660e81de62 100644 --- a/packages/engine/src/worktree-acquisition.ts +++ b/packages/engine/src/worktree-acquisition.ts @@ -9,13 +9,16 @@ import { formatError } from "./logger.js"; import { classifyBootstrapMisbinding, isBranchConflictError, reanchorBranchToBase } from "./branch-conflicts.js"; import { type WorktreePool, + canonicalizePath, classifyTaskWorktree, + getRegisteredWorktreeBranches, isInsideWorktreesDir, isRepoRootPath, removeWorktree, RemovalReason, PoolDoubleLeaseError, } from "./worktree-pool.js"; +import { isTaskPinnedWorktreeNaming, pinnedWorktreePathForTask } from "./worktree-pinning.js"; import { NativeWorktreeBackend, WorktrunkOperationError, @@ -168,6 +171,34 @@ async function maybeWarnForeignTaskStartPoint( } } +/* +FNXC:TaskPinnedWorktrees 2026-07-16-00:00: +Warm-reuse of a task-pinned worktree requires the on-disk directory to be checked out on the task's own +branch. A same-name directory carrying a foreign branch (or detached HEAD) is stale/foreign and must be +reclaimed in place rather than reused, so pinned mode never hands a task another task's checkout. +*/ +async function pinnedWorktreeBranchMatches(rootDir: string, worktreePath: string, expectedBranch: string): Promise { + const canonical = canonicalizePath(worktreePath); + const entries = await getRegisteredWorktreeBranches(rootDir); + /* + * FNXC:TaskPinnedWorktrees 2026-07-16-12:30: + * `false` (branch mismatch) drives DESTRUCTIVE reclaim, so it must mean a PROVEN mismatch — never a probe + * failure. This function is only called after `classifyTaskWorktree` already proved the pinned path is a + * registered, usable worktree, so a totally empty branch enumeration is an inconsistency: the underlying + * `git worktree list` is failing transiently (it swallows errors and returns []). Treating that as + * "foreign branch" would blow away a valid warm worktree. Throw so acquisition fails safe and retries with + * a fresh probe, rather than reclaiming on a flaky signal. A non-empty list that simply omits this path + * (detached HEAD / no branch line) is a genuine reclaim case and correctly returns false below. + */ + if (entries.length === 0) { + throw new Error( + `pinned branch probe returned no registered worktrees for ${rootDir}; cannot confirm branch of ${worktreePath} (transient git failure) — refusing to prove mismatch`, + ); + } + const match = entries.find((entry) => entry.worktreePath === canonical); + return match?.branch === expectedBranch; +} + export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Promise { const { task, rootDir, store, settings, pool, logger, audit, runContext, createWorktree, runConfiguredCommand, runInitCommand, taskEnv, secretsStore } = opts; const notifyFallback = async (op: WorktrunkOpName, stderr?: string) => { @@ -217,6 +248,19 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro } const branchName = resolveTaskWorkingBranch(task); const naming = settings.worktreeNaming || "random"; + /* + * FNXC:TaskPinnedWorktrees 2026-07-16-00:00: + * Pinning and `recycleWorktrees` are MUTUALLY EXCLUSIVE — the settings-write boundary rejects enabling both + * (see `assertWorktreeNamingRecycleExclusive`). Pinned mode is therefore active only under + * `worktreeNaming: "task-id"` AND recycling OFF AND the native backend. The `!recycleWorktrees` guard here is + * the runtime backstop: a legacy/hand-edited on-disk config that still carries both settings degrades safely + * to recycling (pinning off), matching the rule "task-pinned worktrees only apply when recycling is off". + * Worktrunk owns its own layout, so pinning is bypassed whenever worktrunk is enabled or in play. + */ + const pinned = isTaskPinnedWorktreeNaming(settings) + && !settings.recycleWorktrees + && backend.kind !== "worktrunk" + && settings.worktrunk?.enabled !== true; const allowSiblingBranchRename = settings.executorAllowSiblingBranchRename === true; const baseBranch = task.executionStartBranch || null; /* @@ -236,7 +280,9 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro } let isResume = Boolean(task.worktree && existsSync(worktreePath)); - if (task.worktree && isResume) { + // FNXC:TaskPinnedWorktrees 2026-07-16-00:00: the non-pinned resume-classification self-heal is skipped in + // pinned mode; acquirePinnedWorktree runs its own derive→validate→reuse-or-recreate decision below. + if (!pinned && task.worktree && isResume) { const resumeClassification = await classifyTaskWorktree(rootDir, worktreePath); /* * FNXC:WorktreeLiveness 2026-06-21-11:10: @@ -481,6 +527,129 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro return createFreshWorktreeFromReturnGuard(result.worktreePath, result.source); }; + /** Warm-reuse an existing, usable, branch-matched worktree (mirrors the resume path). */ + const reuseWarmWorktree = async (path: string, resumedBranch: string, source: "existing"): Promise => { + logger?.log(`Reusing existing worktree: ${path}`); + const cleanup = await removeDesktopBuildArtifacts(path, logger); + if (cleanup.removed.length > 0) { + await store.logEntry(task.id, `Removed desktop build artifacts from worktree: ${cleanup.removed.join(", ")}`, undefined, runContext); + } + const hydrated = await hydrate(path); + await verifyResumeBranchNotMisbound({ + worktreePath: path, + branchName: resumedBranch, + taskId: task.id, + rootDir, + store, + audit, + logger, + runContext, + }); + return guardAcquisitionReturn({ worktreePath: path, branch: resumedBranch, source, hydrated, isResume: true }); + }; + + /* + * FNXC:TaskPinnedWorktrees 2026-07-16-00:00: + * Pinned-mode acquisition: derive → validate → reuse-or-recreate at the SAME derived path. + * `task.worktree` is a cache here — if it disagrees with the derived pinned path (the FN-7996 stale/foreign + * pointer shape), re-derive, correct the metadata, and emit `worktree:pin-rederived` before validating. + * The pool is never consulted (a pooled dir has the wrong name), so a task dispatched N times only ever + * touches `/` and never suffixes a sibling directory name. Recreate-in-place does NOT + * consume any worktree-session retry budget (acquisition returns a valid fresh worktree directly). + */ + const acquirePinnedWorktree = async (): Promise => { + const pinnedPath = pinnedWorktreePathForTask(task.id, settings, rootDir); + const resumedBranch = task.branch ?? branchName; + + if (task.worktree && canonicalizePath(task.worktree) !== canonicalizePath(pinnedPath)) { + await audit?.git({ + type: "worktree:pin-rederived", + target: pinnedPath, + metadata: { taskId: task.id, previous: task.worktree, derived: pinnedPath, source: "acquire" }, + }); + await store.logEntry(task.id, "Re-derived task-pinned worktree path from task id", `${task.worktree} -> ${pinnedPath}`, runContext); + await store.updateTask(task.id, { worktree: pinnedPath }); + } + + worktreePath = pinnedPath; + branch = branchName; + + if (existsSync(pinnedPath)) { + const classification = await classifyTaskWorktree(rootDir, pinnedPath); + const branchMatches = classification.ok + ? await pinnedWorktreeBranchMatches(rootDir, pinnedPath, resumedBranch) + : false; + if (classification.ok && branchMatches) { + /* + * FNXC:TaskPinnedWorktrees 2026-07-16-12:30: + * Warm reuse can ADOPT an orphaned pinned directory (dir exists on its own branch while `task.worktree` + * is null — first dispatch onto a leftover dir, or a recovery path that cleared the pointer). Persist + * the derived worktree/branch before returning so the acquisition leaves the task assigned; otherwise + * later lifecycle steps see a successful acquisition on an unassigned task. Idempotent when the cache + * was already correct. + */ + if (task.worktree !== pinnedPath || task.branch !== resumedBranch) { + await store.updateTask(task.id, { worktree: pinnedPath, branch: resumedBranch }); + } + return reuseWarmWorktree(pinnedPath, resumedBranch, "existing"); + } + // Invalid / foreign-branch / stale (crash leftover, archive→restore) → reclaim in place: remove the + // registered worktree (owner probe via removeWorktree) then recreate fresh at the SAME path — never suffix. + await audit?.git({ + type: "worktree:incomplete-detected", + target: pinnedPath, + metadata: { + classification: classification.ok ? "foreign-branch" : classification.classification, + reason: classification.ok ? `branch mismatch (expected ${resumedBranch})` : classification.reason, + source: "pinned-acquire", + taskId: task.id, + }, + }); + await store.logEntry( + task.id, + classification.ok + ? `Task-pinned worktree ${pinnedPath} is checked out on a foreign branch; reclaiming in place` + : `Task-pinned worktree ${pinnedPath} is ${classification.classification} (${classification.reason}); reclaiming in place`, + undefined, + runContext, + ); + if (isInsideWorktreesDir(rootDir, pinnedPath, settings)) { + try { + await removeWorktree({ + rootDir, + worktreePath: pinnedPath, + settings, + reason: RemovalReason.PoolPrune, + taskId: task.id, + audit: undefined, + }); + } catch (removeErr) { + /* + * FNXC:TaskPinnedWorktrees 2026-07-16-12:30: + * Reclaim-in-place must FAIL LOUD when removal fails, not swallow-and-recreate. If removeWorktree + * rejected (e.g. ActiveSessionWorktreeRemovalError — a live session still owns the path) or otherwise + * left the stale checkout registered, the path is still occupied: proceeding would clobber a live + * session, and `git worktree add` would then reject the occupied path AFTER we cleared sessionFile — + * stranding the task with no worktree and no resume metadata. Rethrow before the sessionFile clear so + * sessionFile is preserved and the executor's retry/self-healing owns recovery. + */ + logger?.warn(`${task.id}: failed to remove stale pinned worktree ${pinnedPath}: ${formatError(removeErr)}`); + await store.logEntry(task.id, `Failed to reclaim task-pinned worktree ${pinnedPath}; leaving resume metadata intact for retry`, formatError(removeErr).detail ?? undefined, runContext); + throw removeErr; + } + } + // The removed worktree's session cannot resume into a fresh checkout — clear it so the executor starts clean. + await store.updateTask(task.id, { sessionFile: null }); + } + + const created = await createWorktreeImpl(branchName, pinnedPath, task.id, freshStartPoint, allowSiblingBranchRename); + return finalizeCreatedWorktree(created, "fresh", "normal"); + }; + + if (pinned) { + return acquirePinnedWorktree(); + } + if (task.worktree && isResume) { logger?.log(`Reusing existing worktree: ${worktreePath}`); const cleanup = await removeDesktopBuildArtifacts(worktreePath, logger); diff --git a/packages/engine/src/worktree-pinning.ts b/packages/engine/src/worktree-pinning.ts new file mode 100644 index 0000000000..a4c6828e54 --- /dev/null +++ b/packages/engine/src/worktree-pinning.ts @@ -0,0 +1,39 @@ +import type { Settings } from "@fusion/core"; +import { resolveTaskWorktreePath } from "./worktree-paths.js"; + +/* +FNXC:TaskPinnedWorktrees 2026-07-16-00:00: +Task-pinned worktrees invariant. When `worktreeNaming === "task-id"`, a task lives in exactly one +derivable worktree directory — `/` (e.g. `.worktrees/fn-7996`) — +for its entire lifecycle. No other task may ever occupy it and no code path may hand this task any other +directory. The path is DERIVED from the task id (unique forever via committed reservations, so no +dedup-suffixing is possible), which makes the FN-7996 stale/foreign `task.worktree` pointer structurally +impossible: `task.worktree` becomes a cache that is re-derived and corrected on acquisition. + +This is intentionally separate from `recycleWorktrees`: pinning takes PRECEDENCE. Under `"random"` / +`"task-title"` naming the pool acquire/release path is untouched and byte-inert; only `"task-id"` naming +bypasses the pool (a pooled dir has the wrong name by definition) and removes-on-release instead of pooling. +Worktrunk-managed layouts own their own path derivation, so pinning is bypassed when that backend is active. +*/ + +/** True iff the resolved worktree naming mode pins each task to a derivable per-task directory. */ +export function isTaskPinnedWorktreeNaming(settings: Pick | undefined): boolean { + return settings?.worktreeNaming === "task-id"; +} + +/** Directory slug for a task-pinned worktree: the lowercased task id (IDs are unique forever). */ +export function pinnedWorktreeSlug(taskId: string): string { + return taskId.toLowerCase(); +} + +/** + * Derive the absolute task-pinned worktree path for a task under `"task-id"` naming. + * Respects the configured `worktreesDir` resolution (`~` expansion + `{repo}` token). + */ +export function pinnedWorktreePathForTask( + taskId: string, + settings: Pick | undefined, + rootDir: string, +): string { + return resolveTaskWorktreePath(rootDir, settings, pinnedWorktreeSlug(taskId)); +} diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 1b44051cd5..346eb969b1 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -6717,7 +6717,8 @@ "keepsProgressMovingBySwitchingToFusionApos": " keeps progress moving by switching to Fusion's built-in worktree backend. ", "limitsTotalGitWorktreesIncludingInReviewTasks": "Limits total git worktrees including in-review tasks. Default: 4.", "maxWorktrees": "Max Worktrees", - "offByDefaultOptInWhenEnabledCompleted": "Off by default (opt-in). When enabled, completed task worktrees are returned to an idle pool instead of being deleted, preserving build caches for faster startup", + "offByDefaultOptInWhenEnabledCompleted": "Off by default (opt-in). When enabled, completed task worktrees are returned to an idle pool instead of being deleted, preserving build caches for faster startup. Mutually exclusive with Task ID worktree naming.", + "recycleNotApplicableWithTaskIdNaming": "Not available with Task ID worktree naming — that mode pins each task to its own worktree directory, which is mutually exclusive with the recycle pool. Switch naming to Random or Task title to enable recycling.", "openApprovals": "Open Approvals", "optionalLeaveBlankToAutoResolveFusionWill": "Optional. Leave blank to auto-resolve; Fusion will offer to install on first use.", "optionalSupports": " Optional. Supports ", @@ -6747,8 +6748,8 @@ "worktreesPickerNote": "Navigate to the folder where Fusion should create task worktrees, then select the current directory.", "showWorktreeGroupingHelp": "Off by default. When enabled, WIP and processing columns always group tasks by worktree and show worktree names, including workflow-mode processing columns.", "copyFilesHelp": "Optional. Repository-root-relative regular files are copied into fresh or pooled task worktrees before init commands run. Missing files or directories are skipped without exposing contents. Default: empty (no files copied).", - "namingStyleNotApplicableWhenRecycling": "Naming style is not applicable when recycling worktrees — pooled worktrees retain their existing names", - "howToNameFreshWorktreeDirectories": "How to name fresh worktree directories. Only applies when recycling is off. Default: random." + "namingStyleNotApplicableWhenRecycling": "Naming style is not applicable when recycling worktrees — pooled worktrees retain their existing names. \"Task ID\" is unavailable here because task-pinned worktrees are mutually exclusive with recycling; turn off Recycle worktrees to use it.", + "howToNameFreshWorktreeDirectories": "How to name fresh worktree directories. Only applies when recycling is off. \"Task ID\" also pins each task to its own worktree directory for its whole lifecycle (mutually exclusive with recycling). Default: random." }, "fileBrowser": { "currentDirectory": "Current directory:",