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:
@@ -13,9 +13,11 @@ have to resolve inside a worker, where ``fluksio`` is the reporter module.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
import json
|
||||
import re
|
||||
import textwrap
|
||||
import warnings
|
||||
from collections.abc import Callable, Sequence
|
||||
from pathlib import Path
|
||||
@@ -265,9 +267,15 @@ def node(
|
||||
A batch run skips this node when an earlier one already ran the same source
|
||||
with the same settings and the same input values, and restores what it
|
||||
returned. ``cache=False`` says not to: the answer can change on its own.
|
||||
|
||||
``timeout`` is seconds of silence — a yield or an emit resets it — after
|
||||
which the node is stopped. Left out it inherits the engine's default, which
|
||||
is no limit; set one where silence means stuck rather than working.
|
||||
"""
|
||||
if device_policy not in ("require", "prefer"):
|
||||
raise SyncError("device_policy is 'require' or 'prefer'")
|
||||
if timeout is not None and timeout < 0:
|
||||
raise SyncError(f"node timeout must be 0 or more (0 disables it), got {timeout}")
|
||||
|
||||
def decorate(fn: F) -> F:
|
||||
required, _ = _ports(requires)
|
||||
@@ -366,6 +374,68 @@ def _check_signature(spec: NodeSpec) -> None:
|
||||
f"add Port('{name}', ...) to requires=, or give it a default"
|
||||
)
|
||||
|
||||
_check_emissions(spec)
|
||||
|
||||
|
||||
def _check_emissions(spec: NodeSpec) -> None:
|
||||
"""Whether what the function yields is what it says it provides.
|
||||
|
||||
The engine refuses an undeclared key at the first yield, which is right but
|
||||
late: a sweep can be an hour in. Only literal keys are read here — a yield
|
||||
of a name the code computes is left to the engine — so this catches the
|
||||
mistake it is for, which is a typo.
|
||||
"""
|
||||
provided = {port.port for port in spec.provides}
|
||||
try:
|
||||
tree = ast.parse(textwrap.dedent(inspect.getsource(spec.fn)))
|
||||
except (OSError, TypeError, SyntaxError):
|
||||
# No source to read: a function built at runtime, or one from a
|
||||
# console. Not a reason to refuse it.
|
||||
return
|
||||
|
||||
for key in _emitted_keys(tree):
|
||||
if key not in provided:
|
||||
declared = ", ".join(sorted(provided)) or "none"
|
||||
raise SyncError(
|
||||
f"node '{spec.id}': {spec.fn.__name__}() emits '{key}', which no "
|
||||
f"port declares. Its ports are: {declared}."
|
||||
)
|
||||
|
||||
|
||||
def _emitted_keys(tree: ast.AST) -> list[str]:
|
||||
"""Literal port names a function yields or emits, its own only.
|
||||
|
||||
A nested function is somebody else's ports — a helper generator yielding
|
||||
pairs is not this node emitting them — so the walk stops at one.
|
||||
"""
|
||||
keys: list[str] = []
|
||||
# Each branch carries whether it is already inside the node's own function,
|
||||
# so the first def is the node and any def below it is a helper.
|
||||
stack: list[tuple[ast.AST, bool]] = [(tree, False)]
|
||||
while stack:
|
||||
node, inside = stack.pop()
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
|
||||
if inside:
|
||||
continue
|
||||
inside = True
|
||||
if isinstance(node, ast.Yield) and isinstance(node.value, ast.Dict):
|
||||
keys += [
|
||||
key.value
|
||||
for key in node.value.keys
|
||||
if isinstance(key, ast.Constant) and isinstance(key.value, str)
|
||||
]
|
||||
elif isinstance(node, ast.Call) and _is_emit(node.func):
|
||||
keys += [kw.arg for kw in node.keywords if kw.arg]
|
||||
stack += [(child, inside) for child in ast.iter_child_nodes(node)]
|
||||
return keys
|
||||
|
||||
|
||||
def _is_emit(func: ast.expr) -> bool:
|
||||
"""``emit(...)`` or ``fluksio.emit(...)``, however it was imported."""
|
||||
if isinstance(func, ast.Name):
|
||||
return func.id == "emit"
|
||||
return isinstance(func, ast.Attribute) and func.attr == "emit"
|
||||
|
||||
|
||||
def _check(spec: NodeSpec, mode: str) -> dict[str, Any]:
|
||||
"""The node as stored, with the settings and wiring of one flow.
|
||||
|
||||
@@ -290,6 +290,13 @@ def _coerce(value: str, dtype: str) -> Any:
|
||||
return value.lower() in ("true", "1", "yes", "on")
|
||||
if dtype == "str":
|
||||
return value
|
||||
if dtype == "artifact" and (
|
||||
value.startswith("@run:") or value.startswith("sha256:")
|
||||
):
|
||||
# The engine turns these into the reference itself. Passing the whole
|
||||
# object as JSON still works, and is what a script that already has one
|
||||
# would do.
|
||||
return value
|
||||
return json.loads(value)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user