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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user