Let a flow keep state in the messages it already has

Logic nodes are pure functions with no state handle, but real automations
count things and remember the last reading. The shape for that is a
message a node both reads and writes: the graph already declines to make
a node depend on itself, so this worked by accident. It is now defined,
tested, and checked — a node that is the only writer of a message it
reads is told at edit time that it needs a starting value, rather than
silently never running.

Feeding a value back between two nodes was still a cycle, and rejected.
An input can now be marked non-triggering: read when the node runs, never
the reason it runs, and no dependency either way. That is what a back
edge actually means.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
This commit is contained in:
root
2026-08-16 07:37:46 +02:00
co-authored by Claude Fable 5
parent f8693daad6
commit 2cc25970e3
5 changed files with 222 additions and 12 deletions
+5
View File
@@ -46,6 +46,10 @@ class MessageSpec(BaseModel):
On an output it holds back publishing, on an input it holds back waking
the node. The value is never lost — state keeps the latest — only the
delivery is skipped.
:param trigger: Whether arriving values wake the node. An input with this
off is read when the node runs for some other reason, but never causes
a run and never makes the node wait — which is how a node reads a
message it also produces without depending on itself.
"""
model_config = ConfigDict(frozen=True)
@@ -54,6 +58,7 @@ class MessageSpec(BaseModel):
port: str = ""
dtype: DType = DType.FLOAT
interval: float = Field(default=0, ge=0)
trigger: bool = True
@model_validator(mode="after")
def _default_port(self) -> MessageSpec:
+44 -8
View File
@@ -35,6 +35,7 @@ class ValidationIssue(BaseModel):
"missing_initial_value",
"node_error",
"unauthenticated_hook",
"self_loop_needs_initial",
]
message: str
flow: str = ""
@@ -91,10 +92,14 @@ class Pipeline:
for msg in node.provides:
self.produces.setdefault(msg, []).append(node)
# A node never depends on itself: reading a message it also provides is
# how state is carried between runs, not a cycle. An input marked
# non-triggering is the same idea across two nodes.
self.dependencies: dict[Node, frozenset[Node]] = {
node: frozenset(
producer
for msg in node.requires
for msg, spec in node.requires.items()
if spec.trigger
for producer in self.produces.get(msg, ())
if producer is not node
)
@@ -211,6 +216,29 @@ class Pipeline:
for node in self._nodes:
for msg_name, spec in node.requires.items():
if msg_name in self.produces:
# A message a node both reads and writes carries state
# between its runs. If the node is the only one writing it,
# the first run has nothing to read unless the flow declares
# a starting value.
if (
spec.trigger
and self.produces[msg_name] == [node]
and not declared.get(msg_name, False)
):
issues.append(
ValidationIssue(
code="self_loop_needs_initial",
message=(
f"'{node.local_id}' reads '{msg_name}' and is "
"the only node writing it, so it needs a "
"starting value to ever run."
),
flow=node.flow,
node=node.id,
port=spec.port,
message_name=msg_name,
)
)
continue
if msg_name not in declared:
@@ -290,18 +318,23 @@ class Pipeline:
self._state.increment(self._version_key(msg_name))
def _check_synchronous_ready(self, node: Node) -> tuple[bool, dict[str, int]]:
"""A synchronous node runs once every input is newer than last time."""
if not node.requires:
"""A synchronous node runs once every input is newer than last time.
Only triggering inputs count: waiting for a value the node itself
writes would mean waiting for a run that can never start.
"""
waited_on = [msg for msg, spec in node.requires.items() if spec.trigger]
if not waited_on:
return True, {}
version_keys = [self._version_key(msg) for msg in node.requires]
last_seen_keys = [self._last_seen_key(node.id, msg) for msg in node.requires]
version_keys = [self._version_key(msg) for msg in waited_on]
last_seen_keys = [self._last_seen_key(node.id, msg) for msg in waited_on]
values = self._state.get_multi(version_keys + last_seen_keys)
current_versions = {}
all_newer = True
for msg_name in node.requires:
for msg_name in waited_on:
current = values.get(self._version_key(msg_name)) or 0
last_seen = values.get(self._last_seen_key(node.id, msg_name)) or 0
current_versions[msg_name] = current
@@ -359,8 +392,11 @@ class Pipeline:
def _is_node_ready(self, node: Node, state: StateBackend) -> bool:
with state.lock():
for msg_name in node.requires:
if msg_name not in state:
for msg_name, spec in node.requires.items():
# A non-triggering input is read if it happens to be there;
# waiting for it would make an accumulator's first run
# impossible, since it is what the node is about to write.
if spec.trigger and msg_name not in state:
return False
if not self._input_is_due(node):