Bug: engine executes ready nodes sequentially — parallel dispatch broken
Severity: P1 — Diamond Pattern (fan-out + fan-in) doesn't get parallel speedup
Filed: 2026-07-28
Sibling: nullboiler-7mn (storage fix, closed), nullboiler-62s (reduce type), nullboiler-9x1 (sequential chain)
Symptom
When a workflow has N independent nodes (no depends_on between them), the engine dispatches them ONE AT A TIME, not concurrently. Total wall time = sum of all node durations, not max(durations).
Measured: 3-node fan-out (codebase_check 56s + noise_patterns 33s + filter_approaches 21s = 110s sequential). If parallel, wall time would be max(56s) = 56s. Nearly 2x speedup lost.
Root cause
src/engine.zig:601:
// 5. Execute ready nodes sequentially
for (ready_nodes) |node_name| {
// executeTaskNode BLOCKS until the worker responds
const result = self.executeTaskNode(alloc, ...);
// apply result, mark completed
}
findReadyNodesFromRoot correctly identifies multiple ready nodes (verified: returns all 3 independent nodes). But the for-loop calls executeTaskNode which dispatches to a worker and BLOCKS waiting for the response. Only after node 1 completes does node 2 dispatch.
Infrastructure already exists for the fix
The engine already has concurrent dispatch infrastructure:
FetchSlot (engine.zig:290-303) — per-dispatch state with mutex + condition variable
fetchWorker (engine.zig:305-339) — worker thread function that calls doFetch and publishes result to slot
doFetchTimed (engine.zig:347-397) — creates FetchSlot, spawns thread, polls slot until result or timeout
Currently used per-node (one dispatch at a time). Fix: dispatch all ready nodes to their own FetchSlots/threads, then collect results.
Proposed fix
Replace the sequential for-loop with a two-phase pattern:
// Phase 1: dispatch all ready nodes concurrently
var slots: []FetchSlot = alloc.alloc(ready_nodes.len);
for (ready_nodes, 0..) |node_name, i| {
slots[i] = startDispatch(alloc, node_name, ...); // non-blocking, thread runs
}
// Phase 2: collect results sequentially (state mutation must be serial)
for (slots) |*slot| {
const result = waitForSlot(slot); // poll until done
applyResult(result); // mutate state
markCompleted(node_name);
}
Constraints:
- Result application stays sequential (can't mutate running_state concurrently)
- Worker
max_concurrent must be respected (if worker has max_concurrent=1, queue nodes for same worker)
- Each FetchSlot needs its own arena allocator (already done in existing pattern)
Acceptance criteria
Evidence that this is a real problem
Experiment run aa84769e-9e5a-469d-b31e-92e98538ebae (bd_0x2c-tvax, Diamond Pattern):
- 3 independent nodes completed at: +56s, +33s, +21s (sequential gaps prove one-at-a-time dispatch)
- Synthesis started only after all 3 completed (correct dependency enforcement)
- Total wall: 163s. With parallel: ~110s (synthesis still sequential after fan-out)
Complexity estimate
100-150 lines of Zig. Reuses FetchSlot + fetchWorker pattern. No new abstractions. The main change is restructuring the for-loop at engine.zig:601 into dispatch-all-then-collect pattern.
Bug: engine executes ready nodes sequentially — parallel dispatch broken
Severity: P1 — Diamond Pattern (fan-out + fan-in) doesn't get parallel speedup
Filed: 2026-07-28
Sibling:
nullboiler-7mn(storage fix, closed),nullboiler-62s(reduce type),nullboiler-9x1(sequential chain)Symptom
When a workflow has N independent nodes (no
depends_onbetween them), the engine dispatches them ONE AT A TIME, not concurrently. Total wall time = sum of all node durations, not max(durations).Measured: 3-node fan-out (codebase_check 56s + noise_patterns 33s + filter_approaches 21s = 110s sequential). If parallel, wall time would be max(56s) = 56s. Nearly 2x speedup lost.
Root cause
src/engine.zig:601:findReadyNodesFromRootcorrectly identifies multiple ready nodes (verified: returns all 3 independent nodes). But the for-loop callsexecuteTaskNodewhich dispatches to a worker and BLOCKS waiting for the response. Only after node 1 completes does node 2 dispatch.Infrastructure already exists for the fix
The engine already has concurrent dispatch infrastructure:
FetchSlot(engine.zig:290-303) — per-dispatch state with mutex + condition variablefetchWorker(engine.zig:305-339) — worker thread function that callsdoFetchand publishes result to slotdoFetchTimed(engine.zig:347-397) — creates FetchSlot, spawns thread, polls slot until result or timeoutCurrently used per-node (one dispatch at a time). Fix: dispatch all ready nodes to their own FetchSlots/threads, then collect results.
Proposed fix
Replace the sequential for-loop with a two-phase pattern:
Constraints:
max_concurrentmust be respected (if worker has max_concurrent=1, queue nodes for same worker)Acceptance criteria
max_concurrenton workers respected (don't overload a single worker)Evidence that this is a real problem
Experiment run
aa84769e-9e5a-469d-b31e-92e98538ebae(bd_0x2c-tvax, Diamond Pattern):Complexity estimate
100-150 lines of Zig. Reuses FetchSlot + fetchWorker pattern. No new abstractions. The main change is restructuring the for-loop at engine.zig:601 into dispatch-all-then-collect pattern.