feat(run): resume-aware per-check runs, verify hooks, run-state hardening
/pygienium-<check> is now resume-aware (terminal checks skipped unless --fresh) and shares run-state with all/resume; every check gets a verify hook that fails loudly when a sub-agent returns ok with no artifact; run-state clears stale errors on retry success and reconciles a run as failed only when every check failed. Drops the superseded hygiene-state.ts model.
This commit is contained in:
@@ -270,6 +270,34 @@ async function complexityGate(cwd: string): Promise<string | undefined> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify hook: confirms the check actually produced its artifacts. After the
|
||||
* scan phase `findings.md` must exist; after the fix phase `changes.md` must
|
||||
* exist too. Without this, a sub-agent that returns empty/ok without writing
|
||||
* its report would be stamped `complete` — a false positive. Mirrors
|
||||
* {@link commentsVerify} / {@link todosVerify}.
|
||||
*/
|
||||
async function complexityVerify(
|
||||
scope: CheckScope,
|
||||
): Promise<string | undefined> {
|
||||
const { stat } = await import("node:fs/promises");
|
||||
const f = findingsPath(scope);
|
||||
try {
|
||||
await stat(f);
|
||||
} catch {
|
||||
return `complexity verify: expected findings.md at ${f} after scan, none found.`;
|
||||
}
|
||||
if (scope.fix) {
|
||||
const c = changesPath(scope);
|
||||
try {
|
||||
await stat(c);
|
||||
} catch {
|
||||
return `complexity verify: expected changes.md at ${c} after --fix, none found.`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The excessive complexity check definition. */
|
||||
export const complexityCheck = {
|
||||
name: "complexity",
|
||||
@@ -282,6 +310,7 @@ export const complexityCheck = {
|
||||
buildScanTask: buildComplexityScanTask,
|
||||
buildFixTask: buildComplexityFixTask,
|
||||
gate: complexityGate,
|
||||
verify: complexityVerify,
|
||||
} as const;
|
||||
|
||||
// Self-register on import so index.ts auto-discovery picks it up.
|
||||
|
||||
@@ -1104,6 +1104,31 @@ export async function deadCodeGate(cwd: string): Promise<string | undefined> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify hook: confirms the check actually produced its artifacts (mirrors
|
||||
* {@link commentsVerify} / {@link todosVerify}). After scan `findings.md`
|
||||
* must exist; after `--fix` `changes.md` must exist too. Catches a sub-agent
|
||||
* that returns ok with no output — which would otherwise be a false
|
||||
* `complete`.
|
||||
*/
|
||||
async function deadCodeVerify(scope: CheckScope): Promise<string | undefined> {
|
||||
const f = findingsPath(scope.target);
|
||||
try {
|
||||
await stat(f);
|
||||
} catch {
|
||||
return `dead-code verify: expected findings.md at ${f} after scan, none found.`;
|
||||
}
|
||||
if (scope.fix) {
|
||||
const c = changesPath(scope.target);
|
||||
try {
|
||||
await stat(c);
|
||||
} catch {
|
||||
return `dead-code verify: expected changes.md at ${c} after --fix, none found.`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The registered `CheckDefinition` (module self-registers on import). */
|
||||
export const deadCodeCheck: CheckDefinition = {
|
||||
name: "dead-code",
|
||||
@@ -1116,6 +1141,7 @@ export const deadCodeCheck: CheckDefinition = {
|
||||
buildScanTask: buildDeadCodeScanTask,
|
||||
buildFixTask: buildDeadCodeFixTask,
|
||||
gate: deadCodeGate,
|
||||
verify: deadCodeVerify,
|
||||
};
|
||||
|
||||
// Self-register so `index.ts` auto-discovers this check with zero wiring edits.
|
||||
|
||||
@@ -69,6 +69,33 @@ function deepModulesGate(cwd: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify hook: confirms the check actually produced its artifacts (mirrors
|
||||
* {@link commentsVerify} / {@link todosVerify}). After scan `findings.md` must
|
||||
* exist; after `--fix` `changes.md` must exist too. Catches a sub-agent that
|
||||
* returns ok with no output — which would otherwise be a false `complete`.
|
||||
*/
|
||||
async function deepModulesVerify(
|
||||
scope: CheckScope,
|
||||
): Promise<string | undefined> {
|
||||
const { stat } = await import("node:fs/promises");
|
||||
const f = findingsPath(scope.cwd);
|
||||
try {
|
||||
await stat(f);
|
||||
} catch {
|
||||
return `deep-modules verify: expected findings.md at ${f} after scan, none found.`;
|
||||
}
|
||||
if (scope.fix) {
|
||||
const c = changesPath(scope.cwd);
|
||||
try {
|
||||
await stat(c);
|
||||
} catch {
|
||||
return `deep-modules verify: expected changes.md at ${c} after --fix, none found.`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the scan task. The deep-modules scanner agent inspects the target,
|
||||
* classifies modules by abstraction depth against the rubric, and writes a
|
||||
@@ -155,6 +182,7 @@ const deepModulesCheck: CheckDefinition = {
|
||||
buildScanTask: buildDeepScanTask,
|
||||
buildFixTask: buildDeepFixTask,
|
||||
gate: deepModulesGate,
|
||||
verify: deepModulesVerify,
|
||||
};
|
||||
|
||||
registerCheck(deepModulesCheck);
|
||||
|
||||
@@ -89,6 +89,33 @@ function defensiveGuardsGate(cwd: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify hook: confirms the check actually produced its artifacts (mirrors
|
||||
* {@link commentsVerify} / {@link todosVerify}). After scan `findings.md` must
|
||||
* exist; after `--fix` `changes.md` must exist too. Catches a sub-agent that
|
||||
* returns ok with no output — which would otherwise be a false `complete`.
|
||||
*/
|
||||
async function defensiveGuardsVerify(
|
||||
scope: CheckScope,
|
||||
): Promise<string | undefined> {
|
||||
const { stat } = await import("node:fs/promises");
|
||||
const f = findingsPath(scope.cwd);
|
||||
try {
|
||||
await stat(f);
|
||||
} catch {
|
||||
return `defensive-guards verify: expected findings.md at ${f} after scan, none found.`;
|
||||
}
|
||||
if (scope.fix) {
|
||||
const c = changesPath(scope.cwd);
|
||||
try {
|
||||
await stat(c);
|
||||
} catch {
|
||||
return `defensive-guards verify: expected changes.md at ${c} after --fix, none found.`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the scan task. The defensive-guards scanner agent inspects the target,
|
||||
* classifies each guard as redundant or a legitimate boundary guard against the
|
||||
@@ -185,6 +212,7 @@ const defensiveGuardsCheck: CheckDefinition = {
|
||||
buildScanTask: buildDefensiveGuardsScanTask,
|
||||
buildFixTask: buildDefensiveGuardsFixTask,
|
||||
gate: defensiveGuardsGate,
|
||||
verify: defensiveGuardsVerify,
|
||||
};
|
||||
|
||||
registerCheck(defensiveGuardsCheck);
|
||||
|
||||
@@ -16,10 +16,11 @@ import {
|
||||
getCheck,
|
||||
type CheckDefinition,
|
||||
} from "./checks/registry.js";
|
||||
import { runCheck, parseCheckArgs } from "./modes/check-runner.js";
|
||||
import {
|
||||
runCheck,
|
||||
parseCheckArgs,
|
||||
type CheckRunOutcome,
|
||||
} from "./modes/check-runner.js";
|
||||
import { parseAllArgs, runAllChecks, allSummaryPath } from "./modes/all.js";
|
||||
import { buildPygieniumHelpLines } from "./help.js";
|
||||
import {
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
markRunStatus,
|
||||
reconcileRunStatus,
|
||||
resetCheckEntry,
|
||||
isCheckTerminal,
|
||||
shouldRunOnResume,
|
||||
} from "./run-state.js";
|
||||
import { formatRunStatus } from "./status.js";
|
||||
@@ -116,21 +118,58 @@ export async function handleHelpCommand(
|
||||
* Exported so `index.ts` can bind one per registered `CheckDefinition` and so
|
||||
* tests can invoke it directly with a stub context.
|
||||
*/
|
||||
/**
|
||||
* `/pygienium-<check> [path] [--fix] [--fresh] [--no-gitignore]` — the
|
||||
* per-check command handler. Exported so `index.ts` binds one per registered
|
||||
* `CheckDefinition` and tests invoke it directly with a stub context.
|
||||
*
|
||||
* Resume-aware (parity with `/pygienium-all` and `/pygienium-resume`): a check
|
||||
* already terminal (`complete`/`skipped`) is NOT re-dispatched unless `--fresh`
|
||||
* resets its run-state entry. A failed/pending/in-progress check is re-run from
|
||||
* analysis — recovering the exact failure mode the MagniFluo run exposed
|
||||
* (sub-agent returns ok with no output → verify now fails loudly → resume
|
||||
* re-runs the analysis and the artifact lands).
|
||||
*/
|
||||
export async function handleCheckCommand(
|
||||
check: CheckDefinition,
|
||||
args: string,
|
||||
ctx: PygieniumCtx,
|
||||
): Promise<void> {
|
||||
const { fix, rest } = splitFlags(args);
|
||||
const { fix, fresh, rest, noGitignore } = splitFlags(args);
|
||||
const target = resolveCwd(rest, ctx.cwd);
|
||||
const scope = parseCheckArgs(fix ? `--fix ${rest}` : rest, ctx.cwd);
|
||||
|
||||
// Resume semantics: skip an already-terminal check unless --fresh forces a
|
||||
// reset. This mirrors the all/resume skip predicate so running the same
|
||||
// per-check command again after a success is a no-op (use --fresh to
|
||||
// re-scan deliberately).
|
||||
const existing = await loadRunState(ctx.cwd);
|
||||
const entry = existing?.checks[check.name];
|
||||
if (entry && isCheckTerminal(entry) && !fresh) {
|
||||
print(
|
||||
ctx,
|
||||
`pygienium ${check.label}: already ${entry.status} (use --fresh to re-run)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Reset the entry when --fresh, or when the fix flag changed since the prior
|
||||
// run: the phase skeleton (fix phase present only with --fix) must match
|
||||
// the requested mode, otherwise re-running analysis wouldn't record a fix
|
||||
// phase entry on a scan-only→--fix transition (and vice versa).
|
||||
if (existing && entry && (fresh || entry.fix !== fix)) {
|
||||
resetCheckEntry(existing, check.name, fix);
|
||||
await saveRunState(existing);
|
||||
}
|
||||
|
||||
const outcome = await runCheck({
|
||||
check,
|
||||
cwd: ctx.cwd,
|
||||
scope: { ...scope, cwd: ctx.cwd, target },
|
||||
scope: { ...scope, cwd: ctx.cwd, target, fix },
|
||||
existingState: existing,
|
||||
ui: ctx.ui,
|
||||
hasUI: ctx.hasUI,
|
||||
sendChatMessage: ctx.sendChatMessage,
|
||||
gitignore: !noGitignore,
|
||||
});
|
||||
|
||||
const giNote = outcome.gitignoreAppended
|
||||
@@ -145,7 +184,7 @@ export async function handleCheckCommand(
|
||||
/**
|
||||
* `/pygienium-all [path] [--fix] [--fresh] [--only=a,b]` — run every
|
||||
* registered check in sequence under a unified status strip, writing
|
||||
* `pygienium/all-summary.md`. Delegates to {@link runAllChecks}.
|
||||
* `.pygienium/all-summary.md`. Delegates to {@link runAllChecks}.
|
||||
*/
|
||||
export async function handleAllCommand(
|
||||
args: string,
|
||||
@@ -246,32 +285,36 @@ export async function handleResumeCommand(
|
||||
resetCheckEntry(state, entry.name);
|
||||
}
|
||||
print(ctx, `pygienium: resuming ${def.label}…`);
|
||||
const outcome = await runCheck({
|
||||
const outcome: CheckRunOutcome = await runCheck({
|
||||
check: def,
|
||||
cwd,
|
||||
scope: { cwd, target: cwd, fix: entry.fix, rest: [] },
|
||||
ui: ctx.ui,
|
||||
hasUI: ctx.hasUI,
|
||||
existingState: state,
|
||||
sendChatMessage: ctx.sendChatMessage,
|
||||
gitignore,
|
||||
});
|
||||
state = outcome.state;
|
||||
giAppended = giAppended || outcome.gitignoreAppended === true;
|
||||
ran++;
|
||||
print(ctx, `pygienium ${def.label}: ${outcome.status}`);
|
||||
}
|
||||
|
||||
markRunStatus(state, reconcileRunStatus(state));
|
||||
await saveRunState(state);
|
||||
const giNote = giAppended ? " · .pygienium/ added to .gitignore" : "";
|
||||
print(
|
||||
ctx,
|
||||
`pygienium: resume done — re-dispatched ${ran}, skipped ${skipped}; run ${state.status} (${runStatePath(cwd)})`,
|
||||
`pygienium: resume done — re-dispatched ${ran}, skipped ${skipped}; run ${state.status} (${runStatePath(cwd)})${giNote}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `/pygienium-export [path] [--check=<n>[,<n>]] [--status=<s>[,<s>]] [--out=md|json]`
|
||||
* — collect every check's `findings.md`/`changes.md` artifacts from
|
||||
* `pygienium/checks/<name>/` (and the legacy `.pygienium/checks/` root), apply
|
||||
* filters, and write a single bundle to `pygienium/export.{md|json}`.
|
||||
* `.pygienium/checks/<name>/`, apply filters, and write a single bundle to
|
||||
* `.pygienium/export.{md|json}`.
|
||||
*/
|
||||
export async function handleExportCommand(
|
||||
args: string,
|
||||
|
||||
@@ -53,7 +53,7 @@ export const CLI_FLAGS: HelpFlag[] = [
|
||||
},
|
||||
{
|
||||
name: "--fresh",
|
||||
scope: "all, resume",
|
||||
scope: "<check>, all, resume",
|
||||
description:
|
||||
"Re-dispatch completed checks too — reset their run-state entries and re-run.",
|
||||
},
|
||||
@@ -98,15 +98,15 @@ export const COMMANDS: HelpCommand[] = [
|
||||
example: "/pygienium-help",
|
||||
},
|
||||
{
|
||||
usage: "pygienium-<check> [path] [--fix]",
|
||||
usage: "pygienium-<check> [path] [--fix] [--fresh]",
|
||||
description:
|
||||
"Run one isolated sub-agent that scans a target, applies fixes with --fix, and emits a findings+changes report.",
|
||||
"Run one isolated sub-agent that scans a target, applies fixes with --fix, and emits a findings+changes report. Resume-aware: a completed/skipped check is skipped unless --fresh re-runs it.",
|
||||
example: "/pygienium-comments src --fix",
|
||||
},
|
||||
{
|
||||
usage: "pygienium-all [path] [--fix] [--fresh] [--only=a,b]",
|
||||
description:
|
||||
"Run every registered check in sequence under one resumable run-state with a unified status strip; writes pygienium/all-summary.md. --fresh re-runs completed checks; --only narrows to a check subset.",
|
||||
"Run every registered check in sequence under one resumable run-state with a unified status strip; writes .pygienium/all-summary.md. --fresh re-runs completed checks; --only narrows to a check subset.",
|
||||
example: "/pygienium-all --fix",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,472 +0,0 @@
|
||||
/**
|
||||
* On-disk model for `pygienium/run-state.json`.
|
||||
*
|
||||
* This is the single source of truth for `/pygienium-status`, `/pygienium-resume`
|
||||
* and the per-run progress the orchestrator reports. The shape is ported from
|
||||
* piolium's `audit-state.ts`, with `audit` → `run` and `phase` → `check` renames
|
||||
* applied so this extension's vocabulary is run/check throughout.
|
||||
*
|
||||
* Snake-case keys are an intentional, persisted on-disk contract — downstream
|
||||
* tasks (06, 12, 13) read them back when resuming or reporting a run. Don't
|
||||
* camelCase them.
|
||||
*
|
||||
* Writes go through `withFileMutationQueue` (process-local serialization) +
|
||||
* temp-file-rename (atomic on POSIX). The combination prevents both
|
||||
* intra-process write-write races and partially-written files on crash.
|
||||
*
|
||||
* Schema is forward-compatible by addition only within this build — no legacy
|
||||
* migration paths exist, so new fields must be optional and additive.
|
||||
*/
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
/** A hygiene run's execution mode — free string ("all" for /all, or a check name). */
|
||||
export type RunMode = string;
|
||||
export type RunStatus = "pending" | "in_progress" | "complete" | "failed";
|
||||
export type CheckStatus =
|
||||
| "pending"
|
||||
| "in_progress"
|
||||
| "complete"
|
||||
| "failed"
|
||||
| "skipped";
|
||||
|
||||
/**
|
||||
* Per-check progress snapshot, persisted inside `checks.<name>`. Mirrors how
|
||||
* the annotating check runner records status, artifacts, attempts and the last
|
||||
* error so a resume can pick up where an interrupted run left off.
|
||||
*/
|
||||
export interface CheckState {
|
||||
status: CheckStatus;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
error?: string;
|
||||
artifacts?: string[];
|
||||
attempt?: number;
|
||||
max_attempts?: number;
|
||||
retry_backoff_ms?: number;
|
||||
next_retry_at?: string;
|
||||
last_error?: string;
|
||||
heartbeat_at?: string;
|
||||
last_event_at?: string;
|
||||
last_tool?: string;
|
||||
last_tool_summary?: string;
|
||||
run_id?: string;
|
||||
}
|
||||
|
||||
/** One hygiene run: metadata + per-check progress. */
|
||||
export interface HygieneRunState {
|
||||
run_id: string;
|
||||
commit?: string | null;
|
||||
branch?: string;
|
||||
repository?: string;
|
||||
history_available?: boolean;
|
||||
mode: RunMode;
|
||||
model?: string;
|
||||
agent_sdk?: string;
|
||||
started_at: string;
|
||||
completed_at?: string | null;
|
||||
status: RunStatus;
|
||||
/** `checks.<checkName>` → that check's progress state. */
|
||||
checks: Record<string, CheckState>;
|
||||
}
|
||||
|
||||
export interface HygieneStateFile {
|
||||
runs: HygieneRunState[];
|
||||
}
|
||||
|
||||
export interface ReadRunStateResult {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
state?: HygieneStateFile;
|
||||
parseError?: string;
|
||||
}
|
||||
|
||||
export function getRunStatePath(cwd: string): string {
|
||||
return join(cwd, "pygienium", "run-state.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the run-state file without ever throwing on a corrupt or non-matching
|
||||
* file — it surfaces a `parseError` instead so callers (status, resume) can
|
||||
* report gracefully.
|
||||
*/
|
||||
export function readRunState(cwd: string): ReadRunStateResult {
|
||||
const path = getRunStatePath(cwd);
|
||||
if (!existsSync(path)) {
|
||||
return { path, exists: false };
|
||||
}
|
||||
try {
|
||||
const raw = readFileSync(path, "utf8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!isHygieneStateFile(parsed)) {
|
||||
return {
|
||||
path,
|
||||
exists: true,
|
||||
parseError:
|
||||
"File is valid JSON but does not match expected run-state shape (missing `runs` array).",
|
||||
};
|
||||
}
|
||||
return { path, exists: true, state: parsed };
|
||||
} catch (err) {
|
||||
return {
|
||||
path,
|
||||
exists: true,
|
||||
parseError: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function isHygieneStateFile(value: unknown): value is HygieneStateFile {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return Array.isArray(v.runs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically replace the state file. Callers should always go through
|
||||
* `mutateRunState` rather than calling this directly so concurrent mutations
|
||||
* within the same process serialize correctly.
|
||||
*/
|
||||
function writeRunStateRaw(path: string, state: HygieneStateFile): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
||||
const json = `${JSON.stringify(state, null, "\t")}\n`;
|
||||
writeFileSync(tmp, json);
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-modify-write the run-state file under the file mutation queue.
|
||||
* The transformer receives the current state (or a fresh empty file if none
|
||||
* exists) and returns the new state. Returning `undefined` aborts the write
|
||||
* (no-op transformer).
|
||||
*/
|
||||
export async function mutateRunState(
|
||||
cwd: string,
|
||||
transform: (state: HygieneStateFile) => HygieneStateFile | undefined,
|
||||
): Promise<HygieneStateFile> {
|
||||
const path = getRunStatePath(cwd);
|
||||
return withFileMutationQueue(path, async () => {
|
||||
const current = readRunStateOrEmpty(path);
|
||||
const next = transform(current);
|
||||
if (!next) return current;
|
||||
writeRunStateRaw(path, next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function readRunStateOrEmpty(path: string): HygieneStateFile {
|
||||
if (!existsSync(path)) return { runs: [] };
|
||||
const raw = readFileSync(path, "utf8");
|
||||
if (raw.trim() === "") return { runs: [] };
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (isHygieneStateFile(parsed)) return parsed;
|
||||
} catch {
|
||||
// fall through to the corrupt-file backup below.
|
||||
}
|
||||
// The file exists with non-empty content that won't parse or doesn't match
|
||||
// the expected shape. Run state is expensive and resumable, so never let
|
||||
// the caller overwrite it blind: move the corrupt file aside first, then
|
||||
// return empty so a fresh file is written alongside the preserved backup.
|
||||
backupCorruptStateFile(path);
|
||||
return { runs: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a corrupt state file to `run-state.json.corrupt-<timestamp>` so a
|
||||
* subsequent write doesn't destroy whatever run history it held. Best-effort:
|
||||
* if the rename fails we leave the file in place rather than risk losing it.
|
||||
*/
|
||||
function backupCorruptStateFile(path: string): void {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
let backup = `${path}.corrupt-${stamp}`;
|
||||
for (let n = 1; existsSync(backup); n++)
|
||||
backup = `${path}.corrupt-${stamp}-${n}`;
|
||||
try {
|
||||
renameSync(path, backup);
|
||||
} catch {
|
||||
// Leave the original untouched if it can't be moved.
|
||||
}
|
||||
}
|
||||
|
||||
/** Most recent run by `started_at` (ISO timestamps sort lexically). */
|
||||
export function latestRun(
|
||||
state: HygieneStateFile,
|
||||
): HygieneRunState | undefined {
|
||||
if (state.runs.length === 0) return undefined;
|
||||
return [...state.runs].sort((a, b) =>
|
||||
a.started_at < b.started_at ? 1 : -1,
|
||||
)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Most recent resumable run across all modes. Preference order: an
|
||||
* `in_progress` run (process killed mid-phase) outranks a `failed` run
|
||||
* (orderly terminal state) because the former is more likely a transient
|
||||
* outage. `complete` runs are never returned.
|
||||
*
|
||||
* Ties are broken by `started_at` (most recent first).
|
||||
*/
|
||||
export function latestResumableRun(
|
||||
state: HygieneStateFile,
|
||||
): HygieneRunState | undefined {
|
||||
const sorted = [...state.runs].sort((a, b) =>
|
||||
a.started_at < b.started_at ? 1 : -1,
|
||||
);
|
||||
return (
|
||||
sorted.find((r) => r.status === "in_progress") ??
|
||||
sorted.find((r) => r.status === "failed") ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
export interface InitRunOptions {
|
||||
mode: RunMode;
|
||||
model?: string;
|
||||
agent_sdk?: string;
|
||||
commit?: string | null;
|
||||
branch?: string;
|
||||
repository?: string;
|
||||
history_available?: boolean;
|
||||
/**
|
||||
* Override the initial check list. Absent, `checks` starts empty and the
|
||||
* orchestrator adds entries as each check transitions. Given one, every
|
||||
* check is seeded as `{ status: "pending" }`.
|
||||
*/
|
||||
checks?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a new hygiene run to the state file. Returns the appended run with a
|
||||
* fresh ISO timestamp `run_id`.
|
||||
*/
|
||||
export async function initRun(
|
||||
cwd: string,
|
||||
options: InitRunOptions,
|
||||
): Promise<HygieneRunState> {
|
||||
const startedAt = new Date().toISOString();
|
||||
const checks: Record<string, CheckState> = {};
|
||||
for (const name of options.checks ?? []) checks[name] = { status: "pending" };
|
||||
|
||||
const run: HygieneRunState = {
|
||||
run_id: startedAt,
|
||||
mode: options.mode,
|
||||
started_at: startedAt,
|
||||
completed_at: null,
|
||||
status: "in_progress",
|
||||
checks,
|
||||
...(options.model !== undefined && { model: options.model }),
|
||||
...(options.agent_sdk !== undefined && { agent_sdk: options.agent_sdk }),
|
||||
...(options.commit !== undefined && { commit: options.commit }),
|
||||
...(options.branch !== undefined && { branch: options.branch }),
|
||||
...(options.repository !== undefined && { repository: options.repository }),
|
||||
...(options.history_available !== undefined && {
|
||||
history_available: options.history_available,
|
||||
}),
|
||||
};
|
||||
|
||||
await mutateRunState(cwd, (state) => ({
|
||||
...state,
|
||||
runs: [...state.runs, run],
|
||||
}));
|
||||
return run;
|
||||
}
|
||||
|
||||
export interface CheckUpdate {
|
||||
status: CheckStatus;
|
||||
error?: string;
|
||||
artifacts?: string[];
|
||||
attempt?: number;
|
||||
max_attempts?: number;
|
||||
retry_backoff_ms?: number | null;
|
||||
next_retry_at?: string | null;
|
||||
last_error?: string | null;
|
||||
heartbeat_at?: string | null;
|
||||
last_event_at?: string | null;
|
||||
last_tool?: string | null;
|
||||
last_tool_summary?: string | null;
|
||||
run_id?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single check on the named run. Auto-stamps `started_at` on
|
||||
* transitions into `in_progress` and `completed_at` on terminal states.
|
||||
* Returns the updated run, or `undefined` if the run_id wasn't found.
|
||||
*/
|
||||
export async function setCheckStatus(
|
||||
cwd: string,
|
||||
runId: string,
|
||||
check: string,
|
||||
update: CheckUpdate,
|
||||
): Promise<HygieneRunState | undefined> {
|
||||
let updated: HygieneRunState | undefined;
|
||||
await mutateRunState(cwd, (state) => {
|
||||
const idx = state.runs.findIndex((r) => r.run_id === runId);
|
||||
if (idx < 0) return undefined;
|
||||
const run = state.runs[idx];
|
||||
if (!run) return undefined;
|
||||
const prev = run.checks[check] ?? { status: "pending" as const };
|
||||
const now = new Date().toISOString();
|
||||
const next: CheckState = {
|
||||
...prev,
|
||||
status: update.status,
|
||||
...(update.error !== undefined && { error: update.error }),
|
||||
...(update.artifacts !== undefined && { artifacts: update.artifacts }),
|
||||
...(update.attempt !== undefined && { attempt: update.attempt }),
|
||||
...(update.max_attempts !== undefined && {
|
||||
max_attempts: update.max_attempts,
|
||||
}),
|
||||
};
|
||||
if (update.retry_backoff_ms !== undefined) {
|
||||
if (update.retry_backoff_ms === null) next.retry_backoff_ms = undefined;
|
||||
else next.retry_backoff_ms = update.retry_backoff_ms;
|
||||
}
|
||||
if (update.next_retry_at !== undefined) {
|
||||
if (update.next_retry_at === null) next.next_retry_at = undefined;
|
||||
else next.next_retry_at = update.next_retry_at;
|
||||
}
|
||||
if (update.last_error !== undefined) {
|
||||
if (update.last_error === null) next.last_error = undefined;
|
||||
else next.last_error = update.last_error;
|
||||
}
|
||||
if (update.heartbeat_at !== undefined) {
|
||||
if (update.heartbeat_at === null) next.heartbeat_at = undefined;
|
||||
else next.heartbeat_at = update.heartbeat_at;
|
||||
}
|
||||
if (update.last_event_at !== undefined) {
|
||||
if (update.last_event_at === null) next.last_event_at = undefined;
|
||||
else next.last_event_at = update.last_event_at;
|
||||
}
|
||||
if (update.last_tool !== undefined) {
|
||||
if (update.last_tool === null) next.last_tool = undefined;
|
||||
else next.last_tool = update.last_tool;
|
||||
}
|
||||
if (update.last_tool_summary !== undefined) {
|
||||
if (update.last_tool_summary === null) next.last_tool_summary = undefined;
|
||||
else next.last_tool_summary = update.last_tool_summary;
|
||||
}
|
||||
if (update.run_id !== undefined) {
|
||||
if (update.run_id === null) next.run_id = undefined;
|
||||
else next.run_id = update.run_id;
|
||||
}
|
||||
if (update.status === "in_progress" && !next.started_at)
|
||||
next.started_at = now;
|
||||
if (update.status === "in_progress") next.completed_at = undefined;
|
||||
if (update.status === "complete") {
|
||||
next.error = undefined;
|
||||
next.artifacts = undefined;
|
||||
next.retry_backoff_ms = undefined;
|
||||
next.next_retry_at = undefined;
|
||||
next.last_error = undefined;
|
||||
next.heartbeat_at = undefined;
|
||||
next.last_event_at = undefined;
|
||||
next.last_tool = undefined;
|
||||
next.last_tool_summary = undefined;
|
||||
next.run_id = undefined;
|
||||
}
|
||||
if (
|
||||
update.status === "complete" ||
|
||||
update.status === "failed" ||
|
||||
update.status === "skipped"
|
||||
) {
|
||||
if (!next.started_at) next.started_at = now;
|
||||
next.completed_at = now;
|
||||
}
|
||||
const checks = { ...run.checks, [check]: next };
|
||||
const newRun: HygieneRunState = { ...run, checks };
|
||||
updated = newRun;
|
||||
const runs = [...state.runs];
|
||||
runs[idx] = newRun;
|
||||
return { ...state, runs };
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around `setCheckStatus` that also mirrors the disk write onto the
|
||||
* caller's in-memory `HygieneRunState`. Use this from orchestrators that hold
|
||||
* a run object across multiple check transitions — otherwise their copy goes
|
||||
* stale the moment any check completes, and downstream prerequisite checks
|
||||
* read "pending" for already-completed checks.
|
||||
*/
|
||||
export async function applyPhaseStatus(
|
||||
cwd: string,
|
||||
run: HygieneRunState,
|
||||
checkName: string,
|
||||
update: CheckUpdate,
|
||||
): Promise<void> {
|
||||
const updated = await setCheckStatus(cwd, run.run_id, checkName, update);
|
||||
if (!updated) return;
|
||||
const fresh = updated.checks[checkName];
|
||||
if (fresh) run.checks[checkName] = fresh;
|
||||
}
|
||||
|
||||
/** Mark a hygiene run as complete or failed. */
|
||||
export async function markRunStatus(
|
||||
cwd: string,
|
||||
runId: string,
|
||||
status: RunStatus,
|
||||
): Promise<HygieneRunState | undefined> {
|
||||
let updated: HygieneRunState | undefined;
|
||||
await mutateRunState(cwd, (state) => {
|
||||
const idx = state.runs.findIndex((r) => r.run_id === runId);
|
||||
if (idx < 0) return undefined;
|
||||
const run = state.runs[idx];
|
||||
if (!run) return undefined;
|
||||
const completedAt =
|
||||
status === "complete" || status === "failed"
|
||||
? new Date().toISOString()
|
||||
: run.completed_at;
|
||||
const newRun: HygieneRunState = {
|
||||
...run,
|
||||
status,
|
||||
completed_at: completedAt ?? null,
|
||||
};
|
||||
updated = newRun;
|
||||
const runs = [...state.runs];
|
||||
runs[idx] = newRun;
|
||||
return { ...state, runs };
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
export interface CheckTally {
|
||||
total: number;
|
||||
complete: number;
|
||||
in_progress: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
export function tallyChecks(run: HygieneRunState): CheckTally {
|
||||
const tally: CheckTally = {
|
||||
total: 0,
|
||||
complete: 0,
|
||||
in_progress: 0,
|
||||
pending: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
for (const check of Object.values(run.checks)) {
|
||||
tally.total++;
|
||||
tally[check.status]++;
|
||||
}
|
||||
return tally;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 04-hygiene-state deliverables list this name alongside the `tally*`
|
||||
* helpers; the rename step (phase → check) yields `tallyChecks`, so this is a
|
||||
* one-line alias kept for spelling compatibility with that spec.
|
||||
*/
|
||||
export const tallyPhases: typeof tallyChecks = tallyChecks;
|
||||
@@ -183,15 +183,17 @@ export function renderAllSummary(
|
||||
lines.push(`## ${check.name} — ${entry?.status ?? "pending"}${fixTag}`);
|
||||
lines.push("");
|
||||
|
||||
if (entry?.error) {
|
||||
// Errors are terminal-run facts: a check that completed via a later
|
||||
// resume/retry must not surface a stale error under a "complete"
|
||||
// status (markCheckStatus clears it on success; this guard covers
|
||||
// hand-edited or legacy state files too).
|
||||
if (entry?.error && entry?.status !== "complete") {
|
||||
lines.push(`- error: ${entry.error}`);
|
||||
}
|
||||
|
||||
// Artifact paths (canonical root). Checks may also write under the
|
||||
// legacy `.pygienium/checks/` root; reference the canonical one and
|
||||
// note findings/changes counts regardless of root.
|
||||
const findingsPath = `${state.cwd}/pygienium/checks/${check.name}/findings.md`;
|
||||
const changesPath = `${state.cwd}/pygienium/checks/${check.name}/changes.md`;
|
||||
// Artifact paths (canonical root: `.pygienium/checks/<name>/`).
|
||||
const findingsPath = `${state.cwd}/.pygienium/checks/${check.name}/findings.md`;
|
||||
const changesPath = `${state.cwd}/.pygienium/checks/${check.name}/changes.md`;
|
||||
const fLines = lineCount(entry?.findings);
|
||||
const cLines = lineCount(entry?.changes);
|
||||
if (fLines > 0) {
|
||||
|
||||
@@ -80,14 +80,6 @@ export const PHASE_FIX = "fix";
|
||||
export const PHASE_VERIFY = "verify";
|
||||
export const PHASE_CLEANUP = "cleanup";
|
||||
|
||||
export const CHECK_PHASES = [
|
||||
PHASE_RECON,
|
||||
PHASE_ANALYSIS,
|
||||
PHASE_FIX,
|
||||
PHASE_VERIFY,
|
||||
PHASE_CLEANUP,
|
||||
] as const;
|
||||
|
||||
function freshPhase(id: string): PhaseEntry {
|
||||
return { id, status: "pending" };
|
||||
}
|
||||
@@ -216,7 +208,9 @@ export function applyPhaseStatus(
|
||||
status === "skipped"
|
||||
) {
|
||||
phase.finishedAt = now;
|
||||
if (error) phase.error = error;
|
||||
// Always set (or clear) the error: a phase that previously failed
|
||||
// and then succeeds on retry must not carry a stale error forward.
|
||||
phase.error = error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +227,9 @@ export function markCheckStatus(
|
||||
const check = state.checks[checkName];
|
||||
if (!check) return;
|
||||
check.status = status;
|
||||
if (error) check.error = error;
|
||||
// Always set (or clear) the error: a check that previously failed
|
||||
// and then succeeds on retry must not carry a stale error forward.
|
||||
check.error = error;
|
||||
if (status === "complete" || status === "failed" || status === "skipped") {
|
||||
check.finishedAt = Date.now();
|
||||
}
|
||||
@@ -306,8 +302,8 @@ export function resetCheckEntry(
|
||||
*/
|
||||
/**
|
||||
* Recompute the run-level status from check statuses. A run is `complete` only
|
||||
* when every check is terminal-complete; `failed` if any failed without fixes
|
||||
* completed; `partial` when some checks were skipped/failed but others ok.
|
||||
* when every check is terminal-complete; `failed` when every check failed;
|
||||
* `partial` when some checks failed/skipped but others succeeded.
|
||||
*/
|
||||
export function reconcileRunStatus(state: RunState): RunStatus {
|
||||
const checks = Object.values(state.checks);
|
||||
@@ -320,7 +316,11 @@ export function reconcileRunStatus(state: RunState): RunStatus {
|
||||
if (c.status === "failed") anyFailed = true;
|
||||
if (c.status === "skipped") anySkipped = true;
|
||||
}
|
||||
if (anyFailed) return "partial";
|
||||
if (anyFailed) {
|
||||
// Every check failed (none succeeded or were skipped) → the run failed;
|
||||
// a mix of failures and successes is only partially complete.
|
||||
return checks.every((c) => c.status === "failed") ? "failed" : "partial";
|
||||
}
|
||||
if (anySkipped) return "partial";
|
||||
return "complete";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user