* test(#2830): add failing regression tests for halted-plan dependent blocking Add tests/fix-2830-halted-plan-dependents.test.cjs covering direct, transitive (2 and 3 hop), and diamond dependents of a halted plan across both independent "which plans are incomplete" readers (phase-plan-index's cmdPhasePlanIndex and findPhaseInternal/searchPhaseInDir), the negative case (an unrelated decoupled plan stays runnable), and a parity check that the two readers agree. Uses only modules that already exist at this commit (gsd-tools.cjs via subprocess, the pre-existing phase-locator.cjs) so the test file loads and runs cleanly on a fresh clone of this exact commit. These fail against current behavior: neither reader has any concept of a halted plan or a blocked_by/runnable view yet. * fix(#2830): a halted plan no longer leaves its dependents on the runnable work list A plan that reaches a designed stop still writes a SUMMARY, so both "which plans are incomplete" readers saw it as an ordinary completion and reported its dependents as ordinary runnable work — never checking whether an upstream plan had halted rather than finished. - New `status: halted` frontmatter value, documented in all four SUMMARY templates alongside the existing `status: complete`. - New shared src/plan-dependency-graph.cts: a single computeHaltPropagation pass that both phase.cts's cmdPhasePlanIndex (wave-grouping) and phase-locator.cts's searchPhaseInDir (the phase-location primitive, ~50 dependent symbols across 5 command routers) now call, so the two-implementation divergence that caused this bug cannot recur. It accepts an optional precomputedOrder so cmdPhasePlanIndex — which already runs Kahn's algorithm in computeDependencyLevels for wave assignment — passes that order straight through instead of a second traversal; searchPhaseInDir (no prior traversal) lets the module derive its own. The two small duplicated predicates each reader would otherwise carry (is this status "halted"?, which summary file matches which plan id?) are centralized in the same module as isHaltedStatus/buildSummaryFileIndex. - Additive fields only: `halted`/`blocked_by`/`runnable` on cmdPhasePlanIndex's plans[] and top level, `halted_plans`/`blocked_by`/ `runnable_plans` on searchPhaseInDir's result. The pre-existing `incomplete`/`incomplete_plans` fields are unchanged in meaning and membership. - execute-phase.md's discover_and_group_plans step now also skips any plan whose `blocked_by` is non-empty, reporting it by name with its blocking chain, in addition to (not instead of) the existing has_summary skip rule. Extends tests/fix-2830-halted-plan-dependents.test.cjs (introduced in the prior commit) with direct unit coverage of computeHaltPropagation (including the precomputedOrder call shape) and a fast-check property test — both only possible once this commit's new module exists. Closes #2830 * fix(#2830): surface the halt-aware view from init execute-phase The adopted work made phase-locator compute halted_plans / blocked_by / runnable_plans, but cmdInitExecutePhase builds its output by explicitly enumerating fields, so all three were computed and then silently dropped at the exact consumer the issue names as regressed. Forwards them additively -- incomplete_plans and incomplete_count keep their name, type and semantics byte-for-byte -- and adds the same three empty defaults to the roadmap-only fallback so the shape is consistent in both branches. Covered by a new test that drives the real CLI end to end rather than the locator function, since the locator already worked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2830): fail closed on dependency cycles and stop the templates inviting the defect Three review findings, all fixed: - BLOCKER (isolated adversarial). Cycle participants never reach indegree 0 in the Kahn pass, so they were excluded from the topological order, never visited by the forward pass, and vanished from blocked_by entirely -- i.e. reported as runnable. The wave-grouping reader hard-fails on a cycle so it never hit this, but the phase-location reader does not, so init execute-phase offered a plan depending directly on a halted plan. Reproduced, then fixed in the shared engine so every consumer is safe regardless of pre-checks: a node absent from the order is now blocked with a deterministic, non-empty named cause. A plan silently missing from both blocked_by and runnable is the exact disappearance this issue exists to prevent. - MAJOR (isolated adversarial). All four summary templates showed the field as an inline comment on the value line. Frontmatter parsing does not strip trailing comments, so an executor copying the templates' own presentation wrote a halt that parsed as a non-halted string, silently reproducing the original bug. Guidance moved off the value line, and the halt predicate now tolerates an unquoted trailing comment. - HARD standards violation. A test regex-matched child-process stderr prose for /cycle/i, which CONTRIBUTING bans. Replaced with the structured failure signal plus a differential assertion (same fixture without the cycle edge must succeed), so it stays cycle-specific without matching prose. Also folds the duplicated read-summary-and-check-halted wrapper out of both readers into the shared module -- centralizing only the predicate left the exact two-copies-that-drift pattern the module exists to prevent -- and commits the artifact-types documentation for the new status value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#2830): stop the property generator hanging the whole suite The remote runner did not fail -- it hung. Two containers sat in this file for 31+ minutes, and an earlier attempt ran 9 hours before I killed it. The runner passes --test-timeout=0, so nothing ever reaps it: this would have hung CI indefinitely, not reported a failure. Root cause: the DAG generator built edges by rejection -- from: fc.integer({ min: 0, max: n - 1 }) to: fc.integer({ min: 0, max: n - 1 }) .filter(({ from, to }) => from < to) With n === 1 both integers are forced to 0, so the predicate is unsatisfiable and fast-check retries value generation forever. n is drawn from 1..12 and fast-check biases toward boundary values, so n === 1 is reached almost at once. This also explains why the failing-first run completed normally while the fixed run hung: before the fix the graph module did not exist, so the property test threw on import and never reached generation. It only starts hanging once the code under test works. Generates the DAG by construction instead -- `to` is drawn strictly above `from`, with the degenerate single-node case short-circuited to an empty edge list -- so no rejection sampling is involved. Switches the import to the shared fast-check setup so the seed and run count are pinned per CONTRIBUTING, and adds a bounded regression guard that samples the arbitrary directly, so a future reintroduction fails loudly instead of hanging. Verified: the file now completes in 2 seconds, 29 tests started and 29 finished, zero failures, against an indefinite hang before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2830): restore the depends_on display contract and acknowledge the workflow growth Full-suite run surfaced two things the focused harnesses could not. 1. Regression of a pinned pre-existing contract (#3785). A refactor routed the EMITTED depends_on field through the new dependency resolver, which also consults the canonical-prefix map. The original consulted the plan map only, so a short canonical prefix passed through verbatim -- '24-01' stayed '24-01' rather than becoming '24-01-auth-hardening'. The emitted field is a DISPLAY mapping, not the DAG resolution, and #3785 pins that. Reverted with a comment recording why it must not use the resolver; full resolution is still used for the wave DAG and halt propagation, which is what needs it. 2. The workflow file grew 518 bytes without an acknowledgment, from the halt-aware skip rule and the widened parse contract. Acknowledged. Note on where the acknowledgment landed: the guidance is to add a NEW fragment, but execute-phase.md is already named by an existing fragment and the linter hard-fails when two ack sources name the same path. Appending to the owning fragment, following its own established multi-PR pattern, was the only lint-clean option. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#2830): backfill changeset pr number Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
257 lines
12 KiB
TypeScript
257 lines
12 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;
|
|
}
|
|
|
|
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 };
|