What happened

In the “Local LLM Nodes” demo, an LLM node (Sentry) watching a camera frame produced the right answer and then overwrote it with the wrong one:

1
2
3
23:07:13 Verdict  FLAG: A large unfamiliar dog standing at the run fence
23:07:13 Verdict  NORMAL          <-- stale inference lands second and wins
23:07:15 Gate     0.0             <-- downstream reads NORMAL; nothing wakes

Two inferences were in flight for the same node. One had resolved its inputs before the new camera frame propagated and returned NORMAL; the other saw the dog and returned FLAG. The snapshot write was last-write-wins, so whichever HTTP call finished last won — and on this run that was the one holding the stale input. The Lambda gate, the threshold, and the 32B model it guards all read NORMAL, and the alert never fired.

On other runs the ordering came out the other way and the chain worked. That is the part that matters: it was not broken, it was nondeterministic. The demo shipped with a workaround — the camera re-publishes the same frame a few seconds later so a fresh inference wins — which hid the defect rather than fixing it.

Root cause

ServerLLMProcessor.process() did scope.launchProcessing { runInference(node) } with no per-node coordination at all, so N invocations meant N concurrent inferences. Each one:

  1. resolved its inputs (resolveInputNodes), then
  2. spent up to five minutes inside callBackend, then
  3. wrote its result with nodeManager.update(successNode).

Nothing between steps 1 and 3 checked whether the inputs it had resolved were still current, and nothing stopped a second invocation from starting while the first was parked in step 2. Worse, runInference wrote back a node.copy(...) derived from the Node captured at invocation time, so even the metadata it persisted could be stale.

The window is enormous — an inference is a multi-second-to-multi-minute HTTP call against a local model — so any node whose source updates faster than the model answers will hit this. The camera in the demo did exactly that.

Fix

Together those give the invariant that actually matters: inputs are resolved inside the lock, so a waiting inference always observes what the previous one wrote, and the last write is therefore always the one derived from the freshest inputs.

Conflation (rather than queueing every invocation) keeps a burst of source updates from building a backlog of inferences that are obsolete before they start. Dropping a redundant invocation is safe precisely because the run already waiting has not resolved anything yet — when it runs it reads exactly what its duplicates would have. It must not replace the waiting run, though: that would let a steady stream of updates starve the node forever. There is a test for that (a run that arrives after the queue drains is not dropped).

Prevention