Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save colonelpanic8/d6b476a30f164f42897d8c7240073782 to your computer and use it in GitHub Desktop.

Select an option

Save colonelpanic8/d6b476a30f164f42897d8c7240073782 to your computer and use it in GitHub Desktop.

glove80-rmk battery buginess discussion with Fable 5

Full transcript of the 2026-08-06 session, one bullet per conversational turn. Ran on jay-lenovo (keyboard attached there), Paseo-hosted.

Outcome summary

  • Root cause found and fixed: the right half’s battery gauge froze because the central’s split-lighting replication livelocked its outbound queue. Not a lighting-logic bug — a liveness bug in replication.
  • Four fixes on colonelpanic8/glove80-rmk master (41168e2..df9126f, plus Go60 repair 07edc50), flashed to both halves.
  • Charging colors reachable for the first time: ChargeState::Charging was provably unreachable; added a VBUS-derived proxy on both halves and boards.
  • Design doc committed: docs/replicated-half-state-sync.md in glove80-rmk — plan to upstream a generic split_sync primitive to RMK.
  • Handoff branch: DELETE-ME/stage2-fable-handoff on colonelpanic8/rmk (tip e1198eb5) carrying the remaining work plan + context docs.
  • Outer glove80-config origin/master 63640b6 pins glove80-rmk 8861bd0.

Turn 1 — Ivan

Get on the latest. Something seems quite buggy with battery display, particular on the non central/slave side. Somehow the right half is now in a permanent state of displaying its battery status, in other cases it seems to never display its battery state.

I wonder if it could have to do with the intersection of layer specific lighting and conditional lighting layer logic, and also lighting being globally disabled/enabled.

Probably worth trying to disambiguate by like cartesian producting that whole thing and seeing what does remain broken and what doesnt by test deploying things.

Also could have to do with applying configuration at runtime, vs applied immediately with a flash.

Some thoughts:

I wonder if adding an api to glove80-control to be able to query exactly what each side is ACTUALLY displaying with lighting at the moment, and maybe even a “virtual press” api to be able to test all of this programatically could be a good idea (although i wonder about virtual press drifting from actual presses is a concern there).

Also, I’d like to be able to visualize battery charging vs discharging state with colors or something. that might actually help us debug as well.

Turn 2 — Fable

(Work: repinned ~dependencies/glove80-rmk~ to origin/master ~a87cd08~, rebuilt, flashed both halves, re-applied runtime config, read the lighting sources.)

Done so far: you’re on latest — firmware reports glove80-rmk v0.1.0 (a87cd08e) / RMK rmk-v0.8.2-1004, right half connected. Three Rain effect params had drifted; re-applied, just diff clean.

Diagnosis so far — the intersection suspicion is right, but narrower: the right half renders battery gauges from a replicated copy of the central’s lighting context (layer bits, battery, powered). The gauge rules require “layer 2 active”, and wake_layers=[2] is what lights an unplugged right half at all. So a peripheral whose replicated context freezes with layer 2 active shows the gauge permanently; frozen the other way, Magic never lights it — one wedge mechanism, both symptoms. Ruled out already: the output_mode clobber in apply_replica, and runtime wake-layer divergence (wake layers are compiled into both halves).

