Refuse what a node cannot publish, and stop timing out work that is fine

Four things the python SDK turned up, each fixed where every client sees it.

A key no port declares is now an error rather than a silent drop, on the
return, the yield and the emit alike — the contract the docs already stated.
The SDK reads literal yields at sync time, so a typo fails before anything
runs, and an emission of one fails the call rather than being logged where
nobody looks.

NaN and infinity are refused at the port. JSON cannot spell either, so one
that travelled came back as a 500, a socket frame that stopped the canvas, or
a metric batch the database dropped whole.

An artifact input takes `@run:<id>.<output>` or a bare digest, resolved on the
engine — so the CLI, the run dialog and a python caller mean the same thing,
and a sweep can pass one at all.

Node timeouts are off by default. The clock measured silence, which a training
node is full of, and remote workers had already stopped enforcing it — their
heartbeat reset it. Now a heartbeat proves the agent rather than the node,
ninety seconds of nothing fails the call either way, and the engine touches
work it is still running so a long node is not redelivered at sixty seconds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019V5bsYGNxcgPs4xXmTPx69
This commit is contained in:
2026-08-25 07:30:14 +02:00
co-authored by Claude Opus 5
parent c33fa404a4
commit 93374a310e
32 changed files with 968 additions and 67 deletions
+95
View File
@@ -373,3 +373,98 @@ def test_a_node_saves_and_loads_an_artifact(tmp_path):
assert loaded == {"size": 2048}
finally:
pool.stop()
# -----------------------------------------------------------------------------
# No timeout at all
#
# The default: silence is a node working, not a node stuck. What still ends a
# call is the worker dying, which arrives as its pipe closing rather than as a
# deadline.
# -----------------------------------------------------------------------------
def test_a_node_with_no_timeout_runs_past_what_the_default_would_have_killed(pool):
assert pool.run(
"demo",
"patient",
"import time\n\n\ndef process():\n time.sleep(2)\n return {'out': 1}\n",
{},
"demo.patient",
timeout=0,
) == {"out": 1}
def test_a_worker_that_dies_still_fails_promptly_with_no_timeout(pool):
started = time.monotonic()
with pytest.raises(Exception, match="worker died"):
pool.run(
"demo",
"doomed",
"import os\n\n\ndef process():\n os._exit(1)\n",
{},
"demo.doomed",
timeout=0,
)
# Not waiting out a poll interval: the pipe closing is what wakes the read.
assert time.monotonic() - started < 5
assert run(pool, "def process():\n return {'out': 3}\n") == {"out": 3}
def test_a_node_with_no_timeout_can_still_be_cancelled(pool):
def stop_it() -> None:
for _ in range(100):
if pool.cancel("demo.slow"):
return
time.sleep(0.05)
stopper = threading.Thread(target=stop_it)
stopper.start()
try:
with pytest.raises(NodeCancelled):
pool.run(
"demo",
"slow",
"import time\n\n\ndef process():\n time.sleep(30)\n",
{},
"demo.slow",
timeout=0,
)
finally:
stopper.join()
def test_an_emission_on_an_undeclared_port_fails_the_call(pool):
"""The engine's sink raises, and that has to reach the node's author.
A yield is held one behind — the last one is the return value when there is
no explicit return — so the mistake surfaces on the loop's second pass
rather than its first. Which is what a training loop does in a moment, and
a long way short of the hours it used to cost.
"""
from fluksio.flow.nodes.base import NodeOutputError
def refuse(event):
raise NodeOutputError("'demo.gen' produced 'lss', which no port declares")
started = time.monotonic()
with pytest.raises(NodeOutputError, match="lss"):
pool.run(
"demo",
"gen",
"import time\n\n\ndef process():\n"
" for _ in range(3):\n"
" yield {'lss': 1.0}\n"
" time.sleep(30)\n"
" return {'out': 1}\n",
{},
"demo.gen",
timeout=0,
on_event=refuse,
)
# It did not wait out the node: the failure stopped the call.
assert time.monotonic() - started < 10
# The worker was retired rather than left mid-generator, so the slot works.
assert run(pool, "def process():\n return {'out': 4}\n") == {"out": 4}