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:
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
284
packages/engine/src/spec-staleness.test.ts
Normal file
284
packages/engine/src/spec-staleness.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
155
packages/engine/src/spec-staleness.ts
Normal file
155
packages/engine/src/spec-staleness.ts
Normal 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");
|
||||
}
|
||||
Reference in New Issue
Block a user