a73x

1aaa22fe

Plan: connection primitives, seven tasks

a73x   2026-08-31 18:05

Commit message
Plan: connection primitives, seven tasks

Task-by-task implementation plan for the link/serve/pumpUntil campaign:
full code for the new rows, exact deletion spans, per-task gates, and
the delivery greps from the spec's success criteria.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWxBL1HBULH1ZwTNzzKTja

docs/superpowers/plans/2026-08-31-connection-primitives.md
Old New
@@ -0,0 +1,1069 @@
1 # Connection Primitives Implementation Plan
2
3 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5 **Goal:** One owner each for "a live connection to a daemon" (`src/link.zig`), "wait for one frame of type X" (`Link.awaitFrame`), "bind a unix socket I own" (`src/serve.zig`), and "pump a test daemon to a condition" (`harness.pumpUntil`) — deleting ~10 await-loop copies, 3 divergent binder sites, and 13 spin loops.
6
7 **Architecture:** The fd|pipe|quic union is promoted out of `client.Transport` into a shared `link` row; `Transport` and muxa's `AgentConnection` become policy wrappers around it. `serve.zig` owns bind+teardown (guarded unlink for all binders); accept loops stay with their owners. No wire change anywhere.
8
9 **Tech Stack:** Zig 0.15.2 (vendored: `deps/zig/zig`, the Makefile points at it — system zig will NOT build this). Linux only.
10
11 **Spec:** `docs/superpowers/specs/2026-08-31-connection-primitives-design.md`
12
13 ## Global Constraints
14
15 - Build/test ONLY through the Makefile: `make check` after every task (fmt + unit tests + comment-claim refs), `make ci` at delivery. Capture `$?` before piping (`make test | tail` reports tail's exit).
16 - **No wire change.** Every byte on every socket identical before and after. No new frame types, no encoder edits.
17 - `proxy.zig` and the QUIC modules must NEVER import `link` or `term` — the byte-blind invariant.
18 - Comments say *why*, plainly; `zig build check` gates that cited symbols resolve. When moving code, move its load-bearing comments with it.
19 - A unit test that writes to fd 1 wedges `zig build test` silently. Tests capture output into pipes if they must.
20 - Where a thing can be plural, the default test fixture is plural; N=1 is an extra case, not the baseline.
21 - Commit per step with `--fixup`/`--squash` where amending; autosquash before delivery so history tells the feature's story. The commit-msg hook stamps `Patch:` trailers.
22 - Any hand-run rig exports isolated `XDG_STATE_HOME` and `XDG_RUNTIME_DIR`.
23 - The plan's code blocks are the reference; if the vendored Zig rejects a detail (e.g. a comptime fn-param spelling), fix the spelling, keep the contract, and note it in the commit body.
24
25 ---
26
27 ### Task 1: `src/link.zig` — the Link union and its mechanics
28
29 **Files:**
30 - Create: `src/link.zig`
31 - Modify: `build.zig` (module table around line 169, test-rows list around line 720)
32
33 **Interfaces:**
34 - Consumes: `term.protocol` (`writeFrame`, `readFrame`, `takeFrame`, `appendFrame`, `Frame`, `MsgType`), `quic.Client` (`pollFd`, `timeoutMs`, `pump`, `send`, `deinit`, fields `in`, `dead`).
35 - Produces (later tasks rely on these exact names):
36 - `pub const Link = union(enum) { fd, pipe, quic }` with `Pipe = { child: std.process.Child, r, w: std.posix.fd_t }` and `Quic = { cl: *quic.Client, qout: std.ArrayList(u8), alloc: std.mem.Allocator }`
37 - `pub const Incoming = union(enum) { frame: proto.Frame, incomplete, closed }`
38 - `pollFd(self: *const Link) std.posix.fd_t`
39 - `timeoutMs(self: *Link, cap_ms: i32) i32`
40 - `service(self: *Link) void`
41 - `sendFrame(self: *Link, t: proto.MsgType, payload: []const u8) !void`
42 - `flushWithin(self: *Link, deadline_ms: u32) !void` (errors: `error.ConnectionLost`, `error.SendStalled`)
43 - `readFrame(self: *Link, alloc: std.mem.Allocator) !Incoming`
44 - `close(self: *Link) void` (idempotent)
45
46 - [ ] **Step 1: Write `src/link.zig` with its tests included (Zig keeps tests in-file)**
47
48 The file. Module header states the contract; bodies below are moved from
49 `client.zig:694-766` (write/service/timeout/flush/read) with the switch arms
50 preserved byte-for-byte where they exist today:
51
52 ```zig
53 //! The live connection to a daemon, however it was reached: a unix-socket fd,
54 //! the stdio of a `--via`/handoff child, or a QUIC client. Mechanics only —
55 //! send a frame, read a frame, wait, close. Policy stays with the owners:
56 //! redial and backoff are `client.Transport`'s and muxa's, attach semantics
57 //! are dial's and the callers'. This row exists because the fd|pipe|quic
58 //! union used to live twice (client.Transport, muxa.AgentConnection), each
59 //! with its own send, await and close.
60 const std = @import("std");
61 const proto = @import("term").protocol;
62 const quic = @import("quic");
63
64 /// Three outcomes, not two: QUIC's socket goes readable for acks and half
65 /// frames, so `null` cannot keep the socket path's meaning of "peer gone"
66 /// without making every partial frame a reconnect. (Moved from client.zig.)
67 pub const Incoming = union(enum) {
68 frame: proto.Frame,
69 incomplete,
70 closed,
71 };
72
73 /// What a non-matching frame does to an `awaitFrame` wait. `on == null` is
74 /// drop: the frame is freed and the wait continues — the observer-verb
75 /// policy dial.ask always had. A non-null `on` BORROWS the frame for the
76 /// duration of the call and must copy anything it keeps; awaitFrame frees
77 /// the frame when `on` returns. An error out of `on` ends the wait with
78 /// that error — some "other" frames are answers, not noise (muxa's
79 /// exit_status), and only the caller knows which.
80 pub const Sink = struct {
81 ctx: ?*anyopaque = null,
82 on: ?*const fn (ctx: ?*anyopaque, frame: proto.Frame) anyerror!void = null,
83 };
84
85 pub const Link = union(enum) {
86 /// A unix socket: one fd, read and write. -1 once closed — close() is
87 /// idempotent because a re-dial releases the dead link on entry and an
88 /// abort then closes the same value again through a defer; a second
89 /// close(2) on a stale fd is EBADF, which std.posix maps to unreachable.
90 fd: std.posix.fd_t,
91 /// `--via`, and the ssh half of a handoff: the child whose stdio IS the
92 /// transport. `r` is its stdout, `w` its stdin.
93 pipe: Pipe,
94 /// The connection that IS the transport; bytes go through the stream
95 /// layer, so there is nothing to write(2) to.
96 quic: Quic,
97
98 pub const Pipe = struct {
99 child: std.process.Child,
100 r: std.posix.fd_t,
101 w: std.posix.fd_t,
102 };
103
104 pub const Quic = struct {
105 cl: *quic.Client,
106 /// Bytes the QUIC ring would not take yet. Frames are appended whole
107 /// and handed over a prefix at a time, so a short accept can never
108 /// split one on the wire — the remainder is offered again next pass.
109 /// Lives here and not in the wrappers because the staging is a
110 /// property of the quic link, and it used to exist twice
111 /// (Transport.qout, muxa.sendFrameQuic's stack buffer).
112 qout: std.ArrayList(u8) = .empty,
113 alloc: std.mem.Allocator,
114 };
115
116 pub fn pollFd(self: *const Link) std.posix.fd_t {
117 return switch (self.*) {
118 .fd => |fd| fd,
119 .pipe => |p| p.r,
120 .quic => |q| q.cl.pollFd(),
121 };
122 }
123
124 /// Folds ngtcp2's next deadline in, so retransmits and idle timeouts
125 /// happen on time without a second timer.
126 pub fn timeoutMs(self: *Link, cap_ms: i32) i32 {
127 return switch (self.*) {
128 .quic => |*q| q.cl.timeoutMs(cap_ms),
129 .fd, .pipe => cap_ms,
130 };
131 }
132
133 /// Unconditional: a QUIC connection's timers are the only thing that
134 /// notices a peer which stopped answering.
135 pub fn service(self: *Link) void {
136 switch (self.*) {
137 .quic => |*q| {
138 q.cl.pump();
139 self.flushQuic();
140 },
141 .fd, .pipe => {},
142 }
143 }
144
145 /// Queue-and-offer, never blocking: fd and pipe write through; quic
146 /// appends whole and flushes what the ring takes. A caller that must
147 /// KNOW the bytes left (muxa's verbs) follows with flushWithin.
148 pub fn sendFrame(self: *Link, t: proto.MsgType, payload: []const u8) !void {
149 switch (self.*) {
150 .quic => |*q| {
151 try proto.appendFrame(&q.qout, q.alloc, t, payload);
152 self.flushQuic();
153 },
154 .fd => |fd| return proto.writeFrame(fd, t, payload),
155 .pipe => |p| return proto.writeFrame(p.w, t, payload),
156 }
157 }
158
159 /// Offer the outbound queue to the ring again. After every write and on
160 /// every service pass, because the room to accept comes from
161 /// acknowledgements, which arrive on their own schedule.
162 fn flushQuic(self: *Link) void {
163 const q = switch (self.*) {
164 .quic => |*q| q,
165 .fd, .pipe => return,
166 };
167 if (q.qout.items.len == 0) return;
168 const n = q.cl.send(q.qout.items);
169 if (n == 0) return;
170 q.qout.replaceRangeAssumeCapacity(0, n, &.{});
171 }
172
173 /// Drive the staged bytes out or say why not, within the deadline. A
174 /// no-op for fd/pipe (their sendFrame already either took the bytes or
175 /// failed). The QUIC arm is muxa's old sendFrameQuic loop: the ring is
176 /// full, only the peer's acks empty it, and they arrive through pump —
177 /// polling first keeps this from spinning.
178 pub fn flushWithin(self: *Link, deadline_ms: u32) !void {
179 const q = switch (self.*) {
180 .quic => |*q| q,
181 .fd, .pipe => return,
182 };
183 const end = std.time.milliTimestamp() + deadline_ms;
184 while (q.qout.items.len != 0) {
185 if (q.cl.dead) return error.ConnectionLost;
186 self.flushQuic();
187 if (q.qout.items.len == 0) return;
188 if (std.time.milliTimestamp() >= end) return error.SendStalled;
189 var fds = [_]std.posix.pollfd{
190 .{ .fd = q.cl.pollFd(), .events = std.posix.POLL.IN, .revents = 0 },
191 };
192 _ = std.posix.poll(&fds, q.cl.timeoutMs(50)) catch return error.ConnectionLost;
193 q.cl.pump();
194 }
195 }
196
197 /// The next whole frame, if there is one. (Moved from
198 /// client.Transport.readFrame; see Incoming for why a missing frame is
199 /// not automatically a dead transport.)
200 pub fn readFrame(self: *Link, alloc: std.mem.Allocator) !Incoming {
201 switch (self.*) {
202 .quic => |*q| {
203 // Death is checked after the pump, so bytes that arrived in
204 // the same pass as the close are still delivered before the
205 // tear.
206 const got = proto.takeFrame(alloc, &q.cl.in) catch |err| switch (err) {
207 error.OutOfMemory => return err, // not a transport event
208 else => return .closed,
209 };
210 if (got) |frame| return .{ .frame = frame };
211 return if (q.cl.dead) .closed else .incomplete;
212 },
213 .fd => |fd| {
214 const frame = (proto.readFrame(alloc, fd) catch |err| switch (err) {
215 error.OutOfMemory => return err,
216 else => return .closed,
217 }) orelse return .closed;
218 return .{ .frame = frame };
219 },
220 .pipe => |p| {
221 const frame = (proto.readFrame(alloc, p.r) catch |err| switch (err) {
222 error.OutOfMemory => return err,
223 else => return .closed,
224 }) orelse return .closed;
225 return .{ .frame = frame };
226 },
227 }
228 }
229
230 /// Wait for one frame of type `want`. THE primitive this row exists
231 /// for; every hand-rolled poll+readFrame loop in the tree is a copy of
232 /// this. Returns null when the deadline runs out (a null deadline waits
233 /// forever), error.Closed when the peer is gone — callers own the
234 /// wording for both (dial.ask maps Closed to its "no answer" null; muxa
235 /// maps it to DaemonGone/ConnectionLost). Non-matching frames go to the
236 /// sink (see Sink for ownership).
237 pub fn awaitFrame(
238 self: *Link,
239 alloc: std.mem.Allocator,
240 want: proto.MsgType,
241 deadline_ms: ?u32,
242 sink: Sink,
243 ) !?proto.Frame {
244 const end: ?i64 = if (deadline_ms) |ms| std.time.milliTimestamp() + ms else null;
245 while (true) {
246 // Deliver what is already in hand before waiting: a QUIC frame
247 // may be whole in cl.in from an earlier pump, and poll would
248 // never fire for bytes already in userspace.
249 switch (try self.readFrame(alloc)) {
250 .frame => |frame| {
251 if (frame.type == want) return frame;
252 if (sink.on) |on| {
253 defer frame.deinit(alloc);
254 try on(sink.ctx, frame);
255 } else frame.deinit(alloc);
256 continue;
257 },
258 .closed => return error.Closed,
259 .incomplete => {},
260 }
261 // Nothing whole in hand: wait. The wait is capped at 250ms even
262 // with no caller deadline, because a QUIC link's timers (loss
263 // detection, keepalive) need servicing on schedule rather than
264 // whenever the daemon happens to say something; a plain fd with
265 // no deadline may block indefinitely, which is what a caller
266 // wants when a stopped daemon should be a visible hang.
267 var cap: i32 = undefined;
268 if (end) |e| {
269 const left = e - std.time.milliTimestamp();
270 if (left <= 0) return null;
271 cap = @intCast(@min(left, 250));
272 } else cap = switch (self.*) {
273 .quic => 250,
274 .fd, .pipe => -1,
275 };
276 var fds = [_]std.posix.pollfd{
277 .{ .fd = self.pollFd(), .events = std.posix.POLL.IN, .revents = 0 },
278 };
279 _ = std.posix.poll(&fds, self.timeoutMs(cap)) catch return error.Closed;
280 self.service();
281 // The fd arms block in readFrame on a partial frame even past
282 // the deadline — the price of frames over a stream, same as
283 // dial.askOn always paid; a malformed frame is an error rather
284 // than an absent reply.
285 }
286 }
287
288 /// Idempotent, and it has to be: a re-dial releases the dead link on
289 /// entry, and an abort then closes the same value again through the
290 /// pump's defer. (The pipe-kill ordering is client.Transport.close's,
291 /// moved: stdin first so the command sees EOF and can wind down its
292 /// remote end, then TERM. kill() waitpid()s internally — no zombie.)
293 pub fn close(self: *Link) void {
294 switch (self.*) {
295 .fd => |fd| {
296 if (fd == -1) return; // already released
297 std.posix.close(fd);
298 },
299 .pipe => |*p| {
300 if (p.child.stdin) |*in| {
301 in.close();
302 p.child.stdin = null;
303 }
304 _ = p.child.kill() catch {};
305 },
306 .quic => |*q| {
307 q.qout.deinit(q.alloc);
308 // Closes the UDP socket with it, so pollFd's value must not
309 // be closed again.
310 q.cl.deinit();
311 },
312 }
313 self.* = .{ .fd = -1 };
314 }
315 };
316 ```
317
318 Tests, in the same file (the socketpair shapes are dial.zig's; a fake peer
319 thread plays the daemon). Note the sink test drives a PLURAL stream — two
320 noise frames before the answer — per the plural-fixture rule:
321
322 ```zig
323 const testing = std.testing;
324
325 fn mkPair() ![2]std.posix.fd_t {
326 var pair: [2]i32 = undefined;
327 try testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair));
328 return .{ pair[0], pair[1] };
329 }
330
331 test "fd link: sendFrame puts one frame on the wire, readFrame takes it back" {
332 const alloc = testing.allocator;
333 const pair = try mkPair();
334 var a: Link = .{ .fd = pair[0] };
335 var b: Link = .{ .fd = pair[1] };
336 defer a.close();
337 defer b.close();
338 try a.sendFrame(.input, "hi");
339 const inc = try b.readFrame(alloc);
340 const frame = inc.frame;
341 defer frame.deinit(alloc);
342 try testing.expectEqual(proto.MsgType.input, frame.type);
343 try testing.expectEqualStrings("hi", frame.payload);
344 }
345
346 test "close is idempotent on every arm that can be closed twice" {
347 const pair = try mkPair();
348 std.posix.close(pair[1]);
349 var l: Link = .{ .fd = pair[0] };
350 l.close();
351 l.close(); // second close must be a no-op, not EBADF-unreachable
352 }
353
354 test "awaitFrame: null sink drops noise frames and returns the match" {
355 const alloc = testing.allocator;
356 const pair = try mkPair();
357 var l: Link = .{ .fd = pair[0] };
358 defer l.close();
359 // Two noise frames BEFORE the answer: the wait must survive a stream,
360 // not a single-frame fixture.
361 try proto.writeFrame(pair[1], .pty_mode, &.{0});
362 try proto.writeFrame(pair[1], .delta, "x");
363 try proto.writeFrame(pair[1], .stats_reply, "ok");
364 std.posix.close(pair[1]);
365 const got = (try l.awaitFrame(alloc, .stats_reply, 1000, .{})).?;
366 defer got.deinit(alloc);
367 try testing.expectEqualStrings("ok", got.payload);
368 }
369
370 test "awaitFrame: the sink sees every non-match, in order, and may end the wait" {
371 const alloc = testing.allocator;
372 const Seen = struct {
373 types: [8]proto.MsgType = undefined,
374 n: usize = 0,
375 fn on(ctx: ?*anyopaque, frame: proto.Frame) anyerror!void {
376 const self: *@This() = @ptrCast(@alignCast(ctx.?));
377 self.types[self.n] = frame.type;
378 self.n += 1;
379 if (frame.type == .exit_status) return error.SessionExited;
380 }
381 };
382 // Leg one: the sink observes and the wait completes.
383 {
384 const pair = try mkPair();
385 var l: Link = .{ .fd = pair[0] };
386 defer l.close();
387 try proto.writeFrame(pair[1], .snapshot, "");
388 try proto.writeFrame(pair[1], .stats_reply, "ok");
389 std.posix.close(pair[1]);
390 var seen: Seen = .{};
391 const got = (try l.awaitFrame(alloc, .stats_reply, 1000, .{ .ctx = &seen, .on = Seen.on })).?;
392 defer got.deinit(alloc);
393 try testing.expectEqual(@as(usize, 1), seen.n);
394 try testing.expectEqual(proto.MsgType.snapshot, seen.types[0]);
395 }
396 // Leg two: the sink's error IS the outcome — an "other" frame that is
397 // an answer, muxa's whole reason for a second copy.
398 {
399 const pair = try mkPair();
400 var l: Link = .{ .fd = pair[0] };
401 defer l.close();
402 try proto.writeFrame(pair[1], .exit_status, &.{7});
403 std.posix.close(pair[1]);
404 var seen: Seen = .{};
405 try testing.expectError(
406 error.SessionExited,
407 l.awaitFrame(alloc, .stats_reply, 1000, .{ .ctx = &seen, .on = Seen.on }),
408 );
409 }
410 }
411
412 test "awaitFrame: silence consumes the deadline and returns null; a closed peer is error.Closed" {
413 const alloc = testing.allocator;
414 {
415 const pair = try mkPair();
416 defer std.posix.close(pair[1]);
417 var l: Link = .{ .fd = pair[0] };
418 defer l.close();
419 const t0 = std.time.milliTimestamp();
420 try testing.expect((try l.awaitFrame(alloc, .stats_reply, 100, .{})) == null);
421 try testing.expect(std.time.milliTimestamp() - t0 >= 100);
422 }
423 {
424 const pair = try mkPair();
425 var l: Link = .{ .fd = pair[0] };
426 defer l.close();
427 std.posix.close(pair[1]);
428 try testing.expectError(error.Closed, l.awaitFrame(alloc, .stats_reply, 1000, .{}));
429 }
430 }
431
432 test "awaitFrame: no deadline waits out a late reply" {
433 const alloc = testing.allocator;
434 const pair = try mkPair();
435 var l: Link = .{ .fd = pair[0] };
436 defer l.close();
437 const Late = struct {
438 fn run(fd: std.posix.fd_t) void {
439 std.Thread.sleep(150 * std.time.ns_per_ms);
440 proto.writeFrame(fd, .stats_reply, "late") catch {};
441 std.posix.close(fd);
442 }
443 };
444 const t = try std.Thread.spawn(.{}, Late.run, .{pair[1]});
445 defer t.join();
446 const got = (try l.awaitFrame(alloc, .stats_reply, null, .{})).?;
447 defer got.deinit(alloc);
448 try testing.expectEqualStrings("late", got.payload);
449 }
450 ```
451
452 - [ ] **Step 2: Wire the row in `build.zig` and verify the tests fail-then-pass**
453
454 In the module table (after the `dial` row at build.zig:169), mirroring dial's
455 comment style:
456
457 ```zig
458 // The live connection itself — fd, pipe or QUIC — and the one wait-for-a-
459 // frame loop. `term` for frames, `quic` for the third arm; policy stays
460 // with the rows that import this one.
461 .{ .name = "link", .path = "src/link.zig", .link_libc = true, .imports = &.{ "term", "quic" }, .quic_tests = true },
462 ```
463
464 Add `"link"` to the test-rows list near build.zig:720 (the block naming
465 `script, cliflags, testtmp, spawn, dial, quic, ...`), in whichever sublist
466 `quic` sits in (link links libc through quic). If the build refuses a flag
467 combination, copy the `client` row's flags — it has the same dependency shape.
468
469 Run: `make check`
470 Expected: PASS, with the new link tests listed as run. If `zig build test`
471 prints nothing for minutes, a test wrote to fd 1 — fix that first.
472
473 - [ ] **Step 3: Commit**
474
475 ```bash
476 git add src/link.zig build.zig
477 git commit -m "link: the fd|pipe|quic union and the one await loop, as a row"
478 ```
479
480 ---
481
482 ### Task 2: `dial.ask` rides the Link; `askOn` dies
483
484 **Files:**
485 - Modify: `src/dial.zig` (delete lines 98–139: `askOn` and its doc comment; rewrite `ask`'s body; the `askOn` socketpair tests at the bottom become `awaitFrame`-through-`ask`-shaped or move their pin to link.zig where Task 1 already covers it)
486 - Modify: `build.zig` (`dial` row gains `"link"` in imports)
487
488 **Interfaces:**
489 - Consumes: `Link.awaitFrame`, `Link.sendFrame`, `Link.close` from Task 1.
490 - Produces: `dial.ask` with its EXACT current signature and contract —
491 `ask(alloc, sock_path, req: proto.MsgType, payload: []const u8, want: proto.MsgType, deadline_ms: ?u32) !?proto.Frame`,
492 still minting `error.NoDaemon` and `error.RequestNotSent`, still returning
493 null for both "deadline ran out" and "peer closed without answering".
494
495 - [ ] **Step 1: Rewrite `ask` over the Link**
496
497 ```zig
498 const link_mod = @import("link");
499
500 pub fn ask(
501 alloc: std.mem.Allocator,
502 sock_path: []const u8,
503 req: proto.MsgType,
504 payload: []const u8,
505 want: proto.MsgType,
506 deadline_ms: ?u32,
507 ) !?proto.Frame {
508 const s = dial(sock_path) catch return error.NoDaemon;
509 var l: link_mod.Link = .{ .fd = s.handle };
510 defer l.close();
511 // A request that could not be delivered is its own answer: the caller
512 // reports a daemon that never heard the question differently from one
513 // that heard it and said nothing.
514 l.sendFrame(req, payload) catch return error.RequestNotSent;
515 // A peer that closed without answering is the same "no answer" as a
516 // deadline that ran out — dial.ask's contract predates the Link and
517 // keeps it; callers that need the distinction hold a Link themselves.
518 return l.awaitFrame(alloc, want, deadline_ms, .{}) catch |e| switch (e) {
519 error.Closed => null,
520 else => e,
521 };
522 }
523 ```
524
525 Delete `askOn` (dial.zig:98–139). Keep `dial`, `dialAttach`,
526 `dialAttachNamed`, `detach` untouched.
527
528 - [ ] **Step 2: Re-point dial's askOn-specific tests**
529
530 The tests "ask: a path nothing is bound at is error.NoDaemon" and the
531 attach-helper tests stay verbatim. The two socketpair tests that reached the
532 private `askOn` ("a deadline gives up on silence…") pin behavior Task 1's
533 link tests now pin (the deadline-consumed case and the
534 late-reply-without-deadline case are both in link.zig); delete them here
535 after confirming both link tests exist by symbol.
536
537 - [ ] **Step 3: Wire and gate**
538
539 `build.zig` dial row becomes:
540
541 ```zig
542 .{ .name = "dial", .path = "src/dial.zig", .link_libc = true, .imports = &.{ "term", "link", "quic" } },
543 ```
544
545 (`link` pulls `quic`; if the build graph complains about transitive libc,
546 mirror what the `client` row does.)
547
548 Run: `make check` — expected PASS.
549 Then the behavior gate against a REAL daemon, because dial.ask serves the
550 observer verbs: `E2E_ONLY=03 make e2e` (the side-connection group), capture
551 `$?` before any pipe. Expected PASS.
552
553 - [ ] **Step 4: Commit**
554
555 ```bash
556 git add src/dial.zig build.zig
557 git commit -m "dial.ask asks through the Link; askOn's loop retires into awaitFrame"
558 ```
559
560 ---
561
562 ### Task 3: `client.Transport` wraps a Link
563
564 **Files:**
565 - Modify: `src/client/client.zig` — fields at 342–380; `pipeTransport`/`quicTransport` constructors; delete the bodies of `writeFrame`/`service`/`timeoutMs`/`flushQuic`/`readFrame` (694–766) and the per-arm half of `close` (767–808); `awaitFrames` (1174–1231); `pollFd`.
566 - Modify: `build.zig` (`client` row gains `"link"`).
567
568 **Interfaces:**
569 - Consumes: everything Task 1 produces.
570 - Produces: `Transport`'s public surface UNCHANGED — `open`, `openHandoff`, `openQuicEndpoint`, `pollFd`, `errFd`, `drainErr`, `adopt`, `writeFrame`, `service`, `timeoutMs`, `flushQuic`, `readFrame`, `close` keep their signatures, so wallview/wall_pump/interact/webhub compile untouched.
571
572 - [ ] **Step 1: Swap the fields**
573
574 `Transport.link` becomes `link_mod.Link` (import as `link_mod`, the same
575 shadow-avoidance dance the file already does for `dial`/`dialer`). Delete
576 `qout` (it lives in `Link.Quic` now) and delete `Conn`/`conn` — `pollFd()`
577 returns `self.link.pollFd()`; grep for every `conn.r`/`conn.w` use in
578 client.zig, wallview.zig, wall_pump.zig first (`grep -rn "\.conn\." src/client src/tui`)
579 and re-point each through the Link. If a use needs the write fd specifically,
580 add nothing to Link — `sendFrame` is the only writer by design; a use that
581 wants raw fds is a policy leak to flag in the PR, not to enable.
582 `Transport.close` keeps ONLY its own policy — the `err_fd` close and the
583 release check — then delegates:
584
585 ```zig
586 pub fn close(self: *Transport) void {
587 if (self.link == .fd and self.link.fd == -1) return; // already released
588 if (self.err_fd >= 0) {
589 // Ours, not the child's: openHandoff took stderr off the Child so
590 // kill would leave it alone, so nothing else will close it.
591 std.posix.close(self.err_fd);
592 self.err_fd = -1;
593 }
594 self.link.close();
595 }
596 ```
597
598 `writeFrame`/`service`/`timeoutMs`/`flushQuic`/`readFrame` become one-line
599 delegations (keep the public names; wall code calls them). The constructors
600 build Links: `pipeTransport` returns `.{ .link = .{ .pipe = .{ .child = child, .r = child.stdout.?.handle, .w = child.stdin.?.handle } } }`;
601 `quicTransport` returns `.{ .link = .{ .quic = .{ .cl = cl, .alloc = alloc } } }`.
602 `Incoming` becomes a re-export: `pub const Incoming = link_mod.Incoming;`
603 (callers spell `client.Incoming` today and must keep compiling).
604
605 - [ ] **Step 2: `awaitFrames` (client.zig:1174) rides `awaitFrame`**
606
607 Read its body first; it waits during birth/attach. Reshape it as a call to
608 `self.link.awaitFrame` with a sink that captures what the current loop's
609 non-match arms do (the BirthFake test at client.zig:2850 pins the order —
610 pty_mode before snapshot — and must still pass unchanged).
611
612 - [ ] **Step 3: Gate**
613
614 Run: `make check` — PASS. Then `make e2e` (the wall, handoff, QUIC groups
615 all cross this seam) — capture `$?`, expected PASS. The named risks: the
616 close-idempotence dance and the pipe-kill ordering moved in Task 1; if e2e
617 group 04 (handoff) fails, diff `Link.close`'s pipe arm against the old
618 `Transport.close` before touching anything else.
619
620 - [ ] **Step 4: Commit**
621
622 ```bash
623 git add src/client/client.zig build.zig
624 git commit -m "Transport wraps the Link: policy stays, mechanics move"
625 ```
626
627 ---
628
629 ### Task 4: muxa's `AgentConnection` wraps a Link
630
631 **Files:**
632 - Modify: `src/cli/muxa.zig` — the link union inside `AgentConnection` (~286), `open`/`openQuic`/`close`, `sendFrame` (deadline plumbing), delete `sendFrameQuic` (386–417), `awaitFrameFd` (425–457), `awaitFrameQuic` (458–507), `waitReady` (549–566, if the client's copy is reachable; otherwise keep — check callers), keep `reconnect` but re-point it at the Link's quic arm.
633 - Modify: `build.zig` (`agent` row gains `"link"`). muxa still does NOT import `client`.
634
635 **Interfaces:**
636 - Consumes: `Link.sendFrame` + `flushWithin`, `Link.awaitFrame` with a Sink, `Link.close`.
637 - Produces: `mux a`'s verbs behave identically — every JSON field, every exit code, every `mechanism` string.
638
639 - [ ] **Step 1: The union becomes a Link plus muxa's policy**
640
641 ```zig
642 const AgentConnection = struct {
643 link: link_mod.Link,
644 /// Reconnect policy is muxa's, not the Link's: reuse the original
645 /// address and key so a mid-command redial cannot select rotated
646 /// credentials or a different resolved address.
647 redial: ?struct {
648 addr: std.net.Address,
649 key: quic.Key,
650 idle_ms: u32,
651 connect_ms: i64,
652 reconnected: bool = false,
653 } = null,
654 alloc: std.mem.Allocator,
655 saw_snapshot: bool = false,
656 session_exit: ?u8 = null,
657 reconnect_failure: ?[]const u8 = null,
658 ...
659 ```
660
661 `sendFrame` becomes: `self.link.sendFrame(t, payload)` then, on the quic arm,
662 `self.link.flushWithin(bounded)` where `bounded` is today's
663 `@min(deadline_ms - now, send_flush_ms)` math converted to relative u32 at
664 this boundary; the BrokenPipe/ConnectionReset/ConnectionLost →
665 `refusalPending` classification stays wrapped around the call exactly as at
666 muxa.zig:358–370.
667
668 - [ ] **Step 2: The three await fns become one sink**
669
670 ```zig
671 fn onOther(ctx: ?*anyopaque, frame: proto.Frame) anyerror!void {
672 const self: *AgentConnection = @ptrCast(@alignCast(ctx.?));
673 // Skip unrelated snapshots and deltas while waiting. An exit frame ends
674 // the wait as a session outcome rather than a transport error: the
675 // daemon uses the same exit frame for a rejected attach and an ended
676 // session, and a valid attach always sends a snapshot first.
677 if (frame.type == .snapshot) self.saw_snapshot = true;
678 if (frame.type == .exit_status) {
679 if (!self.saw_snapshot) return error.AttachRefused;
680 // A missing status byte still ends the session; its code is unknown
681 // rather than zero.
682 self.session_exit = if (frame.payload.len >= 1) frame.payload[0] else null;
683 return error.SessionExited;
684 }
685 }
686
687 fn awaitFrame(self: *AgentConnection, want: proto.MsgType, deadline_ms: i64) !proto.Frame {
688 const left = deadline_ms - std.time.milliTimestamp();
689 if (left <= 0) return error.Timeout;
690 const got = self.link.awaitFrame(self.alloc, want, @intCast(left), .{
691 .ctx = self,
692 .on = onOther,
693 }) catch |e| switch (e) {
694 // Wording is per-arm on purpose: a closed unix socket is a dead
695 // daemon; a dead QUIC connection cannot distinguish daemon exit
696 // from path failure, so it reports ConnectionLost and allows the
697 // one reconnect.
698 error.Closed => return switch (self.link) {
699 .quic => error.ConnectionLost,
700 else => error.DaemonGone,
701 },
702 else => return e,
703 };
704 return got orelse error.Timeout;
705 }
706 ```
707
708 Delete `awaitFrameFd`, `awaitFrameQuic`, `sendFrameQuic`. `reconnect` builds a
709 fresh `quic.Client` from `self.redial.?` and swaps it into `self.link.quic.cl`
710 (deinit the old one first, exactly the current ordering at muxa.zig:504–510).
711 `graceMs` reads `self.redial.?.connect_ms`.
712
713 - [ ] **Step 3: Gate**
714
715 Run: `make check` then `make agent` — capture `$?`, both PASS. The agent
716 suite runs real verbs against a real daemon over both transports; it is the
717 pin for "every JSON field identical". Then
718 `grep -c "awaitFrameFd\|awaitFrameQuic\|sendFrameQuic" src/cli/muxa.zig`
719 must print 0.
720
721 - [ ] **Step 4: Commit**
722
723 ```bash
724 git add src/cli/muxa.zig build.zig
725 git commit -m "muxa rides the Link: the second transport retires"
726 ```
727
728 ---
729
730 ### Task 5: `src/serve.zig` — bind and teardown get one owner
731
732 **Files:**
733 - Create: `src/serve.zig`
734 - Modify: `build.zig` (new row; `daemon` and `client` rows gain `"serve"`)
735 - Modify: `src/server/server.zig` (init at 505–540: claim+listen+PathId → `serve.bind`; deinit at 815–817: guarded unlink → `Bound.close`; `initFromManifest` at ~628: `serve.adopt`)
736 - Modify: `src/server/server_agent.zig` (bind at 150–176, `AgentSock.release` at 22–34)
737 - Modify: `src/client/askpass.zig` (bind at 145–162, the unlink in `retire` at ~206)
738 - Test: additions in `src/serve.zig` itself, `src/server/server_test_agent.zig`, askpass's tests in `src/client/askpass.zig`
739
740 **Interfaces:**
741 - Consumes: `sockpath.claim`, `sockpath.PathId`.
742 - Produces:
743 - `pub const Policy = enum { refuse_live, clobber_own };`
744 - `pub fn bind(path: []const u8, opts: BindOpts) !Bound` where `BindOpts = struct { policy: Policy, backlog: u31 = 128, cloexec: bool = false }`
745 - `pub fn adopt(fd: std.posix.fd_t, path: []const u8) !Bound`
746 - `pub const Bound = struct { fd: std.posix.fd_t, path_id: sockpath.PathId, pub fn close(self: *Bound, path: []const u8) void; }`
747
748 - [ ] **Step 1: Write `src/serve.zig` with tests**
749
750 ```zig
751 //! The server side of a unix socket path: the right to bind it and the duty
752 //! to unlink it, written once. Three binders used to answer this trio
753 //! independently — the daemon socket, the per-session agent sockets, the
754 //! askpass socket — and only the first carried the guarded unlink that
755 //! sockpath's incident record paid for. Accept loops are NOT here: the
756 //! daemon's slot-table accept, askpass's credential check and the hub's
757 //! thread-per-conn differ for reasons, and a shared loop would be a shape
758 //! they do not fit.
759 const std = @import("std");
760 const sockpath = @import("sockpath");
761
762 pub const Policy = enum {
763 /// Never steal a path that answers: sockpath.claim's refusal, the
764 /// daemon-socket rule.
765 refuse_live,
766 /// The name embeds our identity (a pid, a session name in our own
767 /// directory), so a leftover file there is ours — a previous us that
768 /// died without unlinking — and is deleted before the bind.
769 clobber_own,
770 };
771
772 pub const BindOpts = struct {
773 policy: Policy,
774 backlog: u31 = 128,
775 /// FALSE is load-bearing: `mux d upgrade` execs the candidate over the
776 /// running daemon, and the daemon listener and every agent socket must
777 /// survive that exec. askpass passes true — its process never execs
778 /// over itself and its children must not inherit the prompt socket.
779 cloexec: bool = false,
780 };
781
782 pub const Bound = struct {
783 fd: std.posix.fd_t,
784 path_id: sockpath.PathId,
785
786 /// Close, then unlink only if the path still names OUR socket: a newer
787 /// owner may have replaced the file, and deleting that one would steal
788 /// its clients. The stat comes after the close because a successor only
789 /// claims once nothing is listening — that narrows the race to the
790 /// stat→unlink gap, the floor Linux gives for deleting by name.
791 pub fn close(self: *Bound, path: []const u8) void {
792 if (self.fd == -1) return;
793 std.posix.close(self.fd);
794 self.fd = -1;
795 if (self.path_id.stillAt(path)) {
796 std.fs.cwd().deleteFile(path) catch {};
797 }
798 }
799 };
800
801 /// Bind, listen, and remember which inode is ours. Overlong paths are
802 /// initUnix's refusal (kernel truth, not a re-stated bound) — `mux d`'s
803 /// parse-time `sockpath.tooLong` with its own stderr wording remains the
804 /// one binder-side pre-check, and it lives with `mux d`.
805 pub fn bind(path: []const u8, opts: BindOpts) !Bound {
806 switch (opts.policy) {
807 .refuse_live => try sockpath.claim(path),
808 .clobber_own => std.fs.cwd().deleteFile(path) catch {},
809 }
810 const addr = try std.net.Address.initUnix(path);
811 const sock_flags: u32 = std.posix.SOCK.STREAM |
812 (if (opts.cloexec) std.posix.SOCK.CLOEXEC else 0);
813 const fd = try std.posix.socket(std.posix.AF.UNIX, sock_flags, 0);
814 errdefer std.posix.close(fd);
815 try std.posix.bind(fd, &addr.any, addr.getOsSockLen());
816 try std.posix.listen(fd, opts.backlog);
817 return .{ .fd = fd, .path_id = try sockpath.PathId.of(path) };
818 }
819
820 /// A listener that already exists — the upgrade manifest's adopted fd. The
821 /// PathId is re-stamped from the file as found, which is the manifest rule:
822 /// a watermark belongs to the space that minted it.
823 pub fn adopt(fd: std.posix.fd_t, path: []const u8) !Bound {
824 return .{ .fd = fd, .path_id = try sockpath.PathId.of(path) };
825 }
826 ```
827
828 Tests in-file (`test_imports = &.{"testtmp"}` gives `TmpDir`):
829
830 ```zig
831 const TmpDir = @import("testtmp").TmpDir;
832 const testing = std.testing;
833
834 test "refuse_live refuses a path a live listener owns; clobber_own takes its own leftover" {
835 var tmp = try TmpDir.make();
836 defer tmp.cleanup();
837 var buf: [280]u8 = undefined;
838 const path = try std.fmt.bufPrint(&buf, "{s}/serve.sock", .{tmp.path()});
839
840 var first = try bind(path, .{ .policy = .refuse_live });
841 try testing.expectError(error.AddressInUse, bind(path, .{ .policy = .refuse_live }));
842
843 // Kill the listener but leave the file: the dead-us case.
844 std.posix.close(first.fd);
845 first.fd = -1;
846 var second = try bind(path, .{ .policy = .clobber_own });
847 second.close(path);
848 }
849
850 test "close unlinks our socket but never a successor's" {
851 var tmp = try TmpDir.make();
852 defer tmp.cleanup();
853 var buf: [280]u8 = undefined;
854 const path = try std.fmt.bufPrint(&buf, "{s}/succ.sock", .{tmp.path()});
855
856 var old = try bind(path, .{ .policy = .clobber_own });
857 // A successor replaces the FILE while old's listener lives on — deleting
858 // a unix socket's path does not touch the listening fd, which is the
859 // incident shape sockpath.zig:213 records: two owners, one name, and
860 // the displaced one's teardown must not delete by that name.
861 var succ = try bind(path, .{ .policy = .clobber_own });
862
863 old.close(path); // guard fires: the inode at path is succ's — no unlink
864 _ = try std.fs.cwd().statFile(path); // successor's file survived
865 succ.close(path); // ours: unlinked
866 try testing.expectError(error.FileNotFound, std.fs.cwd().statFile(path));
867 }
868 ```
869
870 (The second test's shape: prove `stillAt` is false for the displaced owner,
871 prove the successor's file survives the displaced owner's teardown, prove the
872 successor's own close does unlink. If `error.AddressInUse` is not the exact
873 error `claim` surfaces, read `sockpath.claim` and pin the error it actually
874 returns — the CONTRACT is "the second refuse_live bind fails while the first
875 listener lives".)
876
877 - [ ] **Step 2: Wire the row**
878
879 ```zig
880 .{ .name = "serve", .path = "src/serve.zig", .imports = &.{"sockpath"}, .test_imports = &.{"testtmp"} },
881 ```
882
883 Add `"serve"` to the daemon and client rows' imports and to the test-rows
884 list near build.zig:720. Run `make check` — the new tests pass.
885
886 - [ ] **Step 3: Convert the three binders, one commit each**
887
888 (a) **Daemon socket.** `Server.init` (server.zig:505–540): replace
889 `sockpath.claim` + `addr.listen` + `PathId.of` with one `serve.bind(opts.sock_path, .{ .policy = .refuse_live })`;
890 keep `self.listener: std.net.Server` by constructing it from the Bound's fd
891 (`.{ .listen_address = addr, .stream = .{ .handle = bound.fd } }`) or store
892 the Bound and re-point `acceptConn` — whichever touches fewer lines; the
893 deinit block at 815–817 becomes `self.bound.close(self.sock_path)` with the
894 moved comment. `initFromManifest` uses `serve.adopt`. Gate:
895 `E2E_ONLY=03 make e2e` plus the upgrade suite
896 (`server_test_upgrade` runs under `make test`) — the adopted-listener path
897 must still not re-claim.
898
899 (b) **Agent sockets.** `server_agent.zig:150–176` becomes
900 `serve.bind(path, .{ .policy = .clobber_own, .backlog = 8 })` (keep the
901 backlog-8 comment: the only dialler is one session's ssh clients).
902 `AgentSock` carries the `Bound`; `release` becomes `bound.close(path)` + free.
903 New test in `server_test_agent.zig`: two sessions' agent sockets (plural),
904 end one session, assert the OTHER session's socket file still exists and
905 still answers — then assert the ended one's file is gone (ask the fs, not
906 the daemon).
907
908 (c) **askpass.** `askpass.zig:153–162` becomes
909 `serve.bind(path, .{ .policy = .clobber_own, .cloexec = true })`; `retire`'s
910 unlink becomes the guarded close via the stored Bound — BUT retire
911 deliberately does not close the fd (detached pumps may be inside `declined`);
912 split: `retire` calls a new `Bound.unlinkIfOurs(path)` (add it: the guard
913 without the close, two lines, doc comment saying retire is why it exists) and
914 `stop` keeps closing the fd. New test beside the existing Listener tests: a
915 successor Listener binds the same pid-named path (simulate pid reuse by
916 spelling the same path), the old Listener's retire must not unlink the
917 successor's socket.
918
919 Run after each: `make check`. After all three: `make test | tail -30` with
920 `$?` captured, then `E2E_ONLY=07 make e2e` if a group covers askpass
921 (check `ls test/e2e_*` for the askpass/handoff group; run the one that names
922 it, else note "covered by unit suite only" in the commit body).
923
924 - [ ] **Step 4: Commits** (one per binder, plus the module)
925
926 ```bash
927 git commit -m "serve: bind and the guarded unlink get one owner"
928 git commit -m "daemon socket binds through serve"
929 git commit -m "agent sockets bind through serve, and gain the successor guard"
930 git commit -m "askpass binds through serve; retire keeps its no-close shape"
931 ```
932
933 ---
934
935 ### Task 6: harness `pumpUntil` and the await-copy sweep
936
937 **Files:**
938 - Modify: `src/server/server_test_harness.zig` (add `pumpUntil`; `awaitFrame`/`awaitFrameOn`/`firstStateFrame` become Link wrappers)
939 - Modify: `src/server/server_test_attach.zig`, `server_test_session.zig`, `server_test_modes.zig`, `server_test_clipboard.zig`, `server_test_deliver.zig`, `server_test_await.zig`, `server_test_agent.zig`, `server_test_upgrade.zig`, `server_test_quic.zig` (spin loops → `pumpUntil`; inline poll+readFrame loops → `awaitFrameOn`/sinks; private awaits deleted)
940 - Modify: `build.zig` (`daemon` row's `test_imports` gains `"link"`)
941
942 **Interfaces:**
943 - Consumes: `Link.awaitFrame` + `Sink`.
944 - Produces (test-only):
945 - `pub fn pumpUntil(srv: *Server, deadline_ms: u64, ctx: anytype, comptime pred: fn (@TypeOf(ctx)) bool) !bool`
946 - `awaitFrame`/`awaitFrameOn`/`firstStateFrame`/`awaitGridText` keep their exact current signatures (eight sibling files call them).
947
948 - [ ] **Step 1: `pumpUntil`**
949
950 ```zig
951 /// Pump the daemon until pred says the world arrived, or the deadline says
952 /// it never will. Returns whether pred fired, so a test asserts the
953 /// CONDITION and its failure names what didn't happen — not a guessed
954 /// round count. A false return is an assertable value: the deadline turns
955 /// a wedge into a legible failure instead of a silent hang (a wedged zig
956 /// test prints nothing).
957 pub fn pumpUntil(
958 srv: *Server,
959 deadline_ms: u64,
960 ctx: anytype,
961 comptime pred: fn (@TypeOf(ctx)) bool,
962 ) !bool {
963 var left = deadline_ms;
964 while (true) {
965 if (pred(ctx)) return true;
966 if (left == 0) return false;
967 try srv.pumpOnce(5);
968 left -|= 5;
969 }
970 }
971 ```
972
973 With a self-test in the harness file: a `TestDaemon`, one dial, and
974 `pumpUntil(td.srv, 2000, td.srv, hasAnyClient)` flips true; a predicate that
975 can never fire returns false in ~deadline time.
976
977 - [ ] **Step 2: The harness awaits become wrappers**
978
979 `awaitFrameOn` keeps its signature and becomes
980 `var l: link_mod.Link = .{ .fd = fd }; defer l.* = .{ .fd = -1 };` — do NOT
981 `close` (the caller owns the fd) — then `l.awaitFrame(alloc, want, timeout, .{})`
982 mapping `error.Closed` to the function's current null/erroring behavior
983 (read its body first and preserve it exactly; the eight siblings' green run
984 is the proof). `firstStateFrame` uses a sink that decodes
985 `readSnapshotPrefix`/`readDeltaHeader` into its `StateFrame` — copying the
986 ints, per the Sink borrow rule. `awaitFrame` (the byte-scanning one at 264 —
987 read it; it may scan a buffer, not an fd) is converted only if it is
988 genuinely the same loop; if it scans captured bytes, it stays and the plan's
989 count table loses those lines — note it in the commit.
990
991 - [ ] **Step 3: The sweep, one test file per commit**
992
993 For each file in the Files list: replace `while (spun < N …) pumpOnce`
994 loops with `try expect(try h.pumpUntil(td.srv, <N*step rounded up to a wall
995 figure>, ctx, pred))`; replace inline poll+readFrame switch loops with
996 `h.awaitFrameOn` + a sink where the loop classified frames (the
997 history-counting loop at server_test_attach.zig:61–78 becomes a sink
998 capturing `history_rows`); delete `awaitSelectionReply` (clipboard:17),
999 `awaitSnapshotSize` (attach:400), `awaitMarkerWithoutSnapshot` (attach:478)
1000 in favor of sinks at their call sites. EVERY replacement must watch its
1001 assertion fire once: before committing each file, break the condition (flip
1002 an expected value), run that file's test, see the new failure message name
1003 the condition, restore. Run `make test | tail -30` (capture `$?`) after each
1004 file.
1005
1006 - [ ] **Step 4: Commit per file, then the gate**
1007
1008 ```bash
1009 git commit -m "harness: pumpUntil and Link-backed awaits"
1010 git commit -m "server_test_attach rides the harness awaits" # ...and so on per file
1011 ```
1012
1013 Final: `grep -rn "spun < \|rounds < " src/server/` prints nothing;
1014 `grep -rln "posix.poll" src/server/server_test_*.zig` shrinks to files whose
1015 polls are not frame-awaits (inspect each survivor and say why in the commit).
1016
1017 ---
1018
1019 ### Task 7: Delivery gate
1020
1021 **Files:**
1022 - Modify: `docs/superpowers/specs/2026-08-31-connection-primitives-design.md` (replace the estimate table with the real diffstat)
1023 - Modify: `CLAUDE.md` (the layout table: `link` and `serve` join the shared row's list)
1024
1025 **Interfaces:** none new.
1026
1027 - [ ] **Step 1: Success-criteria greps from the spec**
1028
1029 Run each; every miss is unfinished work, not a tolerable gap:
1030 - `grep -rln "posix.poll" src/ --include="*.zig" | xargs grep -ln readFrame` → only `src/link.zig` (plus the wall's event loop files; name them in the delivery note).
1031 - `grep -rn "union(enum)" src/cli/muxa.zig src/client/client.zig | grep -i "fd\|quic"` → no second link union.
1032 - `grep -rn "addr.listen\|posix.listen" src/server/server.zig src/server/server_agent.zig src/client/askpass.zig` → nothing (all through serve).
1033 - `grep -rn "while (spun" src/server/` → nothing.
1034 - `grep -rn "@import(\"link\")\|@import(\"term\")" src/proxy.zig src/quic.zig` → nothing.
1035
1036 - [ ] **Step 2: The full gate**
1037
1038 ```bash
1039 make ci; echo "ci=$?"
1040 make xversion-build && make xversion; echo "xver=$?"
1041 ```
1042
1043 Both 0. The xversion old side is `../mux-xver-old`; build its prefix first
1044 if stale. No wire change means xversion is a formality — but run it, because
1045 "should be a formality" is an assumption and the gate is the assertion.
1046
1047 - [ ] **Step 3: Re-measure and record**
1048
1049 `git diff --stat <base>..HEAD` and `wc -l src/*.zig src/*/*.zig | tail -1`;
1050 replace the spec's estimate table with the real numbers. Update CLAUDE.md's
1051 module table row for `src/`: add `link` and `serve` to the shared list with
1052 one clause each.
1053
1054 - [ ] **Step 4: Autosquash and finish**
1055
1056 ```bash
1057 git rebase -i --autosquash <base> # NOTE: -i is unsupported in this harness —
1058 # use: GIT_SEQUENCE_EDITOR=true git rebase --autosquash <base>
1059 ```
1060
1061 Only if no sibling agent holds the old tip (the no-history-rewrite rule).
1062 Then a demo: `try.sh`-style script that starts an isolated daemon
1063 (`XDG_STATE_HOME`/`XDG_RUNTIME_DIR` exported to a scratch dir), runs
1064 `mux d endpoint`, one `mux a status`, and a `mux d stop` — the primitives
1065 exercised end to end by the real binary.
1066
1067 ```bash
1068 git commit -m "docs: link and serve join the table; spec table becomes the diffstat"
1069 ```