On the ideas:

  • Query API: building it. No vendor passthrough exists in Rynk, so adding two proper commands — GetLightingFrame (per-half readback of the last presented LED frame) and GetLightingReplicaStatus (peripheral’s applied revision + replicated context vs the central’s live context).
  • Virtual press: deferred, and mostly unnecessary — the drift concern is valid, and SetDefaultLayer 2 already activates layer 2 programmatically.
  • Charging colors: already in your config — the Magic gauge colors charging blue (#0040a0) vs green/amber discharge. Likely a casualty of this bug.

Turn 3 — Ivan

huh i dont think ive ever seen blue tbh. oops did you not use paseo agents? make sure to use paseo agents becuase if you dont me sending you a message seems to kill them all

isn’t there guidance in your context to do this?

i guess in this case we should just restart them to avoid a context disaster

Turn 4 — Fable

Correct — my miss. PASEO_AGENT_ID is set, and AGENTS.md says to read PASEO.md and prefer Paseo-managed subagents. I never checked the env var. Saved a feedback memory so future sessions check before delegating.

Restarted both as Paseo agents (Opus 5, high thinking): the wedge diagnosis (which also picked up the “never seen blue” datapoint — tracing whether ChargeState::Charging is ever actually produced), and the rmk observability protocol. One correction found while restarting: the outer AGENTS.md’s topic list is stale — fold/lighting-rynk was deleted; the lighting stack is carried as PR 1031 (glove80-rmk/lighting-v2).

Salvaged from the wreck: one child of the killed agent completed an excellent audit of the split-app transport before the interrupt. Saved to .claude-session/split-app-transport-report.md. Headlines: the peripheral’s inbox is only 8 messages deep and drops silently while a 70-packet snapshot burst arrives during ~ApplyReplica; the central’s outbound queue is never flushed across link drops; the BLE notification queue evicts oldest with zero logging on the peripheral→central path.

Turn 5 — Fable (diagnosis agent reported)

The bug is diagnosed — confirmed in code. Saved to .claude-session/wedge-diagnosis.md.

W1 — the permanent wedge (explains both symptoms). Replicating lighting takes a ~64-packet atomic burst over a 112-slot queue. The enqueue isn’t atomic: when space runs short it fills the queue with a partial snapshot and aborts, leaving the queue exactly full. The central’s retry path then sleeps 50 ms and continue s — skipping its own select loop forever, so it never sees acks, link changes, or anything else. The right half receives endless snapshot prefixes but never a Commit, so its replicated context freezes: frozen while Magic was active → gauge permanently on; frozen otherwise → gauge never appears. Only rebooting the left half clears it. Two confirmed entry paths: typing ~112 left-half keys while the right half is disconnected (effect-hit mirroring fills the never-drained queue), and the sleep-mode BLE regime where each 500 ms timeout stacks another snapshot.

Also found: W3 the ack timeout is restarted by any event (staleness amplifier), W4 the peripheral flushes its inbox on the link up edge, discarding each reconnect’s first snapshot, W5 transient inbox drops.

Your instinct about the layer/conditional/global-lighting intersection was half right — those gates determine which polarity the freeze shows, but the freeze is in replication queueing. Runtime-vs-flash was not a factor.

“Never seen blue” — fully explained. ChargeState::Charging is unreachable, twice over: no charger-detect pin is configured, and rmk’s ChargingStateReader is never instantiated anywhere. The 10 charging rules are dead code; the plain gauge only works because level-only rules match Unknown.

Turn 6 — Ivan

okay questions:

Is the right side in a slave configuration where it is told like light exactly these keys by the left side? or does it have its own view of “this is what the lighting is for each layer” and then its told when certain layers are active?

I think we should definitely be using the second model. The issue that you end up having to solve there is making sure the two halfs actually agree about “what is the lighting configuration” Here’s something I’d like your thoughts on:

  • I think part of why this is hard is because its hard to synchronize the entire lighting state so we kind of try to do it piecemeal.
  • you said something about “commit messages” not existing which made me think this, and it makes perfect sense that things could get messed up if this is how were doing things.
  • I think one idea that could be interesting to try to prevent this is like doing almost a merkle tree sort of thing for lighting where maybe we hierarchically hash different parts of the layer lighting tree. This will let us detect when were out of sync and also sort of figure out what needs to be synced somewhat efficiently.

very open to push back, and also would like you to clarify my understanding

Turn 7 — Fable

It already is the second model. The peripheral runs the same full lighting engine: its own copy of scene tables, conditional rules, overlay, effect state, rendering every frame locally from its own clock. What crosses the link is declarative: (1) table replication as an atomic staged transaction (Begin → Context → cells… → Commit with generation/revision and an Ack), and (2) tiny context deltas (“layers 0 and 2 active, batteries X/Y”) that the peripheral re-evaluates its own rules against.

Correcting one understanding: consistency held; /liveness/ failed. I didn’t mean commits don’t exist — the protocol is properly transactional and a partial snapshot cannot be applied. Your “piecemeal sync → mixed state” model is the failure this protocol successfully prevents. What broke is dumber: the sender’s queue livelocked, the peripheral correctly refused prefixes, and just never got new state. Consistency perfect, availability zero — rendering a coherent snapshot of the world as it was an hour ago.

On merkle: the instinct — content-based divergence detection — is exactly right and is the real missing piece (today the central infers peripheral state from acks). But the whole replicated state is ~1.6 KB. At that size, “detect mismatch → resend everything” costs half a second; a tree buys savings on repair traffic that’s already negligible while adding multi-round protocol over a lossy 26-byte channel. Ladder: (1) flat self-attesting heartbeat — do this; (2) per-domain digests (5 hashes in one packet) if tables grow; (3) full recursive merkle at 50–100× the state. Also: revisions assert “same version” but a bug can produce same-revision-different-content; content hashes catch the class, not just the instance.

On when to check: no new commit notion needed — enrich the existing Ack into an attestation, add an idle heartbeat (~10 s), and attest on link-up.

Turn 8 — Ivan

okay i guess thats fair pushback on merklization. It does mean we have limited capacity at some point, and like what if the person has like 20 layers of custom lighting. Are we really not going to benefit there?

right thats why i like my content hashes idea (or did you have it first) in either case i had it independently because your output was really long lol

Turn 9 — Fable

Convergent evolution — content-hash attestation is the textbook anti-entropy move (Dynamo/Cassandra do exactly this). Yours arrived independently and framed the right layer: hash the state, don’t trust version counters.

On 20 layers: right, and it’s a scaling law — hash-tree depth should track log(state size). Today (~1.6 KB): flat digest. At 20 dense custom layers (~30–40 KB): domain hashes plus per-layer hashes under the scene domain — your merkle idea with recursion bottoming out early (a layer’s cells are ~2 KB, cheap to resend whole). So the honest version of the pushback is “the tree earns one level of depth per ~20× of state growth, and we’re at depth zero-point-five.”

Caveat: before 20 layers hits sync, it hits capacitySCENE_CAPACITY = 100 cells total across all layers (you’re at 95), and the config records that widening conditional cells at capacity 160 blew the central’s stack.

Turn 10 — Ivan

“Hash the canonical wire encoding, never the in-RAM structs” — huh okay yeah this is a good point, but does that mean recalculating it every time? or i guess we can sort of wrap everything so that we cache the computed hash?

but we have to be careful about invalidation then

Turn 11 — Fable

Mostly don’t cache — and where we do, don’t invent a new invalidation scheme.

Cost check: durable state ≈ 4 KB wire-encoded; FNV-1a over that on a 64 MHz M4 is well under a millisecond, and the fast-moving state is excluded from the hash by design. Durable state mutates only on host config ops and TTL expiry — human timescale. So full recompute at every mutation and attestation is viable: no cached state, no invalidation problem at all.

If we cache anyway: the rail exists — advance_revision(). Every durable mutation already funnels through it, and that invariant is already load-bearing (the existing ack protocol is only correct if “revision unchanged ⇒ content unchanged”). A missed invalidation becomes a revision bug, which existing tests already police.

Third option: XOR-fold of per-cell hashes — O(1) updates and order-independent (dissolving the canonical-ordering requirement). Duplicate cancellation can’t bite because the tables enforce uniqueness. But incrementally-maintained digests can silently drift and then the attestation lies. So: peripheral always full-recomputes what it applied; central full-recomputes on revision bump; the XOR trick stays in the drawer, and if ever enabled the heartbeat recomputes from scratch and asserts against the maintained value — a self-checking cache.

The meta-answer: be careful about invalidation by arranging for nothing to invalidate.

Turn 12 — Ivan

Okay I think maybe you should do the rest of the implementation. I think your context will be valuable. Can we start on that? I guess ideally we wouldn’t lose their work, but I’m okay with serializing it and having you take care of most of it.

Turn 13 — Fable

(Took over from the paused agents; surveyed and salvaged their partial work.)

Implemented, committed, pushed, and flashed. Four commits:

  1. fix(lighting): reserve the whole snapshot before queueing any of it — kills the W1 livelock (counting and sending share one walk; nothing enqueues unless the whole transaction fits) plus an effect-hit link gate.
  2. fix(lighting): replicate on absolute deadlines, not inline sleeps — the retry no longer bypasses the select, and the ack timeout is a real deadline (W3).
  3. fix(lighting): stop discarding the reconnect snapshot on the up edge (W4).
  4. feat(lighting): derive the charge state from VBUS on both halves — blue charging bands reachable at last; plugged = charging, no full-detection.

Along the way: another of your sessions pushed Go60 trackpad work to master mid-flight, so I rebased onto it, rebuilt against the newer rmk, pushed df9126ff, repinned, and reflashed both halves. Config re-applied clean.

Noticed: Rain effect params revert on every flash while Crosshair’s survive — looks like only the active effect’s params persist. Cosmetic.

Turn 14 — Ivan

huh did we make sure to use fork assembler? where did all you fixes land (are they upstream in rmk or are they in glove80-rmk?)

Turn 15 — Fable

No fork-fold needed, by design. All four commits are in glove80-rmk (central_lighting.rs, split_lighting.rs, lighting.rs + binaries), pushed to master. That’s where the bugs lived: the replication protocol is glove80-rmk’s own code; upstream rmk only provides the raw split-app channel. Even the charging fix stayed board-side — rmk already had ChargingStateEvent and all the plumbing; nothing ever published it. dependencies/rmk was never modified, only repinned when rebasing onto your Go60 master.

Two footnotes: (1) one rmk-side wart was worked around, not fixed — SPLIT_APP_TX is never drained while the link is down; upstreamable via fold/split-reliability if we want it. (2) The observability work does go through fork-fold: it sits on a branch off glove80-rmk/lighting-v2 (the PR-1031 manifest entry), so landing it means the full documented cycle.

Turn 16 — Ivan

hmmm, i do wonder if some of this work is generalizable to other split hardware that wants to have lighting. are the fixes also applying to go60?

Turn 17 — Fable

Yes — automatically, and the question caught a real break. Go60 doesn’t copy the code; its binaries #[path]-include the same source files with different board constants (30 LEDs/half, 40% ceiling). All four fixes compile straight in.

But include-by-path has no compile-time contract, and checking exposed that my rename peripheral_power_monitor()power_monitor() had silently broken the Go60 build — the glove80 dist recipe doesn’t compile Go60, and CI (which attaches Go60 bundles to master releases) would have failed. Fixed and pushed; also registered the monitor on Go60’s central, so Go60 gets VBUS charge state on both halves too. Outer repo repinned 3aaf4f6; no reflash needed.

Three tiers of generalizability: (1) already generalized crudely via board constants, but path-includes are the fragile form; (2) lift into a real in-workspace crate, const-generic, so the compiler checks consumers; (3) upstream into rmk — any RMK split board with per-key lighting has this problem.

Turn 18 — Ivan

hmmm another detail here is don’t lighting overrides in layers get represented as like some form of list? i guess order is important here in some sense but thats okay?

yeah i feel like the thing to upstream to rmk is like arbitrary half state sync?

Turn 19 — Fable

On ordered lists: right, and it splits by table. The scene table is semantically a map keyed (layer, slot) — duplicates rejected, later writes replace. Order-free, so the XOR-fold is safe. Composition order comes from the live layer stack, not table order.

But the conditional table is genuinely an ordered list with later-wins semantics — your own config says so: “Order matters: later rules win the LEDs they share — level bands first, low-battery overrides next, charging last.” Two tables with the same cells in different order are different lighting programs, so the digest must be order-sensitive there. Set-hash for scenes, sequence-hash for conditionals. That distinction would have been a subtle false-agreement bug.

On “arbitrary half state sync”: yes — that’s the right altitude. rmk’s split layer already contains SplitMessage::Layer, ::BatteryStatus, ::ConnectionStatus, ::Pointing — four hand-rolled one-off syncs — plus the raw split_app bytes over which we hand-rolled a fifth. One generic primitive: boards register named state cells (type + apply hook); rmk owns chunked atomic snapshot transfer, generation/revision, staging, deadline ack/retry, link-edge resync, and digest attestation with map-vs-sequence declared at registration. Every failure we found is a bug class each hand-rolled sync can independently rediscover. Routing: fold/split-reliability, not the lighting PR.

Turn 20 — Ivan

okay are we almost done here? maybe we commit we have, and then you take a swing (separately) at that plan of upstreaming replicated state sync to rmk?

Turn 21 — Fable

Wrapped up.

Committed and pushed (glove80-rmk master 8861bd0): the four fixes + Go60 repair, and docs/replicated-half-state-sync.md — the upstreaming plan as a durable design doc. It records the four bug classes as motivation; the split_sync primitive (durable cells = staged atomic snapshots + digests; ephemeral cells = seq-numbered last-value-wins deltas); a reliability-core table where every element maps to a specific found defect; the digest rules including your order-sensitivity catch; multi-peripheral addressing from day one; and the phased path (prove here → extract crate → RFC → migrate rmk’s own syncs).

Checkpointed (local, deliberately unpushed): the observability work — 3 commits, verified rmk-types 95/95 and 5/5 integration tests including the new observability loopback. Pushing it is step one of the fork-fold cycle.

Turn 22 — Ivan

okay what about Doing the rest of the work? I actually think were really low on anthropic usage, so what I’d like you to do is use paseo subagents to use sol agents from the ben account for open ai. are you able to see that you can use agents of that type? Maybe make a formal implementation plan and then hand it off. quickly verify you can hand off to ben sol agents

Turn 23 — Fable

Verified Sol works via Paseo (smoke test returned SOL_READY) — but on your OpenAI account: ~~/.codex/auth.json~ is a ChatGPT login for ivanmalison@gmail.com, and no “ben” credentials exist on this machine. Wrote the formal plan to .claude-session/stage2-handoff-plan.md (phases A–D), then asked how to reach ben’s account.

Ivan: “paseo should expose it. it has multiple sets of codex agents”

Turn 24 — Ivan

can you not just list like all the provider options you see right now

through the mcp tools?

Turn 25 — Fable

Complete picture from this session:

ProviderStateAccount
Claudeavailableyour Anthropic plan (fable-5, opus-5 working)
Codexavailableivanmalison@gmail.com (gpt-5.6-sol tested)
OpenCode“available”auth store empty — would fail/prompt
copilotnot configured
pinot configured

No Paseo MCP tools exist in this session (only computer-use-linux, Gmail/ Calendar/Drive, nixos); a tool search confirmed it. I probed the daemon HTTP API directly (/api/providers, /api/models, /api/accounts) — all exist but return Unauthorized (they want the CLI’s keypair auth), and CLI 0.2.5 has no models~/~providers subcommand. Crucially, nothing in the daemon’s schema carries an account dimension — agent records store only provider: codex + a model string. So multi-account Codex, if it exists, lives in the Paseo desktop/ web UI or another daemon/host, not in anything this CLI can reach.

Turn 26 — Ivan

okay can we actually just make a document for fable on ryzen-shine to finish this work? start by making a throwaway branch for where it should be done on rmk (maybe just off of main, or maybe off of our branch that introduces the data exchange), and make sure to include DELETE-ME in the name but commit the plan and give me the branch name. Give as much useful context as possible

Turn 27 — Fable

Branch: ~DELETE-ME/stage2-fable-handoff~ on colonelpanic8/rmk, tip e1198eb5.

Built off the data-exchange work so the receiving session starts on top of the code it must land:

e1198eb5  DELETE-ME: stage-2 handoff plan and context     <- throwaway
f3de72e3  feat(rynk): serve the frame and replica-status endpoints
ace9f676  feat(rynk): add lighting frame and replica-status readback
0ce05f06  feat(lighting): read back the frame the output presented
dfdafb17  <- glove80-rmk/lighting-v2 tip (PR 1031)

Plan at docs/DELETE-ME-handoff/README.md. Important catch: the directory also carries the wedge diagnosis and the split-app transport audit, which existed only in this machine’s gitignored .claude-session/ and would have been invisible to ryzen-shine. The README covers the three-repo layout with the stale-AGENTS.md correction, what’s already fixed (don’t redo), the three commits’ verification state, four phases (with “decide phase C’s rmk payload needs before running fork-fold so it runs once”), the binding digest rules, the Go60 shared-modules trap, toolchain rules, and that the keyboard is on jay-lenovo so phase D stops at build-verified elsewhere.

Also synced: another session had bumped the outer repo’s submodules to an older pin, so I rebuilt the repins cleanly — glove80-config origin/master 63640b6 pins glove80-rmk 8861bd0, matching what’s flashed.

Turn 28 — Ivan

can we write out this entire conversation, but somewhat formatted in org mode style where each turn is an org mode bullet to: [this file]

Turn 29 — Fable

This document. Rewrote the partial UI-paste that was here, preserving the org-roam ID and title (and dropping a stray X that preceded the properties drawer).

Key artifacts and locations

  • Fixes: colonelpanic8/glove80-rmk master 8861bd0 (fixes 41168e2..df9126f, Go60 07edc50, design doc commit).
  • Design doc: docs/replicated-half-state-sync.md (glove80-rmk).
  • Handoff: DELETE-ME/stage2-fable-handoff on colonelpanic8/rmk (e1198eb5), with docs/DELETE-ME-handoff/ = plan + wedge diagnosis + transport report.
  • Outer config: colonelpanic8/glove80-config master 63640b6.
  • Flashed build: df9126ff on both halves; runtime config verified clean.

Open threads

  • Land observability through fork-fold (PR-1031 entry / glove80-rmk/lighting-v2).
  • Board-side frame/status transport + lighting frame / lighting replica-status CLI.
  • Digest attestation (heartbeat, attestation-first reconnect).
  • Cartesian verification matrix on hardware.
  • Longer term: extract the replication crate, then upstream split_sync to RMK.
  • Minor: Rain effect params revert on flash (only the active effect’s params persist).
  • Unresolved: how to route Paseo Codex/Sol agents to the “ben” OpenAI account.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment