fix(engine): preserve parked progress through stale-spec checks

This commit is contained in:
Phil Larson
2026-05-30 21:47:35 -07:00
parent ab38ee09e0
commit ce6ebb7b8e
5 changed files with 128 additions and 5 deletions

View File

@@ -350,6 +350,59 @@ describe("TaskExecutor bounded recovery retries", () => {
}));
});
it("does not clobber self-healing parked incomplete-task pause metadata during abort cleanup", async () => {
const store = createMockStore();
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "FN-001",
title: "Test",
description: "Test",
column: "todo",
status: "queued",
paused: true,
userPaused: false,
pausedReason: undefined,
branch: "fusion/fn-001",
worktree: null,
dependencies: [],
steps: [{ name: "Testing & Verification", status: "in-progress" }],
currentStep: 6,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const executor = new TaskExecutor(store, "/tmp/test", {});
mockedCreateFnAgent.mockRejectedValue(new Error("Aborted"));
(executor as any).pausedAborted.add("FN-001");
await executor.execute({
id: "FN-001",
title: "Test",
description: "Test",
column: "in-progress",
recoveryRetryCount: 1,
branch: "fusion/fn-001",
dependencies: [],
steps: [{ name: "Testing & Verification", status: "in-progress" }],
currentStep: 6,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", expect.objectContaining({
worktree: undefined,
branch: undefined,
}));
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo");
expect(store.logEntry).toHaveBeenCalledWith(
"FN-001",
"Execution abort cleanup skipped — incomplete stuck-loop task is already parked with progress preserved",
undefined,
expect.anything(),
);
});
it("does NOT consume retry budget for stuck-task-detector kills", async () => {
const store = createMockStore();

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { evaluateSpecStaleness, getPromptPath } from "../spec-staleness.js";
import { stat } from "node:fs/promises";
import { join } from "node:path";
import type { Settings } from "@fusion/core";
import type { Settings, Task } from "@fusion/core";
vi.mock("node:fs/promises", () => ({
stat: vi.fn(),
@@ -131,6 +131,33 @@ describe("evaluateSpecStaleness", () => {
expect(result.reason).toContain("moved to triage for re-planning");
});
it("skips stale-spec rerouting for parked tasks with preserved execution progress", async () => {
const now = 100_000_000_000;
const mtime = now - defaultMaxAgeMs - 1000;
mockStat.mockResolvedValue({ mtimeMs: mtime } as Awaited<ReturnType<typeof stat>>);
const settings = createMockSettings({ specStalenessEnabled: true });
const task = {
id: "FN-249",
column: "todo",
currentStep: 6,
paused: true,
pausedReason: "stuck-loop-exhausted-incomplete-steps",
steps: [
{ name: "Implement", status: "done" },
{ name: "Testing & Verification", status: "in-progress" },
{ name: "Documentation & Delivery", status: "pending" },
],
} as Task;
const result = await evaluateSpecStaleness({ settings, promptPath, nowMs: now, task });
expect(result.isStale).toBe(false);
expect(result.skipped).toBe(true);
expect(result.reason).toContain("preserved execution progress");
expect(mockStat).not.toHaveBeenCalled();
});
it("uses custom specStalenessMaxAgeMs when set and valid", async () => {
const now = 100_000_000_000;
const customMaxAge = 60 * 60 * 1000; // 1 hour

View File

@@ -3161,7 +3161,7 @@ export class TaskExecutor {
if (!isActiveTask) {
const tasksDir = join(this.store.getFusionDir(), "tasks");
const promptPath = getPromptPath(tasksDir, task.id);
const staleness = await evaluateSpecStaleness({ settings, promptPath });
const staleness = await evaluateSpecStaleness({ settings, promptPath, task });
if (staleness.isStale) {
executorLog.warn(`Task ${task.id} specification is stale — ${staleness.reason}`);
// Move to triage first, then set status so the task enters triage with needs-replan
@@ -4939,6 +4939,21 @@ export class TaskExecutor {
return;
}
this.pausedAborted.delete(task.id);
const latestTask = await this.store.getTask(task.id);
if (
latestTask?.column === "todo" &&
latestTask.paused === true &&
((latestTask.currentStep ?? 0) > 0 || latestTask.steps?.some((step) => step.status === "done" || step.status === "in-progress"))
) {
executorLog.log(`${task.id} paused-abort cleanup skipped — incomplete task is already parked with progress preserved`);
await this.store.logEntry(
task.id,
"Execution abort cleanup skipped — incomplete stuck-loop task is already parked with progress preserved",
undefined,
this.getRunContextFor(task.id),
);
return;
}
if (await this.shouldFinalizeCompletedTask(task.id, taskDone)) {
if (await this.shouldDeferCompletionForGlobalPause(task.id, "paused after completion")) {
return;

View File

@@ -1281,7 +1281,7 @@ export class Scheduler {
// so they receive fresh specification before execution. This guard runs after
// filesystem validation so missing/unreadable files skip staleness checks entirely.
const promptPath = getPromptPath(this.store.getTasksDir(), task.id);
const staleness = await evaluateSpecStaleness({ settings, promptPath });
const staleness = await evaluateSpecStaleness({ settings, promptPath, task });
if (staleness.isStale) {
schedulerLog.warn(`Task ${task.id} specification is stale — ${staleness.reason}`);
await this.store.moveTask(task.id, "triage");

View File

@@ -8,7 +8,7 @@
import { stat } from "node:fs/promises";
import { join } from "node:path";
import type { Settings } from "@fusion/core";
import type { Settings, Task } from "@fusion/core";
/** Default maximum age for a specification before it is considered stale (6 hours in ms). */
const DEFAULT_SPEC_STALENESS_MAX_AGE_MS = 6 * 60 * 60 * 1000;
@@ -49,6 +49,12 @@ export interface EvaluateSpecStalenessOptions {
* Defaults to `Date.now()` when not provided.
*/
nowMs?: number;
/**
* Optional task metadata. When provided, evaluation skips already-started,
* parked work so preserved progress is not sent back through triage solely
* because the original PROMPT.md mtime exceeded the staleness threshold.
*/
task?: Pick<Task, "id" | "column" | "status" | "currentStep" | "steps" | "pausedReason">;
}
/**
@@ -83,10 +89,22 @@ export interface EvaluateSpecStalenessOptions {
* @param options - Evaluation options including settings and PROMPT.md path
* @returns Spec staleness decision with staleness flag, metrics, and skip indicator
*/
export function shouldSkipSpecStalenessForPreservedProgress(
task: EvaluateSpecStalenessOptions["task"] | undefined,
): boolean {
if (!task || task.column === "triage" || task.status === "needs-replan" || task.status === "planning") {
return false;
}
if ((task.currentStep ?? 0) > 0) {
return true;
}
return !!task.steps?.some((step) => step.status === "done" || step.status === "in-progress");
}
export async function evaluateSpecStaleness(
options: EvaluateSpecStalenessOptions,
): Promise<SpecStalenessResult> {
const { settings, promptPath, nowMs } = options;
const { settings, promptPath, nowMs, task } = options;
// Disabled mode: strict no-op — no file access
if (settings.specStalenessEnabled !== true) {
@@ -99,6 +117,16 @@ export async function evaluateSpecStaleness(
};
}
if (shouldSkipSpecStalenessForPreservedProgress(task)) {
return {
isStale: false,
ageMs: undefined,
maxAgeMs: undefined,
reason: "Specification staleness skipped for task with preserved execution progress",
skipped: true,
};
}
// Resolve max age with fallback to default
const configuredMaxAgeMs = settings.specStalenessMaxAgeMs;
const maxAgeMs =