What happened

In the “Local LLM Nodes” demo, the gate Lambda had to sit on the same server as the DataPoint it read, and that DataPoint had to exist purely to launder an upstream LLM node’s output into a shape the Lambda would accept. Both constraints looked like design limits. Neither was — they were two bugs in one seven-line function.

Root cause

LambdaPythonExecutor.getScriptInput():

1
2
3
val inputNode = nodeManager.readNodeStateOrNull(meta.inputs.first().nodeId).value
val inputMeta = inputNode.meta as DataPointMetaData
val snapshot = dataProcessor.last(inputNode) ?: Snapshot()
  1. Local-only resolution. The input is a NodeIdentity — a (nodeId, hostId) pair — but this passed only .nodeId to readNodeStateOrNull, which reads the local store. Every other consumer (LLM inputs, Calculation formulas, trigger samples) goes through nodeManager.findNode(identity), which is hostId-aware and fetches a peer’s node over HTTP. So a Lambda could not read an input on another server — even though the wiring is allowed and the arcs draw in the editor.

  2. Blind cast. inputNode.meta as DataPointMetaData threw ClassCastException for any other input type — an LLM node, a webhook, another Lambda.

  3. The catch hid both. catch (_: Exception) { nodeManager.failed(node, "Failed to retrieve input node for lambda input") } produced the same message whether the node was missing, unreachable, or simply the wrong type. Two distinct architectural limitations presented as one vague string, which is why they read as intentional.

A third defect fell out of fixing (1): dataProcessor.last() reads the time-series off local disk (/srv/krill/data/<nodeId>). A node that lives on a peer has no local directory, so last() returns null and the original ?: Snapshot() would have flattened a successfully-fetched remote DataPoint to an empty value. Resolving remotely without fixing that would have traded “no value” for “wrong value” — strictly worse.

Fix

Note DataProcessor.last() performs the same node.meta as DataPointMetaData cast internally, so it must only ever be called for a DataPoint — the when branch is load-bearing, not stylistic.

Prevention