fix: recover orphaned specifying tasks whose agent session died before approval
Tasks stuck in triage with status "specifying" had no recovery path when the agent session crashed mid-specification (before producing an approved spec). The stuck task detector only monitors tracked sessions, and recoverApprovedTriageTasks only handles tasks with an approved spec — leaving unapproved specifying tasks stranded indefinitely. Add recoverOrphanedSpecifyingTasks to clear status back to null so the next triage poll picks them up for a fresh specification attempt. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1279,4 +1279,159 @@ describe("SelfHealingManager", () => {
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("recoverOrphanedSpecifyingTasks", () => {
|
||||
it("clears status for orphaned specifying tasks without approval", async () => {
|
||||
const getSpecifying = vi.fn().mockReturnValue(new Set<string>());
|
||||
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
getSpecifyingTaskIds: getSpecifying,
|
||||
});
|
||||
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-200",
|
||||
column: "triage",
|
||||
status: "specifying",
|
||||
paused: false,
|
||||
log: [],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:05:00.000Z"));
|
||||
|
||||
const result = await managerWithRecovery.recoverOrphanedSpecifyingTasks();
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-200", { status: null });
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-200",
|
||||
"Auto-recovered orphaned specifying task — agent session lost, cleared for re-specification",
|
||||
);
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("skips tasks that are still actively being specified", async () => {
|
||||
const getSpecifying = vi.fn().mockReturnValue(new Set(["FN-201"]));
|
||||
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
getSpecifyingTaskIds: getSpecifying,
|
||||
});
|
||||
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-201",
|
||||
column: "triage",
|
||||
status: "specifying",
|
||||
paused: false,
|
||||
log: [],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:05:00.000Z"));
|
||||
|
||||
const result = await managerWithRecovery.recoverOrphanedSpecifyingTasks();
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("skips tasks that have an approved spec (handled by recoverApprovedTriageTasks)", async () => {
|
||||
const getSpecifying = vi.fn().mockReturnValue(new Set<string>());
|
||||
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
getSpecifyingTaskIds: getSpecifying,
|
||||
});
|
||||
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-202",
|
||||
column: "triage",
|
||||
status: "specifying",
|
||||
paused: false,
|
||||
log: [
|
||||
{ action: "Spec review requested" },
|
||||
{ action: "Spec review: APPROVE" },
|
||||
],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:05:00.000Z"));
|
||||
|
||||
const result = await managerWithRecovery.recoverOrphanedSpecifyingTasks();
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("skips paused tasks", async () => {
|
||||
const getSpecifying = vi.fn().mockReturnValue(new Set<string>());
|
||||
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
getSpecifyingTaskIds: getSpecifying,
|
||||
});
|
||||
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-203",
|
||||
column: "triage",
|
||||
status: "specifying",
|
||||
paused: true,
|
||||
log: [],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:05:00.000Z"));
|
||||
|
||||
const result = await managerWithRecovery.recoverOrphanedSpecifyingTasks();
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("skips tasks within the grace period", async () => {
|
||||
const getSpecifying = vi.fn().mockReturnValue(new Set<string>());
|
||||
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
getSpecifyingTaskIds: getSpecifying,
|
||||
});
|
||||
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-204",
|
||||
column: "triage",
|
||||
status: "specifying",
|
||||
paused: false,
|
||||
log: [],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
|
||||
// Only 30s later — within the 60s grace period
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:30.000Z"));
|
||||
|
||||
const result = await managerWithRecovery.recoverOrphanedSpecifyingTasks();
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,6 +110,7 @@ export class SelfHealingManager {
|
||||
await this.recoverMisclassifiedFailures();
|
||||
await this.recoverOrphanedExecutions();
|
||||
await this.recoverApprovedTriageTasks();
|
||||
await this.recoverOrphanedSpecifyingTasks();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
@@ -350,6 +351,7 @@ export class SelfHealingManager {
|
||||
await this.recoverNoProgressNoTaskDoneFailures();
|
||||
await this.recoverOrphanedExecutions();
|
||||
await this.recoverApprovedTriageTasks();
|
||||
await this.recoverOrphanedSpecifyingTasks();
|
||||
await this.archiveStaleDoneTasks();
|
||||
|
||||
const elapsedMs = Date.now() - startMs;
|
||||
@@ -825,6 +827,62 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover triage tasks stuck in `status: "specifying"` whose agent session
|
||||
* died before producing an approved spec.
|
||||
*
|
||||
* These tasks fall through two cracks:
|
||||
* - The stuck task detector only monitors tasks with active tracked sessions.
|
||||
* If the session crashed or was never started, the task is never tracked.
|
||||
* - `recoverApprovedTriageTasks` only handles tasks with an approved spec.
|
||||
*
|
||||
* Recovery clears the status back to `null` so the next triage poll picks
|
||||
* them up for a fresh specification attempt.
|
||||
*/
|
||||
async recoverOrphanedSpecifyingTasks(): Promise<number> {
|
||||
try {
|
||||
const tasks = await this.store.listTasks({ column: "triage" });
|
||||
const specifyingIds = this.options.getSpecifyingTaskIds?.() ?? new Set<string>();
|
||||
const now = Date.now();
|
||||
|
||||
const orphaned = tasks.filter((t) =>
|
||||
t.column === "triage" &&
|
||||
t.status === "specifying" &&
|
||||
!t.paused &&
|
||||
!specifyingIds.has(t.id) &&
|
||||
now - new Date(t.updatedAt).getTime() >= APPROVED_TRIAGE_RECOVERY_GRACE_MS &&
|
||||
!hasLatestSpecReviewApproval(t),
|
||||
);
|
||||
|
||||
if (orphaned.length === 0) return 0;
|
||||
|
||||
log.warn(`Found ${orphaned.length} orphaned specifying triage task(s) without approval`);
|
||||
|
||||
let recovered = 0;
|
||||
for (const task of orphaned) {
|
||||
try {
|
||||
log.log(`Recovering orphaned specifying task ${task.id}: ${task.title || task.description?.slice(0, 60) || "(untitled)"}`);
|
||||
await this.store.updateTask(task.id, { status: null });
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
"Auto-recovered orphaned specifying task — agent session lost, cleared for re-specification",
|
||||
);
|
||||
recovered++;
|
||||
} catch (err: any) {
|
||||
log.error(`Failed to recover orphaned specifying task ${task.id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (recovered > 0) {
|
||||
log.log(`Recovered ${recovered} orphaned specifying task(s) — cleared for re-specification`);
|
||||
}
|
||||
return recovered;
|
||||
} catch (err: any) {
|
||||
log.error(`Orphaned specifying task recovery failed: ${err.message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Run `git worktree prune` to clean stale metadata. */
|
||||
private async pruneWorktrees(): Promise<void> {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user