On the Swarm Work screen, a fleet chip’s cost N froze at whatever it was
when the node list last changed. Driving the sensor feeding a swarm-enabled
LLM node’s costScoreSource changed the value on the server (confirmed via
GET /node/{id}) but the on-screen chip never moved. The accepting/not-accepting
indicator on the same chip was live, which made the bug easy to dismiss as
“just needs a recompose” — it wasn’t.
An earlier fix (16bbb35b2, “live Swarm Work screen state + claim cooldown”)
had already corrected the recomposition half:
SwarmWorkScreen used to derive its fleet/work/batch lists with
remember(swarm), where swarm is a Set<String> of node ids that only
emits on add/remove, so a meta-only advert refresh never rebuilt the list.
Switching to a per-node readNodeStateOrNull(id).collectAsState() fixed
that — but the chip still showed a stale number, because recomposing more
often doesn’t help if the value being recomposed was never updated.
ServerLLMProcessor.republishAdvert persists the freshly folded cost with
nodeManager.update(node.copy(meta = folded), propagate = false). The
propagate = false is deliberate — an advert refresh must not wake the
node’s downstream observers or spray SOURCE_TRIGGERED at peers, since the
swarm arbiter re-reads the claimant fresh over HTTP at claim time anyway.
The code’s own comment justified this with “the client UI rides the CRUD
stream” — but the client (EventClient) does not consume that stream at
all. It connects to /events (EventFlowContainer), not /sse (the raw
Node stream ServerNodeManager.update unconditionally emits to but which,
per that file’s own note, “has no client consumer”). propagate = false
also suppresses the only other signal a republishAdvert write could produce
(no STATE_CHANGE, since state stays NONE; no SNAPSHOT_UPDATE, since
republishAdvert never posts one). The net effect: a costScore change from
an advert refresh reached zero client-visible channels. The client’s cached
copy of the LLM node kept whatever costScore it last saw from an actual
inference result or manual edit, forever, regardless of how often the UI
recomposed.
ServerLLMProcessor.republishAdvert now explicitly announces the folded
cost on the one channel the client does listen to: it posts
EventFlowContainer.postEvent(Event(node.id, EventType.LLM,
AdvertCostUpdatedPayload(folded.costScore, folded.advertisedAt))) alongside
the existing propagate = false write. EventType.LLM was already defined
but unused on the client (“LLM nodes now surface results via
SNAPSHOT_UPDATE… no LLM-specific arm”) — the SDK’s own docs anticipated
exactly this extension shape, so no changes were needed in the SDK. The new
payload type (AdvertCostUpdatedPayload) lives in shared and registers
against the SDK’s EventPayload interface the same way NodeCreatedPayload
does.
EventClient’s previously-empty EventType.LLM arm now applies the payload:
nodeManager.updateMetaData(currentNode, meta.copy(costScore = ...,
advertisedAt = ...)) — the same targeted meta-merge pattern PIN_CHANGED
already uses for a single-field update that shouldn’t touch the rest of the
node.
/events-only architecture and nobody re-checked it
when propagate = false was introduced for the advert-refresh path.propagate (or similar) flag that suppresses “does this wake
observers” should not be the same flag that decides “does this reach
clients at all.” Those are two different audiences with two different
legitimate reasons to say no independently; conflating them means the only
way to silence one is to silence both, which is what happened here.meta that’s meant to be watched live
(as opposed to snapshot data), check which existing wire event actually
carries it end-to-end — don’t assume the general CRUD/update path implies
client delivery. krill-oss’s Event.kt already documents the intended
seam (EventType.LLM + a shared-registered payload) for exactly this
case; it was defined and never wired up.