7c2b4725
docs: M17 close-out record; a recovered tile clears its stuck badge
a73x 2026-08-13 13:02
Commit message
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 |