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:
@@ -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.
|
||||||
@@ -1845,6 +1845,17 @@ export interface MergeDetails {
|
|||||||
* `task.mergeRetries`, which counts in-cycle aiMergeTask retries.
|
* `task.mergeRetries`, which counts in-cycle aiMergeTask retries.
|
||||||
*/
|
*/
|
||||||
transientRecoveryCount?: number;
|
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. */
|
/** 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
|
* 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
|
* per-repo analogue of the single-repo base-commit capture and prevents
|
||||||
* cross-repo files-changed inflation when local integration is ahead of origin.
|
* 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[];
|
steps: TaskStep[];
|
||||||
currentStep: number;
|
currentStep: number;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,11 +10,14 @@ injected (deps) so NO real AI calls happen and the squash is produced by a plain
|
|||||||
|
|
||||||
Coverage (FN-5893 surfaces):
|
Coverage (FN-5893 surfaces):
|
||||||
- happy: two acquired repos both clean → BOTH local integration refs advance against
|
- 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
|
- per-repo resolution: repos with DIFFERENT origin/HEAD integration branches → each
|
||||||
lands on its own (override-stripping works, not a shared branch).
|
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
|
- 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
|
- defense-in-depth: store.mergeTask / aiMergeTask with a workspace task → still throw
|
||||||
WorkspaceTaskMergeError.
|
WorkspaceTaskMergeError.
|
||||||
The single-repo runAiMerge regression lives in the existing merger-ai*.test.ts (the
|
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("");
|
expect(remoteRefs).toBe("");
|
||||||
}
|
}
|
||||||
|
|
||||||
// U1 does NOT move the task to done.
|
// U2 finalize-once: every repo landed → the task moves to done exactly once.
|
||||||
expect(store.moveTaskCalls).toHaveLength(0);
|
expect(store.moveTaskCalls).toEqual([{ id: TASK_ID, column: "done" }]);
|
||||||
expect(store.emitted.some((e) => e.event === "task:merged")).toBe(false);
|
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 () => {
|
it("per-repo resolution: each repo lands on its OWN origin/HEAD branch (override-stripping)", async () => {
|
||||||
|
|||||||
@@ -1341,6 +1341,13 @@ export interface WorkspaceRepoLandResult {
|
|||||||
localSync?: LocalSyncOutcome;
|
localSync?: LocalSyncOutcome;
|
||||||
/** Failure message when `status === "failed"`. */
|
/** Failure message when `status === "failed"`. */
|
||||||
error?: string;
|
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. */
|
/** Aggregated result of a workspace task's per-repo merge loop. */
|
||||||
@@ -1349,6 +1356,12 @@ export interface WorkspaceMergeResult {
|
|||||||
repos: WorkspaceRepoLandResult[];
|
repos: WorkspaceRepoLandResult[];
|
||||||
/** True iff every acquired sub-repo landed (or was empty) with no failure. */
|
/** True iff every acquired sub-repo landed (or was empty) with no failure. */
|
||||||
allLanded: boolean;
|
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.
|
workspace branch.
|
||||||
|
|
||||||
U1 scope: on a repo failure we stop the loop and return a PARTIAL result (repo A may
|
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
|
have landed; B reports the failure). Routing the engine + CLI doors to this loop is KTD2.
|
||||||
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
|
FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3):
|
||||||
to this loop is KTD2.
|
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(
|
export async function landWorkspaceTask(
|
||||||
store: TaskStore,
|
store: TaskStore,
|
||||||
@@ -1430,6 +1463,19 @@ export async function landWorkspaceTask(
|
|||||||
break;
|
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 {
|
try {
|
||||||
const landResult = await landOneRepo(store, repoRootDir, entry.branch, integrationBranch, {
|
const landResult = await landOneRepo(store, repoRootDir, entry.branch, integrationBranch, {
|
||||||
taskId, settings, audit, log, setStatus, maxPasses,
|
taskId, settings, audit, log, setStatus, maxPasses,
|
||||||
@@ -1438,6 +1484,10 @@ export async function landWorkspaceTask(
|
|||||||
allowDirtyLocalCheckoutSync: options.allowDirtyLocalCheckoutSync === true,
|
allowDirtyLocalCheckoutSync: options.allowDirtyLocalCheckoutSync === true,
|
||||||
});
|
});
|
||||||
if (landResult.outcome === "landed") {
|
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({
|
repos.push({
|
||||||
repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch,
|
repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch,
|
||||||
status: "landed", landedSha: landResult.squashSha, localSync: landResult.localSync,
|
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);
|
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 });
|
repos.push({ repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, status: "failed", error: message });
|
||||||
allLanded = false;
|
allLanded = false;
|
||||||
// U1: stop on first failure and return a partial result. U2 adds the landed
|
// Stop on first failure and return a partial result. The already-landed repos'
|
||||||
// predicate + idempotent retry so a re-run skips 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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await setStatus(null);
|
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
|
// U2 finalize-once (KTD3): move the task to `done` EXACTLY ONCE, only after EVERY
|
||||||
// predicate + idempotent retry land, this loop leaves the task in place; the
|
// acquired repo's landed predicate holds (all landed/empty, none failed). Reuse the
|
||||||
// engine dispatch (KTD2) does not move it on a partial result.
|
// task-global `finalizeTask` move-done path with an aggregate mergeDetails so the
|
||||||
return { taskId, repos, allLanded };
|
// 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: {
|
async function mergeAndReview(input: {
|
||||||
|
|||||||
@@ -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"
|
* FN-5627: Defense-in-depth gate for the auto-merge "merge already confirmed"
|
||||||
* fast-path. Verifies the task's recorded `mergeDetails.commitSha` is actually
|
* fast-path. Verifies the task's recorded `mergeDetails.commitSha` is actually
|
||||||
@@ -2301,10 +2323,14 @@ export class ProjectEngine {
|
|||||||
const isWorkspaceMerge =
|
const isWorkspaceMerge =
|
||||||
!!mergeTask?.workspaceWorktrees && Object.keys(mergeTask.workspaceWorktrees).length > 0;
|
!!mergeTask?.workspaceWorktrees && Object.keys(mergeTask.workspaceWorktrees).length > 0;
|
||||||
if (isWorkspaceMerge) {
|
if (isWorkspaceMerge) {
|
||||||
// U1: land each acquired sub-repo on its own local integration ref.
|
// FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3):
|
||||||
// Task move-to-done (finalize once after all land) + idempotent retry
|
// Land each acquired sub-repo on its own local integration ref;
|
||||||
// are U2 — for now the loop returns a partial/aggregate result and the
|
// `landWorkspaceTask` records each landed `landedSha`, skips
|
||||||
// task is left in place.
|
// 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 settings = await store.getSettings().catch(() => ({}) as Settings);
|
||||||
const workspaceResult = await landWorkspaceTask(
|
const workspaceResult = await landWorkspaceTask(
|
||||||
store,
|
store,
|
||||||
@@ -2312,15 +2338,28 @@ export class ProjectEngine {
|
|||||||
cwd,
|
cwd,
|
||||||
{ ...mergerOptions, allowDirtyLocalCheckoutSync: settings.merger?.allowDirtyLocalCheckoutSync === true },
|
{ ...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 latest = await store.getTask(taskId).catch(() => mergeTask!);
|
||||||
|
const anyLanded = workspaceResult.repos.some((r) => r.status === "landed");
|
||||||
return {
|
return {
|
||||||
task: latest ?? mergeTask!,
|
task: latest ?? mergeTask!,
|
||||||
branch: mergeTask!.branch ?? "",
|
branch: mergeTask!.branch ?? "",
|
||||||
// U1 does not finalize the task; report merged=false until U2 wires
|
merged: anyLanded,
|
||||||
// the finalize-once move-to-done after every repo lands.
|
noOp: !anyLanded,
|
||||||
merged: false,
|
ok: true,
|
||||||
noOp: !workspaceResult.repos.some((r) => r.status === "landed"),
|
commitSha: workspaceResult.repos.find((r) => r.status === "landed")?.landedSha,
|
||||||
ok: workspaceResult.allLanded,
|
mergeConfirmed: anyLanded,
|
||||||
worktreeRemoved: false,
|
worktreeRemoved: false,
|
||||||
branchDeleted: false,
|
branchDeleted: false,
|
||||||
} as MergeResult;
|
} as MergeResult;
|
||||||
@@ -2421,6 +2460,54 @@ export class ProjectEngine {
|
|||||||
continue;
|
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}`);
|
runtimeLog.error(`${hasManualResolver ? "Manual" : "Auto"}-merge failed for ${taskId}: ${errorMsg}`);
|
||||||
|
|
||||||
// Surface every merge failure on the task log so the dashboard shows
|
// Surface every merge failure on the task log so the dashboard shows
|
||||||
|
|||||||
Reference in New Issue
Block a user