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:
gsxdsm
2026-04-13 23:42:51 -07:00
parent 4e9eb8b8a1
commit 461e60a4f6
2 changed files with 213 additions and 0 deletions

View File

@@ -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();
});
});
});

View File

@@ -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 {