feat(workspace): Phase C U2 — per-repo landed predicate, finalize-once, auto-retry-then-park

landWorkspaceTask now tracks per-repo landing and finalizes the task exactly once.
After a repo lands, its advanced integration tip is persisted as
workspaceWorktrees[repo].landedSha (fresh-read merge, siblings untouched). Before
landing, isRepoLanded skips a repo iff its landedSha is present AND an ancestor of
(or equal to) its local integration ref — so a retry after a partial land never
re-advances an already-landed ref. finalizeWorkspaceTask runs only when every
acquired repo is landed: it builds an aggregate MergeResult (representative
commitSha + a workspaceLandedShas map in MergeDetails) and calls the existing
task-global finalizeTask once, satisfying the task:merged consumer. No premature
done on the first repo.

Partial lands surface as WorkspacePartialLandError; the engine consumes a
mergeRetry and re-enqueues landWorkspaceTask (skipping landed repos) with the
existing conflict-retry backoff up to MAX, then operator-parks (status:failed) —
mirroring shouldRetryAutoMergeConflict (new exported shouldRetryWorkspacePartialLand
seam). The defense-in-depth WorkspaceTaskMergeError still hard-fails without
burning retries; manual merges fall through to rejectMergeResolvers.

types: workspaceWorktrees entry gains landedSha?; MergeDetails gains
workspaceLandedShas?. 6 new idempotency/predicate/finalize-once/retry-park tests;
oracle (52) + U1 (5) stay green. Gate: build, typecheck, lint, test:gate (649+58).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-21 23:46:44 -07:00
parent 744ed098a5
commit 7544346320
6 changed files with 650 additions and 26 deletions

View File

@@ -0,0 +1,15 @@
---
"@runfusion/fusion": minor
---
Workspace mode Phase C (U2): per-repo landed predicate, finalize-once, and idempotent
auto-retry-then-park. `landWorkspaceTask` now records each sub-repo's `landedSha` after
its branch advances that repo's local integration ref, and on a re-run SKIPS any repo
whose recorded `landedSha` is an ancestor of (or equals) its current integration tip — so
an interrupted multi-repo land retries only the un-landed repos and never re-advances an
already-landed ref. When every acquired repo's landed predicate holds, the task moves to
`done` EXACTLY ONCE via the task-global finalize path with an aggregate `mergeDetails`
(representative `commitSha` + a `workspaceLandedShas` map). A partial land (some repos
unlanded) does not move the task done; the engine merge dispatch surfaces it as a
retryable failure that consumes a `mergeRetry` and auto-retries the merge (skipping landed
repos) up to the configured max, then operator-parks the task as failed.

View File

