fix: recover approved triage tasks stuck specifying

This commit is contained in:
gsxdsm
2026-04-08 08:57:09 -07:00
parent 4eef067baa
commit c152517d1e
6 changed files with 405 additions and 67 deletions

View File

@@ -594,4 +594,108 @@ describe("SelfHealingManager", () => {
managerWithRecovery.stop();
});
});
describe("recoverApprovedTriageTasks", () => {
it("recovers approved specifying triage tasks that are not actively processing", async () => {
const recoverFn = vi.fn().mockResolvedValue(true);
const getSpecifying = vi.fn().mockReturnValue(new Set<string>());
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
recoverApprovedTriageTask: recoverFn,
getSpecifyingTaskIds: getSpecifying,
});
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-100",
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.recoverApprovedTriageTasks();
expect(result).toBe(1);
expect(recoverFn).toHaveBeenCalledWith(
expect.objectContaining({ id: "FN-100" }),
);
managerWithRecovery.stop();
});
it("skips tasks that are still actively being specified", async () => {
const recoverFn = vi.fn().mockResolvedValue(true);
const getSpecifying = vi.fn().mockReturnValue(new Set(["FN-101"]));
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
recoverApprovedTriageTask: recoverFn,
getSpecifyingTaskIds: getSpecifying,
});
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-101",
column: "triage",
status: "specifying",
paused: false,
log: [{ 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.recoverApprovedTriageTasks();
expect(result).toBe(0);
expect(recoverFn).not.toHaveBeenCalled();
managerWithRecovery.stop();
});
it("skips specifying triage tasks whose latest review is not APPROVE", async () => {
const recoverFn = vi.fn().mockResolvedValue(true);
const getSpecifying = vi.fn().mockReturnValue(new Set<string>());
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
recoverApprovedTriageTask: recoverFn,
getSpecifyingTaskIds: getSpecifying,
});
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-102",
column: "triage",
status: "specifying",
paused: false,
log: [
{ action: "Spec review: APPROVE" },
{ action: "Spec review requested" },
{ action: "Spec review: REVISE" },
],
updatedAt: "2026-01-01T00:00:00.000Z",
},
]);
vi.setSystemTime(new Date("2026-01-01T00:05:00.000Z"));
const result = await managerWithRecovery.recoverApprovedTriageTasks();
expect(result).toBe(0);
expect(recoverFn).not.toHaveBeenCalled();
managerWithRecovery.stop();
});
});
});

View File

