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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-19 18:57:33 -07:00
parent 179f08c2d4
commit ef35fb8e5a
18 changed files with 548 additions and 44 deletions

View File

@@ -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.

View File

@@ -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/<id>` 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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;

View File

@@ -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);

View File

@@ -61,6 +61,8 @@ export function WorkspaceWorktreesSummary({ task, compact = false }: WorkspaceWo
{landedSha && <span className="workspace-worktrees-sha">{landedSha.slice(0, 8)}</span>}
<span className="workspace-worktrees-path" title={entry.worktreePath}>{entry.worktreePath}</span>
<span className="workspace-worktrees-branch" title={entry.branch}>{entry.branch}</span>
{entry.baseBranch && <span className="workspace-worktrees-base" data-testid="workspace-repo-base-branch" title={t("tasks.workspaceRepoBaseBranch", "Base branch for {{repo}}", { repo: repoRelPath })}>{t("tasks.workspaceRepoBase", "Base: {{branch}}", { branch: entry.baseBranch })}</span>}
{entry.baseBranchFallbackFrom && <span className="workspace-worktrees-base-fallback" data-testid="workspace-repo-base-fallback" title={t("tasks.workspaceRepoBaseFallbackTitle", "{{requested}} was unavailable in {{repo}}; using {{resolved}}", { requested: entry.baseBranchFallbackFrom, repo: repoRelPath, resolved: entry.baseBranch ?? entry.branch })}>{t("tasks.workspaceRepoBaseFallback", "Base fallback")}</span>}
{failureMessage && <span className="workspace-worktrees-failure-message">{failureMessage}</span>}
</li>)}
</ul>

View File

@@ -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(<WorkspaceWorktreesSummary task={{ worktree: undefined, workspaceWorktrees: {
"repo-a": { worktreePath: "/wt/repo-a", branch: "fusion/fn-1-a", baseBranch: "release/1.2" },
"repo-b": { worktreePath: "/wt/repo-b", branch: "fusion/fn-1-b", baseBranch: "main", baseBranchFallbackFrom: "release/1.2" },
"repo-legacy": { worktreePath: "/wt/legacy", branch: "fusion/fn-1-legacy" },
} }} />);
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(<WorkspaceWorktreesSummary task={workspaceTask} compact />);
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", () => {

View File

@@ -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) {

View File

@@ -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<string, unknown> }> = [];
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<string, unknown> }); } },
})).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");

View File

@@ -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<Task, "id" | "baseBranch">);
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<string, unknown> }> = [];
const logs: string[] = [];
await recordWorkspaceBaseBranchDecision({
store: { logEntry: async (_id: string, message: string) => { logs.push(message); } } as Pick<TaskStore, "logEntry">,
audit: { git: async (event: never) => { events.push(event as unknown as { target: string; metadata: Record<string, unknown> }); } },
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");
});
});

View File

@@ -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");

View File

@@ -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<RunAuditor, "git">;
}
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<Settings>,
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<RunAuditor, "git">;
}
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<Settings>,
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);
}

View File

@@ -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}`);

View File

@@ -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 });

View File

@@ -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"
/**

View File

@@ -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<Settings>;
logger?: Pick<Console, "warn">;
execImpl?: ExecAsyncImpl;
} & (
| { mode: "acquire"; task: Pick<Task, "baseBranch">; recordedBaseBranch?: never }
| { mode: "recorded"; task: Pick<Task, never>; 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<Settings>,
logger?: Pick<Console, "warn">,
): Promise<string> {
return resolveIntegrationBranch(
repoRootDir,
{ ...settings, integrationBranch: undefined, baseBranch: undefined },
logger ? { logger } : undefined,
);
}
async function refExistsInRepo(repoRootDir: string, ref: string, execImpl: ExecAsyncImpl): Promise<boolean> {
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<string | undefined> {
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/<branch>. 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<WorkspaceRepoBaseBranchResolution> {
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<TaskStore, "logEntry">;
audit?: Pick<RunAuditor, "git">;
task: Pick<Task, "id">;
repoRelPath: string;
repoAbsPath: string;
resolution: WorkspaceRepoBaseBranchResolution;
stage: WorkspaceBaseBranchStage;
runContext?: RunMutationContext;
}): Promise<void> {
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.
}
}

View File

@@ -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) {