feat(FN-5353): fix merge-reuse handoff race by gating reacquire and strict
Fixes merge handoff stalls by enforcing strict queue targeting and self-enqueue in the merger (FN-5353 Step 4), forcing reacquire before reuse handoff gates (Step 5), and aligning integration-root contract tests (Steps 1 & 7). Consolidates two prior changesets into `fn-5353-merge-reuse-stall-fix.md` Fusion-Task-Id: FN-5353
This commit is contained in:
committed by
gsxdsm
parent
f21464070a
commit
8df21a6b68
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
fix(FN-5353): reacquire fresh worktree when merge reuse handoff fails instead of falling back to main
|
||||
|
||||
When `mergeIntegrationWorktree=reuse-task-worktree` and no task worktree is available (worktree=null after executor teardown), the merger now acquires a fresh worktree (`git worktree add -b fusion/<id>`) instead of falling back to `cwd-main`. Falls back to `cwd-main` only if fresh acquisition itself throws. New audit events: `merge:reuse-fallback-new-worktree`, `merge:reuse-worktree-fresh-acquire`, `merge:reuse-worktree-fresh-acquired`, `merge:reuse-fallback-cwd-main`.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix merge-queue reuse-handoff contention that caused `merge:reuse-handoff-refused` (`reason=no-lease`) for valid in-review tasks when unrelated rows polluted queue head state. `acquireMergeQueueLease({ targetTaskId })` now honors the target strictly (no queue-head fallback), and merge-queue lifecycle handling is now column-aware: enqueue is rejected for non-`in-review` tasks, lease selection auto-cleans stale non-review rows, and `in-review` column exits scrub queue rows when leases are absent or expired.
|
||||
5
.changeset/fn-5353-merge-reuse-stall-fix.md
Normal file
5
.changeset/fn-5353-merge-reuse-stall-fix.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix in-review merge stall under the new mergeIntegrationWorktree=reuse-task-worktree default: strict targetTaskId leasing prevents cross-task lease bleed, and missing task.worktree always triggers the reacquire fallback instead of misrouting handoff gates against the project root.
|
||||
@@ -236,6 +236,7 @@ Detailed mechanism logs live in `docs/architecture.md` and `docs/design/`. The c
|
||||
- **In-review is terminal-until-merged under `autoMerge: false` (FN-5147)**: when a project sets `settings.autoMerge: false`, `in-review` is the intended resting state until a human merges the PR. No lifecycle-mutating self-healing sweep (`reclaimSelfOwnedBranchConflicts`, `recoverGhostReviewTasks`, `recoverStaleIncompleteReviewTasks`, `recoverInterruptedMergingTasks`, `recoverStuckMergeDeadlocks`, `recoverMissingWorktreeReviewFailures`, `recoverPartialProgressNoTaskDoneFailures`, `recoverCompletionHandoffLimbo`, `recoverMergeableReviewTasks`, `recoverMergedReviewTasks`, `recoverAlreadyMergedReviewTasks`, `recoverOrphanOnlyScopeViolations`, `recoverForeignOnlyContaminatedInReviewTasks`, `recoverReviewTasksWithFailedPreMergeSteps`, `finalizeNoOpReviewTasks`, `surfaceInReviewStalls`, `surfaceInReviewStalled`) may move the task out of `in-review`, mark it `paused`/`failed`, or re-enqueue it for execution. RECONCILE-ONLY sweeps (branch rebind, blocker fan-out, stale-status clears, contamination metadata cleanup, attribution restore, PR refresh, misclassified-failure error clearing) continue to run.
|
||||
- **Auto-merge integration-root default (FN-5279)**: direct auto-merge now defaults `mergeIntegrationWorktree` to `reuse-task-worktree`; merger must pass the reuse handoff gates or emit `merge:reuse-handoff-refused` and leave the task in `in-review` without silently falling back to `cwd-main`.
|
||||
- (FN-5363) `acquireMergeQueueLease` now honors `targetTaskId` strictly (no queue-head fallback), `mergeQueue` enqueue/lease paths are gated to tasks currently in `in-review`, and stale non-review rows are auto-scrubbed (including column-exit cleanup when tasks leave `in-review`).
|
||||
- (FN-5353) reuse-handoff now self-enqueues before target leasing, null target leases surface as `target-not-queued`, and `resolveMergeIntegrationRoot` returns a non-project-root sentinel for missing `task.worktree` so reacquire runs before any handoff gate touches project root.
|
||||
- **Orphaned execution sweep is observation-only (FN-5337)**: `recoverOrphanedExecutions` only annotates stale in-progress candidates with `task:orphan-detected-no-action` and `[orphan-detected] ... no action (operator-decides)` logs. It must never move `in-progress`/`in-review` backward to `todo` or mutate lease/worktree metadata. Proof-based backward recovery remains exclusively in `recoverInProgressLimbo` (FN-5219), `RestartRecoveryCoordinator`, `recoverMissingWorktreeReviewFailures`, and explicit executor/merger failure paths. Reintroducing lifecycle mutation here requires hard git/session proof gating plus CEO+CTO+PM sign-off.
|
||||
- **No-progress churn terminalization (FN-5168)**: `StuckTaskDetector` now tracks ignored `fn_task_update` rebuffs via `recordIgnoredStepUpdate(taskId)` and, after one loop/compact-and-resume recovery has already fired in the same `execute()` lifecycle, escalates `ignoredStepUpdateCount >= 25` to the terminal reason `no-progress-churn`. `SelfHealingManager.checkStuckBudget()` maps that reason directly to `STUCK_NO_PROGRESS_CHURN`, emits `task:stuck-no-progress-churn-terminalized` with `{ taskId, ignoredStepUpdateCount, stuckKillStreak, lastReason }`, and parks the task in `in-review` without consuming the normal stuck-kill budget. Under FN-5147 `autoMerge: false`, that failed in-review task remains terminal-until-merged just like `STUCK_LOOP_EXHAUSTED`; the new class adds an earlier bounded exit, not a re-execution path.
|
||||
- **Landed-files attribution (FN-5103)**: Rebase-strategy `mergeDetails.landedFiles` / `filesChanged` / `insertions` / `deletions` are captured from task-attributable commits only via `filterFilesToOwnTaskCommits` (subject-prefix + trailer + bracket-prefix evidence), tagged `landedFilesAttributionRestricted: true`. Zero own commits → `landedFiles: []` and `noOpVerifiedShortCircuit: true`. FN-5304 guard: when `<rebaseBaseSha>..HEAD` reports zero own commits, merger must also validate the source `fusion/<id>` tip; if that source tip still has attributable own commits relative to `rebaseBaseSha`, throw `SilentNoOpAttributionMismatchError`, refuse writing `mergeConfirmed: true`, park the task in `in-review` with `status: "failed"`, and emit `merge:no-op-attribution-mismatch`. If source ref is unavailable, skip with diagnostic + `merge:no-op-attribution-mismatch-skipped` (`reason: "source-ref-unavailable"`). Attribution-helper failures fall back to the unrestricted `rebaseBaseSha..sha` walk and set `landedFilesCaptureFallback: 'attribution-failed'`. Self-healing `recoverDoneTaskMergeMetadata` skips reconcile when `landedFilesAttributionRestricted` or `noOpVerifiedShortCircuit` is set so the narrower set is not overwritten with the full range. Squash-strategy capture is unchanged.
|
||||
|
||||
@@ -1566,7 +1566,7 @@ The GitHub tracking state listener now attaches to every registered project stor
|
||||
### Merge strategies
|
||||
- Setting type: `MergeStrategy = "direct" | "pull-request"` (`types.ts`)
|
||||
- `aiMergeTask()` in `merger.ts` performs merge flow
|
||||
- FN-5279 adds `mergeIntegrationWorktree` for auto-merge only. Default `reuse-task-worktree` hands merger ownership from executor to the merger inside the task worktree after five gates (clean tree, expected branch, no live executor session, canonical branch/worktree binding, lease handoff). Refusals emit `merge:reuse-handoff-refused`, leave the task in `in-review`, and do **not** silently fall back to project-root merge mode. `cwd-main` preserves the legacy project-root path. Integration-branch defaults across merger and self-healing flows are resolved dynamically via `resolveIntegrationBranch(rootDir, settings)` (`integrationBranch` → `baseBranch` → `origin/HEAD` → `main`). When `worktrunk.enabled=true`, worktrunk-managed merge/worktree behavior still wins and the handoff path emits a defer event instead of taking over. FN-5363 tightens this path: `acquireMergeQueueLease({ targetTaskId })` is strict (no queue-head fallback), merge queue rows are enqueue/lease-gated to `in-review` tasks, and stale non-review rows are auto-cleaned (including on `in-review` column exit when leases are absent or expired). FN-5351 adds a production verification trail for integration-branch invariants: `merge:integration-worktree-state`, `merge:cwd-integration-fallback-refused`, and `merge:integration-ref-advance`.
|
||||
- FN-5279 adds `mergeIntegrationWorktree` for auto-merge only. Default `reuse-task-worktree` hands merger ownership from executor to the merger inside the task worktree after five gates (clean tree, expected branch, no live executor session, canonical branch/worktree binding, lease handoff). Refusals emit `merge:reuse-handoff-refused`, leave the task in `in-review`, and do **not** silently fall back to project-root merge mode. `cwd-main` preserves the legacy project-root path. Integration-branch defaults across merger and self-healing flows are resolved dynamically via `resolveIntegrationBranch(rootDir, settings)` (`integrationBranch` → `baseBranch` → `origin/HEAD` → `main`). When `worktrunk.enabled=true`, worktrunk-managed merge/worktree behavior still wins and the handoff path emits a defer event instead of taking over. FN-5363 tightens this path: `acquireMergeQueueLease({ targetTaskId })` is strict (no queue-head fallback), merge queue rows are enqueue/lease-gated to `in-review` tasks, and stale non-review rows are auto-cleaned (including on `in-review` column exit when leases are absent or expired). FN-5353 extends the same contract: merger self-enqueues the target before strict target leasing, null target leases are surfaced as `merge:reuse-handoff-refused` with `reason: "target-not-queued"`, `acquireReuseHandoff` hard-refuses `reason: "worktree-equals-project-root"`, and `resolveMergeIntegrationRoot` returns a missing-worktree sentinel (`rootDir: ""`) so reacquire executes before any reuse gate can misroute against project root. FN-5351 adds a production verification trail for integration-branch invariants: `merge:integration-worktree-state`, `merge:cwd-integration-fallback-refused`, and `merge:integration-ref-advance`.
|
||||
- `merger.ts` also exposes a test-only `__test__` helper object for internal merger unit/integration coverage (for example autostash orphan cleanup behavior)
|
||||
- Supports workflow-step execution after merge (post-merge phase)
|
||||
- Deterministic verification now runs a bootstrap preamble (`node scripts/ensure-test-artifacts.mjs`) before configured `testCommand`/`buildCommand`, then self-heals Vite `Failed to resolve entry for package "@fusion/..."` workspace-entry faults by rebuilding the missing package once and retrying the failed command. If that retry still reports the same missing-entry fault, merger raises a typed environment fault and `ProjectEngine` leaves the task in-review (no verificationFailureCount increment or in-progress bounce) so the next recovery sweep can retry after other runs rebuild artifacts.
|
||||
|
||||
@@ -173,6 +173,70 @@ describe("TaskStore merge queue", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("acquireMergeQueueLease targetTaskId isolation (FN-5353)", () => {
|
||||
it("returns null when target is absent even if another task is queued", async () => {
|
||||
const taskA = await createInReviewTask();
|
||||
store.getDatabase().prepare("DELETE FROM mergeQueue WHERE taskId = ?").run(taskA);
|
||||
store.enqueueMergeQueue(taskA, { now: "2026-05-19T00:00:00.000Z" });
|
||||
|
||||
const before = store.getDatabase().prepare("SELECT leasedBy, leasedAt, leaseExpiresAt FROM mergeQueue WHERE taskId = ?").get(taskA) as {
|
||||
leasedBy: string | null;
|
||||
leasedAt: string | null;
|
||||
leaseExpiresAt: string | null;
|
||||
};
|
||||
|
||||
const lease = store.acquireMergeQueueLease("worker-target-miss", {
|
||||
targetTaskId: "FN-5353-MISSING",
|
||||
leaseDurationMs: 60_000,
|
||||
now: "2026-05-19T00:01:00.000Z",
|
||||
});
|
||||
expect(lease).toBeNull();
|
||||
|
||||
const after = store.getDatabase().prepare("SELECT leasedBy, leasedAt, leaseExpiresAt FROM mergeQueue WHERE taskId = ?").get(taskA);
|
||||
expect(after).toEqual(before);
|
||||
});
|
||||
|
||||
it("returns null when target row is currently leased by another worker", async () => {
|
||||
const taskA = await createInReviewTask();
|
||||
store.getDatabase().prepare("UPDATE mergeQueue SET leasedBy = ?, leasedAt = ?, leaseExpiresAt = ? WHERE taskId = ?").run(
|
||||
"worker-one",
|
||||
"2026-05-19T00:01:00.000Z",
|
||||
"2099-05-19T00:10:00.000Z",
|
||||
taskA,
|
||||
);
|
||||
|
||||
const lease = store.acquireMergeQueueLease("worker-two", {
|
||||
targetTaskId: taskA,
|
||||
leaseDurationMs: 60_000,
|
||||
now: "2026-05-19T00:01:30.000Z",
|
||||
});
|
||||
expect(lease).toBeNull();
|
||||
});
|
||||
|
||||
it("leases the targeted row when available", async () => {
|
||||
const taskA = await createInReviewTask();
|
||||
const lease = store.acquireMergeQueueLease("worker-target-hit", {
|
||||
targetTaskId: taskA,
|
||||
leaseDurationMs: 60_000,
|
||||
now: "2026-05-19T00:02:00.000Z",
|
||||
});
|
||||
|
||||
expect(lease?.taskId).toBe(taskA);
|
||||
expect(lease?.leasedBy).toBe("worker-target-hit");
|
||||
});
|
||||
|
||||
it("preserves legacy queue-head selection when targetTaskId is omitted", async () => {
|
||||
const taskA = await createInReviewTask();
|
||||
const lease = store.acquireMergeQueueLease("worker-head", {
|
||||
leaseDurationMs: 60_000,
|
||||
now: "2026-05-19T00:03:00.000Z",
|
||||
});
|
||||
|
||||
expect(lease?.taskId).toBe(taskA);
|
||||
expect(lease?.leasedBy).toBe("worker-head");
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects enqueue for tasks outside in-review", async () => {
|
||||
const todoTask = await createTask();
|
||||
await store.moveTask(todoTask, "todo");
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("resolveMergeIntegrationRoot", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the project root when the task worktree is missing", () => {
|
||||
it("returns empty sentinel rootDir when the task worktree is missing", () => {
|
||||
expect(
|
||||
resolveMergeIntegrationRoot({
|
||||
task: { id: "FN-5279", worktree: undefined } as any,
|
||||
@@ -72,7 +72,7 @@ describe("resolveMergeIntegrationRoot", () => {
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "reuse-task-worktree",
|
||||
rootDir: "/tmp/project-root",
|
||||
rootDir: "",
|
||||
branchName: "fusion/fn-5279",
|
||||
});
|
||||
});
|
||||
@@ -550,11 +550,11 @@ describe("acquireReuseHandoff", () => {
|
||||
worktreePath: "/tmp/task-worktree",
|
||||
}),
|
||||
"lease-handoff-failed",
|
||||
"no-lease",
|
||||
"target-not-queued",
|
||||
);
|
||||
expect(refusal.payload).toMatchObject({
|
||||
queueHeadTaskId: "FN-5329",
|
||||
queueHeadLeasedBy: "merger-reuse-handoff",
|
||||
taskId: "FN-5279",
|
||||
worktreePath: "/tmp/task-worktree",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -625,6 +625,7 @@ describe("aiMergeTask integration-root behavior", () => {
|
||||
});
|
||||
store.acquireMergeQueueLease = vi.fn().mockReturnValue({ taskId: "FN-5279" });
|
||||
store.releaseMergeQueueLease = vi.fn();
|
||||
store.enqueueMergeQueue = vi.fn();
|
||||
store.listTasks.mockResolvedValue([{ id: "FN-5279", column: "in-review", worktree: "/tmp/task-worktree" }]);
|
||||
|
||||
const baseImpl = mockedExecSync.getMockImplementation();
|
||||
|
||||
@@ -223,7 +223,7 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it.skipIf(!hasGit)("missing merge queue lease refuses handoff with no-lease diagnostics", async () => {
|
||||
it.skipIf(!hasGit)("missing merge queue lease refuses handoff with target-not-queued diagnostics", async () => {
|
||||
const fixture = await makeReliabilityFixture({
|
||||
taskId: "FN-5279-RI-NO-LEASE",
|
||||
settings: {
|
||||
@@ -264,18 +264,182 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
|
||||
await expect(aiMergeTask(store, rootDir, task.id)).rejects.toMatchObject({
|
||||
name: "MergeHandoffRefusedError",
|
||||
gate: "lease-handoff-failed",
|
||||
reason: "no-lease",
|
||||
reason: "target-not-queued",
|
||||
});
|
||||
const refused = store.getRunAuditEvents({ taskId: task.id }).find((event) => event.mutationType === "merge:reuse-handoff-refused");
|
||||
expect(refused?.metadata).toMatchObject({
|
||||
gate: "lease-handoff-failed",
|
||||
reason: "no-lease",
|
||||
reason: "target-not-queued",
|
||||
});
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it.skipIf(!hasGit)("FN-5353: aiMergeTask succeeds without pre-enqueue by self-enqueueing before handoff", async () => {
|
||||
const fixture = await makeReliabilityFixture({
|
||||
taskId: "FN-5353-RI-SELF-ENQUEUE",
|
||||
settings: {
|
||||
baseBranch: "master",
|
||||
mergeIntegrationWorktree: "reuse-task-worktree",
|
||||
} as any,
|
||||
});
|
||||
|
||||
try {
|
||||
const { rootDir, store, task } = fixture;
|
||||
const actualTask = await store.getTask(task.id);
|
||||
const branch = `fusion/${actualTask!.id.toLowerCase()}`;
|
||||
const worktreeRoot = `${rootDir}-worktrees`;
|
||||
const worktreePath = join(worktreeRoot, actualTask!.id.toLowerCase());
|
||||
|
||||
git(rootDir, "git branch -m main master");
|
||||
const completedSteps = (actualTask?.steps ?? []).map((step) => ({ ...step, status: "done" as const }));
|
||||
await store.updateTask(task.id, { baseBranch: "master", branch, steps: completedSteps, currentStep: completedSteps.length } as any);
|
||||
await fixture.createBranch(branch);
|
||||
await fixture.writeAndCommit("packages/engine/src/fn-5353-ri-self-enqueue.ts", "export const selfEnqueue = true;\n", "feat: add self enqueue merge content");
|
||||
await fixture.checkout("master");
|
||||
await mkdir(worktreeRoot, { recursive: true });
|
||||
git(rootDir, `git worktree add ${JSON.stringify(worktreePath)} ${JSON.stringify(branch)}`);
|
||||
await store.updateTask(task.id, { worktree: worktreePath, branch } as any);
|
||||
store.getDatabase().prepare("DELETE FROM mergeQueue WHERE taskId = ?").run(task.id);
|
||||
|
||||
const result = await aiMergeTask(store, rootDir, task.id);
|
||||
expect(result.merged).toBe(true);
|
||||
expect((await store.getTask(task.id))?.column).toBe("done");
|
||||
const auditTypes = store.getRunAuditEvents({ taskId: task.id }).map((event) => event.mutationType);
|
||||
expect(auditTypes).toContain("merge:reuse-handoff-acquired");
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it.skipIf(!hasGit)("FN-5353: cross-task queue entries remain untouched when aiMergeTask self-enqueues target", async () => {
|
||||
const fixtureA = await makeReliabilityFixture({
|
||||
taskId: "FN-5353-RI-TARGET-A",
|
||||
settings: { baseBranch: "master", mergeIntegrationWorktree: "reuse-task-worktree" } as any,
|
||||
});
|
||||
|
||||
try {
|
||||
const { rootDir, store, task } = fixtureA;
|
||||
const actualTask = await store.getTask(task.id);
|
||||
const branch = `fusion/${actualTask!.id.toLowerCase()}`;
|
||||
const worktreeRoot = `${rootDir}-worktrees`;
|
||||
const worktreePath = join(worktreeRoot, actualTask!.id.toLowerCase());
|
||||
|
||||
const other = await store.createTask({ description: "queue head other", priority: "normal" });
|
||||
await store.moveTask(other.id, "todo");
|
||||
await store.moveTask(other.id, "in-progress");
|
||||
await store.handoffToReview(other.id, {
|
||||
ownerAgentId: "agent-1",
|
||||
evidence: { reason: "fn_task_done", runId: "run-1", agentId: "agent-1" },
|
||||
});
|
||||
store.enqueueMergeQueue(other.id, { now: "2026-05-19T00:00:00.000Z" });
|
||||
|
||||
git(rootDir, "git branch -m main master");
|
||||
const completedSteps = (actualTask?.steps ?? []).map((step) => ({ ...step, status: "done" as const }));
|
||||
await store.updateTask(task.id, { baseBranch: "master", branch, steps: completedSteps, currentStep: completedSteps.length } as any);
|
||||
await fixtureA.createBranch(branch);
|
||||
await fixtureA.writeAndCommit("packages/engine/src/fn-5353-ri-target-not-queued.ts", "export const targetNotQueued = true;\n", "feat: add target not queued reproduction");
|
||||
await fixtureA.checkout("master");
|
||||
await mkdir(worktreeRoot, { recursive: true });
|
||||
git(rootDir, `git worktree add ${JSON.stringify(worktreePath)} ${JSON.stringify(branch)}`);
|
||||
await store.updateTask(task.id, { worktree: worktreePath, branch } as any);
|
||||
store.getDatabase().prepare("DELETE FROM mergeQueue WHERE taskId = ?").run(task.id);
|
||||
|
||||
const result = await aiMergeTask(store, rootDir, task.id);
|
||||
expect(result.merged).toBe(true);
|
||||
expect((await store.getTask(task.id))?.column).toBe("done");
|
||||
|
||||
const otherRow = store.getDatabase().prepare("SELECT taskId, leasedBy FROM mergeQueue WHERE taskId = ?").get(other.id) as {
|
||||
taskId: string;
|
||||
leasedBy: string | null;
|
||||
};
|
||||
expect(otherRow.taskId).toBe(other.id);
|
||||
expect(otherRow.leasedBy).toBeNull();
|
||||
} finally {
|
||||
await fixtureA.cleanup();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it.skipIf(!hasGit)("FN-5353: reuse handoff rejects project-root worktree misconfiguration", async () => {
|
||||
const fixture = await makeReliabilityFixture({
|
||||
taskId: "FN-5353-RI-PROJECT-ROOT-WORKTREE",
|
||||
settings: {
|
||||
baseBranch: "master",
|
||||
mergeIntegrationWorktree: "reuse-task-worktree",
|
||||
} as any,
|
||||
});
|
||||
|
||||
try {
|
||||
const { rootDir, store, task } = fixture;
|
||||
const actualTask = await store.getTask(task.id);
|
||||
const branch = `fusion/${actualTask!.id.toLowerCase()}`;
|
||||
git(rootDir, "git branch -m main master");
|
||||
const completedSteps = (actualTask?.steps ?? []).map((step) => ({ ...step, status: "done" as const }));
|
||||
await store.updateTask(task.id, {
|
||||
baseBranch: "master",
|
||||
branch,
|
||||
worktree: rootDir,
|
||||
steps: completedSteps,
|
||||
currentStep: completedSteps.length,
|
||||
} as any);
|
||||
await fixture.createBranch(branch);
|
||||
await fixture.writeAndCommit("packages/engine/src/fn-5353-ri-project-root.ts", "export const projectRootReuse = true;\n", "feat: add project root misconfiguration content");
|
||||
await fixture.checkout("master");
|
||||
store.enqueueMergeQueue(task.id);
|
||||
|
||||
await expect(aiMergeTask(store, rootDir, task.id)).rejects.toMatchObject({
|
||||
name: "MergeHandoffRefusedError",
|
||||
gate: "reuse-misconfigured",
|
||||
reason: "worktree-equals-project-root",
|
||||
});
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it.skipIf(!hasGit)("FN-5353: missing task.worktree reacquires a reusable worktree before handoff gates", async () => {
|
||||
const fixture = await makeReliabilityFixture({
|
||||
taskId: "FN-5353-RI-MISSING-WORKTREE-HANDOFF",
|
||||
settings: {
|
||||
baseBranch: "master",
|
||||
mergeIntegrationWorktree: "reuse-task-worktree",
|
||||
} as any,
|
||||
});
|
||||
|
||||
try {
|
||||
const { rootDir, store, task } = fixture;
|
||||
const actualTask = await store.getTask(task.id);
|
||||
const branch = `fusion/${actualTask!.id.toLowerCase()}`;
|
||||
git(rootDir, "git branch -m main master");
|
||||
|
||||
const completedSteps = (actualTask?.steps ?? []).map((step) => ({ ...step, status: "done" as const }));
|
||||
await store.updateTask(task.id, {
|
||||
baseBranch: "master",
|
||||
branch,
|
||||
worktree: null,
|
||||
steps: completedSteps,
|
||||
currentStep: completedSteps.length,
|
||||
} as any);
|
||||
await fixture.createBranch(branch);
|
||||
await fixture.writeAndCommit("packages/engine/src/fn-5353-ri-missing-worktree-handoff.ts", "export const missingHandoff = true;\n", "feat: add missing worktree handoff content");
|
||||
await fixture.checkout("master");
|
||||
store.enqueueMergeQueue(task.id);
|
||||
|
||||
const result = await aiMergeTask(store, rootDir, task.id);
|
||||
expect(result.merged).toBe(true);
|
||||
expect((await store.getTask(task.id))?.column).toBe("done");
|
||||
const audits = store.getRunAuditEvents({ taskId: task.id });
|
||||
const auditTypes = audits.map((event) => event.mutationType);
|
||||
expect(auditTypes).toContain("merge:reuse-fallback-new-worktree");
|
||||
expect(auditTypes).not.toContain("merge:reuse-handoff-refused");
|
||||
const refused = audits.find((event) => event.mutationType === "merge:reuse-handoff-refused");
|
||||
expect((refused?.metadata as { reason?: string } | undefined)?.reason).not.toBe("worktree-equals-project-root");
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it.skipIf(!hasGit)("FN-5363: queue-head pollution by non-in-review tasks does not block target reuse handoff", async () => {
|
||||
const fixture = await makeReliabilityFixture({
|
||||
taskId: "FN-5363-RI-POLLUTED",
|
||||
@@ -329,7 +493,7 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it.skipIf(!hasGit)("FN-5363: target row leased by another worker refuses with no-lease and queue-head diagnostics", async () => {
|
||||
it.skipIf(!hasGit)("FN-5363: target row leased by another worker refuses with target-not-queued diagnostics", async () => {
|
||||
const fixture = await makeReliabilityFixture({
|
||||
taskId: "FN-5363-RI-NO-LEASE-TARGET",
|
||||
settings: {
|
||||
@@ -374,10 +538,10 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
|
||||
await expect(aiMergeTask(store, rootDir, task.id)).rejects.toMatchObject({
|
||||
name: "MergeHandoffRefusedError",
|
||||
gate: "lease-handoff-failed",
|
||||
reason: "no-lease",
|
||||
reason: "target-not-queued",
|
||||
});
|
||||
const refused = store.getRunAuditEvents({ taskId: task.id }).find((event) => event.mutationType === "merge:reuse-handoff-refused");
|
||||
expect(refused?.metadata).toMatchObject({ reason: "no-lease" });
|
||||
expect(refused?.metadata).toMatchObject({ reason: "target-not-queued" });
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ const MERGE_HANDOFF_WORKER_ID = "merger-reuse-handoff";
|
||||
|
||||
export interface MergeIntegrationRootResolution {
|
||||
mode: MergeIntegrationWorktreeMode;
|
||||
// Sentinel: empty string means reuse mode is requested but no reusable
|
||||
// task.worktree is currently recorded; caller must reacquire before use.
|
||||
rootDir: string;
|
||||
branchName: string;
|
||||
}
|
||||
@@ -51,10 +53,11 @@ export function resolveMergeIntegrationRoot(
|
||||
input.settings.mergeIntegrationWorktree,
|
||||
);
|
||||
|
||||
const reusablePath = input.task.worktree?.trim() || "";
|
||||
return {
|
||||
mode,
|
||||
rootDir: mode === "reuse-task-worktree"
|
||||
? input.task.worktree?.trim() || input.projectRoot
|
||||
? reusablePath
|
||||
: input.projectRoot,
|
||||
branchName,
|
||||
};
|
||||
@@ -295,6 +298,13 @@ function asCentralClaimAccessor(store: TaskStore): {
|
||||
export async function acquireReuseHandoff(input: ReuseHandoffInput): Promise<HandoffResult> {
|
||||
const expectedBranch = canonicalFusionBranchName(input.task.id);
|
||||
const worktreePath = input.worktreePath;
|
||||
if (canonicalizePath(worktreePath) === canonicalizePath(input.projectRoot)) {
|
||||
throw new MergeHandoffRefusedError("reuse-misconfigured", "worktree-equals-project-root", {
|
||||
taskId: input.task.id,
|
||||
projectRoot: input.projectRoot,
|
||||
worktreePath,
|
||||
});
|
||||
}
|
||||
const dirtyPaths = Array.from(await snapshotDirtyFilesLocal(worktreePath)).sort();
|
||||
const dirtyFingerprint = await gitDirtyFingerprintLocal(worktreePath);
|
||||
if (dirtyPaths.length > 0 || dirtyFingerprint) {
|
||||
@@ -481,14 +491,21 @@ export async function acquireReuseHandoff(input: ReuseHandoffInput): Promise<Han
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!lease || !("taskId" in lease) || lease.taskId !== input.task.id) {
|
||||
if (!lease) {
|
||||
throw new MergeHandoffRefusedError("lease-handoff-failed", "target-not-queued", {
|
||||
taskId: input.task.id,
|
||||
worktreePath,
|
||||
});
|
||||
}
|
||||
|
||||
if (!("taskId" in lease) || lease.taskId !== input.task.id) {
|
||||
const queueHead = (input.store as TaskStore & {
|
||||
peekMergeQueueHead?: () => { taskId: string; leasedBy: string | null; column: string | null } | null;
|
||||
}).peekMergeQueueHead?.();
|
||||
throw new MergeHandoffRefusedError("lease-handoff-failed", "no-lease", {
|
||||
taskId: input.task.id,
|
||||
worktreePath,
|
||||
acquiredTaskId: lease && "taskId" in lease ? lease.taskId : null,
|
||||
acquiredTaskId: "taskId" in lease ? lease.taskId : null,
|
||||
queueHeadTaskId: queueHead?.taskId ?? null,
|
||||
queueHeadLeasedBy: queueHead?.leasedBy ?? null,
|
||||
});
|
||||
|
||||
@@ -6760,12 +6760,7 @@ export async function aiMergeTask(
|
||||
projectRoot: projectRootDir,
|
||||
});
|
||||
let reuseTaskWorktreeMerge = integrationRoot.mode === "reuse-task-worktree";
|
||||
rootDir = integrationRoot.rootDir;
|
||||
let integrationRemote = await resolveIntegrationRemote({
|
||||
settings,
|
||||
rootDir: rootDir,
|
||||
integrationBranch: mergeTarget.branch,
|
||||
});
|
||||
let integrationRemote: string | undefined;
|
||||
const reacquireReuseIntegrationWorktree = async (
|
||||
reason: string,
|
||||
diagnostics: Record<string, unknown>,
|
||||
@@ -7006,6 +7001,14 @@ export async function aiMergeTask(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rootDir = integrationRoot.rootDir;
|
||||
integrationRemote = await resolveIntegrationRemote({
|
||||
settings,
|
||||
rootDir,
|
||||
integrationBranch: mergeTarget.branch,
|
||||
});
|
||||
|
||||
try {
|
||||
const integrationWorktreeState = await probeIntegrationWorktreeState({
|
||||
rootDir: integrationRoot.rootDir,
|
||||
@@ -7032,6 +7035,9 @@ export async function aiMergeTask(
|
||||
}
|
||||
|
||||
if (integrationRoot.mode === "reuse-task-worktree") {
|
||||
// FN-5353: ensure the target task is in mergeQueue before attempting strict
|
||||
// targetTaskId lease acquisition for reuse handoff.
|
||||
store.enqueueMergeQueue(task.id, { priority: task.priority });
|
||||
try {
|
||||
reuseHandoff = await acquireReuseHandoff({
|
||||
task,
|
||||
|
||||
@@ -5532,6 +5532,8 @@ export class SelfHealingManager {
|
||||
await this.store.logEntry(task.id, "Auto-recovered (FN-4999): task in 'in-review' past handoff grace with no merge fan-out — re-emitting auto-merge handoff");
|
||||
if (this.options.requeueForAutoMerge) {
|
||||
try {
|
||||
// FN-5353: strict targetTaskId leasing in reuse handoff requires an
|
||||
// explicit queue row before re-emitting auto-merge.
|
||||
await this.store.enqueueMergeQueue(task.id);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
|
||||
Reference in New Issue
Block a user