Files
fusion/packages/engine/src/spec-staleness.ts
Fusion bbc872cee9 feat(FN-2603): merge fusion/fn-2603 (auto-resolved)
- test(FN-2603): update downstream tests for planning label expectations
- test(FN-2603): complete Step 6 — update engine tests for planning terminology
- feat(FN-2603): complete Step 5 — rename self-healing planning APIs
- feat(FN-2603): complete Step 4 — rename needs-respecify status to needs-replan
- feat(FN-2603): complete Step 3 — rename specifying and re-specification text
- feat(FN-2603): complete Step 2 — rename triageLog usages to planLog
- feat(FN-2603): complete Step 1 — rename triage logger to plan logger
- feat(FN-2602): complete Step 7 — add release changeset
- test(FN-2602): complete Step 6 — align migration and schema tests
- test(FN-2602): complete Step 5 — update store status assertions
- feat(FN-2602): complete Step 4 — rename triage prompt labels
- feat(FN-2602): complete Step 3 — add status rename migration
- feat(FN-2602): complete Step 2 — rename respecify status literals
- feat(FN-2602): complete Step 1 — rename triage display labels
2026-04-26 12:45:05 -07:00

156 lines
4.9 KiB
TypeScript

/**
* 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-planned 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-planning. */
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-planning`
: "";
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");
}