@@ -40,8 +40,20 @@ export interface SelfHealingOptions {
* Used to avoid recovering tasks that are actively being worked on.
*/
getExecutingTaskIds?: () => Set<string>;
/**
* Recover a triage task whose spec was approved but whose final transition
* out of `status: "specifying"` never completed.
*/
recoverApprovedTriageTask?: (task: Task) => Promise<boolean>;
/**
* Returns the set of task IDs currently being specified by triage.
* Used to avoid recovering active triage sessions.
*/
getSpecifyingTaskIds?: () => Set<string>;
}
const APPROVED_TRIAGE_RECOVERY_GRACE_MS = 60_000;
export class SelfHealingManager {
// ── Auto-unpause state ──────────────────────────────────────────────
private unpauseTimer: ReturnType<typeof setTimeout> | null = null;
@@ -251,6 +263,7 @@ export class SelfHealingManager {
this.checkpointWal();
await this.enforceWorktreeCap();
await this.recoverCompletedTasks();
await this.recoverApprovedTriageTasks();
const elapsedMs = Date.now() - startMs;
log.log(`Maintenance cycle completed in ${elapsedMs}ms`);
@@ -308,6 +321,52 @@ export class SelfHealingManager {
}
}
/**
* Recover triage tasks that already have an approved specification but were
* left stuck in `status: "specifying"` without an active triage session.
*
* This catches the mirror-image of executor recovery: the review completed,
* but the final transition to `todo` / `awaiting-approval` never happened.
*/
async recoverApprovedTriageTasks(): Promise<number> {
const recoverFn = this.options.recoverApprovedTriageTask;
if (!recoverFn) return 0;
try {
const tasks = await this.store.listTasks();
const specifyingIds = this.options.getSpecifyingTaskIds?.() ?? new Set<string>();
const now = Date.now();
const orphanedApproved = 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 (orphanedApproved.length === 0) return 0;
log.warn(`Found ${orphanedApproved.length} approved triage task(s) stuck in specifying`);
let recovered = 0;
for (const task of orphanedApproved) {
log.log(`Recovering approved triage task ${task.id}: ${task.title || task.description?.slice(0, 60) || "(untitled)"}`);
const success = await recoverFn(task);
if (success) recovered++;
}
if (recovered > 0) {
log.log(`Recovered ${recovered} approved triage task(s) out of specifying`);
}
return recovered;
} catch (err: any) {
log.error(`Approved triage recovery failed: ${err.message}`);
return 0;
}
}
/** Run `git worktree prune` to clean stale metadata. */
private async pruneWorktrees(): Promise<void> {
try {
@@ -479,3 +538,13 @@ export class SelfHealingManager {
}
}
}
function hasLatestSpecReviewApproval(task: Task): boolean {
for (let i = task.log.length - 1; i >= 0; i--) {
const action = task.log[i]?.action ?? "";
if (action.startsWith("Spec review: ")) {
return action === "Spec review: APPROVE";
}
}
return false;
}

View File

@@ -809,6 +809,106 @@ describe("requirePlanApproval setting", () => {
});
});
describe("approved triage recovery", () => {
const rootDir = join(__dirname, "__test_triage_recovery__");
beforeEach(async () => {
await mkdir(join(rootDir, ".fusion", "tasks", "FN-001"), { recursive: true });
await writeFile(
join(rootDir, ".fusion", "tasks", "FN-001", "PROMPT.md"),
"# Task: FN-001\n\n**Size:** M\n\n## Review Level: 2\n\nRecovered specification",
);
});
afterEach(async () => {
await rm(rootDir, { recursive: true, force: true });
});
it("moves approved specifying task to todo during recovery", async () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
requirePlanApproval: false,
} as Settings),
parseDependenciesFromPrompt: vi.fn().mockResolvedValue(["FN-1247"]),
});
const processor = new TriageProcessor(store, rootDir);
const recovered = await processor.recoverApprovedTask({
id: "FN-001",
description: "Recovered triage task",
column: "triage",
status: "specifying",
dependencies: [],
steps: [],
currentStep: 0,
log: [
{ timestamp: "2026-01-01T00:00:00.000Z", action: "Spec review requested" },
{ timestamp: "2026-01-01T00:01:00.000Z", action: "Spec review: APPROVE" },
],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:02:00.000Z",
});
expect(recovered).toBe(true);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
status: null,
error: null,
dependencies: ["FN-1247"],
size: "M",
reviewLevel: 2,
});
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
expect(store.logEntry).toHaveBeenCalledWith(
"FN-001",
"Auto-recovered approved specification stuck in specifying — moved to todo",
);
});
it("moves approved specifying task to awaiting-approval when manual approval is required", async () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
requirePlanApproval: true,
} as Settings),
});
const processor = new TriageProcessor(store, rootDir);
const recovered = await processor.recoverApprovedTask({
id: "FN-001",
description: "Recovered triage task",
column: "triage",
status: "specifying",
dependencies: [],
steps: [],
currentStep: 0,
log: [
{ timestamp: "2026-01-01T00:00:00.000Z", action: "Spec review: APPROVE" },
],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:02:00.000Z",
});
expect(recovered).toBe(true);
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
status: "awaiting-approval",
});
expect(store.logEntry).toHaveBeenCalledWith(
"FN-001",
"Auto-recovered approved specification stuck in specifying — awaiting manual approval",
);
});
});
describe("taskCreate tool model inheritance", () => {
it("inherits parent task model settings when creating subtasks", async () => {
const parentTask: Task = {

View File

@@ -27,6 +27,8 @@ import { isTransientError, isSilentTransientError } from "./transient-error-dete
import { withRateLimitRetry } from "./rate-limit-retry.js";
import { computeRecoveryDecision, formatDelay, MAX_RECOVERY_RETRIES } from "./recovery-policy.js";
import type { StuckTaskDetector } from "./stuck-task-detector.js";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
export const TRIAGE_SYSTEM_PROMPT = `You are a task specification agent for "kb", an AI-orchestrated task board.
@@ -357,6 +359,45 @@ export class TriageProcessor {
this.stuckAborted.add(taskId);
}
/**
* Return a snapshot of tasks currently being specified by this processor.
* Used by self-healing maintenance to avoid recovering live sessions.
*/
getProcessingTaskIds(): Set<string> {
return new Set(this.processing);
}
/**
* Recover a triage task whose spec was already approved but the final
* handoff out of `status: "specifying"` never completed.
*/
async recoverApprovedTask(task: Task): Promise<boolean> {
if (task.column !== "triage" || task.status !== "specifying") {
return false;
}
if (!hasLatestSpecReviewApproval(task)) {
return false;
}
const settings = await this.store.getSettings();
const promptPath = join(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
const written = await readFile(promptPath, "utf-8").catch(() => "");
if (!written.trim()) {
triageLog.warn(`${task.id} approved-spec recovery skipped — PROMPT.md missing or empty`);
return false;
}
await this.finalizeApprovedTask(task, written, settings, {
recoveryLogAction: settings.requirePlanApproval
? "Auto-recovered approved specification stuck in specifying — awaiting manual approval"
: "Auto-recovered approved specification stuck in specifying — moved to todo",
});
return true;
}
/**
* If `newIntervalMs` differs from the currently active timer, restart
* the `setInterval` so the new cadence takes effect immediately.
@@ -665,10 +706,6 @@ export class TriageProcessor {
return;
}
// Check if the agent flagged a duplicate
const { readFile } = await import("node:fs/promises");
const { join } = await import("node:path");
// Stale-approval detection: re-read the task to check if new user
// comments arrived after the spec was approved. If the comment
// fingerprint changed, the approval is stale and the task needs
@@ -691,70 +728,12 @@ export class TriageProcessor {
join(this.rootDir, promptPath),
"utf-8",
).catch(() => "");
const dupMatch = written.match(/^DUPLICATE:\s*([A-Z]+-\d+)/i);
if (dupMatch) {
const dupId = dupMatch[1];
triageLog.log(`${task.id} is a duplicate of ${dupId} — closing`);
await this.store.logEntry(
task.id,
`Duplicate of ${dupId} — closed`,
);
await this.store.deleteTask(task.id);
} else {
// Parse dependencies, size, and review level from the generated PROMPT.md
const parsedDeps = await this.store.parseDependenciesFromPrompt(
task.id,
);
const taskUpdates: Record<string, any> = { status: null };
if (parsedDeps.length > 0) {
taskUpdates.dependencies = parsedDeps;
triageLog.log(
`${task.id} dependencies: ${parsedDeps.join(", ")}`,
);
}
// Extract size (S|M|L) from front-matter
const sizeMatch = written.match(/^\*\*Size:\*\*\s+(S|M|L)\b/m);
if (sizeMatch) {
taskUpdates.size = sizeMatch[1] as "S" | "M" | "L";
}
// Extract review level from heading
const reviewMatch = written.match(/^##\s+Review\s+Level:\s+(\d+)/m);
if (reviewMatch) {
taskUpdates.reviewLevel = parseInt(reviewMatch[1], 10);
}
await this.store.updateTask(task.id, taskUpdates);
// Check if manual plan approval is required
if (settings.requirePlanApproval) {
// Set awaiting-approval status instead of moving to todo
await this.store.updateTask(task.id, { status: "awaiting-approval" });
await this.store.logEntry(
task.id,
"Specification approved by AI — awaiting manual approval",
);
triageLog.log(
`${task.id} specified and awaiting manual approval`,
);
} else {
// Auto-move to todo (existing behavior)
await this.store.moveTask(task.id, "todo");
// Log completion for re-specification
if (isRespecify) {
await this.store.logEntry(task.id, "Spec revised by AI", feedback);
triageLog.log(`${task.id} re-specified and moved to todo`);
} else {
triageLog.log(`${task.id} specified and moved to todo`);
}
}
this.options.onSpecifyComplete?.(task);
}
await this.finalizeApprovedTask(task, written, settings, {
isRespecify,
feedback,
});
this.options.onSpecifyComplete?.(task);
} finally {
this.activeSessions.delete(task.id);
stuckDetector?.untrackTask(task.id);
@@ -1192,6 +1171,85 @@ export class TriageProcessor {
},
};
}
private async finalizeApprovedTask(
task: Task,
written: string,
settings: Settings,
options: {
isRespecify?: boolean;
feedback?: string;
recoveryLogAction?: string;
} = {},
): Promise<void> {
const dupMatch = written.match(/^DUPLICATE:\s*([A-Z]+-\d+)/i);
if (dupMatch) {
const dupId = dupMatch[1];
triageLog.log(`${task.id} is a duplicate of ${dupId} — closing`);
await this.store.logEntry(
task.id,
`Duplicate of ${dupId} — closed`,
);
await this.store.deleteTask(task.id);
return;
}
const parsedDeps = await this.store.parseDependenciesFromPrompt(task.id);
const taskUpdates: Record<string, any> = { status: null, error: null };
if (parsedDeps.length > 0) {
taskUpdates.dependencies = parsedDeps;
triageLog.log(`${task.id} dependencies: ${parsedDeps.join(", ")}`);
}
const sizeMatch = written.match(/^\*\*Size:\*\*\s+(S|M|L)\b/m);
if (sizeMatch) {
taskUpdates.size = sizeMatch[1] as "S" | "M" | "L";
}
const reviewMatch = written.match(/^##\s+Review\s+Level:\s+(\d+)/m);
if (reviewMatch) {
taskUpdates.reviewLevel = parseInt(reviewMatch[1], 10);
}
await this.store.updateTask(task.id, taskUpdates);
if (settings.requirePlanApproval) {
await this.store.updateTask(task.id, { status: "awaiting-approval" });
await this.store.logEntry(
task.id,
options.recoveryLogAction ?? "Specification approved by AI — awaiting manual approval",
);
triageLog.log(`${task.id} specified and awaiting manual approval`);
return;
}
await this.store.moveTask(task.id, "todo");
if (options.recoveryLogAction) {
await this.store.logEntry(task.id, options.recoveryLogAction);
triageLog.log(`${task.id} recovered and moved to todo`);
return;
}
if (options.isRespecify) {
await this.store.logEntry(task.id, "Spec revised by AI", options.feedback);
triageLog.log(`${task.id} re-specified and moved to todo`);
} else {
triageLog.log(`${task.id} specified and moved to todo`);
}
}
}
function hasLatestSpecReviewApproval(task: Task): boolean {
for (let i = task.log.length - 1; i >= 0; i--) {
const action = task.log[i]?.action ?? "";
if (action.startsWith("Spec review: ")) {
return action === "Spec review: APPROVE";
}
}
return false;
}
/** Content read from an attachment file for inlining in the prompt. */