feat(FN-4086): complete Step 2 — add stranded todo recovery pass

Fusion-Task-Id: FN-4086
Fusion-Task-Lineage: b2338fbc-960c-4540-9c19-216462525f0c
This commit is contained in:
Fusion
2026-05-14 04:25:12 -07:00
committed by gsxdsm
parent d74ba386e7
commit 151503c40e
2 changed files with 67 additions and 0 deletions

View File

@@ -2120,6 +2120,9 @@ export class TaskExecutor {
return false;
}
await this.persistTokenUsage(task.id);
if (task.column === "todo") {
await this.store.moveTask(task.id, "in-progress");
}
await this.store.moveTask(task.id, "in-review");
this.clearCompletedTaskWatchdog(task.id);
await this.store.logEntry(task.id, "Auto-recovered: task work was complete but stuck in in-progress — moved to in-review");

View File

@@ -155,6 +155,16 @@ const APPROVED_TRIAGE_RECOVERY_GRACE_MS = 60_000;
const ORPHANED_EXECUTION_RECOVERY_GRACE_MS = 60_000;
const ACTIVE_MERGE_STATUSES = new Set(["merging", "merging-pr", "merging-fix"]);
const NON_TERMINAL_STEP_STATUSES = new Set(["pending", "in-progress"]);
const STRANDED_COMPLETED_TODO_ACTIVE_STATUSES = new Set([
"in-progress",
"planning",
"specifying",
"queued",
"merging",
"merging-pr",
"merging-fix",
"mission-validation",
]);
/** Statuses that represent an explicit human-handoff or active merge —
* the ghost-review fallback must not disturb tasks parked in these states. */
const GHOST_REVIEW_PRESERVED_STATUSES = new Set([
@@ -358,6 +368,7 @@ export class SelfHealingManager {
const steps: Array<{ name: string; fn: () => Promise<unknown> }> = [
{ name: "no-progress-no-task-done", fn: () => this.recoverNoProgressNoTaskDoneFailures().then(() => undefined) },
{ name: "completed-tasks", fn: () => this.recoverCompletedTasks().then(() => undefined) },
{ name: "recover-stranded-completed-todo", fn: () => this.recoverStrandedCompletedTodoTasks().then(() => undefined) },
{ name: "stale-incomplete-review", fn: () => this.recoverStaleIncompleteReviewTasks().then(() => undefined) },
{ name: "failed-pre-merge-steps", fn: () => this.recoverReviewTasksWithFailedPreMergeSteps().then(() => undefined) },
{ name: "interrupted-merging", fn: () => this.recoverInterruptedMergingTasks().then(() => undefined) },
@@ -1052,6 +1063,7 @@ export class SelfHealingManager {
// Batch 2 — Task recovery (operations are independent of each other)
const batch2Fns: Array<{ name: string; fn: () => Promise<unknown> }> = [
{ name: "recover-completed-tasks", fn: () => this.recoverCompletedTasks() },
{ name: "recover-stranded-completed-todo", fn: () => this.recoverStrandedCompletedTodoTasks() },
{ name: "recover-stale-incomplete-review", fn: () => this.recoverStaleIncompleteReviewTasks() },
{ name: "recover-failed-pre-merge-steps", fn: () => this.recoverReviewTasksWithFailedPreMergeSteps() },
{ name: "recover-interrupted-merging", fn: () => this.recoverInterruptedMergingTasks() },
@@ -1245,6 +1257,58 @@ export class SelfHealingManager {
}
}
/**
* Recover todo tasks whose implementation steps are fully complete.
*
* This closes the lifecycle gap where self-healing paths can requeue a
* finished task back to todo with progress preserved; these tasks should be
* promoted via normal transition flow instead of waiting for re-execution.
*/
async recoverStrandedCompletedTodoTasks(): Promise<number> {
const recoverFn = this.options.recoverCompletedTask;
if (!recoverFn) return 0;
try {
const tasks = await this.store.listTasks({ column: "todo", slim: true });
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
const stranded = tasks.filter((task) => {
if (task.column !== "todo" || task.paused) return false;
if (executingIds.has(task.id)) return false;
if (task.steps.length === 0 || !task.steps.every((s) => s.status === "done" || s.status === "skipped")) return false;
if (task.error) return false;
if (task.status && STRANDED_COMPLETED_TODO_ACTIVE_STATUSES.has(task.status)) return false;
if (task.reviewState?.refreshStatus === "refreshing") return false;
return true;
});
if (stranded.length === 0) return 0;
log.warn(`Found ${stranded.length} completed task(s) stranded in todo`);
let recovered = 0;
for (const task of stranded) {
const latestExecutingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
if (latestExecutingIds.has(task.id)) {
log.log(`${task.id} started executing concurrently — skipping stranded todo recovery this cycle`);
continue;
}
const success = await recoverFn(task);
if (success) recovered++;
}
if (recovered > 0) {
log.log(`Recovered ${recovered} stranded completed todo task(s)`);
}
return recovered;
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
log.error(`Stranded completed todo task recovery failed: ${errorMessage}`);
return 0;
}
}
/**
* Clear stale transient merge statuses when no active merger owns the task.
*