diff --git a/.changeset/workspace-phase-c-u2-landed-predicate-finalize-once.md b/.changeset/workspace-phase-c-u2-landed-predicate-finalize-once.md new file mode 100644 index 0000000000..1f7f837c80 --- /dev/null +++ b/.changeset/workspace-phase-c-u2-landed-predicate-finalize-once.md @@ -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. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6e40bad2d0..98c69d02ad 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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; } /** 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; + workspaceWorktrees?: Record; steps: TaskStep[]; currentStep: number; /** diff --git a/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts b/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts new file mode 100644 index 0000000000..af9ed2e1af --- /dev/null +++ b/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts @@ -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 = {}): 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) => { + 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/` 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 => { + 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 => "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); + }); +}); diff --git a/packages/engine/src/__tests__/workspace-merger.test.ts b/packages/engine/src/__tests__/workspace-merger.test.ts index 0e4ec55a64..fe15703435 100644 --- a/packages/engine/src/__tests__/workspace-merger.test.ts +++ b/packages/engine/src/__tests__/workspace-merger.test.ts @@ -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 () => { diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index ab9302a1ea..b810cd59f8 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -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 { + 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 { + 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 { + const landed = repos.filter((r) => r.status === "landed" && r.landedSha); + const workspaceLandedShas: Record = {}; + 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: { diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 35f87f4308..0276d4ee00 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -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