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.
|
||||
*/
|
||||
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;
|
||||
/**
|
||||
|
||||
@@ -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):
|
||||
- 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 () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user