/* RFC-0686: Dependency-aware pipeline scheduler. Translates KernelPipelineStep[] into a ScheduledStep[] with resolved dependency indices, then executes them concurrently up to a configurable concurrency limit. Steps whose dependencies are all completed start concurrently; failed steps cause transitive dependents to be skipped. Does not handle cache reads/writes, telemetry, or context creation — those remain in execute-pipeline.ts via the executeStep callback. Does not handle cross-pipeline dependencies — each pipeline invocation gets its own scheduler instance. The scheduler translates steps into a dependency-aware plan — execution order is derived, not declared. RFC-0686: created — buildSchedule, executeScheduledSteps, ScheduledStep interface. RFC-1097: step 6 — compass.migrate codemod run Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order. RFC-1097: sweep — werkstatt-engine clean Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics. RFC-1144: collect-errors scheduling — read-only steps (ScheduledStep.readOnly) run after dependency failures with ranAfterFailedDependency annotation; transitive ranTainted set keeps mutating dependents skipped; executeScheduledSteps accepts { collectErrors } option. RFC-1144: run read-only steps to completion in collect-errors pipeline mode Scheduler: collectErrors option on executeScheduledSteps — read-only steps (ScheduledStep.readOnly, executor-resolved from mutatesState === false) run after dependency failures with ranAfterFailedDependency annotation; ranTainted set propagates taint transitively so mutating dependents still skip. Executor: mergeCollectErrorsOption OR-merges PipelineDeclaration.collectErrors with the CLI flag at registry resolution; readOnly annotated post-buildSchedule; taint flag copied to step reports; tainted failures marked in pretty output. Contracts: PipelineDeclaration.collectErrors, KernelExecutionReport.ranAfterFailedDependency, pipelines maps store full declarations. Declarations: collectErrors on validation-gate registrations (build.post, check, build.check, compass, integrity.release in the workpiece template; packages.check in checks module). Tests: 10 new cases covering AC-1..AC-6 plus transitive taint. */ import type { KernelExecutionReport, KernelPipelineStep } from "@warpgogol/werkstatt-shared/kernel"; export interface ScheduledStep { step: KernelPipelineStep; stepIndex: number; /** Indices of steps this step depends on (resolved from dependsOn command names or implicit sequential ordering). */ dependencies: Set; /** * RFC-1144: resolved by the executor from the command's `mutatesState` * declaration (`mutatesState === false` ⇒ true). Missing declaration ⇒ * false (conservative — treated as mutating). */ readOnly?: boolean; /** * RFC-1144: set by the scheduler when this step is queued despite a * failed/failure-skipped/tainted dependency (collect-errors mode only). * Copied onto the step's KernelExecutionReport by the executor. */ ranAfterFailedDependency?: boolean; } export class ScheduleError extends Error { constructor(message: string) { super(message); this.name = "ScheduleError"; } } /** * Build a schedule from pipeline steps. * * For steps WITHOUT `dependsOn`: an implicit dependency on the previous * non-skipped step's index is added (backward-compatible sequential behavior). * * For steps WITH `dependsOn: []`: no dependencies are added (start immediately). * * For steps WITH `dependsOn: ["cmd.a", "cmd.b"]`: command names are translated * to step indices by finding the first step with a matching `command` name * that appears before the current step in the array. * * Throws ScheduleError on: * - Forward references (dependsOn references a command that appears later) * - Missing references (dependsOn references a command not in the pipeline) * - Circular dependencies * - Duplicate command names (would make dependsOn ambiguous) */ export function buildSchedule(steps: KernelPipelineStep[]): ScheduledStep[] { // Detect duplicate command names. const commandToFirstIndex = new Map(); for (let i = 0; i < steps.length; i++) { const cmd = steps[i]!.command; if (commandToFirstIndex.has(cmd)) { throw new ScheduleError( `Duplicate command name \`${cmd}\` at step index ${i} (first occurrence at index ${commandToFirstIndex.get(cmd)}). dependsOn is ambiguous with duplicate command names.`, ); } commandToFirstIndex.set(cmd, i); } const scheduled: ScheduledStep[] = []; for (let i = 0; i < steps.length; i++) { const step = steps[i]!; const dependencies = new Set(); if (step.dependsOn !== undefined) { // Explicit dependsOn — resolve command names to indices. for (const depName of step.dependsOn) { const depIndex = commandToFirstIndex.get(depName); if (depIndex === undefined) { throw new ScheduleError( `Step ${i} (\`${step.command}\`) depends on \`${depName}\` which is not in the pipeline.`, ); } if (depIndex >= i) { throw new ScheduleError( `Step ${i} (\`${step.command}\`) depends on \`${depName}\` which appears at index ${depIndex} (forward reference). Dependencies must appear before the dependent step.`, ); } dependencies.add(depIndex); } } else { // Implicit dependency on the previous non-skipped step. // Skipped steps (step.skip === true) are treated as completed and // don't create implicit dependencies — we look backward for the // last non-skipped step. for (let j = i - 1; j >= 0; j--) { if (!steps[j]!.skip) { dependencies.add(j); break; } } } scheduled.push({ step, stepIndex: i, dependencies }); } // Detect circular dependencies via topological sort (Kahn's algorithm). detectCycles(scheduled); return scheduled; } /** * Detect circular dependencies in the schedule using Kahn's algorithm. * Throws ScheduleError if a cycle is found. */ function detectCycles(scheduled: ScheduledStep[]): void { const n = scheduled.length; const inDegree = new Array(n).fill(0); const adjacency: number[][] = Array.from({ length: n }, () => []); for (const s of scheduled) { for (const dep of s.dependencies) { adjacency[dep]!.push(s.stepIndex); inDegree[s.stepIndex]!++; } } const queue: number[] = []; for (let i = 0; i < n; i++) { if (inDegree[i] === 0) queue.push(i); } let visited = 0; while (queue.length > 0) { const node = queue.shift()!; visited++; for (const neighbor of adjacency[node]!) { inDegree[neighbor]!--; if (inDegree[neighbor] === 0) queue.push(neighbor); } } if (visited !== n) { // Find a node in a cycle for the error message. const inCycle = []; for (let i = 0; i < n; i++) { if (inDegree[i]! > 0) inCycle.push(scheduled[i]!.step.command); } throw new ScheduleError(`Circular dependency detected involving: ${inCycle.join(" → ")}`); } } /** * Result of executing a single scheduled step. * Contains the step index for correlation and the execution report. */ export interface StepExecutionResult { stepIndex: number; report: KernelExecutionReport; /** When true, this step was skipped due to a failed dependency (not executed). */ dependencySkipped: boolean; } /** * Execute scheduled steps with dependency-aware concurrency. * * - Steps whose dependencies are all completed start concurrently, * up to `concurrency` parallel executions. * - When a step fails, all transitive dependents are skipped with * `dependencySkipped: true`. Independent steps continue executing. * - When `concurrency` is 1, the scheduler ignores all `dependsOn` fields * and treats every step as depending on the previous non-skipped step * (full sequential mode with abort-on-failure semantics). * * The `executeStep` callback handles cache, telemetry, context creation, * and progress reporting. It receives a `ScheduledStep` and returns a * `KernelExecutionReport`. * * RFC-1144: when `opts.collectErrors` is true, the failure cascade skips * only mutating dependents — read-only steps (`ScheduledStep.readOnly`) * run with `ranAfterFailedDependency` set, and their completion taints * downstream steps transitively (mutating dependents still skip). * * Returns results sorted by stepIndex (declaration order). */ export async function executeScheduledSteps( scheduled: ScheduledStep[], concurrency: number, executeStep: (step: ScheduledStep) => Promise, opts?: { collectErrors?: boolean }, ): Promise { const collectErrors = opts?.collectErrors === true; const n = scheduled.length; const results: StepExecutionResult[] = []; const completed = new Set(); const failed = new Set(); const skippedDueToFailure = new Set(); // RFC-1144: steps that ran after a failed dependency — they taint their // dependents transitively regardless of their own outcome. const ranTainted = new Set(); // When concurrency is 1, rebuild the schedule with full sequential // implicit dependencies (ignoring all dependsOn fields). This gives // true abort-on-failure semantics identical to the current executor. let effectiveSchedule = scheduled; if (concurrency === 1) { effectiveSchedule = scheduled.map((s, i) => ({ step: s.step, stepIndex: s.stepIndex, dependencies: new Set(i > 0 && !s.step.skip ? [scheduled[i - 1]!.stepIndex] : []), // RFC-1144: preserve executor-resolved fields across the sequential rebuild. readOnly: s.readOnly, ranAfterFailedDependency: s.ranAfterFailedDependency, })); } // Track in-flight steps. const inFlight = new Map>(); // Track which steps are ready to execute (all deps completed, not yet started). const readyQueue: number[] = []; function updateReady(): void { for (const s of effectiveSchedule) { if ( completed.has(s.stepIndex) || failed.has(s.stepIndex) || skippedDueToFailure.has(s.stepIndex) ) continue; if (inFlight.has(s.stepIndex)) continue; if (readyQueue.includes(s.stepIndex)) continue; // Check if any dependency failed, was skipped due to failure, or ran // tainted (RFC-1144: transitive upstream failure). let depFailed = false; let allDepsComplete = true; for (const dep of s.dependencies) { if (failed.has(dep) || skippedDueToFailure.has(dep) || ranTainted.has(dep)) { depFailed = true; break; } if (!completed.has(dep)) { allDepsComplete = false; break; } } if (depFailed) { // RFC-1144: under collect-errors, read-only steps still run — // annotated so the report marks them as possibly cascaded. if (collectErrors && s.readOnly === true) { s.ranAfterFailedDependency = true; readyQueue.push(s.stepIndex); } else { // Mark this step and all its transitive dependents as skipped. markSkippedDueToFailure(s.stepIndex); } } else if (allDepsComplete) { readyQueue.push(s.stepIndex); } } } function markSkippedDueToFailure(stepIndex: number): void { if (skippedDueToFailure.has(stepIndex)) return; skippedDueToFailure.add(stepIndex); // Cascade to dependents. for (const s of effectiveSchedule) { if ( s.dependencies.has(stepIndex) && !skippedDueToFailure.has(s.stepIndex) && !completed.has(s.stepIndex) && !failed.has(s.stepIndex) ) { markSkippedDueToFailure(s.stepIndex); } } } function processReadyQueue(): void { updateReady(); while (readyQueue.length > 0 && inFlight.size < concurrency) { const idx = readyQueue.shift()!; if (completed.has(idx) || failed.has(idx) || skippedDueToFailure.has(idx)) continue; if (inFlight.has(idx)) continue; const step = effectiveSchedule[idx]!; const promise = (async () => { const report = await executeStep(step); if (report.ok) { completed.add(idx); } else { failed.add(idx); } // RFC-1144: a step that ran after a failed dependency taints its // dependents — mutating ones skip, read-only ones run annotated. if (step.ranAfterFailedDependency === true) { ranTainted.add(idx); } results.push({ stepIndex: idx, report, dependencySkipped: false }); inFlight.delete(idx); processReadyQueue(); })(); inFlight.set(idx, promise); } } // Process skipped steps (add them to results immediately). function processSkipped(): void { updateReady(); // Add skipped-due-to-failure steps to results. for (const s of effectiveSchedule) { if ( skippedDueToFailure.has(s.stepIndex) && !results.some((r) => r.stepIndex === s.stepIndex) ) { results.push({ stepIndex: s.stepIndex, report: { commandName: s.step.command, exitCode: 0, ok: true, summary: `Skipped: dependency failed`, metadata: {} as never, logs: [], timing: { durationMs: 0, exceededTimeout: false }, filesModified: [], }, dependencySkipped: true, }); } } } processReadyQueue(); // Wait for all in-flight steps to complete, then process again. while (inFlight.size > 0 || results.length < n) { if (inFlight.size > 0) { await Promise.race(inFlight.values()); } processReadyQueue(); processSkipped(); // If nothing is in-flight and nothing is ready and we haven't processed // everything, we might be stuck — check for remaining steps. if (inFlight.size === 0 && readyQueue.length === 0) { // Process any remaining skipped steps. processSkipped(); // Check if there are steps that should be ready but weren't picked up. updateReady(); if (readyQueue.length === 0 && results.length < n) { // Remaining steps are all skipped due to failure — add them. for (const s of effectiveSchedule) { if (!results.some((r) => r.stepIndex === s.stepIndex)) { if (!skippedDueToFailure.has(s.stepIndex)) { // RFC-1144: read-only steps run even here — their dependencies // can never complete, which is itself a tainted state. if (collectErrors && s.readOnly === true) { s.ranAfterFailedDependency = true; readyQueue.push(s.stepIndex); } else { markSkippedDueToFailure(s.stepIndex); } } } } processSkipped(); if (readyQueue.length === 0) break; } } } // Sort results by stepIndex (declaration order). results.sort((a, b) => a.stepIndex - b.stepIndex); return results; }