feat(FN-4563): complete Step 2 — broaden self-healing rationale
Fusion-Task-Id: FN-4563 Fusion-Task-Lineage: a0caaa7b-8e87-4ae1-8c51-b46e4ac61d59
This commit is contained in:
@@ -1760,7 +1760,7 @@ describe("SelfHealingManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("recoverMissingWorktreeReviewFailures", () => {
|
describe("recoverMissingWorktreeReviewFailures", () => {
|
||||||
it("requeues failed in-review tasks with missing-worktree session-start errors", async () => {
|
it("requeues failed in-review tasks with unusable-worktree session-start errors", async () => {
|
||||||
const managerWithRecovery = new SelfHealingManager(store, {
|
const managerWithRecovery = new SelfHealingManager(store, {
|
||||||
rootDir: "/tmp/test-project",
|
rootDir: "/tmp/test-project",
|
||||||
});
|
});
|
||||||
@@ -1792,17 +1792,96 @@ describe("SelfHealingManager", () => {
|
|||||||
});
|
});
|
||||||
expect(store.logEntry).toHaveBeenCalledWith(
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
"FN-3900",
|
"FN-3900",
|
||||||
expect.stringContaining("missing worktree"),
|
expect.stringContaining("unusable worktree"),
|
||||||
);
|
);
|
||||||
expect(store.logEntry).toHaveBeenCalledWith(
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
"FN-3900",
|
"FN-3900",
|
||||||
expect.stringContaining("/tmp/project/.worktrees/fn-3900-stale"),
|
expect.stringContaining("/tmp/project/.worktrees/fn-3900-stale"),
|
||||||
);
|
);
|
||||||
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
|
"FN-3900",
|
||||||
|
expect.stringContaining("Refusing to start coding agent in missing worktree"),
|
||||||
|
);
|
||||||
expect(store.moveTask).toHaveBeenCalledWith("FN-3900", "todo", { preserveProgress: true });
|
expect(store.moveTask).toHaveBeenCalledWith("FN-3900", "todo", { preserveProgress: true });
|
||||||
|
|
||||||
managerWithRecovery.stop();
|
managerWithRecovery.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requeues incomplete-worktree failures and clears stale worktree metadata", async () => {
|
||||||
|
const managerWithRecovery = new SelfHealingManager(store, {
|
||||||
|
rootDir: "/tmp/test-project",
|
||||||
|
});
|
||||||
|
|
||||||
|
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "FN-4559",
|
||||||
|
column: "in-review",
|
||||||
|
paused: false,
|
||||||
|
status: "failed",
|
||||||
|
worktree: "/tmp/project/.worktrees/noble-eagle-stale",
|
||||||
|
branch: "fusion/FN-4559",
|
||||||
|
sessionFile: "/tmp/project/.fusion/sessions/FN-4559.json",
|
||||||
|
error: "Refusing to start coding agent in incomplete worktree: /tmp/project/.worktrees/noble-eagle",
|
||||||
|
steps: [{ status: "done" }, { status: "pending" }],
|
||||||
|
log: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await managerWithRecovery.recoverMissingWorktreeReviewFailures();
|
||||||
|
|
||||||
|
expect(result).toBe(1);
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith("FN-4559", {
|
||||||
|
status: null,
|
||||||
|
error: null,
|
||||||
|
worktree: "/tmp/project/.worktrees/noble-eagle-stale",
|
||||||
|
branch: "fusion/FN-4559",
|
||||||
|
sessionFile: null,
|
||||||
|
});
|
||||||
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
|
"FN-4559",
|
||||||
|
expect.stringContaining("Auto-recovered"),
|
||||||
|
);
|
||||||
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
|
"FN-4559",
|
||||||
|
expect.stringContaining("incomplete worktree"),
|
||||||
|
);
|
||||||
|
expect(store.moveTask).toHaveBeenCalledWith("FN-4559", "todo", { preserveProgress: true });
|
||||||
|
|
||||||
|
managerWithRecovery.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requeues unregistered-worktree failures", async () => {
|
||||||
|
const managerWithRecovery = new SelfHealingManager(store, {
|
||||||
|
rootDir: "/tmp/test-project",
|
||||||
|
});
|
||||||
|
|
||||||
|
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "FN-4560",
|
||||||
|
column: "in-review",
|
||||||
|
paused: false,
|
||||||
|
status: "failed",
|
||||||
|
worktree: "/tmp/project/.worktrees/fn-4560",
|
||||||
|
branch: "fusion/FN-4560",
|
||||||
|
sessionFile: "/tmp/project/.fusion/sessions/FN-4560.json",
|
||||||
|
error: "Refusing to start coding agent in unregistered git worktree: /tmp/project/.worktrees/fn-4560",
|
||||||
|
steps: [{ status: "done" }, { status: "pending" }],
|
||||||
|
log: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await managerWithRecovery.recoverMissingWorktreeReviewFailures();
|
||||||
|
|
||||||
|
expect(result).toBe(1);
|
||||||
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
|
"FN-4560",
|
||||||
|
expect.stringContaining("unregistered git worktree"),
|
||||||
|
);
|
||||||
|
expect(store.moveTask).toHaveBeenCalledWith("FN-4560", "todo", { preserveProgress: true });
|
||||||
|
|
||||||
|
managerWithRecovery.stop();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not requeue non-matching in-review failures", async () => {
|
it("does not requeue non-matching in-review failures", async () => {
|
||||||
const managerWithRecovery = new SelfHealingManager(store, {
|
const managerWithRecovery = new SelfHealingManager(store, {
|
||||||
rootDir: "/tmp/test-project",
|
rootDir: "/tmp/test-project",
|
||||||
|
|||||||
@@ -4095,7 +4095,7 @@ export class SelfHealingManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recover failed `in-review` retries that point at a missing worktree path.
|
* Recover failed `in-review` retries that point at an unusable worktree path.
|
||||||
*
|
*
|
||||||
* This is a narrow guard for session-start failures thrown by
|
* This is a narrow guard for session-start failures thrown by
|
||||||
* assertValidWorktreeSession() (`Refusing to start coding agent in missing worktree:`,
|
* assertValidWorktreeSession() (`Refusing to start coding agent in missing worktree:`,
|
||||||
@@ -4111,7 +4111,7 @@ export class SelfHealingManager {
|
|||||||
|
|
||||||
if (candidates.length === 0) return 0;
|
if (candidates.length === 0) return 0;
|
||||||
|
|
||||||
log.warn(`Found ${candidates.length} in-review task(s) failed by missing-worktree session start`);
|
log.warn(`Found ${candidates.length} in-review task(s) failed by unusable-worktree session start`);
|
||||||
|
|
||||||
let recovered = 0;
|
let recovered = 0;
|
||||||
for (const task of candidates) {
|
for (const task of candidates) {
|
||||||
@@ -4130,27 +4130,30 @@ export class SelfHealingManager {
|
|||||||
branch: hasMismatchedLiveWorktree ? task.branch ?? null : null,
|
branch: hasMismatchedLiveWorktree ? task.branch ?? null : null,
|
||||||
sessionFile: null,
|
sessionFile: null,
|
||||||
});
|
});
|
||||||
|
const failureExcerpt = typeof task.error === "string"
|
||||||
|
? task.error.slice(0, 200)
|
||||||
|
: "unknown error";
|
||||||
await this.store.logEntry(
|
await this.store.logEntry(
|
||||||
task.id,
|
task.id,
|
||||||
hasMismatchedLiveWorktree
|
hasMismatchedLiveWorktree
|
||||||
? `Auto-recovered: stale resume referenced missing worktree (${missingWorktreePath}) while live task worktree is ${staleWorktree} — cleared stale session metadata and requeued to todo`
|
? `Auto-recovered: stale resume referenced unusable worktree (${missingWorktreePath}) while live task worktree is ${staleWorktree} — cleared stale session metadata and requeued to todo (failure: ${failureExcerpt})`
|
||||||
: `Auto-recovered: retry/verification session targeted missing worktree${staleWorktree ? ` (${staleWorktree})` : ""} — cleared stale session metadata and requeued to todo`,
|
: `Auto-recovered: retry/verification session targeted unusable worktree${staleWorktree ? ` (${staleWorktree})` : ""} — cleared stale session metadata and requeued to todo (failure: ${failureExcerpt})`,
|
||||||
);
|
);
|
||||||
await this.store.moveTask(task.id, "todo", { preserveProgress: true });
|
await this.store.moveTask(task.id, "todo", { preserveProgress: true });
|
||||||
recovered++;
|
recovered++;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
log.error(`Failed to recover missing-worktree review failure ${task.id}: ${errorMessage}`);
|
log.error(`Failed to recover unusable-worktree review failure ${task.id}: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (recovered > 0) {
|
if (recovered > 0) {
|
||||||
log.log(`Recovered ${recovered} missing-worktree review failure(s) → todo`);
|
log.log(`Recovered ${recovered} unusable-worktree review failure(s) → todo`);
|
||||||
}
|
}
|
||||||
return recovered;
|
return recovered;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
log.error(`Missing-worktree review recovery failed: ${errorMessage}`);
|
log.error(`Unusable-worktree review recovery failed: ${errorMessage}`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user