fix: recover completed tasks stuck in progress

This commit is contained in:
gsxdsm
2026-04-05 15:24:32 -07:00
parent cb941df102
commit f4012fc5f2
10 changed files with 500 additions and 36 deletions

View File

@@ -16,7 +16,7 @@
import { execSync } from "node:child_process";
import { existsSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import type { TaskStore, Settings } from "@fusion/core";
import type { TaskStore, Settings, Task } from "@fusion/core";
import { createLogger } from "./logger.js";
import { scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
@@ -25,6 +25,21 @@ const log = createLogger("self-healing");
export interface SelfHealingOptions {
/** Project root directory (parent of .worktrees/) */
rootDir: string;
/**
* Callback to recover a completed task that is stuck in in-progress.
* Called by the periodic maintenance cycle when it detects a task whose
* work is done but was never transitioned to in-review (e.g., killed by
* stuck detector after task_done but before moveTask).
*
* Should return true if the task was successfully transitioned out of
* in-progress, false if recovery failed.
*/
recoverCompletedTask?: (task: Task) => Promise<boolean>;
/**
* Returns the set of task IDs currently being executed by the executor.
* Used to avoid recovering tasks that are actively being worked on.
*/
getExecutingTaskIds?: () => Set<string>;
}
export class SelfHealingManager {
@@ -235,6 +250,7 @@ export class SelfHealingManager {
await this.cleanupOrphanedBranches();
this.checkpointWal();
await this.enforceWorktreeCap();
await this.recoverCompletedTasks();
const elapsedMs = Date.now() - startMs;
log.log(`Maintenance cycle completed in ${elapsedMs}ms`);
@@ -243,6 +259,55 @@ export class SelfHealingManager {
}
}
// ── Completed task recovery ──────────────────────────────────────
/**
* Recover tasks stuck in in-progress whose work is actually complete.
*
* This catches tasks where the agent called task_done() (all steps marked
* done, summary written) but the session was killed before the executor
* could call moveTask("in-review"). Without this, such tasks sit
* indefinitely in in-progress with no active session.
*
* @returns Number of tasks recovered
*/
async recoverCompletedTasks(): Promise<number> {
const recoverFn = this.options.recoverCompletedTask;
if (!recoverFn) return 0;
try {
const tasks = await this.store.listTasks();
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
const stuckCompleted = tasks.filter((t) =>
t.column === "in-progress" &&
!t.paused &&
!executingIds.has(t.id) &&
t.steps.length > 0 &&
t.steps.every((s) => s.status === "done" || s.status === "skipped"),
);
if (stuckCompleted.length === 0) return 0;
log.warn(`Found ${stuckCompleted.length} completed task(s) stuck in in-progress`);
let recovered = 0;
for (const task of stuckCompleted) {
log.log(`Recovering completed task ${task.id}: ${task.title || task.description.slice(0, 60)}`);
const success = await recoverFn(task);
if (success) recovered++;
}
if (recovered > 0) {
log.log(`Recovered ${recovered} completed task(s) → in-review`);
}
return recovered;
} catch (err: any) {
log.error(`Completed task recovery failed: ${err.message}`);
return 0;
}
}
/** Run `git worktree prune` to clean stale metadata. */
private async pruneWorktrees(): Promise<void> {
try {