fix(#4628): expose DAG-ready plans and restrict dispatch to them (#4781)

Emitted-Drift-Ack-Growth: execute-phase.md — #4628 consumer wiring: ready_plans parse pointer, not-ready named skip, and waiting condition 2b reference to the ready-wave-gate step file

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-16 02:38:43 -04:00
committed by GitHub
parent febe6c9885
commit 740ba0d8a3
41 changed files with 432 additions and 684 deletions

View File

@@ -974,13 +974,6 @@ function cmdInitExecutePhase(
? toPosixPath(path.join(cwd, phaseInfo['directory'] as string))
: null,
phase_number: phaseInfo?.['phase_number'] || null,
// #4748: the disk path hands back the directory's padded number (`03A`)
// but the ROADMAP fallback above hands back the heading's bare one (`3A`),
// and execute-phase.md's review lookup needs the padded form for
// `{PADDED}-REVIEW.md`. It used to re-pad in shell with `printf "%02d"`,
// which cannot pad a letter id and reads an already-padded `08` as octal.
// Emit the canonical normalization, as the plan-phase/code-review inits do.
padded_phase: phaseInfo?.['phase_number'] ? normalizePhaseName(phaseInfo['phase_number']) : null,
// #3171: prefer the ROADMAP's curated display name for `phase_name`. When
// the phase directory already exists on disk, the disk-lookup path
// (searchPhaseInDir) derives phase_name from the directory-name remainder

View File

@@ -32,7 +32,7 @@ import frontmatterModule = require('./frontmatter.cjs');
const { extractFrontmatter } = frontmatterModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planDependencyGraphModule = require('./plan-dependency-graph.cjs');
const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted } = planDependencyGraphModule;
const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted, isSummaryFileBlocked } = planDependencyGraphModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapParserModule = require('./roadmap-parser.cjs');
const { getMilestonePhaseFilter } = roadmapParserModule;
@@ -74,8 +74,18 @@ interface PhaseSearchResult {
* #2830: the runnable-only view — `incomplete_plans` filtered to exclude
* anything present as a key in `blocked_by`. `incomplete_plans` itself
* keeps its pre-#2830 meaning ("no matching SUMMARY yet") unchanged.
* NOTE (#4628): runnable means "not halted-blocked", NOT DAG-ready — a
* runnable plan whose dependencies lack completion evidence is NOT ready.
* Consumers dispatch from `ready_plans`.
*/
runnable_plans: string[];
/**
* #4628: the DAG-ready view the dispatcher consumes — incomplete plans
* whose every resolved dependency has completion evidence (a matching
* SUMMARY file) and which are not halted-blocked. Ready-ness is transitive:
* a ready plan's own dependencies are complete.
*/
ready_plans: string[];
}
/**
@@ -224,6 +234,7 @@ function searchPhaseInDir(baseDir: string, relBase: string, normalized: string):
halted_plans: [],
blocked_by: {},
runnable_plans: [],
ready_plans: [],
};
}
@@ -264,12 +275,22 @@ function searchPhaseInDir(baseDir: string, relBase: string, normalized: string):
plans.map((p, i) => [extractCanonicalPlanId(p).toLowerCase(), planIds[i]]),
);
// #4628: raw depends_on per plan, so readiness can fail closed on a
// DROPPED edge (a dep token that resolves to nothing carries no evidence)
// instead of the resolution silently shrinking the dependency list.
const rawDeps = plans.map((p) => parsePlanDependsOn(phaseDir, p));
// #4628: completion evidence excludes status:blocked summaries (#3345) —
// a failure record is not completion, matching cmdPhasePlanIndex's count.
const completionEvidence = buildSummaryFileIndex(
summaries.filter((f) => !isSummaryFileBlocked(path.join(phaseDir, f))),
extractCanonicalPlanId,
);
const haltNodes = plans.map((p, i) => {
const planId = planIds[i];
const canonical = extractCanonicalPlanId(p);
const summaryFile = summaryFileByPlanId.get(planId) ?? summaryFileByPlanId.get(canonical);
const halted = summaryFile !== undefined && isSummaryFileHalted(path.join(phaseDir, summaryFile));
const resolvedDependsOn = parsePlanDependsOn(phaseDir, p)
const resolvedDependsOn = rawDeps[i]
.map((dep) => {
const lower = dep.toLowerCase();
return planIdByLower.get(lower) ?? canonicalToPlanId.get(lower) ?? null;
@@ -283,15 +304,23 @@ function searchPhaseInDir(baseDir: string, relBase: string, normalized: string):
const incompletePlanSet = new Set(incompletePlans);
const blockedByFiles: Record<string, string[]> = {};
const runnablePlans: string[] = [];
const readyPlans: string[] = [];
for (let i = 0; i < plans.length; i++) {
const p = plans[i];
if (!incompletePlanSet.has(p)) continue;
const causes = blockedBy.get(planIds[i]) ?? [];
if (causes.length > 0) {
blockedByFiles[p] = causes;
} else {
runnablePlans.push(p);
continue;
}
runnablePlans.push(p);
// #4628: DAG-ready on top of runnable — every dependency must have
// completion evidence (a matching, non-blocked SUMMARY) and no dropped
// edge: both readers of this contract fail closed identically.
const depsComplete =
rawDeps[i].length === haltNodes[i].resolvedDependsOn.length &&
haltNodes[i].resolvedDependsOn.every((dep) => completionEvidence.has(dep));
if (depsComplete) readyPlans.push(p);
}
return {
@@ -315,6 +344,7 @@ function searchPhaseInDir(baseDir: string, relBase: string, normalized: string):
halted_plans: haltedPlans,
blocked_by: blockedByFiles,
runnable_plans: runnablePlans,
ready_plans: readyPlans,
};
} catch {
return null;

View File

@@ -855,7 +855,7 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
phase: normalized,
error: `Phase ${normalized} is ambiguous: ${ambiguousMatches.length} directories match (${ambiguousMatches.map((m) => `"${m}"`).join(', ')}).`,
ambiguous_matches: ambiguousMatches,
plans: [], waves: {}, incomplete: [], has_checkpoints: false,
plans: [], waves: {}, incomplete: [], runnable: [], ready_plans: [], has_checkpoints: false,
},
raw,
);
@@ -864,7 +864,7 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
if (!phaseDir) {
output(
{ phase: normalized, error: 'Phase not found', plans: [], waves: {}, incomplete: [], runnable: [], has_checkpoints: false },
{ phase: normalized, error: 'Phase not found', plans: [], waves: {}, incomplete: [], runnable: [], ready_plans: [], has_checkpoints: false },
raw,
);
return;
@@ -1034,6 +1034,10 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
const waves: Record<string, string[]> = {};
const incomplete: string[] = [];
const runnable: string[] = [];
// #4628: DAG-ready view — see the per-plan emission below. Keyed by id so
// the per-plan pass can resolve each plan's dependency edges.
const resolvedDepsByPlan = new Map(haltNodes.map((n) => [n.id, n.resolvedDependsOn]));
const readyPlans: string[] = [];
let hasCheckpoints = false;
const warnings: string[] = [];
@@ -1044,8 +1048,12 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
// plan with no dropped edges and a genuinely wrong `wave:` still warns
// (N3, D6, T25).
const plansWithUnresolvedTokens = new Set<string>();
const unresolvedTokensByPlan = new Map<string, string[]>();
for (const { plan, token } of unresolved) {
plansWithUnresolvedTokens.add(plan);
const tokens = unresolvedTokensByPlan.get(plan) ?? [];
tokens.push(formatDiagnosticToken(token));
unresolvedTokensByPlan.set(plan, tokens);
warnings.push(
`Plan ${plan}: depends_on token ${formatDiagnosticToken(token)} does not resolve to any plan in this phase — edge dropped, wave placement for this plan may be unreliable`,
);
@@ -1056,14 +1064,34 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
hasCheckpoints = true;
}
const blockedByIds = blockedBy.get(rawPlan.id) ?? [];
// #4628: readiness fails closed — every resolved dependency must have
// completion evidence (has_summary), and a depends_on edge that never
// resolved (#3427 — a dropped edge) carries no evidence to check. A
// single evaluation feeds both the ready_plans list and the per-plan
// `ready` / `unresolved_dependencies` fields below.
// Case-fold the dep before the planMap lookup: planMap is lowercase-keyed
// (#2237) while resolveDependencyId returns the raw id — a mixed-case id
// must not read as 'no evidence' (over-blocking a ready plan).
const missingEvidence = (resolvedDepsByPlan.get(rawPlan.id) ?? [])
.filter((dep) => planMap.get(dep.toLowerCase())?.hasSummary !== true);
if (plansWithUnresolvedTokens.has(rawPlan.id)) {
missingEvidence.push(...(unresolvedTokensByPlan.get(rawPlan.id) ?? []));
}
const isReady = blockedByIds.length === 0 && missingEvidence.length === 0;
if (!rawPlan.hasSummary) {
incomplete.push(rawPlan.id);
// #2830: the runnable-only view — incomplete AND not transitively
// blocked by a halted upstream plan. Additive alongside `incomplete`,
// which keeps its existing "no SUMMARY yet" meaning unchanged.
// #4628: runnable says NOTHING about completion evidence — a runnable
// plan whose dependencies lack a SUMMARY is not DAG-ready. Consumers
// dispatch from `ready_plans`.
if (blockedByIds.length === 0) {
runnable.push(rawPlan.id);
}
if (isReady) {
readyPlans.push(rawPlan.id);
}
}
const computedWave = (level.get(rawPlan.id) ?? 0) + levelOffset;
@@ -1111,6 +1139,14 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
halted: rawPlan.halted,
blocked_by: blockedByIds,
};
if (!rawPlan.hasSummary) {
// #4628: readiness is a property of INCOMPLETE plans (a summarized plan
// is filtered by has_summary before readiness is ever consulted). The
// unresolved_dependencies list names exactly which predecessors lack
// completion evidence, for the named-skip report.
plan['ready'] = isReady;
if (missingEvidence.length > 0) plan['unresolved_dependencies'] = missingEvidence;
}
plans.push(plan);
@@ -1127,6 +1163,7 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
waves,
incomplete,
runnable,
ready_plans: readyPlans,
has_checkpoints: hasCheckpoints,
};
if (planNamingWarning) result['warning'] = planNamingWarning;