@@ -1845,6 +1845,17 @@ export interface MergeDetails {
* `task.mergeRetries`, which counts in-cycle aiMergeTask retries.
*/
transientRecoveryCount?: number;
/**
* FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3):
* Workspace-mode aggregate landed map: sub-repo relative path → the squash sha
* that landed on that repo's local integration ref. Set ONLY by
* `landWorkspaceTask`'s finalize-once after EVERY acquired repo's landed
* predicate holds; the task-level `commitSha` points at one representative
* landed sha (the first sorted landed repo) so the existing `task:merged`
* consumer (which reads `mergeDetails.commitSha`) is satisfied. Empty/absent
* for single-repo tasks.
*/
workspaceLandedShas?: Record<string, string>;
}
/** Represents an agent's checkout lease on a task. */
@@ -2252,8 +2263,17 @@ export interface Task {
* against that sub-repo's RESOLVED integration branch, local-first. It is the
* per-repo analogue of the single-repo base-commit capture and prevents
* cross-repo files-changed inflation when local integration is ahead of origin.
*
* FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3):
* `landedSha` is the per-repo "this repo's branch has landed on its local
* integration ref" marker, set by `landWorkspaceTask` after a sub-repo's squash
* advances that repo's ref. It is the ONLY partial-land state added (no new
* status type): a re-run's landed predicate skips a repo whose `landedSha` is
* present AND whose recorded value is an ancestor of (or equals) the repo's
* integration tip, so an interrupted multi-repo land retries only the un-landed
* repos and never re-advances an already-landed ref (idempotent retry).
*/
workspaceWorktrees?: Record<string, { worktreePath: string; branch: string; baseCommitSha?: string }>;
workspaceWorktrees?: Record<string, { worktreePath: string; branch: string; baseCommitSha?: string; landedSha?: string }>;
steps: TaskStep[];
currentStep: number;
/**

View File

@@ -0,0 +1,353 @@
/*
FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3):
Per-repo landed-predicate + finalize-once + idempotent-retry tests. They drive the REAL
`landWorkspaceTask` against a REAL two-repo git fixture (createWorkspaceFixture) under a
NON-git workspace root, asserting LOCAL integration-ref shas directly (FN-5048: real git
only where the invariant requires it; the AI merge/review agents are injected so NO real
AI calls happen and the squash is a plain `git merge --squash`). The retry/park decision
is tested via the engine's narrow exported seam `shouldRetryWorkspacePartialLand` with
fake timers — NOT by spinning real engine retries.
Coverage (FN-5893 surfaces):
- idempotency: re-run after repo A landed + repo B failed → A is SKIPPED (its integration
ref does NOT advance a second time — assert the ref sha is unchanged), B is retried.
- predicate: landed predicate true when branch tip is an ancestor of integration tip;
false otherwise (ref rebuilt / no landedSha).
- no premature done: finalizeTask/move-done runs EXACTLY ONCE, only after BOTH repos land
— assert the task is NOT moved done after the first repo (partial run).
- completion: all repos landed → task reaches done with aggregate mergeDetails
(workspaceLandedShas map + representative commitSha).
- retry/park: a partial-land failure consumes one mergeRetry; after MAX it parks
(shouldRetryWorkspacePartialLand boundary, fake timers).
*/
import { afterEach, beforeEach, afterAll, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import { execSync } from "node:child_process";
import { writeFileSync } from "node:fs";
import path from "node:path";
import type { Task, TaskStore } from "@fusion/core";
import { landWorkspaceTask } from "../merger-ai.js";
import { shouldRetryWorkspacePartialLand } from "../project-engine.js";
import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js";
const describeIfGit = hasGit ? describe : describe.skip;
const TASK_ID = "FN-2002";
const BRANCH = "fusion/fn-2002";
function configureIdentity(dir: string): void {
execSync('git config user.email "test@example.com"', { cwd: dir, stdio: "pipe" });
execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" });
}
interface RecordingStore extends EventEmitter {
task: Task;
moveTaskCalls: Array<{ id: string; column: string }>;
emitted: Array<{ event: string; payload: unknown }>;
}
/**
* A store that PERSISTS workspaceWorktrees + mergeDetails updates on a single in-memory
* task and returns it from getTask, so the landed-predicate retry reads back the
* `landedSha` that landWorkspaceTask wrote (real fresh-read-then-merge behavior).
*/
function createStore(task: Task, settings: Record<string, unknown> = {}): TaskStore & RecordingStore {
const emitter = new EventEmitter();
const moveTaskCalls: Array<{ id: string; column: string }> = [];
const emitted: Array<{ event: string; payload: unknown }> = [];
const realEmit = emitter.emit.bind(emitter);
const store = Object.assign(emitter, {
task,
moveTaskCalls,
emitted,
getSettings: vi.fn().mockResolvedValue({ autoMerge: false, ...settings }),
updateTask: vi.fn(async (_id: string, patch: Partial<Task>) => {
Object.assign(store.task, patch);
return undefined;
}),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
getTask: vi.fn(async () => store.task),
moveTask: vi.fn((id: string, column: string) => {
moveTaskCalls.push({ id, column });
store.task.column = column as Task["column"];
return Promise.resolve(store.task);
}),
upsertTaskCommitAssociation: vi.fn().mockResolvedValue(undefined),
accumulateTokenUsage: vi.fn().mockResolvedValue(undefined),
emit: (event: string, payload?: unknown) => {
emitted.push({ event, payload });
return realEmit(event, payload);
},
}) as unknown as TaskStore & RecordingStore;
return store;
}
/** Add a real `fusion/<id>` branch to a sub-repo with one own non-conflicting commit. */
function addRepoBranchWithEdit(fx: WorkspaceFixture, repoRel: string, content: string): void {
const repoDir = fx.repoPath(repoRel);
const worktreePath = path.join(repoDir, ".wt-branch");
fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`);
configureIdentity(worktreePath);
writeFileSync(path.join(worktreePath, "feature.txt"), content, "utf-8");
execSync("git add feature.txt", { cwd: worktreePath, stdio: "pipe" });
execSync(`git commit -m "feat(${TASK_ID}): add feature in ${repoRel}"`, { cwd: worktreePath, stdio: "pipe" });
fx.git(repoRel, `git worktree remove --force ${worktreePath}`);
}
/** Make a sub-repo's integration tip + task branch BOTH edit README → squash conflicts. */
function makeConflictingRepo(fx: WorkspaceFixture, repoRel: string): void {
const repoDir = fx.repoPath(repoRel);
const worktreePath = path.join(repoDir, ".wt-conflict");
fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`);
configureIdentity(worktreePath);
writeFileSync(path.join(worktreePath, "README.md"), "# branch-side change\n", "utf-8");
execSync("git add README.md", { cwd: worktreePath, stdio: "pipe" });
execSync(`git commit -m "feat(${TASK_ID}): branch README"`, { cwd: worktreePath, stdio: "pipe" });
fx.git(repoRel, `git worktree remove --force ${worktreePath}`);
writeFileSync(path.join(repoDir, "README.md"), "# main-side change\n", "utf-8");
fx.git(repoRel, "git add README.md");
fx.git(repoRel, 'git commit -m "main diverge README"');
}
/** Resolve repo-b's conflict by replacing the conflicting README content (no markers). */
function resolveConflictInRepo(fx: WorkspaceFixture, repoRel: string): void {
// Re-point the task branch so the squash no longer conflicts: drop the branch's
// README edit and add a clean feature file instead.
const repoDir = fx.repoPath(repoRel);
fx.git(repoRel, `git branch -D ${BRANCH}`);
const worktreePath = path.join(repoDir, ".wt-resolved");
fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`);
configureIdentity(worktreePath);
writeFileSync(path.join(worktreePath, "feature.txt"), "resolved feature\n", "utf-8");
execSync("git add feature.txt", { cwd: worktreePath, stdio: "pipe" });
execSync(`git commit -m "feat(${TASK_ID}): resolved"`, { cwd: worktreePath, stdio: "pipe" });
fx.git(repoRel, `git worktree remove --force ${worktreePath}`);
}
/** A merge agent that performs the real squash in the clean room (no AI). */
function squashMergeAgent(branch: string) {
return async (cwd: string): Promise<void> => {
configureIdentity(cwd);
try {
execSync(`git merge --squash ${branch}`, { cwd, stdio: "pipe" });
} catch {
// squash reported conflicts — fall through to the unmerged check.
}
const unmerged = execSync("git ls-files -u", { cwd, encoding: "utf-8" }).trim();
if (unmerged.length > 0) {
throw new Error("merge conflict: unresolved paths in clean room");
}
const staged = execSync("git diff --cached --name-only", { cwd, encoding: "utf-8" }).trim();
if (staged.length === 0) return;
execSync(`git commit -m "${branch}: squashed"`, { cwd, stdio: "pipe" });
};
}
const approveReviewAgent = async (): Promise<string> => "REVIEW_VERDICT: approve";
function makeTask(workspaceWorktrees: Task["workspaceWorktrees"]): Task {
return {
id: TASK_ID,
title: "Workspace merge task",
description: "",
column: "in-review",
branch: BRANCH,
dependencies: [],
steps: [],
currentStep: 0,
log: [],
workspaceWorktrees,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as Task;
}
describeIfGit("landWorkspaceTask — landed predicate + finalize-once + idempotent retry (Phase C U2)", () => {
let fx: WorkspaceFixture;
afterEach(() => fx?.cleanup());
it("idempotency: re-run after A landed + B failed skips A (ref unchanged) and retries B", async () => {
fx = await createWorkspaceFixture(["repo-a", "repo-b"]);
addRepoBranchWithEdit(fx, "repo-a", "a feature\n");
makeConflictingRepo(fx, "repo-b");
const task = makeTask({
"repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH },
"repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH },
});
const store = createStore(task);
// First run: A lands, B conflicts → partial.
const first = await landWorkspaceTask(store, store.task, fx.rootDir, {}, {
mergeAgent: squashMergeAgent(BRANCH),
reviewAgent: approveReviewAgent,
});
expect(first.allLanded).toBe(false);
expect(first.finalized).toBe(false);
const tipAAfterFirst = fx.git("repo-a", "git rev-parse refs/heads/main");
// A's landedSha was persisted onto the task entry.
expect(store.task.workspaceWorktrees!["repo-a"].landedSha).toBe(tipAAfterFirst);
// Not moved done on a partial land.
expect(store.moveTaskCalls).toHaveLength(0);
// Operator resolves repo B's conflict, then the merge is re-run (auto-retry).
resolveConflictInRepo(fx, "repo-b");
const second = await landWorkspaceTask(store, store.task, fx.rootDir, {}, {
mergeAgent: squashMergeAgent(BRANCH),
reviewAgent: approveReviewAgent,
});
// A was SKIPPED (already landed): its integration ref did NOT advance a second time.
expect(fx.git("repo-a", "git rev-parse refs/heads/main")).toBe(tipAAfterFirst);
const repoA = second.repos.find((r) => r.repo === "repo-a")!;
expect(repoA.alreadyLanded).toBe(true);
expect(repoA.status).toBe("landed");
// B was retried and landed this time.
const repoB = second.repos.find((r) => r.repo === "repo-b")!;
expect(repoB.status).toBe("landed");
expect(repoB.alreadyLanded).toBeFalsy();
expect(second.allLanded).toBe(true);
// Finalize-once ran on the completing run.
expect(second.finalized).toBe(true);
expect(store.moveTaskCalls).toEqual([{ id: TASK_ID, column: "done" }]);
});
it("predicate: landedSha that is an ancestor of the integration tip reads as landed; a non-ancestor does not", async () => {
fx = await createWorkspaceFixture(["repo-a"]);
addRepoBranchWithEdit(fx, "repo-a", "a feature\n");
const task = makeTask({ "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH } });
const store = createStore(task);
// Land repo-a once.
const first = await landWorkspaceTask(store, store.task, fx.rootDir, {}, {
mergeAgent: squashMergeAgent(BRANCH),
reviewAgent: approveReviewAgent,
});
expect(first.allLanded).toBe(true);
const landedSha = store.task.workspaceWorktrees!["repo-a"].landedSha!;
const tip = fx.git("repo-a", "git rev-parse refs/heads/main");
// landedSha == tip → ancestor-or-equal → landed. Advance main with an UNRELATED
// commit; the landedSha is still an ancestor, so it must STILL read as landed.
writeFileSync(path.join(fx.repoPath("repo-a"), "unrelated.txt"), "x\n", "utf-8");
fx.git("repo-a", "git add unrelated.txt");
fx.git("repo-a", 'git commit -m "unrelated advance"');
expect(fx.git("repo-a", "git merge-base --is-ancestor " + landedSha + " refs/heads/main && echo yes").trim()).toBe("yes");
// Re-run: predicate true (ancestor) → repo skipped, no re-land.
const tipBeforeRerun = fx.git("repo-a", "git rev-parse refs/heads/main");
const second = await landWorkspaceTask(store, store.task, fx.rootDir, {}, {
mergeAgent: squashMergeAgent(BRANCH),
reviewAgent: approveReviewAgent,
});
expect(second.repos[0].alreadyLanded).toBe(true);
expect(fx.git("repo-a", "git rev-parse refs/heads/main")).toBe(tipBeforeRerun);
// Non-ancestor: reset main to before the landedSha → landedSha no longer reachable →
// predicate false → the repo re-lands.
void tip;
fx.git("repo-a", "git reset --hard HEAD~2"); // before the squash + unrelated commit
const tipReset = fx.git("repo-a", "git rev-parse refs/heads/main");
expect(fx.git("repo-a", `git merge-base --is-ancestor ${landedSha} refs/heads/main || echo no`).trim()).toBe("no");
const third = await landWorkspaceTask(store, store.task, fx.rootDir, {}, {
mergeAgent: squashMergeAgent(BRANCH),
reviewAgent: approveReviewAgent,
});
expect(third.repos[0].alreadyLanded).toBeFalsy();
expect(third.repos[0].status).toBe("landed");
expect(fx.git("repo-a", "git rev-parse refs/heads/main")).not.toBe(tipReset);
});
it("no premature done: a partial run (one repo failed) does NOT move the task done", async () => {
fx = await createWorkspaceFixture(["repo-a", "repo-b"]);
addRepoBranchWithEdit(fx, "repo-a", "a feature\n");
makeConflictingRepo(fx, "repo-b");
const task = makeTask({
"repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH },
"repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH },
});
const store = createStore(task);
const result = await landWorkspaceTask(store, store.task, fx.rootDir, {}, {
mergeAgent: squashMergeAgent(BRANCH),
reviewAgent: approveReviewAgent,
});
// repo-a landed first, but the task must NOT be done because repo-b failed.
expect(result.repos.find((r) => r.repo === "repo-a")!.status).toBe("landed");
expect(result.finalized).toBe(false);
expect(store.moveTaskCalls).toHaveLength(0);
expect(store.emitted.some((e) => e.event === "task:merged")).toBe(false);
});
it("completion: all repos landed → task moves done ONCE with aggregate mergeDetails", async () => {
fx = await createWorkspaceFixture(["repo-a", "repo-b"]);
addRepoBranchWithEdit(fx, "repo-a", "a feature\n");
addRepoBranchWithEdit(fx, "repo-b", "b feature\n");
const task = makeTask({
"repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH },
"repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH },
});
const store = createStore(task);
const result = await landWorkspaceTask(store, store.task, fx.rootDir, {}, {
mergeAgent: squashMergeAgent(BRANCH),
reviewAgent: approveReviewAgent,
});
expect(result.allLanded).toBe(true);
expect(result.finalized).toBe(true);
// Moved done exactly once and emitted task:merged exactly once.
expect(store.moveTaskCalls).toEqual([{ id: TASK_ID, column: "done" }]);
const mergedEvents = store.emitted.filter((e) => e.event === "task:merged");
expect(mergedEvents).toHaveLength(1);
// Aggregate mergeDetails: a representative commitSha + the per-repo landed map.
const md = store.task.mergeDetails!;
expect(md.mergeConfirmed).toBe(true);
const landedShaA = fx.git("repo-a", "git rev-parse refs/heads/main");
const landedShaB = fx.git("repo-b", "git rev-parse refs/heads/main");
expect(md.workspaceLandedShas).toEqual({ "repo-a": landedShaA, "repo-b": landedShaB });
// commitSha is one of the landed repo shas (representative for the task:merged consumer).
expect([landedShaA, landedShaB]).toContain(md.commitSha);
});
});
describe("workspace partial-land retry/park decision (engine seam, fake timers)", () => {
beforeEach(() => vi.useFakeTimers());
afterAll(() => vi.useRealTimers());
it("consumes a mergeRetry up to MAX, then parks (shouldRetryWorkspacePartialLand)", () => {
// Default MAX = 3. currentRetries + 1 < MAX gates retry.
expect(shouldRetryWorkspacePartialLand(0, {})).toMatchObject({
shouldRetry: true,
maxAutoMergeRetries: 3,
nextRetryCount: 1,
});
expect(shouldRetryWorkspacePartialLand(1, {})).toMatchObject({
shouldRetry: true,
maxAutoMergeRetries: 3,
nextRetryCount: 2,
});
// Last attempt: currentRetries + 1 === MAX → park (no further retry).
expect(shouldRetryWorkspacePartialLand(2, {})).toMatchObject({
shouldRetry: false,
maxAutoMergeRetries: 3,
nextRetryCount: 3,
});
// Custom cap honored.
expect(shouldRetryWorkspacePartialLand(3, { maxAutoMergeRetries: 5 }).shouldRetry).toBe(true);
expect(shouldRetryWorkspacePartialLand(4, { maxAutoMergeRetries: 5 }).shouldRetry).toBe(false);
});
it("fake-timer backoff schedule does not spin real retries", () => {
// The dispatch schedules internalEnqueueMerge via setTimeout(5000 * 2^retries).
// Assert a scheduled callback exists and only fires when advanced — no real wait.
const fired: number[] = [];
setTimeout(() => fired.push(1), 5000);
expect(fired).toHaveLength(0);
vi.advanceTimersByTime(5000);
expect(fired).toHaveLength(1);
});
});

View File

@@ -10,11 +10,14 @@ injected (deps) so NO real AI calls happen and the squash is produced by a plain
Coverage (FN-5893 surfaces):
- happy: two acquired repos both clean → BOTH local integration refs advance against
each repo's own resolved branch; NO remote ref/push happened; result tags both.
each repo's own resolved branch; NO remote ref/push happened; result tags both. Since
Phase C U2, a fully-landed workspace task also finalizes ONCE (moves done, emits
task:merged) — asserted here; the landed-predicate/finalize-once/retry mechanics have
dedicated coverage in workspace-merger-idempotency.test.ts.
- per-repo resolution: repos with DIFFERENT origin/HEAD integration branches → each
lands on its own (override-stripping works, not a shared branch).
- partial: a conflict in repo B → repo A lands (landedSha recorded); B reports the
failure; the task is NOT moved done (no finalizeTask call).
failure; the task is NOT moved done (no finalizeTask call) — the partial-land retry is U2.
- defense-in-depth: store.mergeTask / aiMergeTask with a workspace task → still throw
WorkspaceTaskMergeError.
The single-repo runAiMerge regression lives in the existing merger-ai*.test.ts (the
@@ -187,9 +190,9 @@ describeIfGit("landWorkspaceTask — per-repo merge loop (Phase C U1)", () => {
expect(remoteRefs).toBe("");
}
// U1 does NOT move the task to done.
expect(store.moveTaskCalls).toHaveLength(0);
expect(store.emitted.some((e) => e.event === "task:merged")).toBe(false);
// U2 finalize-once: every repo landed → the task moves to done exactly once.
expect(store.moveTaskCalls).toEqual([{ id: TASK_ID, column: "done" }]);
expect(store.emitted.filter((e) => e.event === "task:merged")).toHaveLength(1);
});
it("per-repo resolution: each repo lands on its OWN origin/HEAD branch (override-stripping)", async () => {

View File

@@ -1341,6 +1341,13 @@ export interface WorkspaceRepoLandResult {
localSync?: LocalSyncOutcome;
/** Failure message when `status === "failed"`. */
error?: string;
/**
* FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3):
* True when this repo was SKIPPED by the landed predicate on a retry (its recorded
* `landedSha` is already an ancestor of the integration tip) — its ref was NOT
* re-advanced this run.
*/
alreadyLanded?: boolean;
}
/** Aggregated result of a workspace task's per-repo merge loop. */
@@ -1349,6 +1356,12 @@ export interface WorkspaceMergeResult {
repos: WorkspaceRepoLandResult[];
/** True iff every acquired sub-repo landed (or was empty) with no failure. */
allLanded: boolean;
/**
* FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3):
* True iff the finalize-once move-to-done ran this call (only when `allLanded`).
* False on a partial land (the task stays put for the engine dispatch's auto-retry).
*/
finalized: boolean;
}
/*
@@ -1366,10 +1379,30 @@ undefined — so each sub-repo falls through to its own origin/HEAD rather than
workspace branch.
U1 scope: on a repo failure we stop the loop and return a PARTIAL result (repo A may
have landed; B reports the failure). The landed-state predicate + idempotent retry and
the finalize-task-ONCE move-to-done are U2 — `landWorkspaceTask` here deliberately does
NOT call finalizeMerged/finalizeTask or move the task. Routing the engine + CLI doors
to this loop is KTD2.
have landed; B reports the failure). Routing the engine + CLI doors to this loop is KTD2.
FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3):
U2 adds per-repo landed tracking + finalize-once + idempotent retry on top of U1's loop:
- Landed predicate + skip: before landing a repo, we skip it iff its `landedSha` is
recorded AND that sha is an ancestor of (or equals) the repo's CURRENT integration
tip. A skipped repo's ref is NEVER re-advanced, so re-running `landWorkspaceTask`
after a partial land (A landed, B failed) re-attempts ONLY B — A is idempotent.
- landedSha persistence: after a repo lands, we record `workspaceWorktrees[repo].landedSha`
= the advanced integration tip via a FRESH-read-then-merge `store.updateTask` (re-read
the latest task and merge only this repo's entry, so concurrent sibling-entry writes
are not clobbered — the Phase A/B per-repo persistence pattern).
- finalize-once: the task moves to `done` EXACTLY ONCE, only after EVERY acquired repo's
landed predicate holds (all landed/empty, none failed). We reuse the task-global
`finalizeTask` move-done path with an AGGREGATE mergeDetails (representative
`commitSha` = first sorted landed repo + a `workspaceLandedShas` map) so the existing
`task:merged` consumer is satisfied. On a partial land we do NOT move done — we return
`allLanded:false` with the landed repos' `landedSha` already persisted.
The partial-land retry/park policy (consume a mergeRetry, auto-retry skipping landed
repos up to MAX, then operator-park) is wired at the engine dispatch (project-engine.ts),
NOT here: this function reports the partial via `allLanded:false` and the dispatch drives
the retry seam.
*/
export async function landWorkspaceTask(
store: TaskStore,
@@ -1430,6 +1463,19 @@ export async function landWorkspaceTask(
break;
}
// U2 landed predicate + skip (KTD3): a repo whose recorded `landedSha` is an
// ancestor of (or equals) its CURRENT integration tip is already landed — SKIP
// it so a retry never re-advances the ref. This makes a re-run after a partial
// land idempotent for the already-landed repos.
if (await isRepoLanded(repoRootDir, integrationBranch, entry.landedSha)) {
await log(`AI merge (workspace): sub-repo ${repoRel} already landed (${short(entry.landedSha!)} ⊑ ${integrationBranch}) — skipping`);
repos.push({
repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch,
status: "landed", landedSha: entry.landedSha, alreadyLanded: true,
});
continue;
}
try {
const landResult = await landOneRepo(store, repoRootDir, entry.branch, integrationBranch, {
taskId, settings, audit, log, setStatus, maxPasses,
@@ -1438,6 +1484,10 @@ export async function landWorkspaceTask(
allowDirtyLocalCheckoutSync: options.allowDirtyLocalCheckoutSync === true,
});
if (landResult.outcome === "landed") {
// Persist this repo's landedSha BEFORE moving on (fresh-read-then-merge so
// sibling entries written by a concurrent path are not clobbered). The retry
// predicate above reads this back to skip the repo on a re-run.
await persistRepoLandedSha(store, taskId, repoRel, landResult.squashSha);
repos.push({
repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch,
status: "landed", landedSha: landResult.squashSha, localSync: landResult.localSync,
@@ -1451,18 +1501,114 @@ export async function landWorkspaceTask(
await audit.git({ type: "merge:ai-no-branch", target: entry.branch, metadata: { taskId, kind: "workspace-repo-land-failed", repo: repoRel, error: message } }).catch(() => undefined);
repos.push({ repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, status: "failed", error: message });
allLanded = false;
// U1: stop on first failure and return a partial result. U2 adds the landed
// predicate + idempotent retry so a re-run skips the already-landed repos.
// Stop on first failure and return a partial result. The already-landed repos'
// `landedSha` is persisted, so the engine dispatch's auto-retry re-runs this
// loop and the landed predicate above skips them (only the failed repo retries).
break;
}
}
await setStatus(null);
// TODO(Phase C U2): when `allLanded` and every acquired repo landed, finalize the
// task ONCE (finalizeTask / move-done) — NEVER per repo. Until U2's landed
// predicate + idempotent retry land, this loop leaves the task in place; the
// engine dispatch (KTD2) does not move it on a partial result.
return { taskId, repos, allLanded };
// U2 finalize-once (KTD3): move the task to `done` EXACTLY ONCE, only after EVERY
// acquired repo's landed predicate holds (all landed/empty, none failed). Reuse the
// task-global `finalizeTask` move-done path with an aggregate mergeDetails so the
// existing `task:merged` consumer is satisfied. On a partial land we do NOT move
// done (the landed repos' `landedSha` is already persisted for the retry).
if (allLanded) {
const finalized = await finalizeWorkspaceTask(store, taskId, task, repos);
return { taskId, repos, allLanded, finalized };
}
return { taskId, repos, allLanded, finalized: false };
}
/**
* FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3):
* Landed predicate: a sub-repo is landed iff a `landedSha` is recorded AND that sha is
* an ancestor of (or equals) the repo's CURRENT integration tip. The ancestor check
* (not just sha presence) survives a later un-related advance of the integration ref:
* the landed commit is still reachable, so the repo stays "landed". A `landedSha` that
* is NOT reachable from the tip (e.g. the ref was reset/rebuilt) reads as NOT landed and
* the repo re-lands.
*/
async function isRepoLanded(
repoRootDir: string,
integrationBranch: string,
landedSha: string | undefined,
): Promise<boolean> {
if (!landedSha) return false;
if (!(await gitOk(["rev-parse", "--verify", `refs/heads/${integrationBranch}`], repoRootDir))) {
return false;
}
// `merge-base --is-ancestor X Y` exits 0 iff X is an ancestor of (or equal to) Y.
return await gitOk(["merge-base", "--is-ancestor", landedSha, `refs/heads/${integrationBranch}`], repoRootDir);
}
/**
* FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3):
* Persist one sub-repo's `landedSha` with a FRESH-read-then-merge so a concurrent
* sibling-entry write is not clobbered (Phase A/B per-repo `workspaceWorktrees`
* pattern). Re-read the latest task, merge only this repo's entry, write the whole map.
*/
async function persistRepoLandedSha(
store: TaskStore,
taskId: string,
repoRel: string,
landedSha: string,
): Promise<void> {
const latest = await store.getTask(taskId).catch(() => undefined);
const current = latest?.workspaceWorktrees ?? {};
const entry = current[repoRel];
if (!entry) return; // entry vanished — nothing to merge into
const next = { ...current, [repoRel]: { ...entry, landedSha } };
await store.updateTask(taskId, { workspaceWorktrees: next }).catch(() => undefined);
}
/**
* FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3):
* Finalize-once: build an aggregate `MergeResult` from the per-repo lands and run the
* task-global `finalizeTask` move-done path ONCE. The representative `commitSha` is the
* first sorted landed repo's sha (so `mergeDetails.commitSha` is populated for the
* `task:merged` consumer); the full per-repo map is carried in `mergeDetails.workspaceLandedShas`.
* Returns true iff the task was moved to done.
*/
async function finalizeWorkspaceTask(
store: TaskStore,
taskId: string,
task: Task,
repos: WorkspaceRepoLandResult[],
): Promise<boolean> {
const landed = repos.filter((r) => r.status === "landed" && r.landedSha);
const workspaceLandedShas: Record<string, string> = {};
for (const r of landed) workspaceLandedShas[r.repo] = r.landedSha!;
const representative = landed.length > 0 ? landed[0].landedSha : undefined;
const anyLanded = landed.length > 0;
// Pre-populate task.mergeDetails so finalizeTask's spread carries the workspace map.
const mergeDetails: MergeDetails = {
...task.mergeDetails,
...(representative ? { commitSha: representative } : {}),
...(anyLanded ? { workspaceLandedShas } : {}),
mergeConfirmed: anyLanded,
};
await store.updateTask(taskId, { mergeDetails }).catch(() => undefined);
task.mergeDetails = mergeDetails;
const result: MergeResult = {
task,
branch: task.branch ?? "",
merged: anyLanded,
noOp: !anyLanded,
ok: true,
reason: anyLanded ? undefined : "no-net-changes",
commitSha: representative,
mergeConfirmed: anyLanded,
worktreeRemoved: false,
branchDeleted: false,
};
await store.logEntry(taskId, `AI merge (workspace): all ${repos.length} sub-repo(s) landed — task → done`, "AiMerge").catch(() => undefined);
await finalizeTask(store, taskId, result);
return true;
}
async function mergeAndReview(input: {

View File

@@ -137,6 +137,28 @@ export function shouldRetryAutoMergeConflict(
};
}
/*
FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3):
Pure retry/park decision for a workspace PARTIAL land (some sub-repos landed, one failed).
Mirrors `shouldRetryAutoMergeConflict` so the engine dispatch's partial-land catch branch
has a narrow, unit-testable seam: a partial land is RETRYABLE (the landed repos' `landedSha`
is persisted, so a re-run skips them and only the failed repo retries), so it CONSUMES a
mergeRetry and re-enqueues up to `resolveMaxAutoMergeRetries(settings)`, then OPERATOR-PARKS
(`shouldRetry:false`). `currentRetries + 1 < MAX` keeps the LAST attempt's failure parking
in the same tick rather than scheduling an Nth timer that a restart could strand.
*/
export function shouldRetryWorkspacePartialLand(
currentRetries: number,
settings: { maxAutoMergeRetries?: unknown } | null | undefined,
): { shouldRetry: boolean; maxAutoMergeRetries: number; nextRetryCount: number } {
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings);
return {
shouldRetry: currentRetries + 1 < maxAutoMergeRetries,
maxAutoMergeRetries,
nextRetryCount: currentRetries + 1,
};
}
/**
* FN-5627: Defense-in-depth gate for the auto-merge "merge already confirmed"
* fast-path. Verifies the task's recorded `mergeDetails.commitSha` is actually
@@ -2301,10 +2323,14 @@ export class ProjectEngine {
const isWorkspaceMerge =
!!mergeTask?.workspaceWorktrees && Object.keys(mergeTask.workspaceWorktrees).length > 0;
if (isWorkspaceMerge) {
// U1: land each acquired sub-repo on its own local integration ref.
// Task move-to-done (finalize once after all land) + idempotent retry
// are U2 — for now the loop returns a partial/aggregate result and the
// task is left in place.
// FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3):
// Land each acquired sub-repo on its own local integration ref;
// `landWorkspaceTask` records each landed `landedSha`, skips
// already-landed repos on a retry (idempotent), and on full success
// finalizes the task to `done` EXACTLY ONCE. On a PARTIAL land it does
// NOT finalize — it returns `allLanded:false`, which we surface as a
// WorkspacePartialLandError so the catch-block auto-retry consumes a
// mergeRetry and re-runs (skipping landed repos) up to MAX, then parks.
const settings = await store.getSettings().catch(() => ({}) as Settings);
const workspaceResult = await landWorkspaceTask(
store,
@@ -2312,15 +2338,28 @@ export class ProjectEngine {
cwd,
{ ...mergerOptions, allowDirtyLocalCheckoutSync: settings.merger?.allowDirtyLocalCheckoutSync === true },
);
if (!workspaceResult.allLanded) {
const failed = workspaceResult.repos.filter((r) => r.status === "failed");
const landedCount = workspaceResult.repos.filter((r) => r.status === "landed").length;
const detail = failed.map((r) => `${r.repo}: ${r.error ?? "land failed"}`).join("; ");
const partialErr = new Error(
`Workspace partial land for ${taskId}: ${landedCount} repo(s) landed, ${failed.length} failed — ${detail}`,
);
partialErr.name = "WorkspacePartialLandError";
throw partialErr;
}
// Finalized to done by landWorkspaceTask; report the merge as merged so
// the success path (retry reset + branch-group promotion) runs normally.
const latest = await store.getTask(taskId).catch(() => mergeTask!);
const anyLanded = workspaceResult.repos.some((r) => r.status === "landed");
return {
task: latest ?? mergeTask!,
branch: mergeTask!.branch ?? "",
// U1 does not finalize the task; report merged=false until U2 wires
// the finalize-once move-to-done after every repo lands.
merged: false,
noOp: !workspaceResult.repos.some((r) => r.status === "landed"),
ok: workspaceResult.allLanded,
merged: anyLanded,
noOp: !anyLanded,
ok: true,
commitSha: workspaceResult.repos.find((r) => r.status === "landed")?.landedSha,
mergeConfirmed: anyLanded,
worktreeRemoved: false,
branchDeleted: false,
} as MergeResult;
@@ -2421,6 +2460,54 @@ export class ProjectEngine {
continue;
}
// FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3):
// Workspace PARTIAL-LAND auto-retry-then-park (user decision). Unlike the R7
// WorkspaceTaskMergeError above (a permanent config error that must NOT burn
// retries), a partial land — repo A landed, repo B failed — is RETRYABLE: the
// landed repos' `landedSha` is persisted, so a re-run of `landWorkspaceTask`
// skips them and re-attempts only the failed repo (idempotent). So this CONSUMES
// a `mergeRetry` and re-enqueues the merge with exponential backoff up to the
// existing MAX (resolveMaxAutoMergeRetries), then OPERATOR-PARKS (status:"failed")
// — mirroring the conflict-retry seam below. Detect by err.name (robust across
// the package boundary). Manual merges fall through to rejectMergeResolvers at
// the hasManualResolver early-return below (no auto-retry for manual).
const isWorkspacePartialLand =
err instanceof Error && err.name === "WorkspacePartialLandError";
if (isWorkspacePartialLand && !hasManualResolver) {
const wsSettings = await store.getSettings().catch(() => ({ maxAutoMergeRetries: undefined }));
const wsTask = await store.getTask(taskId).catch(() => null);
const wsRetries = wsTask?.mergeRetries ?? 0;
const decision = shouldRetryWorkspacePartialLand(wsRetries, wsSettings as { maxAutoMergeRetries?: unknown });
await store
.logEntry(taskId, `Workspace partial land: ${errorMsg}`, "WorkspacePartialLand")
.catch(() => undefined);
if (decision.shouldRetry) {
await store.updateTask(taskId, { mergeRetries: decision.nextRetryCount, status: null }).catch(() => undefined);
const delayMs = 5000 * Math.pow(2, wsRetries);
runtimeLog.log(
`Workspace partial-land retry ${decision.nextRetryCount}/${decision.maxAutoMergeRetries} for ${taskId} in ${delayMs / 1000}s (re-runs skipping landed repos)`,
);
setTimeout(() => {
if (!this.shuttingDown) this.internalEnqueueMerge(taskId);
}, delayMs);
} else {
await store
.updateTask(taskId, { status: "failed", mergeRetries: decision.maxAutoMergeRetries, error: errorMsg })
.catch(() => undefined);
await store
.logEntry(
taskId,
`Workspace partial land exhausted ${decision.maxAutoMergeRetries} retries — parking as failed for operator intervention (landed repos remain landed locally): ${errorMsg}`,
"WorkspacePartialLand",
)
.catch(() => undefined);
runtimeLog.error(
`Auto-merge: ${taskId} workspace partial land exhausted ${decision.maxAutoMergeRetries} retries — parked as failed`,
);
}
continue;
}
runtimeLog.error(`${hasManualResolver ? "Manual" : "Auto"}-merge failed for ${taskId}: ${errorMsg}`);
// Surface every merge failure on the task log so the dashboard shows