Symptom

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.

Root cause

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.

Fix

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.

Prevention