feat(FN-4259): complete Step 2 — deterministic exhausted contract
Fusion-Task-Id: FN-4259 Fusion-Task-Lineage: 7a7ca17b-93ce-4319-8461-7d1462a2c0e1
This commit is contained in:
@@ -328,7 +328,7 @@ describe("SelfHealingManager", () => {
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { stuckKillCount: 3 });
|
||||
});
|
||||
|
||||
it("moves task to in-review when stuck-kill budget is exhausted", async () => {
|
||||
it("moves task to in-review with deterministic STUCK_LOOP_EXHAUSTED contract when budget is exhausted", async () => {
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "FN-001",
|
||||
stuckKillCount: 6,
|
||||
@@ -336,18 +336,18 @@ describe("SelfHealingManager", () => {
|
||||
|
||||
manager.start();
|
||||
|
||||
const result = await manager.checkStuckBudget("FN-001");
|
||||
const result = await manager.checkStuckBudget("FN-001", "loop");
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
||||
stuckKillCount: 7,
|
||||
status: "failed",
|
||||
error: expect.stringContaining("exceeded maximum of 6"),
|
||||
error: "STUCK_LOOP_EXHAUSTED: stuck kill budget exhausted (7/6) after last reason=loop.",
|
||||
});
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
expect.stringContaining("moved to in-review"),
|
||||
"STUCK_LOOP_EXHAUSTED: stuck kill budget exhausted (7/6), last reason=loop. No further automatic retries will run. Manually retry, pause, or move the task to triage to resume work.",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -453,10 +453,17 @@ export class SelfHealingManager {
|
||||
* Check whether a stuck-killed task should be re-queued or marked as failed.
|
||||
* Called by StuckTaskDetector's `beforeRequeue` callback.
|
||||
*
|
||||
* Terminal contract for stuck-loop exhaustion:
|
||||
* - Task is marked `status: "failed"` with `error` starting with
|
||||
* `STUCK_LOOP_EXHAUSTED: ` and including kill count, max, and last reason.
|
||||
* - Task is moved to `in-review` (best-effort if move fails).
|
||||
* - Task log gets a final `STUCK_LOOP_EXHAUSTED` entry with operator guidance
|
||||
* to manually retry, pause, or move to triage.
|
||||
*
|
||||
* @returns `true` if the task should be re-queued, `false` if budget exhausted
|
||||
* (task has been marked as permanently failed).
|
||||
*/
|
||||
async checkStuckBudget(taskId: string): Promise<boolean> {
|
||||
async checkStuckBudget(taskId: string, reason: "loop" | "inactivity" = "inactivity"): Promise<boolean> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
const maxKills = settings.maxStuckKills ?? 6;
|
||||
@@ -466,11 +473,13 @@ export class SelfHealingManager {
|
||||
|
||||
if (newCount > maxKills) {
|
||||
// Budget exhausted — mark as permanently failed
|
||||
log.warn(`${taskId} exceeded stuck kill budget (${newCount}/${maxKills}) — marking failed`);
|
||||
log.warn(`${taskId} exceeded stuck kill budget (${newCount}/${maxKills}, reason=${reason}) — marking failed`);
|
||||
const exhaustedError =
|
||||
`STUCK_LOOP_EXHAUSTED: stuck kill budget exhausted (${newCount}/${maxKills}) after last reason=${reason}.`;
|
||||
await this.store.updateTask(taskId, {
|
||||
stuckKillCount: newCount,
|
||||
status: "failed",
|
||||
error: `Task stuck ${newCount} times — exceeded maximum of ${maxKills} stuck kills`,
|
||||
error: exhaustedError,
|
||||
});
|
||||
try {
|
||||
await this.store.moveTask(taskId, "in-review");
|
||||
@@ -478,11 +487,11 @@ export class SelfHealingManager {
|
||||
// moveTask may fail if task was concurrently moved (e.g., dep-abort).
|
||||
// The task is already marked failed — don't allow requeue.
|
||||
const moveErrMessage = moveErr instanceof Error ? moveErr.message : String(moveErr);
|
||||
log.warn(`${taskId} moveTask("in-review") failed (${moveErrMessage}) — task already marked failed, not re-queuing`);
|
||||
log.warn(`${taskId} moveTask("in-review") failed (${moveErrMessage}) after STUCK_LOOP_EXHAUSTED terminalization — task already marked failed, not re-queuing`);
|
||||
}
|
||||
await this.store.logEntry(
|
||||
taskId,
|
||||
`Permanently failed: agent stuck ${newCount} times (max: ${maxKills}) — moved to in-review`,
|
||||
`STUCK_LOOP_EXHAUSTED: stuck kill budget exhausted (${newCount}/${maxKills}), last reason=${reason}. No further automatic retries will run. Manually retry, pause, or move the task to triage to resume work.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user