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 cea0b26ed9
commit 715e9a9e31
7 changed files with 224 additions and 62 deletions
+11 -8
View File
@@ -178,12 +178,15 @@ Deferring because out of scope is fine, but don't mention deferring than.
insurance for a future widget, but nothing would notice if they regressed.
- CHORE/API: artifact blobs are content-addressed and have no GC, so deleting a flow drops
its `run_artifact` rows and leaves the bytes on the data volume.
- CHORE/API: `DELETE /flows/{name}` does not refuse while the flow has a `running` or
`queued` Run. `RunService._record_node` can then insert `run_node` rows for a run that no
longer exists; `_finish` is an UPDATE, so it degrades to a harmless 0-row no-op.
- CHORE/API: no run table carries a foreign key to `run.id` — the baseline migration declares
none for `run_node`, `run_metric` or `run_artifact` — so nothing at the database level stops
a late write from orphaning rows. `DELETE /flows/{name}` now refuses while a run is
`running` or `queued`, which closes the path that was known; a constraint would need a
migration and would turn `_record_node`'s late insert into a hard error rather than the
no-op it is today.
- CHORE/INFRA: `backend/htmlcov` can be left root-owned by a container run, and
`make test-backend` then fails at the coverage HTML step *after* every test has passed —
which reads like a test failure and is not one.
(tests/flows.spec.ts, tests/admin.spec.ts)". There are nine.
which reads like a test failure and is not one. `make clean` is the fix.
- CHORE/UI: the edge popover shows the same value twice — `MessageSparkline` falls through to a collapsed `ValuePreview` for a non-numeric value, and `EdgeInspector` then renders its own `ValuePreview defaultOpen` below it. Cosmetic; one of the two is redundant.
- FEAT/UI: a settings-and-inputs overview page, so what every node of an installation is configured with can be read and searched in one place rather than one panel at a time.
- FEAT/UI: an input endpoint opens the flow panel, which is right for editing but not for reading one value. A panel of its own — the declaration, the current value, its history — is what clicking a label wants to give.
@@ -279,11 +282,11 @@ does not settle.
### Out-of-process nodes and modules
- CHORE/FLOW: `compile_check` sends the *draft* source under the running node's cache key, so the worker recompiles the published source on its next call. Correct, but one wasted compile per save on a busy node.
- FEAT/API: `POST /modules/apply` rebuilds the whole pipeline so a node that could not import its package stops being red. That resubscribes every MQTT node in the deployment; a targeted rebuild of the flows that actually failed to load would be gentler.
- CHORE/FLOW: a node's return value now round-trips through JSON, so tuples arrive downstream as lists and anything non-JSON is an explicit error. That is the message contract, but flows written before this may notice.
- BUG/FLOW: a node whose cold-start imports plus body exceed its timeout can never succeed. The timeout covers the first call's imports, a timeout kills the worker so the next attempt is cold again, and `compile()` only ever warms one of the N workers. Broadcasting `compile` to every worker is the candidate fix, at the cost of N module executions per reload.
- CHORE/FLOW: worker protocol loose ends — the request `id` is echoed but never checked, `json.dumps` runs twice per result (once to prove it is JSON, once to send it), `_remote_types` is an unbounded cache keyed on class names that user code chooses, and `PythonWorkerPool._lock` guards less than its name suggests.
- CHORE/FLOW: a remote worker's agent spawns a fresh subprocess per call, so a node on a device pays for its imports on *every* call and pays them out of the node's own timeout — the cold-start problem local workers no longer have. Masked above ten seconds, because the agent's heartbeat resets the hub's silence deadline; a node with a shorter timeout and slow imports cannot run there at all. The fix is a persistent process on the agent, which is where its module cache would live.
- CHORE/FLOW: `worker_main.load_function` keeps every source it ever compiled in `sys.modules`, keyed by digest, and never drops one. A worker that has served many edits of one node holds all of them. Bounded in practice by `respawn_all` on a module change.
- CHORE/FLOW: `_remote_class` caps how many exception classes it caches but not how long a name may be, and for a remote worker that name comes off the wire. Local workers can only send a name their own code defined.
### Engine history