fix(engine): make stale-merge recovery robust to includeTaskIdInCommit=false

recoverInterruptedMergingTasks searched for landed commits by grepping
commit subjects for the task ID. Users with includeTaskIdInCommit=false
have commit subjects like `feat: ...` (no task ID), so if the merger
crashed after committing but before storing mergeDetails, recovery would
silently fail to find the commit and incorrectly retry the merge.

Three layered defenses:

1. Emit a Fusion-Task-Id: <id> trailer in every Fusion-managed merge
   commit body. The 4 fallback commit invocations now include
   `-m "Fusion-Task-Id: ..."`. After the AI agent commits, an
   idempotent ensureTaskIdTrailerOnHead() amends the trailer in via
   `git interpret-trailers` (no-op if already present).

2. findLandedTaskCommit now tries three sources in order:
   a. task.mergeDetails.commitSha (if reachable from HEAD)
   b. Fusion-Task-Id trailer grep (anchored regex)
   c. Subject grep (legacy commits)

3. Trailer grep uses an anchored regex `^Fusion-Task-Id: <id>$` so
   it doesn't false-match task IDs appearing as substrings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-28 17:32:30 -07:00
parent 9492ed4c4e
commit 86a1232fa9
3 changed files with 197 additions and 26 deletions

View File

@@ -1254,6 +1254,68 @@ describe("SelfHealingManager", () => {
managerWithRecovery.stop();
});
it("finds landed commit via Fusion-Task-Id trailer when subject lacks the task ID", async () => {
// includeTaskIdInCommit=false: commit subject is `feat: ...` with no
// task ID. Recovery must locate the commit via the trailer in the body.
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
});
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
taskStuckTimeoutMs: 60_000,
});
const staleUpdatedAt = new Date(Date.now() - 61_000).toISOString();
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-2900",
column: "in-review",
status: "merging",
error: null,
paused: false,
worktree: "/tmp/test-project/.worktrees/fn-2900",
branch: "fusion/fn-2900",
baseCommitSha: "base999",
updatedAt: staleUpdatedAt,
steps: [{ name: "Ship it", status: "done" }],
workflowStepResults: [],
mergeDetails: undefined,
log: [],
},
]);
mockedExistsSync.mockReturnValue(true);
mockedExecSync.mockImplementation((command) => {
const cmd = String(command);
if (cmd.includes("git log")) {
// Recovery searches by trailer first; only the trailer-grep result
// returns a match. Subject grep would be empty (no task ID in subj).
if (cmd.includes("Fusion-Task-Id: FN-2900")) {
return "trailerSha123feat: ship something opaque\n" as any;
}
if (cmd.includes("--fixed-strings")) return "" as any;
}
if (cmd.includes("git show --shortstat")) {
return " 2 files changed, 5 insertions(+), 1 deletion(-)\n" as any;
}
return "" as any;
});
const result = await managerWithRecovery.recoverInterruptedMergingTasks();
expect(result).toBe(1);
expect(store.updateTask).toHaveBeenCalledWith("FN-2900", {
status: null,
error: null,
mergeRetries: 0,
mergeDetails: expect.objectContaining({
commitSha: "trailerSha123",
mergeConfirmed: true,
}),
});
expect(store.moveTask).toHaveBeenCalledWith("FN-2900", "done");
managerWithRecovery.stop();
});
it("finalizes stale merging tasks when baseCommitSha was advanced past the landed commit", async () => {
// Reproduces the case where the merger fast-forward-rebased the task branch
// and updated baseCommitSha to the new HEAD; the bounded `base..HEAD` range