Warm a worker before the node's clock starts, and refuse to delete a live flow

A node's timeout now covers its body only: the pool loads the source into the
worker it picked, off the node's budget, so imports that outlast the timeout no
longer make a node impossible to run. Draft checks compile without caching, so
saving does not evict what a busy node is serving calls from. Requests carry an
id the worker echoes and the pool checks, a reply is encoded once, and the
remote-exception cache is bounded.

DELETE /flows/{name} answers 409 while the flow has a running or queued run,
which is what was letting run_node rows outlive their run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01StpRc2C6au1WJ1EUU7fsfu
This commit is contained in:
2026-08-23 17:11:55 +02:00
co-authored by Claude Opus 5
parent ce465e7b0e
commit 084194f77b
7 changed files with 224 additions and 62 deletions
+33 -11
View File
@@ -294,6 +294,13 @@ def _handle(request: dict[str, Any], cache: dict[tuple[str, str], Any]) -> Any:
source = request.get("source") or ""
digest = hashlib.md5(source.encode()).hexdigest()
if request["op"] == "compile" and not request.get("keep", True):
# A question about a draft, not a load: does this source compile? The
# answer is all the editor wants, and keeping it would throw away the
# published source this worker is still serving calls from.
load_function(flow, node, source)
return None
cached = cache.get((flow, node))
if cached is None or cached[0] != digest:
cache[(flow, node)] = (digest, load_function(flow, node, source))
@@ -305,15 +312,6 @@ def _handle(request: dict[str, Any], cache: dict[tuple[str, str], Any]) -> Any:
result = function(**(request.get("kwargs") or {}))
if inspect.isgenerator(result):
result = _drain(result)
try:
json.dumps(result)
except (TypeError, ValueError):
# The typed-message contract only carries JSON, and a node running out
# of process is where that stops being a formality.
raise ValueError(
f"returned {type(result).__name__}, which cannot be sent back as "
"JSON — return numbers, strings, booleans, lists or dicts."
) from None
return result
@@ -360,7 +358,10 @@ def main() -> None:
request = json.loads(line)
_CALL_ID = str(request.get("call_id") or "")
captured = _Capped()
response: dict[str, Any] = {"call_id": _CALL_ID}
# ``id`` back untouched: the engine matches it against what it sent, so
# a pipe that has slipped a call is caught rather than handing one node
# another node's answer.
response: dict[str, Any] = {"id": request.get("id"), "call_id": _CALL_ID}
try:
with (
contextlib.redirect_stdout(captured),
@@ -377,7 +378,28 @@ def main() -> None:
"traceback": _node_traceback(exc),
}
response["logs"] = captured.getvalue()
_RPC.write(json.dumps(response) + "\n")
try:
reply = json.dumps(response)
except (TypeError, ValueError, RecursionError):
# Encoding the reply is also the check that the node returned
# something the typed-message contract can carry — the result is
# the only part of this dict a node controls, so it is the only
# part that can fail. Doing it once is why there is no separate
# dumps of the result above.
kind = type(response.pop("result", None)).__name__
trouble = (
f"returned {kind}, which cannot be sent back as JSON — "
"return numbers, strings, booleans, lists or dicts."
)
response["ok"] = False
response["error"] = {
"type": "ValueError",
"message": trouble,
"short": f"ValueError: {trouble}",
"traceback": "",
}
reply = json.dumps(response)
_RPC.write(reply + "\n")
_RPC.flush()