feat(FN-1646): add stale-spec guard to prevent execution of outdated tasks

- Add spec-staleness evaluator to check task specification age before execution
- Guard executor startup and resume to prevent running tasks with stale specs
- Guard scheduler dispatch to skip stale tasks and move them back to triage
- Add comprehensive tests for spec staleness detection (7d/14d thresholds)
- Add getFusionDir mock for executor staleness check tests
This commit is contained in:
gsxdsm
2026-04-12 17:49:22 -07:00
parent e4853c01f0
commit 860d3c29fe
6 changed files with 481 additions and 0 deletions

View File

@@ -30,6 +30,7 @@ import { StepSessionExecutor, type StepSessionExecutorOptions, type StepResult }
import { resolveAgentInstructions, buildSystemPromptWithInstructions } from "./agent-instructions.js";
import type { AgentReflectionService } from "./agent-reflection.js";
import { createRunAuditor, generateSyntheticRunId, type EngineRunContext } from "./run-audit.js";
import { evaluateSpecStaleness, getPromptPath } from "./spec-staleness.js";
import {
createReflectOnPerformanceTool,
createTaskCreateTool as sharedCreateTaskCreateTool,
@@ -876,6 +877,24 @@ export class TaskExecutor {
// Create run auditor for TaskStore-backed audit emission (no-ops if store doesn't support it)
const audit = createRunAuditor(this.store, engineRunContext);
// Stale spec enforcement: check if PROMPT.md has aged beyond the configured threshold.
// When enabled, stale tasks are moved back to triage with status "needs-respecify"
// so they receive fresh specification before execution. This guard runs early in
// execute() to prevent stale tasks from entering worktree creation or agent sessions.
// If timestamp evaluation is skipped (missing/unreadable file), continue with execution
// so existing filesystem validation paths remain authoritative.
const tasksDir = join(this.store.getFusionDir(), "tasks");
const promptPath = getPromptPath(tasksDir, task.id);
const staleness = await evaluateSpecStaleness({ settings, promptPath });
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-respecify
await this.store.moveTask(task.id, "triage");
await this.store.updateTask(task.id, { status: "needs-respecify" });
await this.store.logEntry(task.id, staleness.reason, undefined, this.currentRunContext);
return;
}
// Hoist worktreePath so it's accessible in the catch block for dep-abort cleanup
// Determine worktree name based on settings
let worktreePath: string;

View File

@@ -136,6 +136,7 @@ function createMockStore(overrides: Record<string, any> = {}) {
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
getRootDir: vi.fn().mockReturnValue("/tmp/root"),
getFusionDir: vi.fn().mockReturnValue("/tmp/root/.fusion"),
getTasksDir: vi.fn().mockReturnValue("/tmp/root/.fusion/tasks"),
updateStep: vi.fn().mockImplementation(async (id: string, step: number, status: StepStatus) => {
return makeTaskDetail(id, "in-progress");

View File

@@ -7,6 +7,7 @@ import { generateReservedWorktreeName, slugify } from "./worktree-names.js";
import { schedulerLog } from "./logger.js";
import { type PrMonitor, type PrComment } from "./pr-monitor.js";
import { reconcileMissionFeatureState } from "./mission-feature-sync.js";
import { evaluateSpecStaleness, getPromptPath } from "./spec-staleness.js";
/**
* Check whether two sets of file scope paths overlap.
@@ -640,6 +641,22 @@ export class Scheduler {
continue;
}
// Stale spec enforcement: check if PROMPT.md has aged beyond the configured threshold.
// When enabled, stale tasks are moved back to triage with status "needs-respecify"
// 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 });
if (staleness.isStale) {
schedulerLog.warn(`Task ${task.id} specification is stale — ${staleness.reason}`);
await this.store.moveTask(task.id, "triage");
await this.store.updateTask(task.id, { status: "needs-respecify" });
await this.store.logEntry(task.id, staleness.reason);
continue;
}
// If staleness evaluation was skipped (missing/unreadable file), continue to
// existing scheduler logic which handles filesystem validation separately.
// Check file scope overlap when enabled
if (settings.groupOverlappingFiles) {
const taskScope = await this.store.parseFileScopeFromPrompt(task.id);

View File

@@ -0,0 +1,284 @@
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";
vi.mock("node:fs/promises", () => ({
stat: vi.fn(),
}));
const mockStat = vi.mocked(stat);
function createMockSettings(overrides: Partial<Settings> = {}): Settings {
return {
specStalenessEnabled: false,
specStalenessMaxAgeMs: 6 * 60 * 60 * 1000,
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: true,
autoMerge: true,
...overrides,
} as Settings;
}
describe("getPromptPath", () => {
it("returns absolute path to PROMPT.md for a task", () => {
const tasksDir = "/project/.fusion/tasks";
const taskId = "FN-001";
expect(getPromptPath(tasksDir, taskId)).toBe(
"/project/.fusion/tasks/FN-001/PROMPT.md",
);
});
it("handles task IDs with different formats", () => {
const tasksDir = "/project/.fusion/tasks";
expect(getPromptPath(tasksDir, "KB-042")).toBe(
"/project/.fusion/tasks/KB-042/PROMPT.md",
);
expect(getPromptPath(tasksDir, "TASK-999")).toBe(
"/project/.fusion/tasks/TASK-999/PROMPT.md",
);
});
});
describe("evaluateSpecStaleness", () => {
const promptPath = "/project/.fusion/tasks/FN-001/PROMPT.md";
const defaultMaxAgeMs = 6 * 60 * 60 * 1000; // 6 hours
describe("disabled mode", () => {
it("returns isStale=false with no file access when specStalenessEnabled is false", async () => {
const settings = createMockSettings({ specStalenessEnabled: false });
const result = await evaluateSpecStaleness({ settings, promptPath });
expect(result.isStale).toBe(false);
expect(result.skipped).toBe(false);
expect(result.reason).toBe("");
expect(result.ageMs).toBeUndefined();
expect(result.maxAgeMs).toBeUndefined();
expect(mockStat).not.toHaveBeenCalled();
});
it("returns isStale=false with no file access when specStalenessEnabled is undefined", async () => {
const settings = createMockSettings({ specStalenessEnabled: undefined });
const result = await evaluateSpecStaleness({ settings, promptPath });
expect(result.isStale).toBe(false);
expect(result.skipped).toBe(false);
expect(mockStat).not.toHaveBeenCalled();
});
it("returns isStale=false with no file access when specStalenessEnabled is null", async () => {
const settings = createMockSettings({ specStalenessEnabled: null as unknown as undefined });
const result = await evaluateSpecStaleness({ settings, promptPath });
expect(result.isStale).toBe(false);
expect(result.skipped).toBe(false);
expect(mockStat).not.toHaveBeenCalled();
});
});
describe("enabled mode", () => {
beforeEach(() => {
mockStat.mockReset();
});
it("returns isStale=false when spec is fresh (ageMs < maxAgeMs)", async () => {
const now = 100_000_000_000;
const mtime = now - (defaultMaxAgeMs - 1000); // 1 second younger than max
mockStat.mockResolvedValue({ mtimeMs: mtime } as Awaited<ReturnType<typeof stat>>);
const settings = createMockSettings({ specStalenessEnabled: true });
const result = await evaluateSpecStaleness({ settings, promptPath, nowMs: now });
expect(result.isStale).toBe(false);
expect(result.skipped).toBe(false);
expect(result.ageMs).toBeLessThan(defaultMaxAgeMs);
expect(result.reason).toBe("");
});
it("returns isStale=false when ageMs === maxAgeMs (boundary is NOT stale)", async () => {
const now = 100_000_000_000;
const mtime = now - defaultMaxAgeMs; // exactly at boundary
mockStat.mockResolvedValue({ mtimeMs: mtime } as Awaited<ReturnType<typeof stat>>);
const settings = createMockSettings({ specStalenessEnabled: true });
const result = await evaluateSpecStaleness({ settings, promptPath, nowMs: now });
expect(result.isStale).toBe(false);
expect(result.reason).toBe("");
});
it("returns isStale=true when spec is stale (ageMs > maxAgeMs)", async () => {
const now = 100_000_000_000;
const mtime = now - defaultMaxAgeMs - 1000; // 1 second older than max
mockStat.mockResolvedValue({ mtimeMs: mtime } as Awaited<ReturnType<typeof stat>>);
const settings = createMockSettings({ specStalenessEnabled: true });
const result = await evaluateSpecStaleness({ settings, promptPath, nowMs: now });
expect(result.isStale).toBe(true);
expect(result.skipped).toBe(false);
expect(result.ageMs).toBe(defaultMaxAgeMs + 1000);
expect(result.maxAgeMs).toBe(defaultMaxAgeMs);
expect(result.reason).toContain("Specification stale");
expect(result.reason).toContain(`age=${defaultMaxAgeMs + 1000}ms`);
expect(result.reason).toContain(`max=${defaultMaxAgeMs}ms`);
expect(result.reason).toContain("moved to triage for re-specification");
});
it("uses custom specStalenessMaxAgeMs when set and valid", async () => {
const now = 100_000_000_000;
const customMaxAge = 60 * 60 * 1000; // 1 hour
const mtime = now - customMaxAge - 1000; // 1 second older than custom max
mockStat.mockResolvedValue({ mtimeMs: mtime } as Awaited<ReturnType<typeof stat>>);
const settings = createMockSettings({
specStalenessEnabled: true,
specStalenessMaxAgeMs: customMaxAge,
});
const result = await evaluateSpecStaleness({ settings, promptPath, nowMs: now });
expect(result.isStale).toBe(true);
expect(result.maxAgeMs).toBe(customMaxAge);
});
it("falls back to default max age when specStalenessMaxAgeMs is undefined", 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,
specStalenessMaxAgeMs: undefined,
});
const result = await evaluateSpecStaleness({ settings, promptPath, nowMs: now });
expect(result.isStale).toBe(true);
expect(result.maxAgeMs).toBe(defaultMaxAgeMs);
});
it("falls back to default max age when specStalenessMaxAgeMs is negative", 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,
specStalenessMaxAgeMs: -1000,
});
const result = await evaluateSpecStaleness({ settings, promptPath, nowMs: now });
expect(result.isStale).toBe(true);
expect(result.maxAgeMs).toBe(defaultMaxAgeMs);
});
it("falls back to default max age when specStalenessMaxAgeMs is zero", 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,
specStalenessMaxAgeMs: 0,
});
const result = await evaluateSpecStaleness({ settings, promptPath, nowMs: now });
expect(result.isStale).toBe(true);
expect(result.maxAgeMs).toBe(defaultMaxAgeMs);
});
it("falls back to default max age when specStalenessMaxAgeMs is NaN", 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,
specStalenessMaxAgeMs: NaN,
});
const result = await evaluateSpecStaleness({ settings, promptPath, nowMs: now });
expect(result.isStale).toBe(true);
expect(result.maxAgeMs).toBe(defaultMaxAgeMs);
});
});
describe("skipped behavior (missing/unreadable file)", () => {
beforeEach(() => {
mockStat.mockReset();
});
it("skips when PROMPT.md does not exist (ENOENT)", async () => {
mockStat.mockRejectedValue(new Error("ENOENT: no such file or directory"));
const settings = createMockSettings({ specStalenessEnabled: true });
const result = await evaluateSpecStaleness({ settings, promptPath });
expect(result.isStale).toBe(false);
expect(result.skipped).toBe(true);
expect(result.reason).toBe("");
expect(result.ageMs).toBeUndefined();
expect(result.maxAgeMs).toBeUndefined();
});
it("skips when PROMPT.md is unreadable (EACCES)", async () => {
mockStat.mockRejectedValue(new Error("EACCES: permission denied"));
const settings = createMockSettings({ specStalenessEnabled: true });
const result = await evaluateSpecStaleness({ settings, promptPath });
expect(result.isStale).toBe(false);
expect(result.skipped).toBe(true);
});
it("skips on any stat error without throwing", async () => {
mockStat.mockRejectedValue(new Error("Unknown error"));
const settings = createMockSettings({ specStalenessEnabled: true });
// Should not throw
const result = await evaluateSpecStaleness({ settings, promptPath });
expect(result.isStale).toBe(false);
expect(result.skipped).toBe(true);
});
it("does not set reason when skipped", async () => {
mockStat.mockRejectedValue(new Error("ENOENT"));
const settings = createMockSettings({ specStalenessEnabled: true });
const result = await evaluateSpecStaleness({ settings, promptPath });
expect(result.reason).toBe("");
});
});
describe("nowMs parameter (deterministic testing)", () => {
it("uses provided nowMs instead of Date.now()", async () => {
const fixedNow = 100_000_000_000;
const mtime = fixedNow - 1000; // 1 second old
mockStat.mockResolvedValue({ mtimeMs: mtime } as Awaited<ReturnType<typeof stat>>);
const settings = createMockSettings({ specStalenessEnabled: true });
const result = await evaluateSpecStaleness({ settings, promptPath, nowMs: fixedNow });
expect(result.ageMs).toBe(1000);
});
it("handles very old files correctly with fixed nowMs", async () => {
const fixedNow = 100_000_000_000;
const oldMtime = 0; // Unix epoch
mockStat.mockResolvedValue({ mtimeMs: oldMtime } as Awaited<ReturnType<typeof stat>>);
const settings = createMockSettings({ specStalenessEnabled: true });
const result = await evaluateSpecStaleness({ settings, promptPath, nowMs: fixedNow });
expect(result.isStale).toBe(true);
expect(result.ageMs).toBe(fixedNow);
});
});
});

View File

@@ -0,0 +1,155 @@
/**
* Spec Staleness Evaluator
*
* Evaluates whether a task's PROMPT.md has become stale based on file modification time.
* When spec staleness enforcement is enabled, tasks whose specification age exceeds
* the configured threshold must be re-triaged before execution.
*/
import { stat } from "node:fs/promises";
import { join } from "node:path";
import type { Settings } 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;
/**
* Result of a spec staleness evaluation.
*
* When `skipped` is true, the evaluation could not determine staleness due to
* missing/unreadable files, and callers should fall back to existing filesystem
* validation logic without throwing.
*/
export interface SpecStalenessResult {
/** Whether the specification is considered stale and requires re-triaging. */
isStale: boolean;
/** Age of the PROMPT.md in milliseconds at evaluation time. Undefined when skipped. */
ageMs: number | undefined;
/** Maximum allowed age in milliseconds. Undefined when skipped. */
maxAgeMs: number | undefined;
/** Human-readable reason for the decision. Empty string when skipped. */
reason: string;
/**
* Whether evaluation was skipped due to missing/unreadable PROMPT.md.
* When true, `isStale` is always false and callers should not stale-reroute.
*/
skipped: boolean;
}
/**
* Input options for spec staleness evaluation.
*/
export interface EvaluateSpecStalenessOptions {
/** Merged project settings containing staleness configuration. */
settings: Settings;
/** Absolute path to the task's PROMPT.md file. */
promptPath: string;
/**
* Optional current timestamp in milliseconds (for deterministic testing).
* Defaults to `Date.now()` when not provided.
*/
nowMs?: number;
}
/**
* Evaluate whether a task's specification (PROMPT.md) is stale.
*
* ## Configuration
*
* - `specStalenessEnabled`: When `true`, enforces staleness checking.
* When `false`/`undefined`, always returns `isStale: false` with no file access.
*
* - `specStalenessMaxAgeMs`: Maximum age in milliseconds before a spec is stale.
* Defaults to `6 * 60 * 60 * 1000` (6 hours) when not set or invalid.
*
* ## Staleness Logic
*
* A spec is stale when `ageMs > maxAgeMs`.
* The boundary condition `ageMs === maxAgeMs` is NOT stale (exclusive comparison).
*
* ## Skipped Behavior
*
* When PROMPT.md cannot be read (missing, unreadable, or stat fails):
* - Returns `skipped: true`, `isStale: false`
* - Does NOT throw — callers should fall back to existing filesystem validation
* - This ensures missing-file semantics remain authoritative in the scheduler/executor
*
* ## Disabled Behavior
*
* When `specStalenessEnabled !== true`:
* - Returns immediately with `isStale: false`, `skipped: false`, empty reason
* - No file system access is performed
*
* @param options - Evaluation options including settings and PROMPT.md path
* @returns Spec staleness decision with staleness flag, metrics, and skip indicator
*/
export async function evaluateSpecStaleness(
options: EvaluateSpecStalenessOptions,
): Promise<SpecStalenessResult> {
const { settings, promptPath, nowMs } = options;
// Disabled mode: strict no-op — no file access
if (settings.specStalenessEnabled !== true) {
return {
isStale: false,
ageMs: undefined,
maxAgeMs: undefined,
reason: "",
skipped: false,
};
}
// Resolve max age with fallback to default
const configuredMaxAgeMs = settings.specStalenessMaxAgeMs;
const maxAgeMs =
typeof configuredMaxAgeMs === "number" && configuredMaxAgeMs > 0
? configuredMaxAgeMs
: DEFAULT_SPEC_STALENESS_MAX_AGE_MS;
const now = nowMs ?? Date.now();
// Attempt to stat PROMPT.md for mtime
let mtimeMs: number;
try {
const fileStat = await stat(promptPath);
mtimeMs = fileStat.mtimeMs;
} catch {
// File missing or unreadable — skip staleness evaluation
// Callers should fall back to existing filesystem validation
return {
isStale: false,
ageMs: undefined,
maxAgeMs: undefined,
reason: "",
skipped: true,
};
}
const ageMs = now - mtimeMs;
// Exclusive comparison: ageMs === maxAgeMs is NOT stale
const isStale = ageMs > maxAgeMs;
const reason = isStale
? `Specification stale (age=${ageMs}ms, max=${maxAgeMs}ms) — moved to triage for re-specification`
: "";
return {
isStale,
ageMs,
maxAgeMs,
reason,
skipped: false,
};
}
/**
* Get the PROMPT.md path for a task given the tasks directory and task ID.
*
* @param tasksDir - The project's tasks directory (e.g., `.fusion/tasks`)
* @param taskId - The task ID (e.g., `FN-001`)
* @returns Absolute path to the task's PROMPT.md file
*/
export function getPromptPath(tasksDir: string, taskId: string): string {
return join(tasksDir, taskId, "PROMPT.md");
}