fix(self-healing): recover merge when baseCommitSha was advanced past landed commit
findLandedTaskCommit only fell back to scanning all of HEAD on git-log exceptions. When the bounded base..HEAD range is valid but empty — e.g. the merger fast-forward-rebased the task branch and set baseCommitSha to the new HEAD, or later commits moved HEAD past the merge — recovery silently returned null and re-queued the merge even though the commit had already landed. Add an empty-stdout fallback that re-scans HEAD, with a regression test covering the FN-2221 scenario. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1229,6 +1229,75 @@ describe("SelfHealingManager", () => {
|
||||
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
|
||||
// is empty even though the merge commit is in HEAD's history.
|
||||
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-2221",
|
||||
column: "in-review",
|
||||
status: "merging",
|
||||
error: null,
|
||||
paused: false,
|
||||
worktree: "/tmp/test-project/.worktrees/amber-lotus",
|
||||
branch: "fusion/fn-2221",
|
||||
baseCommitSha: "headsha0",
|
||||
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") && cmd.includes("headsha0..HEAD")) {
|
||||
return "" as any; // bounded range is empty (baseCommitSha === HEAD)
|
||||
}
|
||||
if (cmd.includes("git log") && cmd.includes("HEAD")) {
|
||||
return "3b212b928feat(FN-2221): constrain setup wizard modal shell\n" as any;
|
||||
}
|
||||
if (cmd.includes("git show --shortstat")) {
|
||||
return " 2 files changed, 154 insertions(+), 0 deletions(-)\n" as any;
|
||||
}
|
||||
return "" as any;
|
||||
});
|
||||
|
||||
const result = await managerWithRecovery.recoverInterruptedMergingTasks();
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-2221", {
|
||||
status: null,
|
||||
error: null,
|
||||
mergeRetries: 0,
|
||||
mergeDetails: expect.objectContaining({
|
||||
commitSha: "3b212b928",
|
||||
mergeCommitMessage: "feat(FN-2221): constrain setup wizard modal shell",
|
||||
mergeConfirmed: true,
|
||||
filesChanged: 2,
|
||||
insertions: 154,
|
||||
deletions: 0,
|
||||
}),
|
||||
});
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-2221", "done");
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-2221",
|
||||
expect.stringContaining("stale merge status finalized from landed commit 3b212b9"),
|
||||
);
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("clears stale merging status for retry when no landed commit is found", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
|
||||
@@ -448,6 +448,15 @@ export class SelfHealingManager {
|
||||
stdout = result.stdout;
|
||||
}
|
||||
|
||||
// The bounded `baseCommitSha..HEAD` range excludes the landed commit when
|
||||
// baseCommitSha was advanced past it (e.g. the merger fast-forward-rebased
|
||||
// the task branch onto a newer main, or later commits moved HEAD up).
|
||||
// Re-scan all of HEAD so recovery still finds the merge.
|
||||
if (!stdout.trim() && task.baseCommitSha) {
|
||||
const result = await readLog("HEAD");
|
||||
stdout = result.stdout;
|
||||
}
|
||||
|
||||
const firstLine = stdout.trim().split("\n").find(Boolean);
|
||||
if (!firstLine) return null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user