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
+30 -3
View File
@@ -31,6 +31,7 @@ from typing import Any
from fluksio_worker import worker_main as _worker_main
from fluksio.flow.events import EventBus
from fluksio.flow.nodes.base import NodeOutputError
logger = logging.getLogger(__name__)
@@ -42,6 +43,12 @@ WORKER_MAIN = Path(_worker_main.__file__)
#: something a person is watching a spinner for.
COMPILE_TIMEOUT = 60.0
#: How often to wake up while waiting on a node with no timeout. Nothing is
#: checked on a schedule — a worker that dies closes its pipe and wakes the
#: read immediately — so this is only here to notice a pipe held open by
#: something that outlived the worker it belonged to.
POLL_S = 30.0
#: Environment the worker is not given. ``SECRET_KEY`` decrypts every stored
#: secret, not only the ones bound to the node asking.
ENV_DENY_PREFIXES = ("DATABASE_URL", "FIRST_SUPERUSER", "SENTRY_DSN")
@@ -402,7 +409,9 @@ class PythonWorkerPool:
# been silent rather than how long it has been working. A node
# that reports nothing is still held to it, which is what keeps
# the deadline meaningful for the ones that never report.
line = worker.read_line(time.monotonic() + timeout)
line = worker.read_line(
time.monotonic() + (timeout if timeout > 0 else POLL_S)
)
if line:
try:
message = dict(json.loads(line))
@@ -418,6 +427,14 @@ class PythonWorkerPool:
if on_event is not None:
try:
on_event(message)
except NodeOutputError:
# An emission on a port the node never declared.
# The call is already wrong, and its generator has
# more frames coming down this pipe — so retire the
# worker and let the author see what they emitted.
worker.cancelled = True
worker.kill()
raise
except Exception:
logger.exception("Could not record a worker event")
continue
@@ -435,8 +452,18 @@ class PythonWorkerPool:
if worker.cancelled:
raise NodeCancelled("cancelled while it was running")
if line is None:
worker.kill()
raise NodeTimeout(f"was silent for {timeout}s and was killed")
if timeout > 0:
worker.kill()
raise NodeTimeout(f"was silent for {timeout}s and was killed")
# No limit, so silence is the node working. What ends the call
# is the worker dying, and that arrives as the pipe closing
# rather than as a deadline — unless something else inherited
# the write end and is holding it open, which is what this
# asks about.
if worker.alive():
continue
worker.cancelled = True
raise RemoteError("worker died")
worker.cancelled = True
raise RemoteError("worker died")