From ef35fb8e5a293f828715d6b36eb9d81803878bec Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 19 Aug 2026 18:57:33 -0700 Subject: [PATCH] FN-9164: select per-repository workspace base branches Workspace worktrees now honor and persist the appropriate base branch for each sub-repository throughout their lifecycle. - Resolve requested bases per repository, normalize remote-only refs, and fall back safely to repository integration branches. - Reuse recorded bases for landing, recovery, and revert flows with privacy-safe audit breadcrumbs. - Surface base and fallback details in task UI, documentation, tests, and release notes. Files changed: .changeset/fn-9164-workspace-base-branch.md | 7 + AGENTS.md | 1 + docs/task-management.md | 2 +- docs/workspaces.md | 8 +- packages/core/src/types/task/task-core.ts | 12 ++ .../dashboard/app/components/TaskDetailModal.css | 14 ++ .../app/components/WorkspaceWorktreesSummary.tsx | 2 + .../__tests__/WorkspaceWorktreesSummary.test.tsx | 13 ++ .../src/routes/register-task-workflow-routes.ts | 2 + .../task-revert.workspace.real-git.test.ts | 25 +++ .../src/__tests__/workspace-base-branch.test.ts | 93 +++++++++++ .../worktree-acquisition-workspace.test.ts | 27 ++++ packages/engine/src/execution/task-revert.ts | 58 +++++-- packages/engine/src/merge/merger-ai.ts | 30 +++- packages/engine/src/self-healing.ts | 50 +++++- packages/engine/src/util/run-audit.ts | 6 + .../engine/src/worktree/workspace-base-branch.ts | 180 +++++++++++++++++++++ .../engine/src/worktree/worktree-acquisition.ts | 62 ++++--- 18 files changed, 548 insertions(+), 44 deletions(-) Fusion-Task-Id: FN-9164 Fusion-Task-Lineage: 487a1c77-32cb-47dd-bf43-297a146951ba Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-9164-workspace-base-branch.md | 7 + AGENTS.md | 1 + docs/task-management.md | 2 +- docs/workspaces.md | 8 +- packages/core/src/types/task/task-core.ts | 12 ++ .../app/components/TaskDetailModal.css | 14 ++ .../components/WorkspaceWorktreesSummary.tsx | 2 + .../WorkspaceWorktreesSummary.test.tsx | 13 ++ .../routes/register-task-workflow-routes.ts | 2 + .../task-revert.workspace.real-git.test.ts | 25 +++ .../__tests__/workspace-base-branch.test.ts | 93 +++++++++ .../worktree-acquisition-workspace.test.ts | 27 +++ packages/engine/src/execution/task-revert.ts | 58 +++++- packages/engine/src/merge/merger-ai.ts | 30 ++- packages/engine/src/self-healing.ts | 50 ++++- packages/engine/src/util/run-audit.ts | 6 + .../src/worktree/workspace-base-branch.ts | 180 ++++++++++++++++++ .../src/worktree/worktree-acquisition.ts | 62 ++++-- 18 files changed, 548 insertions(+), 44 deletions(-) create mode 100644 .changeset/fn-9164-workspace-base-branch.md create mode 100644 packages/engine/src/__tests__/workspace-base-branch.test.ts create mode 100644 packages/engine/src/worktree/workspace-base-branch.ts diff --git a/.changeset/fn-9164-workspace-base-branch.md b/.changeset/fn-9164-workspace-base-branch.md new file mode 100644 index 0000000000..a004b00055 --- /dev/null +++ b/.changeset/fn-9164-workspace-base-branch.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Let workspace tasks choose and display a verified base branch per repository. +category: feature +dev: Per-repo verification falls back safely, pins durable base fields for landing/revert, and records an ids-only audit decision. diff --git a/AGENTS.md b/AGENTS.md index dffd5fd891..60d330b0ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -306,6 +306,7 @@ Scoped exception (FN-5819/FN-8823): while project auto-merge is On, shared-branc - FN-6793/FN-6797: self-healing emits `task:reconcile-in-review-unmet-dependencies` when it rebounds an `in-review` task whose declared dependencies are still unmet, and `task:reconcile-in-review-unmet-dependencies-no-action` when pause/user-pause, `autoMerge:false`, live execution/checkout proof, or a failed rebound mutation blocks that backward move. - Workspace (Phase D U1): self-healing emits `task:reconcile-workspace-partial-land` when it re-enqueues a partial/zero-landed workspace task's per-repo land (or parks it `failed` for proven branch absence or exhausted `evidence-unavailable` branch reads), and `task:reconcile-workspace-partial-land-no-action` when `autoMerge:false`, user-pause, a live sub-repo worktree (workspace-aware liveness), or `evidence-unavailable` blocks that backward move. The bounded evidence-exhaustion reason is `evidence-unavailable-exhausted`; audit metadata remains ids/counts/outcomes-only. - Workspace (Phase D U1): self-healing emits `task:reclaim-phantom-workspace-land-lease` when it clears a leaked `workspace-repo-land` lease whose owning task is terminal/dead and older than the FN-6736 staleness floor. Archived-role and soft-deleted owners are terminal; live merging, executing, or merge-pending owners are untouched. +- FN-9164: `worktree:workspace-repo-base-branch` records per-repo base resolution with exactly `taskId`, `repoRelPath`, `stage`, `source`, `outcome`, and optional `fallbackReason`; branch/ref names are deliberately excluded from metadata and `target`, living only in the durable entry and task log. - FN-9058: `worktree:workspace-main-checkout-edit` records workspace completion guard evidence with ids/counts/fixed outcomes only: task/repo IDs, file/commit counts, evidence or warning reason enum, `taskDoneRetryCount`, and `blocked`/`warned`/`skipped`; never paths, file content, or commit prose. - FN-9059: workspace coordination emits `workspace-lease:*` events for lease acquisition, renewal, release, `fence-published`, `fence-superseded`, `reclaimed`, and `reclaim-refused`, plus `workspace-land-intent:*` events for write-ahead intent lifecycle and `resolve-refused`. Metadata is ids, SHAs, counts, and fixed outcomes only; it never includes a credential-bearing remote URL. - FN-9056: self-healing emits `task:reconcile-orphaned-workspace-worktree` when it reclaims a complete-lane or conservatively-idle failed/soft-deleted workspace entry. It vetoes raw/canonical active paths, task-session/executor/merge liveness, pauses and scheduled recovery; archived rows remain archive-lifecycle-owned. It runs `git worktree prune` even for already-gone paths and deletes only safely-discardable canonical `fusion/` branches. Duplicate, foreign, unowned, or outside-root claims are skipped without git work; one entry-scoped `MAX_STARVATION_DROPS` budget plus settlement bounds retries. Metadata is ids/counts/fixed outcomes: task/repo/path, success/reason/lane, worktree/prune/branch outcomes, and attempt. diff --git a/docs/task-management.md b/docs/task-management.md index c7e7f041fd..20b1df8893 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -428,7 +428,7 @@ These filters apply only to board rendering (not list view). Each filter support Task branch fields are intentionally distinct: - `task.branch` — the actual working branch used for the task worktree (for example `fusion/fn-1234` or a conflict-suffixed variant). -- `task.baseBranch` — the task's configured merge target/base branch intent. +- `task.baseBranch` — the task's configured merge target/base branch intent. For workspace tasks it is verified in each sub-repository at acquisition; each resolved (or fallback) selection is pinned in that repository's worktree entry. Editing it after worktrees exist affects only future acquisition and never retargets existing member worktrees. - `task.executionStartBranch` — internal execution provenance used when scheduler/executor temporarily start from a dependency branch; this is transient and cleared during execution resets/recovery. `PrInfo.baseBranch` is unchanged and continues to represent pull-request target branch metadata. diff --git a/docs/workspaces.md b/docs/workspaces.md index 24b7c10746..e77061e2b7 100644 --- a/docs/workspaces.md +++ b/docs/workspaces.md @@ -59,6 +59,12 @@ The tool accepts only a configured repository name and returns an isolated, task Fusion adds acquired member paths to the task's active-worktree set, so liveness and ownership checks see the root plus every active member worktree. A live remembered worktree is reused across a resumed task or executor restart. If another task is acquiring the same member, the tool returns a temporary busy error asking the agent to retry `fn_acquire_repo_worktree` shortly; acquire a different member or retry rather than editing the original repository checkout. +## Choosing the base branch + +Set a task's `baseBranch` in the New Task form or Task Detail to choose the base for a workspace task. At acquisition, Fusion verifies that ref independently in every sub-repository. Where it resolves, it is that worktree's start point, base-SHA anchor, land target, and revert target. Where it does not resolve, Fusion safely falls back to that repository's own integration branch rather than failing acquisition; the requested and selected refs are recorded in the task log and Task Detail, while run audit stores only the task/repository identifiers and fixed decision outcome. + +The choice is pinned per repository at acquisition. The recorded `WorkspaceWorktreeEntry.baseBranch` wins for land, self-healing, and revert even if `task.baseBranch` is later edited. If a recorded ref disappears, those operations fall back to that repository's integration branch and leave a breadcrumb. Legacy entries without a recorded base (including worktrees acquired before this feature or a restored task) continue to target their own integration branch and ignore `task.baseBranch`; Fusion does not backfill them, and mixed legacy/recorded workspaces are valid. Checking out a desired branch first is not required: the task field is authoritative when it verifies in that member repository. + ## Review and verification Fusion captures changes per acquired sub-repository, not from the non-Git root. Modified file paths are repository-prefixed, such as `api/src/server.ts`, and each member is diffed against its own base. Per-repository branch attribution, contamination, and worktree-invariant checks apply to those member worktrees. Review and verification should therefore identify the member repository alongside every changed path and command result. @@ -106,7 +112,7 @@ Archiving a workspace task synchronously removes every recorded member worktree. ## Limitations and known sharp edges - Landing is non-atomic. A later failure does not undo earlier local integration-ref advances; use task logs, per-repository history, and `landedSha` proof before retrying or manually recovering. -- The dashboard task detail does not currently expose a dedicated per-repository land-status view. Use `fn task merge` output, task logs, and run audit for the repository-level state. +- A requested base can resolve in some members and not others. Inspect the per-repository Task Detail base/fallback marker and task log before manually coordinating a mixed workspace. - Exclusivity is per sub-repository. Two workspace tasks can work in different members concurrently, but cannot acquire or land the same member at the same time. - Detection is intentionally shallow. A Git repository nested below a non-repository direct child is not a workspace member until you restructure or configure a valid direct-child entry. diff --git a/packages/core/src/types/task/task-core.ts b/packages/core/src/types/task/task-core.ts index 5f3fc50468..7d381b7186 100644 --- a/packages/core/src/types/task/task-core.ts +++ b/packages/core/src/types/task/task-core.ts @@ -681,9 +681,21 @@ The atomic per-repository store mutation and its engine callers share this entry per-key merges preserve every durable workspace worktree field rather than drifting into independent inline shapes. */ +/* +FNXC:Workspace 2026-08-20-00:56: +Each sub-repository records the base ref selected at acquisition because later land, self-heal, +and revert operations must target the ref the worktree was actually derived from. A requested +base missing in one repo falls back to that repo's integration branch and records the requested +name in baseBranchFallbackFrom. Legacy entries without these fields remain pinned to their own +integration branch and ignore task.baseBranch. Ref names live here and in task logs, never audit metadata. +*/ export interface WorkspaceWorktreeEntry { worktreePath: string; branch: string; + /** The ref this sub-repository worktree was derived from and must later target. */ + baseBranch?: string; + /** The operator-requested ref when this repository instead used its integration branch. */ + baseBranchFallbackFrom?: string; baseCommitSha?: string; landedSha?: string; revertBoundarySha?: string; diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index 883dfd71f4..3f246119d8 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -3875,6 +3875,20 @@ Read-only list/placeholder only — not the deferred rich per-repo-status compon .workspace-worktrees-branch { color: var(--text-muted, inherit); } +/* +FNXC:Workspace 2026-08-20-00:56: +The full Task Detail list exposes durable per-repo base decisions; the compact card stays count-only. +Fallback uses the existing warning status treatment because it is observable divergence, not an error. +*/ +.workspace-worktrees-base { + color: var(--text-muted, inherit); +} +.workspace-worktrees-base-fallback { + color: var(--color-warning); + background: color-mix(in srgb, var(--color-warning) 15%, transparent); + border-radius: var(--radius-sm); + padding: 0 var(--space-xs); +} .workspace-worktrees-status { border-radius: var(--radius-sm); padding: 0 var(--space-xs); diff --git a/packages/dashboard/app/components/WorkspaceWorktreesSummary.tsx b/packages/dashboard/app/components/WorkspaceWorktreesSummary.tsx index eb66e6fbb5..c523510472 100644 --- a/packages/dashboard/app/components/WorkspaceWorktreesSummary.tsx +++ b/packages/dashboard/app/components/WorkspaceWorktreesSummary.tsx @@ -61,6 +61,8 @@ export function WorkspaceWorktreesSummary({ task, compact = false }: WorkspaceWo {landedSha && {landedSha.slice(0, 8)}} {entry.worktreePath} {entry.branch} + {entry.baseBranch && {t("tasks.workspaceRepoBase", "Base: {{branch}}", { branch: entry.baseBranch })}} + {entry.baseBranchFallbackFrom && {t("tasks.workspaceRepoBaseFallback", "Base fallback")}} {failureMessage && {failureMessage}} )} diff --git a/packages/dashboard/app/components/__tests__/WorkspaceWorktreesSummary.test.tsx b/packages/dashboard/app/components/__tests__/WorkspaceWorktreesSummary.test.tsx index 0a474479d9..6e29a81948 100644 --- a/packages/dashboard/app/components/__tests__/WorkspaceWorktreesSummary.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkspaceWorktreesSummary.test.tsx @@ -65,12 +65,25 @@ describe("WorkspaceWorktreesSummary", () => { expect(screen.getByText("fusion/fn-1-b")).toBeTruthy(); }); + it("renders recorded bases and fallback markers only in the full per-repo list", () => { + render(); + expect(screen.getAllByTestId("workspace-repo-base-branch")).toHaveLength(2); + expect(screen.getByTestId("workspace-repo-base-fallback")).toHaveAttribute("title", expect.stringContaining("release/1.2")); + expect(screen.getByTestId("workspace-repo-base-fallback")).toHaveAttribute("title", expect.stringContaining("repo-b")); + }); + it("renders only the compact placeholder in compact mode", () => { render(); expect(screen.getByTestId("workspace-worktrees-placeholder").textContent).toContain("2 repos"); // Compact variant omits the full per-repo list. expect(screen.queryByTestId("workspace-worktrees-summary")).toBeNull(); expect(screen.queryByText("/wt/repo-a")).toBeNull(); + expect(screen.queryByTestId("workspace-repo-base-branch")).toBeNull(); + expect(screen.queryByTestId("workspace-repo-base-fallback")).toBeNull(); }); it("renders landed, failed, and pending statuses with the partial-land detail", () => { diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index d4b6ce3c36..a04d4c289d 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -2882,6 +2882,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork getTaskCommitAssociationsByLineageId: (lineageId: string) => scopedStore.getTaskCommitAssociationsByLineageId(lineageId), }, + store: scopedStore, }); if (!prepared.eligible) { @@ -3035,6 +3036,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork scopedStore.getTaskCommitAssociationsByLineageId(lineageId), }, effectiveAutoMerge: settings.autoMerge, + store: scopedStore, }); if (workspaceResult.mode === "git" && "clean" in workspaceResult && workspaceResult.clean === true) { diff --git a/packages/engine/src/__tests__/task-revert.workspace.real-git.test.ts b/packages/engine/src/__tests__/task-revert.workspace.real-git.test.ts index 4fd0e3ce8c..166e43e844 100644 --- a/packages/engine/src/__tests__/task-revert.workspace.real-git.test.ts +++ b/packages/engine/src/__tests__/task-revert.workspace.real-git.test.ts @@ -174,6 +174,31 @@ describeIfGit("task-revert workspace real-git scenarios", { timeout: 30_000 }, ( expect(git(repoB, "git show HEAD:b.ts")).toBe("line1"); }); + it("records a revert-stage breadcrumb when a recorded base has vanished", async () => { + const { workspaceRoot, repoA, repoB } = workspaceFixture(); + const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a"); + const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b"); + const task = makeWorkspaceTask(shaA, shaB, { + workspaceWorktrees: { + "repo-a": { worktreePath: "repo-a", branch: "fusion/FN-A", landedSha: shaA, baseBranch: "release/deleted" }, + "repo-b": { worktreePath: "repo-b", branch: "fusion/FN-A", landedSha: shaB }, + }, + }); + const logs: string[] = []; + const events: Array<{ metadata: Record }> = []; + + await expect(revertWorkspaceTask({ + task, workspaceRootDir: workspaceRoot, settings: {}, + store: { logEntry: async (_id: string, message: string) => { logs.push(message); } } as unknown as import("@fusion/core").TaskStore, + audit: { git: async (event: never) => { events.push(event as unknown as { metadata: Record }); } }, + })).resolves.toMatchObject({ mode: "git", clean: true }); + + expect(logs.some((message) => message.includes("release/deleted"))).toBe(true); + expect(events).toHaveLength(1); + expect(events[0].metadata).toMatchObject({ stage: "revert", outcome: "fallback", fallbackReason: "recorded-base-vanished" }); + expect(JSON.stringify(events[0].metadata)).not.toContain("release/deleted"); + }); + it("the workspace guard still REFUSES a live lane under a resolved set", async () => { const { workspaceRoot, repoA, repoB } = workspaceFixture(); const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a"); diff --git a/packages/engine/src/__tests__/workspace-base-branch.test.ts b/packages/engine/src/__tests__/workspace-base-branch.test.ts new file mode 100644 index 0000000000..1cca7c10c5 --- /dev/null +++ b/packages/engine/src/__tests__/workspace-base-branch.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import type { Task, TaskStore } from "@fusion/core"; +import { + recordWorkspaceBaseBranchDecision, + resolveWorkspaceRepoBaseBranch, +} from "../worktree/workspace-base-branch.js"; + +const task = (baseBranch?: string) => ({ id: "FN-9164", baseBranch } as Pick); + +function execFor(refs: string[], commands: string[] = []) { + return (async (command: string) => { + commands.push(command); + if (refs.some((ref) => command.includes(`${ref}^{commit}`))) return { stdout: "abc123\n", stderr: "" }; + throw new Error("missing ref"); + }) as never; +} + +describe("resolveWorkspaceRepoBaseBranch", () => { + it("uses a verified requested base and shell-quotes it", async () => { + const commands: string[] = []; + const resolution = await resolveWorkspaceRepoBaseBranch({ + mode: "acquire", + repoRootDir: "/missing-repo", + repoRelPath: "repo-a", + task: task("release/needle-9164; echo nope"), + settings: {}, + execImpl: execFor(["release/needle-9164; echo nope"], commands), + }); + expect(resolution).toMatchObject({ branch: "release/needle-9164; echo nope", source: "task-base-branch" }); + expect(commands[0]).toContain("'release/needle-9164; echo nope^{commit}'"); + }); + + it("normalizes a remote-tracking-only base into a local lifecycle target", async () => { + const commands: string[] = []; + let localCreated = false; + const execImpl = (async (command: string) => { + commands.push(command); + if (command.includes("origin/release/remote-only^{commit}")) return { stdout: "abc123\n", stderr: "" }; + if (command.includes("release/remote-only^{commit}") && localCreated) return { stdout: "abc123\n", stderr: "" }; + if (command.includes("git branch -- 'release/remote-only' 'origin/release/remote-only'")) { + localCreated = true; + return { stdout: "", stderr: "" }; + } + throw new Error("missing ref"); + }) as never; + + const resolution = await resolveWorkspaceRepoBaseBranch({ + mode: "acquire", repoRootDir: "/missing-repo", repoRelPath: "repo-a", task: task("release/remote-only"), settings: {}, execImpl, + }); + + expect(resolution).toMatchObject({ branch: "release/remote-only", requested: "release/remote-only", source: "task-base-branch" }); + expect(commands).toContain("git branch -- 'release/remote-only' 'origin/release/remote-only'"); + }); + + it("falls back without failing acquisition for unresolvable and sibling task refs", async () => { + const unresolved = await resolveWorkspaceRepoBaseBranch({ + mode: "acquire", repoRootDir: "/missing-repo", repoRelPath: "repo-a", task: task("release/missing"), settings: {}, execImpl: execFor([]), + }); + const sibling = await resolveWorkspaceRepoBaseBranch({ + mode: "acquire", repoRootDir: "/missing-repo", repoRelPath: "repo-a", task: task("fusion/fn-123"), settings: {}, execImpl: execFor([]), + }); + expect(unresolved).toMatchObject({ branch: "main", source: "repo-integration", fallbackReason: "unresolvable-in-repo" }); + expect(sibling).toMatchObject({ branch: "main", source: "repo-integration", fallbackReason: "sibling-task-branch" }); + }); + + it("keeps legacy and recorded entries independent of task.baseBranch", async () => { + const legacy = await resolveWorkspaceRepoBaseBranch({ + mode: "recorded", recordedBaseBranch: undefined, repoRootDir: "/missing-repo", repoRelPath: "repo-a", task: task("release/new"), settings: {}, execImpl: execFor(["release/new"]), + }); + const recorded = await resolveWorkspaceRepoBaseBranch({ + mode: "recorded", recordedBaseBranch: "release/old", repoRootDir: "/missing-repo", repoRelPath: "repo-a", task: task("release/new"), settings: {}, execImpl: execFor(["release/old"]), + }); + expect(legacy).toEqual({ branch: "main", source: "legacy-entry" }); + expect(recorded).toMatchObject({ branch: "release/old", requested: "release/old", source: "recorded-base" }); + }); + + it("emits no ref names to audit while retaining human-readable logs", async () => { + const events: Array<{ target: string; metadata: Record }> = []; + const logs: string[] = []; + await recordWorkspaceBaseBranchDecision({ + store: { logEntry: async (_id: string, message: string) => { logs.push(message); } } as Pick, + audit: { git: async (event: never) => { events.push(event as unknown as { target: string; metadata: Record }); } }, + task: task("release/needle-9164"), repoRelPath: "repo-a", repoAbsPath: "/workspace/repo-a", stage: "acquire", + resolution: { branch: "main", requested: "release/needle-9164", source: "repo-integration", fallbackReason: "unresolvable-in-repo" }, + }); + expect(logs[0]).toContain("release/needle-9164"); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ target: "/workspace/repo-a", metadata: { taskId: "FN-9164", repoRelPath: "repo-a", stage: "acquire", source: "repo-integration", outcome: "fallback", fallbackReason: "unresolvable-in-repo" } }); + expect(Object.keys(events[0].metadata).sort()).toEqual(["fallbackReason", "outcome", "repoRelPath", "source", "stage", "taskId"]); + expect(JSON.stringify(events[0].metadata)).not.toContain("release/needle-9164"); + expect(events[0].target).not.toContain("release/needle-9164"); + }); +}); diff --git a/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts b/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts index e52898908f..5aaf2e92e9 100644 --- a/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts +++ b/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts @@ -166,6 +166,33 @@ describeIfGit("acquireWorkspaceRepoWorktree (U2 per-repo hardening)", { timeout: expect(result.baseCommitSha).toBe(developTip); }); + it("materializes a remote-tracking-only requested base as a local land target", async () => { + fixture = await createWorkspaceFixture(["repo-a"]); + const repoA = fixture.repoPath("repo-a"); + const origin = `${repoA}-origin`; + git(repoA, "git init --bare " + JSON.stringify(origin)); + git(repoA, `git remote add origin ${JSON.stringify(origin)}`); + git(repoA, "git checkout -qb release/remote-only"); + git(repoA, "git commit --allow-empty -m 'release base'"); + const releaseTip = git(repoA, "git rev-parse HEAD"); + git(repoA, "git push -u origin release/remote-only"); + git(repoA, "git checkout main"); + git(repoA, "git branch -D release/remote-only"); + expect(git(repoA, "git rev-parse origin/release/remote-only")).toBe(releaseTip); + + const remoteBaseTask = makeTask("FN-9164-remote"); + remoteBaseTask.baseBranch = "release/remote-only"; + const { store, current } = makeFakeStore(remoteBaseTask); + const result = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", workspaceRootDir: fixture.rootDir, task: current(), store, + settings: SETTINGS, registry: new ActiveSessionRegistry(), + }); + + expect(git(repoA, "git rev-parse release/remote-only")).toBe(releaseTip); + expect(git(repoA, `git merge-base ${result.branch} release/remote-only`)).toBe(releaseTip); + expect(current().workspaceWorktrees?.["repo-a"]?.baseBranch).toBe("release/remote-only"); + }); + it("reconciles a sub-repo dangling collision branch from its resolved integration tip", async () => { fixture = await createWorkspaceFixture(["repo-a"]); const repoA = fixture.repoPath("repo-a"); diff --git a/packages/engine/src/execution/task-revert.ts b/packages/engine/src/execution/task-revert.ts index 8d729ed5bc..70592e636d 100644 --- a/packages/engine/src/execution/task-revert.ts +++ b/packages/engine/src/execution/task-revert.ts @@ -50,9 +50,11 @@ import { exec } from "node:child_process"; import { join } from "node:path"; import { promisify } from "node:util"; -import { isWorkspaceTask, type Task, type TaskCommitAssociation, type TaskCreateInput } from "@fusion/core"; +import { isWorkspaceTask, type Settings, type Task, type TaskCommitAssociation, type TaskCreateInput, type TaskStore } from "@fusion/core"; import { collectOwnTaskCommitsForRange } from "./branch-attribution.js"; -import { resolveIntegrationBranch, type IntegrationBranchSettings } from "../merge/integration-branch.js"; +import { type IntegrationBranchSettings } from "../merge/integration-branch.js"; +import { recordWorkspaceBaseBranchDecision, resolveWorkspaceRepoBaseBranch } from "../worktree/workspace-base-branch.js"; +import { createRunAuditor, generateSyntheticRunId, type RunAuditor } from "../util/run-audit.js"; const defaultExecAsync = promisify(exec); type ExecAsyncImpl = typeof defaultExecAsync; @@ -1111,6 +1113,9 @@ export interface RevertWorkspaceTaskOptions { commitAssociationSource?: TaskCommitAssociationSource; /** Resolved effective project autoMerge setting (task.autoMerge overrides this when set). Defaults to true (autoMerge on) when omitted. */ effectiveAutoMerge?: boolean; + /** Optional observability sinks supplied by the route; decision breadcrumbs never affect reversion. */ + store?: TaskStore; + audit?: Pick; } interface WorkspaceRepoRevertContext { @@ -1189,12 +1194,27 @@ export async function revertWorkspaceTask(opts: RevertWorkspaceTaskOptions): Pro for (const repoRel of repoKeys) { const repoRootDir = join(workspaceRootDir, repoRel); - // Re-resolve THIS sub-repo's integration branch with the shared overrides - // stripped (KTD1), mirroring `landWorkspaceTask`/self-healing, so each - // sub-repo resolves its own default rather than inheriting a workspace-wide override. + // Recorded acquisition state, not a later task.baseBranch edit, controls the revert target. let integrationBranch: string; try { - integrationBranch = await resolveIntegrationBranch(repoRootDir, { ...opts.settings, integrationBranch: undefined, baseBranch: undefined }); + const baseResolution = await resolveWorkspaceRepoBaseBranch({ + mode: "recorded", repoRootDir, repoRelPath: repoRel, task, + settings: opts.settings as Partial, + recordedBaseBranch: workspaceWorktrees[repoRel].baseBranch, + }); + integrationBranch = baseResolution.branch; + if (opts.store) { + await recordWorkspaceBaseBranchDecision({ + store: opts.store, + audit: opts.audit ?? createRunAuditor(opts.store, { + runId: generateSyntheticRunId("workspace-repo-base-branch", task.id), + agentId: "system:task-revert", + phase: "workspace-repo-base-branch", + }), + task, repoRelPath: repoRel, repoAbsPath: repoRootDir, + resolution: baseResolution, stage: "revert", + }); + } } catch (error) { throw new TaskRevertError(`failed to resolve integration branch for sub-repo ${repoRel}`, "integration-branch-resolve-failed", error); } @@ -1344,6 +1364,9 @@ export interface PrepareWorkspaceRevertPrBranchesOptions { revertBranch: string; execAsyncImpl?: ExecAsyncImpl; commitAssociationSource?: TaskCommitAssociationSource; + /** Optional observability sinks supplied by the route; decision breadcrumbs never affect PR preparation. */ + store?: TaskStore; + audit?: Pick; } interface WorkspaceRepoRevertPrContext { @@ -1478,16 +1501,31 @@ export async function prepareWorkspaceRevertPrBranches( commitAssociationSource: opts.commitAssociationSource, }); - // Phase 1: resolve each sub-repo's integration branch, refuse (without - // mutating) on branch-mismatch/dirty-tree, then dry-run classify EVERY - // sub-repo — mirrors `revertWorkspaceTask`'s Phase 1 verbatim. + // Phase 1: resolve each recorded per-repository target before every refusal/classification. const contexts: WorkspaceRepoRevertPrContext[] = []; for (const repoRel of repoKeys) { const repoRootDir = join(workspaceRootDir, repoRel); let integrationBranch: string; try { - integrationBranch = await resolveIntegrationBranch(repoRootDir, { ...opts.settings, integrationBranch: undefined, baseBranch: undefined }); + const baseResolution = await resolveWorkspaceRepoBaseBranch({ + mode: "recorded", repoRootDir, repoRelPath: repoRel, task, + settings: opts.settings as Partial, + recordedBaseBranch: workspaceWorktrees[repoRel].baseBranch, + }); + integrationBranch = baseResolution.branch; + if (opts.store) { + await recordWorkspaceBaseBranchDecision({ + store: opts.store, + audit: opts.audit ?? createRunAuditor(opts.store, { + runId: generateSyntheticRunId("workspace-repo-base-branch", task.id), + agentId: "system:task-revert", + phase: "workspace-repo-base-branch", + }), + task, repoRelPath: repoRel, repoAbsPath: repoRootDir, + resolution: baseResolution, stage: "revert", + }); + } } catch (error) { throw new TaskRevertError(`failed to resolve integration branch for sub-repo ${repoRel}`, "integration-branch-resolve-failed", error); } diff --git a/packages/engine/src/merge/merger-ai.ts b/packages/engine/src/merge/merger-ai.ts index 2d585feaff..f3aa64772a 100644 --- a/packages/engine/src/merge/merger-ai.ts +++ b/packages/engine/src/merge/merger-ai.ts @@ -70,6 +70,7 @@ import { import { selectUserCommentsForAgentContext } from "../agents/agent-user-comments.js"; import { resolveTaskWorkingBranch } from "../worktree/worktree-names.js"; import { resolveIntegrationBranch } from "./integration-branch.js"; +import { recordWorkspaceBaseBranchDecision, resolveWorkspaceRepoBaseBranch } from "../worktree/workspace-base-branch.js"; import { advanceIntegrationBranchRef } from "./merger-ref-update-advance.js"; import { enforceAiMergeSquashGates } from "./merger-ai-squash-gates.js"; import { @@ -2106,15 +2107,32 @@ export async function landWorkspaceTask( const entry = workspaceWorktrees[repoRel]; const repoRootDir = join(workspaceRootDir, repoRel); - // Re-resolve THIS sub-repo's integration branch with the shared overrides - // stripped (KTD1) so each sub-repo lands on its OWN origin/HEAD, not a shared - // workspace branch. + /* + FNXC:Workspace 2026-08-20-00:56: + Recorded acquisition state is the only workspace landing target. A worktree forked from + release/x lands on release/x, while a legacy or acquisition-fallback entry remains on its + own integration branch even if task.baseBranch has since changed. + */ let integrationBranch: string; try { - integrationBranch = await resolveIntegrationBranch( + const baseResolution = await resolveWorkspaceRepoBaseBranch({ + mode: "recorded", repoRootDir, - { ...settings, integrationBranch: undefined, baseBranch: undefined }, - ); + repoRelPath: repoRel, + task, + settings, + recordedBaseBranch: entry.baseBranch, + }); + integrationBranch = baseResolution.branch; + await recordWorkspaceBaseBranchDecision({ + store, + audit, + task, + repoRelPath: repoRel, + repoAbsPath: repoRootDir, + resolution: baseResolution, + stage: "land", + }); } catch (err: unknown) { const message = getErrorMessage(err); await log(`AI merge (workspace): failed to resolve integration branch for sub-repo ${repoRel}: ${message}`); diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 6c1d63d93c..795e8a6d80 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -107,6 +107,7 @@ import { isWorktreeContainerDir, resolveAiMergeRootPath, resolveLegacyAiMergeRoo import { canonicalFusionBranchName, resolveTaskWorkingBranch } from "./worktree/worktree-names.js"; import { preservedWorktreeTargetPathForTask } from "./worktree/worktree-pinning.js"; import { resolveIntegrationBranch } from "./merge/integration-branch.js"; +import { recordWorkspaceBaseBranchDecision, resolveWorkspaceRepoBaseBranch } from "./worktree/workspace-base-branch.js"; import { resolveBranchGroupMergeRouting } from "./merge/group-merge-coordinator.js"; import type { OwnedLandedClassification } from "./merger.js"; import { regenerateBareMergeSubject } from "./merge/merger-bare-subject.js"; @@ -10410,10 +10411,28 @@ const movedTask = await this.store.moveTask(task.id, completeLane); const repoRootDir = join(this.options.rootDir, repoRel); let integrationBranch: string; try { - integrationBranch = await resolveIntegrationBranch( + const baseResolution = await resolveWorkspaceRepoBaseBranch({ + mode: "recorded", repoRootDir, - { ...settings, integrationBranch: undefined, baseBranch: undefined }, - ); + repoRelPath: repoRel, + task, + settings, + recordedBaseBranch: entry.baseBranch, + }); + integrationBranch = baseResolution.branch; + await recordWorkspaceBaseBranchDecision({ + store: this.store, + audit: createRunAuditor(this.store, { + runId: generateSyntheticRunId("workspace-repo-base-branch", task.id), + agentId: "system:self-healing", + phase: "workspace-repo-base-branch", + }), + task, + repoRelPath: repoRel, + repoAbsPath: repoRootDir, + resolution: baseResolution, + stage: "self-heal", + }); } catch { // Cannot resolve the sub-repo's integration branch → treat as retryable (re-enqueue // re-runs the same resolution and surfaces the real error there). @@ -10958,11 +10977,28 @@ const movedTask = await this.store.moveTask(task.id, completeLane); */ let safe = Boolean(task.deletedAt); if (!safe && entry.landedSha) { - const integrationBranch = await resolveIntegrationBranch( + const baseResolution = await resolveWorkspaceRepoBaseBranch({ + mode: "recorded", repoRootDir, - { ...settings, integrationBranch: undefined, baseBranch: undefined }, - ); - safe = await isRepoLanded(repoRootDir, integrationBranch, entry.landedSha, task.id, branch, entry.revertBoundarySha, task.createdAt); + repoRelPath: repoRel, + task, + settings, + recordedBaseBranch: entry.baseBranch, + }); + await recordWorkspaceBaseBranchDecision({ + store: this.store, + audit: createRunAuditor(this.store, { + runId: generateSyntheticRunId("workspace-repo-base-branch", task.id), + agentId: "system:self-healing", + phase: "workspace-repo-base-branch", + }), + task, + repoRelPath: repoRel, + repoAbsPath: repoRootDir, + resolution: baseResolution, + stage: "self-heal", + }); + safe = await isRepoLanded(repoRootDir, baseResolution.branch, entry.landedSha, task.id, branch, entry.revertBoundarySha, task.createdAt); } if (!safe && entry.baseCommitSha) { const count = await this.execWorkspaceTeardownGit(`git rev-list --count ${shellQuote(entry.baseCommitSha)}..${shellQuote(branch)}`, { cwd: repoRootDir, timeout: 120_000 }); diff --git a/packages/engine/src/util/run-audit.ts b/packages/engine/src/util/run-audit.ts index 214c2a5447..cd8d113481 100644 --- a/packages/engine/src/util/run-audit.ts +++ b/packages/engine/src/util/run-audit.ts @@ -129,6 +129,12 @@ export type GitMutationType = // -failed: a sub-repo worktree acquisition threw; surfaced + audited, never swallowed. | "worktree:workspace-repo-acquire-busy" | "worktree:workspace-repo-acquire-failed" + /* + FNXC:Workspace 2026-08-20-00:56: + Per-repo base decisions carry only { taskId, repoRelPath, stage, source, outcome, + fallbackReason? }; operator-supplied ref names are excluded from both metadata and target. + */ + | "worktree:workspace-repo-base-branch" /* FNXC:Workspace 2026-08-15-07:05: Main-checkout guard reports only ids/counts/fixed outcomes. */ | "worktree:workspace-main-checkout-edit" /** diff --git a/packages/engine/src/worktree/workspace-base-branch.ts b/packages/engine/src/worktree/workspace-base-branch.ts new file mode 100644 index 0000000000..636dbe3cb1 --- /dev/null +++ b/packages/engine/src/worktree/workspace-base-branch.ts @@ -0,0 +1,180 @@ +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +import type { RunMutationContext, Settings, Task, TaskStore } from "@fusion/core"; +import type { RunAuditor } from "../util/run-audit.js"; +import { resolveIntegrationBranch } from "../merge/integration-branch.js"; + +const defaultExecAsync = promisify(exec); +type ExecAsyncImpl = typeof defaultExecAsync; + +export type WorkspaceBaseBranchSource = "task-base-branch" | "recorded-base" | "repo-integration" | "legacy-entry"; +export type WorkspaceBaseBranchFallbackReason = "unresolvable-in-repo" | "sibling-task-branch" | "recorded-base-vanished"; +export type WorkspaceBaseBranchStage = "acquire" | "land" | "revert" | "self-heal"; + +export interface WorkspaceRepoBaseBranchResolution { + branch: string; + requested?: string; + source: WorkspaceBaseBranchSource; + fallbackReason?: WorkspaceBaseBranchFallbackReason; +} + +export type ResolveWorkspaceRepoBaseBranchOptions = { + repoRootDir: string; + repoRelPath: string; + settings: Partial; + logger?: Pick; + execImpl?: ExecAsyncImpl; +} & ( + | { mode: "acquire"; task: Pick; recordedBaseBranch?: never } + | { mode: "recorded"; task: Pick; recordedBaseBranch?: string } +); + +function quoteShellArg(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function normalized(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +async function resolveRepoIntegrationBranch( + repoRootDir: string, + settings: Partial, + logger?: Pick, +): Promise { + return resolveIntegrationBranch( + repoRootDir, + { ...settings, integrationBranch: undefined, baseBranch: undefined }, + logger ? { logger } : undefined, + ); +} + +async function refExistsInRepo(repoRootDir: string, ref: string, execImpl: ExecAsyncImpl): Promise { + try { + const { stdout } = await execImpl(`git rev-parse --verify ${quoteShellArg(`${ref}^{commit}`)}`, { + cwd: repoRootDir, + encoding: "utf8", + }); + return Boolean(stdout.trim()); + } catch { + return false; + } +} + +async function resolveRefInRepo(repoRootDir: string, ref: string, execImpl: ExecAsyncImpl): Promise { + if (await refExistsInRepo(repoRootDir, ref, execImpl)) return ref; + + const remoteRef = `origin/${ref}`; + if (!await refExistsInRepo(repoRootDir, remoteRef, execImpl)) return undefined; + + /* + FNXC:Workspace 2026-08-20-01:21: + A remote-tracking-only requested base can fork a worktree but cannot be a land or revert target: + those lifecycle paths require refs/heads/. Materialize the verified remote ref as the + requested local branch once, then record the local name so every later stage targets it safely. + */ + try { + await execImpl(`git branch -- ${quoteShellArg(ref)} ${quoteShellArg(remoteRef)}`, { + cwd: repoRootDir, + encoding: "utf8", + }); + } catch { + // A concurrent creator may have won; only accept the race if the local ref now verifies. + } + return await refExistsInRepo(repoRootDir, ref, execImpl) ? ref : undefined; +} + +/** + * Resolve the only ref a workspace sub-repository may use at a given lifecycle stage. + * + * FNXC:Workspace 2026-08-20-00:56: + * Acquire may inspect task.baseBranch after verifying it in this repository. Recorded mode is + * structurally separate and never reads task.baseBranch, so changing a task after acquisition + * cannot retarget an already-created worktree during land, self-heal, or revert. + */ +export async function resolveWorkspaceRepoBaseBranch( + opts: ResolveWorkspaceRepoBaseBranchOptions, +): Promise { + const execImpl = opts.execImpl ?? defaultExecAsync; + const integrationBranch = () => resolveRepoIntegrationBranch(opts.repoRootDir, opts.settings, opts.logger); + + if (opts.mode === "recorded") { + const requested = normalized(opts.recordedBaseBranch); + if (!requested) { + return { branch: await integrationBranch(), source: "legacy-entry" }; + } + const resolved = await resolveRefInRepo(opts.repoRootDir, requested, execImpl); + if (resolved) return { branch: resolved, requested, source: "recorded-base" }; + return { + branch: await integrationBranch(), + requested, + source: "repo-integration", + fallbackReason: "recorded-base-vanished", + }; + } + + const requested = normalized(opts.task.baseBranch); + if (!requested) return { branch: await integrationBranch(), source: "repo-integration" }; + if (/^fusion\/fn-/i.test(requested)) { + return { + branch: await integrationBranch(), + requested, + source: "repo-integration", + fallbackReason: "sibling-task-branch", + }; + } + + const resolved = await resolveRefInRepo(opts.repoRootDir, requested, execImpl); + if (resolved) return { branch: resolved, requested, source: "task-base-branch" }; + return { + branch: await integrationBranch(), + requested, + source: "repo-integration", + fallbackReason: "unresolvable-in-repo", + }; +} + +export async function recordWorkspaceBaseBranchDecision(opts: { + store: Pick; + audit?: Pick; + task: Pick; + repoRelPath: string; + repoAbsPath: string; + resolution: WorkspaceRepoBaseBranchResolution; + stage: WorkspaceBaseBranchStage; + runContext?: RunMutationContext; +}): Promise { + const { resolution } = opts; + if (!resolution.requested) return; + + const outcome = resolution.source === "task-base-branch" || resolution.source === "recorded-base" + ? "honored" + : "fallback"; + const message = outcome === "honored" + ? `Workspace sub-repo ${opts.repoRelPath} ${opts.stage} uses base branch ${resolution.branch} requested as ${resolution.requested}.` + : `Workspace sub-repo ${opts.repoRelPath} ${opts.stage} could not use requested base branch ${resolution.requested}; using ${resolution.branch}.`; + + // Decision breadcrumbs are observability only; failure must not change git lifecycle semantics. + try { + await opts.store.logEntry(opts.task.id, message, undefined, opts.runContext); + } catch { + // Best effort, matching workspace acquisition's safeObserve contract. + } + try { + await opts.audit?.git({ + type: "worktree:workspace-repo-base-branch", + target: opts.repoAbsPath, + metadata: { + taskId: opts.task.id, + repoRelPath: opts.repoRelPath, + stage: opts.stage, + source: resolution.source, + outcome, + ...(resolution.fallbackReason ? { fallbackReason: resolution.fallbackReason } : {}), + }, + }); + } catch { + // Refs are deliberately absent from audit metadata; do not escalate an audit failure. + } +} diff --git a/packages/engine/src/worktree/worktree-acquisition.ts b/packages/engine/src/worktree/worktree-acquisition.ts index 1a916ea5bc..8df1ce4fed 100644 --- a/packages/engine/src/worktree/worktree-acquisition.ts +++ b/packages/engine/src/worktree/worktree-acquisition.ts @@ -45,6 +45,7 @@ import { installTaskWorktreeIdentityGuard } from "./worktree-hooks.js"; import { copyConfiguredWorktreeFiles, type WorktreeCopyFileResult } from "./worktree-copy-files.js"; import { resolveCapturedBaseCommitSha } from "../execution/base-commit-capture.js"; import { resolveIntegrationBranch } from "../merge/integration-branch.js"; +import { recordWorkspaceBaseBranchDecision, resolveWorkspaceRepoBaseBranch } from "./workspace-base-branch.js"; import { activeSessionRegistry, type ActiveSessionRegistry } from "../agents/active-session-registry.js"; import { refreshReusedWorktreeBase, type WorktreeBaseRefreshResult } from "../worktree-base-refresh.js"; @@ -1441,8 +1442,22 @@ export async function acquireWorkspaceRepoWorktree( live task through means a later repo can reuse the first repo's worktree. Clear singular fields on the copy so every sub-repo acquires freshly; per-repo state is `task.workspaceWorktrees`. */ + /* + FNXC:Workspace 2026-08-20-00:56: + executionStartBranch belongs to the root-repository dependency chain and can be a fusion/fn-* + sibling absent from this sub-repo. Resolve task.baseBranch per repo instead, then overwrite the + copied task's start point so acquireTaskWorktree never forwards that sibling ref to git worktree add. + */ + const baseResolution = await resolveWorkspaceRepoBaseBranch({ + mode: "acquire", + repoRootDir: repoAbsPath, + repoRelPath, + task, + settings, + logger, + }); const result = await acquireTaskWorktree({ - task: { ...task, worktree: undefined, branch: undefined }, + task: { ...task, worktree: undefined, branch: undefined, executionStartBranch: baseResolution.branch }, suppressSingularWorktreePersist: true, rootDir: repoAbsPath, store, @@ -1451,9 +1466,8 @@ export async function acquireWorkspaceRepoWorktree( // when no executionStartBranch is present, so new branches never inherit an ambient root HEAD. // For a workspace sub-repo, `settings` carries the SHARED project integrationBranch/baseBranch; // honoring it resolves a branch absent from this sub-repo and fails `git worktree add` with - // "invalid reference". Strip both overrides here so freshStartPoint falls through to this - // sub-repo's own origin/HEAD — matching the per-repo base-SHA capture below, which already - // resolves against stripped settings (F4/KTD3). + // "invalid reference". Keep both overrides stripped: the per-repository resolver above + // has already verified this repo's base and supplies it as executionStartBranch. settings: { ...settings, integrationBranch: undefined, baseBranch: undefined }, logger, secretsStore, @@ -1518,23 +1532,13 @@ export async function acquireWorkspaceRepoWorktree( /* FNXC:Workspace 2026-06-21-20:10: - Per-repo base SHA (KTD3): resolve THIS sub-repo's integration branch with the - shared settings.integrationBranch AND settings.baseBranch overrides STRIPPED. - resolveFromSettings (integration-branch.ts) falls back integrationBranch → - baseBranch → origin/HEAD, so leaving either set means every sub-repo resolves to - the shared workspace branch — defeating per-repo resolution (F4). With both - undefined, each sub-repo falls through to its own origin/HEAD. Capture the base - local-first against that branch so local-ahead-of-origin integration tips don't - inflate the per-repo diff (FN-5937 invariant, per sub-repo). + Per-repo base SHA (KTD3): capture against the same verified per-repository base used to + create this worktree. This keeps the fork point and contamination anchor aligned while the + resolver retains the FN-7360 fallback to this repo's own integration branch. */ let baseCommitSha: string | undefined; try { - const integrationBranch = await resolveIntegrationBranch( - repoAbsPath, - { ...settings, integrationBranch: undefined, baseBranch: undefined }, - { logger }, - ); - baseCommitSha = await resolveCapturedBaseCommitSha(result.worktreePath, logger, integrationBranch); + baseCommitSha = await resolveCapturedBaseCommitSha(result.worktreePath, logger, baseResolution.branch); } catch (baseErr) { // FNXC:Workspace 2026-06-21-22:30: F3 — base-SHA capture is non-fatal; an undefined baseCommitSha is an accepted state. // FNXC:Workspace 2026-06-22-00:00: guard the best-effort logEntry/audit so a logging throw cannot promote this @@ -1571,9 +1575,29 @@ export async function acquireWorkspaceRepoWorktree( await store.mergeWorkspaceWorktreeEntry( task.id, repoRelPath, - { worktreePath: result.worktreePath, branch: result.branch, baseCommitSha }, + { + worktreePath: result.worktreePath, + branch: result.branch, + baseCommitSha, + ...(baseResolution.requested + ? { + baseBranch: baseResolution.branch, + ...(baseResolution.fallbackReason ? { baseBranchFallbackFrom: baseResolution.requested } : {}), + } + : {}), + }, { clearSingularWorktree: true }, ); + await recordWorkspaceBaseBranchDecision({ + store, + audit, + task, + repoRelPath, + repoAbsPath, + resolution: baseResolution, + stage: "acquire", + runContext, + }); return { worktreePath: result.worktreePath, branch: result.branch, baseCommitSha, alreadyAcquired: false }; } catch (err) {