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:
7
.changeset/fn-9164-workspace-base-branch.md
Normal file
7
.changeset/fn-9164-workspace-base-branch.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
93
packages/engine/src/__tests__/workspace-base-branch.test.ts
Normal file
93
packages/engine/src/__tests__/workspace-base-branch.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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"
|
||||
/**
|
||||
|
||||
180
packages/engine/src/worktree/workspace-base-branch.ts
Normal file
180
packages/engine/src/worktree/workspace-base-branch.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user