Skip to content

feat(studio): controller-fault detection, post-mortem probes, soak driver, GET_HEALTH decode — Studio v0.76 (fw #50) - #198

Open
mbreiser wants to merge 14 commits into
mainfrom
claude/mode3-wedge-soak
Open

feat(studio): controller-fault detection, post-mortem probes, soak driver, GET_HEALTH decode — Studio v0.76 (fw #50)#198
mbreiser wants to merge 14 commits into
mainfrom
claude/mode3-wedge-soak

Conversation

@mbreiser

Copy link
Copy Markdown
Contributor

Combines the Mode-3 controller-wedge bug (reiserlab/LED-Display_G6_Firmware_Arena#50) with the telemetry plan from #192. Not bench-tested — the whole chain needs a night on the CSHL 10-10 controller with the simulator. Design, findings, hypothesis ladder and the campaign spec: docs/development/mode3-wedge-soak-plan.md. Codex (gpt-6-astra) plan review reconciled in .codex-review/report-20260911-wedge-soak.md (local, gitignored) — its corrections are folded in (0x82 is a RAM lookup, so the issue's "SD tier" is a queueing artifact; late same-opcode replies defeat a consecutive counter; faulted runs must produce a durable artifact; hold the firmware frame-cache candidate until a baseline wedge reproduces).

What changes for every run (fail closed)

  • Closed loop: ≥3 failed frame commands in the last 10 → latched fault, apply OFF, the run aborts with outcome CONTROLLER_FAULT (never "aborted by user") and the log auto-commits. Before this a wedged 40-minute run completed and committed looking normal.
  • A timed-out protocol command (the 0x08 after the 0x70s) is labelled the same. summary.stopAcked records whether the best-effort STOP got through ("fail closed" = the host stopped; quiescence only if acked).
  • Runner sends (trialParams, STOP, AO/DO) now go through session.send, so they appear as arena_command rows. dt is measured on the monotonic clock.
  • After the run unwinds, js/studio-postmortem.js runs: quiet ≥1 s + rx flush → confirmation 0xC2 → ~60 s probe window (0xC2/0x33/0x72/0xCA/0x88/0xE3, 5 s timeouts, typed probe rows with decoded + raw hex) → halt (outside a soak).

Soak driver (advanced-only)

File ▾ → Soak (repeat protocol)… or ?soak=1: repeats the open protocol via the new Studio.runOnce() (N times / H hours / gap), refuses without a behavior_v2 ack or without bridge frames, pauses (not faults) when the simulator dies, halts on the first fault by default (evidence first), then SYSTEM_RESET → ArenaLink.reconnect() from granted ports (VID/PID, ambiguity refused) → MAC verified → post-reset probe → new iteration (≤3 resets). Soak runs never auto-commit. protocols/soak_mode3_closed_loop.yaml = 20 × 60 s Mode-3 trials alternating the 200-frame bar and the 20-frame grating. fictrac_sim.py gains --turn-sigma/--jump-every/--jump-deg.

Analyzer

scripts/wedge-scan.py (v1/v2/gz/.runlog.json, streaming) — over the course repo it found a sixth, earlier occurrence (rig2, 2026-07-11, third controller, identical signature) and reproduced the issue's clean-control numbers exactly. The five reported wire logs are not in the repo (aborted runs never auto-committed).

Firmware side

reiserlab/LED-Display_G6_Firmware_Arena#53: GET_HEALTH 0xCA (loop timing, SD read stats + error code, counters, reset cause, reset-surviving breadcrumb + slowest op) and scripts/soak_mode3.py (browser-free driver, same lifecycle/log format). Decoded here by encodeGetHealth/decodeHealth (55 B + 11 B tail) and Console → Debug ▾ → Controller health. Follow-ups filed: fw #51 (flushResponses short write), fw #52 (SD path), #197 here (self-healing runner), Linear LAB-212.

Tests

Full pixi run test green. New: fault latch (incl. interleaved + window aging), runner fault() mid-wait + timeout labelling, session facade + fault routing + sanitizer, link reconnect() (same port / re-enumerated / ambiguous / timeout) + write-stall deadline, wire GET_HEALTH + 0x72 vectors, adapter/run-log CONTROLLER_FAULT, postmortem lifecycle (transient / halt / dead / reset-continue / reset-failed / wrong MAC / operator abort / capability gate), URL soak flag, tests/test-wedge-scan.py (97 checks).

Bench gate (before merge)

  1. Course benches: git pull, restart pixi run bridge, hard-refresh; log-level mirror reads behavior_v2.
  2. Unplug USB mid-Mode-3 with the sim running → run ends DISCONNECTED (unchanged path); Node tests cover the timeout path.
  3. Start a soak (iterations=1 hours=0 gap=5 first=halt) → one clean iteration, soak rows in the bridge log, no auto-commit.
  4. Flash fw Experiment Designer: hover tooltips on filmstrip blocks #53, Console → Debug ▾ → Controller health reads sane values; manual Reset controller → next health read shows breadcrumb_valid + prev_slow_op.
  5. First soak night per docs/development/mode3-wedge-soak-plan.md § 6.

🤖 Generated with Claude Code

mbreiser and others added 8 commits September 11, 2026 22:50
…iver, GET_HEALTH decode — Studio v0.76 (fw #50)

A Mode-3 controller wedge (firmware #50) used to be invisible: the closed-loop
drain loop swallowed every 0x70 timeout and a dead-arena run "completed" and
auto-committed. Now:

- fictrac-bridge-client: rolling fault detector (>=3 failed applies in the
  last 10, not "3 consecutive" — a late same-opcode reply can satisfy the next
  request) → latched `fault` event, apply forced OFF.
- arena-runner: `fault(reason, detail)` wakes the trial wait and unwinds;
  `summary.fault` / `summary.faultDetail` / `summary.stopAcked`; a timed-out
  protocol command (the 0x08 after the 0x70s) is labelled the same.
- arena-session: bridge fault → runner.fault + `fault` event; runner sends go
  through session.send (a link facade) so trialParams/STOP land in the run
  log; `reconnect()` / `flushRx()`; `dt` on the monotonic clock; terminal
  summary + fault detail in the sanitized runner rows.
- arena-link: `reconnect()` from granted ports (navigator.serial.getPorts,
  VID/PID match, ambiguity refused, re-enumeration poll); the response deadline
  now covers a stalled write; `flushRx()`.
- run-log / adapter: outcome CONTROLLER_FAULT (auto-commits; ABORTED_BY_USER
  still doesn't); `fault` phase in the transcript.
- studio-postmortem (new, classic dual-export): quiet period + rx flush →
  confirmation 0xC2 → probe window (5 s timeouts, typed `probe` rows with
  decoded + raw hex) → halt | SYSTEM_RESET → reconnect → MAC verify →
  post-reset probe.
- wire: GET_HEALTH 0xCA encode/decode (55 B + optional 11 B slowest-op tail),
  GET_FRAME_POSITION 0x72, capability bit 7 `health`.
- Studio: fault banner; `runOnce()` (the no-dialog half of beginRun); soak
  driver (File ▾ → Soak…, `?soak=1`, advanced-only; refuses without a
  behavior_v2 ack or bridge frames; halt-first; never auto-commits);
  Debug ▾ → Controller health; URL-state `soak` flag; v0.76 footer.
- scripts/wedge-scan.py + tests/test-wedge-scan.py: per-run wedge/onset table
  over v1/v2/gz/.runlog.json (found a sixth, earlier occurrence: rig2
  2026-07-11).
- fictrac_sim.py: --turn-sigma / --jump-every / --jump-deg.
- protocols/soak_mode3_closed_loop.yaml; docs/development/mode3-wedge-soak-plan.md
  (findings, hypothesis ladder, campaign spec, bench recipe); release notes;
  CLAUDE.md rule.

Tests: full `pixi run test` green (new: fault latch, runner fault, session
facade + fault routing, link reconnect/write-deadline, wire GET_HEALTH vectors,
adapter/run-log outcome, postmortem lifecycle, URL soak flag, wedge-scan).
Not bench-tested. Codex (gpt-6-astra) plan review reconciled in
.codex-review/report-20260911-wedge-soak.md (gitignored).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…havior_v2; honest stop reason

Bench 2026-09-11 (10-10 controller, sim): the first smoke iteration ran clean
(2,001 × 0x70, RTT 2/6 ms) but (1) the protocol's last step disconnected the
fictrac plugin — the bridge IS the run's logger, so the run's terminal event
never reached the log and the soak loop then waited on frames from a
disconnected bridge; (2) a remembered behavior_v1 session setting overrode the
soak preflight's v2 negotiation at run start. Drop fictrac.disconnect from the
soak protocol, reconnect the bridge before the freshness check, force
behavior_v2 while a soak runs, and report the operator's stop reason instead
of "no-frames".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…roller (silent, 0x01 no ack); flash mishap note

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… an apply failure

Bench 2026-09-12 07:00 ET: a firmware built for a 4x10 arena rejected every
pattern on the 2x10 card, and the runner streamed 21k rejected 0x70s
("no pattern selected", status 1) for minutes without tripping the fault
detector — it only counted throws (timeouts/transport). The session's bridge
applyFrame now decodes the reply and throws on a non-ok status, so rejects feed
the ≥3-of-10 window like timeouts do (one #199 reject per trial start stays
well below the threshold).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…RMWARE_VERSION 0xCB) in every run log

No G6 controller build was identifiable until now: 0xC2 carries a format byte
that is always 1, 0xE3 is the panel image footer, and run_metadata.firmware
held "v1" (or null after a gesture-free reconnect). Firmware
feat/controller-health adds GET_FIRMWARE_VERSION 0xCB — git SHA, dirty flag,
branch, build date, arena rows×cols, compiled in by a PlatformIO pre-script.

- wire: encodeGetFirmwareVersion / decodeFirmwareVersion (46 B payload →
  {sha, dirty, debug, date, branch, arena, label}), golden tests.
- Studio: Studio.refreshFirmwareVersion() on the Connect button, the Console
  info command, and a state hook that covers every session.reconnect() (the
  post-mortem/soak paths, which used to leave firmware null); gated on the
  `health` capability bit because older firmware flashes CE 01 on the arena
  for an unknown opcode. Studio.firmware → run_metadata.firmware → run log.
- post-mortem probe set includes firmware_version (post-reset probe records
  which build wedged); wedge-scan.py gains an `fw` column.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ost side (fw feat/telemetry-ring)

The controller keeps CMD/FRAME/STATE records in reboot-surviving OCRAM
(proposal § 3.1); the host drains them with GET_TELEMETRY_BLOCK 0xA9 using an
ack cursor (lossless, framing A) and writes compact rows into the run log.

- js/arena-telemetry.js (new, classic dual-export): parseBlock (18 B header +
  records: CMD/FRAME/STATE/PAD), toRows → ["cc"|"cf"|"cs", rx, t_us, seq, …],
  createDrainer (ack cursor, seq gaps, controller drop counter, drainAll crash
  dump), createPoller (single-flight, gated). tests/test-arena-telemetry.js.
- wire: SET_TELEMETRY 0xA8 / GET_TELEMETRY_BLOCK 0xA9 encoders + tests.
- bridge 3.1: {type:'rows'} → each shape-checked array row written verbatim
  (write_rows); fictrac-bridge-client.logRows(); Python test.
- Studio: Studio.initTelemetry() on every link-up (health cap + 0xA8 ack),
  10 Hz drain while connected (pauses under the post-mortem), stream_schema
  line per log, minute stats, Studio.drainTelemetry() wired as the
  post-mortem's afterReconnect → the ring's last records after a reset.
- wedge-scan.py: ctl recs / sd max us / ctl states / ctl rejects columns and,
  with --verbose, the last 40 controller records before a wedge.
- docs: plan § 11 (T4 as built), release notes, CLAUDE.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…before apply (#199)

The bridge applies a new `frames` modulus one frame late, so the first index
after a trial whose pattern has a different frame count carried the previous
modulus → "index out of range" + an error glyph once per trial (bench
2026-09-11: 2 of 110,643). Now that controller rejects feed the fault window,
a protocol with short alternating trials could even trip a false
CONTROLLER_FAULT. Wrap locally (idx mod frames) before the consumer clamp; no
configured count → untouched. run_metadata gains `telemetry: ring-10hz|off`
so the instrumentation factor is visible in analysis.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
mbreiser and others added 4 commits September 12, 2026 12:04
…red ring drain, deferred fault commit, reader safety

Reconciles the gpt-6-astra status + diff reviews (.codex-review/report-20260912-status.md):

- runner: terminal event emitted after the best-effort STOP so the serialized
  summary carries stopAcked; Studio defers a CONTROLLER_FAULT run's export/commit
  until the post-mortem (probes + ring dump) has finished
- post-mortem: identity requires a decoded 0xC2 reply and the same MAC; "recovered"
  requires zero timeouts/errors and every required probe ok (SD-image probes are
  optional); capability-gated probes are skipped + recorded when caps are unknown;
  evidence first after reset (health → ring drain → probes), duplicate drain removed
- telemetry: FRAME sd_load_us u32, header flag bits 2/3, maxChunks 40 / maxBytes 178,
  ack only when the sink stored the rows (notStored), drainer.idle(); Studio gates
  SET_TELEMETRY on GET_FIRMWARE_VERSION flags bit 2 (never on `health`), on/off
  switch (Debug ▾), stop path, poller yields to the post-mortem; analog-in too
- readers: runlog-replay + dashboard analysis-core skip string-tagged rows
- bridge 3.1: per-tag minimum row lengths; logRows returns acceptance
- wedge-scan: fault-declared outcome, pre-boot ring tails, malformed rows counted
- soak: per-iteration applied_0x70 / duration / telemetry stats, `unexercised`
- docs: status doc §4b/§4c/§6, plan bench log, release notes, CLAUDE.md rules

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… capability

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…(re)connect and post-reboot, duplicates dropped, fresh ring resets the cursor

Codex firmware review: an old ack sent to a power-cycled controller would free
unseen records. The Studio now rearms the drainer on every link-up and before the
post-reboot crash-dump drain.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
mbreiser and others added 2 commits September 12, 2026 12:30
…ed items in #200 / fw #54

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…refused-row spin)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant