Stop a quiet producer vetoing a noisy one in the same wave

Both boilers on the house had been unable to switch on since the Node-RED
transition, and the reason was here rather than in their logic: the command
reached `boiler.water_boiler` and stopped, because `dmx.switches` never ran.

A wave orders nodes by a dependency count, and two things decremented that
count only on success:

- a node that published nothing — rate limited, unchanged, or failed — never
  freed its consumers. `dmx.switches` reads both boilers through `rbe` nodes,
  so the kitchen one being unchanged, which it is nearly always, held the main
  one's command back. The encoder ran about four times an hour, and only when
  the lights happened to change in the same wave.
- a node that could not run at all never freed them either, permanently.
  `plugs.pump_run` waits on a watering pulse that only exists at 02:00, so
  every wave it appeared in took its consumers out with it.

Freeing a consumer is not the same as running it: `untouched` already refuses
to run anything whose inputs nothing refreshed, and that is the accurate test.
The dependency count is ordering, not permission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5H4uLCCpsbipL1R7WKCee
This commit is contained in:
2026-08-28 16:53:49 +02:00
co-authored by Claude Opus 5
parent 8f1e685526
commit 831a537980
2 changed files with 181 additions and 14 deletions
+34 -14
View File
@@ -1277,9 +1277,23 @@ class Pipeline:
progressed = True
continue
if check_ready and not self._is_node_ready(n, state):
if n.synchronous:
# Not ready now; a later trigger may make it ready.
skipped.add(n)
# Completed, not merely passed over: a later trigger may
# still make it ready, but not running is not the same
# as blocking. Whatever it last published is in state,
# and a consumer reading that alongside something this
# wave *did* publish has every right to run on the pair.
#
# Passing over it held that consumer for the whole wave,
# and a node that can never be ready — an input nothing
# has ever published — took its consumers out for good.
# On this house `plugs.pump_run` waits on a watering
# pulse that only exists at 02:00, and it sits upstream
# of `dmx.switches`: every boiler, plug and appliance
# command reached its own message and stopped there,
# reaching the DMX universe only in the occasional wave
# the pump node happened not to be part of.
complete(n)
progressed = True
continue
if replay and entry_id and self._already_done(entry_id, n):
# Its side effect happened on an earlier delivery; its
@@ -1301,17 +1315,23 @@ class Pipeline:
for n in completed:
result = node_futures.pop(n).result()
# A node returning nothing (rate limiting, an error) stops
# propagation along its branch.
if result is not None:
# Before the decrement: a consumer freed by this node
# is judged on what it just published.
if fresh is not None:
fresh.update(result)
for consumer in edges[n]:
if consumer in target_nodes:
in_degree[consumer] -= 1
submit_ready(executor)
# Before the decrement: a consumer freed by this node is
# judged on what it just published.
if result is not None and fresh is not None:
fresh.update(result)
# Decremented whatever it returned. A node that published
# nothing — rate limited, unchanged, or failed — has not
# *blocked* its consumers; it has merely given them nothing
# to read, and `untouched` above is the accurate test of
# that. Holding the count instead made a quiet producer
# veto a noisy sibling: `dmx.switches` reads both boilers
# through report-by-exception nodes, so the kitchen one
# staying silent — which is almost always — kept the main
# boiler's command from ever reaching the DMX universe.
for consumer in edges[n]:
if consumer in target_nodes:
in_degree[consumer] -= 1
submit_ready(executor)
if self._node_pool is not None:
# The execution service owns a long-lived pool; building one per