pi-krill, Cron→Calc→Counter. The Counter (DataPoint) showed the correct
new value during the activity pulse, then reverted to its app-launch
value the moment the pulse ended. The value was received and briefly
displayed, so it reached ClientNodeManager, but did not stick.
UniversalAppNodeProcessor.showActivity captures the node at pulse time,
calls startPairing(node), waits 1500 ms, then reset(node) — using the
stale captured node. ClientNodeManager.reset/startPairing did
update(node.copy(state = …)), writing that stale node’s old meta back
with only state changed. The updateSnapshot ingested during the 1500 ms
window was clobbered when reset fired at the end. showActivity even
read the current state to decide whether to reset, but then reset with
the stale node.
reset/startPairing now resolve the node from the store
(readNodeStateOrNull(node.id).value ?: node) and transition state on
that, preserving the latest meta/snapshot. A pulse/lifecycle state
transition is not a data revert. Regression:
ClientNodeManagerPulseSnapshotTest (red→green) — a snapshot ingested
between capture and reset must survive.
Any “set this node’s state” helper that takes a Node and is invoked
after a delay (or from a captured closure) must re-resolve the node from
the store, never trust the captured copy’s meta. Captured-node-plus-copy
across an await is a clobber waiting to happen whenever another path can
mutate the same node concurrently. Rule: state-only mutators read current,
copy state, write back — they never carry stale meta forward.