a73x

7c2b4725

docs: M17 close-out record; a recovered tile clears its stuck badge

a73x   2026-08-13 13:02

Commit message
docs: M17 close-out record; a recovered tile clears its stuck badge

The final re-review's one Minor, taken as the lead's chore: a frame
that applies cleanly is the recovery the terminal replay state was
waiting for, so replaySucceeded clears replayDead and the stuck badge
— previously the badge lied about a working tile and sendAttach stayed
gated until the socket happened to reopen. decisions.md M17 carries
the passivity contract, the amended proxy thesis, the std WebSocket
hazards, the headFrame overflow lesson (pin wire-length bounds at the
type boundary), the page-never-ran record, the deliberate v1 stances,
and the banked follow-ups; roadmap marks M17 code-complete with the
kill criterion explicitly owed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

docs/decisions.md
Old New
@@ -2856,3 +2856,96 @@ together; the trigger remains M9's banked input-ack item.
2856 2856
2857 Process note: both issues closed through `git collab` with sha-naming 2857 Process note: both issues closed through `git collab` with sha-naming
2858 comments — the tracker's first full lifecycle. 2858 comments — the tracker's first full lifecycle.
2859
2860 ## M17 — web client (browser wall of devices)
2861
2862 Shipped 2026-08-13 as 04861b7..002d1f7 plus this close-out: muxweb (the
2863 hub), replica.zig (the replay core, extracted and finally unit-tested),
2864 keymap.zig, the wasm core (ghostty-vt on wasm32-freestanding, 345KB),
2865 the web shell, wsclient (the scripted browser stand-in), and three e2e
2866 scenarios (suite now 23 scenarios / 35 convergence points). Kill
2867 criterion (the live wall of three devices) still owed — the milestone
2868 is code-complete, not field-complete, until it runs.
2869
2870 **The passivity contract: wall tiles attach at 1×1.** The spec's
2871 original "browser attaches at the tile's cols×rows" would have failed
2872 its own kill criterion — any attach at a differing size moves the
2873 shared grid and repaints every client. The daemon already contained the
2874 mechanism: applySize refuses cols<2/rows<2, the refused attacher gets a
2875 unicast snapshot carrying the true grid, its slot stays 0×0, and
2876 claimGrid refuses it on every keystroke thereafter. Permanent
2877 passivity, zero daemon changes, pinned by a hub-free e2e scenario so
2878 the contract outlives the hub.
2879
2880 **The proxy thesis, amended for message-delimited transports.** The
2881 hub parses the 5-byte frame header in both directions — WebSocket is
2882 message-delimited, the daemon socket is a byte stream, and re-framing
2883 requires knowing where frames end. It still never parses payloads.
2884 "Frame-agnostic" was a property of byte-pipe transports, not of pumps
2885 in general.
2886
2887 **std.http's WebSocket server, and the two hazards it hands you.**
2888 respondWebSocket does the RFC 6455 accept-key work, but (1) it does
2889 NOT check Origin — that gate is ours, before upgrade, exact origins
2890 only — and (2) readSmallMessage BLOCKS mid-message and silently
2891 swallows pongs and loops (std Server.zig: `.pong => continue`), so a
2892 pump that polls must gate every read on a complete-frame check over
2893 the reader's buffered bytes (headFrame), and must consume pongs
2894 itself: handing a buffered pong to readSmallMessage re-enters the
2895 blocking hazard by construction. One buffer bounds both max HTTP
2896 header and max inbound message (64 KiB; browser pastes chunk at 32 KiB
2897 under it, wrapped ONCE via paste_begin/end).
2898
2899 **headFrame's capacity check is load-bearing twice.** Beyond refusing
2900 oversize messages, `.too_big` before the buffer fills is what keeps
2901 fillMore off std's defaultRebase assert (`buffer.len - seek >=
2902 capacity`), which panics on a full unreclaimable buffer. And the check
2903 itself was the milestone's one Critical regression: `off + payload_len`
2904 with a wire-controlled u64 overflows — 14 bytes panicked the hub in
2905 Debug and wrapped to a permanent hang in ReleaseFast. The pin that
2906 covers it now fails BOTH ways (panic in Debug, wrong verdict in
2907 ReleaseFast); a Debug-only pin would have left the release wrap
2908 unguarded. Lesson: bounds involving wire-declared lengths get pinned
2909 at the TYPE boundary, not just the capacity edge.
2910
2911 **"The page never ran."** WebAssembly.instantiate is shape-polymorphic:
2912 bytes yield {module, instance}, a compiled Module yields a bare
2913 Instance. The shell destructured the wrong shape, every tile died on an
2914 unread promise rejection, and verify.js — which feeds bytes — stayed
2915 green. Found only because the fix round added the missing .catch and
2916 then drove the real page under headless Chrome. verify.js now pins
2917 both shapes, but the residual stands: nothing automated boots the page,
2918 so the shell's use of the contract is still uncovered. The named
2919 follow-up, highest value in this milestone: automate the headless
2920 browser boot. The general lesson repeated itself precisely — defects
2921 clustered in the one layer with no tests.
2922
2923 **Process record.** The execution fork could not spawn subagents, so
2924 Tasks 1–10 shipped with NO reviews; the post-hoc pair found 1 Critical
2925 + 5 Important, all in the untested JS layer, and the two fix rounds
2926 added (then caught) one Critical of their own. Review-after-the-fact
2927 worked, but the Critical-per-round rate says reviews belong in the
2928 loop, not after it.
2929
2930 **Deliberate v1 stances, recorded so they read as chosen:** bracketed
2931 paste is unconditional (the replica cannot see DECSET 2004 — pty_mode
2932 carries icanon/echo only; extending it is a daemon protocol change,
2933 deferred); {"state":"gone"} is in the control vocabulary but never
2934 emitted (the browser derives gone from ws.onclose; kept for the day
2935 the hub gives up); a slow browser stalls only its own tile's thread;
2936 a mid-paste socket death leaves the application in paste mode (the end
2937 marker cannot transit a dead socket; state lives beyond the pty);
2938 after unzoom a tile keeps the grid the zoom claimed (unzoom sends
2939 nothing, per spec); snapshots cannot exceed the 256 KiB staging cap in
2940 practice because Engine.dumpState is viewport-only by design.
2941
2942 **Banked by M17:** the headless boot automation (above); a keyboard
2943 route out of zoom (the shade ring is mouse-only; Escape belongs to the
2944 application); automated coverage for the 30/60/90s dead-leg timing (the
2945 pong path is covered every e2e run — with the caveat that the pin
2946 catches framing regressions loudly but a swallowing regression only
2947 probabilistically; the timing itself is reasoned, not observed);
2948 GONE_AFTER_FAILURES bounds two different counters (socket opens,
2949 replay attempts) and the name no longer says so; per-tab daemon slots
2950 (N tiles × M tabs = N×M attaches against max_clients=8) — half-open
2951 pressure the M16 reaping-policy item now also covers.
docs/roadmap.md
Old New
@@ -177,6 +177,34 @@ golden bytes, the transport dispatch decision, `openFailure`'s 12 message
177 classes and `keyRefusalBody` all got their **first** pins 177 classes and `keyRefusalBody` all got their **first** pins
178 (decisions.md, M15, which carries the tables and the failure lines). 178 (decisions.md, M15, which carries the tables and the failure lines).
179 179
180 ## M17 — web client: a wall of devices in a browser — code-complete
181
182 Shipped 2026-08-13 (04861b7..002d1f7 + close-out). **Field verdict
183 owed**: the kill criterion — three real devices live in one page, zoom
184 the LAN tile, vim/type/scroll, the CLI client never glitches, pull the
185 network and watch the tile recover — has not run yet, and the milestone
186 is not *complete* until it has.
187
188 What shipped: `muxweb` (localhost-only hub; tiles are argv in mux's own
189 TARGET spellings; one WebSocket per tile; std.http's upgrade with the
190 Origin gate ours), `replica.zig` (the replay core extracted from
191 `session()` and unit-tested for the first time), `keymap.zig`,
192 `mux_core.wasm` (ghostty-vt on wasm32-freestanding, 345KB, flat
193 damage-hinted viewport ABI, verify.js wired into `make test`), the
194 canvas shell (1×1 passive wall tiles — the daemon's degenerate-size
195 refusal is the passivity mechanism, pinned hub-free; zoomed-only
196 input; IME; once-wrapped chunked paste), and `wsclient` (a scripted
197 replica client speaking RFC 6455). Suite grew 20/33 → **23 scenarios /
198 35 convergence points**, units 255 → 285, plus 76 wasm-ABI assertions.
199
200 The process record is in decisions.md M17: no in-loop reviews ran (the
201 execution fork could not spawn subagents); the post-hoc pair found
202 1 Critical + 5 Important, every one in the untested JS layer, and the
203 fix rounds added-then-caught a Critical of their own (the headFrame
204 u64 overflow — 14 bytes could panic the hub). Banked follow-ups, led
205 by the headless browser-boot automation ("the page never ran" was the
206 milestone's defining defect), are listed there.
207
180 ## M16 candidates — and still outranked by trial feedback 208 ## M16 candidates — and still outranked by trial feedback
181 209
182 What remains is what was ranked behind M13 and M14 and survived M15, 210 What remains is what was ranked behind M13 and M14 and survived M15,
web/mux.js
Old New
@@ -192,6 +192,12 @@ class Tile {
192 replaySucceeded() { 192 replaySucceeded() {
193 this.replayFailures = 0; 193 this.replayFailures = 0;
194 this.replayBackoffMs = 0; 194 this.replayBackoffMs = 0;
195 // A frame that applies cleanly is the recovery the terminal state was
196 // waiting for (e.g. the session shrank and its snapshot fits again) —
197 // clear the give-up flag or the badge lies about a working tile and
198 // sendAttach stays gated off a tile that no longer deserves it.
199 this.replayDead = false;
200 if (this.badgeIs('stuck')) this.setBadge('up', 'up');
195 } 201 }
196 202
197 // The core is unusable — re-init and re-attach from nothing. Everything 203 // The core is unusable — re-init and re-attach from nothing. Everything