topological-scheduling.decision-log.md
May 21, 2026 · View on GitHub
Title: Topological scheduling for the graph runner
Proposed by: Kuba Skibiński
Date: 28.04.2026
Context
The previous graph runner in packages/execution-core/src/graph-runner.ts ran a BFS-style wave loop where a node was scheduled in the next wave the moment any of its predecessors completed. The follow-up dedup (!nextNodes.some(...)) prevented double-scheduling within a single wave but did not verify that all of a node's predecessors had finished.
Concrete failure mode: in the DAG A → A' → C, B → C where B is at depth 1 and A' at depth 2, B finishes in wave 1 and schedules C for wave 2. A' is also at depth 2, so it runs in the same wave as C. C executes with nodeOutputs[A'] missing. Linear and single-decision flows are unaffected; any real fan-in pattern is subtly wrong.
Decision routing complicated this further. Decision nodes set nextPort (the chosen branch's sourceHandle); the wave loop filtered outgoing edges by that handle. The filter was "late" — successors were collected and then deduped — and made it impossible to express "this entire downstream subtree should be skipped because no live edge reaches it".
The reserved branch_spawned / branches_joined event types in packages/types/src/workflow-execution/execution-events.ts hint this scheduling work was intended for a later iteration. They remain unemitted (frontend doesn't consume them yet); kept on the punch list as a follow-up.
execution-core had no test infrastructure; given that the bug is subtle and easy to regress, this PR also stands up vitest in the package and ships eleven unit tests covering linear / fan-out / diamond / asymmetric / multi-entrypoint / decision-routing / decision-pruned-fan-in / skip-propagation / failure-short-circuit / no-entrypoint / cycle scenarios.
Decision
Replace the BFS wave loop with predecessor-counter scheduling:
pendingPredecessors[nodeId]— count of incoming edges not yet resolved (either the predecessor completed, or its decision pruned the edge).liveIncoming[nodeId]— count of incoming edges that resolved via a non-pruned route.status[nodeId]∈{ 'pending', 'completed', 'skipped' }.
When a node completes (with optional nextPort from a decision):
- For each outgoing edge
(sourceHandle, target):- Decrement
pendingPredecessors[target](always — the edge is resolved either way). - If
!nextPort || sourceHandle === nextPort→ also incrementliveIncoming[target].
- Decrement
- When
pendingPredecessors[target]reaches 0:- If
liveIncoming[target] > 0→ schedule for the next wave. - Else → mark as
'skipped'and recurse withsourceLive = false, so the skip propagates throughtarget's outgoing edges and any unreachable subtree resolves cleanly.
- If
Per-wave parallelism preserved: each iteration runs all currently-ready nodes via Promise.all. Failure short-circuit preserved: any failure in a wave aborts the graph and emits execution_failed. Empty entrypoint set still throws 'Workflow has no entrypoint node'.
Skip propagation runs as an iterative breadth-first walk over a small queue rather than direct recursion, so pathological dead-branch chains can't blow the call stack.
After the wave loop exits, a stall check inspects pendingPredecessors: any node still 'pending' with a non-zero counter never became reachable. The graph emits execution_failed with a message listing the stalled node IDs and updateStatus('failed', ...) instead of execution_completed. This catches reachable cycles (B → C → B), dangling-target edges that sit in pendingPredecessors but never resolve, and any future scheduling regression that leaves nodes hanging — all of which would otherwise produce a "successful" completion event with parts of the workflow never run.
Vitest infra introduced in packages/execution-core/: vitest.config.mts with environment: 'node', test/test:watch scripts in package.json, root pnpm test updated to run both SDK and execution-core suites.
Alternative Options Considered
- Kahn's algorithm with priority queue — rejected. Equivalent semantics for a DAG, more machinery, no runtime advantage when nodes within a wave run concurrently anyway.
- Per-completion (event-driven) scheduling instead of wave-batched — rejected. Cleaner conceptually for true concurrent ready-set growth, but harder to test deterministically and changes the timing of
Promise.all-driven event ordering. Wave-batched matches existing semantics one-to-one. - Defer the fix; bundle cycle detection — rejected. Cycles deadlock silently today; the scheduling fix is independent and worth landing on its own. Full cycle detection (proactive, before execution starts) belongs to a separate hardening pass — but a cheap post-loop stall check (see Decision below) catches cycles reachable from an entrypoint as a side effect, so we don't ship a known silent-completion regression alongside the fix.
- Emit
node_skippedevent for decision-pruned nodes — rejected for this PR. Frontend doesn't consume yet; surfacing it would be a UX feature, separate ticket. - Manual smoke testing only (consistent with I-01) — rejected. The bug is scheduler-level and easy to regress unobserved. The bug-report itself estimates "M (2-3 days including tests)". Test infra was always part of scope.
Consequences
-
Pros
- Fan-in semantics correct: a join point sees outputs from all of its live predecessors, in the wave after all of them have finished.
- Decision-pruned branches genuinely skipped at scheduling time — pruned subtrees never enter the ready set, never emit
node_started. Skip propagates transitively (E ← C ← Cprime, whenCis in the dead branch,CprimeandEresolve correctly). - The
(!result.nextPort || result.nextPort === sourceHandle)filter logic is unchanged — same edge-liveness predicate, just applied in a different place. No semantic drift on existing flows. - Test infra now exists in
execution-core. Future graph-runner / executor / template-resolution work has a place to land tests.
-
Cons
- More state to track per node —
O(nodes)extra memory on top of the existing adjacency map. Not material at expected workflow sizes. - The stall check is reactive (post-loop) rather than proactive (before execution starts). Workflows containing reachable cycles still execute their non-cycle prefix before failing. Proactive cycle detection is a future ticket; the reactive check is enough to keep
execution_completedhonest.
- More state to track per node —
Status
Accepted