Files
msd-core/src/plan-dependency-graph.cts
Tom Boucher da7d4dac52 fix(#3345): stop counting blocked summaries as completed plans (#3459)
* fix(#3345): stop counting blocked summaries as completed plans

* chore(#3345): set changeset pr reference

---------

Co-authored-by: sim <sim@local>
2026-08-14 10:24:05 -04:00

326 lines
15 KiB
TypeScript

/**
* Plan Dependency Graph — shared halt-propagation over a plan's depends_on DAG (#2830).
*
* Two independent "which plans are incomplete" readers exist in this codebase:
* phase.cts's wave-grouping (`cmdPhasePlanIndex`) and phase-locator.cts's
* phase-location primitive (`searchPhaseInDir`, consumed by ~50 symbols across
* five command routers). Before #2830, only the former parsed `depends_on` —
* and even it used the DAG only for topological wave assignment, never to
* propagate a halted plan's block onto its dependents. The latter never parsed
* `depends_on` at all; it derived completion from summary-file presence only.
* A plan that reaches a designed stop still writes a SUMMARY (recording the
* halt in prose only — no prior structured status existed for it), so both
* readers saw it as ordinary "complete" and reported its dependents as
* ordinary incomplete work, available to spawn against.
*
* This module is the SINGLE topological-order + halt-propagation engine both
* readers call, so the two can never re-diverge on this rule again. Each
* caller resolves its own raw `depends_on` tokens to canonical plan ids
* (case-fold + `extractCanonicalPlanId` fallback — the same resolution
* already performed by phase.cts's `computeDependencyLevels`) before
* building `PlanHaltNode[]`; this module owns the graph traversal (exactly
* one pass — Kahn's algorithm, or none at all when the caller already has a
* valid topological order, see `computeHaltPropagation`'s `precomputedOrder`
* parameter) plus the two small pure helpers below (`isHaltedStatus`,
* `buildSummaryFileIndex`) that both callers would otherwise duplicate
* identically — the exact "two implementations, one drifts" failure mode
* this fix exists to close. Each caller still owns its own file I/O (the
* actual `fs.readFileSync` + `extractFrontmatter` calls); only the
* interpretive logic is centralized here — except the SUMMARY-file
* read+extract wrapper itself (`isSummaryFileHalted`), which both callers
* previously duplicated near-identically and which is now centralized here
* too, for the same reason.
*/
import fs from 'node:fs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
import frontmatterMod = require('./frontmatter.cjs');
const { extractFrontmatter } = frontmatterMod;
/**
* The one place "does this SUMMARY status value mean halted" is decided.
* Case-insensitive, trims whitespace. Both `phase.cts`'s `cmdPhasePlanIndex`
* and `phase-locator.cts`'s `searchPhaseInDir` call this after reading a
* completed plan's SUMMARY frontmatter `status` field, so the definition of
* "halted" cannot drift between the two readers.
*/
function isHaltedStatus(status: unknown): boolean {
if (typeof status !== 'string') return false;
// #2830 review (defect 2): strip an unquoted trailing YAML comment (a run
// of whitespace followed by `#` and the rest of the line) before
// trimming/lowercasing. YAML scalars don't need quoting to carry an inline
// comment (`status: halted # designed stop`), but `extractFrontmatter`
// does not strip one — and all four summary templates literally show that
// spelling as guidance on the value line. Without this, an executor that
// mimics the template's own presentation would write a halt that silently
// reads back as not-halted. A `#` with no preceding whitespace is NOT a
// YAML comment start, so `halted#nospace` intentionally still fails to match.
const withoutTrailingComment = status.replace(/\s+#.*$/, '');
return withoutTrailingComment.trim().toLowerCase() === 'halted';
}
/**
* Read a plan's SUMMARY file and report whether it declares `status: halted`
* (a designed stop, not an ordinary completion). Returns false — never
* throws — on a missing/unreadable/malformed SUMMARY, so an unreadable file
* degrades to the pre-#2830 behavior ("has a SUMMARY = complete") rather
* than breaking either caller.
*
* Two callers share this wrapper: `phase.cts`'s `cmdPhasePlanIndex` and
* `phase-locator.cts`'s `searchPhaseInDir` (the phase-location primitive
* consumed by ~50 symbols across five command routers). Both previously
* carried a near-identical local copy (read file -> `extractFrontmatter` ->
* `isHaltedStatus` -> swallow errors) that this module's own header comment
* calls out as the exact "two implementations, one drifts" failure mode it
* exists to prevent — centralizing the read+extract wrapper here, not just
* the `isHaltedStatus` predicate, closes that gap.
*
* Takes a single resolved `summaryPath` (not a `dir` + `filename` pair) —
* `phase-locator.cts`'s prior local copy took the two parts separately and
* `path.join`'d them internally; that caller now does the join itself
* before calling in, so both callers share one signature.
*
* @param summaryPath - absolute or relative path to a `*-SUMMARY.md` file.
*/
function isSummaryFileHalted(summaryPath: string): boolean {
try {
const content = fs.readFileSync(summaryPath, 'utf-8');
const fm = extractFrontmatter(content, summaryPath);
return isHaltedStatus(fm['status']);
} catch {
return false;
}
}
/**
* Build a planId -> summary-filename lookup from a phase's summary file
* list, keyed by both the exact SUMMARY-file-derived id and its canonical
* form (mirrors the `completedPlanIds` construction each caller already
* performs for its own SUMMARY-presence check — same `summaryFiles` list,
* same exact/canonical key pair — so the two can never disagree about which
* summary file belongs to which plan id). `extractCanonicalPlanId` is
* supplied by the caller (each module owns its own resolution helper).
*/
function buildSummaryFileIndex(
summaryFiles: string[],
extractCanonicalPlanId: (filename: string) => string,
): Map<string, string> {
const index = new Map<string, string>();
for (const s of summaryFiles) {
const exact = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
const canonical = extractCanonicalPlanId(s);
index.set(exact, s);
if (canonical !== exact) index.set(canonical, s);
}
return index;
}
/**
* #3345: the one place "does this SUMMARY status value mean blocked" is
* decided, sibling to `isHaltedStatus` above. A SUMMARY declaring
* `status: blocked` records a plan that could NOT finish — it is a failure
* record, not a completion record — so it must not count toward
* `completed_plans` (scanPhasePlans's summaryCount) nor read as
* `has_summary: true` in phase-plan-index's `incomplete` construction.
*
* `halted` is deliberately NOT matched here: a designed stop still writes a
* completion record (#2830's model — its dependents get `blocked_by` halt
* propagation), so a `status: halted` SUMMARY keeps counting as summarized.
* Case-insensitive, trims whitespace, and strips an unquoted trailing YAML
* comment exactly like `isHaltedStatus` (same #2830 review defect-2 rule).
*/
function isBlockedStatus(status: unknown): boolean {
if (typeof status !== 'string') return false;
const withoutTrailingComment = status.replace(/\s+#.*$/, '');
return withoutTrailingComment.trim().toLowerCase() === 'blocked';
}
// #3345: SUMMARY frontmatter sits at byte 0 and closes well before the body,
// so only a bounded prefix is ever needed to read the `status` marker — the
// same cap discipline as plan-scan.cts's PLAN_FRONTMATTER_READ_CAP (#2349),
// kept here because this predicate's primary caller (scanPhasePlans) loops
// over every phase directory on hot paths (state sync, roadmap progress).
const SUMMARY_FRONTMATTER_READ_CAP = 64 * 1024;
/**
* #3345: read a SUMMARY file's frontmatter `status` (bounded-prefix read) and
* report whether it declares `status: blocked`. Returns false — never throws —
* on a missing/unreadable/malformed/non-regular SUMMARY, so an unreadable file
* degrades to the pre-#3345 filename-existence behaviour rather than breaking
* either caller. Fail-open by design, mirroring `isPlanSuperseded`'s posture.
*
* Callers: plan-scan.cts's `scanPhasePlans` (the count side) and phase.cts's
* `cmdPhasePlanIndex` (the read side) BOTH filter their summary lists through
* this one predicate, so the count and the `incomplete` list can never
* re-diverge on the blocked rule.
*/
function isSummaryFileBlocked(summaryPath: string): boolean {
let content: string;
try {
const st = fs.statSync(summaryPath); // follows symlinks → resolves to the target's real type
if (!st.isFile()) return false;
const length = Math.min(st.size, SUMMARY_FRONTMATTER_READ_CAP);
if (length === 0) return false;
const fd = fs.openSync(summaryPath, 'r');
try {
const buf = Buffer.allocUnsafe(length);
const bytesRead = fs.readSync(fd, buf, 0, length, 0);
content = buf.toString('utf8', 0, bytesRead);
} finally {
fs.closeSync(fd);
}
} catch {
return false;
}
return isBlockedStatus(extractFrontmatter(content, summaryPath)['status']);
}
interface PlanHaltNode {
/** Canonical plan id, already resolved — matches another node's `id` for a dependency edge to count. */
id: string;
/** Dependency ids, already resolved to `id` values present in this node list. Unresolved/cross-phase deps must be filtered out by the caller before this call. */
resolvedDependsOn: string[];
/** True iff this plan's own completion record (SUMMARY) declares `status: halted`. */
halted: boolean;
}
interface HaltPropagationResult {
/**
* Topological order (Kahn's algorithm), dependencies before dependents.
* A length shorter than the input `nodes.length` signals a dependency
* cycle among the unlisted ids — mirrors `computeDependencyLevels`'s
* `visited` counter contract.
*/
order: string[];
visited: number;
/**
* planId -> de-duplicated list of halted plan ids that transitively block
* it (direct or via any number of intermediate dependents). No entry means
* not blocked. A halted plan's own id is never a key in its own value — a
* halted plan is halted, not blocked by itself.
*/
blockedBy: Map<string, string[]>;
}
/**
* Computes halt-propagation over a plan dependency DAG.
*
* `precomputedOrder`: when the caller ALREADY has a valid topological order
* for these exact node ids (e.g. phase.cts's `cmdPhasePlanIndex`, which runs
* `computeDependencyLevels`'s Kahn's-algorithm pass for wave assignment
* before ever calling this function), pass it here and this function skips
* running Kahn's algorithm a second time — the halt-propagation forward pass
* below only needs A valid topological order, not to derive one itself.
* Omit it (as phase-locator.cts's `searchPhaseInDir` does — it has no prior
* traversal of this DAG) and this function derives the order itself; either
* way, exactly one Kahn's-algorithm pass runs per caller, never two.
*
* Diamond-safe (a plan blocked via two different halted ancestors gets both
* in its `blockedBy` list, deduplicated) and transitive-safe (a dependent of
* a dependent of a halted plan is blocked, at any depth).
*/
function computeHaltPropagation(nodes: PlanHaltNode[], precomputedOrder?: string[]): HaltPropagationResult {
const byId = new Map(nodes.map((n) => [n.id, n]));
let order: string[];
let visited: number;
if (precomputedOrder) {
// Caller already ran Kahn's algorithm over this exact node set (same ids)
// — trust its order and skip re-deriving one. `visited` mirrors the same
// "count of nodes reachable in valid topological order" contract a fresh
// derivation would produce.
order = precomputedOrder;
visited = precomputedOrder.length;
} else {
const inDeg = new Map<string, number>();
const adj = new Map<string, string[]>(); // dependency id -> dependent ids
for (const n of nodes) {
if (!inDeg.has(n.id)) inDeg.set(n.id, 0);
if (!adj.has(n.id)) adj.set(n.id, []);
for (const depId of n.resolvedDependsOn) {
if (!byId.has(depId)) continue; // fail-safe: caller should have filtered these already
if (!adj.has(depId)) adj.set(depId, []);
(adj.get(depId) as string[]).push(n.id);
inDeg.set(n.id, (inDeg.get(n.id) ?? 0) + 1);
}
}
const queue: string[] = [];
for (const n of nodes) {
if ((inDeg.get(n.id) ?? 0) === 0) queue.push(n.id);
}
// Dequeue by head index, not Array.shift() — O(1) amortized, same
// rationale as computeDependencyLevels (#307).
let head = 0;
let v = 0;
while (head < queue.length) {
const cur = queue[head++];
v++;
for (const dep of adj.get(cur) ?? []) {
inDeg.set(dep, (inDeg.get(dep) as number) - 1);
if (inDeg.get(dep) === 0) queue.push(dep);
}
}
order = queue;
visited = v;
}
// `order` is a valid topological order for every visited node: for edge
// dep -> dependent, dep appears before dependent. A single forward pass
// over it — using each node's own already-resolved dependsOn ids —
// computes blockedBy without any further graph traversal.
const blockedBy = new Map<string, string[]>();
for (const id of order) {
const node = byId.get(id);
if (!node) continue;
const causes = new Set<string>();
for (const depId of node.resolvedDependsOn) {
const depNode = byId.get(depId);
if (!depNode) continue;
if (depNode.halted) causes.add(depId);
const depCauses = blockedBy.get(depId);
if (depCauses) {
for (const c of depCauses) causes.add(c);
}
}
if (causes.size > 0) blockedBy.set(id, Array.from(causes));
}
// #2830 review (defect 1): a node involved in a depends_on cycle (or
// downstream of one) never reaches indegree 0, so it never appears in
// `order` and the forward pass above never visits it — it would otherwise
// end up absent from BOTH `blockedBy` and any cycle diagnostic, i.e.
// reported as ordinary runnable. A node whose position in the topological
// order is undecidable cannot be shown to be safe to run: silently
// dropping it is the exact silent-disappearance failure #2830 exists to
// prevent. Fail closed — give every such non-halted node an explicit,
// non-empty `blockedBy` entry so no consumer (present or future) can
// re-admit it as runnable merely by checking "absent from blockedBy".
// (A node that is itself halted is not "blocked" — it IS the blocker —
// so it is left out here exactly as the normal forward pass leaves it out.)
if (order.length < nodes.length) {
const orderSet = new Set(order);
for (const n of nodes) {
if (orderSet.has(n.id) || n.halted) continue;
const causes = Array.from(new Set(n.resolvedDependsOn.filter((depId) => byId.has(depId)))).sort();
blockedBy.set(n.id, causes.length > 0 ? causes : [n.id]);
}
}
return { order, visited, blockedBy };
}
export = {
computeHaltPropagation,
isHaltedStatus,
buildSummaryFileIndex,
isSummaryFileHalted,
// #3345: blocked-SUMMARY detection, shared by the count side (scanPhasePlans)
// and the read side (phase-plan-index) so the two cannot diverge.
isBlockedStatus,
isSummaryFileBlocked,
};