From 4fee284d177cbd1138e5c19d3073b9989265df7a Mon Sep 17 00:00:00 2001 From: yxlyx <85774423+yxlyx@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:50:51 +0800 Subject: [PATCH 01/27] fix(readline): make pasted text placeholders atomic Track collapsed paste labels as positional semantic spans instead of re-associating hidden bodies through display-string matches. Live spans now render with attachment highlighting, move as one cursor/word unit, detach on edits, and are consumed on expansion so typed lookalikes cannot resurrect or duplicate removed content. Split history replay into a focused sibling module so replayed draft labels remain ordinary text and readline stays within the source line ceiling. Closes #673 Co-Authored-By: Codegraff --- src/input_util.zig | 24 +-- src/readline.zig | 206 ++++++++++++------------ src/readline_paste.zig | 338 ++++++++++++++++++++++++++++++++++++++++ src/readline_replay.zig | 28 ++++ 4 files changed, 482 insertions(+), 114 deletions(-) create mode 100644 src/readline_paste.zig create mode 100644 src/readline_replay.zig diff --git a/src/input_util.zig b/src/input_util.zig index c71898df..d812912e 100644 --- a/src/input_util.zig +++ b/src/input_util.zig @@ -23,6 +23,7 @@ const runCapped = jobs.runCapped; const pricing = @import("pricing.zig"); const util = @import("util.zig"); +const readline_paste = @import("readline_paste.zig"); const main_mod = @import("main.zig"); const provider_mod = @import("provider.zig"); @@ -303,12 +304,12 @@ pub fn cleanDroppedPath(gpa: Allocator, home: []const u8, pasted: []const u8) ?[ // Redraw the whole input below a fixed prompt prefix, wrapping it // across rows. Spans listed in `marks` (paths from the @ picker or a -// file drop, plus "[Image]") render as an accent chip (reverse video + -// Codegraff emerald) so they keep reading as attached files, not typed words; a -// chip crossing a row break keeps its colour. `st` carries the row -// count + cursor row of the previous draw so this one can clear it -// with relative moves only — no DECSC anchor for a scroll to strand. -pub fn redraw(o: *Io.Writer, items: []const u8, c: usize, marks: []const []const u8, st: *LineRender, pcol: usize) void { +// file drop, plus "[Image]") and live semantic paste spans render as accent +// chips (reverse video + Codegraff emerald), so attachments do not look like +// typed words. A chip crossing a row break keeps its colour. `st` carries the +// row count + cursor row of the previous draw so this one can clear it with +// relative moves only — no DECSC anchor for a scroll to strand. +pub fn redraw(o: *Io.Writer, items: []const u8, c: usize, marks: []const []const u8, pastes: ?*const readline_paste.Store, st: *LineRender, pcol: usize) void { const cols = termCols(); const plen = if (pcol > 0) pcol - 1 else 0; // columns the prompt holds on row 0 @@ -357,18 +358,21 @@ pub fn redraw(o: *Io.Writer, items: []const u8, c: usize, marks: []const []const vcol = 0; } if (!mark_open) { // open a chip that starts here (longest wins) - var best: usize = 0; + var best_end: usize = i; for (marks) |m| { if (m.len == 0 or i + m.len > items.len) continue; - if (std.mem.eql(u8, items[i .. i + m.len], m) and m.len > best) best = m.len; + if (std.mem.eql(u8, items[i .. i + m.len], m)) best_end = @max(best_end, i + m.len); } - if (best > 0) { + if (pastes) |store| { + if (store.highlightEndAt(items, i)) |end| best_end = @max(best_end, end); + } + if (best_end > i) { if (shine_active) { o.writeAll("\x1b[0m") catch {}; shine_active = false; } o.writeAll(if (main_mod.use_color) "\x1b[7;38;2;5;150;105m" else "\x1b[7m") catch {}; - mark_end = i + best; + mark_end = best_end; mark_open = true; } } diff --git a/src/readline.zig b/src/readline.zig index 838f6887..37019316 100644 --- a/src/readline.zig +++ b/src/readline.zig @@ -40,13 +40,9 @@ const collectRepoFiles = input_util.collectRepoFiles; const isImagePath = input_util.isImagePath; const redraw = input_util.redraw; const editByte = input_util.editByte; // #396: job-control-aware continuation reads -const setLine = input_util.setLine; const delRange = input_util.delRange; -const prevWord = input_util.prevWord; -const nextWord = input_util.nextWord; const addMark = input_util.addMark; const insertImageChip = input_util.insertImageChip; -const markImageChips = input_util.markImageChips; const util = @import("util.zig"); const main_mod = @import("main.zig"); @@ -57,6 +53,8 @@ const saveSession = session.saveSession; const shutdown_trace = @import("shutdown_trace.zig"); // #364: the quit path's first phase stamp const rl_history = @import("readline_history.zig"); const HistoryNav = rl_history.HistoryNav; +const PasteStore = @import("readline_paste.zig").Store; +const replayStep = @import("readline_replay.zig").apply; /// Read one input line with a tiny raw-mode editor: ↑/↓ walk history, /// Tab completes/cycles (models, providers, slash commands), backspace edits, @@ -156,18 +154,10 @@ pub fn readLine( var comp_idx: usize = 0; var comp_active = false; - // Bracketed-paste collapse: a multi-line paste becomes a "[Pasted text #N - // +L lines]" placeholder in the buffer; on submit each placeholder is - // expanded back to its full text. - const Paste = struct { ph: []const u8, body: []const u8 }; - var pastes: std.ArrayList(Paste) = .empty; - defer { - for (pastes.items) |p| { - gpa.free(p.ph); - gpa.free(p.body); - } - pastes.deinit(gpa); - } + // Long pastes are semantic spans: their labels render as attachment chips, + // move atomically, and cannot be recreated by typing the same display text. + var pastes: PasteStore = .{}; + defer pastes.deinit(gpa); // File paths inserted by the @ picker or a drag-and-drop (plus the // "[Image]" attachment marker): redraw renders these spans highlighted. @@ -192,7 +182,7 @@ pub fn readLine( while (main_mod.use_color and util.indexOfIgnoreCase(buf.items, "ultracode") != null) { if (inputPendingTimed(140)) break; // keystroke ready — read it below input_util.g_shine_phase +%= 1; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); } break :blk switch (tty.promptByte(in)) { .byte => |b| b, @@ -221,10 +211,12 @@ pub fn readLine( } else if (comp_items.items.len > 0) { comp_idx = (comp_idx + 1) % comp_items.items.len; } else continue; + const old_len = buf.items.len; buf.shrinkRetainingCapacity(comp_base); buf.appendSlice(gpa, comp_items.items[comp_idx]) catch {}; + pastes.edited(gpa, comp_base, old_len, buf.items.len - comp_base); cur = buf.items.len; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, '\r', '\n' => { // The cursor may be mid-block; step past the last input row so @@ -235,50 +227,45 @@ pub fn readLine( if (rstate.rows - 1 > rstate.crow) out.print("\x1b[{d}B", .{rstate.rows - 1 - rstate.crow}) catch {}; out.writeAll("\r\n\r\n") catch {}; out.flush() catch {}; - // Expand any pasted placeholders back to their full text. - for (pastes.items) |p| { - if (std.mem.indexOf(u8, buf.items, p.ph) == null) continue; - const sz = std.mem.replacementSize(u8, buf.items, p.ph, p.body); - const tmp = gpa.alloc(u8, sz) catch break; - _ = std.mem.replace(u8, buf.items, p.ph, p.body, tmp); - buf.clearRetainingCapacity(); - buf.appendSlice(gpa, tmp) catch {}; - gpa.free(tmp); - } + // Expand live semantic paste spans; typed lookalikes stay text. + try pastes.expand(gpa, buf); break; }, 0x01 => { // Ctrl-A → start of line cur = 0; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 0x05 => { // Ctrl-E → end of line cur = buf.items.len; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 0x02 => if (cur > 0) { // Ctrl-B → left - cur -= 1; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + cur = pastes.left(cur); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 0x06 => if (cur < buf.items.len) { // Ctrl-F → right - cur += 1; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + cur = pastes.right(cur, buf.items.len); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 0x17, 0x1f => { // Ctrl-W / Ctrl-_ → delete previous word - const s = prevWord(buf.items, cur); + const s = pastes.prevWord(buf.items, cur); if (s < cur) { + pastes.edited(gpa, s, cur, 0); delRange(buf, s, cur); cur = s; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); } }, 0x15 => if (cur > 0) { // Ctrl-U → delete to start of line + pastes.edited(gpa, 0, cur, 0); delRange(buf, 0, cur); cur = 0; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 0x0b => if (cur < buf.items.len) { // Ctrl-K → delete to end of line + pastes.edited(gpa, cur, buf.items.len, 0); buf.shrinkRetainingCapacity(cur); - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 0x16 => { // Ctrl-V: attach a clipboard image (macOS) at the cursor var mbuf: [224]u8 = undefined; @@ -290,8 +277,10 @@ pub fn readLine( vision.tracePasteResult(root, grab.flavor, staged); // #350: every paste leaves a receipt if (staged.isOk()) { @import("vision_queue.zig").markLastComposer(root); + const at = cur; insertImageChip(gpa, buf, &cur, &marks, root.pending_image_len); - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + if (cur > at) pastes.edited(gpa, at, at, cur - at); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); } else { // No `.no_vision` arm here: clipboardPasteSource // already answers .no_vision above, so on a @@ -310,13 +299,15 @@ pub fn readLine( out.print("\r\n{s}· {s}{s}", .{ style.dim, m, style.reset }) catch {}; root.prompt() catch {}; rstate = .{}; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); } }, - 0x7f, 0x08 => if (cur > 0) { // backspace → delete char before cursor - delRange(buf, cur - 1, cur); - cur -= 1; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + 0x7f, 0x08 => if (cur > 0) { // backspace → delete previous atom + const start = pastes.left(cur); + pastes.edited(gpa, start, cur, 0); + delRange(buf, start, cur); + cur = start; + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 0x03 => { // Ctrl-C: clear a non-empty line; on an empty line, quit if (buf.items.len == 0) { @@ -325,8 +316,9 @@ pub fn readLine( return null; } buf.clearRetainingCapacity(); + pastes.clear(gpa); cur = 0; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 0x1a => { // Ctrl-Z: save the session and quit (like a safe Ctrl-D) out.writeAll("^Z — saving & quit\n") catch {}; @@ -341,8 +333,10 @@ pub fn readLine( return null; } if (cur < buf.items.len) { - delRange(buf, cur, cur + 1); - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + const end = pastes.right(cur, buf.items.len); + pastes.edited(gpa, cur, end, 0); + delRange(buf, cur, end); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); } }, 0x1b => { // escape sequence: arrows, Alt/Option chords, CSI @@ -351,35 +345,38 @@ pub fn readLine( // the next keypress. if (tty.pendingBytes() == 0 and in.buffered().len == 0 and !inputPending()) { buf.clearRetainingCapacity(); + pastes.clear(gpa); cur = 0; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); continue; } const b1 = editByte(in) orelse break; // #396: guarded if (b1 == 0x7f or b1 == 0x08) { // Option/Alt+Delete → delete previous word - const s = prevWord(buf.items, cur); + const s = pastes.prevWord(buf.items, cur); if (s < cur) { + pastes.edited(gpa, s, cur, 0); delRange(buf, s, cur); cur = s; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); } continue; } if (b1 == 'b') { // Alt-b → word left - cur = prevWord(buf.items, cur); - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + cur = pastes.prevWord(buf.items, cur); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); continue; } if (b1 == 'f') { // Alt-f → word right - cur = nextWord(buf.items, cur); - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + cur = pastes.nextWord(buf.items, cur); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); continue; } if (b1 == 'd') { // Alt-d → delete next word - const e = nextWord(buf.items, cur); + const e = pastes.nextWord(buf.items, cur); if (e > cur) { + pastes.edited(gpa, cur, e, 0); delRange(buf, cur, e); - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); } continue; } @@ -403,28 +400,28 @@ pub fn readLine( const word_mod = std.mem.indexOfScalar(u8, ps, ';') != null; // 1;3 (alt) / 1;5 (ctrl) switch (final) { 'A' => if (nav.up(gpa, history.items, rl_history.g_history_images.slice(), buf.items, root.pending_image)) |step| { // up → history back; snapshots draft (#101) - replayStep(root, gpa, buf, &cur, &marks, step); - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + replayStep(root, gpa, buf, &cur, &marks, &pastes, step); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 'B' => if (nav.down(history.items, rl_history.g_history_images.slice())) |step| { // down → history forward; restores draft past newest (#101) - replayStep(root, gpa, buf, &cur, &marks, step); - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + replayStep(root, gpa, buf, &cur, &marks, &pastes, step); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 'C' => { // right (word-right with a modifier) - cur = if (word_mod) nextWord(buf.items, cur) else @min(cur + 1, buf.items.len); - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + cur = if (word_mod) pastes.nextWord(buf.items, cur) else pastes.right(cur, buf.items.len); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 'D' => { // left (word-left with a modifier) - cur = if (word_mod) prevWord(buf.items, cur) else (if (cur > 0) cur - 1 else 0); - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + cur = if (word_mod) pastes.prevWord(buf.items, cur) else pastes.left(cur); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 'H' => { cur = 0; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 'F' => { cur = buf.items.len; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 'R' => { // late DSR cursor-position reply (slow or // multiplexed terminal missed the 20ms startup @@ -442,7 +439,7 @@ pub fn readLine( } else prompt_col = col; } } - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, '~' => { if (std.mem.eql(u8, ps, "200")) { // bracketed paste start @@ -487,11 +484,18 @@ pub fn readLine( } if (staged) { @import("vision_queue.zig").markLastComposer(root); + const at = cur; insertImageChip(gpa, buf, &cur, &marks, root.pending_image_len); + if (cur > at) pastes.edited(gpa, at, at, cur - at); } else { + const at = cur; + const old_len = buf.items.len; buf.insertSlice(gpa, cur, dropped.?) catch {}; - cur += dropped.?.len; - addMark(gpa, &marks, dropped.?); + if (buf.items.len == old_len + dropped.?.len) { + pastes.edited(gpa, at, at, dropped.?.len); + cur += dropped.?.len; + addMark(gpa, &marks, dropped.?); + } } if (dmsg) |m| { // feedback below the input, then redraw fresh (below) if (rstate.rows - 1 > rstate.crow) out.print("\x1b[{d}B", .{rstate.rows - 1 - rstate.crow}) catch {}; @@ -500,29 +504,30 @@ pub fn readLine( rstate = .{}; } } else if (lines == 1 and pasted.len <= 80) { + const at = cur; + const old_len = buf.items.len; buf.insertSlice(gpa, cur, pasted) catch {}; // short single-line paste: inline - cur += pasted.len; - } else { // multi-line/long: collapse to a placeholder, expand on submit - const ph = std.fmt.allocPrint(gpa, "[Pasted text #{d} +{d} lines]", .{ pastes.items.len + 1, lines }) catch ""; - const body = gpa.dupe(u8, pasted) catch ""; - if (ph.len > 0 and body.len > 0) { - pastes.append(gpa, .{ .ph = ph, .body = body }) catch {}; - buf.insertSlice(gpa, cur, ph) catch {}; - cur += ph.len; + if (buf.items.len == old_len + pasted.len) { + pastes.edited(gpa, at, at, pasted.len); + cur += pasted.len; } + } else { // multi-line/long: semantic chip, expanded on submit + pastes.insert(gpa, buf, &cur, pasted, lines) catch {}; } - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); } else if (std.mem.eql(u8, ps, "3")) { // forward delete if (cur < buf.items.len) { - delRange(buf, cur, cur + 1); - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + const end = pastes.right(cur, buf.items.len); + pastes.edited(gpa, cur, end, 0); + delRange(buf, cur, end); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); } } else if (std.mem.eql(u8, ps, "1") or std.mem.eql(u8, ps, "7")) { cur = 0; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); } else if (std.mem.eql(u8, ps, "4") or std.mem.eql(u8, ps, "8")) { cur = buf.items.len; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); } }, else => {}, @@ -549,17 +554,27 @@ pub fn readLine( // screen and cursor on exit, so the input block and // rstate still match — just redraw over them below. if (picked) |idx| { + const at = cur; + const old_len = buf.items.len; buf.insertSlice(gpa, cur, files.items[idx]) catch {}; - cur += files.items[idx].len; - addMark(gpa, &marks, files.items[idx]); + if (buf.items.len == old_len + files.items[idx].len) { + pastes.edited(gpa, at, at, files.items[idx].len); + cur += files.items[idx].len; + addMark(gpa, &marks, files.items[idx]); + } } - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); continue; } } + const at = cur; + const old_len = buf.items.len; buf.insert(gpa, cur, c) catch {}; - cur += 1; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + if (buf.items.len == old_len + 1) { + pastes.edited(gpa, at, at, 1); + cur += 1; + } + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, } } @@ -578,20 +593,3 @@ pub fn readLine( } return buf.items; } - -/// Apply one history step to the editor: text into the buffer, and the entry's -/// attachment back onto the agent so resending sends the image and not just the -/// literal "[Image]" marker (#108). A text-only entry clears whatever was staged. -fn replayStep( - root: *Agent, - gpa: Allocator, - buf: *std.ArrayList(u8), - cur: *usize, - marks: *std.ArrayList([]const u8), - step: rl_history.Step, -) void { - setLine(gpa, buf, step.text); - cur.* = buf.items.len; - root.pending_image = step.image; - if (step.image != null) markImageChips(gpa, marks, buf.items); -} diff --git a/src/readline_paste.zig b/src/readline_paste.zig new file mode 100644 index 00000000..9206d512 --- /dev/null +++ b/src/readline_paste.zig @@ -0,0 +1,338 @@ +//! Semantic long-paste spans for the raw readline composer (#673). +//! +//! The visible `[Pasted text …]` label is presentation, not identity. Each live +//! span owns its hidden body and tracks its byte range as surrounding text is +//! edited. An edit touching the range detaches it, so retyping the same label +//! cannot resurrect a removed paste. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const Entry = struct { + start: usize, + end: usize, + label: []u8, + body: []u8, +}; + +pub const Store = struct { + entries: std.ArrayList(Entry) = .empty, + next_id: usize = 1, + + pub fn deinit(self: *Store, gpa: Allocator) void { + self.clear(gpa); + self.entries.deinit(gpa); + } + + pub fn clear(self: *Store, gpa: Allocator) void { + for (self.entries.items) |entry| freeEntry(gpa, entry); + self.entries.clearRetainingCapacity(); + } + + pub fn count(self: *const Store) usize { + return self.entries.items.len; + } + + /// Insert one collapsed long paste at the cursor. Existing spans move with + /// the surrounding text; the new span is inserted in positional order. + pub fn insert( + self: *Store, + gpa: Allocator, + buf: *std.ArrayList(u8), + cursor: *usize, + body: []const u8, + lines: usize, + ) !void { + const label = try std.fmt.allocPrint(gpa, "[Pasted text #{d} +{d} lines]", .{ self.next_id, lines }); + errdefer gpa.free(label); + const owned_body = try gpa.dupe(u8, body); + errdefer gpa.free(owned_body); + try self.entries.ensureUnusedCapacity(gpa, 1); + try buf.insertSlice(gpa, cursor.*, label); + self.edited(gpa, cursor.*, cursor.*, label.len); + self.entries.appendAssumeCapacity(.{ + .start = cursor.*, + .end = cursor.* + label.len, + .label = label, + .body = owned_body, + }); + std.mem.sort(Entry, self.entries.items, {}, lessThan); + self.next_id += 1; + cursor.* += label.len; + } + + /// Update semantic ranges after replacing `[from,to)` with `inserted_len` + /// bytes. Touching any part of a span detaches it; edits at either boundary + /// remain ordinary surrounding edits. + pub fn edited(self: *Store, gpa: Allocator, from: usize, to: usize, inserted_len: usize) void { + std.debug.assert(from <= to); + const removed_len = to - from; + var i: usize = 0; + while (i < self.entries.items.len) { + const entry = &self.entries.items[i]; + if (to <= entry.start) { + shift(entry, removed_len, inserted_len); + i += 1; + } else if (from >= entry.end) { + i += 1; + } else { + freeEntry(gpa, self.entries.orderedRemove(i)); + } + } + } + + /// Replace every live semantic span with its body, then consume the entries. + /// Lookalike text without an entry is copied verbatim, and a stale/corrupt + /// range is never expanded. + pub fn expand(self: *Store, gpa: Allocator, buf: *std.ArrayList(u8)) !void { + if (self.entries.items.len == 0) return; + var out: std.ArrayList(u8) = .empty; + defer out.deinit(gpa); + var source: usize = 0; + var expanded = false; + for (self.entries.items) |entry| { + if (entry.start < source or entry.end > buf.items.len) continue; + if (!std.mem.eql(u8, buf.items[entry.start..entry.end], entry.label)) continue; + try out.appendSlice(gpa, buf.items[source..entry.start]); + try out.appendSlice(gpa, entry.body); + source = entry.end; + expanded = true; + } + if (!expanded) return; + try out.appendSlice(gpa, buf.items[source..]); + try buf.ensureTotalCapacity(gpa, out.items.len); + buf.clearRetainingCapacity(); + try buf.appendSlice(gpa, out.items); + self.clear(gpa); + } + + /// End byte for the live span beginning at `at`, used by readline's chip + /// renderer. Position plus label validation prevents a typed lookalike at a + /// different location from receiving attachment styling. + pub fn highlightEndAt(self: *const Store, items: []const u8, at: usize) ?usize { + for (self.entries.items) |entry| { + if (entry.start != at or entry.end > items.len) continue; + if (std.mem.eql(u8, items[entry.start..entry.end], entry.label)) return entry.end; + } + return null; + } + + pub fn left(self: *const Store, cursor: usize) usize { + for (self.entries.items) |entry| { + if (cursor > entry.start and cursor <= entry.end) return entry.start; + } + return cursor -| 1; + } + + pub fn right(self: *const Store, cursor: usize, len: usize) usize { + for (self.entries.items) |entry| { + if (cursor >= entry.start and cursor < entry.end) return entry.end; + } + return @min(cursor + 1, len); + } + + pub fn prevWord(self: *const Store, items: []const u8, cursor: usize) usize { + for (self.entries.items) |entry| { + if (cursor > entry.start and cursor <= entry.end) return entry.start; + } + const plain = plainPrevWord(items, cursor); + for (self.entries.items) |entry| { + if (plain > entry.start and plain < entry.end) return entry.start; + } + return plain; + } + + pub fn nextWord(self: *const Store, items: []const u8, cursor: usize) usize { + for (self.entries.items) |entry| { + if (cursor >= entry.start and cursor < entry.end) return entry.end; + } + const plain = plainNextWord(items, cursor); + for (self.entries.items) |entry| { + if (plain > entry.start and plain < entry.end) return entry.end; + } + return plain; + } +}; + +fn lessThan(_: void, a: Entry, b: Entry) bool { + return a.start < b.start; +} + +fn freeEntry(gpa: Allocator, entry: Entry) void { + gpa.free(entry.label); + gpa.free(entry.body); +} + +fn shift(entry: *Entry, removed_len: usize, inserted_len: usize) void { + if (inserted_len >= removed_len) { + const delta = inserted_len - removed_len; + entry.start += delta; + entry.end += delta; + } else { + const delta = removed_len - inserted_len; + entry.start -= delta; + entry.end -= delta; + } +} + +fn plainPrevWord(items: []const u8, cursor: usize) usize { + var i = cursor; + while (i > 0 and items[i - 1] == ' ') i -= 1; + while (i > 0 and items[i - 1] != ' ') i -= 1; + return i; +} + +fn plainNextWord(items: []const u8, cursor: usize) usize { + var i = cursor; + while (i < items.len and items[i] == ' ') i += 1; + while (i < items.len and items[i] != ' ') i += 1; + return i; +} + +fn deleteRange(buf: *std.ArrayList(u8), from: usize, to: usize) void { + std.mem.copyForwards(u8, buf.items[from..], buf.items[to..]); + buf.shrinkRetainingCapacity(buf.items.len - (to - from)); +} + +const testing = std.testing; + +test "paste span is highlighted and navigated as one unit" { + var store: Store = .{}; + defer store.deinit(testing.allocator); + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(testing.allocator); + try buf.appendSlice(testing.allocator, "before after"); + var cursor: usize = 7; + try store.insert(testing.allocator, &buf, &cursor, "one\ntwo", 2); + const start: usize = 7; + const end = cursor; + + try testing.expectEqual(end, store.highlightEndAt(buf.items, start).?); + try testing.expectEqual(start, store.left(end)); + try testing.expectEqual(end, store.right(start, buf.items.len)); + try testing.expectEqual(start, store.prevWord(buf.items, end)); + try testing.expectEqual(end, store.nextWord(buf.items, start)); +} + +test "deleting a span prevents a typed lookalike from resurrecting its body" { + var store: Store = .{}; + defer store.deinit(testing.allocator); + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(testing.allocator); + var cursor: usize = 0; + try store.insert(testing.allocator, &buf, &cursor, "secret\nbody", 2); + const label = try testing.allocator.dupe(u8, buf.items); + defer testing.allocator.free(label); + + store.edited(testing.allocator, 0, cursor, 0); + deleteRange(&buf, 0, cursor); + try buf.appendSlice(testing.allocator, label); + store.edited(testing.allocator, 0, 0, label.len); + try store.expand(testing.allocator, &buf); + + try testing.expectEqual(@as(usize, 0), store.count()); + try testing.expectEqualStrings(label, buf.items); + try testing.expect(store.highlightEndAt(buf.items, 0) == null); +} + +test "two paste spans expand independently exactly once" { + var store: Store = .{}; + defer store.deinit(testing.allocator); + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(testing.allocator); + var cursor: usize = 0; + try store.insert(testing.allocator, &buf, &cursor, "alpha\nbeta", 2); + try buf.insertSlice(testing.allocator, cursor, " + "); + store.edited(testing.allocator, cursor, cursor, 3); + cursor += 3; + try store.insert(testing.allocator, &buf, &cursor, "gamma\ndelta", 2); + try store.expand(testing.allocator, &buf); + + try testing.expectEqualStrings("alpha\nbeta + gamma\ndelta", buf.items); +} + +test "removing one paste keeps and shifts the other" { + var store: Store = .{}; + defer store.deinit(testing.allocator); + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(testing.allocator); + var cursor: usize = 0; + try store.insert(testing.allocator, &buf, &cursor, "first\nbody", 2); + const first_end = cursor; + try buf.insertSlice(testing.allocator, cursor, " "); + store.edited(testing.allocator, cursor, cursor, 1); + cursor += 1; + try store.insert(testing.allocator, &buf, &cursor, "second\nbody", 2); + + store.edited(testing.allocator, 0, first_end, 0); + deleteRange(&buf, 0, first_end); + try store.expand(testing.allocator, &buf); + + try testing.expectEqual(@as(usize, 0), store.count()); + try testing.expectEqualStrings(" second\nbody", buf.items); +} + +test "surrounding insertions shift a span without detaching it" { + var store: Store = .{}; + defer store.deinit(testing.allocator); + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(testing.allocator); + var cursor: usize = 0; + try store.insert(testing.allocator, &buf, &cursor, "kept\nbody", 2); + try buf.insertSlice(testing.allocator, 0, "prefix "); + store.edited(testing.allocator, 0, 0, 7); + try buf.appendSlice(testing.allocator, " suffix"); + store.edited(testing.allocator, buf.items.len - 7, buf.items.len - 7, 7); + try store.expand(testing.allocator, &buf); + + try testing.expectEqualStrings("prefix kept\nbody suffix", buf.items); +} + +test "typed duplicate beside a live span stays literal and unhighlighted" { + var store: Store = .{}; + defer store.deinit(testing.allocator); + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(testing.allocator); + var cursor: usize = 0; + try store.insert(testing.allocator, &buf, &cursor, "real\nbody", 2); + const label = try testing.allocator.dupe(u8, buf.items); + defer testing.allocator.free(label); + try buf.append(testing.allocator, ' '); + try buf.appendSlice(testing.allocator, label); + store.edited(testing.allocator, cursor, cursor, label.len + 1); + + try testing.expect(store.highlightEndAt(buf.items, cursor + 1) == null); + try store.expand(testing.allocator, &buf); + try testing.expectEqualStrings("real\nbody [Pasted text #1 +2 lines]", buf.items); +} + +test "paste ids are not reused after an attachment is removed" { + var store: Store = .{}; + defer store.deinit(testing.allocator); + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(testing.allocator); + var cursor: usize = 0; + try store.insert(testing.allocator, &buf, &cursor, "first\nbody", 2); + store.edited(testing.allocator, 0, cursor, 0); + deleteRange(&buf, 0, cursor); + cursor = 0; + try store.insert(testing.allocator, &buf, &cursor, "second\nbody", 2); + + try testing.expectEqualStrings("[Pasted text #2 +2 lines]", buf.items); +} + +test "expansion consumes identity even when the body begins with its label" { + var store: Store = .{}; + defer store.deinit(testing.allocator); + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(testing.allocator); + var cursor: usize = 0; + const body = "[Pasted text #1 +2 lines]\nX"; + try store.insert(testing.allocator, &buf, &cursor, body, 2); + + try store.expand(testing.allocator, &buf); + try store.expand(testing.allocator, &buf); + + try testing.expectEqual(@as(usize, 0), store.count()); + try testing.expectEqualStrings(body, buf.items); +} diff --git a/src/readline_replay.zig b/src/readline_replay.zig new file mode 100644 index 00000000..abfce227 --- /dev/null +++ b/src/readline_replay.zig @@ -0,0 +1,28 @@ +//! History-step replay for the raw readline composer. +//! +//! Replacing the draft invalidates semantic paste spans: history stores their +//! display labels, not their hidden bodies, so a replayed lookalike must remain +//! ordinary text rather than resurrecting an attachment (#673). + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Agent = @import("agent.zig").Agent; +const input_util = @import("input_util.zig"); +const PasteStore = @import("readline_paste.zig").Store; +const Step = @import("readline_history.zig").Step; + +pub fn apply( + root: *Agent, + gpa: Allocator, + buf: *std.ArrayList(u8), + cursor: *usize, + marks: *std.ArrayList([]const u8), + pastes: *PasteStore, + step: Step, +) void { + pastes.clear(gpa); + input_util.setLine(gpa, buf, step.text); + cursor.* = buf.items.len; + root.pending_image = step.image; + if (step.image != null) input_util.markImageChips(gpa, marks, buf.items); +} From 4087b95f3ca6e1964ff350f96df8fcace0b5845d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 18:01:13 +0000 Subject: [PATCH 02/27] Reuse warmed TLS on MCP HTTP and WSS reconnects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modern tools/list probe and legacy initialized notify each built a throwaway HTTP client, so a successful probe's keep-alive died before the next call. WSS rescanned the host CA store on every dial. Probe and initialized now use the persistent transport; WSS shares a process-lifetime CA bundle warmed once. WS→SSE stays on the prewarmed Agent pool. Loopback test: connect + next list is one TCP accept. --- ...0043-reuse-warmed-tls-on-known-networks.md | 36 +++++ docs/adr/README.md | 1 + src/agent_stream.zig | 8 +- src/http_warm.zig | 36 ++++- src/mcp_lifecycle.zig | 31 ++-- src/mcp_rpc.zig | 34 +---- src/net_efficiency_test.zig | 132 ++++++++++++++++++ src/test_hooks.zig | 1 + src/ws.zig | 24 ++-- 9 files changed, 235 insertions(+), 68 deletions(-) create mode 100644 docs/adr/0043-reuse-warmed-tls-on-known-networks.md create mode 100644 src/net_efficiency_test.zig diff --git a/docs/adr/0043-reuse-warmed-tls-on-known-networks.md b/docs/adr/0043-reuse-warmed-tls-on-known-networks.md new file mode 100644 index 00000000..b7835aea --- /dev/null +++ b/docs/adr/0043-reuse-warmed-tls-on-known-networks.md @@ -0,0 +1,36 @@ +# 0043. Reuse warmed TLS state on the networks we already speak + +Status: accepted 2026-08-29 + +## Context + +The harness already talks to three networks: provider HTTP/SSE (and WS), +MCP Streamable HTTP, and the Codegraff gateway. Two patterns were wasting +a handshake on every first useful call: + +1. MCP Auto's modern `tools/list` probe and the legacy + `notifications/initialized` notify each built a throwaway `std.http.Client`, + so a successful probe's keep-alive died before `tools/call`, and initialized + paid a second TLS for a fire-and-forget 202. +2. Every WSS dial (`ws.WsClient.connect`) rescanned the host CA store from + disk, even though launch already warms the HTTP client's bundle. + +ADR 0002 (xAI WS full-resend), 0009/0011/0028 (prompt-cache keys), and 0035 +(deferred MCP join) stay untouched: this is transport reuse, not wire shape. + +## Decision + +- MCP HTTP probe and `notifications/initialized` use `server.transport.http`. + Do not construct a per-call client for those paths. +- WSS TLS uses a process-lifetime CA bundle warmed once (`http_warm.ensureProcessCa`). + A reconnect must not walk the host store again. +- WS→SSE fallback keeps the Agent's prewarmed HTTP pool (`postStream`). Do not + introduce a fresh client on that latch: WS never used the HTTP pool, and a + new TLS would be strictly more expensive. + +## Consequences + +A modern MCP connect plus the next list is one TCP accept on keep-alive. +WSS reconnects skip the CA disk walk. Revisit only if a shared `std.http.Client` +is shown unsafe for the concurrent initialized+list pair, or if a host CA +rotation must be picked up mid-process without restart. diff --git a/docs/adr/README.md b/docs/adr/README.md index e76f1c51..d5bfacfa 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -52,6 +52,7 @@ record only when you need the evidence or the edge cases. | [0039](0039-local-tools-are-project-scripts.md) | Agent-authored local tools are project scripts under `.graff/tools/`; skills stay instructions. Runtime catalog extras, not `schema.effectiveRootSpecs`. | | [0040](0040-codedb-stays-when-licensed.md) | Ordinary reads use native `codedb` / `read_file`; codedb-pro is extra search, not the default reader. | | [0041](0041-tui-is-an-acp-client.md) | The fullscreen TUI is an in-process ACP client: session/prompt in, session/update thought/tool/text out. No child `graff acp`. | +| [0043](0043-reuse-warmed-tls-on-known-networks.md) | Reuse warmed TLS: MCP probe/initialized stay on the persistent HTTP client; WSS CA is scanned once per process; WS→SSE keeps the prewarmed pool. | ## When to write one diff --git a/src/agent_stream.zig b/src/agent_stream.zig index 7335450c..40eb98f5 100644 --- a/src/agent_stream.zig +++ b/src/agent_stream.zig @@ -47,10 +47,10 @@ pub fn postStream(self: *Agent, body: []const u8) ![]u8 { return postStreamWithClient(self, self.client, body); } -/// SSE stream using an explicit HTTP client. Normal traffic uses the Agent's -/// shared pool; a WebSocket failure supplies a fresh client so a stale pooled -/// keep-alive cannot poison the WS→SSE handoff and every fallback retry dials -/// from a clean pool. +/// SSE stream using an explicit HTTP client. Normal traffic and the WS→SSE +/// latch both use the Agent's prewarmed pool (agent_ws.postLive); a test can +/// pass another client. A failed SEND still poisons that connection so the +/// next retry dials fresh instead of replaying a dead keep-alive. pub fn postStreamWithClient(self: *Agent, client: *std.http.Client, body: []const u8) ![]u8 { const sink = engine_sink.forAgent(self); sink.emit(self.io, .stream_begin); diff --git a/src/http_warm.zig b/src/http_warm.zig index ff5c88cf..adb889a4 100644 --- a/src/http_warm.zig +++ b/src/http_warm.zig @@ -1,14 +1,46 @@ -//! Shared HTTP client CA-bundle warming. +//! Shared CA-bundle warming for HTTP and WSS. const std = @import("std"); const Io = std.Io; +/// Process-lifetime bundle. Always `page_allocator` so a test GPA cannot +/// free it while a later WSS dial still holds the pointer. +var process_ca: std.crypto.Certificate.Bundle = .empty; +var process_ca_rw: Io.RwLock = .init; +var process_ca_init: Io.Mutex = .init; +var process_ca_ready = std.atomic.Value(bool).init(false); + +/// Test seam: how many times the process bundle actually hit the disk. +pub var process_ca_rescans: u32 = 0; + +pub fn processCa() *std.crypto.Certificate.Bundle { + return &process_ca; +} + +pub fn processCaLock() *Io.RwLock { + return &process_ca_rw; +} + +/// Scan the host CA store once. Later WSS reconnects reuse the same bytes. +pub fn ensureProcessCa(io: Io) !void { + if (process_ca_ready.load(.acquire)) return; + process_ca_init.lockUncancelable(io); + defer process_ca_init.unlock(io); + if (process_ca_ready.load(.acquire)) return; + const now = Io.Clock.real.now(io); + process_ca.rescan(std.heap.page_allocator, io, now) catch return error.HandshakeFailed; + process_ca_rescans += 1; + process_ca_ready.store(true, .release); +} + /// Pre-load the shared HTTP client's CA bundle single-threaded so concurrent -/// agents never race Zig's lazy first-connect rescan. +/// agents never race Zig's lazy first-connect rescan. Also warms the process +/// bundle so the first WSS dial does not pay a second disk walk. pub fn prewarmCaBundle(client: *std.http.Client, gpa: std.mem.Allocator, io: Io) void { const now = Io.Clock.real.now(io); client.ca_bundle.rescan(gpa, io, now) catch return; client.now = now; + ensureProcessCa(io) catch return; } /// Warm off the launch critical path. Outbound users wait on diff --git a/src/mcp_lifecycle.zig b/src/mcp_lifecycle.zig index a7386758..06b601a5 100644 --- a/src/mcp_lifecycle.zig +++ b/src/mcp_lifecycle.zig @@ -6,7 +6,6 @@ //! surfaced rather than hidden behind fallback, matching rust-sdk Auto. const std = @import("std"); -const Io = std.Io; const Value = std.json.Value; const Allocator = std.mem.Allocator; @@ -59,27 +58,12 @@ const ProbeOut = struct { id: i64, }; -fn probeMethod( - io: Io, - gpa: Allocator, - url: []const u8, - headers: []const std.http.Header, - oauth_home: ?[]const u8, - method: []const u8, - id: i64, -) ProbeOut { - var arena_state = std.heap.ArenaAllocator.init(gpa); +fn probeMethod(http: *mcp_http.HttpTransport, method: []const u8, id: i64) ProbeOut { + var arena_state = std.heap.ArenaAllocator.init(http.client.allocator); defer arena_state.deinit(); - var transport: mcp_http.HttpTransport = .{ - .url = url, - .client = .{ .allocator = gpa, .io = io }, - .headers = headers, - .oauth_home = oauth_home, - }; - defer transport.client.deinit(); const body = mcp_protocol.buildRequest(arena_state.allocator(), id, method, "{}", true) catch return .{ .reply = .{ .status = 0, .body = null }, .id = id }; - const reply = mcp_http.probe(&transport, body, .{ + const reply = mcp_http.probe(http, body, .{ .protocol_version = modern_protocol, .method = method, .modern = true, @@ -126,7 +110,7 @@ fn connectHttpAttempt(server: *mcp_rpc.Server, a: Allocator, session_alloc: Allo const id_list = server.next_id; server.next_id += 1; - const list = probeMethod(io, gpa, http.url, http.headers, http.oauth_home, "tools/list", id_list); + const list = probeMethod(http, "tools/list", id_list); defer if (list.reply.body) |b| gpa.free(b); // Any modern request may be first. A real tools/list result is the catalog @@ -232,6 +216,13 @@ test "Auto: first launch tries modern tools/list before legacy fallback" { try std.testing.expect(list_pos < fallback_pos); } +test "Auto: modern probe reuses the persistent HTTP client" { + const src = @embedFile("mcp_lifecycle.zig"); + try std.testing.expect(std.mem.indexOf(u8, src, "fn probeMethod(http: *mcp_http.HttpTransport") != null); + try std.testing.expect(std.mem.indexOf(u8, src, "const list = probeMethod(http,") != null); + try std.testing.expect(std.mem.indexOf(u8, src, "var transport: mcp_http.HttpTransport") == null); +} + test { _ = @import("mcp_cache.zig"); } diff --git a/src/mcp_rpc.zig b/src/mcp_rpc.zig index ed5dc316..15a7d033 100644 --- a/src/mcp_rpc.zig +++ b/src/mcp_rpc.zig @@ -236,43 +236,21 @@ pub fn connectLegacy(server: *Server, a: Allocator, session_alloc: Allocator, bo return listed; } -const InitializedJob = struct { - url: []const u8, - headers: []const std.http.Header, - oauth_home: ?[]const u8, - protocol_version: []const u8, - gpa: Allocator, -}; - -fn httpInitializedTask(io: Io, job: InitializedJob) void { - var transport: mcp_http.HttpTransport = .{ - .url = job.url, - .client = .{ .allocator = job.gpa, .io = io }, - .headers = job.headers, - .oauth_home = job.oauth_home, - }; - defer transport.client.deinit(); +fn httpInitializedTask(http: *mcp_http.HttpTransport, protocol_version: []const u8) void { const body = "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\",\"params\":{}}"; - if (mcp_http.post(&transport, body, .{ - .protocol_version = job.protocol_version, + if (mcp_http.post(http, body, .{ + .protocol_version = protocol_version, .method = "notifications/initialized", .modern = false, }, null)) |maybe| { - if (maybe) |b| job.gpa.free(b); + if (maybe) |b| http.client.allocator.free(b); } else |_| {} } fn kickHttpInitialized(server: *Server) void { const http = &server.transport.http; - const job = InitializedJob{ - .url = http.url, - .headers = http.headers, - .oauth_home = http.oauth_home, - .protocol_version = server.protocol_version, - .gpa = http.client.allocator, - }; - server.pending_initialized = http.client.io.concurrent(httpInitializedTask, .{ http.client.io, job }) catch - http.client.io.async(httpInitializedTask, .{ http.client.io, job }); + server.pending_initialized = http.client.io.concurrent(httpInitializedTask, .{ http, server.protocol_version }) catch + http.client.io.async(httpInitializedTask, .{ http, server.protocol_version }); } pub fn finishInitialized(server: *Server) void { diff --git a/src/net_efficiency_test.zig b/src/net_efficiency_test.zig new file mode 100644 index 00000000..d5975e01 --- /dev/null +++ b/src/net_efficiency_test.zig @@ -0,0 +1,132 @@ +//! Measured hot-path checks for reuse on the networks graff already speaks: +//! process-warmed WSS CA, MCP Streamable HTTP keep-alive, and the source +//! guards that keep those clients from becoming throwaways again. + +const std = @import("std"); +const builtin = @import("builtin"); +const Io = std.Io; + +const http_warm = @import("http_warm.zig"); +const mcp_lifecycle = @import("mcp_lifecycle.zig"); +const mcp_rpc = @import("mcp_rpc.zig"); + +test "WSS CA bundle is scanned from disk at most once per process" { + const io = std.testing.io; + const before = http_warm.process_ca_rescans; + try http_warm.ensureProcessCa(io); + const mid = http_warm.process_ca_rescans; + try http_warm.ensureProcessCa(io); + const after = http_warm.process_ca_rescans; + try std.testing.expect(mid == before or mid == before + 1); + try std.testing.expectEqual(mid, after); + try std.testing.expect(http_warm.processCa().map.count() > 0); +} + +test "MCP initialized notify reuses the persistent HTTP client" { + const src = @embedFile("mcp_rpc.zig"); + try std.testing.expect(std.mem.indexOf(u8, src, "fn httpInitializedTask(http: *mcp_http.HttpTransport") != null); + try std.testing.expect(std.mem.indexOf(u8, src, "http.client.io.concurrent(httpInitializedTask, .{ http,") != null); + try std.testing.expect(std.mem.indexOf(u8, src, "var transport: mcp_http.HttpTransport") == null); +} + +test "WS→SSE fallback latches the prewarmed Agent client, not a fresh pool" { + const src = @embedFile("agent_ws.zig"); + const latch = std.mem.indexOf(u8, src, "return self.postStream(body);").?; + try std.testing.expect(std.mem.indexOf(u8, src, "postStreamFresh") == null); + try std.testing.expect(std.mem.indexOf(u8, src, "using persistent prewarmed SSE") != null); + _ = latch; +} + +const ListSrv = struct { + accepts: *std.atomic.Value(u8), + posts: *std.atomic.Value(u8), + done: *std.atomic.Value(bool), + + fn run(self: *ListSrv, io: Io, listener: *std.Io.net.Server) void { + while (!self.done.load(.acquire)) { + const stream = listener.accept(io) catch { + if (self.done.load(.acquire)) return; + continue; + }; + _ = self.accepts.fetchAdd(1, .monotonic); + defer stream.close(io); + self.serveConn(io, stream) catch {}; + } + } + + fn serveConn(self: *ListSrv, io: Io, stream: std.Io.net.Stream) !void { + var rbuf: [4096]u8 = undefined; + var wbuf: [4096]u8 = undefined; + var rd = std.Io.net.Stream.Reader.init(stream, io, &rbuf); + var wr = std.Io.net.Stream.Writer.init(stream, io, &wbuf); + const r = &rd.interface; + const w = &wr.interface; + while (self.posts.load(.acquire) < 2) { + var content_len: usize = 0; + while (true) { + const line = (r.takeDelimiter('\n') catch return) orelse return; + if (line.len == 0 or (line.len == 1 and line[0] == '\r')) break; + if (std.ascii.startsWithIgnoreCase(line, "content-length:")) { + const raw = if (line[line.len - 1] == '\r') line[0 .. line.len - 1] else line; + const v = std.mem.trim(u8, raw["content-length:".len..], " \t"); + content_len = std.fmt.parseInt(usize, v, 10) catch 0; + } + } + if (content_len > 0) _ = try r.take(content_len); + const n = self.posts.fetchAdd(1, .monotonic) + 1; + const body = if (n == 1) + \\{"jsonrpc":"2.0","id":1,"result":{"tools":[],"supportedVersions":["2026-07-28"]}} + else + \\{"jsonrpc":"2.0","id":2,"result":{"tools":[]}} + ; + try w.print( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: keep-alive\r\n\r\n{s}", + .{ body.len, body }, + ); + try w.flush(); + } + } +}; + +test "MCP modern connect + next list reuse one TCP connection" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + + var addr = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var listener = try std.Io.net.IpAddress.listen(&addr, io, .{}); + defer listener.deinit(io); + + var accepts: std.atomic.Value(u8) = .init(0); + var posts: std.atomic.Value(u8) = .init(0); + var done: std.atomic.Value(bool) = .init(false); + var srv: ListSrv = .{ .accepts = &accepts, .posts = &posts, .done = &done }; + var fut = io.async(ListSrv.run, .{ &srv, io, &listener }); + defer fut.await(io); + defer done.store(true, .release); + defer if (std.Io.net.IpAddress.connect(&listener.socket.address, io, .{ .mode = .stream })) |s| s.close(io) else |_| {}; + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/mcp", .{listener.socket.address.getPort()}); + + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var server: mcp_rpc.Server = .{ + .name = "loop", + .transport = .{ .http = .{ + .url = url, + .client = .{ .allocator = gpa, .io = io }, + } }, + }; + defer server.transport.http.client.deinit(); + + const first = try mcp_lifecycle.connectHttp(&server, arena, arena, .unknown); + try std.testing.expect(first.object.get("result") != null); + const second = try mcp_rpc.request(&server, arena, "{}", "tools/list", null); + try std.testing.expect(second.object.get("result") != null); + + try std.testing.expectEqual(@as(u8, 1), accepts.load(.monotonic)); + try std.testing.expectEqual(@as(u8, 2), posts.load(.monotonic)); +} diff --git a/src/test_hooks.zig b/src/test_hooks.zig index 15212124..08fb225e 100644 --- a/src/test_hooks.zig +++ b/src/test_hooks.zig @@ -341,4 +341,5 @@ test { _ = @import("channel_worker.zig"); _ = @import("session_wake.zig"); _ = @import("tui_acp.zig"); + _ = @import("net_efficiency_test.zig"); } diff --git a/src/ws.zig b/src/ws.zig index 186fa5af..54839878 100644 --- a/src/ws.zig +++ b/src/ws.zig @@ -21,6 +21,7 @@ const net = std.Io.net; const HostName = net.HostName; const Allocator = std.mem.Allocator; const tls = std.crypto.tls; +const http_warm = @import("http_warm.zig"); /// GRAFF_WS_DEBUG=1 → dump the handshake + frame headers to stderr. pub var g_debug: bool = false; @@ -76,8 +77,6 @@ pub const WsClient = struct { r: *Io.Reader = undefined, w: *Io.Writer = undefined, tls_client: ?tls.Client = null, - ca_bundle: std.crypto.Certificate.Bundle = .empty, - ca_lock: Io.RwLock = .init, gpa: Allocator, /// (#401) The peer is wedged or gone — tear down with a plain FIN instead /// of deinit's courtesy close frame, which is another blocking write on the @@ -123,28 +122,26 @@ pub const WsClient = struct { self.* = .{ .io = io, .stream = stream, .rd = undefined, .wr = undefined, .gpa = gpa }; self.rd = net.Stream.Reader.init(stream, io, &self.sock_rbuf); self.wr = net.Stream.Writer.init(stream, io, &self.sock_wbuf); - // From here the client owns the socket (and, for wss, the CA bundle): - // release both if the TLS or upgrade handshake fails, or every failed - // dial leaks an fd — which #401's reconnect ladder now retries into. - errdefer { - self.ca_bundle.deinit(gpa); - self.stream.close(io); - } + // From here the client owns the socket: release it if the TLS or + // upgrade handshake fails, or every failed dial leaks an fd — which + // #401's reconnect ladder now retries into. The CA bundle is the + // process-warmed one (http_warm); do not deinit it here. + errdefer self.stream.close(io); if (u.tls) { var entropy: [tls.Client.Options.entropy_len]u8 = undefined; io.random(&entropy); - if (!insecure) self.ca_bundle.rescan(gpa, io, Io.Clock.real.now(io)) catch |e| { + if (!insecure) http_warm.ensureProcessCa(io) catch |e| { dbg("ca rescan failed: {s}", .{@errorName(e)}); return error.HandshakeFailed; }; self.tls_client = tls.Client.init(&self.rd.interface, &self.wr.interface, .{ .host = if (insecure) .no_verification else .{ .explicit = u.host }, .ca = if (insecure) .no_verification else .{ .bundle = .{ - .gpa = gpa, + .gpa = std.heap.page_allocator, .io = io, - .lock = &self.ca_lock, - .bundle = &self.ca_bundle, + .lock = http_warm.processCaLock(), + .bundle = http_warm.processCa(), } }, .write_buffer = &self.tls_wbuf, .read_buffer = &self.tls_rbuf, @@ -167,7 +164,6 @@ pub const WsClient = struct { pub fn deinit(self: *WsClient, gpa: Allocator) void { if (!self.dead) self.sendFrame(.close, "") catch {}; // (#401) see `dead` - self.ca_bundle.deinit(gpa); self.stream.close(self.io); gpa.destroy(self); } From 9b81ec4839a3805fbd7714bbfd4ddd064ed6168d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 18:04:38 +0000 Subject: [PATCH 03/27] Fix source-guard needles that matched their own test text embedFile includes the test body, so a contiguous throwaway-client string in the assertion always "found" itself. Split the needles. --- src/mcp_lifecycle.zig | 3 ++- src/net_efficiency_test.zig | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/mcp_lifecycle.zig b/src/mcp_lifecycle.zig index 06b601a5..abdf7992 100644 --- a/src/mcp_lifecycle.zig +++ b/src/mcp_lifecycle.zig @@ -220,7 +220,8 @@ test "Auto: modern probe reuses the persistent HTTP client" { const src = @embedFile("mcp_lifecycle.zig"); try std.testing.expect(std.mem.indexOf(u8, src, "fn probeMethod(http: *mcp_http.HttpTransport") != null); try std.testing.expect(std.mem.indexOf(u8, src, "const list = probeMethod(http,") != null); - try std.testing.expect(std.mem.indexOf(u8, src, "var transport: mcp_http.HttpTransport") == null); + const throwaway = "var transport: " ++ "mcp_http.HttpTransport"; + try std.testing.expect(std.mem.indexOf(u8, src, throwaway) == null); } test { diff --git a/src/net_efficiency_test.zig b/src/net_efficiency_test.zig index d5975e01..7524dab8 100644 --- a/src/net_efficiency_test.zig +++ b/src/net_efficiency_test.zig @@ -26,7 +26,8 @@ test "MCP initialized notify reuses the persistent HTTP client" { const src = @embedFile("mcp_rpc.zig"); try std.testing.expect(std.mem.indexOf(u8, src, "fn httpInitializedTask(http: *mcp_http.HttpTransport") != null); try std.testing.expect(std.mem.indexOf(u8, src, "http.client.io.concurrent(httpInitializedTask, .{ http,") != null); - try std.testing.expect(std.mem.indexOf(u8, src, "var transport: mcp_http.HttpTransport") == null); + const throwaway = "var transport: " ++ "mcp_http.HttpTransport"; + try std.testing.expect(std.mem.indexOf(u8, src, throwaway) == null); } test "WS→SSE fallback latches the prewarmed Agent client, not a fresh pool" { From 4589a70c188e1e2cc002b0bb9079b22291fe049b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 18:23:12 +0000 Subject: [PATCH 04/27] Accept gzip on MCP HTTP so catalogs shrink on the wire Streamable HTTP omitted Accept-Encoding and read the raw body, so a tools/list catalog crossed uncompressed. Provider POST already decompresses. Advertise the std client defaults and decode Content-Encoding. Loopback: a 40-tool list is smaller on the wire than plaintext, and the client still returns the JSON. ADR 0043 updated. --- ...0043-reuse-warmed-tls-on-known-networks.md | 11 +- docs/adr/README.md | 2 +- src/mcp_http.zig | 59 +++++----- src/net_efficiency_test.zig | 111 ++++++++++++++++++ 4 files changed, 147 insertions(+), 36 deletions(-) diff --git a/docs/adr/0043-reuse-warmed-tls-on-known-networks.md b/docs/adr/0043-reuse-warmed-tls-on-known-networks.md index b7835aea..f33fd21c 100644 --- a/docs/adr/0043-reuse-warmed-tls-on-known-networks.md +++ b/docs/adr/0043-reuse-warmed-tls-on-known-networks.md @@ -27,10 +27,15 @@ ADR 0002 (xAI WS full-resend), 0009/0011/0028 (prompt-cache keys), and 0035 - WS→SSE fallback keeps the Agent's prewarmed HTTP pool (`postStream`). Do not introduce a fresh client on that latch: WS never used the HTTP pool, and a new TLS would be strictly more expensive. +- MCP Streamable HTTP advertises the std client's default Accept-Encoding + (gzip/deflate) and decompresses Content-Encoding. Do not omit it: a tools/list + catalog is the fat payload on that network, and provider POST already accepts + compression. ## Consequences A modern MCP connect plus the next list is one TCP accept on keep-alive. -WSS reconnects skip the CA disk walk. Revisit only if a shared `std.http.Client` -is shown unsafe for the concurrent initialized+list pair, or if a host CA -rotation must be picked up mid-process without restart. +WSS reconnects skip the CA disk walk. MCP catalogs can travel gzip-compressed. +Revisit only if a shared `std.http.Client` is shown unsafe for the concurrent +initialized+list pair, a host CA rotation must be picked up mid-process without +restart, or a server is broken by Accept-Encoding. diff --git a/docs/adr/README.md b/docs/adr/README.md index d5bfacfa..13abb757 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -52,7 +52,7 @@ record only when you need the evidence or the edge cases. | [0039](0039-local-tools-are-project-scripts.md) | Agent-authored local tools are project scripts under `.graff/tools/`; skills stay instructions. Runtime catalog extras, not `schema.effectiveRootSpecs`. | | [0040](0040-codedb-stays-when-licensed.md) | Ordinary reads use native `codedb` / `read_file`; codedb-pro is extra search, not the default reader. | | [0041](0041-tui-is-an-acp-client.md) | The fullscreen TUI is an in-process ACP client: session/prompt in, session/update thought/tool/text out. No child `graff acp`. | -| [0043](0043-reuse-warmed-tls-on-known-networks.md) | Reuse warmed TLS: MCP probe/initialized stay on the persistent HTTP client; WSS CA is scanned once per process; WS→SSE keeps the prewarmed pool. | +| [0043](0043-reuse-warmed-tls-on-known-networks.md) | Reuse warmed TLS: MCP probe/initialized stay on the persistent HTTP client; WSS CA is scanned once per process; WS→SSE keeps the prewarmed pool; MCP HTTP accepts gzip. | ## When to write one diff --git a/src/mcp_http.zig b/src/mcp_http.zig index dd3bf7ec..80489e7d 100644 --- a/src/mcp_http.zig +++ b/src/mcp_http.zig @@ -174,7 +174,6 @@ fn httpPostUnwatched(http: *HttpTransport, body: []const u8, meta: RequestMeta, .redirect_behavior = .unhandled, .headers = .{ .content_type = .{ .override = "application/json" }, - .accept_encoding = .omit, .user_agent = .{ .override = "codegraff-mcp/1" }, }, .extra_headers = extra, @@ -224,28 +223,7 @@ fn httpPostUnwatched(http: *HttpTransport, body: []const u8, meta: RequestMeta, } } - if (response.head.content_length == 0) return null; - const is_sse = if (response.head.content_type) |content_type| - std.ascii.startsWithIgnoreCase(content_type, "text/event-stream") - else - false; - var transfer_buf: [4096]u8 = undefined; - const reader = response.reader(&transfer_buf); - if (is_sse) return readSseResponse(http.client.allocator, reader, expected_id); - - const response_buf = try http.client.allocator.alloc(u8, max_http_response); - errdefer http.client.allocator.free(response_buf); - var fixed = Io.Writer.fixed(response_buf); - _ = reader.streamRemaining(&fixed) catch |err| switch (err) { - error.WriteFailed => return error.McpResponseTooLarge, - else => return err, - }; - const len = fixed.buffered().len; - if (len == 0) { - http.client.allocator.free(response_buf); - return null; - } - return try http.client.allocator.realloc(response_buf, len); + return readResponseBody(http.client.allocator, &response, expected_id); } const HttpPostDone = union(enum) { @@ -325,7 +303,6 @@ fn probeUnwatched(http: *HttpTransport, body: []const u8, meta: RequestMeta) !Pr .redirect_behavior = .unhandled, .headers = .{ .content_type = .{ .override = "application/json" }, - .accept_encoding = .omit, .user_agent = .{ .override = "codegraff-mcp/1" }, }, .extra_headers = extra, @@ -355,17 +332,35 @@ fn probeUnwatched(http: *HttpTransport, body: []const u8, meta: RequestMeta) !Pr if (req.connection) |connection| connection.closing = true; } - if (response.head.content_length == 0) return .{ .status = status, .body = null }; + return .{ .status = status, .body = try readResponseBody(http.client.allocator, &response, null) }; +} + +fn decompressWindow(gpa: Allocator, encoding: std.http.ContentEncoding) ![]u8 { + return switch (encoding) { + .identity => &.{}, + .zstd => gpa.alloc(u8, std.compress.zstd.default_window_len), + .deflate, .gzip => gpa.alloc(u8, std.compress.flate.max_window_len), + .compress => error.UnsupportedCompressionMethod, + }; +} + +/// Decode the HTTP body, honoring Content-Encoding (gzip/deflate/zstd). +/// Callers used to omit Accept-Encoding so catalogs crossed the wire raw. +fn readResponseBody(gpa: Allocator, response: *std.http.Client.Response, expected_id: ?i64) !?[]u8 { + if (response.head.content_length == 0) return null; const is_sse = if (response.head.content_type) |content_type| std.ascii.startsWithIgnoreCase(content_type, "text/event-stream") else false; + const window = try decompressWindow(gpa, response.head.content_encoding); + defer if (window.len > 0) gpa.free(window); var transfer_buf: [4096]u8 = undefined; - const reader = response.reader(&transfer_buf); - if (is_sse) return .{ .status = status, .body = try readSseResponse(http.client.allocator, reader, null) }; + var decompress: std.http.Decompress = undefined; + const reader = response.readerDecompressing(&transfer_buf, &decompress, window); + if (is_sse) return readSseResponse(gpa, reader, expected_id); - const response_buf = try http.client.allocator.alloc(u8, max_http_response); - errdefer http.client.allocator.free(response_buf); + const response_buf = try gpa.alloc(u8, max_http_response); + errdefer gpa.free(response_buf); var fixed = Io.Writer.fixed(response_buf); _ = reader.streamRemaining(&fixed) catch |err| switch (err) { error.WriteFailed => return error.McpResponseTooLarge, @@ -373,10 +368,10 @@ fn probeUnwatched(http: *HttpTransport, body: []const u8, meta: RequestMeta) !Pr }; const len = fixed.buffered().len; if (len == 0) { - http.client.allocator.free(response_buf); - return .{ .status = status, .body = null }; + gpa.free(response_buf); + return null; } - return .{ .status = status, .body = try http.client.allocator.realloc(response_buf, len) }; + return try gpa.realloc(response_buf, len); } const ProbeDone = union(enum) { diff --git a/src/net_efficiency_test.zig b/src/net_efficiency_test.zig index 7524dab8..c7f401a9 100644 --- a/src/net_efficiency_test.zig +++ b/src/net_efficiency_test.zig @@ -7,7 +7,9 @@ const builtin = @import("builtin"); const Io = std.Io; const http_warm = @import("http_warm.zig"); +const mcp_http = @import("mcp_http.zig"); const mcp_lifecycle = @import("mcp_lifecycle.zig"); +const mcp_protocol = @import("mcp_protocol.zig"); const mcp_rpc = @import("mcp_rpc.zig"); test "WSS CA bundle is scanned from disk at most once per process" { @@ -131,3 +133,112 @@ test "MCP modern connect + next list reuse one TCP connection" { try std.testing.expectEqual(@as(u8, 1), accepts.load(.monotonic)); try std.testing.expectEqual(@as(u8, 2), posts.load(.monotonic)); } + +test "MCP HTTP advertises gzip and decompresses a smaller wire body" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + + var plain_w: Io.Writer.Allocating = .init(gpa); + defer plain_w.deinit(); + try plain_w.writer.writeAll("{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"tools\":["); + for (0..40) |i| { + if (i != 0) try plain_w.writer.writeByte(','); + try plain_w.writer.print( + "{{\"name\":\"tool_{d}\",\"description\":\"search the workspace and return matching files\",\"inputSchema\":{{\"type\":\"object\",\"properties\":{{\"q\":{{\"type\":\"string\"}}}}}}}}", + .{i}, + ); + } + try plain_w.writer.writeAll("],\"supportedVersions\":[\"2026-07-28\"]}}"); + const plain = plain_w.writer.buffered(); + + const gz = try gzipAlloc(gpa, plain); + defer gpa.free(gz); + try std.testing.expect(gz.len < plain.len); + + var saw_accept_gzip = std.atomic.Value(bool).init(false); + var wire_len = std.atomic.Value(usize).init(0); + + var addr = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var listener = try std.Io.net.IpAddress.listen(&addr, io, .{}); + defer listener.deinit(io); + const Srv = struct { + fn run( + io_: Io, + listener_: *std.Io.net.Server, + gz_: []const u8, + saw: *std.atomic.Value(bool), + wire: *std.atomic.Value(usize), + ) void { + const stream = listener_.accept(io_) catch return; + defer stream.close(io_); + var rbuf: [4096]u8 = undefined; + var rd = std.Io.net.Stream.Reader.init(stream, io_, &rbuf); + const r = &rd.interface; + var content_len: usize = 0; + var accept_enc: bool = false; + while (true) { + const line = (r.takeDelimiter('\n') catch return) orelse return; + if (line.len == 0 or (line.len == 1 and line[0] == '\r')) break; + if (std.ascii.startsWithIgnoreCase(line, "accept-encoding:") and + std.mem.indexOf(u8, line, "gzip") != null) accept_enc = true; + if (std.ascii.startsWithIgnoreCase(line, "content-length:")) { + const raw = if (line[line.len - 1] == '\r') line[0 .. line.len - 1] else line; + content_len = std.fmt.parseInt(usize, std.mem.trim(u8, raw["content-length:".len..], " \t"), 10) catch 0; + } + } + if (content_len > 0) _ = r.take(content_len) catch {}; + saw.store(accept_enc, .release); + wire.store(gz_.len, .release); + var wbuf: [1024]u8 = undefined; + var wr = std.Io.net.Stream.Writer.init(stream, io_, &wbuf); + wr.interface.print( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Encoding: gzip\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{gz_.len}, + ) catch return; + wr.interface.writeAll(gz_) catch return; + wr.interface.flush() catch {}; + } + }; + var fut = io.async(Srv.run, .{ io, &listener, gz, &saw_accept_gzip, &wire_len }); + defer fut.await(io); + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/mcp", .{listener.socket.address.getPort()}); + var http: mcp_http.HttpTransport = .{ + .url = url, + .client = .{ .allocator = gpa, .io = io }, + }; + defer http.client.deinit(); + const body = (try mcp_http.post(&http, "{}", .{ + .protocol_version = mcp_protocol.modern_protocol, + .method = "tools/list", + .modern = true, + }, 1)) orelse return error.TestUnexpectedResult; + defer gpa.free(body); + + try std.testing.expect(saw_accept_gzip.load(.acquire)); + try std.testing.expectEqual(gz.len, wire_len.load(.acquire)); + try std.testing.expect(wire_len.load(.acquire) < plain.len); + try std.testing.expectEqualStrings(plain, body); +} + +test "MCP HTTP no longer omits Accept-Encoding" { + const src = @embedFile("mcp_http.zig"); + const omit = "accept_encoding = " ++ ".omit"; + try std.testing.expect(std.mem.indexOf(u8, src, omit) == null); + try std.testing.expect(std.mem.indexOf(u8, src, "readerDecompressing") != null); +} + +fn gzipAlloc(gpa: std.mem.Allocator, plain: []const u8) ![]u8 { + var out_buf: [4096]u8 = undefined; + var out: Io.Writer = .fixed(&out_buf); + const window = try gpa.alloc(u8, std.compress.flate.max_window_len); + defer gpa.free(window); + const c = try gpa.create(std.compress.flate.Compress); + defer gpa.destroy(c); + c.* = try std.compress.flate.Compress.init(&out, window, .gzip, .default); + try c.writer.writeAll(plain); + try c.finish(); + return gpa.dupe(u8, out.buffered()); +} From 54fd5f3e7f2967c6cb83aafe7607ed57ef873b52 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 03:29:40 +0000 Subject: [PATCH 05/27] docs(adr): record measured before/after for network reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second launch CA walk is 4–7ms and OAuth throwaways are login-only; ADR 0002 blocks shrinking WS bodies. No further code change. Table is the evidence for stopping. --- docs/adr/0043-reuse-warmed-tls-on-known-networks.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/adr/0043-reuse-warmed-tls-on-known-networks.md b/docs/adr/0043-reuse-warmed-tls-on-known-networks.md index f33fd21c..dc3c1473 100644 --- a/docs/adr/0043-reuse-warmed-tls-on-known-networks.md +++ b/docs/adr/0043-reuse-warmed-tls-on-known-networks.md @@ -39,3 +39,14 @@ WSS reconnects skip the CA disk walk. MCP catalogs can travel gzip-compressed. Revisit only if a shared `std.http.Client` is shown unsafe for the concurrent initialized+list pair, a host CA rotation must be picked up mid-process without restart, or a server is broken by Accept-Encoding. + +## Measured (2026-08-30, this host) + +| Path | Before | After | Left on the table | +|---|---|---|---| +| MCP modern connect + next `tools/list` | 2 TCP accepts (throwaway probe client, then the persistent one) | 1 accept, 2 POSTs | nothing — keep-alive is the rest | +| WSS CA disk walk | 1 `rescan` per connect, including every reconnect | 1 per process | launch still walks once for the HTTP client (~5–7 ms, 144 certs / 154 KB here). Cloning that bundle into the process one saves one launch scan and risks a double-free; not worth it | +| MCP `tools/list` catalog (40-tool fixture) | 6110 B raw (`Accept-Encoding` omitted) | 320 B gzip (5% of plaintext) | only if the server ignores gzip — then we still send the header and read identity | +| WS→SSE latch | prewarmed Agent HTTP pool | unchanged | a fresh client would add a TLS handshake | +| xAI / Codex WS turn body | full history | unchanged | ADR 0002: no `previous_response_id` chain | +| MCP OAuth token / login 401 probe | throwaway `std.http.Client`; login probe omits encoding | unchanged | not on the turn path | From 1a13511586e54871132544012d401ba179bae853 Mon Sep 17 00:00:00 2001 From: yxlyx <85774423+yxlyx@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:04:30 +0800 Subject: [PATCH 06/27] fix(codex): preserve terminal websocket API errors Codex closes its Responses socket immediately after a type:error frame. Graff previously waited for another frame, discarded the accumulated API body on EOF, and retried an unchanged deterministic request across fresh WS and SSE transports. Classify authoritative error frames as terminal API responses, retire the closing socket without charging the transport ladder, and allow only recognized stale chains to rebuild once with full input. Bound parsed code/message diagnostics before tracing or displaying them, including through the fullscreen TUI. Add loopback, interactive PTY, stale-chain, diagnostic-redaction, and headless TUI regressions while retaining the existing fallback and compaction trajectories. Co-Authored-By: Codegraff --- TUI/sim.zig | 37 ++++++ scripts/codex_ws_error_test.py | 205 +++++++++++++++++++++++++++++++++ scripts/codex_ws_mock.py | 9 +- scripts/test-pty-codex-ws.py | 21 ++++ src/agent_request.zig | 17 +-- src/agent_responses.zig | 41 +++++++ src/agent_ws.zig | 14 +-- src/agent_ws_mock.zig | 9 ++ src/agent_ws_reuse_test.zig | 39 +++++++ src/agent_ws_signal.zig | 47 +++++--- src/agent_ws_test.zig | 2 +- src/repl_turn.zig | 4 + 12 files changed, 411 insertions(+), 34 deletions(-) create mode 100644 scripts/codex_ws_error_test.py diff --git a/TUI/sim.zig b/TUI/sim.zig index 9dd735b7..d26ce9e9 100644 --- a/TUI/sim.zig +++ b/TUI/sim.zig @@ -20,10 +20,12 @@ const std = @import("std"); const app = @import("app.zig"); const dump = @import("dump.zig"); +const engine_mod = @import("engine.zig"); const key_mod = @import("key.zig"); const keys = @import("keys.zig"); const render_mod = @import("render.zig"); const theme_mod = @import("theme.zig"); +const turn = @import("turn.zig"); const Model = app.Model; const Effect = app.Effect; @@ -221,6 +223,41 @@ test "typeText lands in the composer dump" { try std.testing.expect(std.mem.indexOf(u8, vis, "›") != null); } +test "#692: terminal API diagnostics survive job finish and the composer recovers" { + const a = std.testing.allocator; + var term: Term = undefined; + term.init(a, 80, 24); + defer term.deinit(); + + try term.model.push(.user, "trigger a provider rejection"); + try term.model.push(.pending, ""); + const job = try a.create(engine_mod.Job); + job.* = .{ + .gpa = a, + .history = &.{}, + .params = .{}, + .stream = .{}, + .raw = .{}, + .threaded = false, + .result = try a.dupe(u8, "codex api error [invalid_request_error]: mock bad request"), + }; + job.events.attach(a); + job.done.store(true, .release); + term.model.pending = job; + turn.finishJob(&term.model); + + const failed = try term.screen(); + defer a.free(failed); + try std.testing.expect(std.mem.indexOf(u8, failed, "invalid_request_error") != null); + try std.testing.expect(std.mem.indexOf(u8, failed, "check /model and your API key") == null); + + _ = term.typeText("continue"); + const recovered = try term.screen(); + defer a.free(recovered); + try std.testing.expect(std.mem.indexOf(u8, recovered, "continue") != null); + try std.testing.expect(std.mem.indexOf(u8, recovered, "›") != null); +} + test "assistant markdown shows lists, quotes, tasks, and rules" { var term: Term = undefined; term.init(std.testing.allocator, 80, 24); diff --git a/scripts/codex_ws_error_test.py b/scripts/codex_ws_error_test.py new file mode 100644 index 00000000..d35c82cc --- /dev/null +++ b/scripts/codex_ws_error_test.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Issue #692 interactive Codex WS error-frame regression scenarios.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +from codex_ws_mock import CodexMock, RecordedRequest +import codex_ws_test as base + +GENERIC_ERROR_CODE = "invalid_request_error" +GENERIC_ERROR_SECRET = "raw-envelope-secret-must-not-leak" +GENERIC_ERROR_TAIL = "generic-error-tail-must-be-truncated" +GENERIC_ERROR_MESSAGE = "mock bad request\r\n" + "x" * 500 + GENERIC_ERROR_TAIL +CHAIN_ERROR_CODE = "previous_response_not_found" +CHAIN_FINAL = "recovered after automatic websocket re-anchor" + + +def generic_error_events(_request: RecordedRequest) -> list[dict]: + """One generic terminal error with raw envelope data that must not leak.""" + return [ + { + "type": "error", + "error": { + "code": GENERIC_ERROR_CODE, + "message": GENERIC_ERROR_MESSAGE, + }, + "debug": {"authorization": GENERIC_ERROR_SECRET}, + } + ] + + +def chain_error_events(request: RecordedRequest) -> list[dict]: + """Tool call, stale chained delta, then success after a full-input redial.""" + if request.ordinal == 1: + call = { + "type": "function_call", + "id": "fc_chain_1", + "call_id": "call_chain_1", + "name": "todo_read", + "arguments": "{}", + "status": "completed", + } + return base.response_events(call, "resp_chain_1", 1_200) + if request.ordinal == 2: + return [ + { + "type": "error", + "error": { + "code": CHAIN_ERROR_CODE, + "message": "Previous response resp_chain_1 was not found", + }, + } + ] + return base.response_events( + base.message_item(CHAIN_FINAL, "msg_chain_final"), + "resp_chain_final", + 1_300, + ) + + +def _env(tmp: str, codex_home: str, port: int) -> tuple[dict[str, str], tuple[str, ...]]: + env = { + "HOME": tmp, + "CODEX_HOME": codex_home, + "CODEGRAFF_API_KEY": "local-pty-test", + "GRAFF_FLEET": "off", + "GRAFF_NO_TELEMETRY": "1", + "GRAFF_CODEX_URL": f"http://127.0.0.1:{port}/backend-api/codex/responses", + "GRAFF_SERVER_COMPACT": "0", + } + ambient = tuple( + key + for key in os.environ + if (key.startswith("GRAFF_") or key.startswith("CODEX_") or key == "NO_COLOR") + and key not in env + ) + return env, ambient + + +def _new_trace(tmp: str, before: set[str]) -> list[dict]: + trace_dir = Path(tmp, ".graff", "traces") + created = sorted(path for path in trace_dir.iterdir() if path.name not in before) + if len(created) != 1: + raise AssertionError(f"expected one new trace, got {created!r}") + return [json.loads(line) for line in created[0].read_text().splitlines() if line] + + +def _assert_ws_stays_primary(session: base.PtySession) -> None: + cursor = len(session.raw) + session.send_line("/models health") + session.wait_for_literal( + "Codex transport: WebSocket primary with automatic SSE fallback", + start=cursor, + ) + session.wait_for_prompt(start=cursor) + + +def _exit(session: base.PtySession, label: str) -> None: + session.send_key("ctrl-d") + result = session.read_until_exit(5.0) + if result.timed_out or result.exit_code != 0: + raise SystemExit( + f"{label}: REPL did not exit cleanly: " + f"exit={result.exit_code} timed_out={result.timed_out}" + ) + + +def run_generic_error_scenario( + tmp: str, codex_home: str, port: int, mock: CodexMock +) -> None: + """A deterministic API error returns once with bounded safe diagnostics.""" + env, ambient = _env(tmp, codex_home, port) + trace_dir = Path(tmp, ".graff", "traces") + before = {path.name for path in trace_dir.iterdir()} if trace_dir.is_dir() else set() + with base.PtySession( + base.GRAFF, + ["--model", "codex", "--no-telemetry"], + cwd=tmp, + env=env, + unset_env=ambient, + timeout=20.0, + ) as session: + session.wait_for_prompt() + cursor = len(session.raw) + session.send_line("trigger the generic websocket rejection") + expected = f"codex api error [{GENERIC_ERROR_CODE}]: mock bad request" + session.wait_for_literal(expected, start=cursor) + session.wait_for_prompt(start=cursor) + rendered = base.terminal_text(bytes(session.raw[cursor:])) + if GENERIC_ERROR_SECRET in rendered or GENERIC_ERROR_TAIL in rendered: + raise AssertionError(f"generic-error: raw/unbounded diagnostics leaked:\n{rendered}") + if mock.ws_turns != 1 or mock.sse_turns != 0 or mock.ws_connections != 1: + raise AssertionError( + "generic-error: deterministic body was retried/fallen back: " + f"connections={mock.ws_connections} ws={mock.ws_turns} sse={mock.sse_turns}" + ) + _assert_ws_stays_primary(session) + _exit(session, "generic-error") + + events = _new_trace(tmp, before) + diagnostics = [event for event in events if event.get("ev") == "ws_api_error"] + if len(diagnostics) != 1: + raise AssertionError(f"generic-error: expected one ws_api_error trace: {events!r}") + detail = diagnostics[0].get("detail", "") + if ( + GENERIC_ERROR_CODE not in detail + or GENERIC_ERROR_SECRET in detail + or GENERIC_ERROR_TAIL in detail + or len(detail.encode("utf-8")) >= 560 + ): + raise AssertionError(f"generic-error: unsafe trace diagnostic: {detail!r}") + ws_details = [event.get("detail", "") for event in events if event.get("ev") == "ws"] + if any("transport error" in detail or "fallback" in detail for detail in ws_details): + raise AssertionError(f"generic-error: API response burned transport ladder: {ws_details!r}") + + +def run_chain_reanchor_scenario( + tmp: str, codex_home: str, port: int, mock: CodexMock +) -> None: + """A recognized stale chain rebuilds once without requiring `continue`.""" + env, ambient = _env(tmp, codex_home, port) + with base.PtySession( + base.GRAFF, + ["--model", "codex", "--no-telemetry"], + cwd=tmp, + env=env, + unset_env=ambient, + timeout=20.0, + ) as session: + session.wait_for_prompt() + cursor = len(session.raw) + session.send_line("exercise automatic stale-chain recovery") + session.wait_for_literal(CHAIN_FINAL, start=cursor) + session.wait_for_prompt(start=cursor) + requests = mock.recorded_requests() + if mock.ws_turns != 3 or mock.sse_turns != 0 or mock.ws_connections != 2: + raise AssertionError( + "chain-error: expected delta rejection then one fresh WS rebuild: " + f"connections={mock.ws_connections} ws={mock.ws_turns} sse={mock.sse_turns}" + ) + if len(requests) != 3: + raise AssertionError(f"chain-error: expected 3 requests, got {requests!r}") + first, rejected, rebuilt = requests + if ( + first.connection_id != rejected.connection_id + or rejected.connection_id == rebuilt.connection_id + or rejected.body.get("previous_response_id") != "resp_chain_1" + or "previous_response_id" in rebuilt.body + ): + raise AssertionError( + "chain-error: rejected delta was not rebuilt as full input on a fresh WS: " + f"{requests!r}" + ) + rebuilt_types = [ + item.get("type") + for item in rebuilt.body.get("input", []) + if isinstance(item, dict) + ] + if "function_call" not in rebuilt_types or "function_call_output" not in rebuilt_types: + raise AssertionError(f"chain-error: full rebuild lost tool history: {rebuilt.body!r}") + _assert_ws_stays_primary(session) + _exit(session, "chain-error") diff --git a/scripts/codex_ws_mock.py b/scripts/codex_ws_mock.py index 0b97a6d8..d5921e33 100644 --- a/scripts/codex_ws_mock.py +++ b/scripts/codex_ws_mock.py @@ -385,13 +385,20 @@ def _serve_ws( self._log(f"ws <- {etype} ({len(message.payload)}b)") if etype != "response.create": continue - for ev in self._events("ws", connection_id, event, headers): + events = self._events("ws", connection_id, event, headers) + for ev in events: _send_frame( conn, OP_TEXT, json.dumps(ev, separators=(",", ":")).encode("utf-8") ) self._log(f"ws -> {ev['type']}") with self._lock: self.ws_turns += 1 + # Real Codex closes immediately after a terminal type:error frame. + # Keeping the mock open would miss #692's exact classification bug: + # the client waited for another frame, saw EOF, and discarded the + # useful API body as a transport reset. + if any(ev.get("type") == "error" for ev in events): + return def _serve_sse( self, conn: socket.socket, reader: _SockReader, headers: dict[str, str] diff --git a/scripts/test-pty-codex-ws.py b/scripts/test-pty-codex-ws.py index 07617519..631d5791 100644 --- a/scripts/test-pty-codex-ws.py +++ b/scripts/test-pty-codex-ws.py @@ -6,6 +6,7 @@ import tempfile from codex_ws_mock import CodexMock +import codex_ws_error_test as error_scenario import codex_ws_test as scenario @@ -100,6 +101,26 @@ def main() -> None: scenario.MIDTURN_CONTEXT_TOKENS = runtime_context scenario.MIDTURN_TOTAL_TOKENS = runtime_context * 9 // 10 + mock = CodexMock(events_for_request=error_scenario.generic_error_events) + port = mock.start() + try: + error_scenario.run_generic_error_scenario(tmp, codex_home, port, mock) + finally: + mock.stop() + print( + "ok generic WS API error: one bounded diagnostic, no transport retry/fallback" + ) + + mock = CodexMock(events_for_request=error_scenario.chain_error_events) + port = mock.start() + try: + error_scenario.run_chain_reanchor_scenario(tmp, codex_home, port, mock) + finally: + mock.stop() + print( + "ok stale WS chain error: automatic full-input re-anchor on one fresh socket" + ) + mock = CodexMock(events_for_request=scenario.midturn_events) port = mock.start() try: diff --git a/src/agent_request.zig b/src/agent_request.zig index 3cbf5ae0..58fff510 100644 --- a/src/agent_request.zig +++ b/src/agent_request.zig @@ -425,13 +425,14 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { }, .err => |failure| { const msg = failure.message; - // (#codex-ws) Belt-and-braces mirror of openai/codex: server - // rejected previous_response_id (stale WS session it no - // longer recognizes) — close it and retry once with full - // input. Gated on codex_prev_id != null (a delta was - // actually sent); it's null after the retry, so this can't - // loop for this request. - if (self.codex_prev_id != null and codex_chain.shouldDropChain(msg, failure.code)) { + const had_chain = self.codex_prev_id != null; + const ws_error_frame = if (self.codex_ws) |c| c.dead else false; + const diagnostic = try responses.failureDiagnostic(self.arena, self.provider.id, failure); + if (ws_error_frame) { + self.closeCodexWs(); + if (self.tracer) |tr| tr.note("ws_api_error", diagnostic); + } + if (had_chain and codex_chain.shouldDropChain(msg, failure.code)) { self.closeCodexWs(); if (self.tracer) |tr| tr.note("ws", "server dropped previous_response_id — re-anchoring with full input"); continue :rebuild; @@ -468,7 +469,7 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { // overload retry path as SSE and JSON error envelopes. if (try retryTransientServerError(self, "", failure.code, msg, &server_retries)) continue :rebuild; if (self.tracer) |tr| tr.api(self.label, self.sub, self.provider.model, ms, body.len, resp_body.len, 0, 0, true); - try self.sayApiError("{s} api error: {s}", .{ self.provider.id, msg }); + try self.sayApiError("{s}", .{diagnostic}); return error.ApiError; }, } diff --git a/src/agent_responses.zig b/src/agent_responses.zig index 7ee0e0b8..f7a1ec57 100644 --- a/src/agent_responses.zig +++ b/src/agent_responses.zig @@ -100,6 +100,47 @@ pub fn parseResponses(self: *Agent, body: []const u8) !ResponsesResult { return error.Unparseable; } +/// Safe diagnostic for a parsed Responses failure. This is the redaction +/// boundary for WS error frames: only the provider, structured code and message +/// survive — never the raw envelope, echoed request, headers or auth fields. +/// Each field is single-lined and bounded before it reaches last_api_error or +/// the default-on trace. +pub fn failureDiagnostic(allocator: std.mem.Allocator, provider: []const u8, failure: ResponsesFailure) ![]u8 { + var buf: [560]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try writeDiagnosticField(&w, provider, 40); + try w.writeAll(" api error"); + if (failure.code) |code| { + try w.writeAll(" ["); + try writeDiagnosticField(&w, code, 96); + try w.writeByte(']'); + } + try w.writeAll(": "); + try writeDiagnosticField(&w, failure.message, 384); + return allocator.dupe(u8, w.buffered()); +} + +fn writeDiagnosticField(w: *std.Io.Writer, raw: []const u8, max: usize) !void { + const prefix = util.utf8Prefix(raw, max); + for (prefix) |b| try w.writeByte(if (b < 0x20 or b == 0x7f) ' ' else b); + if (prefix.len < raw.len) try w.writeAll("…"); +} + +test "failureDiagnostic retains only bounded single-line code and message" { + const a = std.testing.allocator; + const long = "x" ** 500; + const diagnostic = try failureDiagnostic(a, "codex", .{ + .code = "invalid_request_error\nignored-envelope", + .message = "bad request\r\n" ++ long, + }); + defer a.free(diagnostic); + try std.testing.expect(std.mem.startsWith(u8, diagnostic, "codex api error [invalid_request_error ignored-envelope]: bad request ")); + try std.testing.expect(std.mem.endsWith(u8, diagnostic, "…")); + try std.testing.expect(diagnostic.len < 560); + try std.testing.expect(std.mem.indexOfScalar(u8, diagnostic, '\n') == null); + try std.testing.expect(std.mem.indexOfScalar(u8, diagnostic, '\r') == null); +} + test "parseResponses: terminal failure beats partial items; incomplete stays marked" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); diff --git a/src/agent_ws.zig b/src/agent_ws.zig index 0dfa87fc..caefefe7 100644 --- a/src/agent_ws.zig +++ b/src/agent_ws.zig @@ -579,14 +579,12 @@ pub fn postResponsesWs(self: *Agent, body: []const u8) ![]u8 { try full.writer.writeAll(fbuf.items); try full.writer.writeByte('\n'); const line = try std.fmt.allocPrint(arena, "data: {s}", .{fbuf.items}); - // xAI error frames are terminal but not in isStreamEnd's set. Both - // arms route through postLive's close + redial (resets chain state). - switch (signal.errorFrameAction(fbuf.items)) { - .none => {}, - .retire, .chain_lost => |act| { - if (self.tracer) |tr| tr.note("ws", if (act == .retire) "server connection limit — retiring socket" else "chain anchor gone — re-anchoring full"); - return error.ConnectionResetByPeer; - }, + // Codex closes after `type:error`; return the accumulated API body + // instead of waiting for EOF and misclassifying it as transport loss. + if (signal.errorFrameAction(gpa, fbuf.items) != .none) { + client.dead = true; // no courtesy close write to a closing peer + if (self.tracer) |tr| tr.note("ws", "terminal API error frame"); + break :stream; } if (isStreamEnd(arena, self.provider.kind, line)) { if (self.tracer) |tr| tr.note("ws", "completed"); diff --git a/src/agent_ws_mock.zig b/src/agent_ws_mock.zig index 1f81e65c..70871df0 100644 --- a/src/agent_ws_mock.zig +++ b/src/agent_ws_mock.zig @@ -20,6 +20,7 @@ pub fn nowMs(io: Io) i64 { pub const delta_event = "{\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}"; pub const completed_event = "{\"type\":\"response.completed\"}"; +pub const generic_error_event = "{\"type\":\"error\",\"error\":{\"code\":\"invalid_request_error\",\"message\":\"mock bad request\"}}"; /// What the backend actually puts on the socket in the first milliseconds after /// a send, before the model has produced anything: the two protocol events, then @@ -76,6 +77,9 @@ pub const Mock = struct { /// Upgrade, take the client's frame, stream a whitelisted meta call's /// arguments (`arg_prose_events`) and then go silent. arg_prose_then_silence, + /// Send a generic terminal API error and close immediately, matching + /// Codex's failure sequence from issue #692. + generic_error_then_close, }; pub fn run(io: Io, server: *std.Io.net.Server, mode: Mode, done: *std.atomic.Value(bool)) void { @@ -119,6 +123,11 @@ pub const Mock = struct { for (arg_prose_events) |ev| writeTextFrame(&sw.interface, ev) catch return idle(io, done); }, + .generic_error_then_close => { + readClientFrame(&sr.interface) catch return; + writeTextFrame(&sw.interface, generic_error_event) catch return; + return; + }, } idle(io, done); } diff --git a/src/agent_ws_reuse_test.zig b/src/agent_ws_reuse_test.zig index cb6dbc64..0f7a84b3 100644 --- a/src/agent_ws_reuse_test.zig +++ b/src/agent_ws_reuse_test.zig @@ -233,3 +233,42 @@ test "#401: a fresh connect keeps the full pre-first-token budget for a slow fir try std.testing.expect(!traced(&tw, "\"detail\":\"stall\"")); try std.testing.expect(agent.codex_ws != null); // held for the next delta } + +test "#692: a generic error frame is returned as the terminal API body before peer close" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + + var addr = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&addr, io, .{}); + defer server.deinit(io); + var done: std.atomic.Value(bool) = .init(false); + var fut = io.async(Mock.run, .{ io, &server, Mock.Mode.generic_error_then_close, &done }); + defer fut.await(io); + defer done.store(true, .release); + + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var tw: Io.Writer.Allocating = .init(gpa); + defer tw.deinit(); + var tracer: trace.Tracer = .{ .io = io, .gpa = gpa, .out = &tw.writer, .start = Io.Timestamp.now(io, .awake) }; + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/x", .{server.socket.address.getPort()}); + var agent = mockAgent(gpa, arena, io, url); + agent.tracer = &tracer; + defer if (agent.codex_ws) |c| { + c.dead = true; + c.deinit(gpa); + agent.codex_ws = null; + }; + + const out = try agent_ws.postResponsesWs(&agent, "{\"model\":\"gpt-5\",\"input\":[]}"); + defer gpa.free(out); + try std.testing.expect(std.mem.indexOf(u8, out, mock.generic_error_event) != null); + try std.testing.expect(traced(&tw, "\"detail\":\"terminal API error frame\"")); + try std.testing.expect(!traced(&tw, "transport error")); + try std.testing.expect(agent.codex_ws != null); + try std.testing.expect(agent.codex_ws.?.dead); +} diff --git a/src/agent_ws_signal.zig b/src/agent_ws_signal.zig index 8fc7f529..b503c482 100644 --- a/src/agent_ws_signal.zig +++ b/src/agent_ws_signal.zig @@ -127,18 +127,31 @@ pub const TokenSignal = struct { } }; -/// xAI WS error frames ({"type":"error"}) are terminal for the turn but not in -/// isStreamEnd's completed/failed set, so without classification they burn the -/// whole stall budget on a doomed socket. -pub const ErrorFrameAction = enum { none, retire, chain_lost }; +/// Responses WS `type:error` frames are terminal API responses, not transport +/// failures. The two known chain errors retain distinct labels for trace +/// diagnostics, but all three actions return the accumulated response body to +/// parseResponses instead of waiting for the peer's expected close frame. +pub const ErrorFrameAction = enum { none, api_error, retire, chain_lost }; -pub fn errorFrameAction(frame: []const u8) ErrorFrameAction { - if (std.mem.indexOf(u8, frame, "\"type\":\"error\"") == null) return .none; - // The server sends this right before closing a 25-minute-old socket. - if (std.mem.indexOf(u8, frame, "websocket_connection_limit_reached") != null) return .retire; - // Our chain anchor is gone (evicted / never cached under store:false). - if (std.mem.indexOf(u8, frame, "previous_response_not_found") != null) return .chain_lost; - return .none; +pub fn errorFrameAction(gpa: std.mem.Allocator, frame: []const u8) ErrorFrameAction { + // Error frames are rare. Parse only a cheap candidate so whitespace around + // `type` is accepted and prose that merely quotes `"type":"error"` is not. + if (std.mem.indexOf(u8, frame, "error") == null) return .none; + var scratch = std.heap.ArenaAllocator.init(gpa); + defer scratch.deinit(); + const v = std.json.parseFromSliceLeaky(std.json.Value, scratch.allocator(), frame, .{ .allocate = .alloc_always }) catch return .none; + if (v != .object) return .none; + const ty = v.object.get("type") orelse return .none; + if (ty != .string or !std.mem.eql(u8, ty.string, "error")) return .none; + const err = v.object.get("error"); + const code = if (err) |e| (if (e == .object) e.object.get("code") else null) else null; + if (code) |c| if (c == .string) { + // The server sends this right before closing a 25-minute-old socket. + if (std.mem.eql(u8, c.string, "websocket_connection_limit_reached")) return .retire; + // Our chain anchor is gone (evicted / never cached under store:false). + if (std.mem.eql(u8, c.string, "previous_response_not_found")) return .chain_lost; + }; + return .api_error; } /// The deadline for writing a frame of `frame_len` bytes. @@ -156,9 +169,11 @@ pub fn sendDeadlineMs(frame_len: usize, head_ms: u64, stream_ms: u64) u64 { return @min(head_ms +| grow, @max(head_ms, stream_ms)); } -test "errorFrameAction classifies the two xAI ws error codes, ignores prose" { - try std.testing.expectEqual(ErrorFrameAction.retire, errorFrameAction("{\"type\":\"error\",\"error\":{\"code\":\"websocket_connection_limit_reached\"}}")); - try std.testing.expectEqual(ErrorFrameAction.chain_lost, errorFrameAction("{\"type\":\"error\",\"error\":{\"code\":\"previous_response_not_found\"}}")); - try std.testing.expectEqual(ErrorFrameAction.none, errorFrameAction("{\"type\":\"response.output_text.delta\",\"delta\":\"websocket_connection_limit_reached\"}")); - try std.testing.expectEqual(ErrorFrameAction.none, errorFrameAction("{\"type\":\"error\",\"error\":{\"code\":\"other\"}}")); +test "errorFrameAction classifies every actual error frame and ignores quoted prose" { + const a = std.testing.allocator; + try std.testing.expectEqual(ErrorFrameAction.retire, errorFrameAction(a, "{\"type\":\"error\",\"error\":{\"code\":\"websocket_connection_limit_reached\"}}")); + try std.testing.expectEqual(ErrorFrameAction.chain_lost, errorFrameAction(a, "{\"type\":\"error\",\"error\":{\"code\":\"previous_response_not_found\"}}")); + try std.testing.expectEqual(ErrorFrameAction.api_error, errorFrameAction(a, "{ \"type\" : \"error\", \"error\" : {\"code\":\"invalid_request_error\"} }")); + try std.testing.expectEqual(ErrorFrameAction.none, errorFrameAction(a, "{\"type\":\"response.output_text.delta\",\"delta\":\"quoted \\\"type\\\":\\\"error\\\"\"}")); + try std.testing.expectEqual(ErrorFrameAction.none, errorFrameAction(a, "not json: error")); } diff --git a/src/agent_ws_test.zig b/src/agent_ws_test.zig index 52dfb8c6..6894ac34 100644 --- a/src/agent_ws_test.zig +++ b/src/agent_ws_test.zig @@ -79,7 +79,7 @@ test "codexWsIdleExpired: fires only strictly past the idle limit (codex-ws)" { test "the codex .responses arm refreshes auth and re-anchors before resending (#402)" { const src = @embedFile("agent_request.zig"); const arm_start = std.mem.indexOf(u8, src, "unparseable codex response").?; - const arm_end = std.mem.indexOf(u8, src, "{s} api error: {s}").?; + const arm_end = std.mem.indexOf(u8, src, "try self.sayApiError(\"{s}\", .{diagnostic})").?; const arm = src[arm_start..arm_end]; const call = std.mem.indexOf(u8, arm, "retryAfterAuthRefresh(self, msg, &auth_refreshed)") orelse diff --git a/src/repl_turn.zig b/src/repl_turn.zig index 624e9e98..b8cb137b 100644 --- a/src/repl_turn.zig +++ b/src/repl_turn.zig @@ -210,6 +210,10 @@ pub fn replTurnCb(ctx_ptr: ?*anyopaque, gpa: Allocator, history: []const repl.Tu error.StreamStalled => return earlyEnd(gpa, &agent, "stream stalled"), // A mid-stream provider drop (#133), same handling as a stall. error.StreamDropped => return earlyEnd(gpa, &agent, "connection dropped"), + // The Responses WS path now preserves a bounded provider diagnostic. + // Return it as the turn result so the fullscreen TUI does not discard + // the live error and replace it with the misleading API-key fallback. + error.ApiError => return gpa.dupe(u8, agent.last_api_error orelse "provider API error") catch null, error.FallbackConsentRequired => return gpa.dupe(u8, "Saved model unavailable. Allow this provider with /fallback in the standard REPL, or choose another model.") catch null, else => return null, }; From c7a54267b32eb6ab08665d176f7a64282b8863c9 Mon Sep 17 00:00:00 2001 From: yxlyx <85774423+yxlyx@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:07:34 +0800 Subject: [PATCH 07/27] fix(tui): recover poisoned TLS client generations A request-construction TLS failure occurs before the existing connection poison can run, so retries and later TUI trajectories kept reusing a launch-scoped client that only process replacement could heal. Route model HTTP calls through leased generations, rotate on construction failure without deinitializing in-flight users, and preserve the original client for unrelated launch consumers. Gate every managed constructor on CA readiness, distinguish CA warm and request-construction failures in traces, and cover concurrent stale reports plus later ordinary and child POST recovery with a loopback regression. Co-Authored-By: Codegraff --- ...l-http-client-recovery-uses-generations.md | 35 +++ docs/adr/README.md | 1 + src/agent_request.zig | 15 +- src/agent_stream.zig | 13 +- src/http.zig | 37 ++- src/http_client.zig | 275 ++++++++++++++++++ src/http_client_integration_tests.zig | 67 +++++ src/http_warm.zig | 8 +- src/main.zig | 29 +- 9 files changed, 430 insertions(+), 50 deletions(-) create mode 100644 docs/adr/0042-model-http-client-recovery-uses-generations.md create mode 100644 src/http_client.zig create mode 100644 src/http_client_integration_tests.zig diff --git a/docs/adr/0042-model-http-client-recovery-uses-generations.md b/docs/adr/0042-model-http-client-recovery-uses-generations.md new file mode 100644 index 00000000..08d19ae2 --- /dev/null +++ b/docs/adr/0042-model-http-client-recovery-uses-generations.md @@ -0,0 +1,35 @@ +# 0042. Model HTTP client recovery uses leased generations + +Status: accepted 2026-08-31 + +## Context + +Issue #691 captured launch-scoped `TlsInitializationFailed` storms: once +`std.http.Client.request` failed during TLS construction, six retries and every +later TUI turn reused the same client and failed until process restart. The +existing #177 connection poison cannot help because no `Request` exists yet. +The launch client is also shared with concurrent root, compaction, title, +recap, and subagent traffic, so deinitializing it in place would race users. + +## Decision + +Model HTTP constructors lease an active launch-level client generation. A +request-construction `TlsInitializationFailed` retires that generation and +publishes a prewarmed replacement under one mutex. Existing requests keep the +retired generation alive through reference-counted leases; later retries and +turns resolve the original launch pointer to the replacement. Owned retired +generations are deinitialized only after their final lease releases. + +The original launch client is never reclaimed by the generation manager: +unrelated launch consumers still holding its pointer remain safe until normal +shutdown. All managed constructors wait for initial CA prewarm readiness, and +CA-prewarm plus request-construction failures leave distinct trace evidence. +Post-construction send/read failures retain #177's per-connection poison. + +## Consequences + +A recovered network can serve later root, synthetic, compaction, and child +model trajectories without replacing the process or durable session. Recovery +adds one mutex operation per model HTTP request and temporarily retains an old +client while requests from that generation are still in flight. WebSocket +transport remains independently managed; this record governs HTTP model calls. diff --git a/docs/adr/README.md b/docs/adr/README.md index e76f1c51..c2943a4e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -52,6 +52,7 @@ record only when you need the evidence or the edge cases. | [0039](0039-local-tools-are-project-scripts.md) | Agent-authored local tools are project scripts under `.graff/tools/`; skills stay instructions. Runtime catalog extras, not `schema.effectiveRootSpecs`. | | [0040](0040-codedb-stays-when-licensed.md) | Ordinary reads use native `codedb` / `read_file`; codedb-pro is extra search, not the default reader. | | [0041](0041-tui-is-an-acp-client.md) | The fullscreen TUI is an in-process ACP client: session/prompt in, session/update thought/tool/text out. No child `graff acp`. | +| [0042](0042-model-http-client-recovery-uses-generations.md) | Model HTTP calls lease a recoverable client generation; request-construction TLS failure rotates safely without deinitializing in-flight users. | ## When to write one diff --git a/src/agent_request.zig b/src/agent_request.zig index 3cbf5ae0..8539cf90 100644 --- a/src/agent_request.zig +++ b/src/agent_request.zig @@ -113,9 +113,9 @@ pub const landing_note = "results beat dying mid-tool-call."; pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { - // Startup paints the prompt while CA loading continues. The root turn and - // title task rendezvous here, then issue their requests concurrently. + // Root and title requests rendezvous after launch-time CA loading. http.waitForClientReady(self.io); + if (http.takeCaWarmFailure()) if (self.tracer) |tr| tr.note("ca_prewarm_failed", "CA bundle rescan failed; request will use lazy TLS initialization"); if (self.registry) |reg| { if (@import("mcp_boot.zig").joinBeforeRequest(reg)) { self.invalidateRootTools(); @@ -346,6 +346,10 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { if (self.tracer) |tr| tr.api(self.label, self.sub, self.provider.model, 0, body.len, 0, 0, 0, true); return error.ApiError; } + if (err == error.TlsRequestConstructionFailed or err == error.TlsRequestConstructionCaWarmFailed) if (self.tracer) |tr| tr.note( + "tls_request_construction", + if (err == error.TlsRequestConstructionCaWarmFailed) "rotated shared HTTP client generation; replacement CA prewarm failed" else "rotated shared HTTP client generation", + ); if (attempt < max_attempts) { if (throttled) { // #retry-after: prefer the provider's Retry-After @@ -362,11 +366,8 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { if (self.tracer) |tr| tr.note("retry", what); self.sleepInterruptible(delay_ms) catch return error.Interrupted; } else { - // Transport flake (HttpConnectionClosing, a reset, - // a truncated TLS read): back off before a fresh - // connection. Rapid-fire retries against a - // just-closed keep-alive almost always re-fail - // (#86). 250ms·2ⁿ, capped at 4s over 6 tries; Esc cancels. + // Transport flakes back off; rapid retries against a + // just-closed keep-alive re-fail (#86). Cap: 4s/6 tries. const delay_ms = RetryPlan.delayMs(throttled, attempt); @import("turn_chrome.zig").emitRetryNotice(self.io, @errorName(err), attempt + 1, max_attempts); if (showRecoveredTransportRetry(self.call_kind)) diff --git a/src/agent_stream.zig b/src/agent_stream.zig index 7335450c..03533704 100644 --- a/src/agent_stream.zig +++ b/src/agent_stream.zig @@ -24,6 +24,7 @@ const reasoningDelta = @import("title.zig").reasoningDelta; const stream_tests = @import("agent_stream_tests.zig"); const http = @import("http.zig"); +const http_client = @import("http_client.zig"); const http_headers = @import("http_headers.zig"); const providerUserAgent = http.providerUserAgent; const capture5xxBodyStream = http.capture5xxBodyStream; @@ -52,6 +53,11 @@ pub fn postStream(self: *Agent, body: []const u8) ![]u8 { /// keep-alive cannot poison the WS→SSE handoff and every fallback retry dials /// from a clean pool. pub fn postStreamWithClient(self: *Agent, client: *std.http.Client, body: []const u8) ![]u8 { + http_client.waitForReady(client.io); + var lease = http_client.acquire(client); + defer lease.release(); + if (http_client.injectedConstructionTls(&lease)) |err| return err; + const transport = lease.client; const sink = engine_sink.forAgent(self); sink.emit(self.io, .stream_begin); // Every exit path — success, interrupt, transport error — tears down the @@ -109,14 +115,17 @@ pub fn postStreamWithClient(self: *Agent, client: *std.http.Client, body: []cons _ = drainSteerStdin(true); restoreStdin(o); }; - var req = try client.request(.POST, try std.Uri.parse(provider.url), .{ + var req = transport.request(.POST, try std.Uri.parse(provider.url), .{ .redirect_behavior = .unhandled, .headers = .{ .content_type = .{ .override = "application/json" }, .user_agent = providerUserAgent(provider), }, .extra_headers = extra, - }); + }) catch |err| { + if (err == error.TlsInitializationFailed) return http_client.constructionTlsError(transport); + return err; + }; defer req.deinit(); // A failed SEND leaves reader.state == .ready, which Request.deinit // reads as "connection still clean" and returns it to the keep-alive diff --git a/src/http.zig b/src/http.zig index 2aa9ac13..9794d27d 100644 --- a/src/http.zig +++ b/src/http.zig @@ -16,15 +16,9 @@ const Provider = provider_mod.Provider; const Agent = agent_mod.Agent; const headers = @import("http_headers.zig"); const stall = @import("http_stall.zig"); // #56: the watchdogs' pure budget arithmetic - -/// Launch-scoped gate installed while the shared client's CA bundle warms in -/// the background. Null in unit tests and standalone pre-client subcommands. -pub var g_client_ready: ?*Io.Event = null; - -pub fn waitForClientReady(io: Io) void { - if (g_client_ready) |ready| ready.waitUncancelable(io); -} - +const http_client = @import("http_client.zig"); +pub const waitForClientReady = http_client.waitForReady; +pub const takeCaWarmFailure = http_client.takeCaWarmFailure; pub const providerUserAgent = headers.userAgent; pub const providerHeaders = headers.providerHeaders; /// Test/call-site seam: the !live request path's POST, with an explicit conv id. @@ -113,15 +107,15 @@ test "retryAfterMs: seconds, ms preferred, cap, HTTP-date/none -> 0 (#retry-afte /// POST the request body; returns the raw response body (caller frees). /// Built on client.request, NOT client.fetch: fetch never exposes the -/// Request, so a failed body send could not be poisoned — std re-pooled the -/// dead connection (a failed SEND leaves reader.state == .ready, which -/// Request.deinit reads as "still clean") and findConnection handed the same -/// corpse to every retry and every later same-host request, so one -/// WriteFailed became a whole-session storm across compaction, [title], and -/// subagents (#177). Mirrors postStream's errdefer poison (agent_stream.zig). -/// The client and its connection pool stay shared across pool threads — -/// client.request is what fetch wraps and is equally thread-safe. +/// Request, so a failed body send could not be poisoned and std re-pooled the +/// dead connection across later retries, compaction, titles, and subagents +/// (#177). Mirrors postStream's errdefer poison (agent_stream.zig). fn post(gpa: Allocator, client: *std.http.Client, provider: Provider, body: []const u8, conv_id: ?[]const u8) ![]u8 { + http_client.waitForReady(client.io); + var lease = http_client.acquire(client); + defer lease.release(); + if (http_client.injectedConstructionTls(&lease)) |err| return err; + const transport = lease.client; var aw: Io.Writer.Allocating = .init(gpa); errdefer aw.deinit(); @@ -132,16 +126,19 @@ fn post(gpa: Allocator, client: *std.http.Client, provider: Provider, body: []co defer if (bearer.len > 0) gpa.free(bearer); var headers_buf: [12]std.http.Header = undefined; - const extra = headers.providerHeadersWithConv(client.io, provider, bearer, &headers_buf, conv_id); + const extra = headers.providerHeadersWithConv(transport.io, provider, bearer, &headers_buf, conv_id); - var req = try client.request(.POST, try std.Uri.parse(provider.url), .{ + var req = transport.request(.POST, try std.Uri.parse(provider.url), .{ .redirect_behavior = .unhandled, .headers = .{ .content_type = .{ .override = "application/json" }, .user_agent = providerUserAgent(provider), }, .extra_headers = extra, - }); + }) catch |err| { + if (err == error.TlsInitializationFailed) return http_client.constructionTlsError(transport); + return err; + }; defer req.deinit(); // The #177 poison: on ANY error make deinit discard this connection // instead of returning it to the keep-alive pool, so the retry (and diff --git a/src/http_client.zig b/src/http_client.zig new file mode 100644 index 00000000..016d7a38 --- /dev/null +++ b/src/http_client.zig @@ -0,0 +1,275 @@ +//! Launch-level HTTP client generations for model traffic. +//! +//! A `TlsInitializationFailed` raised by `Client.request` occurs before a +//! Request exists, so the connection-poison cleanup in http.zig cannot touch +//! it. Model calls lease the active generation through this module. On that +//! construction error, one caller rotates the generation; in-flight callers +//! keep their old lease, and owned retired clients are reclaimed only after +//! their final lease is released. + +const std = @import("std"); +const builtin = @import("builtin"); +const Io = std.Io; +const Allocator = std.mem.Allocator; +const warm = @import("http_warm.zig"); + +const Generation = struct { + client: *std.http.Client, + id: u64, + refs: usize = 0, + retired: bool = false, + owned: bool, + next: ?*Generation = null, +}; + +pub const RecoveryOutcome = enum { + unavailable, + rotated, + rotated_ca_warm_failed, + already_rotated, +}; + +pub const Recovery = struct { + gpa: Allocator, + io: Io, + mutex: Io.Mutex = .init, + original: Generation, + active: *Generation, + retired: ?*Generation = null, + next_id: u64 = 1, + warm_replacements: bool, + + pub fn init(self: *Recovery, gpa: Allocator, io: Io, original: *std.http.Client, warm_replacements: bool) void { + self.* = .{ + .gpa = gpa, + .io = io, + .original = .{ .client = original, .id = 0, .owned = false }, + .active = undefined, + .warm_replacements = warm_replacements, + }; + self.active = &self.original; + } + + pub fn deinit(self: *Recovery) void { + std.debug.assert(self.active.refs == 0); + if (self.active.owned) self.destroyOwned(self.active); + var cursor = self.retired; + while (cursor) |generation| { + const next = generation.next; + std.debug.assert(generation.refs == 0); + if (generation.owned) self.destroyOwned(generation); + cursor = next; + } + self.retired = null; + } + + pub fn acquire(self: *Recovery, requested: *std.http.Client) Lease { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + if (requested != self.original.client and requested != self.active.client) + return .{ .client = requested }; + self.active.refs += 1; + return .{ .client = self.active.client, .owner = self, .generation = self.active, .generation_id = self.active.id }; + } + + /// Retire the generation that failed while constructing a request. If a + /// concurrent caller already rotated it, this is a successful no-op: the + /// next retry will acquire that newer generation. + pub fn recoverConstructionTls(self: *Recovery, failed: *std.http.Client) RecoveryOutcome { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + if (failed != self.active.client) + return if (failed == self.original.client or self.isRetired(failed)) .already_rotated else .unavailable; + + const client = self.gpa.create(std.http.Client) catch return .unavailable; + client.* = .{ .allocator = self.gpa, .io = self.io }; + var ca_warm_failed = false; + if (self.warm_replacements) warm.prewarmCaBundle(client, self.gpa, self.io) catch { + ca_warm_failed = true; + }; + const generation = self.gpa.create(Generation) catch { + client.deinit(); + self.gpa.destroy(client); + return .unavailable; + }; + generation.* = .{ .client = client, .id = self.next_id, .owned = true }; + self.next_id += 1; + + const old = self.active; + old.retired = true; + old.next = self.retired; + self.retired = old; + self.active = generation; + if (old.refs == 0) self.reclaim(old); + return if (ca_warm_failed) .rotated_ca_warm_failed else .rotated; + } + + fn release(self: *Recovery, generation: *Generation) void { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + std.debug.assert(generation.refs > 0); + generation.refs -= 1; + if (generation.retired and generation.refs == 0) self.reclaim(generation); + } + + fn isRetired(self: *Recovery, client: *std.http.Client) bool { + var cursor = self.retired; + while (cursor) |generation| : (cursor = generation.next) { + if (generation.client == client) return true; + } + return false; + } + + fn reclaim(self: *Recovery, target: *Generation) void { + var link = &self.retired; + while (link.*) |generation| { + if (generation == target) { + link.* = generation.next; + generation.next = null; + generation.retired = false; + if (generation.owned) self.destroyOwned(generation); + return; + } + link = &generation.next; + } + } + + fn destroyOwned(self: *Recovery, generation: *Generation) void { + generation.client.deinit(); + self.gpa.destroy(generation.client); + self.gpa.destroy(generation); + } +}; + +pub const Lease = struct { + client: *std.http.Client, + owner: ?*Recovery = null, + generation: ?*Generation = null, + generation_id: u64 = 0, + + pub fn release(self: *Lease) void { + if (self.owner) |owner| owner.release(self.generation.?); + self.* = .{ .client = self.client }; + } +}; + +var g_recovery: ?*Recovery = null; +var g_client_ready: ?*Io.Event = null; +var g_ca_warm_failed: std.atomic.Value(bool) = .init(false); +const no_test_failure = std.math.maxInt(u64); +var g_test_fail_generation: std.atomic.Value(u64) = .init(no_test_failure); + +pub fn acquire(requested: *std.http.Client) Lease { + if (g_recovery) |recovery| return recovery.acquire(requested); + return .{ .client = requested }; +} + +pub fn constructionTlsError(failed: *std.http.Client) anyerror { + const outcome = if (g_recovery) |recovery| recovery.recoverConstructionTls(failed) else .unavailable; + return switch (outcome) { + .unavailable => error.TlsInitializationFailed, + .rotated_ca_warm_failed => error.TlsRequestConstructionCaWarmFailed, + .rotated, .already_rotated => error.TlsRequestConstructionFailed, + }; +} + +pub fn injectConstructionTlsForTest(generation: u64) void { + if (builtin.is_test) g_test_fail_generation.store(generation, .release); +} + +pub fn injectedConstructionTls(lease: *const Lease) ?anyerror { + if (!builtin.is_test) return null; + if (g_test_fail_generation.cmpxchgStrong(lease.generation_id, no_test_failure, .acq_rel, .acquire) == null) + return constructionTlsError(lease.client); + return null; +} + +pub fn waitForReady(io: Io) void { + if (g_client_ready) |ready| ready.waitUncancelable(io); +} + +pub fn takeCaWarmFailure() bool { + return g_ca_warm_failed.swap(false, .acq_rel); +} + +pub const Runtime = struct { + gpa: Allocator, + io: Io, + client: std.http.Client, + ready: Io.Event, + warm_future: Io.Future(void), + recovery: Recovery, + + pub fn init(self: *Runtime, gpa: Allocator, io: Io) void { + self.gpa = gpa; + self.io = io; + self.client = .{ .allocator = gpa, .io = io }; + self.ready = .unset; + self.recovery.init(gpa, io, &self.client, true); + g_recovery = &self.recovery; + g_client_ready = &self.ready; + g_ca_warm_failed.store(false, .release); + self.warm_future = io.async(warm.prewarmCaBundleTask, .{ &self.client, gpa, io, &self.ready, &g_ca_warm_failed }); + } + + pub fn deinit(self: *Runtime, await_io: Io) void { + _ = self.warm_future.await(await_io); + g_client_ready = null; + g_recovery = null; + self.recovery.deinit(); + self.client.deinit(); + } +}; + +test "request-construction TLS recovery reaches later root and child trajectories" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = gpa, .io = io }; + defer original.deinit(); + var recovery: Recovery = undefined; + recovery.init(gpa, io, &original, false); + defer recovery.deinit(); + + var failed_root = recovery.acquire(&original); + try std.testing.expectEqual(@as(u64, 0), failed_root.generation_id); + try std.testing.expectEqual(RecoveryOutcome.rotated, recovery.recoverConstructionTls(failed_root.client)); + + var later_root = recovery.acquire(&original); + defer later_root.release(); + var child = recovery.acquire(&original); + defer child.release(); + try std.testing.expectEqual(@as(u64, 1), later_root.generation_id); + try std.testing.expectEqual(later_root.generation_id, child.generation_id); + try std.testing.expect(later_root.client == child.client); + try std.testing.expect(later_root.client != failed_root.client); + failed_root.release(); +} + +fn recoverTask(recovery: *Recovery, client: *std.http.Client) RecoveryOutcome { + return recovery.recoverConstructionTls(client); +} + +test "concurrent stale TLS reports rotate a generation only once" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = gpa, .io = io }; + defer original.deinit(); + var recovery: Recovery = undefined; + recovery.init(gpa, io, &original, false); + defer recovery.deinit(); + + var first = recovery.acquire(&original); + var concurrent = recovery.acquire(&original); + var first_fut = io.async(recoverTask, .{ &recovery, first.client }); + var second_fut = io.async(recoverTask, .{ &recovery, concurrent.client }); + const first_result = first_fut.await(io); + const second_result = second_fut.await(io); + try std.testing.expect(first_result != .unavailable); + try std.testing.expect(second_result != .unavailable); + try std.testing.expect(first_result != second_result); + var after = recovery.acquire(&original); + defer after.release(); + try std.testing.expectEqual(@as(u64, 1), after.generation_id); + first.release(); + concurrent.release(); +} diff --git a/src/http_client_integration_tests.zig b/src/http_client_integration_tests.zig new file mode 100644 index 00000000..7b5f92a7 --- /dev/null +++ b/src/http_client_integration_tests.zig @@ -0,0 +1,67 @@ +//! End-to-end model-POST regression for request-construction TLS recovery. + +const std = @import("std"); +const Io = std.Io; +const http = @import("http.zig"); +const http_client = @import("http_client.zig"); +const Provider = @import("provider.zig").Provider; + +fn serveOk(io: Io, server: *std.Io.net.Server) void { + for (0..2) |_| { + const conn = server.accept(io) catch return; + defer conn.close(io); + var read_buf: [4096]u8 = undefined; + var reader = std.Io.net.Stream.Reader.init(conn, io, &read_buf); + while (true) { + const line = (reader.interface.takeDelimiter('\n') catch return) orelse return; + if (line.len == 0 or (line.len == 1 and line[0] == '\r')) break; + } + _ = reader.interface.take(2) catch return; + var write_buf: [256]u8 = undefined; + var writer = std.Io.net.Stream.Writer.init(conn, io, &write_buf); + writer.interface.writeAll("HTTP/1.1 200 OK\r\ncontent-length: 2\r\nconnection: close\r\n\r\nok") catch return; + writer.interface.flush() catch return; + } +} + +test "one TLS-broken generation recovers later ordinary and child model POSTs" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + defer runtime.deinit(io); + http.waitForClientReady(io); + + var address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&address, io, .{}); + defer server.deinit(io); + var server_future = io.async(serveOk, .{ io, &server }); + defer server_future.await(io); + + const bound = server.socket.address; + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/test", .{bound.getPort()}); + const provider: Provider = .{ + .id = "test", + .kind = .openai, + .auth = .x_api_key, + .url = url, + .api_key = "test", + .model = "test", + .context = 0, + }; + + http_client.injectConstructionTlsForTest(0); + try std.testing.expectError( + error.TlsRequestConstructionFailed, + http.postWithConv(gpa, &runtime.client, provider, "{}", "tui-root-failed"), + ); + + const ordinary = try http.postWithConv(gpa, &runtime.client, provider, "{}", "tui-root-later"); + defer gpa.free(ordinary); + try std.testing.expectEqualStrings("ok", ordinary); + + const child = try http.postWithConv(gpa, &runtime.client, provider, "{}", "tui-child-later"); + defer gpa.free(child); + try std.testing.expectEqualStrings("ok", child); +} diff --git a/src/http_warm.zig b/src/http_warm.zig index ff5c88cf..d95d744b 100644 --- a/src/http_warm.zig +++ b/src/http_warm.zig @@ -5,15 +5,15 @@ const Io = std.Io; /// Pre-load the shared HTTP client's CA bundle single-threaded so concurrent /// agents never race Zig's lazy first-connect rescan. -pub fn prewarmCaBundle(client: *std.http.Client, gpa: std.mem.Allocator, io: Io) void { +pub fn prewarmCaBundle(client: *std.http.Client, gpa: std.mem.Allocator, io: Io) !void { const now = Io.Clock.real.now(io); - client.ca_bundle.rescan(gpa, io, now) catch return; + try client.ca_bundle.rescan(gpa, io, now); client.now = now; } /// Warm off the launch critical path. Outbound users wait on /// `http.g_client_ready`, so prompt painting can overlap the scan safely. -pub fn prewarmCaBundleTask(client: *std.http.Client, gpa: std.mem.Allocator, io: Io, ready: *Io.Event) void { +pub fn prewarmCaBundleTask(client: *std.http.Client, gpa: std.mem.Allocator, io: Io, ready: *Io.Event, failed: *std.atomic.Value(bool)) void { defer ready.set(io); - prewarmCaBundle(client, gpa, io); + prewarmCaBundle(client, gpa, io) catch failed.store(true, .release); } diff --git a/src/main.zig b/src/main.zig index e6f5c174..48367233 100644 --- a/src/main.zig +++ b/src/main.zig @@ -4,9 +4,7 @@ const std = @import("std"); pub const panic = @import("tui").restore.Panic; // leave the alt screen BEFORE std prints a panic, or the restore sequence erases the trace (#535) const Io = std.Io; -const http_warm = @import("http_warm.zig"); -pub const prewarmCaBundle = http_warm.prewarmCaBundle; -const prewarmCaBundleTask = http_warm.prewarmCaBundleTask; +const http_client = @import("http_client.zig"); const Value = std.json.Value; const Allocator = std.mem.Allocator; const mcp = @import("mcp.zig"); @@ -292,16 +290,11 @@ pub fn main(init: std.process.Init) !void { const stale_saved_model = resolved_keys.stale_saved_model; const preferred_provider = resolved_keys.preferred_provider; const codex_account = resolved_keys.codex_account; - var client: std.http.Client = .{ .allocator = gpa, .io = io }; - defer client.deinit(); - var client_ready: Io.Event = .unset; - http.g_client_ready = &client_ready; - var client_warm_fut = io.async(prewarmCaBundleTask, .{ &client, gpa, io, &client_ready }); + var client_runtime: http_client.Runtime = undefined; + client_runtime.init(gpa, io); + defer client_runtime.deinit(startup_timing.shutdown_trace.at(io, "ca-warm-await")); + const client = &client_runtime.client; boot.mark(io, "CA warm scheduled"); - defer { - _ = client_warm_fut.await(startup_timing.shutdown_trace.at(io, "ca-warm-await")); - http.g_client_ready = null; - } var stdin_buf: [64 * 1024]u8 = undefined; var stdin_reader = Io.File.stdin().reader(io, &stdin_buf); const in = &stdin_reader.interface; @@ -309,7 +302,7 @@ pub fn main(init: std.process.Init) !void { var stdout_writer = Io.File.stdout().writer(io, &stdout_buf); const out = &stdout_writer.interface; g_out = out; - if (try session_start.runTitleCommand(io, gpa, arena, &client, default_provider, out, flags, &invocation_budget)) return; + if (try session_start.runTitleCommand(io, gpa, arena, client, default_provider, out, flags, &invocation_budget)) return; // Generate identity before opening either JSONL. The score channel and both // files share this run id; session_id is a separate runtime correlation id. session_start.initScoreRunId(io); @@ -359,7 +352,7 @@ pub fn main(init: std.process.Init) !void { } traj.node(.{ .kind = "session", .version = harness_version, .unix_ms = unixMs(io) }); - var telem = session_start.initTelemetry(io, gpa, &client, init.environ_map, flags, default_telemetry_endpoint); + var telem = session_start.initTelemetry(io, gpa, client, init.environ_map, flags, default_telemetry_endpoint); telemetry.g_telem = &telem; if (init.environ_map.get("GRAFF_FLEET")) |fv| { g_fleet = !(std.ascii.eqlIgnoreCase(fv, "off") or std.mem.eql(u8, fv, "0") or std.ascii.eqlIgnoreCase(fv, "false") or std.ascii.eqlIgnoreCase(fv, "no")); @@ -425,7 +418,7 @@ pub fn main(init: std.process.Init) !void { // Root Agent construction + post-construction config (session name, persisted thinking/goal/eval settings, session-start trace note) + the // backgrounded fleet-champion pull live in session_start.zig. `root`'s pointer fields (snapshots/client/tracer/approvals/registry) all reference // already-stable main()-owned storage passed in by address, so returning the constructed Agent by value here is safe. - var root = try session_run.buildRootAgent(gpa, arena, io, &client, default_provider, subagent_provider, init.environ_map, out, in, registry, &approvals, &tracer, sys_normal, &snaps, flags, telem.endpoint); + var root = try session_run.buildRootAgent(gpa, arena, io, client, default_provider, subagent_provider, init.environ_map, out, in, registry, &approvals, &tracer, sys_normal, &snaps, flags, telem.endpoint); root.run_budget = &invocation_budget; root.model_catalog = resolved_keys.model_catalog; root.stored_keys_loaded = resolved_keys.stored_keys_loaded; @@ -439,7 +432,7 @@ pub fn main(init: std.process.Init) !void { // JSONL and the privacy-projected upload are independent sinks; the Boot // owns both, wired and torn down in LIFO order (behavior_trace.zig). var behavior_buf: [8 * 1024]u8 = undefined; - var behavior_boot = behavior_trace.boot(io, gpa, &client, init.environ_map, telem.endpoint, telem.auth_key, telem.install_id, telem.client_name, harness_version, &behavior_buf); + var behavior_boot = behavior_trace.boot(io, gpa, client, init.environ_map, telem.endpoint, telem.auth_key, telem.install_id, telem.client_name, harness_version, &behavior_buf); behavior_boot.link(&tracer); // A dead local sink must not be silent: the collision that disabled local // capture in every session shipped invisibly because every failure path @@ -484,7 +477,7 @@ pub fn main(init: std.process.Init) !void { // `graff` is the default session. TTY `graff repl` / `graff tui` open the Grok-style pager. // `graff acp` (acp.zig) is the same idea over Zed's stdio Agent Client Protocol. Both self-contained — each exits after. - if (try session_run.runReplCommand(gpa, io, init.environ_map, &root, @import("bench_priors.zig").noteKeys(&keys), &client, in, out, arena, flags) or try @import("acp.zig").runAcpCommand(gpa, io, init.environ_map, &root, @import("bench_priors.zig").noteKeys(&keys), &client, in, out, arena, flags) or try @import("tui_launch.zig").maybeRun(gpa, io, init.environ_map, &root, @import("bench_priors.zig").noteKeys(&keys), &client, arena, flags, json_mode, g_cwd_display)) return; + if (try session_run.runReplCommand(gpa, io, init.environ_map, &root, @import("bench_priors.zig").noteKeys(&keys), client, in, out, arena, flags) or try @import("acp.zig").runAcpCommand(gpa, io, init.environ_map, &root, @import("bench_priors.zig").noteKeys(&keys), client, in, out, arena, flags) or try @import("tui_launch.zig").maybeRun(gpa, io, init.environ_map, &root, @import("bench_priors.zig").noteKeys(&keys), client, arena, flags, json_mode, g_cwd_display)) return; // One-shot print mode: run the single prompt to completion, print the final text to stdout, exit. if (flags.oneshot_prompt) |prompt_text| { try session_run.runOneshotPrompt(gpa, io, arena, &root, @import("bench_priors.zig").noteKeys(&keys), &tracer, out, prompt_text); // one-shot exits before loop_ctx below — capture keys for sub-first routing here too @@ -584,6 +577,8 @@ test { // pull in tests from imported modules (mcp.zig) _ = @import("mcp.zig"); _ = @import("mcp_rpc.zig"); _ = @import("main_test.zig"); + _ = @import("http_client.zig"); + _ = @import("http_client_integration_tests.zig"); // A module whose tests must run needs an explicit reference here (a plain @import elsewhere compiles to nothing); scripts/eval-tier1.sh --only reach catches one. _ = @import("test_hooks.zig"); // unreached modules; their tests were silently skipped _ = @import("agent_overflow_tests.zig"); // #414: and, through it, agent_overflow.zig's table tests From 484152095c118240316cfc2dd3e4cc4147343bf4 Mon Sep 17 00:00:00 2001 From: yxlyx <85774423+yxlyx@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:16:03 +0800 Subject: [PATCH 08/27] test(ci): avoid version-sensitive string repetition The local Zig 0.16 compiler accepted a repeated string expression in the new diagnostic test, but the repository's pinned Zig 0.17 CI compiler rejects it during parsing on both Linux and Windows. Build the same long message with the repository's established @splat pattern so the test remains portable across both toolchains. Co-Authored-By: Codegraff --- src/agent_responses.zig | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/agent_responses.zig b/src/agent_responses.zig index f7a1ec57..90df207a 100644 --- a/src/agent_responses.zig +++ b/src/agent_responses.zig @@ -128,10 +128,11 @@ fn writeDiagnosticField(w: *std.Io.Writer, raw: []const u8, max: usize) !void { test "failureDiagnostic retains only bounded single-line code and message" { const a = std.testing.allocator; - const long = "x" ** 500; + var long: [513]u8 = @splat('x'); + @memcpy(long[0..13], "bad request\r\n"); const diagnostic = try failureDiagnostic(a, "codex", .{ .code = "invalid_request_error\nignored-envelope", - .message = "bad request\r\n" ++ long, + .message = &long, }); defer a.free(diagnostic); try std.testing.expect(std.mem.startsWith(u8, diagnostic, "codex api error [invalid_request_error ignored-envelope]: bad request ")); From 23422550d820a0afe847649e52aa0cb9dc9f220a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 00:53:56 +0000 Subject: [PATCH 09/27] docs(release): cut v0.0.282 notes from yxlyx #674 #693 #694 Record the three product merges on the new release branch, remap the TLS-generation ADR to 0048 so 0042 stays TUI claims, and leave #277 / #200 parked. --- CHANGELOG.md | 11 +++++++ docs/releases/v0.0.282.md | 69 +++++++++++++++++++++++++++++++++++++++ docs/yxlyx-leftovers.md | 4 +++ src/cli.zig | 5 +++ src/libgraff.zig | 2 +- 5 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 docs/releases/v0.0.282.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 10149a92..787c3efd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,17 @@ The release workflow uses a tag's section here as its release notes (a hand-written `docs/releases/.md` wins if present), so keeping this file current is part of cutting a release. +## v0.0.282 (2026-08-31) + +- Next cut after v0.0.281 landed on main (`#670`). Headline is + yxlyx's three open product PRs: atomic paste spans (`#674`), + terminal Codex WS API errors (`#693`), and recoverable HTTP + client generations (`#694` / ADR 0048). +- `#694` numbered its record 0042; that slot is already TUI claims + screen (`#666`). The TLS-generation decision is **ADR 0048**. +- `#277` (Streamable HTTP OAuth / Smolify) and `#200` (idle + localhost servers) stay parked — Smolify is not a core MCP. + ## v0.0.281 (2026-08-29) - Next cut after tagged v0.0.280. Headline is the **hardlink** diff --git a/docs/releases/v0.0.282.md b/docs/releases/v0.0.282.md new file mode 100644 index 00000000..ca2dcbae --- /dev/null +++ b/docs/releases/v0.0.282.md @@ -0,0 +1,69 @@ +# v0.0.282 (2026-08-31) + +Next cut after [v0.0.281](v0.0.281.md) merged to main (`#670`). The +headline is three yxlyx product PRs that were open against the old +main tip: atomic paste spans, terminal Codex WS errors, and +recoverable HTTP client generations. + +281 stays published as-is. This is not a retag. No GitHub tag until +asked; download / notarization ids land after the build. + +## Atomic pasted-text placeholders (#674 / #673) + +The composer used to store a long paste body out of band and represent +it with ordinary editable text. Submit searched for the exact display +string and replaced every match, so Option+Backspace chewed the marker +word by word and a typed lookalike could resurrect a hidden body. + +`readline` now owns a semantic paste store: each collapsed +`[Pasted text #N +L lines]` chip is a positional span. Navigation and +deletion treat it as one unit. Expansion happens only for a live span +and consumes its identity, so a typed lookalike stays literal. History +replay drops identity (history has no hidden body to restore). +`readline_replay.zig` holds history replay so `readline.zig` stays +under the 600-line ceiling. + +## Codex WS `type:error` is a terminal API response (#693 / #692) + +Codex sends a useful `type:error` frame and then closes the socket. +Graff recognized only two special codes as terminal; a generic error +waited for another frame, treated the expected close as transport +loss, discarded the API body, and redialed. Users saw `Bad Request` +and the fullscreen TUI replaced the failure with an API-key row. + +Every authoritative Responses `type:error` is now a terminal API +response. A bounded single-line `provider/code/message` diagnostic +rides `last_api_error` (no raw envelope). A stale +`previous_response_id` rebuilds full input exactly once. Generic +deterministic API errors return immediately and do not consume the +WS-retry / SSE-fallback ladder. + +## Model HTTP client generations (ADR 0048 / #694 / #691) + +`TlsInitializationFailed` at `std.http.Client.request` happens before +a `Request` exists, so the #177 connection-poison path cannot mark +anything closing. Retrying the same launch-owned client left every +later TUI turn failing until process restart. + +Model HTTP constructors lease an active launch-level generation. A +construction TLS failure retires that generation and publishes a +prewarmed replacement. In-flight callers keep the retired generation +through reference-counted leases; later retries resolve the original +launch pointer to the replacement. The original launch client is never +reclaimed by the manager (unrelated launch consumers still hold its +pointer). WebSocket transport stays independently managed. + +`#694` recorded this as ADR 0042. That number is already TUI claims +screen on main. This cut remaps the record to **0048**. + +## Parked + +`#277` (Streamable HTTP OAuth / Smolify) and `#200` (idle localhost +servers) stay inventory in [yxlyx-leftovers.md](../yxlyx-leftovers.md). +Smolify is not a reserved core MCP. + +## Tests + +This cut keeps the 281 floor (**1804**, slack 25) and adds the paste +span, Codex WS error, and HTTP-generation suites. The ratchet is +bumped only after `zig build test` reports the new total. diff --git a/docs/yxlyx-leftovers.md b/docs/yxlyx-leftovers.md index dc5b6714..e24fa4e3 100644 --- a/docs/yxlyx-leftovers.md +++ b/docs/yxlyx-leftovers.md @@ -2,6 +2,10 @@ What still exists in this repo versus what is parked. No new product mode. +Landed on [v0.0.282](releases/v0.0.282.md): `#674` atomic paste spans, +`#693` Codex WS `type:error`, `#694` HTTP client generations (ADR 0048). +Still parked below. + | Issue | In this repo today | This cut | | --- | --- | --- | | [#321](https://github.com/justrach/codegraff/issues/321) `/doctor` | Goal/todo slice shipped: `/doctor` in `src/doctor.zig` + `commands_misc` dispatch. Checks: `GOAL_STATE`, `GOAL_TODO_EPOCH_MISMATCH`, `STALE_GOAL`, `TODO_EPOCH_ABOVE_GOAL`, `COMPLETION_GATE_ARMED`. Read-only; JSON via `toJson`. | Landed earlier. **Parked:** session-lease, owned-job, listener, and aggregate-budget findings (`DUPLICATE_WORKTREE_OWNER`, `STALE_SESSION_LEASE`, `ORPHANED_OWNED_JOB`, …). Those need a durable job/session registry `doctor.zig` refuses to fake. | diff --git a/src/cli.zig b/src/cli.zig index 8288118a..bde8e014 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -21,6 +21,11 @@ const harness_version = root.harness_version; pub const changelog_text = \\What's new \\────────── + \\0.0.282 + \\ • pasted-text chips are atomic spans — a typed lookalike stays literal (#674) + \\ • Codex WS type:error is a terminal API response; last_api_error stays (#693) + \\ • request-construction TLS failure rotates a leased HTTP client generation (ADR 0048) + \\ \\0.0.281 \\ • -p / --json skip learn auto-init — no 132M graff-pinned copy (ADR 0044) \\ • SuperGrok SWE 5/6 in 205s / 6.5s CPU (was 4/6 in 456s / 230s) diff --git a/src/libgraff.zig b/src/libgraff.zig index f54d9d15..5108379b 100644 --- a/src/libgraff.zig +++ b/src/libgraff.zig @@ -76,7 +76,7 @@ export fn graff_acp_create(new_seed: u32) void { session_id = null; seed = if (new_seed == 0) 1 else new_seed; engine.cancel_flag.store(false, .release); - engine.implementation_version = "0.0.281-core"; + engine.implementation_version = "0.0.282-core"; } export fn graff_acp_feed(len: usize) i32 { From da6511be31c71efeae5cabc1dbff99e5ff8ad2c3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 00:58:21 +0000 Subject: [PATCH 10/27] fix: split request scratch out of agent_request.zig (600-line ceiling) #693 pushed the request loop to 602 lines. Move keep-alive retry and scratch-arena reset into agent_request_scratch.zig and ratchet the suite floor to 1817 for the yxlyx paste / WS / TLS tests. --- CHANGELOG.md | 2 + docs/releases/v0.0.282.md | 8 ++-- scripts/eval/tier1-manifest.json | 2 +- src/agent_request.zig | 67 ++---------------------------- src/agent_request_scratch.zig | 71 ++++++++++++++++++++++++++++++++ src/test_hooks.zig | 1 + 6 files changed, 84 insertions(+), 67 deletions(-) create mode 100644 src/agent_request_scratch.zig diff --git a/CHANGELOG.md b/CHANGELOG.md index 787c3efd..24e09650 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ current is part of cutting a release. screen (`#666`). The TLS-generation decision is **ADR 0048**. - `#277` (Streamable HTTP OAuth / Smolify) and `#200` (idle localhost servers) stay parked — Smolify is not a core MCP. +- Suite floor **1817** (was 1804). `agent_request.zig` split + scratch/keep-alive helpers so the WS merge stays under 600 lines. ## v0.0.281 (2026-08-29) diff --git a/docs/releases/v0.0.282.md b/docs/releases/v0.0.282.md index ca2dcbae..0fe84a73 100644 --- a/docs/releases/v0.0.282.md +++ b/docs/releases/v0.0.282.md @@ -64,6 +64,8 @@ Smolify is not a reserved core MCP. ## Tests -This cut keeps the 281 floor (**1804**, slack 25) and adds the paste -span, Codex WS error, and HTTP-generation suites. The ratchet is -bumped only after `zig build test` reports the new total. +This cut raises the release-cut floor to **1817** (slack 25). The +paste-span, Codex WS error, and HTTP-generation suites land on top of +the 1804 floor from 281. `agent_request.zig` split request-scratch +helpers into `agent_request_scratch.zig` so the Codex WS merge stays +under the 600-line ceiling. diff --git a/scripts/eval/tier1-manifest.json b/scripts/eval/tier1-manifest.json index 63e68b24..2d20bd6f 100644 --- a/scripts/eval/tier1-manifest.json +++ b/scripts/eval/tier1-manifest.json @@ -15,7 +15,7 @@ "src/repl.zig", "TUI/root.zig" ], - "test_count_baseline": 1804, + "test_count_baseline": 1817, "test_count_slack": 25, "required_invariants": [ { diff --git a/src/agent_request.zig b/src/agent_request.zig index 10a54b3f..bec3949c 100644 --- a/src/agent_request.zig +++ b/src/agent_request.zig @@ -30,22 +30,6 @@ const run_budget_mod = @import("run_budget.zig"); const wire_messages = @import("messages.zig"); const policy = @import("agent_request_policy.zig"); -const max_server_retries: usize = 3; // bounded retries for a keep-alive-only response body - -/// A response body of only SSE comment lines (`: OPENROUTER PROCESSING` …) -/// means the gateway queued us and never produced tokens — back off and re-ask -/// like a 5xx instead of dying on an "unparseable" JSON parse. -fn retryKeepAliveOnlyResponse(self: *Agent, body: []const u8, retries: *usize) !bool { - if (!policy.sseKeepAliveOnly(body)) return false; - if (retries.* >= max_server_retries) return false; - retries.* += 1; - self.partial_text.clearRetainingCapacity(); - const delay_ms = RetryPlan.delayMs(true, retries.* - 1); // 1·2·4s - try self.say("[provider queued the request (keep-alive only, no tokens) — retrying in {d}s ({d}/{d})]\n", .{ delay_ms / 1000, retries.*, max_server_retries }); - self.sleepInterruptible(delay_ms) catch return error.Interrupted; - return true; -} - const overflow = @import("agent_overflow.zig"); const errorCode = policy.errorCode; const isQuotaExceeded = policy.isQuotaExceeded; @@ -82,27 +66,7 @@ pub const buildBody = @import("agent_request_body.zig").buildBody; const codex_chain = @import("codex_chain.zig"); const req_stats = @import("req_stats.zig"); // GRAFF_REQ_STATS anatomy (session_settings arms req_stats.g_armed) - -/// Keep the normal request hot path allocation-free while avoiding a permanent -/// RSS high-water mark after one anomalously large stream. Small scratch arenas -/// retain their pages for the next request; large ones return all pages to the -/// backing allocator. History never lives here, so either reset mode is safe at -/// the start of the next request. -const scratch_retain_limit = 4 * 1024 * 1024; - -fn resetRequestScratch(scratch: *std.heap.ArenaAllocator) void { - if (scratch.queryCapacity() > scratch_retain_limit) { - _ = scratch.reset(.free_all); - } else { - _ = scratch.reset(.retain_capacity); - } -} - -/// Detached recaps are cosmetic: keep recovered transport details in the trace, -/// but do not surface them as raw worker chatter in the normal REPL. -fn showRecoveredTransportRetry(kind: run_budget_mod.CallKind) bool { - return kind != .recap; -} +const scratch = @import("agent_request_scratch.zig"); /// #390 — appended once, on the run's final admitted model call, right where /// the tools disappear, so the model knows WHY and lands instead of retrying. @@ -160,7 +124,7 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { // for this request. Safe: all scratch data is // consumed before the next request(); messages/todos/prompts live on the // session arena. - if (self.scratch_arena) |sa| resetRequestScratch(sa); + if (self.scratch_arena) |sa| scratch.reset(sa); // #148/#402: a login-sourced OAuth token expires mid-session and is minted // only at startup; pick up whatever is currently on disk before the call, so // a long session — or a subagent that inherited the token — never 401s over @@ -371,7 +335,7 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { // just-closed keep-alive re-fail (#86). Cap: 4s/6 tries. const delay_ms = RetryPlan.delayMs(throttled, attempt); @import("turn_chrome.zig").emitRetryNotice(self.io, @errorName(err), attempt + 1, max_attempts); - if (showRecoveredTransportRetry(self.call_kind)) + if (scratch.showRecoveredTransportRetry(self.call_kind)) try self.say("[network error: {t} — retrying in {d}ms ({d}/{d})]\n", .{ err, delay_ms, attempt + 1, max_attempts }); // Same trace breadcrumb the 429/5xx branch leaves: a // transport-flake retry is otherwise invisible in the @@ -399,7 +363,6 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { const ms: i64 = t0.untilNow(self.io, .awake).toMilliseconds(); if (!live) self.traceFirstToken(); - // object — pull the final `response` out of it (or an error). // object — pull the final `response` out of it (or an error). if (self.provider.kind == .responses) { const r = self.parseResponses(resp_body) catch { @@ -506,7 +469,7 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { const resp = std.json.parseFromSliceLeaky(Value, self.messageMutationAlloc(), resp_body, .{ .allocate = .alloc_always, }) catch { - if (try retryKeepAliveOnlyResponse(self, resp_body, &server_retries)) continue; + if (try scratch.retryKeepAliveOnly(self, resp_body, &server_retries)) continue; try self.sayApiError("unparseable response: {s}", .{resp_body[0..@min(resp_body.len, 400)]}); return error.ApiError; }; @@ -578,25 +541,3 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { return root; } } - -test "recovered recap transport retries stay out of normal REPL output" { - try std.testing.expect(!showRecoveredTransportRetry(.recap)); - try std.testing.expect(showRecoveredTransportRetry(.root)); - try std.testing.expect(showRecoveredTransportRetry(.child)); -} - -test "request scratch retains normal capacity but releases an oversized spike" { - var scratch = std.heap.ArenaAllocator.init(std.testing.allocator); - defer scratch.deinit(); - - _ = try scratch.allocator().alloc(u8, 1024); - const normal_capacity = scratch.queryCapacity(); - try std.testing.expect(normal_capacity > 0); - resetRequestScratch(&scratch); - try std.testing.expectEqual(normal_capacity, scratch.queryCapacity()); - - _ = try scratch.allocator().alloc(u8, scratch_retain_limit + 1); - try std.testing.expect(scratch.queryCapacity() > scratch_retain_limit); - resetRequestScratch(&scratch); - try std.testing.expectEqual(@as(usize, 0), scratch.queryCapacity()); -} diff --git a/src/agent_request_scratch.zig b/src/agent_request_scratch.zig new file mode 100644 index 00000000..c1eb1c39 --- /dev/null +++ b/src/agent_request_scratch.zig @@ -0,0 +1,71 @@ +//! Per-request scratch and keep-alive retry helpers for the request loop. +//! +//! Split from agent_request.zig so that file stays under the 600-line ceiling +//! after the Codex WS error-frame merge (#693). + +const std = @import("std"); + +const Agent = @import("agent.zig").Agent; +const policy = @import("agent_request_policy.zig"); +const http = @import("http.zig"); +const RetryPlan = http.RetryPlan; +const run_budget_mod = @import("run_budget.zig"); + +const max_server_retries: usize = 3; + +/// Keep the normal request hot path allocation-free while avoiding a permanent +/// RSS high-water mark after one anomalously large stream. Small scratch arenas +/// retain their pages for the next request; large ones return all pages to the +/// backing allocator. History never lives here, so either reset mode is safe at +/// the start of the next request. +const scratch_retain_limit = 4 * 1024 * 1024; + +pub fn reset(scratch: *std.heap.ArenaAllocator) void { + if (scratch.queryCapacity() > scratch_retain_limit) { + _ = scratch.reset(.free_all); + } else { + _ = scratch.reset(.retain_capacity); + } +} + +/// Detached recaps are cosmetic: keep recovered transport details in the trace, +/// but do not surface them as raw worker chatter in the normal REPL. +pub fn showRecoveredTransportRetry(kind: run_budget_mod.CallKind) bool { + return kind != .recap; +} + +/// A response body of only SSE comment lines (`: OPENROUTER PROCESSING` …) +/// means the gateway queued us and never produced tokens — back off and re-ask +/// like a 5xx instead of dying on an "unparseable" JSON parse. +pub fn retryKeepAliveOnly(self: *Agent, body: []const u8, retries: *usize) !bool { + if (!policy.sseKeepAliveOnly(body)) return false; + if (retries.* >= max_server_retries) return false; + retries.* += 1; + self.partial_text.clearRetainingCapacity(); + const delay_ms = RetryPlan.delayMs(true, retries.* - 1); // 1·2·4s + try self.say("[provider queued the request (keep-alive only, no tokens) — retrying in {d}s ({d}/{d})]\n", .{ delay_ms / 1000, retries.*, max_server_retries }); + self.sleepInterruptible(delay_ms) catch return error.Interrupted; + return true; +} + +test "recovered recap transport retries stay out of normal REPL output" { + try std.testing.expect(!showRecoveredTransportRetry(.recap)); + try std.testing.expect(showRecoveredTransportRetry(.root)); + try std.testing.expect(showRecoveredTransportRetry(.child)); +} + +test "request scratch retains normal capacity but releases an oversized spike" { + var scratch = std.heap.ArenaAllocator.init(std.testing.allocator); + defer scratch.deinit(); + + _ = try scratch.allocator().alloc(u8, 1024); + const normal_capacity = scratch.queryCapacity(); + try std.testing.expect(normal_capacity > 0); + reset(&scratch); + try std.testing.expectEqual(normal_capacity, scratch.queryCapacity()); + + _ = try scratch.allocator().alloc(u8, scratch_retain_limit + 1); + try std.testing.expect(scratch.queryCapacity() > scratch_retain_limit); + reset(&scratch); + try std.testing.expectEqual(@as(usize, 0), scratch.queryCapacity()); +} diff --git a/src/test_hooks.zig b/src/test_hooks.zig index 00e782a7..e5122cea 100644 --- a/src/test_hooks.zig +++ b/src/test_hooks.zig @@ -344,4 +344,5 @@ test { _ = @import("session_wake.zig"); _ = @import("tui_acp.zig"); _ = @import("readline_paste.zig"); // #674: semantic paste spans (not reached via readline.zig's runtime import) + _ = @import("agent_request_scratch.zig"); // #693: split out of agent_request.zig (600-line ceiling) } From f334bb5225e834141a259f19d519f309dd87fb31 Mon Sep 17 00:00:00 2001 From: yxlyx <85774423+yxlyx@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:05:21 +0800 Subject: [PATCH 11/27] fix(tui): harden TLS recovery lifecycle Close model-client admission before teardown, drain active generation leases and CA-ready waiters, and serialize global lookup with destruction. This prevents ReleaseFast use-after-free races while allowing unrelated HTTP clients to remain usable.\n\nCorrect retry accounting so the advertised limit is the actual total, preserve throttle/server failure attribution, and add deterministic real-TLS, concurrent, allocation, TUI-root, foreground-child, and background-child recovery coverage. Remove the unrequested ADR and raise the test-count ratchet to the verified suite size.\n\nCo-Authored-By: Codegraff --- ...l-http-client-recovery-uses-generations.md | 35 -- docs/adr/README.md | 1 - scripts/eval/tier1-manifest.json | 2 +- src/agent_request.zig | 15 +- src/agent_stream.zig | 1 + src/http.zig | 1 + src/http_client.zig | 245 ++++++-- src/http_client_integration_tests.zig | 549 ++++++++++++++++-- src/http_client_tests.zig | 294 ++++++++++ src/http_client_trajectory_tests.zig | 60 ++ src/main.zig | 2 + 11 files changed, 1063 insertions(+), 142 deletions(-) delete mode 100644 docs/adr/0042-model-http-client-recovery-uses-generations.md create mode 100644 src/http_client_tests.zig create mode 100644 src/http_client_trajectory_tests.zig diff --git a/docs/adr/0042-model-http-client-recovery-uses-generations.md b/docs/adr/0042-model-http-client-recovery-uses-generations.md deleted file mode 100644 index 08d19ae2..00000000 --- a/docs/adr/0042-model-http-client-recovery-uses-generations.md +++ /dev/null @@ -1,35 +0,0 @@ -# 0042. Model HTTP client recovery uses leased generations - -Status: accepted 2026-08-31 - -## Context - -Issue #691 captured launch-scoped `TlsInitializationFailed` storms: once -`std.http.Client.request` failed during TLS construction, six retries and every -later TUI turn reused the same client and failed until process restart. The -existing #177 connection poison cannot help because no `Request` exists yet. -The launch client is also shared with concurrent root, compaction, title, -recap, and subagent traffic, so deinitializing it in place would race users. - -## Decision - -Model HTTP constructors lease an active launch-level client generation. A -request-construction `TlsInitializationFailed` retires that generation and -publishes a prewarmed replacement under one mutex. Existing requests keep the -retired generation alive through reference-counted leases; later retries and -turns resolve the original launch pointer to the replacement. Owned retired -generations are deinitialized only after their final lease releases. - -The original launch client is never reclaimed by the generation manager: -unrelated launch consumers still holding its pointer remain safe until normal -shutdown. All managed constructors wait for initial CA prewarm readiness, and -CA-prewarm plus request-construction failures leave distinct trace evidence. -Post-construction send/read failures retain #177's per-connection poison. - -## Consequences - -A recovered network can serve later root, synthetic, compaction, and child -model trajectories without replacing the process or durable session. Recovery -adds one mutex operation per model HTTP request and temporarily retains an old -client while requests from that generation are still in flight. WebSocket -transport remains independently managed; this record governs HTTP model calls. diff --git a/docs/adr/README.md b/docs/adr/README.md index c2943a4e..e76f1c51 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -52,7 +52,6 @@ record only when you need the evidence or the edge cases. | [0039](0039-local-tools-are-project-scripts.md) | Agent-authored local tools are project scripts under `.graff/tools/`; skills stay instructions. Runtime catalog extras, not `schema.effectiveRootSpecs`. | | [0040](0040-codedb-stays-when-licensed.md) | Ordinary reads use native `codedb` / `read_file`; codedb-pro is extra search, not the default reader. | | [0041](0041-tui-is-an-acp-client.md) | The fullscreen TUI is an in-process ACP client: session/prompt in, session/update thought/tool/text out. No child `graff acp`. | -| [0042](0042-model-http-client-recovery-uses-generations.md) | Model HTTP calls lease a recoverable client generation; request-construction TLS failure rotates safely without deinitializing in-flight users. | ## When to write one diff --git a/scripts/eval/tier1-manifest.json b/scripts/eval/tier1-manifest.json index cd4c2181..b7953261 100644 --- a/scripts/eval/tier1-manifest.json +++ b/scripts/eval/tier1-manifest.json @@ -15,7 +15,7 @@ "src/repl.zig", "TUI/root.zig" ], - "test_count_baseline": 1745, + "test_count_baseline": 1783, "test_count_slack": 25, "required_invariants": [ { diff --git a/src/agent_request.zig b/src/agent_request.zig index 8539cf90..f5abc30a 100644 --- a/src/agent_request.zig +++ b/src/agent_request.zig @@ -244,6 +244,7 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { // error.ApiError so the REPL returns to the prompt, never crashes. const resp_body = blk: { var attempt: usize = 0; + var retry_limit: ?usize = null; while (true) : (attempt += 1) { var conv_buf: [96]u8 = undefined; const conv = http_headers.promptCacheKey(self.io, self.label, self, &conv_buf); @@ -327,7 +328,8 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { // (1s·2ⁿ, capped at 8s; Esc cancels) and allow a few // more attempts than a plain transport flake gets. const throttled = err == error.RateLimited or err == error.ServerError; - const max_attempts: usize = RetryPlan.maxAttempts(throttled); + const max_attempts = retry_limit orelse RetryPlan.maxAttempts(throttled); + retry_limit = max_attempts; // #opencode-parity: a 429 that's a billing/quota cap (not // transient throttling) won't clear by retrying — fail fast so // cross-provider /fallback can take over, instead of burning all @@ -350,7 +352,7 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { "tls_request_construction", if (err == error.TlsRequestConstructionCaWarmFailed) "rotated shared HTTP client generation; replacement CA prewarm failed" else "rotated shared HTTP client generation", ); - if (attempt < max_attempts) { + if (attempt + 1 < max_attempts) { if (throttled) { // #retry-after: prefer the provider's Retry-After // (429/503) over our computed backoff, capped — like @@ -381,12 +383,9 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { continue; } try self.say("[request failed: {t} — giving up this turn]\n", .{err}); - // Network give-up is its own error kind: the ApiError - // handler's last_api_error would otherwise be an API - // envelope, stale or null on a pure transport failure — - // record the real reason so the failed turn's --json error - // event and trajectory node preserve it (#86). - self.last_api_error = std.fmt.allocPrint(self.arena, "network error: {s} (gave up after {d} attempts)", .{ @errorName(err), max_attempts }) catch null; + // Preserve whether this was provider throttling or a transport failure in the failed turn's JSON/trajectory (#86). + const failure_kind = if (err == error.RateLimited) "rate limited (429)" else if (err == error.ServerError) "server error (5xx)" else "network error"; + self.last_api_error = std.fmt.allocPrint(self.arena, "{s}: {s} (gave up after {d} attempts)", .{ failure_kind, @errorName(err), max_attempts }) catch null; self.last_request_write_failed = std.mem.eql(u8, @errorName(err), "WriteFailed"); if (telemetry.g_telem) |t| t.errorEvent("net", @errorName(err)); if (self.tracer) |tr| tr.api(self.label, self.sub, self.provider.model, 0, body.len, 0, 0, 0, true); diff --git a/src/agent_stream.zig b/src/agent_stream.zig index 03533704..142841b9 100644 --- a/src/agent_stream.zig +++ b/src/agent_stream.zig @@ -56,6 +56,7 @@ pub fn postStreamWithClient(self: *Agent, client: *std.http.Client, body: []cons http_client.waitForReady(client.io); var lease = http_client.acquire(client); defer lease.release(); + if (!lease.available) return error.Canceled; if (http_client.injectedConstructionTls(&lease)) |err| return err; const transport = lease.client; const sink = engine_sink.forAgent(self); diff --git a/src/http.zig b/src/http.zig index 9794d27d..fce77266 100644 --- a/src/http.zig +++ b/src/http.zig @@ -114,6 +114,7 @@ fn post(gpa: Allocator, client: *std.http.Client, provider: Provider, body: []co http_client.waitForReady(client.io); var lease = http_client.acquire(client); defer lease.release(); + if (!lease.available) return error.Canceled; if (http_client.injectedConstructionTls(&lease)) |err| return err; const transport = lease.client; var aw: Io.Writer.Allocating = .init(gpa); diff --git a/src/http_client.zig b/src/http_client.zig index 016d7a38..c476a5cd 100644 --- a/src/http_client.zig +++ b/src/http_client.zig @@ -29,14 +29,25 @@ pub const RecoveryOutcome = enum { already_rotated, }; +pub const Stats = struct { + active_id: u64, + active_refs: usize, + retired: usize, + total_refs: usize, + shutting_down: bool, +}; + pub const Recovery = struct { gpa: Allocator, io: Io, mutex: Io.Mutex = .init, + idle: Io.Condition = .init, original: Generation, active: *Generation, retired: ?*Generation = null, next_id: u64 = 1, + total_refs: usize = 0, + shutting_down: bool = false, warm_replacements: bool, pub fn init(self: *Recovery, gpa: Allocator, io: Io, original: *std.http.Client, warm_replacements: bool) void { @@ -50,7 +61,21 @@ pub const Recovery = struct { self.active = &self.original; } + pub fn beginShutdown(self: *Recovery) void { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + self.shutting_down = true; + } + + pub fn shutdown(self: *Recovery) void { + self.beginShutdown(); + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + while (self.total_refs != 0) self.idle.waitUncancelable(self.io, &self.mutex); + } + pub fn deinit(self: *Recovery) void { + self.shutdown(); std.debug.assert(self.active.refs == 0); if (self.active.owned) self.destroyOwned(self.active); var cursor = self.retired; @@ -68,25 +93,52 @@ pub const Recovery = struct { defer self.mutex.unlock(self.io); if (requested != self.original.client and requested != self.active.client) return .{ .client = requested }; + if (self.shutting_down) return .{ .client = requested, .available = false }; self.active.refs += 1; + self.total_refs += 1; return .{ .client = self.active.client, .owner = self, .generation = self.active, .generation_id = self.active.id }; } + pub fn stats(self: *Recovery) Stats { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + var retired: usize = 0; + var cursor = self.retired; + while (cursor) |generation| : (cursor = generation.next) retired += 1; + return .{ + .active_id = self.active.id, + .active_refs = self.active.refs, + .retired = retired, + .total_refs = self.total_refs, + .shutting_down = self.shutting_down, + }; + } + /// Retire the generation that failed while constructing a request. If a /// concurrent caller already rotated it, this is a successful no-op: the /// next retry will acquire that newer generation. pub fn recoverConstructionTls(self: *Recovery, failed: *std.http.Client) RecoveryOutcome { self.mutex.lockUncancelable(self.io); defer self.mutex.unlock(self.io); + if (self.shutting_down) return .unavailable; if (failed != self.active.client) return if (failed == self.original.client or self.isRetired(failed)) .already_rotated else .unavailable; const client = self.gpa.create(std.http.Client) catch return .unavailable; client.* = .{ .allocator = self.gpa, .io = self.io }; var ca_warm_failed = false; - if (self.warm_replacements) warm.prewarmCaBundle(client, self.gpa, self.io) catch { - ca_warm_failed = true; - }; + if (self.warm_replacements) { + if (builtin.is_test and g_test_fail_ca_warm.swap(false, .acq_rel)) { + ca_warm_failed = true; + } else warm.prewarmCaBundle(client, self.gpa, self.io) catch { + ca_warm_failed = true; + }; + } + if (builtin.is_test and g_test_fail_generation_alloc.swap(false, .acq_rel)) { + client.deinit(); + self.gpa.destroy(client); + return .unavailable; + } const generation = self.gpa.create(Generation) catch { client.deinit(); self.gpa.destroy(client); @@ -109,7 +161,9 @@ pub const Recovery = struct { defer self.mutex.unlock(self.io); std.debug.assert(generation.refs > 0); generation.refs -= 1; + self.total_refs -= 1; if (generation.retired and generation.refs == 0) self.reclaim(generation); + if (self.shutting_down and self.total_refs == 0) self.idle.broadcast(self.io); } fn isRetired(self: *Recovery, client: *std.http.Client) bool { @@ -146,6 +200,7 @@ pub const Lease = struct { owner: ?*Recovery = null, generation: ?*Generation = null, generation_id: u64 = 0, + available: bool = true, pub fn release(self: *Lease) void { if (self.owner) |owner| owner.release(self.generation.?); @@ -153,18 +208,35 @@ pub const Lease = struct { } }; +var g_lifecycle_mutex: Io.Mutex = .init; +var g_lifecycle_idle: Io.Condition = .init; +var g_ready_waiters: usize = 0; +var g_closing: bool = false; var g_recovery: ?*Recovery = null; var g_client_ready: ?*Io.Event = null; +var g_closed_client: ?*std.http.Client = null; +var g_test_wait_entered: ?*Io.Event = null; var g_ca_warm_failed: std.atomic.Value(bool) = .init(false); const no_test_failure = std.math.maxInt(u64); var g_test_fail_generation: std.atomic.Value(u64) = .init(no_test_failure); +var g_test_fail_through_generation: std.atomic.Value(u64) = .init(no_test_failure); +var g_test_fail_ca_warm: std.atomic.Value(bool) = .init(false); +var g_test_fail_generation_alloc: std.atomic.Value(bool) = .init(false); +var g_test_tls_arrivals: ?*std.atomic.Value(usize) = null; +var g_test_tls_all_arrived: ?*Io.Event = null; +var g_test_tls_release: ?*Io.Event = null; pub fn acquire(requested: *std.http.Client) Lease { + g_lifecycle_mutex.lockUncancelable(requested.io); + defer g_lifecycle_mutex.unlock(requested.io); if (g_recovery) |recovery| return recovery.acquire(requested); + if (g_closed_client == requested) return .{ .client = requested, .available = false }; return .{ .client = requested }; } pub fn constructionTlsError(failed: *std.http.Client) anyerror { + g_lifecycle_mutex.lockUncancelable(failed.io); + defer g_lifecycle_mutex.unlock(failed.io); const outcome = if (g_recovery) |recovery| recovery.recoverConstructionTls(failed) else .unavailable; return switch (outcome) { .unavailable => error.TlsInitializationFailed, @@ -177,15 +249,99 @@ pub fn injectConstructionTlsForTest(generation: u64) void { if (builtin.is_test) g_test_fail_generation.store(generation, .release); } -pub fn injectedConstructionTls(lease: *const Lease) ?anyerror { - if (!builtin.is_test) return null; - if (g_test_fail_generation.cmpxchgStrong(lease.generation_id, no_test_failure, .acq_rel, .acquire) == null) - return constructionTlsError(lease.client); - return null; +pub fn injectConstructionTlsThroughGenerationForTest(last_generation: u64) void { + if (builtin.is_test) g_test_fail_through_generation.store(last_generation, .release); +} + +pub fn injectReplacementCaWarmFailureForTest() void { + if (builtin.is_test) g_test_fail_ca_warm.store(true, .release); +} + +pub fn injectGenerationAllocationFailureForTest() void { + if (builtin.is_test) g_test_fail_generation_alloc.store(true, .release); +} + +pub fn injectLaunchCaWarmFailureForTest() void { + if (builtin.is_test) g_ca_warm_failed.store(true, .release); +} + +pub fn installConstructionTlsBarrierForTest(arrivals: *std.atomic.Value(usize), all_arrived: *Io.Event, release: *Io.Event) void { + if (!builtin.is_test) return; + g_test_tls_arrivals = arrivals; + g_test_tls_all_arrived = all_arrived; + g_test_tls_release = release; +} + +inline fn resetTestHooks() void { + if (comptime !builtin.is_test) return; + g_test_wait_entered = null; + g_test_fail_generation.store(no_test_failure, .release); + g_test_fail_through_generation.store(no_test_failure, .release); + g_test_fail_ca_warm.store(false, .release); + g_test_fail_generation_alloc.store(false, .release); + g_test_tls_arrivals = null; + g_test_tls_all_arrived = null; + g_test_tls_release = null; +} + +pub fn installForTest(recovery: *Recovery, ready: ?*Io.Event, wait_entered: ?*Io.Event) void { + if (!builtin.is_test) return; + g_lifecycle_mutex.lockUncancelable(recovery.io); + defer g_lifecycle_mutex.unlock(recovery.io); + g_closing = false; + g_closed_client = null; + g_recovery = recovery; + g_client_ready = ready; + resetTestHooks(); + g_test_wait_entered = wait_entered; + g_ca_warm_failed.store(false, .release); +} + +pub fn uninstallForTest() void { + if (!builtin.is_test) return; + const recovery = g_recovery orelse return; + g_lifecycle_mutex.lockUncancelable(recovery.io); + defer g_lifecycle_mutex.unlock(recovery.io); + g_closing = true; + while (g_ready_waiters != 0) g_lifecycle_idle.waitUncancelable(recovery.io, &g_lifecycle_mutex); + g_recovery = null; + g_client_ready = null; + g_closed_client = null; + g_closing = false; + resetTestHooks(); +} + +pub inline fn injectedConstructionTls(lease: *const Lease) ?anyerror { + if (comptime !builtin.is_test) return null; + if (lease.owner == null) return null; + const through = g_test_fail_through_generation.load(.acquire); + const should_fail = through != no_test_failure and lease.generation_id <= through or + g_test_fail_generation.cmpxchgStrong(lease.generation_id, no_test_failure, .acq_rel, .acquire) == null; + if (!should_fail) return null; + if (g_test_tls_arrivals) |arrivals| { + if (arrivals.fetchAdd(1, .acq_rel) + 1 == 2) g_test_tls_all_arrived.?.set(lease.owner.?.io); + g_test_tls_release.?.waitUncancelable(lease.owner.?.io); + } + return constructionTlsError(lease.client); } pub fn waitForReady(io: Io) void { - if (g_client_ready) |ready| ready.waitUncancelable(io); + g_lifecycle_mutex.lockUncancelable(io); + if (g_closing or g_client_ready == null) { + g_lifecycle_mutex.unlock(io); + return; + } + const ready = g_client_ready.?; + g_ready_waiters += 1; + g_lifecycle_mutex.unlock(io); + + if (comptime builtin.is_test) if (g_test_wait_entered) |entered| entered.set(io); + ready.waitUncancelable(io); + + g_lifecycle_mutex.lockUncancelable(io); + g_ready_waiters -= 1; + if (g_closing and g_ready_waiters == 0) g_lifecycle_idle.broadcast(io); + g_lifecycle_mutex.unlock(io); } pub fn takeCaWarmFailure() bool { @@ -206,70 +362,35 @@ pub const Runtime = struct { self.client = .{ .allocator = gpa, .io = io }; self.ready = .unset; self.recovery.init(gpa, io, &self.client, true); + + g_lifecycle_mutex.lockUncancelable(io); + g_closing = false; + g_closed_client = null; g_recovery = &self.recovery; g_client_ready = &self.ready; + resetTestHooks(); g_ca_warm_failed.store(false, .release); + g_lifecycle_mutex.unlock(io); self.warm_future = io.async(warm.prewarmCaBundleTask, .{ &self.client, gpa, io, &self.ready, &g_ca_warm_failed }); } pub fn deinit(self: *Runtime, await_io: Io) void { + g_lifecycle_mutex.lockUncancelable(self.io); + g_closing = true; + self.recovery.beginShutdown(); + while (g_ready_waiters != 0) g_lifecycle_idle.waitUncancelable(self.io, &g_lifecycle_mutex); + g_lifecycle_mutex.unlock(self.io); + + self.recovery.shutdown(); _ = self.warm_future.await(await_io); + + g_lifecycle_mutex.lockUncancelable(self.io); g_client_ready = null; g_recovery = null; + g_closed_client = &self.client; + resetTestHooks(); self.recovery.deinit(); self.client.deinit(); + g_lifecycle_mutex.unlock(self.io); } }; - -test "request-construction TLS recovery reaches later root and child trajectories" { - const gpa = std.testing.allocator; - const io = std.testing.io; - var original: std.http.Client = .{ .allocator = gpa, .io = io }; - defer original.deinit(); - var recovery: Recovery = undefined; - recovery.init(gpa, io, &original, false); - defer recovery.deinit(); - - var failed_root = recovery.acquire(&original); - try std.testing.expectEqual(@as(u64, 0), failed_root.generation_id); - try std.testing.expectEqual(RecoveryOutcome.rotated, recovery.recoverConstructionTls(failed_root.client)); - - var later_root = recovery.acquire(&original); - defer later_root.release(); - var child = recovery.acquire(&original); - defer child.release(); - try std.testing.expectEqual(@as(u64, 1), later_root.generation_id); - try std.testing.expectEqual(later_root.generation_id, child.generation_id); - try std.testing.expect(later_root.client == child.client); - try std.testing.expect(later_root.client != failed_root.client); - failed_root.release(); -} - -fn recoverTask(recovery: *Recovery, client: *std.http.Client) RecoveryOutcome { - return recovery.recoverConstructionTls(client); -} - -test "concurrent stale TLS reports rotate a generation only once" { - const gpa = std.testing.allocator; - const io = std.testing.io; - var original: std.http.Client = .{ .allocator = gpa, .io = io }; - defer original.deinit(); - var recovery: Recovery = undefined; - recovery.init(gpa, io, &original, false); - defer recovery.deinit(); - - var first = recovery.acquire(&original); - var concurrent = recovery.acquire(&original); - var first_fut = io.async(recoverTask, .{ &recovery, first.client }); - var second_fut = io.async(recoverTask, .{ &recovery, concurrent.client }); - const first_result = first_fut.await(io); - const second_result = second_fut.await(io); - try std.testing.expect(first_result != .unavailable); - try std.testing.expect(second_result != .unavailable); - try std.testing.expect(first_result != second_result); - var after = recovery.acquire(&original); - defer after.release(); - try std.testing.expectEqual(@as(u64, 1), after.generation_id); - first.release(); - concurrent.release(); -} diff --git a/src/http_client_integration_tests.zig b/src/http_client_integration_tests.zig index 7b5f92a7..74624e6d 100644 --- a/src/http_client_integration_tests.zig +++ b/src/http_client_integration_tests.zig @@ -1,30 +1,123 @@ -//! End-to-end model-POST regression for request-construction TLS recovery. +//! End-to-end model-transport regressions for request-construction TLS recovery. const std = @import("std"); const Io = std.Io; +const Agent = @import("agent.zig").Agent; const http = @import("http.zig"); const http_client = @import("http_client.zig"); +const mock = @import("agent_ws_mock.zig"); +const trace = @import("trace.zig"); const Provider = @import("provider.zig").Provider; +const Approvals = @import("approvals.zig").Approvals; +const repl_turn = @import("repl_turn.zig"); +const subagent_run = @import("subagent_run.zig"); +const tools = @import("tools.zig"); -fn serveOk(io: Io, server: *std.Io.net.Server) void { - for (0..2) |_| { +pub const Reply = struct { + status: []const u8 = "200 OK", + content_type: []const u8 = "application/json", + body: []const u8, +}; + +pub const chat_body = + \\{"choices":[{"index":0,"message":{"role":"assistant","content":"child-ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}} +; +const sse_body = + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"root-ok\"}\n\n" ++ + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r1\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n"; +const chat_sse_body = + "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"root-ok\"},\"finish_reason\":null}]}\n\n" ++ + "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n" ++ + "data: [DONE]\n\n"; + +fn readRequest(reader: *std.Io.net.Stream.Reader) !void { + var content_length: usize = 0; + while (true) { + const line = (try reader.interface.takeDelimiter('\n')) orelse return error.EndOfStream; + if (line.len == 0 or (line.len == 1 and line[0] == '\r')) break; + if (std.ascii.startsWithIgnoreCase(line, "content-length:")) { + content_length = try std.fmt.parseInt(usize, std.mem.trim(u8, line[15..], " \t\r"), 10); + } + } + try reader.interface.discardAll(content_length); +} + +pub fn serveReplies(io: Io, server: *std.Io.net.Server, replies: []const Reply, accepted: *std.atomic.Value(usize)) void { + for (replies) |reply| { const conn = server.accept(io) catch return; - defer conn.close(io); - var read_buf: [4096]u8 = undefined; - var reader = std.Io.net.Stream.Reader.init(conn, io, &read_buf); - while (true) { - const line = (reader.interface.takeDelimiter('\n') catch return) orelse return; - if (line.len == 0 or (line.len == 1 and line[0] == '\r')) break; + { + defer conn.close(io); + _ = accepted.fetchAdd(1, .acq_rel); + var read_buf: [16 * 1024]u8 = undefined; + var reader = std.Io.net.Stream.Reader.init(conn, io, &read_buf); + readRequest(&reader) catch return; + var head_buf: [256]u8 = undefined; + const head = std.fmt.bufPrint( + &head_buf, + "HTTP/1.1 {s}\r\ncontent-type: {s}\r\ncontent-length: {d}\r\nconnection: close\r\n\r\n", + .{ reply.status, reply.content_type, reply.body.len }, + ) catch return; + var write_buf: [4096]u8 = undefined; + var writer = std.Io.net.Stream.Writer.init(conn, io, &write_buf); + writer.interface.writeAll(head) catch return; + writer.interface.writeAll(reply.body) catch return; + writer.interface.flush() catch return; } - _ = reader.interface.take(2) catch return; - var write_buf: [256]u8 = undefined; + } +} + +fn serveInvalidTls(io: Io, server: *std.Io.net.Server, count: usize, accepted: *std.atomic.Value(usize)) void { + for (0..count) |_| { + const conn = server.accept(io) catch return; + defer conn.close(io); + _ = accepted.fetchAdd(1, .acq_rel); + var write_buf: [128]u8 = undefined; var writer = std.Io.net.Stream.Writer.init(conn, io, &write_buf); - writer.interface.writeAll("HTTP/1.1 200 OK\r\ncontent-length: 2\r\nconnection: close\r\n\r\nok") catch return; - writer.interface.flush() catch return; + writer.interface.writeAll("HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n") catch continue; + writer.interface.flush() catch continue; } } -test "one TLS-broken generation recovers later ordinary and child model POSTs" { +pub fn releaseAccept(io: Io, server: *std.Io.net.Server) void { + const address = server.socket.address; + if (std.Io.net.IpAddress.connect(&address, io, .{ .mode = .stream })) |stream| stream.close(io) else |_| {} +} + +pub fn provider(url: []const u8) Provider { + return .{ + .id = "test", + .kind = .openai, + .auth = .x_api_key, + .url = url, + .api_key = "test", + .model = "test", + .context = 100_000, + }; +} + +fn childAgent(gpa: std.mem.Allocator, arena: std.mem.Allocator, io: Io, client: *std.http.Client, p: Provider) Agent { + return .{ + .gpa = gpa, + .arena = arena, + .io = io, + .client = client, + .provider = p, + .messages = std.json.Array.init(arena), + .sub = true, + .label = "test-child", + .out = null, + }; +} + +fn postTask(gpa: std.mem.Allocator, client: *std.http.Client, p: Provider) anyerror![]u8 { + return http.postWithConv(gpa, client, p, "{}", null); +} + +fn postWatchedTask(gpa: std.mem.Allocator, io: Io, client: *std.http.Client, p: Provider) anyerror![]u8 { + return http.postWatched(gpa, io, client, p, "{}", null); +} + +test "real malformed TLS handshakes traverse both production constructor catches" { const gpa = std.testing.allocator; const io = std.testing.io; var runtime: http_client.Runtime = undefined; @@ -35,33 +128,419 @@ test "one TLS-broken generation recovers later ordinary and child model POSTs" { var address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); var server = try std.Io.net.IpAddress.listen(&address, io, .{}); defer server.deinit(io); - var server_future = io.async(serveOk, .{ io, &server }); + var accepted: std.atomic.Value(usize) = .init(0); + var server_future = io.async(serveInvalidTls, .{ io, &server, 2, &accepted }); defer server_future.await(io); + defer releaseAccept(io, &server); - const bound = server.socket.address; var url_buf: [64]u8 = undefined; - const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/test", .{bound.getPort()}); - const provider: Provider = .{ - .id = "test", - .kind = .openai, - .auth = .x_api_key, - .url = url, - .api_key = "test", - .model = "test", - .context = 0, - }; - - http_client.injectConstructionTlsForTest(0); + const url = try std.fmt.bufPrint(&url_buf, "https://127.0.0.1:{d}/v1/test", .{server.socket.address.getPort()}); try std.testing.expectError( error.TlsRequestConstructionFailed, - http.postWithConv(gpa, &runtime.client, provider, "{}", "tui-root-failed"), + http.postWithConv(gpa, &runtime.client, provider(url), "{}", null), ); + try std.testing.expectEqual(@as(u64, 1), runtime.recovery.stats().active_id); - const ordinary = try http.postWithConv(gpa, &runtime.client, provider, "{}", "tui-root-later"); - defer gpa.free(ordinary); - try std.testing.expectEqualStrings("ok", ordinary); + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + var root = childAgent(gpa, arena_state.allocator(), io, &runtime.client, provider(url)); + root.sub = false; + try std.testing.expectError(error.TlsRequestConstructionFailed, root.postStreamWithClient(&runtime.client, "{}")); + try std.testing.expectEqual(@as(u64, 2), runtime.recovery.stats().active_id); + try std.testing.expectEqual(@as(usize, 2), accepted.load(.acquire)); +} - const child = try http.postWithConv(gpa, &runtime.client, provider, "{}", "tui-child-later"); - defer gpa.free(child); - try std.testing.expectEqualStrings("ok", child); +test "TUI turn agent and actual runSub child share the recovered generation" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + defer runtime.deinit(io); + http.waitForClientReady(io); + + var address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&address, io, .{}); + defer server.deinit(io); + const replies = [_]Reply{ + .{ .content_type = "text/event-stream", .body = chat_sse_body }, + .{ .body = chat_body }, + }; + var accepted: std.atomic.Value(usize) = .init(0); + var server_future = io.async(serveReplies, .{ io, &server, @as([]const Reply, &replies), &accepted }); + defer server_future.await(io); + defer releaseAccept(io, &server); + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/test", .{server.socket.address.getPort()}); + const p = provider(url); + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + var output: Io.Writer.Allocating = .init(gpa); + defer output.deinit(); + var approvals: Approvals = .{ .yolo = true }; + var ctx = repl_turn.testCtx(&runtime.client); + ctx.provider = p; + var root = try repl_turn.turnAgent(&ctx, gpa, arena_state.allocator(), .{}, &output.writer, &approvals); + defer root.tools_used.deinit(gpa); + + http_client.injectConstructionTlsForTest(0); + _ = try root.request(null); + try std.testing.expect(std.mem.indexOf(u8, output.written(), "root-ok") != null); + try std.testing.expectEqual(@as(u64, 1), runtime.recovery.stats().active_id); + + const tool_ctx: tools.ToolCtx = .{ + .gpa = gpa, + .io = io, + .client = &runtime.client, + .provider = p, + .registry = null, + .from_sub = false, + .approvals = &approvals, + .tracer = null, + }; + const child = try subagent_run.runSub(tool_ctx, "subagent", "tls-test-child", "reply once", "test child", "", .shared_cwd, false, p, null); + defer gpa.free(child.output.text); + try std.testing.expect(!child.output.is_error); + try std.testing.expect(std.mem.indexOf(u8, child.output.text, "child-ok") != null); + try std.testing.expectEqual(@as(usize, 2), accepted.load(.acquire)); + try std.testing.expectEqual(@as(usize, 0), runtime.recovery.stats().active_refs); +} + +test "failed streaming root generation recovers later root and child requests" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + defer runtime.deinit(io); + http.waitForClientReady(io); + + var address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&address, io, .{}); + defer server.deinit(io); + const replies = [_]Reply{ + .{ .content_type = "text/event-stream", .body = sse_body }, + .{ .body = chat_body }, + }; + var accepted: std.atomic.Value(usize) = .init(0); + var server_future = io.async(serveReplies, .{ io, &server, @as([]const Reply, &replies), &accepted }); + defer server_future.await(io); + defer releaseAccept(io, &server); + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/test", .{server.socket.address.getPort()}); + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var root = mock.mockAgent(gpa, arena, io, url); + root.client = &runtime.client; + http_client.injectConstructionTlsForTest(0); + try std.testing.expectError(error.TlsRequestConstructionFailed, root.postStreamWithClient(root.client, "{}")); + + const streamed = try root.postStreamWithClient(root.client, "{}"); + defer gpa.free(streamed); + try std.testing.expect(std.mem.indexOf(u8, streamed, "response.completed") != null); + + var child = childAgent(gpa, arena, io, &runtime.client, provider(url)); + _ = try child.request(null); + try std.testing.expectEqual(@as(usize, 2), accepted.load(.acquire)); + const stats = runtime.recovery.stats(); + try std.testing.expectEqual(@as(u64, 1), stats.active_id); + try std.testing.expectEqual(@as(usize, 0), stats.active_refs); + try std.testing.expectEqual(@as(usize, 0), stats.retired); +} + +test "child retry ladder recovers within the same request" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + defer runtime.deinit(io); + http.waitForClientReady(io); + + var address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&address, io, .{}); + defer server.deinit(io); + const replies = [_]Reply{.{ .body = chat_body }}; + var accepted: std.atomic.Value(usize) = .init(0); + var server_future = io.async(serveReplies, .{ io, &server, @as([]const Reply, &replies), &accepted }); + defer server_future.await(io); + defer releaseAccept(io, &server); + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/test", .{server.socket.address.getPort()}); + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + var child = childAgent(gpa, arena_state.allocator(), io, &runtime.client, provider(url)); + + http_client.injectConstructionTlsForTest(0); + _ = try child.request(null); + try std.testing.expectEqual(@as(usize, 1), accepted.load(.acquire)); + try std.testing.expectEqual(@as(u64, 1), runtime.recovery.stats().active_id); + try std.testing.expectEqual(@as(usize, 0), runtime.recovery.stats().active_refs); +} + +test "later root and child recover after the retry ladder exhausts TLS generations" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + defer runtime.deinit(io); + http.waitForClientReady(io); + + var address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&address, io, .{}); + defer server.deinit(io); + const replies = [_]Reply{ + .{ .content_type = "text/event-stream", .body = chat_sse_body }, + .{ .body = chat_body }, + }; + var accepted: std.atomic.Value(usize) = .init(0); + var server_future = io.async(serveReplies, .{ io, &server, @as([]const Reply, &replies), &accepted }); + defer server_future.await(io); + defer releaseAccept(io, &server); + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/test", .{server.socket.address.getPort()}); + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var output: Io.Writer.Allocating = .init(gpa); + defer output.deinit(); + var trace_output: Io.Writer.Allocating = .init(gpa); + defer trace_output.deinit(); + var tracer: trace.Tracer = .{ .io = io, .gpa = gpa, .out = &trace_output.writer, .start = Io.Timestamp.now(io, .awake) }; + + var root = childAgent(gpa, arena, io, &runtime.client, provider(url)); + root.sub = false; + root.label = "test-root"; + root.out = &output.writer; + root.tracer = &tracer; + http_client.injectConstructionTlsThroughGenerationForTest(5); + try std.testing.expectError(error.ApiError, root.request(null)); + try std.testing.expectEqual(@as(usize, 0), accepted.load(.acquire)); + try std.testing.expectEqual(@as(u64, 6), runtime.recovery.stats().active_id); + try std.testing.expectEqual(@as(usize, 6), std.mem.count(u8, trace_output.written(), "\"ev\":\"tls_request_construction\"")); + try std.testing.expect(std.mem.indexOf(u8, root.last_api_error.?, "gave up after 6 attempts") != null); + + _ = try root.request(null); + try std.testing.expect(std.mem.indexOf(u8, output.written(), "root-ok") != null); + var child = childAgent(gpa, arena, io, &runtime.client, provider(url)); + const child_response = try child.request(null); + const choices = child_response.get("choices").?.array.items; + const content = choices[0].object.get("message").?.object.get("content").?.string; + try std.testing.expectEqualStrings("child-ok", content); + try std.testing.expectEqual(@as(usize, 2), accepted.load(.acquire)); + try std.testing.expectEqual(@as(usize, 0), runtime.recovery.stats().active_refs); + try std.testing.expectEqual(@as(usize, 0), runtime.recovery.stats().retired); +} + +test "repeated TLS failures rotate multiple generations before recovery" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + defer runtime.deinit(io); + http.waitForClientReady(io); + + var address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&address, io, .{}); + defer server.deinit(io); + const replies = [_]Reply{.{ .body = "ok" }}; + var accepted: std.atomic.Value(usize) = .init(0); + var server_future = io.async(serveReplies, .{ io, &server, @as([]const Reply, &replies), &accepted }); + defer server_future.await(io); + defer releaseAccept(io, &server); + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/test", .{server.socket.address.getPort()}); + const p = provider(url); + http_client.injectConstructionTlsForTest(0); + try std.testing.expectError(error.TlsRequestConstructionFailed, http.postWithConv(gpa, &runtime.client, p, "{}", null)); + http_client.injectConstructionTlsForTest(1); + try std.testing.expectError(error.TlsRequestConstructionFailed, http.postWithConv(gpa, &runtime.client, p, "{}", null)); + const recovered = try http.postWithConv(gpa, &runtime.client, p, "{}", null); + defer gpa.free(recovered); + try std.testing.expectEqualStrings("ok", recovered); + try std.testing.expectEqual(@as(u64, 2), runtime.recovery.stats().active_id); + try std.testing.expectEqual(@as(usize, 0), runtime.recovery.stats().retired); +} + +test "simultaneous callers survive one shared generation failure" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + defer runtime.deinit(io); + http.waitForClientReady(io); + + var address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&address, io, .{}); + defer server.deinit(io); + const replies = [_]Reply{.{ .body = "ok" }}; + var accepted: std.atomic.Value(usize) = .init(0); + var server_future = io.async(serveReplies, .{ io, &server, @as([]const Reply, &replies), &accepted }); + defer server_future.await(io); + defer releaseAccept(io, &server); + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/test", .{server.socket.address.getPort()}); + const p = provider(url); + var arrivals: std.atomic.Value(usize) = .init(0); + var all_arrived: Io.Event = .unset; + var release: Io.Event = .unset; + http_client.installConstructionTlsBarrierForTest(&arrivals, &all_arrived, &release); + http_client.injectConstructionTlsThroughGenerationForTest(0); + var first = io.async(postTask, .{ gpa, &runtime.client, p }); + var second = io.async(postTask, .{ gpa, &runtime.client, p }); + all_arrived.waitUncancelable(io); + try std.testing.expectEqual(@as(usize, 2), runtime.recovery.stats().active_refs); + release.set(io); + const results = [_]anyerror![]u8{ first.await(io), second.await(io) }; + for (results) |result| { + if (result) |body| { + gpa.free(body); + return error.UnexpectedSuccess; + } else |err| try std.testing.expectEqual(error.TlsRequestConstructionFailed, err); + } + try std.testing.expectEqual(@as(u64, 1), runtime.recovery.stats().active_id); + try std.testing.expectEqual(@as(usize, 0), runtime.recovery.stats().retired); + + const recovered = try http.postWithConv(gpa, &runtime.client, p, "{}", null); + defer gpa.free(recovered); + try std.testing.expectEqualStrings("ok", recovered); + try std.testing.expectEqual(@as(usize, 1), accepted.load(.acquire)); + try std.testing.expectEqual(@as(usize, 0), runtime.recovery.stats().active_refs); +} + +test "direct model POST waits for CA readiness before dialing" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = gpa, .io = io }; + defer original.deinit(); + var recovery: http_client.Recovery = undefined; + recovery.init(gpa, io, &original, false); + defer recovery.deinit(); + var ready: Io.Event = .unset; + var wait_entered: Io.Event = .unset; + http_client.installForTest(&recovery, &ready, &wait_entered); + defer http_client.uninstallForTest(); + + var address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&address, io, .{}); + defer server.deinit(io); + const replies = [_]Reply{.{ .body = "ok" }}; + var accepted: std.atomic.Value(usize) = .init(0); + var server_future = io.async(serveReplies, .{ io, &server, @as([]const Reply, &replies), &accepted }); + defer server_future.await(io); + defer releaseAccept(io, &server); + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/compact", .{server.socket.address.getPort()}); + var posted = io.async(postWatchedTask, .{ gpa, io, &original, provider(url) }); + wait_entered.waitUncancelable(io); + try std.testing.expectEqual(@as(usize, 0), accepted.load(.acquire)); + ready.set(io); + const body = try posted.await(io); + defer gpa.free(body); + try std.testing.expectEqualStrings("ok", body); + try std.testing.expectEqual(@as(usize, 1), accepted.load(.acquire)); +} + +test "launch CA failure trace is consumed once and does not block model requests" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + defer runtime.deinit(io); + http.waitForClientReady(io); + + var address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&address, io, .{}); + defer server.deinit(io); + const replies = [_]Reply{ .{ .body = chat_body }, .{ .body = chat_body } }; + var accepted: std.atomic.Value(usize) = .init(0); + var server_future = io.async(serveReplies, .{ io, &server, @as([]const Reply, &replies), &accepted }); + defer server_future.await(io); + defer releaseAccept(io, &server); + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/test", .{server.socket.address.getPort()}); + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + var trace_output: Io.Writer.Allocating = .init(gpa); + defer trace_output.deinit(); + var tracer: trace.Tracer = .{ .io = io, .gpa = gpa, .out = &trace_output.writer, .start = Io.Timestamp.now(io, .awake) }; + var child = childAgent(gpa, arena_state.allocator(), io, &runtime.client, provider(url)); + child.tracer = &tracer; + + http_client.injectLaunchCaWarmFailureForTest(); + _ = try child.request(null); + _ = try child.request(null); + try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, trace_output.written(), "\"ev\":\"ca_prewarm_failed\"")); + try std.testing.expectEqual(@as(usize, 2), accepted.load(.acquire)); +} + +test "Agent request traces replacement CA failure and recovers in the same retry ladder" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + defer runtime.deinit(io); + http.waitForClientReady(io); + + var address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&address, io, .{}); + defer server.deinit(io); + const replies = [_]Reply{ .{ .body = chat_body }, .{ .body = chat_body } }; + var accepted: std.atomic.Value(usize) = .init(0); + var server_future = io.async(serveReplies, .{ io, &server, @as([]const Reply, &replies), &accepted }); + defer server_future.await(io); + defer releaseAccept(io, &server); + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/test", .{server.socket.address.getPort()}); + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + var trace_output: Io.Writer.Allocating = .init(gpa); + defer trace_output.deinit(); + var tracer: trace.Tracer = .{ .io = io, .gpa = gpa, .out = &trace_output.writer, .start = Io.Timestamp.now(io, .awake) }; + var child = childAgent(gpa, arena_state.allocator(), io, &runtime.client, provider(url)); + child.tracer = &tracer; + + http_client.injectReplacementCaWarmFailureForTest(); + http_client.injectConstructionTlsForTest(0); + _ = try child.request(null); + try std.testing.expect(std.mem.indexOf( + u8, + trace_output.written(), + "rotated shared HTTP client generation; replacement CA prewarm failed", + ) != null); + _ = try child.request(null); + try std.testing.expectEqual(@as(usize, 2), accepted.load(.acquire)); + try std.testing.expectEqual(@as(usize, 0), runtime.recovery.stats().active_refs); +} + +test "HTTP response error releases its generation lease" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + defer runtime.deinit(io); + http.waitForClientReady(io); + + var address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&address, io, .{}); + defer server.deinit(io); + const replies = [_]Reply{.{ .status = "500 Internal Server Error", .body = "upstream failed\n" }}; + var accepted: std.atomic.Value(usize) = .init(0); + var server_future = io.async(serveReplies, .{ io, &server, @as([]const Reply, &replies), &accepted }); + defer server_future.await(io); + defer releaseAccept(io, &server); + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/test", .{server.socket.address.getPort()}); + try std.testing.expectError(error.ServerError, http.postWithConv(gpa, &runtime.client, provider(url), "{}", null)); + try std.testing.expectEqual(@as(usize, 0), runtime.recovery.stats().active_refs); + try std.testing.expectEqual(@as(usize, 0), runtime.recovery.stats().retired); } diff --git a/src/http_client_tests.zig b/src/http_client_tests.zig new file mode 100644 index 00000000..2fd652a1 --- /dev/null +++ b/src/http_client_tests.zig @@ -0,0 +1,294 @@ +//! Unit tests for recoverable HTTP client generation lifetime and fault hooks. + +const std = @import("std"); +const http_client = @import("http_client.zig"); +const Recovery = http_client.Recovery; +const RecoveryOutcome = http_client.RecoveryOutcome; + +fn recoverTask(recovery: *Recovery, client: *std.http.Client) RecoveryOutcome { + return recovery.recoverConstructionTls(client); +} + +fn shutdownTask(recovery: *Recovery) void { + recovery.shutdown(); +} + +test "runtime teardown leaves the global transport closed to late acquisitions" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + runtime.deinit(io); + + var late = http_client.acquire(&runtime.client); + defer late.release(); + try std.testing.expect(!late.available); + + var unrelated: std.http.Client = .{ .allocator = gpa, .io = io }; + defer unrelated.deinit(); + var unmanaged = http_client.acquire(&unrelated); + defer unmanaged.release(); + try std.testing.expect(unmanaged.available); +} + +fn runtimeDeinitTask(runtime: *http_client.Runtime, io: std.Io) void { + runtime.deinit(io); +} + +test "runtime teardown closes admission before draining an active lease" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + http_client.waitForReady(io); + var lease = http_client.acquire(&runtime.client); + try std.testing.expect(lease.available); + + var teardown = io.async(runtimeDeinitTask, .{ &runtime, io }); + for (0..100) |_| { + if (runtime.recovery.stats().shutting_down) break; + try io.sleep(.fromMilliseconds(1), .awake); + } + try std.testing.expect(runtime.recovery.stats().shutting_down); + var denied = http_client.acquire(&runtime.client); + defer denied.release(); + try std.testing.expect(!denied.available); + + lease.release(); + _ = teardown.await(io); +} + +fn waitReadyTask(io: std.Io) void { + http_client.waitForReady(io); +} + +fn uninstallTask() void { + http_client.uninstallForTest(); +} + +test "lifecycle teardown drains callers already waiting for CA readiness" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = gpa, .io = io }; + defer original.deinit(); + var recovery: Recovery = undefined; + recovery.init(gpa, io, &original, false); + defer recovery.deinit(); + var ready: std.Io.Event = .unset; + var wait_entered: std.Io.Event = .unset; + http_client.installForTest(&recovery, &ready, &wait_entered); + + var waiter = io.async(waitReadyTask, .{io}); + wait_entered.waitUncancelable(io); + var teardown = io.async(uninstallTask, .{}); + ready.set(io); + _ = waiter.await(io); + _ = teardown.await(io); +} + +test "retired generation stays alive until its final concurrent lease releases" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = gpa, .io = io }; + defer original.deinit(); + var recovery: Recovery = undefined; + recovery.init(gpa, io, &original, false); + defer recovery.deinit(); + + var first = recovery.acquire(&original); + var second = recovery.acquire(&original); + try std.testing.expectEqual(RecoveryOutcome.rotated, recovery.recoverConstructionTls(first.client)); + try std.testing.expectEqual(@as(usize, 1), recovery.stats().retired); + first.release(); + try std.testing.expectEqual(@as(usize, 1), recovery.stats().retired); + second.release(); + try std.testing.expectEqual(@as(usize, 0), recovery.stats().retired); +} + +test "replacement allocation failures keep the current generation usable" { + const backing = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = backing, .io = io }; + defer original.deinit(); + + for (0..2) |fail_index| { + var failing = std.testing.FailingAllocator.init(backing, .{ .fail_index = fail_index }); + var recovery: Recovery = undefined; + recovery.init(failing.allocator(), io, &original, false); + defer recovery.deinit(); + var lease = recovery.acquire(&original); + defer lease.release(); + try std.testing.expectEqual(RecoveryOutcome.unavailable, recovery.recoverConstructionTls(lease.client)); + try std.testing.expectEqual(@as(u64, 0), recovery.stats().active_id); + } +} + +test "unmanaged client TLS failure does not rotate the launch generation" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = gpa, .io = io }; + defer original.deinit(); + var unrelated: std.http.Client = .{ .allocator = gpa, .io = io }; + defer unrelated.deinit(); + var recovery: Recovery = undefined; + recovery.init(gpa, io, &original, false); + defer recovery.deinit(); + + try std.testing.expectEqual(RecoveryOutcome.unavailable, recovery.recoverConstructionTls(&unrelated)); + try std.testing.expectEqual(@as(u64, 0), recovery.stats().active_id); +} + +test "replacement CA warm failure is attributed to the triggering rotation" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = gpa, .io = io }; + defer original.deinit(); + var recovery: Recovery = undefined; + recovery.init(gpa, io, &original, true); + defer recovery.deinit(); + + var failed = recovery.acquire(&original); + defer failed.release(); + http_client.injectReplacementCaWarmFailureForTest(); + try std.testing.expectEqual(RecoveryOutcome.rotated_ca_warm_failed, recovery.recoverConstructionTls(failed.client)); +} + +test "request-construction TLS recovery reaches later root and child trajectories" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = gpa, .io = io }; + defer original.deinit(); + var recovery: Recovery = undefined; + recovery.init(gpa, io, &original, false); + defer recovery.deinit(); + + var failed_root = recovery.acquire(&original); + try std.testing.expectEqual(@as(u64, 0), failed_root.generation_id); + try std.testing.expectEqual(RecoveryOutcome.rotated, recovery.recoverConstructionTls(failed_root.client)); + + var later_root = recovery.acquire(&original); + defer later_root.release(); + var child = recovery.acquire(&original); + defer child.release(); + try std.testing.expectEqual(@as(u64, 1), later_root.generation_id); + try std.testing.expectEqual(later_root.generation_id, child.generation_id); + try std.testing.expect(later_root.client == child.client); + try std.testing.expect(later_root.client != failed_root.client); + failed_root.release(); +} + +test "concurrent stale TLS reports rotate a generation only once" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = gpa, .io = io }; + defer original.deinit(); + var recovery: Recovery = undefined; + recovery.init(gpa, io, &original, false); + defer recovery.deinit(); + + var first = recovery.acquire(&original); + var concurrent = recovery.acquire(&original); + var first_fut = io.async(recoverTask, .{ &recovery, first.client }); + var second_fut = io.async(recoverTask, .{ &recovery, concurrent.client }); + const first_result = first_fut.await(io); + const second_result = second_fut.await(io); + try std.testing.expect(first_result != .unavailable); + try std.testing.expect(second_result != .unavailable); + try std.testing.expect(first_result != second_result); + var after = recovery.acquire(&original); + defer after.release(); + try std.testing.expectEqual(@as(u64, 1), after.generation_id); + first.release(); + concurrent.release(); +} + +test "shutdown rejects new managed acquisitions and drains an in-flight replacement" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = gpa, .io = io }; + defer original.deinit(); + var recovery: Recovery = undefined; + recovery.init(gpa, io, &original, false); + defer recovery.deinit(); + + var initial = recovery.acquire(&original); + try std.testing.expectEqual(RecoveryOutcome.rotated, recovery.recoverConstructionTls(initial.client)); + initial.release(); + var in_flight = recovery.acquire(&original); + try std.testing.expectEqual(@as(u64, 1), in_flight.generation_id); + + var shutdown = io.async(shutdownTask, .{&recovery}); + for (0..100) |_| { + if (recovery.stats().shutting_down) break; + try io.sleep(.fromMilliseconds(1), .awake); + } + try std.testing.expect(recovery.stats().shutting_down); + const denied = recovery.acquire(&original); + try std.testing.expect(!denied.available); + try std.testing.expectEqual(@as(usize, 1), recovery.stats().total_refs); + + in_flight.release(); + _ = shutdown.await(io); + try std.testing.expectEqual(@as(usize, 0), recovery.stats().total_refs); +} + +test "owned retired client remains alive until both stale leases release" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = gpa, .io = io }; + defer original.deinit(); + var recovery: Recovery = undefined; + recovery.init(gpa, io, &original, false); + defer recovery.deinit(); + + var initial = recovery.acquire(&original); + try std.testing.expectEqual(RecoveryOutcome.rotated, recovery.recoverConstructionTls(initial.client)); + initial.release(); + var first = recovery.acquire(&original); + var second = recovery.acquire(&original); + try std.testing.expectEqual(RecoveryOutcome.rotated, recovery.recoverConstructionTls(first.client)); + try std.testing.expectEqual(@as(usize, 1), recovery.stats().retired); + first.release(); + try std.testing.expectEqual(@as(usize, 1), recovery.stats().retired); + second.release(); + try std.testing.expectEqual(@as(usize, 0), recovery.stats().retired); +} + +test "generation zero injection never intercepts an unmanaged client" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = gpa, .io = io }; + defer original.deinit(); + var unrelated: std.http.Client = .{ .allocator = gpa, .io = io }; + defer unrelated.deinit(); + var recovery: Recovery = undefined; + recovery.init(gpa, io, &original, false); + defer recovery.deinit(); + http_client.installForTest(&recovery, null, null); + defer http_client.uninstallForTest(); + + http_client.injectConstructionTlsForTest(0); + var unmanaged = recovery.acquire(&unrelated); + defer unmanaged.release(); + try std.testing.expect(http_client.injectedConstructionTls(&unmanaged) == null); + var managed = recovery.acquire(&original); + defer managed.release(); + try std.testing.expectEqual(error.TlsRequestConstructionFailed, http_client.injectedConstructionTls(&managed).?); +} + +test "post-prewarm generation allocation failure cleans up and preserves active client" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = gpa, .io = io }; + defer original.deinit(); + var recovery: Recovery = undefined; + recovery.init(gpa, io, &original, true); + defer recovery.deinit(); + + var lease = recovery.acquire(&original); + defer lease.release(); + http_client.injectGenerationAllocationFailureForTest(); + try std.testing.expectEqual(RecoveryOutcome.unavailable, recovery.recoverConstructionTls(lease.client)); + try std.testing.expectEqual(@as(u64, 0), recovery.stats().active_id); + try std.testing.expect(lease.client == &original); +} diff --git a/src/http_client_trajectory_tests.zig b/src/http_client_trajectory_tests.zig new file mode 100644 index 00000000..5c25993c --- /dev/null +++ b/src/http_client_trajectory_tests.zig @@ -0,0 +1,60 @@ +//! Production-shaped model-call trajectories that sit above the transport adapter. + +const std = @import("std"); +const Io = std.Io; +const http_client = @import("http_client.zig"); +const support = @import("http_client_integration_tests.zig"); +const subagent = @import("subagent.zig"); +const tools = @import("tools.zig"); + +test "background subagent tool recovers TLS and completes through agent_output" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + defer runtime.deinit(io); + http_client.waitForReady(io); + defer subagent.agentJobsReap(gpa, io); + + var address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&address, io, .{}); + defer server.deinit(io); + const replies = [_]support.Reply{.{ .body = support.chat_body }}; + var accepted: std.atomic.Value(usize) = .init(0); + var server_future = io.async(support.serveReplies, .{ io, &server, @as([]const support.Reply, &replies), &accepted }); + defer server_future.await(io); + defer support.releaseAccept(io, &server); + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/test", .{server.socket.address.getPort()}); + const ctx: tools.ToolCtx = .{ + .gpa = gpa, + .io = io, + .client = &runtime.client, + .provider = support.provider(url), + .registry = null, + .from_sub = false, + .approvals = null, + .tracer = null, + }; + const parsed = try std.json.parseFromSlice(std.json.Value, gpa, + \\{"description":"tls-background-child","prompt":"reply once","run_in_background":true} + , .{}); + defer parsed.deinit(); + + http_client.injectConstructionTlsForTest(0); + const spawned = try subagent.execSubagent(ctx, parsed.value); + defer gpa.free(spawned.text); + try std.testing.expect(!spawned.is_error); + const id_start = std.mem.indexOf(u8, spawned.text, "[agent ").? + "[agent ".len; + const id_end = std.mem.indexOfScalarPos(u8, spawned.text, id_start, ' ').?; + const id = try std.fmt.parseInt(u32, spawned.text[id_start..id_end], 10); + + const completed = try subagent.agentOutput(gpa, io, id, 1); + defer gpa.free(completed.text); + try std.testing.expect(!completed.is_error); + try std.testing.expect(std.mem.indexOf(u8, completed.text, "child-ok") != null); + try std.testing.expectEqual(@as(u64, 1), runtime.recovery.stats().active_id); + try std.testing.expectEqual(@as(usize, 1), accepted.load(.acquire)); + try std.testing.expectEqual(@as(usize, 0), runtime.recovery.stats().active_refs); +} diff --git a/src/main.zig b/src/main.zig index 48367233..17ec520c 100644 --- a/src/main.zig +++ b/src/main.zig @@ -578,7 +578,9 @@ test { // pull in tests from imported modules (mcp.zig) _ = @import("mcp_rpc.zig"); _ = @import("main_test.zig"); _ = @import("http_client.zig"); + _ = @import("http_client_tests.zig"); _ = @import("http_client_integration_tests.zig"); + _ = @import("http_client_trajectory_tests.zig"); // A module whose tests must run needs an explicit reference here (a plain @import elsewhere compiles to nothing); scripts/eval-tier1.sh --only reach catches one. _ = @import("test_hooks.zig"); // unreached modules; their tests were silently skipped _ = @import("agent_overflow_tests.zig"); // #414: and, through it, agent_overflow.zig's table tests From e21372394232ad87a3a7b994f1cef471ada83229 Mon Sep 17 00:00:00 2001 From: yxlyx <85774423+yxlyx@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:16:10 +0800 Subject: [PATCH 12/27] fix: add isolated session branching Concurrent resumes previously shared one durable session identity, so autosaves and shutdown writes could overwrite another continuation. Add clone-on-write resume targets across line, scripted, ACP, and fullscreen TUI paths, claim destination names atomically, and keep provider-native history plus session metadata attached to the selected branch.\n\nRecord the identity contract in ADR 0042 and cover source immutability, branch isolation, ownership stability, cache identity, and destination races with an offline process regression.\n\nCo-Authored-By: Codegraff --- TUI/catalog.zig | 1 + TUI/dispatch.zig | 18 +- TUI/engine.zig | 28 ++ TUI/resume.zig | 70 +++++ TUI/root.zig | 9 + TUI/run.zig | 15 + ...nches-have-independent-durable-identity.md | 43 +++ docs/adr/README.md | 1 + scripts/test-session-branching.py | 295 ++++++++++++++++++ src/agent.zig | 1 + src/args.zig | 8 +- src/cli.zig | 1 + src/command_catalog.zig | 2 +- src/commands_misc.zig | 63 +--- src/commands_resume.zig | 81 +++++ src/http_headers.zig | 9 + src/main.zig | 5 +- src/repl_convo.zig | 19 ++ src/repl_turn.zig | 49 ++- src/session.zig | 8 + src/session_branch.zig | 75 +++++ src/session_index.zig | 22 +- src/session_run.zig | 50 ++- src/test_hooks.zig | 2 + src/tui_launch.zig | 40 +++ src/tui_session.zig | 123 ++++++++ 26 files changed, 951 insertions(+), 87 deletions(-) create mode 100644 TUI/resume.zig create mode 100644 docs/adr/0042-resume-branches-have-independent-durable-identity.md create mode 100644 scripts/test-session-branching.py create mode 100644 src/commands_resume.zig create mode 100644 src/session_branch.zig create mode 100644 src/tui_session.zig diff --git a/TUI/catalog.zig b/TUI/catalog.zig index 0e045055..da899449 100644 --- a/TUI/catalog.zig +++ b/TUI/catalog.zig @@ -11,6 +11,7 @@ pub const Item = struct { pub const items = [_]Item{ .{ .name = "/new", .desc = "Start a fresh session", .aliases = &.{"/clear"} }, + .{ .name = "/resume", .desc = "Resume or branch a saved session" }, .{ .name = "/home", .desc = "Return to the welcome screen", .aliases = &.{"/welcome"} }, .{ .name = "/compact", .desc = "Engine-compact model-visible history" }, .{ .name = "/context", .desc = "Show context-window use" }, diff --git a/TUI/dispatch.zig b/TUI/dispatch.zig index e7ddb2c2..a5e2b115 100644 --- a/TUI/dispatch.zig +++ b/TUI/dispatch.zig @@ -8,6 +8,7 @@ const bgop = @import("bgop.zig"); const catalog = @import("catalog.zig"); const engine = @import("engine.zig"); const peer_cmd = @import("peer_cmd.zig"); +const resume_mod = @import("resume.zig"); const meters = @import("meters.zig"); const theme_mod = @import("theme.zig"); const turn = @import("turn.zig"); @@ -58,7 +59,7 @@ pub fn runCommand(self: *Model, line: []const u8) Effect { // #521: history-destroying commands must not run under a live job — the // steer guard in promptKey only covers plain text, and the slash menu, // palette, and steer drain all land here. - const destroys = std.mem.eql(u8, canon, "/new") or std.mem.eql(u8, canon, "/compact") or std.mem.eql(u8, canon, "/rewind"); + const destroys = std.mem.eql(u8, canon, "/new") or std.mem.eql(u8, canon, "/compact") or std.mem.eql(u8, canon, "/rewind") or std.mem.eql(u8, canon, "/resume"); if (self.pending != null and destroys) { self.push(.system, "a turn is still running — press Esc to cancel it first") catch {}; return .stay; @@ -76,6 +77,8 @@ pub fn runCommand(self: *Model, line: []const u8) Effect { _ = self.newSession(); // the `destroys` guard above already refused a live call self.push(.system, "started a new conversation") catch {}; self.screen = .welcome; + } else if (std.mem.eql(u8, canon, "/resume")) { + resume_mod.run(self, arg); } else if (std.mem.eql(u8, canon, "/home")) { self.screen = .welcome; self.focus = .prompt; @@ -142,17 +145,21 @@ pub fn runCommand(self: *Model, line: []const u8) Effect { self.pushFmt(.system, "fast: {s}", .{onOff(self.fast)}) catch {}; } else if (std.mem.eql(u8, canon, "/ultracode")) { self.ultracode = !self.ultracode; + publishState(self); self.pushFmt(.system, "ultracode: {s}", .{onOff(self.ultracode)}) catch {}; } else if (std.mem.eql(u8, canon, "/strict")) { self.strict = !self.strict; + publishState(self); self.pushFmt(.system, "strict: {s}", .{onOff(self.strict)}) catch {}; } else if (std.mem.eql(u8, canon, "/goal")) { if (self.goal) |g| self.alloc.free(g); self.goal = if (arg.len > 0) (self.alloc.dupe(u8, arg) catch null) else null; + publishState(self); if (self.goal) |g| self.pushFmt(.system, "goal set: {s}", .{g}) catch {} else self.push(.system, "goal cleared") catch {}; } else if (std.mem.eql(u8, canon, "/rename")) { if (self.session_name) |s| self.alloc.free(s); self.session_name = if (arg.len > 0) (self.alloc.dupe(u8, arg) catch null) else null; + publishState(self); self.pushFmt(.system, "session: {s}", .{self.session_name orelse "untitled"}) catch {}; } else if (std.mem.eql(u8, canon, "/session-info")) { meters.sessionInfo(self); @@ -208,6 +215,15 @@ pub fn runCommand(self: *Model, line: []const u8) Effect { return if (self.quit_requested) .quit else .stay; } +fn publishState(self: *Model) void { + if (engine.g_state_fn) |f| f(engine.g_turn_ctx, .{ + .session_name = self.session_name orelse "", + .goal = self.goal orelse "", + .strict = self.strict, + .ultracode = self.ultracode, + }); +} + /// `!cmd` — run a shell line locally (grok-style bash mode) on a background /// thread, so a slow command no longer freezes the frame for its whole 20s /// cap (#533). Output stays out of the model history: EntryKind.system never diff --git a/TUI/engine.zig b/TUI/engine.zig index efa90405..94c5ad00 100644 --- a/TUI/engine.zig +++ b/TUI/engine.zig @@ -152,6 +152,24 @@ pub const CompactFn = *const fn (turn_ctx: ?*anyopaque, gpa: std.mem.Allocator, pub const HistoryOp = enum { reset, rewind }; pub const HistoryFn = *const fn (turn_ctx: ?*anyopaque, op: HistoryOp) void; +pub const SessionState = struct { + session_name: []const u8 = "", + goal: []const u8 = "", + strict: bool = false, + ultracode: bool = false, +}; +pub const StateFn = *const fn (turn_ctx: ?*anyopaque, state: SessionState) void; + +pub const ResumeOut = struct { + turns: []Turn = &.{}, + session_name: []const u8 = "", + goal: []const u8 = "", + strict: bool = false, + ultracode: bool = false, + note: []const u8 = "", +}; +pub const ResumeFn = *const fn (turn_ctx: ?*anyopaque, gpa: std.mem.Allocator, spec: []const u8, out: *ResumeOut) bool; + pub const Job = struct { thread: std.Thread = undefined, threaded: bool = true, @@ -238,6 +256,11 @@ pub const RunOpts = struct { cancel_fn: ?CancelFn = null, model_name: []const u8 = "", model_provider: []const u8 = "", + initial_history: []const Turn = &.{}, + session_name: []const u8 = "", + initial_goal: []const u8 = "", + initial_strict: bool = false, + initial_ultracode: bool = false, /// The model catalog with its provider column (see ModelEntry). model_entries: []const ModelEntry = &.{}, cwd: []const u8 = ".", @@ -249,6 +272,9 @@ pub const RunOpts = struct { copy_fn: ?CopyFn = null, compact_fn: ?CompactFn = null, history_fn: ?HistoryFn = null, + resume_fn: ?ResumeFn = null, + state_fn: ?StateFn = null, + emergency_fn: ?*const fn (turn_ctx: ?*anyopaque) void = null, idle_wake_fn: ?IdleWakeFn = null, peer_fn: ?PeerFn = null, }; @@ -264,6 +290,8 @@ pub var g_files_fn: ?FilesFn = null; pub var g_copy_fn: ?CopyFn = null; pub var g_compact_fn: ?CompactFn = null; pub var g_history_fn: ?HistoryFn = null; +pub var g_resume_fn: ?ResumeFn = null; +pub var g_state_fn: ?StateFn = null; /// Tell the engine the transcript was cut. Silent when nothing is wired /// (offline TUI, unit tests). diff --git a/TUI/resume.zig b/TUI/resume.zig new file mode 100644 index 00000000..0192a67d --- /dev/null +++ b/TUI/resume.zig @@ -0,0 +1,70 @@ +//! Fullscreen `/resume SOURCE [--branch DEST]` projection. + +const std = @import("std"); +const app = @import("app.zig"); +const engine = @import("engine.zig"); + +pub fn run(self: *app.Model, spec: []const u8) void { + if (spec.len == 0) { + self.push(.system, "usage: /resume SOURCE [--branch DEST]") catch {}; + return; + } + const callback = engine.g_resume_fn orelse { + self.push(.system, "resume isn't available (offline)") catch {}; + return; + }; + var out: engine.ResumeOut = .{}; + if (!callback(engine.g_turn_ctx, self.alloc, spec, &out)) { + if (out.note.len > 0) { + self.push(.err, out.note) catch {}; + self.alloc.free(out.note); + } else self.push(.err, "resume failed") catch {}; + return; + } + self.clearHistory(); + for (out.turns) |turn| { + self.push(if (turn.role == .user) .user else .assistant, turn.text) catch {}; + self.alloc.free(turn.text); + } + if (out.turns.len > 0) self.alloc.free(out.turns); + self.turns = self.userTurnCount(); + if (self.session_name) |old| self.alloc.free(old); + self.session_name = out.session_name; + if (self.goal) |old| self.alloc.free(old); + self.goal = if (out.goal.len > 0) out.goal else null; + self.strict = out.strict; + self.ultracode = out.ultracode; + if (out.note.len > 0) { + self.push(.system, out.note) catch {}; + self.alloc.free(out.note); + } +} + +fn fakeResume(_: ?*anyopaque, gpa: std.mem.Allocator, spec: []const u8, out: *engine.ResumeOut) bool { + if (!std.mem.eql(u8, spec, "base --branch child")) return false; + const turns = gpa.alloc(engine.Turn, 2) catch return false; + turns[0] = .{ .role = .user, .text = gpa.dupe(u8, "baseline prompt") catch return false }; + turns[1] = .{ .role = .assistant, .text = gpa.dupe(u8, "baseline answer") catch return false }; + out.* = .{ + .turns = turns, + .session_name = gpa.dupe(u8, "child") catch return false, + .note = gpa.dupe(u8, "branched base → child") catch return false, + }; + return true; +} + +test "fullscreen resume replaces transcript and selects the branch" { + const saved = engine.g_resume_fn; + defer engine.g_resume_fn = saved; + engine.g_resume_fn = fakeResume; + var model: app.Model = undefined; + model.setup(std.testing.allocator); + defer model.deinit(); + try model.push(.user, "stale prompt"); + run(&model, "base --branch child"); + try std.testing.expectEqualStrings("child", model.session_name.?); + try std.testing.expectEqual(@as(usize, 3), model.history.items.len); + try std.testing.expectEqualStrings("baseline prompt", model.history.items[0].text); + try std.testing.expectEqualStrings("baseline answer", model.history.items[1].text); + try std.testing.expectEqualStrings("branched base → child", model.history.items[2].text); +} diff --git a/TUI/root.zig b/TUI/root.zig index 503c73f6..ca45e023 100644 --- a/TUI/root.zig +++ b/TUI/root.zig @@ -35,9 +35,17 @@ pub const CompactOut = engine.CompactOut; pub const CompactFn = engine.CompactFn; pub const HistoryOp = engine.HistoryOp; pub const HistoryFn = engine.HistoryFn; +pub const ResumeOut = engine.ResumeOut; +pub const ResumeFn = engine.ResumeFn; +pub const SessionState = engine.SessionState; +pub const StateFn = engine.StateFn; pub const PeerFn = engine.PeerFn; pub const RunOpts = run_mod.RunOpts; pub const run = run_mod.run; +pub fn setCurrentModel(name: []const u8, provider: []const u8) void { + engine.g_model_name = name; + engine.g_model_provider = provider; +} pub const restore = @import("restore.zig"); /// Restore the terminal BEFORE std prints a panic, or the alt-screen exit in /// the restore sequence erases the message and the stack trace (#535). @@ -55,6 +63,7 @@ test { _ = app; _ = @import("app_tests.zig"); _ = @import("dispatch.zig"); + _ = @import("resume.zig"); _ = @import("peer_cmd.zig"); _ = @import("peer_tests.zig"); _ = @import("prompt_history.zig"); diff --git a/TUI/run.zig b/TUI/run.zig index 5f19f0c6..5a24f295 100644 --- a/TUI/run.zig +++ b/TUI/run.zig @@ -55,6 +55,8 @@ pub fn run( engine.g_copy_fn = opts.copy_fn; engine.g_compact_fn = opts.compact_fn; engine.g_history_fn = opts.history_fn; + engine.g_resume_fn = opts.resume_fn; + engine.g_state_fn = opts.state_fn; engine.g_idle_wake_fn = opts.idle_wake_fn; engine.g_peer_fn = opts.peer_fn; engine.g_model_name = opts.model_name; @@ -65,6 +67,18 @@ pub fn run( var m: Model = undefined; m.setup(gpa); defer m.deinit(); + defer if (opts.state_fn) |f| f(opts.turn_ctx, .{ + .session_name = m.session_name orelse "", + .goal = m.goal orelse "", + .strict = m.strict, + .ultracode = m.ultracode, + }); + for (opts.initial_history) |item| m.push(if (item.role == .user) .user else .assistant, item.text) catch {}; + m.turns = m.userTurnCount(); + if (opts.session_name.len > 0) m.session_name = gpa.dupe(u8, opts.session_name) catch null; + if (opts.initial_goal.len > 0) m.goal = gpa.dupe(u8, opts.initial_goal) catch null; + m.strict = opts.initial_strict; + m.ultracode = opts.initial_ultracode; if (opts.yolo) m.mode = .always_approve; var raw = tty.enterRaw() orelse return error.NotATty; @@ -437,6 +451,7 @@ pub fn run( // The threads are still writing into the job and the op, so the // process must not outlive the restore: put the terminal back with // the same bytes the defers would have written, then leave. + if (opts.emergency_fn) |f| f(opts.turn_ctx); w.flush() catch {}; restore_mod.emergency(); std.process.exit(0); diff --git a/docs/adr/0042-resume-branches-have-independent-durable-identity.md b/docs/adr/0042-resume-branches-have-independent-durable-identity.md new file mode 100644 index 00000000..c4be74f5 --- /dev/null +++ b/docs/adr/0042-resume-branches-have-independent-durable-identity.md @@ -0,0 +1,43 @@ +# 0042. Resume branches have independent durable identity + +Status: accepted 2026-08-30 + +## Context + +Issue #689 reproduced silent loss when two processes resumed one session name. +The advisory writer lock serialized individual whole-file replacements, but it +could not turn two descendants into independent tips. It also exposed that the +fullscreen ACP-backed TUI rendered an empty conversation after startup resume +and bypassed the root final-save path. + +Provider-native tool/reasoning blocks and compaction boundaries make merging two +message arrays unsafe. A process-lifetime source lock would avoid corruption by +rejecting the second user, but would not provide the requested branch behavior. + +## Decision + +`--resume SOURCE --branch DESTINATION` and +`/resume SOURCE --branch DESTINATION` clone SOURCE once into a new durable +identity. DESTINATION must be new and distinct from SOURCE; creation uses an +exclusive filesystem claim so two processes cannot both win a previously +unused name. Future turns, transcripts, checkpoints, compaction, and shutdown +saves target only DESTINATION; the session header records `parent: SOURCE` and +the branch receives a fresh persisted cache/session UUID. + +The fullscreen TUI, TTY `graff repl`, scripted `graff repl`, and ACP startup all +consume the same restored provider-native history. The fullscreen frontend also +projects that history into visible rows and syncs its engine-owned conversation +back to the root before final save. + +A branch copies ADR 0014's peer cursor and unread inbox snapshot exactly once as +part of the session snapshot. Parent and child then persist their cursors +independently; neither replays the room and neither shares mutable inbox state. +Git worktree isolation remains a separate choice. + +## Consequences + +Concurrent continuations need distinct destination names, which keeps conflicts +explicit and makes reopening deterministic. Existing `/resume SOURCE` remains a +same-tip continuation for compatibility and is not safe as a branching command. +There is no automatic merge; future merge/cherry-pick work must understand +provider-native history rather than appending JSON arrays. diff --git a/docs/adr/README.md b/docs/adr/README.md index e76f1c51..783f6a71 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -52,6 +52,7 @@ record only when you need the evidence or the edge cases. | [0039](0039-local-tools-are-project-scripts.md) | Agent-authored local tools are project scripts under `.graff/tools/`; skills stay instructions. Runtime catalog extras, not `schema.effectiveRootSpecs`. | | [0040](0040-codedb-stays-when-licensed.md) | Ordinary reads use native `codedb` / `read_file`; codedb-pro is extra search, not the default reader. | | [0041](0041-tui-is-an-acp-client.md) | The fullscreen TUI is an in-process ACP client: session/prompt in, session/update thought/tool/text out. No child `graff acp`. | +| [0042](0042-resume-branches-have-independent-durable-identity.md) | `--resume SOURCE --branch DEST` clones provider history and peer cursor state once; every later save belongs only to DEST. | ## When to write one diff --git a/scripts/test-session-branching.py b/scripts/test-session-branching.py new file mode 100644 index 00000000..f2124aaf --- /dev/null +++ b/scripts/test-session-branching.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python3 +"""Offline process-level regression for #689 session clone-on-write branching. + +Runs two live line-REPL processes from one baseline against codex_ws_mock.py, +proves their provider histories and durable files stay isolated, then exercises +the original input-buffer `/resume` autosave corruption path. + +Usage: python3 scripts/test-session-branching.py [path/to/graff] +""" + +from __future__ import annotations + +import json +import os +import pathlib +import subprocess +import sys +import tempfile +import threading +import time + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from codex_ws_mock import CodexMock, RecordedRequest # noqa: E402 + +_arg = sys.argv[1] if len(sys.argv) > 1 else "zig-out/bin/graff" +GRAFF = str(pathlib.Path(_arg).resolve()) +BASE = "BRANCH_BASELINE_689" +A = "BRANCH_ONLY_A_689" +A_CHECK = "REOPEN_BRANCH_A_689" +B = "BRANCH_ONLY_B_689" +B2 = "BRANCH_B_AFTER_A_EXIT_689" +B_CHECK = "REOPEN_BRANCH_B_689" +OWNED = "RESUME_INPUT_BUFFER_OWNERSHIP_689" + + +def reply(text: str, ordinal: int) -> list[dict]: + return [ + { + "type": "response.output_item.done", + "item": { + "type": "message", + "id": f"msg_{ordinal}", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": text, "annotations": []}], + }, + }, + { + "type": "response.completed", + "response": { + "id": f"resp_{ordinal}", + "usage": { + "input_tokens": 100, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens": 10, + "total_tokens": 110, + }, + }, + }, + ] + + +def events(request: RecordedRequest) -> list[dict]: + body = json.dumps(request.body.get("input", [])) + if A in body and B not in body: + time.sleep(0.15) + elif B in body and A not in body: + time.sleep(0.4) + seen = [token for token in (BASE, A, B, B2, A_CHECK, B_CHECK, OWNED) if token in body] + return reply("SEEN " + ",".join(seen), request.ordinal) + + +def environment(workspace: pathlib.Path, port: int) -> dict[str, str]: + codex_home = workspace / "codex-home" + codex_home.mkdir() + (codex_home / "auth.json").write_text( + json.dumps({"tokens": {"access_token": "mock", "account_id": "acct"}}), + encoding="utf-8", + ) + harness = workspace / ".harness" + harness.mkdir() + (harness / "settings.json").write_text( + json.dumps({"ai_title": False, "skills": {"codedbpro": False}}), + encoding="utf-8", + ) + empty_mcp = workspace / "empty-mcp.json" + empty_mcp.write_text('{"mcpServers":{}}', encoding="utf-8") + env = { + key: value + for key, value in os.environ.items() + if not key.startswith("GRAFF_") and not key.startswith("CODEX_") + } + env.update( + { + "HOME": str(workspace), + "CODEX_HOME": str(codex_home), + "GRAFF_CODEX_URL": f"http://127.0.0.1:{port}/backend-api/codex/responses", + "GRAFF_CODEX_WS": "off", + "GRAFF_FLEET": "off", + "GRAFF_NO_TELEMETRY": "1", + "GRAFF_LEARN_AUTO": "off", + "GRAFF_MCP_CONFIG": str(empty_mcp), + "NO_COLOR": "1", + } + ) + return env + + +def pump(stream, target: list[str]) -> None: + for line in stream: + target.append(line) + + +def spawn(cmd: list[str], workspace: pathlib.Path, env: dict[str, str]): + proc = subprocess.Popen( + cmd, + cwd=workspace, + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + out: list[str] = [] + err: list[str] = [] + threading.Thread(target=pump, args=(proc.stdout, out), daemon=True).start() + threading.Thread(target=pump, args=(proc.stderr, err), daemon=True).start() + return proc, out, err + + +def wait_for(proc: subprocess.Popen, output: list[str], needle: str, timeout: float = 30) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + if needle in "".join(output): + return + if proc.poll() is not None: + raise AssertionError( + f"process exited {proc.returncode} waiting for {needle!r}:\n{''.join(output)[-1500:]}" + ) + time.sleep(0.03) + raise AssertionError(f"timed out waiting for {needle!r}:\n{''.join(output)[-1500:]}") + + +def messages(path: pathlib.Path) -> str: + return json.dumps(json.loads(path.read_text(encoding="utf-8"))["messages"]) + + +def request_for(mock: CodexMock, marker: str) -> str: + matches = [json.dumps(req.body.get("input", [])) for req in mock.requests if marker in json.dumps(req.body)] + if not matches: + raise AssertionError(f"mock saw no request containing {marker}") + return matches[-1] + + +def close(proc: subprocess.Popen) -> None: + assert proc.stdin is not None + proc.stdin.close() + proc.wait(timeout=30) + if proc.returncode != 0: + raise AssertionError(f"graff exited {proc.returncode}") + + +def main() -> None: + mock = CodexMock(events_for_request=events) + port = mock.start() + try: + with tempfile.TemporaryDirectory(prefix="graff-session-branching-") as tmp: + workspace = pathlib.Path(tmp) + env = environment(workspace, port) + cmd = [GRAFF, "--model", "codex", "--yolo", "--no-telemetry"] + + seed = subprocess.run( + cmd + ["--resume", "baseline"], + cwd=workspace, + env=env, + input=BASE + "\n", + text=True, + capture_output=True, + timeout=30, + ) + assert seed.returncode == 0, seed.stderr + sessions = workspace / ".graff" / "sessions" + source = sessions / "baseline.session.json" + assert BASE in messages(source) + + pa, oa, ea = spawn(cmd + ["--resume", "baseline", "--branch", "branch-a"], workspace, env) + pb, ob, eb = spawn(cmd + ["--resume", "baseline", "--branch", "branch-b"], workspace, env) + wait_for(pa, oa, "branched baseline.session.json → branch-a.session.json") + wait_for(pb, ob, "branched baseline.session.json → branch-b.session.json") + assert pa.poll() is None and pb.poll() is None + + assert pa.stdin is not None and pb.stdin is not None + pa.stdin.write(A + "\n") + pa.stdin.flush() + pb.stdin.write(B + "\n") + pb.stdin.flush() + wait_for(pa, oa, f"SEEN {BASE},{A}") + wait_for(pb, ob, f"SEEN {BASE},{B}") + time.sleep(0.5) + + branch_a = sessions / "branch-a.session.json" + branch_b = sessions / "branch-b.session.json" + source_body = messages(source) + a_body = messages(branch_a) + b_body = messages(branch_b) + assert A not in source_body and B not in source_body + assert BASE in a_body and A in a_body and B not in a_body + assert BASE in b_body and B in b_body and A not in b_body + source_json = json.loads(source.read_text()) + branch_a_json = json.loads(branch_a.read_text()) + branch_b_json = json.loads(branch_b.read_text()) + assert branch_a_json["parent"] == "baseline" + assert branch_b_json["parent"] == "baseline" + assert len({source_json["cache_key"], branch_a_json["cache_key"], branch_b_json["cache_key"]}) == 3 + + close(pa) + assert pb.poll() is None, "closing A terminated B" + pb.stdin.write(B2 + "\n") + pb.stdin.flush() + wait_for(pb, ob, f"SEEN {BASE},{B},{B2}") + close(pb) + + for name, marker in (("branch-a", A_CHECK), ("branch-b", B_CHECK)): + reopened = subprocess.run( + cmd + ["--resume", name, marker], + cwd=workspace, + env=env, + text=True, + capture_output=True, + timeout=30, + ) + assert reopened.returncode == 0, reopened.stderr + a_request = request_for(mock, A_CHECK) + b_request = request_for(mock, B_CHECK) + assert BASE in a_request and A in a_request and B not in a_request and B2 not in a_request + assert BASE in b_request and B in b_request and B2 in b_request and A not in b_request + + duplicate = subprocess.run( + cmd + ["--resume", "baseline", "--branch", "branch-a"], + cwd=workspace, + env=env, + text=True, + capture_output=True, + timeout=15, + ) + assert duplicate.returncode != 0 + assert "BranchAlreadyExists" in duplicate.stderr or "already exists" in duplicate.stderr + + race = [spawn(cmd + ["--resume", "baseline", "--branch", "race-dest"], workspace, env) for _ in range(2)] + deadline = time.time() + 30 + while time.time() < deadline: + live = [item for item in race if item[0].poll() is None] + done = [item for item in race if item[0].poll() is not None] + if len(live) == 1 and len(done) == 1 and "branched baseline.session.json → race-dest.session.json" in "".join(live[0][1]): + break + time.sleep(0.03) + else: + raise AssertionError(f"same-destination race was not exclusive: {[(p.poll(), ''.join(o), ''.join(e)) for p, o, e in race]}") + winner = next(item for item in race if item[0].poll() is None) + loser = next(item for item in race if item[0].poll() is not None) + assert loser[0].returncode != 0 + assert "BranchAlreadyExists" in "".join(loser[2]) or "already exists" in "".join(loser[2]) + close(winner[0]) + + owner, owner_out, owner_err = spawn(cmd, workspace, env) + assert owner.stdin is not None + owner.stdin.write("/resume baseline\n") + owner.stdin.flush() + wait_for(owner, owner_out, "resumed baseline.session.json") + owner.stdin.write(OWNED + "\n") + owner.stdin.flush() + wait_for(owner, owner_out, f"SEEN {BASE},{OWNED}") + close(owner) + assert OWNED in messages(source) + + files = sorted(path.name for path in sessions.glob("*.session.json")) + assert "baseline.session.json" in files + assert "branch-a.session.json" in files + assert "branch-b.session.json" in files + assert all("\n" not in name and "\r" not in name for name in files), files + for transcript in sessions.glob("*.transcript.jsonl"): + for line in transcript.read_text(encoding="utf-8").splitlines(): + json.loads(line) + + assert not ea, "branch A stderr: " + "".join(ea) + assert not eb, "branch B stderr: " + "".join(eb) + assert not owner_err, "ownership stderr: " + "".join(owner_err) + print("session branching: source immutable, branches isolated/reopenable, ownership and destination claims stable") + finally: + mock.stop() + + +if __name__ == "__main__": + main() diff --git a/src/agent.zig b/src/agent.zig index 7f3ad6ac..059012af 100644 --- a/src/agent.zig +++ b/src/agent.zig @@ -167,6 +167,7 @@ pub const Agent = struct { loop_deadline_ms: ?i64 = null, // the running /loop's wall-clock deadline (goal_pacing.LoopClock); read by the subagent spawn path so a child inherits it. Run-local: never saved, cleared on stop/steer history_rewrites: u32 = 0, // bumped by compact()/emergencyTrim; state pasted into the dead history (e.g. the /loop checklist copy) must be re-carried (#318) session_name: []const u8 = "last", // autosave/resume target (.session.json) + session_parent: ?[]const u8 = null, // clone-on-write ancestry: this session branched from session_title: ?[]const u8 = null, // human-readable title/rename metadata sys_base: []const u8 = "", // #381: the last BASE handed to prompts.setSystemPrompts, WITHOUT the playbook block — what a mid-session constraint re-composes from (playbook_glue.refreshRoot) sys_strict: []const u8 = prompts.main_system_prompt_strict, diff --git a/src/args.zig b/src/args.zig index ffd5c496..3f28ca1d 100644 --- a/src/args.zig +++ b/src/args.zig @@ -60,7 +60,8 @@ pub const Flags = struct { host_flag: []const u8 = "127.0.0.1", // harness serve port_flag: u16 = 8787, // harness serve token_flag: ?[]const u8 = null, // harness serve - resume_flag: ?[]const u8 = null, // restore/save this named session + resume_flag: ?[]const u8 = null, // restore this named session + branch_flag: ?[]const u8 = null, // clone --resume into this independent autosave target goal_flag: ?[]const u8 = null, // --goal: standing objective (todos) every turn gets, incl. --json/-p eval_cmd_flag: ?[]const u8 = null, // --eval: scoring command for the eval-driven loop worktree_flag: ?[]const u8 = null, // --worktree/-w: isolate this session in a git worktree (parallel agents, no file collisions) @@ -206,6 +207,9 @@ pub fn parse(init: std.process.Init) !Flags { } else if (std.mem.eql(u8, arg, "--resume")) { const rv = it.next() orelse std.process.fatal("--resume needs a session name — harness --help", .{}); flags.resume_flag = try arena.dupe(u8, rv); + } else if (std.mem.eql(u8, arg, "--branch")) { + const bv = it.next() orelse std.process.fatal("--branch needs a destination session name — harness --help", .{}); + flags.branch_flag = try arena.dupe(u8, bv); } else if (std.mem.eql(u8, arg, "--no-resume")) { flags.no_resume_flag = true; } else if (std.mem.eql(u8, arg, "--new")) { @@ -261,6 +265,8 @@ pub fn parse(init: std.process.Init) !Flags { flags.oneshot_prompt = try std.mem.join(arena, " ", flags.positionals.items); } if (flags.print_flag and flags.oneshot_prompt == null) std.process.fatal("-p needs a prompt: harness -p \"do something\"", .{}); + if (flags.branch_flag != null and flags.resume_flag == null) std.process.fatal("--branch needs --resume ", .{}); + if (flags.branch_flag != null and (flags.new_session_flag or flags.no_resume_flag)) std.process.fatal("--branch cannot be combined with --new or --no-resume", .{}); // The tool-surface half of lean, set AFTER the one-shot prompt is // assembled: on for --lean and for every one-shot without --no-lean (the diff --git a/src/cli.zig b/src/cli.zig index 8d3ad8c4..558e8873 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -267,6 +267,7 @@ pub const usage_text = \\ --allow-cross-provider-subagents confirm prompts/code may go to the worker provider \\ --no-subagent-tier opt out of the default worker tier ladder (inherit the root model) \\ --resume resume/autosave .session.json + \\ --branch clone --resume into an independent autosave target \\ --new start a fresh autosaved session (default) \\ --no-resume ignore --resume and start fresh \\ --system-prompt replace the built-in system prompt diff --git a/src/command_catalog.zig b/src/command_catalog.zig index fcdac633..fbc4b14a 100644 --- a/src/command_catalog.zig +++ b/src/command_catalog.zig @@ -65,7 +65,7 @@ pub const commands = [_]Item{ .{ .name = "/paste", .desc = "attach the clipboard image — macOS; also Ctrl-V (⌘V can't be captured)" }, .{ .name = "/bash", .usage = "/bash ", .desc = "run a shell command directly" }, .{ .name = "/save", .usage = "/save [name]", .desc = "write the conversation to .session.json (default: current)" }, - .{ .name = "/resume", .usage = "/resume [name]", .desc = "restore a saved conversation (no arg → interactive picker)" }, + .{ .name = "/resume", .usage = "/resume [source] [--branch destination]", .desc = "restore a saved conversation, optionally cloning it into an independent branch" }, .{ .name = "/sessions", .desc = "list saved sessions in the cwd" }, .{ .name = "/workspace", .usage = "/workspace [list|use ]", .desc = "list git worktrees or switch this session into one (file tools follow)" }, .{ .name = "/experiment", .usage = "/experiment [N|off|status]", .desc = "pre-mint N child worktrees (1-16) and seat the next spawns in them; off clears the pool" }, diff --git a/src/commands_misc.zig b/src/commands_misc.zig index aad656ec..289a346a 100644 --- a/src/commands_misc.zig +++ b/src/commands_misc.zig @@ -20,7 +20,6 @@ const mcp_config_path = main_mod.mcp_config_path; const session_ext = session.session_ext; const saveSession = session.saveSession; const session = @import("session.zig"); -const loadSession = session.loadSession; const listSavedSessions = session.listSavedSessions; const sessionAge = session.sessionAge; @@ -498,62 +497,7 @@ pub fn tryHandle(root: *Agent, keys: *Keys, arena: Allocator, line: []const u8, try out.flush(); return true; } - if (std.mem.startsWith(u8, line, "/resume")) { - root.ensureStoredKeys(keys); - const arg = std.mem.trim(u8, line["/resume".len..], " \t"); - var name: []const u8 = ownedSessionName(arena, arg, "last") catch |err| { - try out.print("resume failed: {t}\n", .{err}); - try out.flush(); - return true; - }; - // Bare /resume on a TTY: pick from the saved sessions interactively, - // labeled by stored title + age instead of raw file names (#109). - if (arg.len == 0 and main_mod.use_color and root.in != null) { - var entries = listSavedSessions(root, arena); - defer entries.deinit(arena); - if (entries.items.len == 0) { - try out.writeAll("(no saved sessions in cwd — /save creates one)\n"); - try out.flush(); - return true; - } - var sessions: std.ArrayList(PickItem) = .empty; - defer sessions.deinit(arena); - for (entries.items) |e| { - const age = sessionAge(arena, root.io, e.updated_ms); - const desc = if (e.title == null) - age - else if (age.len > 0) - std.fmt.allocPrint(arena, "{s} · {s}", .{ age, e.base }) catch e.base - else - e.base; - try sessions.append(arena, .{ .name = e.title orelse e.base, .desc = desc }); - } - const idx = listPicker(root, arena, out, "Resume session ›", sessions.items) orelse return true; - name = entries.items[idx].base; // arena-owned by listSavedSessions, so it outlives the turn too - } - loadSession(root, keys, arena, name) catch |err| { - switch (err) { - error.FileNotFound => try out.print("no session named '{s}' ({s}{s} not found in cwd) — /sessions lists saved ones\n", .{ name, name, session_ext }), - else => try out.print("resume failed: {t}\n", .{err}), - } - try out.flush(); - return true; - }; - root.session_name = name; - // #445: after the rename, so the re-arm reads the resumed session. The - // history just loaded IS that file's contents, so the #410 line would - // again only describe what the live window already holds. - prompts.resetSessionCompacted(root, arena); - // The third restore path (#318): --goal outranks the restored goal here - // too, idempotently, or /resume was the one door that silently dropped it. - if (root.goal_flag) |g| root.pending_goal_note = goal_flow.reapplyFlagGoal(arena, root, g, util.unixMs(root.io)) catch null; - try out.print("resumed {s}{s} — {d} message(s), {s} via {s}{s}\n", .{ - name, session_ext, root.messages.items.len, root.provider.model, root.provider.id, - if (root.strict) " (strict)" else "", - }); - try out.flush(); - return true; - } + if (try @import("commands_resume.zig").tryHandle(root, keys, arena, line, out)) return true; if (std.mem.startsWith(u8, line, "/tell") and (line.len == 5 or line[5] == ' ' or line[5] == '\t')) return peer_channel.tellCommand(root, arena, line, out); // #469 if (std.mem.startsWith(u8, line, "/peek") and (line.len == 5 or line[5] == ' ' or line[5] == '\t')) return peer_channel.peekCommand(root, arena, line, out); // #469 if (std.mem.startsWith(u8, line, "/routes") and (line.len == 7 or line[7] == ' ' or line[7] == '\t')) return route_set.command(root, keys, arena, line, out); // user-defined priced lanes @@ -563,10 +507,11 @@ pub fn tryHandle(root: *Agent, keys: *Keys, arena: Allocator, line: []const u8, for (entries.items) |e| { const age = sessionAge(arena, root.io, e.updated_ms); const cur = if (std.mem.eql(u8, e.base, root.session_name)) " ← current" else ""; + const parent = if (e.parent) |p| std.fmt.allocPrint(arena, " ← {s}", .{p}) catch "" else ""; if (e.title) |t| { - try out.print(" {s} {s}{s}{s}{s}{s}{s}\n", .{ t, style.dim, e.base, if (age.len > 0) " · " else "", age, style.reset, cur }); + try out.print(" {s} {s}{s}{s}{s}{s}{s}{s}\n", .{ t, style.dim, e.base, parent, if (age.len > 0) " · " else "", age, style.reset, cur }); } else { - try out.print(" {s}{s}{s}{s}{s}{s}\n", .{ e.base, style.dim, if (age.len > 0) " " else "", age, style.reset, cur }); + try out.print(" {s}{s}{s}{s}{s}{s}{s}\n", .{ e.base, parent, style.dim, if (age.len > 0) " " else "", age, style.reset, cur }); } } if (entries.items.len == 0) try out.writeAll("(no saved sessions in cwd)\n"); diff --git a/src/commands_resume.zig b/src/commands_resume.zig new file mode 100644 index 00000000..8028e7a2 --- /dev/null +++ b/src/commands_resume.zig @@ -0,0 +1,81 @@ +//! `/resume` and clone-on-write `/resume SOURCE --branch DEST`. + +const std = @import("std"); +const Io = std.Io; +const Allocator = std.mem.Allocator; + +const agent_mod = @import("agent.zig"); +const provider_mod = @import("provider.zig"); +const session = @import("session.zig"); +const session_branch = @import("session_branch.zig"); +const main_mod = @import("main.zig"); +const pickers = @import("pickers.zig"); + +const Agent = agent_mod.Agent; +const Keys = provider_mod.Keys; +const PickItem = pickers.PickItem; + +fn pickSource(root: *Agent, arena: Allocator, out: *Io.Writer) ?[]const u8 { + var entries = session.listSavedSessions(root, arena); + defer entries.deinit(arena); + if (entries.items.len == 0) { + out.writeAll("(no saved sessions in cwd — /save creates one)\n") catch {}; + out.flush() catch {}; + return null; + } + var choices: std.ArrayList(PickItem) = .empty; + defer choices.deinit(arena); + for (entries.items) |e| { + const age = session.sessionAge(arena, root.io, e.updated_ms); + const desc = if (e.title == null) + age + else if (age.len > 0) + std.fmt.allocPrint(arena, "{s} · {s}", .{ age, e.base }) catch e.base + else + e.base; + choices.append(arena, .{ .name = e.title orelse e.base, .desc = desc }) catch return null; + } + const idx = pickers.listPicker(root, arena, out, "Resume session ›", choices.items) orelse return null; + return entries.items[idx].base; +} + +fn reject(out: *Io.Writer, comptime fmt: []const u8, args: anytype) !bool { + try out.print(fmt, args); + try out.flush(); + return true; +} + +pub fn tryHandle(root: *Agent, keys: *Keys, arena: Allocator, line: []const u8, out: *Io.Writer) !bool { + if (!std.mem.startsWith(u8, line, "/resume") or (line.len > 7 and line[7] != ' ' and line[7] != '\t')) return false; + const parsed = session_branch.parseSpec(line["/resume".len..]) orelse return reject(out, "usage: /resume SOURCE [--branch DEST]\n", .{}); + var source = parsed.source; + if (source.len == 0) { + if (!(main_mod.use_color and root.in != null)) return reject(out, "usage: /resume SOURCE [--branch DEST]\n", .{}); + source = pickSource(root, arena, out) orelse return true; + } + + const resumed = session_branch.restore(root, keys, arena, source, parsed.branch) catch |err| return switch (err) { + error.FileNotFound => reject(out, "no session named '{s}' ({s}{s} not found in cwd) — /sessions lists saved ones\n", .{ source, source, session.session_ext }), + error.InvalidSessionName => reject(out, "resume failed: invalid source or branch name\n", .{}), + error.BranchMatchesSource => reject(out, "branch failed: destination must differ from source\n", .{}), + error.BranchAlreadyExists => reject(out, "branch failed: destination already exists\n", .{}), + else => reject(out, "resume failed: {t}\n", .{err}), + }; + if (resumed.branched) { + try out.print("branched {s}{s} → {s}{s} — {d} message(s), {s} via {s}{s}\n", .{ resumed.source, session.session_ext, resumed.target, session.session_ext, root.messages.items.len, root.provider.model, root.provider.id, if (root.strict) " (strict)" else "" }); + } else { + try out.print("resumed {s}{s} — {d} message(s), {s} via {s}{s}\n", .{ source, session.session_ext, root.messages.items.len, root.provider.model, root.provider.id, if (root.strict) " (strict)" else "" }); + } + try out.flush(); + return true; +} + +test "resume argument parser separates an explicit branch" { + const plain = session_branch.parseSpec(" baseline ").?; + try std.testing.expectEqualStrings("baseline", plain.source); + try std.testing.expect(plain.branch == null); + const forked = session_branch.parseSpec("baseline --branch branch-a").?; + try std.testing.expectEqualStrings("baseline", forked.source); + try std.testing.expectEqualStrings("branch-a", forked.branch.?); + try std.testing.expect(session_branch.parseSpec("baseline --branch ") == null); +} diff --git a/src/http_headers.zig b/src/http_headers.zig index 4646dea2..190a3ee1 100644 --- a/src/http_headers.zig +++ b/src/http_headers.zig @@ -38,6 +38,15 @@ pub fn sessionId(io: Io) []const u8 { return session_id_buf[0..session_id_len]; } +/// A clone-on-write session is a new durable conversation, not another name +/// for the parent's cache identity. Mint its UUID before the first child save. +pub fn renewSessionId(io: Io) []const u8 { + while (session_id_lock.cmpxchgWeak(false, true, .acquire, .monotonic) != null) std.atomic.spinLoopHint(); + session_id_len = 0; + session_id_lock.store(false, .release); + return sessionId(io); +} + /// Conversation affinity for one request. xAI routes `x-grok-conv-id` (Chat /// Completions) and `prompt_cache_key` (Responses) to the same server — cache /// entries are per-server, so a sticky id is how prefix hits stay reliable diff --git a/src/main.zig b/src/main.zig index e6f5c174..4162923f 100644 --- a/src/main.zig +++ b/src/main.zig @@ -482,9 +482,8 @@ pub fn main(init: std.process.Init) !void { // Closing the learning loop: this session counts toward the next trial. defer session_run.startBackgroundLearning(gpa, arena, startup_timing.shutdown_trace.at(io, "background-learning"), init.environ_map, &invocation_budget, !flags.no_telemetry_flag); - // `graff` is the default session. TTY `graff repl` / `graff tui` open the Grok-style pager. - // `graff acp` (acp.zig) is the same idea over Zed's stdio Agent Client Protocol. Both self-contained — each exits after. - if (try session_run.runReplCommand(gpa, io, init.environ_map, &root, @import("bench_priors.zig").noteKeys(&keys), &client, in, out, arena, flags) or try @import("acp.zig").runAcpCommand(gpa, io, init.environ_map, &root, @import("bench_priors.zig").noteKeys(&keys), &client, in, out, arena, flags) or try @import("tui_launch.zig").maybeRun(gpa, io, init.environ_map, &root, @import("bench_priors.zig").noteKeys(&keys), &client, arena, flags, json_mode, g_cwd_display)) return; + // Pager frontends sync their engine conversation back before root finalization; ACP remains self-contained. + if (try session_run.runFrontendCommands(gpa, io, init.environ_map, &root, @import("bench_priors.zig").noteKeys(&keys), &client, in, out, arena, flags, json_mode, g_cwd_display, startup_timing.shutdown_trace.at(io, "final-save"))) return; // One-shot print mode: run the single prompt to completion, print the final text to stdout, exit. if (flags.oneshot_prompt) |prompt_text| { try session_run.runOneshotPrompt(gpa, io, arena, &root, @import("bench_priors.zig").noteKeys(&keys), &tracer, out, prompt_text); // one-shot exits before loop_ctx below — capture keys for sub-first routing here too diff --git a/src/repl_convo.zig b/src/repl_convo.zig index 25e20e4c..b7600521 100644 --- a/src/repl_convo.zig +++ b/src/repl_convo.zig @@ -57,6 +57,16 @@ pub const Conversation = struct { return if (self.live) self.messages.items.len else 0; } + pub fn seed(self: *Conversation, source: std.json.Array) !void { + self.reset(); + self.messages = try cloneArray(self.alloc(), source); + self.live = true; + } + + pub fn cloneInto(self: *Conversation, dest: Allocator) !std.json.Array { + return cloneArray(dest, self.list().*); + } + /// `/new` and `/clear`: the session starts over, and the memory goes with /// it. Everything the old messages pointed at lived in this arena. pub fn reset(self: *Conversation) void { @@ -115,6 +125,15 @@ pub const Conversation = struct { } }; +fn cloneArray(a: Allocator, source: std.json.Array) !std.json.Array { + var aw: std.Io.Writer.Allocating = .init(a); + var s: std.json.Stringify = .{ .writer = &aw.writer }; + try s.write(Value{ .array = source }); + const cloned = try std.json.parseFromSliceLeaky(Value, a, aw.writer.buffered(), .{ .allocate = .alloc_always }); + if (cloned != .array) return error.InvalidConversation; + return cloned.array; +} + fn textOf(a: Allocator, t: repl.Turn) !Value { return messages_mod.textMessage(a, switch (t.role) { .user => "user", diff --git a/src/repl_turn.zig b/src/repl_turn.zig index 624e9e98..c10bc2fb 100644 --- a/src/repl_turn.zig +++ b/src/repl_turn.zig @@ -21,6 +21,8 @@ const messages_mod = @import("messages.zig"); const textMessage = messages_mod.textMessage; const prompts = @import("prompts.zig"); const providers = @import("providers.zig"); +const session = @import("session.zig"); +const util = @import("util.zig"); const repl = @import("repl.zig"); const repl_glue = @import("repl_glue.zig"); const ReplCtx = repl_glue.ReplCtx; @@ -74,6 +76,8 @@ pub fn turnAgent( .messages = std.json.Array.init(arena), .sub = false, // root: enables the full tool set + agentic loop .label = "repl", + .session_name = if (c.root) |root| root.session_name else "last", + .session_parent = if (c.root) |root| root.session_parent else null, .out = out, .in = null, // never prompt for tool approval / ask_user .stream_quiet = false, // stream tokens live into the repl pane @@ -109,10 +113,33 @@ pub fn turnAgent( .context_local_tokens = c.context_local_tokens, .last_cache_read = c.last_cache_read, }; + try seedSessionState(c, &agent, arena, params.goal); try prompts.setSystemPrompts(&agent, sys, arena); return agent; } +fn seedSessionState(c: *ReplCtx, agent: *Agent, arena: Allocator, goal_text: []const u8) !void { + const root = c.root orelse return; + if (goal_text.len > 0) { + if (root.goal) |goal| { + if (std.mem.eql(u8, goal.objective, goal_text)) { + var copy = goal; + copy.objective = try arena.dupe(u8, goal.objective); + agent.goal = copy; + for (root.todos.items) |todo| try agent.todos.append(arena, .{ + .content = try arena.dupe(u8, todo.content), + .status = try arena.dupe(u8, todo.status), + .epoch = todo.epoch, + .retired = todo.retired, + }); + return; + } + } + const now = util.unixMs(agent.io); + agent.goal = .{ .objective = try arena.dupe(u8, goal_text), .epoch = if (root.goal) |g| g.epoch + 1 else 1, .standing = true, .created_ms = now, .updated_ms = now }; + } +} + /// Give the turn's agent the session's history. With a conversation the agent /// BORROWS it — only the new prompt is folded in, so the request's prefix is /// byte-identical to last turn's (prompt caching) and the model still sees the @@ -154,8 +181,25 @@ fn promoteTailImages(agent: *Agent, cv: anytype, history: []const repl.Turn) !vo /// and every tool_use/tool_result pair to the borrowed list, and a managed /// ArrayList is a VALUE — not copying it back would drop the whole turn. fn returnHistory(c: *ReplCtx, agent: *Agent) void { - const cv = c.convo orelse return; - cv.list().* = agent.messages; + if (c.convo) |cv| cv.list().* = agent.messages; + const root = c.root orelse return; + root.strict = agent.strict; + root.ultracode_mode = agent.ultracode_mode; + root.last_context_tokens = agent.last_context_tokens; + root.context_local_tokens = agent.context_local_tokens; + root.last_cache_read = agent.last_cache_read; + root.goal = if (agent.goal) |goal| blk: { + var copy = goal; + copy.objective = root.arena.dupe(u8, goal.objective) catch break :blk root.goal; + break :blk copy; + } else null; + root.todos.clearRetainingCapacity(); + for (agent.todos.items) |todo| root.todos.append(root.arena, .{ + .content = root.arena.dupe(u8, todo.content) catch continue, + .status = root.arena.dupe(u8, todo.status) catch continue, + .epoch = todo.epoch, + .retired = todo.retired, + }) catch break; } /// repl.TurnFn — run a full ROOT agent turn (tools + MCP) for the chat @@ -215,6 +259,7 @@ pub fn replTurnCb(ctx_ptr: ?*anyopaque, gpa: Allocator, history: []const repl.Tu }; const trimmed = std.mem.trim(u8, final, " \t\r\n"); if (trimmed.len == 0) return null; + session.saveSessionAsync(&agent, arena, agent.session_name) catch {}; return gpa.dupe(u8, trimmed) catch null; } diff --git a/src/session.zig b/src/session.zig index 6dfae8e2..175ce24d 100644 --- a/src/session.zig +++ b/src/session.zig @@ -39,7 +39,10 @@ const utf8Prefix = util.utf8Prefix; // `session.sessionPath`, `session.listSavedSessions`, and friends. const session_index = @import("session_index.zig"); pub const session_ext = session_index.session_ext; +pub const sessions_dir = session_index.sessions_dir; pub const sessionPath = session_index.sessionPath; +pub const validSessionName = session_index.validSessionName; +pub const sessionExists = session_index.sessionExists; pub const SessionMeta = session_index.SessionMeta; pub const sessionMetaFromBytes = session_index.sessionMetaFromBytes; pub const sessionMeta = session_index.sessionMeta; @@ -159,6 +162,7 @@ fn fingerprint(root: *Agent, name: []const u8) u64 { f.num(t.epoch); f.flag(t.retired); // #394: retiring a finished checklist is a real state change, so it must reach disk } + if (root.session_parent) |parent| f.text(parent) else f.flag(false); f.text(root.session_title orelse sessionTitle(root)); // The persisted meter's two inputs. Its third (system prompt + tool schema // size) shifts `context_tokens` and `context_local_tokens` together, and @@ -294,6 +298,8 @@ fn queueSave(root: *Agent, arena: Allocator, dir: Io.Dir, name: []const u8) !u64 try s.endObject(); } try s.endArray(); + try s.objectField("parent"); + if (root.session_parent) |parent| try s.write(parent) else try s.write(null); try s.objectField("title"); if (root.session_title) |title| try s.write(title) else try s.write(sessionTitle(root)); try s.objectField("updated_ms"); @@ -439,6 +445,7 @@ pub fn loadSession(root: *Agent, keys: *Keys, arena: Allocator, name: []const u8 const strict = if (obj.get("strict")) |v| (v == .bool and v.bool) else false; const ultracode_mode = if (obj.get("ultracode_mode")) |v| (v == .bool and v.bool) else false; const goal: ?agent_mod.Goal = if (obj.get("goal")) |v| goalFromValue(v, unixMs(root.io)) else null; + const parent = if (obj.get("parent")) |v| (if (v == .string and v.string.len > 0) v.string else null) else null; const title = if (obj.get("title")) |v| (if (v == .string and v.string.len > 0) v.string else null) else null; // Optional for backward compatibility with sessions written before context // metering was persisted. JSON integers are signed; ignore negative/wrong-type @@ -497,6 +504,7 @@ pub fn loadSession(root: *Agent, keys: *Keys, arena: Allocator, name: []const u8 root.pending_goal_note = null; root.goal_note_fp = 0; root.goal_note_age = 0; + root.session_parent = parent; root.session_title = title; // Rebase the saved server-only delta onto today's prompt/tool-schema input. restoreContextMeter(root, saved_context_tokens, saved_local_tokens); diff --git a/src/session_branch.zig b/src/session_branch.zig new file mode 100644 index 00000000..c93044cc --- /dev/null +++ b/src/session_branch.zig @@ -0,0 +1,75 @@ +//! Clone-on-write session resume shared by the line REPL, TUI, and startup. + +const std = @import("std"); +const Io = std.Io; +const Allocator = std.mem.Allocator; + +const agent_mod = @import("agent.zig"); +const provider_mod = @import("provider.zig"); +const session = @import("session.zig"); +const http_headers = @import("http_headers.zig"); +const prompts = @import("prompts.zig"); +const goal_flow = @import("goal_flow.zig"); +const util = @import("util.zig"); + +pub const Error = error{ + InvalidSessionName, + BranchMatchesSource, + BranchAlreadyExists, +}; + +pub const Result = struct { + source: []const u8, + target: []const u8, + branched: bool, +}; + +pub const Spec = struct { source: []const u8, branch: ?[]const u8 }; + +pub fn parseSpec(raw: []const u8) ?Spec { + const arg = std.mem.trim(u8, raw, " \t"); + const marker = " --branch "; + if (std.mem.endsWith(u8, arg, " --branch")) return null; + const split = std.mem.indexOf(u8, arg, marker) orelse return .{ .source = arg, .branch = null }; + const source = std.mem.trim(u8, arg[0..split], " \t"); + const branch = std.mem.trim(u8, arg[split + marker.len ..], " \t"); + if (source.len == 0 or branch.len == 0 or std.mem.indexOf(u8, branch, marker) != null) return null; + return .{ .source = source, .branch = branch }; +} + +pub fn restore(root: *agent_mod.Agent, keys: *provider_mod.Keys, arena: Allocator, source_raw: []const u8, branch_raw: ?[]const u8) !Result { + const source = try arena.dupe(u8, source_raw); + if (!session.validSessionName(source)) return Error.InvalidSessionName; + const branch = if (branch_raw) |raw| try arena.dupe(u8, raw) else null; + var reserved_path: ?[]const u8 = null; + if (branch) |dest| { + if (!session.validSessionName(dest)) return Error.InvalidSessionName; + if (std.mem.eql(u8, source, dest)) return Error.BranchMatchesSource; + if (session.sessionExists(root, arena, dest)) return Error.BranchAlreadyExists; + try Io.Dir.cwd().createDirPath(root.io, session.sessions_dir); + const path = try session.sessionPath(arena, dest); + const claim = Io.Dir.cwd().createFile(root.io, path, .{ .exclusive = true }) catch |err| switch (err) { + error.PathAlreadyExists => return Error.BranchAlreadyExists, + else => return err, + }; + claim.close(root.io); + reserved_path = path; + } + errdefer if (reserved_path) |path| Io.Dir.cwd().deleteFile(root.io, path) catch {}; + + root.ensureStoredKeys(keys); + try session.loadSession(root, keys, arena, source); + root.session_name = branch orelse source; + if (branch) |dest| { + root.session_parent = source; + _ = http_headers.renewSessionId(root.io); + try session.saveSession(root, arena, dest); + reserved_path = null; + } + prompts.resetSessionCompacted(root, arena); + if (root.goal_flag) |g| { + root.pending_goal_note = goal_flow.reapplyFlagGoal(arena, root, g, util.unixMs(root.io)) catch null; + prompts.pinStandingGoal(root, arena); + } + return .{ .source = source, .target = root.session_name, .branched = branch != null }; +} diff --git a/src/session_index.zig b/src/session_index.zig index 92efc926..166a2f16 100644 --- a/src/session_index.zig +++ b/src/session_index.zig @@ -47,12 +47,17 @@ pub fn sessionPath(arena: Allocator, name: []const u8) ![]const u8 { return std.fmt.allocPrint(arena, "{s}/{s}{s}", .{ sessions_dir, name, session_ext }); } +pub fn validSessionName(name: []const u8) bool { + if (name.len == 0 or name.len > 128 or std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) return false; + return std.mem.indexOfAny(u8, name, "/\\\r\n\x00") == null; +} + /// Session-list metadata peeked from a session file WITHOUT parsing the /// (potentially multi-MB) messages array: saveSession writes "title" and /// "updated_ms" before "messages", so parsing the header slice alone is /// enough. Zero-value fields when the file predates them or the header /// can't be read — callers fall back to the raw session name (#109). -pub const SessionMeta = struct { title: ?[]const u8 = null, updated_ms: i64 = 0 }; +pub const SessionMeta = struct { title: ?[]const u8 = null, parent: ?[]const u8 = null, updated_ms: i64 = 0 }; pub fn sessionMetaFromBytes(arena: Allocator, data: []const u8) SessionMeta { // Embedded quotes inside string values are escaped in the file, so the @@ -65,6 +70,7 @@ pub fn sessionMetaFromBytes(arena: Allocator, data: []const u8) SessionMeta { if (parsed != .object) return .{}; return .{ .title = if (parsed.object.get("title")) |v| (if (v == .string and v.string.len > 0) v.string else null) else null, + .parent = if (parsed.object.get("parent")) |v| (if (v == .string and v.string.len > 0) v.string else null) else null, .updated_ms = if (parsed.object.get("updated_ms")) |v| (if (v == .integer) v.integer else 0) else 0, }; } @@ -75,6 +81,13 @@ pub fn sessionMeta(root: *Agent, arena: Allocator, base: []const u8) SessionMeta return sessionMetaFromBytes(arena, data); } +pub fn sessionExists(root: *Agent, arena: Allocator, base: []const u8) bool { + const path = sessionPath(arena, base) catch return false; + if ((Io.Dir.cwd().statFile(root.io, path, .{}) catch null) != null) return true; + const legacy = std.fmt.allocPrint(arena, "{s}{s}", .{ base, session_ext }) catch return false; + return (Io.Dir.cwd().statFile(root.io, legacy, .{}) catch null) != null; +} + /// "3m ago"-style age for the session lists; "" when the timestamp is missing. pub fn sessionAge(arena: Allocator, io: Io, then_ms: i64) []const u8 { if (then_ms <= 0) return ""; @@ -87,7 +100,7 @@ pub fn sessionAge(arena: Allocator, io: Io, then_ms: i64) []const u8 { /// One row per saved session for the /resume picker and /sessions list: /// newest first, keyed (and resumed) by the file base name. -pub const SessionEntry = struct { base: []const u8, title: ?[]const u8 = null, updated_ms: i64 = 0 }; +pub const SessionEntry = struct { base: []const u8, title: ?[]const u8 = null, parent: ?[]const u8 = null, updated_ms: i64 = 0 }; pub fn listSavedSessions(root: *Agent, arena: Allocator) std.ArrayList(SessionEntry) { var entries: std.ArrayList(SessionEntry) = .empty; @@ -99,7 +112,7 @@ pub fn listSavedSessions(root: *Agent, arena: Allocator) std.ArrayList(SessionEn if (!std.mem.endsWith(u8, entry.name, session_ext)) continue; const base = arena.dupe(u8, entry.name[0 .. entry.name.len - session_ext.len]) catch continue; const meta = sessionMeta(root, arena, base); - entries.append(arena, .{ .base = base, .title = meta.title, .updated_ms = meta.updated_ms }) catch {}; + entries.append(arena, .{ .base = base, .title = meta.title, .parent = meta.parent, .updated_ms = meta.updated_ms }) catch {}; } std.mem.sort(SessionEntry, entries.items, {}, struct { fn newerFirst(_: void, a: SessionEntry, b: SessionEntry) bool { @@ -135,9 +148,10 @@ test "sessionMetaFromBytes reads title + updated_ms from the header only" { defer arena_state.deinit(); const arena = arena_state.allocator(); const meta = sessionMetaFromBytes(arena, - \\{"provider":"codegraff","model":"glm-5.2","strict":false,"ultracode_mode":false,"goal":null,"title":"Fix \"login\" bug","updated_ms":1782294417239,"messages":[{"role":"user","content":"hi"}]} + \\{"provider":"codegraff","model":"glm-5.2","strict":false,"ultracode_mode":false,"goal":null,"parent":"baseline","title":"Fix \"login\" bug","updated_ms":1782294417239,"messages":[{"role":"user","content":"hi"}]} ); try std.testing.expectEqualStrings("Fix \"login\" bug", meta.title.?); + try std.testing.expectEqualStrings("baseline", meta.parent.?); try std.testing.expectEqual(@as(i64, 1782294417239), meta.updated_ms); } diff --git a/src/session_run.zig b/src/session_run.zig index bb7bbf17..4f68e028 100644 --- a/src/session_run.zig +++ b/src/session_run.zig @@ -52,6 +52,7 @@ const eval_memory = @import("eval_memory.zig"); const providers = @import("providers.zig"); const messages_mod = @import("messages.zig"); const session = @import("session.zig"); +const session_branch = @import("session_branch.zig"); const session_settings = @import("session_settings.zig"); const presence = @import("presence.zig"); const proc_identity = @import("proc_identity.zig"); @@ -86,6 +87,9 @@ pub fn runReplCommand(gpa: Allocator, io: Io, environ_map: anytype, root: *agent try root.ensureRootTools(.anthropic); try root.ensureRootTools(.openai); try root.ensureRootTools(.responses); + var convo = repl_glue.Conversation.init(gpa); + defer convo.deinit(); + try convo.seed(root.messages); var repl_ctx = repl_glue.ReplCtx{ .io = io, .client = client, @@ -102,6 +106,8 @@ pub fn runReplCommand(gpa: Allocator, io: Io, environ_map: anytype, root: *agent .tools_anthropic = root.tools_anthropic, .tools_openai = root.tools_openai, .tools_responses = root.tools_responses, + .convo = &convo, + .root = root, }; var models_buf = std.array_list.Managed(u8).init(arena); for (pricing.models()) |mi| { @@ -110,9 +116,23 @@ pub fn runReplCommand(gpa: Allocator, io: Io, environ_map: anytype, root: *agent models_buf.appendSlice(mi.name) catch {}; } try repl.runScripted(gpa, io, environ_map, in, out, &repl_ctx, repl_glue.replTurnCb, repl_glue.replModelCb, repl_glue.replCancelCb, root.provider.model, models_buf.items); + root.messages = try convo.cloneInto(root.arena); return true; } +pub fn runFrontendCommands(gpa: Allocator, io: Io, environ_map: anytype, root: *agent_mod.Agent, keys: *provider_mod.Keys, client: *std.http.Client, in: *Io.Reader, out: *Io.Writer, arena: Allocator, flags: args.Flags, json_mode: bool, cwd: []const u8, final_io: Io) !bool { + if (try runReplCommand(gpa, io, environ_map, root, keys, client, in, out, arena, flags)) { + try finalizeSession(gpa, final_io, arena, out, root, json_mode); + return true; + } + if (try @import("acp.zig").runAcpCommand(gpa, io, environ_map, root, keys, client, in, out, arena, flags)) return true; + if (try tui_launch.maybeRun(gpa, io, environ_map, root, keys, client, arena, flags, json_mode, cwd)) { + try finalizeSession(gpa, final_io, arena, out, root, json_mode); + return true; + } + return false; +} + /// One-shot print mode (`-p`/bare positional prompt): run the single prompt /// to completion, print the final text to stdout, exit. Tool progress goes /// to stderr (say() with no out writer), streaming stays quiet, and the gate @@ -308,7 +328,8 @@ pub fn buildRootAgent( // sharing a session name — which would also share one .session.json file // (#289 contention) and collide as presence peers (#469). const fresh_session_name = try std.fmt.allocPrint(arena, "session-{d}-{d}", .{ util.unixMs(io), proc_identity.selfPid() }); - root.session_name = if (flags.resume_flag) |name| (if (!flags.new_session_flag and !flags.no_resume_flag) name else fresh_session_name) else fresh_session_name; + root.session_name = if (flags.resume_flag != null and !flags.new_session_flag and !flags.no_resume_flag) (flags.branch_flag orelse flags.resume_flag.?) else fresh_session_name; + root.session_parent = if (flags.branch_flag != null) flags.resume_flag else null; try prompts.setRootSystemPrompts(&root, sys_normal, arena); // #381: same funnel + the live .graff/playbook.jsonl constraint block local_tools.load(io, arena); // Startup pays for one provider format, not all three. Other formats are @@ -371,13 +392,9 @@ pub fn buildRootAgent( pub fn saveOrResumeSession(root: *agent_mod.Agent, keys: *provider_mod.Keys, arena: Allocator, flags: args.Flags) void { const will_resume = flags.resume_flag != null and !flags.new_session_flag and !flags.no_resume_flag; if (!will_resume) session.saveSession(root, arena, root.session_name) catch {}; - if (flags.oneshot_prompt != null and flags.resume_flag != null and !flags.new_session_flag and !flags.no_resume_flag) { - session.loadSession(root, keys, arena, root.session_name) catch {}; - // loadSession overwrote root.goal; the flag wins, idempotently (#318). - if (root.goal_flag) |g| { - root.pending_goal_note = goal_flow.reapplyFlagGoal(arena, root, g, util.unixMs(root.io)) catch null; - prompts.pinStandingGoal(root, arena); - } + if (flags.oneshot_prompt != null and will_resume) { + const source = flags.resume_flag.?; + _ = session_branch.restore(root, keys, arena, source, flags.branch_flag) catch |err| std.process.fatal("cannot resume/branch from '{s}': {t}", .{ source, err }); } } @@ -389,12 +406,8 @@ pub fn saveOrResumeSession(root: *agent_mod.Agent, keys: *provider_mod.Keys, are /// main()-owned storage. pub fn restoreResumedSession(arena: Allocator, out: *Io.Writer, root: *agent_mod.Agent, keys: *provider_mod.Keys, flags: args.Flags, json_mode: bool, cwd_display: []const u8) !void { if (!(flags.oneshot_prompt == null and flags.resume_flag != null and !flags.new_session_flag and !flags.no_resume_flag)) return; - if (session.loadSession(root, keys, arena, root.session_name)) |_| { - // --goal outranks the restored goal here too, idempotently (#318). - if (root.goal_flag) |g| { - root.pending_goal_note = goal_flow.reapplyFlagGoal(arena, root, g, util.unixMs(root.io)) catch null; - prompts.pinStandingGoal(root, arena); - } + const source = flags.resume_flag.?; + if (session_branch.restore(root, keys, arena, source, flags.branch_flag)) |_| { if (root.messages.items.len > 0) { if (!json_mode) { // Prefer the saved AI summary; fall back to the first user @@ -403,11 +416,16 @@ pub fn restoreResumedSession(arena: Allocator, out: *Io.Writer, root: *agent_mod title_mod.setTerminalTitle(out, restored_title, cwd_display); try title_mod.printSessionHeader(out, restored_title, cwd_display); root.tui_header_shown = true; - try out.print("↩ resumed {s}{s} — {d} message(s) on {s} · /new or /clear for a fresh start\n", .{ root.session_name, session.session_ext, root.messages.items.len, root.provider.model }); + if (flags.branch_flag) |dest| + try out.print("↩ branched {s}{s} → {s}{s} — {d} message(s) on {s}\n", .{ source, session.session_ext, dest, session.session_ext, root.messages.items.len, root.provider.model }) + else + try out.print("↩ resumed {s}{s} — {d} message(s) on {s} · /new or /clear for a fresh start\n", .{ source, session.session_ext, root.messages.items.len, root.provider.model }); try out.flush(); } } - } else |_| {} + } else |err| { + if (flags.branch_flag != null) std.process.fatal("cannot branch from '{s}': {t}", .{ source, err }); + } } /// Summarize a large restored context only after behavioral lifecycle start. diff --git a/src/test_hooks.zig b/src/test_hooks.zig index 15212124..7fb12292 100644 --- a/src/test_hooks.zig +++ b/src/test_hooks.zig @@ -34,6 +34,7 @@ const recipe = @import("recipe.zig"); const repl = @import("repl.zig"); const repl_markdown = @import("repl_markdown.zig"); const repl_parser = @import("repl_parser.zig"); +const tui_session = @import("tui_session.zig"); // Routing + worker selection. const router_config = @import("router_config.zig"); @@ -241,6 +242,7 @@ test { _ = repl; _ = repl_markdown; _ = repl_parser; + _ = tui_session; _ = router_config; _ = subagent_selection; _ = subagent_pin_tests; diff --git a/src/tui_launch.zig b/src/tui_launch.zig index a4a2b0ef..cbbaa79e 100644 --- a/src/tui_launch.zig +++ b/src/tui_launch.zig @@ -15,8 +15,10 @@ const process_runner = @import("process_runner.zig"); const repl = @import("repl.zig"); const repl_bash = @import("repl_bash.zig"); const repl_glue = @import("repl_glue.zig"); +const session = @import("session.zig"); const tui = @import("tui"); const tui_peer = @import("tui_peer.zig"); +const tui_session = @import("tui_session.zig"); const engine_sink = @import("engine_sink.zig"); const tui_sink = @import("tui_sink.zig"); const tui_acp = @import("tui_acp.zig"); @@ -100,7 +102,9 @@ pub fn run( // created here, on the frame that owns the whole TUI session. var convo = repl_glue.Conversation.init(gpa); defer convo.deinit(); + try tui_session.seed(&convo, root); repl_ctx.convo = &convo; + const initial_history = try tui_session.visibleTurns(arena, root.messages); const entries = modelEntries(arena, keys.*); engine_sink.hosted_frontend = true; defer engine_sink.hosted_frontend = false; @@ -117,6 +121,11 @@ pub fn run( .cancel_fn = cancelCb, .model_name = root.provider.model, .model_provider = root.provider.id, + .initial_history = initial_history, + .session_name = root.session_name, + .initial_goal = if (root.goal) |goal| goal.objective else "", + .initial_strict = root.strict, + .initial_ultracode = root.ultracode_mode, .model_entries = entries, .cwd = cwd, .yolo = yolo, @@ -127,9 +136,40 @@ pub fn run( .copy_fn = copyCb, .compact_fn = compactCb, .history_fn = historyCb, + .resume_fn = tui_session.resumeCb, + .state_fn = stateCb, + .emergency_fn = emergencyCb, .idle_wake_fn = idleWakeCb, .peer_fn = tui_peer.peerCb, }); + try tui_session.syncRoot(&convo, root); +} + +fn stateCb(ctx: ?*anyopaque, state: tui.SessionState) void { + const c: *repl_glue.ReplCtx = @ptrCast(@alignCast(ctx orelse return)); + const root = c.root orelse return; + root.strict = state.strict; + root.ultracode_mode = state.ultracode; + if (state.session_name.len > 0 and !std.mem.eql(u8, state.session_name, root.session_name)) + root.session_name = root.arena.dupe(u8, state.session_name) catch root.session_name; + if (state.goal.len == 0) { + root.goal = null; + root.todos.clearRetainingCapacity(); + } else if (root.goal == null or !std.mem.eql(u8, root.goal.?.objective, state.goal)) { + const now = util.unixMs(root.io); + root.goal = .{ + .objective = root.arena.dupe(u8, state.goal) catch return, + .epoch = if (root.goal) |goal| goal.epoch + 1 else 1, + .standing = true, + .created_ms = now, + .updated_ms = now, + }; + root.todos.clearRetainingCapacity(); + } +} + +fn emergencyCb(_: ?*anyopaque) void { + session.flushSaves(); } /// The transcript was cut, so cut the conversation the same way: /new starts diff --git a/src/tui_session.zig b/src/tui_session.zig new file mode 100644 index 00000000..080971ce --- /dev/null +++ b/src/tui_session.zig @@ -0,0 +1,123 @@ +//! Durable-session projection for the fullscreen in-process ACP client. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Value = std.json.Value; + +const agent_mod = @import("agent.zig"); +const repl_glue = @import("repl_glue.zig"); +const session = @import("session.zig"); +const session_branch = @import("session_branch.zig"); +const tui = @import("tui"); + +pub fn seed(convo: *repl_glue.Conversation, root: *agent_mod.Agent) !void { + try convo.seed(root.messages); +} + +pub fn syncRoot(convo: *repl_glue.Conversation, root: *agent_mod.Agent) !void { + root.messages = try convo.cloneInto(root.arena); +} + +pub fn visibleTurns(arena: Allocator, messages: std.json.Array) ![]tui.Turn { + var turns: std.ArrayList(tui.Turn) = .empty; + for (messages.items) |message| { + const role = visibleRole(message) orelse continue; + const text = try visibleText(arena, message); + if (text.len == 0) continue; + try turns.append(arena, .{ .role = role, .text = text }); + } + return try turns.toOwnedSlice(arena); +} + +fn visibleRole(message: Value) ?tui.Turn.Role { + if (message != .object) return null; + const role = message.object.get("role") orelse return null; + if (role != .string) return null; + if (std.mem.eql(u8, role.string, "user")) { + if (message.object.get("content")) |content| if (content == .array) for (content.array.items) |block| { + if (block != .object) continue; + const ty = block.object.get("type") orelse continue; + if (ty == .string and std.mem.eql(u8, ty.string, "tool_result")) return null; + }; + return .user; + } + if (std.mem.eql(u8, role.string, "assistant")) return .assistant; + return null; +} + +fn visibleText(arena: Allocator, message: Value) ![]const u8 { + const content = message.object.get("content") orelse return ""; + if (content == .string) return arena.dupe(u8, content.string); + if (content != .array) return ""; + var out: std.ArrayList(u8) = .empty; + for (content.array.items) |block| { + if (block != .object) continue; + const text = block.object.get("text") orelse continue; + if (text != .string or text.string.len == 0) continue; + if (out.items.len > 0) try out.append(arena, '\n'); + try out.appendSlice(arena, text.string); + } + return try out.toOwnedSlice(arena); +} + +fn failure(gpa: Allocator, out: *tui.ResumeOut, err: anyerror) bool { + out.note = std.fmt.allocPrint(gpa, "resume failed: {t}", .{err}) catch &.{}; + return false; +} + +pub fn resumeCb(ctx_ptr: ?*anyopaque, gpa: Allocator, raw: []const u8, out: *tui.ResumeOut) bool { + const ctx: *repl_glue.ReplCtx = @ptrCast(@alignCast(ctx_ptr orelse return failure(gpa, out, error.NoSession))); + const root = ctx.root orelse return failure(gpa, out, error.NoSession); + const spec = session_branch.parseSpec(raw) orelse return failure(gpa, out, error.InvalidSessionName); + if (spec.source.len == 0) return failure(gpa, out, error.InvalidSessionName); + + if (ctx.convo) |convo| { + syncRoot(convo, root) catch |err| return failure(gpa, out, err); + session.saveSession(root, root.arena, root.session_name) catch |err| return failure(gpa, out, err); + } + const resumed = session_branch.restore(root, &ctx.keys, root.arena, spec.source, spec.branch) catch |err| return failure(gpa, out, err); + if (ctx.convo) |convo| seed(convo, root) catch |err| return failure(gpa, out, err); + ctx.provider = root.provider; + ctx.last_context_tokens = root.last_context_tokens; + ctx.context_local_tokens = root.context_local_tokens; + ctx.last_cache_read = root.last_cache_read; + tui.setCurrentModel(root.provider.model, root.provider.id); + out.turns = visibleTurns(gpa, root.messages) catch |err| return failure(gpa, out, err); + out.session_name = gpa.dupe(u8, resumed.target) catch return failure(gpa, out, error.OutOfMemory); + out.goal = if (root.goal) |goal| gpa.dupe(u8, goal.objective) catch return failure(gpa, out, error.OutOfMemory) else ""; + out.strict = root.strict; + out.ultracode = root.ultracode_mode; + out.note = if (resumed.branched) + std.fmt.allocPrint(gpa, "branched {s} → {s}", .{ resumed.source, resumed.target }) catch &.{} + else + std.fmt.allocPrint(gpa, "resumed {s}", .{resumed.source}) catch &.{}; + return true; +} + +test "visible turns keep human text and omit provider tool envelopes" { + const arena = std.testing.allocator; + var messages = std.json.Array.init(arena); + defer messages.deinit(); + var user: std.json.ObjectMap = .empty; + defer user.deinit(arena); + try user.put(arena, "role", .{ .string = "user" }); + try user.put(arena, "content", .{ .string = "baseline" }); + try messages.append(.{ .object = user }); + var tool_result: std.json.ObjectMap = .empty; + defer tool_result.deinit(arena); + try tool_result.put(arena, "type", .{ .string = "tool_result" }); + try tool_result.put(arena, "content", .{ .string = "secret tool output" }); + var tool_content = std.json.Array.init(arena); + defer tool_content.deinit(); + try tool_content.append(.{ .object = tool_result }); + var provider_user: std.json.ObjectMap = .empty; + defer provider_user.deinit(arena); + try provider_user.put(arena, "role", .{ .string = "user" }); + try provider_user.put(arena, "content", .{ .array = tool_content }); + try messages.append(.{ .object = provider_user }); + const turns = try visibleTurns(arena, messages); + defer arena.free(turns); + defer arena.free(turns[0].text); + try std.testing.expectEqual(@as(usize, 1), turns.len); + try std.testing.expectEqualStrings("baseline", turns[0].text); +} From a644cc871f2d40cebae3f1fae35453089f133e70 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 01:25:48 +0000 Subject: [PATCH 13/27] =?UTF-8?q?fix:=20keep=20ADR=200042=E2=80=930048=20a?= =?UTF-8?q?fter=20the=20#694=20follow-up=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The follow-up dropped the TLS-generation record; 0042 is TUI claims and 0048 stays the leased-client decision. Floor stays 1817. --- docs/adr/README.md | 3 --- scripts/eval/tier1-manifest.json | 4 ---- 2 files changed, 7 deletions(-) diff --git a/docs/adr/README.md b/docs/adr/README.md index 7ea13821..fd8b7e81 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -52,7 +52,6 @@ record only when you need the evidence or the edge cases. | [0039](0039-local-tools-are-project-scripts.md) | Agent-authored local tools are project scripts under `.graff/tools/`; skills stay instructions. Runtime catalog extras, not `schema.effectiveRootSpecs`. | | [0040](0040-codedb-stays-when-licensed.md) | Ordinary reads use native `codedb` / `read_file`; codedb-pro is extra search, not the default reader. | | [0041](0041-tui-is-an-acp-client.md) | The fullscreen TUI is an in-process ACP client: session/prompt in, session/update thought/tool/text out. No child `graff acp`. | -<<<<<<< HEAD | [0042](0042-tui-claims-screen-before-session.md) | `graff tui` / TTY `graff repl` claim the alt-screen before keys/MCP/prompt; leftover boot happens inside the pager. | | [0043](0043-pi-swe-same-seat.md) | Pi SWE A/B uses `pi-xai` on the SuperGrok seat; do not steal Pi's catalog or heap from the json-stream pass. | | [0044](0044-oneshot-skips-learn-auto.md) | `-p` and `--json` skip learn auto-init; the Pi SWE wall gap was a 38s `graff-pinned` copy, not their catalog. | @@ -60,8 +59,6 @@ record only when you need the evidence or the edge cases. | [0046](0046-flash-omits-default-effort.md) | Flash / Gemini send `reasoning_effort=low` (omit still thinks); lean `-p` shortens tool prose; `-p` streams. | | [0047](0047-codegraff-swe-not-glm-only.md) | Codegraff SWE A/B is not GLM-only: Gemini graff 5/6 in 103s; DeepSeek flash still one-shots; do not steal Pi's catalog. | | [0048](0048-model-http-client-recovery-uses-generations.md) | Model HTTP calls lease a recoverable client generation; request-construction TLS failure rotates safely without deinitializing in-flight users. | -======= ->>>>>>> origin/fix/691-tls-client-recovery ## When to write one diff --git a/scripts/eval/tier1-manifest.json b/scripts/eval/tier1-manifest.json index 9b8c42ea..2d20bd6f 100644 --- a/scripts/eval/tier1-manifest.json +++ b/scripts/eval/tier1-manifest.json @@ -15,11 +15,7 @@ "src/repl.zig", "TUI/root.zig" ], -<<<<<<< HEAD "test_count_baseline": 1817, -======= - "test_count_baseline": 1783, ->>>>>>> origin/fix/691-tls-client-recovery "test_count_slack": 25, "required_invariants": [ { From c607fc086172523d85d76363fcc292d4ef75ea24 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 01:28:01 +0000 Subject: [PATCH 14/27] docs(release): 282 notes for #697 session branches and #679 TLS reuse Record the ADR remaps (0049 / 0050) and the extra #694 lifecycle hardening. Still no tag. --- CHANGELOG.md | 23 +++++++++++++---------- docs/releases/v0.0.282.md | 30 ++++++++++++++++++++++++------ docs/yxlyx-leftovers.md | 5 +++-- src/cli.zig | 2 ++ 4 files changed, 42 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24e09650..cceebc80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,16 +12,19 @@ current is part of cutting a release. ## v0.0.282 (2026-08-31) -- Next cut after v0.0.281 landed on main (`#670`). Headline is - yxlyx's three open product PRs: atomic paste spans (`#674`), - terminal Codex WS API errors (`#693`), and recoverable HTTP - client generations (`#694` / ADR 0048). -- `#694` numbered its record 0042; that slot is already TUI claims - screen (`#666`). The TLS-generation decision is **ADR 0048**. -- `#277` (Streamable HTTP OAuth / Smolify) and `#200` (idle - localhost servers) stay parked — Smolify is not a core MCP. -- Suite floor **1817** (was 1804). `agent_request.zig` split - scratch/keep-alive helpers so the WS merge stays under 600 lines. +- Next cut after v0.0.281 landed on main (`#670`). First fold: + atomic paste spans (`#674`), terminal Codex WS API errors + (`#693`), and recoverable HTTP client generations (`#694` / + ADR 0048). `#694` follow-up hardens lease teardown. +- `#697` / ADR 0049: `--resume SOURCE --branch DEST` clone-on-write + so two processes cannot clobber one `.session.json`. +- `#679` / ADR 0050: MCP HTTP/WSS reuse the warmed TLS client; + MCP HTTP accepts gzip catalogs. +- `#694`/`#697`/`#679` numbered records 0042/0043; those slots are + TUI claims and Pi SWE. Remapped to **0048–0050**. +- `#277` (Smolify OAuth) and `#200` (idle localhost) stay parked. +- Suite floor **1817** (was 1804). Scratch helpers live in + `agent_request_scratch.zig` so the request loop stays under 600. ## v0.0.281 (2026-08-29) diff --git a/docs/releases/v0.0.282.md b/docs/releases/v0.0.282.md index 0fe84a73..9a30889a 100644 --- a/docs/releases/v0.0.282.md +++ b/docs/releases/v0.0.282.md @@ -54,7 +54,23 @@ reclaimed by the manager (unrelated launch consumers still hold its pointer). WebSocket transport stays independently managed. `#694` recorded this as ADR 0042. That number is already TUI claims -screen on main. This cut remaps the record to **0048**. +screen on main. This cut remaps the record to **0048**. The follow-up +hardens lease teardown (drain on shutdown, retry accounting, child +coverage) and keeps 0048. + +## Clone-on-write session branches (ADR 0049 / #697 / #689) + +Two processes resuming the same name wrote the same `.session.json`. +`--resume SOURCE --branch DEST` (and `/resume … --branch`) clones +provider history and the peer cursor once; later saves belong only to +DEST. A filesystem-exclusive claim stops a destination race. Branches +do not auto-merge. `#697` numbered this 0042; remapped to **0049**. + +## Warmed TLS on MCP HTTP and WSS (ADR 0050 / #679) + +MCP probe/initialized stay on the persistent HTTP client. WSS CA is +scanned once per process. WS→SSE keeps the prewarmed pool. MCP HTTP +accepts gzip catalogs. `#679` numbered this 0043; remapped to **0050**. ## Parked @@ -64,8 +80,10 @@ Smolify is not a reserved core MCP. ## Tests -This cut raises the release-cut floor to **1817** (slack 25). The -paste-span, Codex WS error, and HTTP-generation suites land on top of -the 1804 floor from 281. `agent_request.zig` split request-scratch -helpers into `agent_request_scratch.zig` so the Codex WS merge stays -under the 600-line ceiling. +This cut raises the release-cut floor to **1817** (slack 25). Paste +spans, Codex WS errors, HTTP generations, session branches, and MCP +TLS-reuse tests land on top of the 1804 floor from 281. The suite +count is re-ratcheted after `zig build test` on this revision. +`agent_request.zig` split request-scratch helpers into +`agent_request_scratch.zig` so the Codex WS merge stays under the +600-line ceiling. diff --git a/docs/yxlyx-leftovers.md b/docs/yxlyx-leftovers.md index e24fa4e3..1359ad4a 100644 --- a/docs/yxlyx-leftovers.md +++ b/docs/yxlyx-leftovers.md @@ -3,8 +3,9 @@ What still exists in this repo versus what is parked. No new product mode. Landed on [v0.0.282](releases/v0.0.282.md): `#674` atomic paste spans, -`#693` Codex WS `type:error`, `#694` HTTP client generations (ADR 0048). -Still parked below. +`#693` Codex WS `type:error`, `#694` HTTP client generations (ADR 0048), +`#697` clone-on-write session branches (ADR 0049), `#679` MCP TLS reuse +(ADR 0050). Still parked below. | Issue | In this repo today | This cut | | --- | --- | --- | diff --git a/src/cli.zig b/src/cli.zig index e5f3edc0..39bf3d02 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -25,6 +25,8 @@ pub const changelog_text = \\ • pasted-text chips are atomic spans — a typed lookalike stays literal (#674) \\ • Codex WS type:error is a terminal API response; last_api_error stays (#693) \\ • request-construction TLS failure rotates a leased HTTP client generation (ADR 0048) + \\ • --resume SOURCE --branch DEST is clone-on-write (ADR 0049) + \\ • MCP HTTP/WSS reuse warmed TLS; catalogs can arrive gzip (ADR 0050) \\ \\0.0.281 \\ • -p / --json skip learn auto-init — no 132M graff-pinned copy (ADR 0044) From f046f1cdb8f80a9f5a6c124398491f9e4818cca3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 01:36:19 +0000 Subject: [PATCH 15/27] fix: keep agent.zig under 600 lines; join the grok-spec mock before close #697 added one Agent field and tripped the ceiling. Move the worker-line row test into agent_tests.zig. The grok-spec loopback was closing the listen socket while accept still ran (BADF); wake, await, then deinit. --- src/agent.zig | 4 ---- src/agent_tests.zig | 4 ++++ src/grok_spec_conformance.zig | 7 +++++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/agent.zig b/src/agent.zig index 450fd238..5dfe4917 100644 --- a/src/agent.zig +++ b/src/agent.zig @@ -595,7 +595,3 @@ test { _ = @import("effort_route.zig"); try agent_tests.lazyRootTools(Agent); } - -test "say: an over-long worker line still ends its row (#tui-tick)" { - try agent_tests.workerLineAlwaysEndsRow(Agent); -} diff --git a/src/agent_tests.zig b/src/agent_tests.zig index 4f961763..51dc0bec 100644 --- a/src/agent_tests.zig +++ b/src/agent_tests.zig @@ -101,3 +101,7 @@ pub fn oneshotUsesLiveTransport(comptime Agent: type) !void { child.stream_quiet = true; try std.testing.expect(child.usesLiveTransport()); } + +test "say: an over-long worker line still ends its row (#tui-tick)" { + try workerLineAlwaysEndsRow(@import("agent.zig").Agent); +} diff --git a/src/grok_spec_conformance.zig b/src/grok_spec_conformance.zig index 02d7f531..f0934cb7 100644 --- a/src/grok_spec_conformance.zig +++ b/src/grok_spec_conformance.zig @@ -148,9 +148,12 @@ test "grok spec: !live post wires Agent conv id; root and child differ" { var addr = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); var server = try std.Io.net.IpAddress.listen(&addr, io, .{}); var fut = io.async(Srv.run, .{ io, &server, &srv }); - defer fut.await(io); - defer server.deinit(io); var bound = server.socket.address; + defer { + if (std.Io.net.IpAddress.connect(&bound, io, .{ .mode = .stream })) |s| s.close(io) else |_| {} + fut.await(io); + server.deinit(io); + } var url_buf: [64]u8 = undefined; const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/chat", .{bound.getPort()}); var p_root = root.provider; From c624d6d29193f979dc7ead60e76ee577ee69edce Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:39:57 +0800 Subject: [PATCH 16/27] fix: omit an empty tool catalog from every request body (#695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wide-native RLM showcase invalidated the root catalog and never rebuilt it, and the next Responses body serialized `"tools":,` — the HTTP 400 RobertDeRose reproduced on 0.0.280. The showcase callers rebuild now (bed8e4f3), but the invariant still lived at N call sites: any future invalidate-without-rebuild reaches the wire malformed again. Enforce it at buildBody, the one serializer every wire and every caller passes through: an empty catalog string means "omit the field", exactly what text_only does on purpose and what every provider accepts. The regression test pins the empty-catalog body on Responses, Chat, and Anthropic, and re-pins that a real catalog still rides untouched. Co-Authored-By: Codegraff --- src/agent_request.zig | 3 +++ src/agent_request_body.zig | 4 ++- src/agent_request_body_responses.zig | 37 ++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/agent_request.zig b/src/agent_request.zig index bec3949c..605628b1 100644 --- a/src/agent_request.zig +++ b/src/agent_request.zig @@ -106,6 +106,9 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { // budget can never pay for — which is how the audit smoke died narrating. // compaction/title requests pass tools=null already and skip this whole. var tools = tools_in; + if (self.tracer) |tr| { + if (tools) |t| if (t.len == 0) tr.note("tools", "empty catalog at request time (#695)"); + } if (self.run_budget) |b| if (budget_permit) |p| { if (b.max_model_calls != 0 and p.call_number == b.max_model_calls and tools != null) { tools = null; diff --git a/src/agent_request_body.zig b/src/agent_request_body.zig index 04fc5e6c..de282796 100644 --- a/src/agent_request_body.zig +++ b/src/agent_request_body.zig @@ -22,7 +22,9 @@ pub fn responsesOutputLimit(self: *const Agent) u32 { return self.responses_output_limit orelse max_tokens; } -pub fn buildBody(self: *Agent, tools: ?[]const u8, force_tool: bool, stream: bool, stream_usage: bool) ![]u8 { +pub fn buildBody(self: *Agent, tools_in: ?[]const u8, force_tool: bool, stream: bool, stream_usage: bool) ![]u8 { + // #695: an EMPTY catalog string means "omit tools" — `""` serialized the malformed `"tools":,` that 400s every provider (0.0.280). + const tools: ?[]const u8 = if (tools_in) |t| (if (t.len == 0) null else t) else null; var aw: Io.Writer.Allocating = .init(self.gpa); errdefer aw.deinit(); var s: std.json.Stringify = .{ .writer = &aw.writer }; diff --git a/src/agent_request_body_responses.zig b/src/agent_request_body_responses.zig index 3c91f448..cae659cc 100644 --- a/src/agent_request_body_responses.zig +++ b/src/agent_request_body_responses.zig @@ -318,6 +318,43 @@ fn testAgentFor(arena: std.mem.Allocator, id: []const u8, kind: @import("provide }; } +// #695: the wide-native RLM showcase invalidated `tools_responses` and never +// rebuilt it, and the next body serialized `"tools":,` — an HTTP 400 on +// every provider. The serialization guard in buildBody turns an empty +// catalog string into "omit the field" on all three wires; this pins that +// so no future invalidate-without-rebuild can reach the wire malformed. +test "#695: an empty catalog string is omitted from the body, never serialized as tools:, (#695)" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + + var responses = try testAgentFor(a, "codex", .responses, "gpt-5.6-sol"); + const rb = try responses.buildBody("", false, true, true); + defer std.testing.allocator.free(rb); + try std.testing.expect(std.mem.indexOf(u8, rb, "\"tools\":,") == null); + try std.testing.expect(std.mem.indexOf(u8, rb, "\"tool_choice\"") == null); + try std.testing.expect(std.mem.indexOf(u8, rb, "\"parallel_tool_calls\"") == null); + + var chat = try testAgentFor(a, "xai", .openai, "grok-4.6"); + const cb = try chat.buildBody("", false, true, true); + defer std.testing.allocator.free(cb); + try std.testing.expect(std.mem.indexOf(u8, cb, "\"tools\":,") == null); + try std.testing.expect(std.mem.indexOf(u8, cb, "\"tool_choice\"") == null); + + var anthropic = try testAgentFor(a, "anthropic", .anthropic, "claude-sonnet-5"); + const ab = try anthropic.buildBody("", false, true, true); + defer std.testing.allocator.free(ab); + try std.testing.expect(std.mem.indexOf(u8, ab, "\"tools\":,") == null); + try std.testing.expect(std.mem.indexOf(u8, ab, "\"tool_choice\"") == null); + + // A real catalog still rides every wire untouched. + var live = try testAgentFor(a, "codex", .responses, "gpt-5.6-sol"); + const catalog = "[{\"type\":\"function\",\"name\":\"bash\",\"description\":\"\"}]"; + const lb = try live.buildBody(catalog, false, true, true); + defer std.testing.allocator.free(lb); + try std.testing.expect(std.mem.indexOf(u8, lb, "\"name\":\"bash\"") != null); +} + test "GPT-5.6 Platform marks the stable prefix; Codex and older routes do not" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); From 4c92f56f4d6f37f5cb81e56a6ab5d286a70d476e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 01:41:11 +0000 Subject: [PATCH 17/27] test: ratchet the 282 suite floor to 1848 TLS-generation, session-branch, and MCP reuse tests landed on top of the 1817 floor. Slack was 31 ahead; the hook warned. --- CHANGELOG.md | 2 +- docs/releases/v0.0.282.md | 2 +- scripts/eval/tier1-manifest.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cceebc80..75e3eeaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ current is part of cutting a release. - `#694`/`#697`/`#679` numbered records 0042/0043; those slots are TUI claims and Pi SWE. Remapped to **0048–0050**. - `#277` (Smolify OAuth) and `#200` (idle localhost) stay parked. -- Suite floor **1817** (was 1804). Scratch helpers live in +- Suite floor **1848** (was 1804). Scratch helpers live in `agent_request_scratch.zig` so the request loop stays under 600. ## v0.0.281 (2026-08-29) diff --git a/docs/releases/v0.0.282.md b/docs/releases/v0.0.282.md index 9a30889a..68880307 100644 --- a/docs/releases/v0.0.282.md +++ b/docs/releases/v0.0.282.md @@ -80,7 +80,7 @@ Smolify is not a reserved core MCP. ## Tests -This cut raises the release-cut floor to **1817** (slack 25). Paste +This cut raises the release-cut floor to **1848** (slack 25). Paste spans, Codex WS errors, HTTP generations, session branches, and MCP TLS-reuse tests land on top of the 1804 floor from 281. The suite count is re-ratcheted after `zig build test` on this revision. diff --git a/scripts/eval/tier1-manifest.json b/scripts/eval/tier1-manifest.json index 2d20bd6f..a913e95d 100644 --- a/scripts/eval/tier1-manifest.json +++ b/scripts/eval/tier1-manifest.json @@ -15,7 +15,7 @@ "src/repl.zig", "TUI/root.zig" ], - "test_count_baseline": 1817, + "test_count_baseline": 1848, "test_count_slack": 25, "required_invariants": [ { From b1fcf67621a2b899dbc9247d211e4b3882473809 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 01:47:38 +0000 Subject: [PATCH 18/27] docs(release): note #695 empty-catalog omit on 282 Remote 282 advanced with the empty-tools serializer while this tip already had #697/#679 and the 1848 floor. Notes and leftovers now match the merged revision. No tag. --- CHANGELOG.md | 2 ++ docs/releases/v0.0.282.md | 16 +++++++++++++--- docs/yxlyx-leftovers.md | 2 +- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75e3eeaf..12566696 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ current is part of cutting a release. so two processes cannot clobber one `.session.json`. - `#679` / ADR 0050: MCP HTTP/WSS reuse the warmed TLS client; MCP HTTP accepts gzip catalogs. +- `#695`: an empty tool catalog omits the `tools` field instead of + serializing `"tools":,` (0.0.280 HTTP 400). - `#694`/`#697`/`#679` numbered records 0042/0043; those slots are TUI claims and Pi SWE. Remapped to **0048–0050**. - `#277` (Smolify OAuth) and `#200` (idle localhost) stay parked. diff --git a/docs/releases/v0.0.282.md b/docs/releases/v0.0.282.md index 68880307..724136b8 100644 --- a/docs/releases/v0.0.282.md +++ b/docs/releases/v0.0.282.md @@ -72,6 +72,15 @@ MCP probe/initialized stay on the persistent HTTP client. WSS CA is scanned once per process. WS→SSE keeps the prewarmed pool. MCP HTTP accepts gzip catalogs. `#679` numbered this 0043; remapped to **0050**. +## Empty tool catalog is omitted (#695) + +A wide-native RLM showcase that invalidated the root catalog and did +not rebuild it serialized `"tools":,` and every provider 400'd +(reproduced on 0.0.280). Showcase callers rebuild now; `buildBody` +also omits the field when the catalog string is empty — the same +shape `text_only` already uses. Responses, Chat, and Anthropic +bodies are pinned. + ## Parked `#277` (Streamable HTTP OAuth / Smolify) and `#200` (idle localhost @@ -81,9 +90,10 @@ Smolify is not a reserved core MCP. ## Tests This cut raises the release-cut floor to **1848** (slack 25). Paste -spans, Codex WS errors, HTTP generations, session branches, and MCP -TLS-reuse tests land on top of the 1804 floor from 281. The suite -count is re-ratcheted after `zig build test` on this revision. +spans, Codex WS errors, HTTP generations, session branches, MCP +TLS-reuse, and empty-catalog omit tests land on top of the 1804 +floor from 281. The suite count is re-ratcheted after +`zig build test` on this revision. `agent_request.zig` split request-scratch helpers into `agent_request_scratch.zig` so the Codex WS merge stays under the 600-line ceiling. diff --git a/docs/yxlyx-leftovers.md b/docs/yxlyx-leftovers.md index 1359ad4a..d176a53a 100644 --- a/docs/yxlyx-leftovers.md +++ b/docs/yxlyx-leftovers.md @@ -5,7 +5,7 @@ What still exists in this repo versus what is parked. No new product mode. Landed on [v0.0.282](releases/v0.0.282.md): `#674` atomic paste spans, `#693` Codex WS `type:error`, `#694` HTTP client generations (ADR 0048), `#697` clone-on-write session branches (ADR 0049), `#679` MCP TLS reuse -(ADR 0050). Still parked below. +(ADR 0050), `#695` omit an empty tool catalog. Still parked below. | Issue | In this repo today | This cut | | --- | --- | --- | From 19ff632f7d65f5eea329d229bafdba1e47699887 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 01:51:04 +0000 Subject: [PATCH 19/27] test: ratchet the 282 suite floor to 1849 #695's empty-catalog omit test lands on top of the 1848 floor from the earlier 282 folds. The suite ran 1849 on this revision. --- CHANGELOG.md | 2 +- docs/releases/v0.0.282.md | 2 +- scripts/eval/tier1-manifest.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12566696..aa30d1e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ current is part of cutting a release. - `#694`/`#697`/`#679` numbered records 0042/0043; those slots are TUI claims and Pi SWE. Remapped to **0048–0050**. - `#277` (Smolify OAuth) and `#200` (idle localhost) stay parked. -- Suite floor **1848** (was 1804). Scratch helpers live in +- Suite floor **1849** (was 1804). Scratch helpers live in `agent_request_scratch.zig` so the request loop stays under 600. ## v0.0.281 (2026-08-29) diff --git a/docs/releases/v0.0.282.md b/docs/releases/v0.0.282.md index 724136b8..fcecfc28 100644 --- a/docs/releases/v0.0.282.md +++ b/docs/releases/v0.0.282.md @@ -89,7 +89,7 @@ Smolify is not a reserved core MCP. ## Tests -This cut raises the release-cut floor to **1848** (slack 25). Paste +This cut raises the release-cut floor to **1849** (slack 25). Paste spans, Codex WS errors, HTTP generations, session branches, MCP TLS-reuse, and empty-catalog omit tests land on top of the 1804 floor from 281. The suite count is re-ratcheted after diff --git a/scripts/eval/tier1-manifest.json b/scripts/eval/tier1-manifest.json index a913e95d..4ad0369e 100644 --- a/scripts/eval/tier1-manifest.json +++ b/scripts/eval/tier1-manifest.json @@ -15,7 +15,7 @@ "src/repl.zig", "TUI/root.zig" ], - "test_count_baseline": 1848, + "test_count_baseline": 1849, "test_count_slack": 25, "required_invariants": [ { From 62ffa9b9b1985d3de1541d9a58e8ff249ebf35c7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 02:14:23 +0000 Subject: [PATCH 20/27] docs(eval): remasure 12-task in-house frontier after #697 Same SuperGrok seat, jobs=1. Graff stays unique vs grok and OpenCode on pass/wall/calls/tokens/list$/RSS. Calls still 52. --- CHANGELOG.md | 2 + README.md | 14 +- docs/releases/v0.0.282.md | 9 ++ graff-evals/hillclimb/baseline.md | 30 ++++- .../frontier-inhouse-12-20260831-282.svg | 120 ++++++++++++++++++ 5 files changed, 167 insertions(+), 8 deletions(-) create mode 100644 graff-evals/hillclimb/frontier-inhouse-12-20260831-282.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index aa30d1e0..a863b428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ current is part of cutting a release. - `#277` (Smolify OAuth) and `#200` (idle localhost) stay parked. - Suite floor **1849** (was 1804). Scratch helpers live in `agent_request_scratch.zig` so the request loop stays under 600. +- 12-task in-house remasure after #697 still unique vs grok / OpenCode: + **12/12 · 201s · 52 calls · $0.34** (`run-20260831-021035-composite`). ## v0.0.281 (2026-08-29) diff --git a/README.md b/README.md index 02db5b24..c3195995 100644 --- a/README.md +++ b/README.md @@ -54,17 +54,17 @@ Same grok-4.6, same SuperGrok seat, same tasks. graff vs grok-build vs OpenCode. Lower is better on every named axis. Full tables: [graff-evals/hillclimb/baseline.md](graff-evals/hillclimb/baseline.md). -**12 shipped-PR fixtures** (`run-20260830-141658`, `--suite inhouse`): +**12 shipped-PR fixtures** (`run-20260831-021035-composite` on 282, `--suite inhouse`): | harness | pass | wall | calls | tokens | list$ | RSS | |---|---:|---:|---:|---:|---:|---:| -| **graff** | **12/12** | **192s** | **52** | **228k** | **$0.35** | **92M** | -| grok-build | 12/12 | 462s | 63 | 1.18M | $1.12 | 170M | -| OpenCode | 12/12 | 236s | 74 | 675k | $0.79 | 1.1G | +| **graff** | **12/12** | **201s** | **52** | **230k** | **$0.34** | **9M** | +| grok-build | 12/12 | 388s | 64 | 1.17M | $1.05 | 161M | +| OpenCode | 12/12 | 310s | 81 | 737k | $0.93 | 1.1G | -Graff is the unique frontier on pass, wall, calls, tokens, list$, and RSS. -(First-token is not scored on that run — graff's `0.02s` is a boot mark, not -first model SSE.) +Graff is still the unique frontier on pass, wall, calls, tokens, list$, and RSS +after folding #697. (First-token is not scored — graff's `0.02s` is a boot +mark, not first model SSE. RSS is ReleaseSafe process peak.) On the 3-task spine (exact-reply + file-ops + fix-fib) graff was **19.9s / 8 calls / $0.048** vs grok 32.3s / 8 / $0.147 and OpenCode 31.2s / 8 / $0.101. diff --git a/docs/releases/v0.0.282.md b/docs/releases/v0.0.282.md index fcecfc28..fa3a8c10 100644 --- a/docs/releases/v0.0.282.md +++ b/docs/releases/v0.0.282.md @@ -81,6 +81,15 @@ also omits the field when the catalog string is empty — the same shape `text_only` already uses. Responses, Chat, and Anthropic bodies are pinned. +## 12-task in-house remasure after #697 + +Same SuperGrok seat, jobs=1, grok-4.6, `x_search` on. Graff is still +the unique frontier vs grok-build and OpenCode on pass / wall / calls / +tokens / list$ / RSS (`run-20260831-021035-composite`). Calls stayed +52. list$ ticked down. Wall is +9s vs the 141658 pin, almost all on +one model-variance retry of `atomic-symlink-write`. `peer-resume` +(the #697-shaped fixture) passed. First-token is not a named win. + ## Parked `#277` (Streamable HTTP OAuth / Smolify) and `#200` (idle localhost diff --git a/graff-evals/hillclimb/baseline.md b/graff-evals/hillclimb/baseline.md index 5c817bb6..27150a08 100644 --- a/graff-evals/hillclimb/baseline.md +++ b/graff-evals/hillclimb/baseline.md @@ -26,7 +26,35 @@ In-house 6 PR fixtures: | grok | 5/6 | 396s | 2.4s | 30 | 535344 | $0.4285 | 157.7M | | opencode | **6/6** | 182.1s | 2.8s | 37 | 340074 | $0.3835 | 1103.9M | -## 12-task in-house remasure (`run-20260830-141658`) +## 12-task in-house remasure after #697 (`run-20260831-021035-composite`) + +Same SuperGrok seat, jobs=1, grok-4.6, `x_search` on, ReleaseSafe +`graff-dev` from `release/v0.0.282` (`b1fcf67`, includes #697 session +branches and #695 empty-catalog omit). Composite of `run-20260831-015222` +(graff 11 + OpenCode 12) + `run-20260831-020406` (grok 12, after +restoring `~/.grok/auth.json`) + `run-20260831-021035` (graff +`atomic-symlink-write` retry — first attempt was a dangling-symlink +model miss, not a harness fail). All three harnesses **12/12**. + +| harness | pass | wall | first | calls | tokens | list$ | RSS | +|---|---:|---:|---:|---:|---:|---:| +| graff-dev | **12/12** | **200.9s** | 0.02s† | **52** | **230028** | **$0.3410** | **8.8M** | +| grok | 12/12 | 387.7s | 2.5s | 64 | 1170113 | $1.0543 | 160.9M | +| opencode | 12/12 | 310.4s | 2.9s | 81 | 737178 | $0.9326 | 1149.9M | + +† Graff `first_out_s` is still boot/`›`, not TTFT. Unique frontier on +pass / wall / calls / tokens / list$ / RSS. First-token is **not** a +named win. Calls stayed **52**. list$ ticked down ($0.3504 → $0.3410). +Wall is +8.8s vs the 141658 pin, almost all on `atomic-symlink-write` +(75s vs 49s, still 4 calls) — model path, not a session-branch +regression. `peer-resume` (the #697-shaped fixture) passed and got +slightly faster (8.14s vs 9.73s). RSS is ReleaseSafe process peak +(child HWM ~20M); the 91.7M pin was a fatter binary. Not a grok-heap +steal (ADR 0024). + +![12-task in-house frontier after #697](frontier-inhouse-12-20260831-282.svg) + +## Prior 12-task pin (`run-20260830-141658`) Same SuperGrok seat, jobs=1, grok-4.6, `x_search` on, the six original fixtures plus #690 (`hardlink-pin`, `x-search-splice`, `mcp-first-turn`, diff --git a/graff-evals/hillclimb/frontier-inhouse-12-20260831-282.svg b/graff-evals/hillclimb/frontier-inhouse-12-20260831-282.svg new file mode 100644 index 00000000..761cca11 --- /dev/null +++ b/graff-evals/hillclimb/frontier-inhouse-12-20260831-282.svg @@ -0,0 +1,120 @@ + +Eval frontier · lower-left is better + +Eval frontier · lower-left is better +Pass is a tie-break, not an axis. Hollow = mixed-model / REPL. REPL first-token is echo — not plotted. + + +wall vs list$ +calls vs list$ +wall (s) → +tool calls → + + +$0.000 +$0.000 + + +$0.303 +$0.303 + + +$0.606 +$0.606 + + +$0.909 +$0.909 + + +$1.212 +$1.212 + +171s + +237s + +303s + +368s + +434s + +52 + +53 + +54 + +55 + +56 + +57 + +58 + +59 + +60 + +61 + +62 + +63 + +64 + +65 + +66 + +67 + +68 + +69 + +70 + +71 + +72 + +73 + +74 + +75 + +76 + +77 + +78 + +79 + +80 + +81 + +graff-dev 200.9s · $0.3410 + +graff-dev 52 · $0.3410 + +graff-dev — 12/12 · on wall/$ + calls/$ + +opencode 310.4s · $0.9326 + +opencode 81 · $0.9326 + +opencode — 12/12 · interior + +grok 387.7s · $1.0543 + +grok 64 · $1.0543 + +grok — 12/12 · interior + From 999a3331611428a9280795b99957b791b7a02f1a Mon Sep 17 00:00:00 2001 From: yxlyx <85774423+yxlyx@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:39:15 +0800 Subject: [PATCH 21/27] fix: leave headroom for pinned debug builds The Linux debug executable crossed the 128 MiB learning-program ceiling after session branching was linked, causing the hosted zero-configuration bootstrap e2e to fail before it could pin the current binary. Keep the safety bound but raise it to 160 MiB so supported debug builds remain valid learning evaluators.\n\nCo-Authored-By: Codegraff --- src/learn_store_types.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/learn_store_types.zig b/src/learn_store_types.zig index 516cb8d3..75b2810e 100644 --- a/src/learn_store_types.zig +++ b/src/learn_store_types.zig @@ -10,7 +10,9 @@ pub const version_bytes = "1\n"; pub const max_config_bytes: usize = 1 << 20; pub const max_record_bytes: usize = 8 << 20; -pub const max_program_bytes: u64 = 128 << 20; +// The supported Linux debug build is itself a pinnable evaluator; keep this +// bounded while leaving headroom beyond its current ~128 MiB link image. +pub const max_program_bytes: u64 = 160 << 20; pub const max_suite_bytes: usize = 8 << 20; pub const max_pairs: usize = 4096; From 493627cbcbc8537b8588c0ccd6361ae6d2953c0a Mon Sep 17 00:00:00 2001 From: yxlyx <85774423+yxlyx@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:58:09 +0800 Subject: [PATCH 22/27] fix: keep compaction handoffs out of source nudges Durable compaction handoffs name and ; substring matching interpreted their prefix as JavaScript and could inject an unrelated fifth model turn. Match source suffixes at a boundary while explicitly supporting JSX/TSX.\n\nAlso make the Codex compaction probe parse both chat strings and normalized Responses blocks, and wait for prompt-first startup settings before counting transport turns.\n\nCo-Authored-By: Codegraff --- scripts/test-pty-codex-ws.py | 9 ++++----- src/named_work.zig | 17 ++++++++++++----- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/scripts/test-pty-codex-ws.py b/scripts/test-pty-codex-ws.py index 631d5791..06f72317 100644 --- a/scripts/test-pty-codex-ws.py +++ b/scripts/test-pty-codex-ws.py @@ -25,16 +25,15 @@ def main() -> None: fh, ) - # The AI tab-titler (titleTask, src/title.zig) fires one extra quiet SSE - # turn on the first prompt, which would corrupt the transport counters. - # Disable it the same way `/title off` does: the persisted setting in - # the session cwd's .harness/settings.json. + # The AI tab-titler and turn recap each fire an extra quiet SSE turn, + # which would corrupt these transport counters. Disable both through + # the persisted settings this test is specifically not exercising. harness_dir = os.path.join(tmp, ".harness") os.makedirs(harness_dir, exist_ok=True) with open( os.path.join(harness_dir, "settings.json"), "w", encoding="utf-8" ) as fh: - json.dump({"ai_title": False}, fh) + json.dump({"ai_title": False, "session_recap": False}, fh) scenarios = [ ( diff --git a/src/named_work.zig b/src/named_work.zig index 762ddaa7..e67ad546 100644 --- a/src/named_work.zig +++ b/src/named_work.zig @@ -21,7 +21,7 @@ pub const nudge_text = /// after the first step, so `handle` must not depend on walking messages. var remembered_task: []const u8 = ""; -const source_needles = [_][]const u8{ ".py", ".zig", ".js", ".ts", "SPEC.md" }; +const source_needles = [_][]const u8{ ".py", ".zig", ".jsx", ".js", ".tsx", ".ts", "SPEC.md" }; pub fn remember(text: []const u8) void { remembered_task = text; @@ -55,8 +55,13 @@ pub fn beginTurn(self: *Agent) void { /// True when `text` names a source path (not a greeting.txt-style data file). pub fn hasNamedSource(text: []const u8) bool { - for (source_needles) |n| { - if (std.mem.indexOf(u8, text, n) != null) return true; + for (source_needles) |needle| { + var from: usize = 0; + while (std.mem.indexOfPos(u8, text, from, needle)) |at| { + const end = at + needle.len; + if (end == text.len or (!std.ascii.isAlphanumeric(text[end]) and text[end] != '_')) return true; + from = end; + } } return false; } @@ -135,10 +140,12 @@ pub fn handle(self: *Agent, _: []const u8) !bool { return true; } -test "hasNamedSource sees SPEC and .py, ignores greeting.txt and pong" { +test "hasNamedSource sees source suffixes without confusing JSON for JavaScript" { try std.testing.expect(hasNamedSource("Read SPEC.md and affinity.py")); try std.testing.expect(hasNamedSource("Fix fib.py")); - try std.testing.expect(hasNamedSource("edit stall_notice.py")); + try std.testing.expect(hasNamedSource("edit view.tsx and helper.jsx")); + try std.testing.expect(!hasNamedSource("resume .graff/sessions/task.session.json")); + try std.testing.expect(!hasNamedSource("read task.transcript.jsonl")); try std.testing.expect(!hasNamedSource("Reply with exactly: pong")); try std.testing.expect(!hasNamedSource("Create hello.txt then rename it")); } From cf195f63c4cf72a7fb6bc7cb9e11788ac8621378 Mon Sep 17 00:00:00 2001 From: yxlyx <85774423+yxlyx@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:01:07 +0800 Subject: [PATCH 23/27] test: stabilize Codex compaction wire assertions Responses history may carry user content as either a chat string or normalized input_text blocks. Parse both forms so the compaction probe validates request meaning rather than racing normalization, and let prompt-first startup apply persisted auxiliary-call settings before transport counting begins. Co-Authored-By: Codegraff --- scripts/codex_ws_test.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/scripts/codex_ws_test.py b/scripts/codex_ws_test.py index 9c5d7896..8941846c 100644 --- a/scripts/codex_ws_test.py +++ b/scripts/codex_ws_test.py @@ -70,7 +70,19 @@ def user_text(item: object) -> str | None: if not isinstance(item, dict) or item.get("role") != "user": return None content = item.get("content") - return content if isinstance(content, str) else None + if isinstance(content, str): + return content + if isinstance(content, list): + texts = [ + block.get("text") + for block in content + if isinstance(block, dict) + and block.get("type") == "input_text" + and isinstance(block.get("text"), str) + ] + if texts: + return "".join(texts) + return None def last_user_text(request: RecordedRequest) -> str: @@ -398,9 +410,15 @@ def assert_midturn_requests(mock: CodexMock) -> None: # The two synthetic turns are identified by CONTENT, not position, so this # cannot silently pass if their order ever swaps. if not last_user_text(note).startswith("Your context is about to be compacted"): - raise AssertionError( - f"midturn: request 2 is not the #391 note turn: {last_user_text(note)[:120]!r}" - ) + shapes = [ + ( + request.transport, + last_user_text(request)[:80], + str(request.body.get("instructions", ""))[:80], + ) + for request in requests + ] + raise AssertionError(f"midturn: request 2 is not the #391 note turn: {shapes!r}") if not last_user_text(compact).startswith("Summarize this entire conversation"): raise AssertionError( f"midturn: request 3 is not the compaction summary: {last_user_text(compact)[:120]!r}" @@ -689,6 +707,9 @@ def run_midturn_compaction_scenario( timeout=45.0, # compaction legs stream 128 KiB of scripted reasoning; 20s flakes on loaded runners ) as session: session.wait_for_prompt() + # ADR 0042 deliberately paints the prompt before the rest of boot; let + # persisted title/recap settings land before transport counting starts. + session.pump_for(0.5) cursor = len(session.raw) session.send_line(MIDTURN_PROMPT) session.wait_for_literal("wrote a pre-compaction note to self", start=cursor) @@ -733,6 +754,7 @@ def run_transactional_compaction_scenario( timeout=45.0, # compaction legs stream 128 KiB of scripted reasoning; 20s flakes on loaded runners ) as session: session.wait_for_prompt() + session.pump_for(0.5) cursor = len(session.raw) session.send_line(TRANSACTIONAL_PROMPT) session.wait_for_literal( From 589797606defb2e052d28d840abdfdf8d2bc665d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 02:23:47 +0000 Subject: [PATCH 24/27] docs(release): fold #698 follow-ups onto 282 Debug learn-pin ceiling, source-nudge suffix boundaries, and Codex compaction wire parsing from the reopened session-branch head. --- CHANGELOG.md | 5 ++++- docs/releases/v0.0.282.md | 14 +++++++++++--- docs/yxlyx-leftovers.md | 5 +++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a863b428..e2c1e078 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,10 @@ current is part of cutting a release. (`#693`), and recoverable HTTP client generations (`#694` / ADR 0048). `#694` follow-up hardens lease teardown. - `#697` / ADR 0049: `--resume SOURCE --branch DEST` clone-on-write - so two processes cannot clobber one `.session.json`. + so two processes cannot clobber one `.session.json`. `#698` follow-ups: + 160 MiB learn-pin headroom for debug builds, source-nudge suffixes no + longer match `.json` / `.jsonl` as JavaScript, Codex compaction probe + reads Responses `input_text` blocks. - `#679` / ADR 0050: MCP HTTP/WSS reuse the warmed TLS client; MCP HTTP accepts gzip catalogs. - `#695`: an empty tool catalog omits the `tools` field instead of diff --git a/docs/releases/v0.0.282.md b/docs/releases/v0.0.282.md index fa3a8c10..42716c78 100644 --- a/docs/releases/v0.0.282.md +++ b/docs/releases/v0.0.282.md @@ -64,7 +64,13 @@ Two processes resuming the same name wrote the same `.session.json`. `--resume SOURCE --branch DEST` (and `/resume … --branch`) clones provider history and the peer cursor once; later saves belong only to DEST. A filesystem-exclusive claim stops a destination race. Branches -do not auto-merge. `#697` numbered this 0042; remapped to **0049**. +do not auto-merge. `#697` numbered this 0042; remapped to **0049**. `#698` reopened the +same head with three follow-ups now on this tip: raise the learn-pin +ceiling to 160 MiB so a Linux debug image still pins after session +branching linked in, match source suffixes at a token boundary so a +compaction handoff's `.session.json` / `.transcript.jsonl` is not a +JavaScript nudge, and parse Codex compaction user text from either a +chat string or Responses `input_text` blocks. ## Warmed TLS on MCP HTTP and WSS (ADR 0050 / #679) @@ -93,8 +99,10 @@ one model-variance retry of `atomic-symlink-write`. `peer-resume` ## Parked `#277` (Streamable HTTP OAuth / Smolify) and `#200` (idle localhost -servers) stay inventory in [yxlyx-leftovers.md](../yxlyx-leftovers.md). -Smolify is not a reserved core MCP. +servers) were closed as abandoned vs tip; the issues stay inventory in +[yxlyx-leftovers.md](../yxlyx-leftovers.md). Smolify is not a reserved +core MCP. Live leftovers that still apply are `#675` (DeepSeek +thinking-off / 15s lean bash) and `#677` (`/teleport` + snapshot GC). ## Tests diff --git a/docs/yxlyx-leftovers.md b/docs/yxlyx-leftovers.md index d176a53a..a2e80cf5 100644 --- a/docs/yxlyx-leftovers.md +++ b/docs/yxlyx-leftovers.md @@ -4,7 +4,8 @@ What still exists in this repo versus what is parked. No new product mode. Landed on [v0.0.282](releases/v0.0.282.md): `#674` atomic paste spans, `#693` Codex WS `type:error`, `#694` HTTP client generations (ADR 0048), -`#697` clone-on-write session branches (ADR 0049), `#679` MCP TLS reuse +`#697` clone-on-write session branches (ADR 0049; `#698` was a yxlyx reopen +of the same head and is closed as duplicate), `#679` MCP TLS reuse (ADR 0050), `#695` omit an empty tool catalog. Still parked below. | Issue | In this repo today | This cut | @@ -14,7 +15,7 @@ Landed on [v0.0.282](releases/v0.0.282.md): `#674` atomic paste spans, | [#220](https://github.com/justrach/codegraff/issues/220) protected verifiers | `--eval` / `--until` scores a command the agent can invoke. ADR [0008](adr/0008-synthetic-evals-use-external-verifiers.md) is the eval-suite rule, not a `/goal` acceptance contract. | **Parked.** Independent verifier stages and hidden tests are a new controller, not a docs tweak. | | [#306](https://github.com/justrach/codegraff/issues/306) review runaway | Same budget knobs as #217. `/review` is one isolated pass (catalog). No review-specific cycle cap or "confirm before implementing" gate. | **Parked** on the #217 remainder. | | [#283](https://github.com/justrach/codegraff/issues/283) cube NDJSON buffer | `graff serve` already emits NDJSON as events happen. The failure is Daytona preview buffering the response and iOS `URLSession` idling out. `graff cube` / `src/cube.zig` is the sandbox+serve CLI. | **Parked** (ingress + iOS client). Not a new harness mode. | -| [#199](https://github.com/justrach/codegraff/issues/199) idle localhost servers | No `graff processes` / idle supervisor. Jobs exist for tool-started bash; they do not pause forgotten `next dev` trees. PR #200 is still open. | **Parked.** Do not start a process-supervisor product on this branch. | +| [#199](https://github.com/justrach/codegraff/issues/199) idle localhost servers | No `graff processes` / idle supervisor. Jobs exist for tool-started bash; they do not pause forgotten `next dev` trees. PR #200 closed as abandoned vs tip. | **Parked.** Do not start a process-supervisor product on this branch. | | [#106](https://github.com/justrach/codegraff/issues/106) representative Ultracode sims | Unit suite + `TUI/sim.zig` Term + tier-2 harness cases. Not a multi-turn human Ultracode drama. | **Parked** (eval/tier-2 work, not 279 continuation). | #218 / #219 (siblings under #216 governed runs) follow #217/#220: do not open a second budget product here. From da50c12264c56eb7dbc5a8d13854b5c82bb9d49f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 02:25:22 +0000 Subject: [PATCH 25/27] docs(release): note #277/#200 closed in the 282 changelog The parked Smolify and idle-localhost PRs are closed as abandoned vs tip; issues stay inventory. Live leftovers remain #675 and #677. --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2c1e078..dd7dc6ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,8 @@ current is part of cutting a release. serializing `"tools":,` (0.0.280 HTTP 400). - `#694`/`#697`/`#679` numbered records 0042/0043; those slots are TUI claims and Pi SWE. Remapped to **0048–0050**. -- `#277` (Smolify OAuth) and `#200` (idle localhost) stay parked. +- `#277` (Smolify OAuth) and `#200` (idle localhost) closed as + abandoned vs tip; issues stay parked. Live leftovers: `#675`, `#677`. - Suite floor **1849** (was 1804). Scratch helpers live in `agent_request_scratch.zig` so the request loop stays under 600. - 12-task in-house remasure after #697 still unique vs grok / OpenCode: From 2211fc7ce3fe5da67e2c3df000e57316ae9e224d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 03:21:02 +0000 Subject: [PATCH 26/27] feat: fold #677 teleport/GC and #675 DeepSeek lean bash into 282 #677 leftover of #554: /teleport restores a snapshot tar onto another CLI backend; /snapshot gc keeps the newest n trees. ADR 0051 (their 0042 collided with TUI claims). #675: lean -p bounces prose-only first "done"; short Codegraff flakes retry twice; DeepSeek thinking off at default low; lean bash auto-backgrounds at 15s. ADRs remapped 0048-0051 -> 0052-0055. Suite floor 1861. Keep-alive-only bodies share the short-flake retry so the request-scratch split stays. Co-authored-by: yxlyx <85774423+yxlyx@users.noreply.github.com> --- CHANGELOG.md | 11 +- docs/adr/0037-experiment-pool-is-opt-in.md | 3 +- docs/adr/0051-sandbox-teleport-and-gc.md | 25 ++++ docs/adr/0052-lean-oneshot-bounces-prose.md | 63 ++++++++ .../0053-codegraff-flake-retry-opencode.md | 56 +++++++ .../0054-deepseek-thinking-disabled-at-low.md | 51 +++++++ docs/adr/0055-lean-oneshot-bash-15s.md | 35 +++++ docs/adr/README.md | 5 + docs/releases/v0.0.282.md | 40 ++++- docs/yxlyx-leftovers.md | 4 +- graff-evals/README.md | 2 + graff-evals/harnesses.json | 8 + graff-evals/opencode-codegraff.json | 31 ++++ graff-evals/opencode-codegraff.sh | 17 +++ scripts/eval/tier1-manifest.json | 2 +- src/agent_empty_completion.zig | 68 ++++++--- src/agent_gateway_retry.zig | 84 ++++++++++- src/agent_request_body.zig | 2 +- src/agent_request_scratch.zig | 14 +- src/cli.zig | 2 + src/command_catalog.zig | 3 +- src/commands_sandbox.zig | 139 ++++++++++++++++-- src/effort_route.zig | 4 +- src/exec_bash.zig | 35 ++++- src/help.zig | 2 +- src/no_local_tools.zig | 2 +- src/prompt_snapshot_tests.zig | 1 + src/prompt_text.zig | 1 + src/provider_codegraff_tests.zig | 46 ++++++ src/sandbox.zig | 71 +++++++++ src/sandbox_docker.zig | 22 ++- src/sandbox_tests.zig | 132 +++++++++++++++++ src/test_hooks.zig | 2 +- src/zai_wire.zig | 27 +++- 34 files changed, 941 insertions(+), 69 deletions(-) create mode 100644 docs/adr/0051-sandbox-teleport-and-gc.md create mode 100644 docs/adr/0052-lean-oneshot-bounces-prose.md create mode 100644 docs/adr/0053-codegraff-flake-retry-opencode.md create mode 100644 docs/adr/0054-deepseek-thinking-disabled-at-low.md create mode 100644 docs/adr/0055-lean-oneshot-bash-15s.md create mode 100644 graff-evals/opencode-codegraff.json create mode 100755 graff-evals/opencode-codegraff.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index dd7dc6ee..e4f3a3de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,9 +27,16 @@ current is part of cutting a release. serializing `"tools":,` (0.0.280 HTTP 400). - `#694`/`#697`/`#679` numbered records 0042/0043; those slots are TUI claims and Pi SWE. Remapped to **0048–0050**. +- `#677` / ADR 0051: `/teleport` restores a snapshot tar onto another + CLI backend; `/snapshot gc` keeps the newest n trees. Leftover of + `#554`. `#677` numbered this 0042; remapped (0042 is TUI claims). +- `#675` / ADR 0052–0055: lean `-p` bounces a prose-only first "done"; + short Codegraff flakes retry twice; DeepSeek family default low + sends `thinking.type=disabled`; lean bash auto-backgrounds at 15s. + `#675` numbered those 0048–0051; remapped off 282's 0048–0051. - `#277` (Smolify OAuth) and `#200` (idle localhost) closed as - abandoned vs tip; issues stay parked. Live leftovers: `#675`, `#677`. -- Suite floor **1849** (was 1804). Scratch helpers live in + abandoned vs tip; issues stay parked. +- Suite floor **1861** (was 1849). Scratch helpers live in `agent_request_scratch.zig` so the request loop stays under 600. - 12-task in-house remasure after #697 still unique vs grok / OpenCode: **12/12 · 201s · 52 calls · $0.34** (`run-20260831-021035-composite`). diff --git a/docs/adr/0037-experiment-pool-is-opt-in.md b/docs/adr/0037-experiment-pool-is-opt-in.md index b1641137..0b77d701 100644 --- a/docs/adr/0037-experiment-pool-is-opt-in.md +++ b/docs/adr/0037-experiment-pool-is-opt-in.md @@ -22,4 +22,5 @@ path, branch, keep-reason, and diffstat. `graff worktree list` tags them ## Consequences Seats are FIFO. A fourth child after `--experiment 3` uses normal isolation. -Dependent pipeline stages still need #295. Docker snapshots stay #554. +Dependent pipeline stages still need #295. Docker snapshots stay #554; +teleport and snapshot GC are ADR 0051. diff --git a/docs/adr/0051-sandbox-teleport-and-gc.md b/docs/adr/0051-sandbox-teleport-and-gc.md new file mode 100644 index 00000000..d67440c3 --- /dev/null +++ b/docs/adr/0051-sandbox-teleport-and-gc.md @@ -0,0 +1,25 @@ +# 0051. Sandbox leftover of #554 is teleport plus snapshot GC + +Status: accepted 2026-08-29 + +## Context + +#554 shipped the Docker CLI seam, `/snapshot`, and `/rewind `. Two exo +misses stayed open: restoring a `docker_image_tar` on a *different* backend, +and deleting leftover snapshot trees. ADR 0037 still pointed experiment-pool +Docker snapshots here. + +## Decision + +`/teleport [docker|container]` restores a captured tar onto another CLI +backend. Apple Container (`container`) is the second backend — same wire as +`docker`, different `bin_name`. Dest defaults to `container` when the live +backend is docker or nothing is attached. `/snapshot gc [n]` keeps the newest +n snapshots (default 1) and deletes the rest. The conversation is never +rewound. Missing CLIs stay explanations, not crashes. + +## Consequences + +Daytona is still not a backend. There is no `graff registry` client. A +snapshot's kind stays `docker_image_tar` even when the dest CLI is +`container`. Experiment-pool isolation is still opt-in worktrees (ADR 0037). diff --git a/docs/adr/0052-lean-oneshot-bounces-prose.md b/docs/adr/0052-lean-oneshot-bounces-prose.md new file mode 100644 index 00000000..3c8ebde1 --- /dev/null +++ b/docs/adr/0052-lean-oneshot-bounces-prose.md @@ -0,0 +1,63 @@ +# 0052. Lean `-p` bounces a first-turn prose-only "done" + +Status: accepted 2026-08-29 + +#675 numbered this 0048. 282 already owns 0048 (HTTP client generations); +this cut remaps the record. + +## Context + +ADR 0047 ran Codegraff `deepseek-v4-flash` SWE. Pi 5/6 in 492s. Graff +2/6 in 199s. The three misses (`map-conflict`, `json-stream`, +`validated`) were 4-second one-call replies (~1.5k in / ~160 out) that +described a patch and never called a tool. An isolated retry of those +three recovered two. Same miss as everyone on `label-sort`. + +Empty-completion retry only covers whitespace. A 158-token "I fixed +it" ends the turn. Forcing `tool_choice=required` is already reserved +for `--strict` / `--eval`; DeepSeek thinking mode has rejected it +before. Do not steal Pi's four-tool catalog (ADR 0024). + +## Decision + +On lean `-p` (unattended, file tools advertised, not review, not a +subagent), a first-turn completion that has text and zero tool calls +is not done. Append one user bounce naming `read_file` / `edit_file` / +`write_file` and re-open the loop. One bounce only (`model_calls == 1`). + +Lean intro also says a file change described in prose is not done. + +## Confirm — `deepseek-v4-flash` after bounce (`run-20260829-063114.jsonl`) + +Same `--suite swe -j 6` seat as ADR 0047. + +| harness | pass | wall (sum) | first | RSS | in | out | calls | +|---|---:|---:|---:|---:|---:|---:|---:| +| graff-dev | **4/6** | 250s | 0.03s | 91.2M | 170k | 25k | 26 | + +| task | before (0047) | after | +|---|---|---| +| cookie-store | ✓ 91s / 8 | ✓ 133s / 7 | +| config-parse | ✓ 33s / 4 | ✗ 5s / 1* | +| label-sort | ✗ 63s / 6 | ✗ 5s / 1* | +| map-conflict | ✗ 4s / 1 | ✓ 20s / 5 | +| json-stream | ✗ 4s / 1 | ✓ 65s / 8 | +| validated | ✗ 4s / 1 | ✓ 20s / 4 | + +\*follow-up API error under `-j 6` (resp 110 B, ~450 ms). Isolated +serial retry (`run-20260829-063451.jsonl`): `config-parse` ✓ 48s / 5, +`label-sort` ✗ 62s / 5 (check, exit 0). Do not stitch that into 5/6. + +The confirm run used tools on the first call (lean intro). Bounce is +the backstop when the model writes "I fixed it" with zero tools. No +`fake_done` notes on this run. + +## Consequences + +- `graff -p "what is 2+2"` pays one extra call if the first reply has + no tool. Acceptable for a coding harness. +- Interactive REPL / TUI / `--no-lean` / subagents are unchanged. +- Revisit if a named flash model needs `tool_choice=required` on the + first call; do not restore a global force. +- Follow-up `-j 6` flakes and the OpenCode A/B are ADR 0053 (graff + 5/6 in 362s after the retry). diff --git a/docs/adr/0053-codegraff-flake-retry-opencode.md b/docs/adr/0053-codegraff-flake-retry-opencode.md new file mode 100644 index 00000000..e939a41d --- /dev/null +++ b/docs/adr/0053-codegraff-flake-retry-opencode.md @@ -0,0 +1,56 @@ +# 0053. Retry short Codegraff follow-up flakes; OpenCode A/B on the same seat + +Status: accepted 2026-08-29 + +#675 numbered this 0049. 282 already owns 0049 (session branches); +this cut remaps the record. + +## Context + +ADR 0052 bounced lean `-p` prose-only first turns. DeepSeek flash SWE +moved 2/6 → 4/6. The leftover miss besides `label-sort` was +`config-parse` dying in ~5s after a successful 4-tool first batch: a +~110-byte / ~450 ms `api_error` under `-j 6`. Isolated serial retry +passed. Overload / `server_error` needles did not match. A missing +`error.message` becomes `unknown error` (`apiErrorMessage`). + +Same-seat OpenCode was missing from `graff-evals`. Fair A/B is the +same `CODEGRAFF_API_KEY` → `gateway.codegraff.com/v1`. Do not steal +Pi's four-tool catalog (ADR 0024) or OpenCode's ~1G heap. + +## Decision + +- Treat short generic envelopes (`unknown error`, `Internal Server + Error`, empty `api_error`) and tiny unparseable bodies (≤256 B) as + bounded retries (2). `invalid_request` / auth / quota / not-found + stay fail-fast. +- Add `opencode-codegraff` (`opencode run --auto --format json` + + tracked `opencode-codegraff.json`). Do not commit OpenCode auth. +- Eval failures keep a 400-byte `stderr_tail` so the next envelope is + visible. + +## Confirm — `deepseek-v4-flash` (`run-20260829-072310` + `072758`) + +Same `--suite swe -j 6` seat as ADR 0047 / 0052. + +| harness | pass | wall (sum) | first | RSS | in | out | calls | +|---|---:|---:|---:|---:|---:|---:|---:| +| graff-dev (`072758`) | **5/6** | 362s | 0.05s | 91.1M | 346k | 38k | 38 | +| opencode-codegraff (`072310`) | **5/6** | 261s | 3.6s | 1064M | 61k | 8k | 36 | +| pi-codegraff (0047) | **5/6** | 492s | 0.7s | 186M | 480k | 56k | 46 | + +The parallel A/B (`072310`) still had graff 4/6 (`config-parse` 5s / +1). After the `unknown error` / tiny-body retry, graff `072758` is +5/6; `config-parse` ✓ 30s / 4. Same `label-sort` miss on all three +harnesses. Do not stitch the two graff suite scores. + +OpenCode `first` is the first JSONL event (~3.6s), not TUI paint. +Graff `first` is the stderr `calling` line. Do not treat 0.05s as a +TUI steal. + +## Consequences + +- Score is now tied with Pi and OpenCode. Wall is not: OpenCode 261s + vs graff 362s is mostly `cookie-store` variance (89s on `072310`, + 185s on `072758`). RSS stays ~91M; do not take OpenCode's heap. +- Revisit if a named 400 should retry; do not retry `invalid_request`. diff --git a/docs/adr/0054-deepseek-thinking-disabled-at-low.md b/docs/adr/0054-deepseek-thinking-disabled-at-low.md new file mode 100644 index 00000000..2b599274 --- /dev/null +++ b/docs/adr/0054-deepseek-thinking-disabled-at-low.md @@ -0,0 +1,51 @@ +# 0054. DeepSeek flash default is thinking off + +Status: accepted 2026-08-29 + +#675 numbered this 0050. 282 already owns 0050 (warmed TLS); +this cut remaps the record. + +## Context + +ADR 0046 maps flash default `.medium` to `reasoning_effort: low`. +That stops GLM/Gemini thinking novels. DeepSeek V4 is different: +thinking is **on at high** unless the request carries +`thinking: {type: disabled}`. Official docs: `low` still thinks; +it only lowers CoT. `none` is a 400 on Chat Completions. + +DeepSeek flash SWE after ADR 0053: graff **5/6 in 362s** vs OpenCode +**5/6 in 261s**. The 100s gap is not catalog or heap. cookie-store +call 2 was **75s / 1.9MB** of `reasoning_content` at `low`. An earlier +A/B without that dump was 89s / 9 calls (OpenCode 76s / 9). Pi sets +`reasoning: false`. OpenCode omits the field (default = thinking on); +it still won on a no-dump draw. Do not steal Pi's four-tool catalog +or OpenCode's ~1G heap. + +## Decision + +DeepSeek family (native `deepseek`, or a `deepseek*` model on +codegraff/fireworks) sends `thinking.type=disabled` when the wire +effort is `low` (flash default). `/effort high` (and any non-low) +sends `type=enabled` plus that effort. GLM keeps `low` only — +`type=disabled` still thinks there (ADR 0046). Do not shrink the +keep-list. + +## Consequences + +- Flash SWE wall is generation without CoT dumps, not Zig vs JS. +- `/effort` remains the only way thinking comes back (no auto-flip). +- Revisit if a named DeepSeek flash pin needs default thinking. + +## Confirm (2026-08-29) + +Live `deepseek-v4-flash` pong on `gateway.codegraff.com`: + +| knob | reasoning_tokens | rc_len | +|---|---:|---:| +| omit | 21 | 79 | +| `reasoning_effort=low` | 31 | 112 | +| `thinking.type=disabled` | **0** | **0** | +| disabled + low | **0** | **0** | +| enabled + high | 29 | 117 | + +`--suite swe -j 6` after this cut (`074142`): 4/6 in **210s** / 91M (cookie-store 83s / 14, no 1.9MB dump). `config-parse` was the 110-byte “Body must be valid JSON” flake (retry in the same branch). After that retry (`074659`): **5/6 in 353s** — leftover wall was hung bash (ADR 0055), not CoT. Same `label-sort` miss. Later 0-token / balance-reservation runs are burnt gateway, not a model result. Do not steal Pi’s catalog. diff --git a/docs/adr/0055-lean-oneshot-bash-15s.md b/docs/adr/0055-lean-oneshot-bash-15s.md new file mode 100644 index 00000000..6bbbff0a --- /dev/null +++ b/docs/adr/0055-lean-oneshot-bash-15s.md @@ -0,0 +1,35 @@ +# 0055. Lean `-p` bash auto-backgrounds at 15s + +Status: accepted 2026-08-29 + +#675 numbered this 0051. 282 already owns 0051 (sandbox teleport / GC); +this cut remaps the record. + +## Context + +ADR 0026 waits 120s before promoting root foreground bash: a human +may be watching a long compile. Lean `-p` has no human. After ADR +0054 killed DeepSeek CoT dumps, SWE wall was **5/6 in 353s** — +`json-stream` sat **120s** on `python3 test_json_stream.py`, +`cookie-store` **71s** on another hang. API calls were 2–13s. +OpenCode’s 261s lead was those waits, not catalog or heap. + +## Decision + +Unattended + lean (`-p` default) auto-backgrounds at **15s**. The +process is not killed (ADR 0026). An explicit `timeout` still wins. +Interactive / `--no-lean` stay 120s. Do not steal Pi’s catalog. + +## Consequences + +A hung test returns a job id in 15s; the model can edit or +`bash_kill`. A 20-minute compile on `-p` also backgrounds at 15s — +`bash_output(wait_ms>0)` still waits for exit (ADR 0010). + +## Confirm (2026-08-29) + +`run-20260829-075402`, thinking off + 15s bash. cookie-store **30s** +(was 114s / 71s hang). json-stream **22s** (was 155s / 120s hang). +Suite 4/6 in **143s** / 91M — `validated` died on a concurrent +reservation / empty balance, not the wait. Same `label-sort` miss. +0-token follow-ups after that are burnt gateway. Do not stitch. diff --git a/docs/adr/README.md b/docs/adr/README.md index cb7e3da6..5bd49c01 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -61,6 +61,11 @@ record only when you need the evidence or the edge cases. | [0048](0048-model-http-client-recovery-uses-generations.md) | Model HTTP calls lease a recoverable client generation; request-construction TLS failure rotates safely without deinitializing in-flight users. | | [0049](0049-resume-branches-have-independent-durable-identity.md) | `--resume SOURCE --branch DEST` clones provider history and peer cursor state once; every later save belongs only to DEST. | | [0050](0050-reuse-warmed-tls-on-known-networks.md) | Reuse warmed TLS: MCP probe/initialized stay on the persistent HTTP client; WSS CA is scanned once per process; WS→SSE keeps the prewarmed pool; MCP HTTP accepts gzip. | +| [0051](0051-sandbox-teleport-and-gc.md) | `/teleport` restores a snapshot tar onto another CLI backend; `/snapshot gc` keeps the newest n trees. | +| [0052](0052-lean-oneshot-bounces-prose.md) | Lean `-p` bounces a first-turn prose-only "done"; one user note naming file tools, then re-open. | +| [0053](0053-codegraff-flake-retry-opencode.md) | Retry short Codegraff follow-up flakes (2); `opencode-codegraff` A/B on the same seat. Auth/quota stay fail-fast. | +| [0054](0054-deepseek-thinking-disabled-at-low.md) | DeepSeek family default low sends `thinking.type=disabled`; `/effort high` still thinks. GLM stays low-only. | +| [0055](0055-lean-oneshot-bash-15s.md) | Lean `-p` bash auto-backgrounds at 15s; interactive / `--no-lean` stay 120s (ADR 0026). | ## When to write one diff --git a/docs/releases/v0.0.282.md b/docs/releases/v0.0.282.md index 42716c78..1c445f38 100644 --- a/docs/releases/v0.0.282.md +++ b/docs/releases/v0.0.282.md @@ -96,21 +96,49 @@ tokens / list$ / RSS (`run-20260831-021035-composite`). Calls stayed one model-variance retry of `atomic-symlink-write`. `peer-resume` (the #697-shaped fixture) passed. First-token is not a named win. +## Sandbox teleport and snapshot GC (ADR 0051 / #677) + +Leftover of `#554`. `/teleport [docker|container]` restores a +captured `docker_image_tar` onto another CLI backend. Apple Container +is the second backend (same wire, different `bin_name`). Dest defaults +to `container` when the live backend is docker or nothing is attached. +`/snapshot gc [n]` keeps the newest n snapshot trees (default 1). +The conversation is never rewound. Missing CLIs explain, they do not +crash. `#677` numbered this 0042; remapped to **0051**. + +## DeepSeek thinking-off and lean 15s bash (ADR 0052–0055 / #675) + +Hillclimb of DeepSeek flash SWE on the Codegraff seat. Do not steal +Pi's four-tool catalog or OpenCode's ~1G heap. + +1. Lean `-p` bounces a first-turn prose-only "done" (ADR 0052). +2. Short Codegraff follow-up flakes retry twice (ADR 0053), including + the 110-byte `invalid_request_error` / "Body must be valid JSON". + Auth / quota / a real invalid prompt stay fail-fast. + `opencode-codegraff` is the same-seat OpenCode A/B harness. +3. DeepSeek family default low sends `thinking.type=disabled` + (ADR 0054). `/effort high` still thinks. GLM stays `low`-only. +4. Lean `-p` bash auto-backgrounds at 15s (ADR 0055). Interactive + stays 120s (ADR 0026). + +`#675` numbered these 0048–0051. 282 already owns 0048–0051; remapped +to **0052–0055**. Keep-alive-only bodies share the short-flake retry +in `agent_gateway_retry.zig` so 282's request-scratch split stays. + ## Parked `#277` (Streamable HTTP OAuth / Smolify) and `#200` (idle localhost servers) were closed as abandoned vs tip; the issues stay inventory in [yxlyx-leftovers.md](../yxlyx-leftovers.md). Smolify is not a reserved -core MCP. Live leftovers that still apply are `#675` (DeepSeek -thinking-off / 15s lean bash) and `#677` (`/teleport` + snapshot GC). +core MCP. ## Tests -This cut raises the release-cut floor to **1849** (slack 25). Paste +This cut raises the release-cut floor to **1861** (slack 25). Paste spans, Codex WS errors, HTTP generations, session branches, MCP -TLS-reuse, and empty-catalog omit tests land on top of the 1804 -floor from 281. The suite count is re-ratcheted after -`zig build test` on this revision. +TLS-reuse, empty-catalog omit, teleport/GC, and DeepSeek/lean-bash +tests land on top of the 1804 floor from 281. The suite count is +re-ratcheted after `zig build test` on this revision. `agent_request.zig` split request-scratch helpers into `agent_request_scratch.zig` so the Codex WS merge stays under the 600-line ceiling. diff --git a/docs/yxlyx-leftovers.md b/docs/yxlyx-leftovers.md index a2e80cf5..76c4b3db 100644 --- a/docs/yxlyx-leftovers.md +++ b/docs/yxlyx-leftovers.md @@ -6,7 +6,9 @@ Landed on [v0.0.282](releases/v0.0.282.md): `#674` atomic paste spans, `#693` Codex WS `type:error`, `#694` HTTP client generations (ADR 0048), `#697` clone-on-write session branches (ADR 0049; `#698` was a yxlyx reopen of the same head and is closed as duplicate), `#679` MCP TLS reuse -(ADR 0050), `#695` omit an empty tool catalog. Still parked below. +(ADR 0050), `#695` omit an empty tool catalog, `#677` `/teleport` + +`/snapshot gc` (ADR 0051), `#675` DeepSeek thinking-off / lean 15s bash +(ADR 0052–0055). Still parked below. | Issue | In this repo today | This cut | | --- | --- | --- | diff --git a/graff-evals/README.md b/graff-evals/README.md index e27b6acd..a287a166 100644 --- a/graff-evals/README.md +++ b/graff-evals/README.md @@ -32,6 +32,8 @@ harness under test spends model calls. CODEGRAFF_API_KEY=cg_sk_… ./run.py --suite swe --harness graff-dev,pi-codegraff --model glm-5.3-flash -j 6 # same seat, other models (ADR 0047): deepseek-v4-flash, gemini-3.7-flash, kimi-k2.6 CODEGRAFF_API_KEY=cg_sk_… ./run.py --suite swe --harness graff-dev,pi-codegraff --model deepseek-v4-flash -j 6 +# same seat, OpenCode vs graff (ADR 0053; needs `opencode` on PATH): +CODEGRAFF_API_KEY=cg_sk_… ./run.py --suite swe --harness graff-dev,opencode-codegraff --model deepseek-v4-flash -j 6 # multi-harness in-house PR suite (OpenCode needs --dir; see harnesses.json): ./run.py --suite inhouse --harness graff-dev,grok,opencode --model grok-4.6 -j 1 ``` diff --git a/graff-evals/harnesses.json b/graff-evals/harnesses.json index b17c0caa..93575b24 100644 --- a/graff-evals/harnesses.json +++ b/graff-evals/harnesses.json @@ -149,6 +149,14 @@ "default_model": "gemini-3.7-flash", "note": "uses ~/.pi/agent/models.json provider codegraff → gateway.codegraff.com; Gemini tool loops need the codegraff-gemini-echo extension" }, + "opencode-codegraff": { + "cmd": ["{repo}/graff-evals/opencode-codegraff.sh", "-m", "codegraff/{model}", "--dir", "{sandbox}", "{prompt}"], + "answer": "opencode-json", + "usage": "opencode-json", + "capabilities": [], + "default_model": "deepseek-v4-flash", + "note": "OpenCode on the Codegraff gateway (same CODEGRAFF_API_KEY seat as graff-dev / pi-codegraff). Wrapper needs `opencode` on PATH (~/.opencode/bin)." + }, "pi-xai": { "cmd": ["{repo}/graff-evals/pi-xai.sh", "-p", "--mode", "json", "--provider", "xai", "--model", "{model}", "--no-session", "{prompt}"], "answer": "pi-json", diff --git a/graff-evals/opencode-codegraff.json b/graff-evals/opencode-codegraff.json new file mode 100644 index 00000000..bb4dd642 --- /dev/null +++ b/graff-evals/opencode-codegraff.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://opencode.ai/config.json", + "model": "codegraff/deepseek-v4-flash", + "provider": { + "codegraff": { + "npm": "@ai-sdk/openai-compatible", + "name": "Codegraff", + "options": { + "baseURL": "https://gateway.codegraff.com/v1", + "apiKey": "{env:CODEGRAFF_API_KEY}", + "headers": { + "User-Agent": "Mozilla/5.0 (compatible; graff-evals/1.0)" + } + }, + "models": { + "deepseek-v4-flash": { + "name": "DeepSeek V4 Flash" + }, + "glm-5.3-flash": { + "name": "GLM 5.3 Flash" + }, + "gemini-3.7-flash": { + "name": "Gemini 3.7 Flash" + }, + "kimi-k2.6": { + "name": "Kimi K2.6" + } + } + } + } +} diff --git a/graff-evals/opencode-codegraff.sh b/graff-evals/opencode-codegraff.sh new file mode 100755 index 00000000..e54094d0 --- /dev/null +++ b/graff-evals/opencode-codegraff.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# Drive OpenCode on the Codegraff gateway (same seat as graff-dev / pi-codegraff). +set -e +root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +export PATH="${HOME}/.opencode/bin:${PATH}" +export OPENCODE_CONFIG="${OPENCODE_CONFIG:-$root/graff-evals/opencode-codegraff.json}" +export OPENCODE_DISABLE_AUTOUPDATE=1 +export OPENCODE_DISABLE_DEFAULT_PLUGINS=1 +if [ -z "${CODEGRAFF_API_KEY:-}" ]; then + echo "opencode-codegraff: CODEGRAFF_API_KEY is unset" >&2 + exit 127 +fi +if ! command -v opencode >/dev/null 2>&1; then + echo "opencode-codegraff: opencode not found (https://opencode.ai/docs)" >&2 + exit 127 +fi +exec opencode run --auto --format json --pure "$@" diff --git a/scripts/eval/tier1-manifest.json b/scripts/eval/tier1-manifest.json index 4ad0369e..84dd57a9 100644 --- a/scripts/eval/tier1-manifest.json +++ b/scripts/eval/tier1-manifest.json @@ -15,7 +15,7 @@ "src/repl.zig", "TUI/root.zig" ], - "test_count_baseline": 1849, + "test_count_baseline": 1861, "test_count_slack": 25, "required_invariants": [ { diff --git a/src/agent_empty_completion.zig b/src/agent_empty_completion.zig index 987b4402..8e5743f4 100644 --- a/src/agent_empty_completion.zig +++ b/src/agent_empty_completion.zig @@ -1,14 +1,18 @@ -//! Degenerate-completion policy for Agent.runTurn. A provider completion can -//! arrive with no text, no tool calls, and no non-"stop" finish reason — -//! observed live on the chat wire as `content: null`, no `tool_calls` -//! (mid-task, right after a successful write_file). The step functions then -//! return "" and the turn ends silently: the session sits there looking dead. -//! Instead, rewind the history the step function just appended and re-ask, -//! bounded per turn so a wedged provider can't spin up unbounded spend. +//! Degenerate-completion policy for Agent.runTurn. +//! +//! 1. Empty / whitespace-only completions (no tool calls) used to end the +//! turn silently. Rewind and re-ask, bounded. +//! 2. Lean `-p` text-only first completions (DeepSeek flash SWE): the model +//! describes a patch and stops. That is not done — bounce once with a +//! user note (ADR 0052). Do not steal Pi's four-tool catalog (ADR 0024 / 0047). +//! //! Split from agent.zig (600-line goal); wired only in runTurn. const std = @import("std"); const Agent = @import("agent.zig").Agent; +const main_mod = @import("main.zig"); +const no_local_tools = @import("no_local_tools.zig"); +const messages = @import("messages.zig"); /// Retries allowed per turn for consecutive degenerate completions. pub const max_consecutive: u8 = 2; @@ -21,17 +25,30 @@ pub fn shouldRetry(final_text: []const u8, retries: u8) bool { return std.mem.trim(u8, final_text, " \t\r\n").len == 0; } -/// Handle a degenerate completion inside runTurn: spend one bounded retry by -/// rewinding the history the step function just appended (clamped — an -/// in-request compaction may already have shrunk it) and re-opening the WS -/// chain, whose watermark is keyed to the dropped messages. Returns true when -/// the caller should `continue` the loop. +/// Lean `-p` described a fix and never called a tool. One bounce. +pub const bounce_note = "You described a change but did not call any tool. Inspect and edit the files with read_file / edit_file / write_file; do not claim the tree is already updated."; + +pub fn shouldBounce(unattended: bool, lean: bool, text_only: bool, review: bool, sub: bool, tool_calls: u64, model_calls: u64, final_text: []const u8) bool { + if (!unattended or !lean or text_only or review or sub) return false; + if (tool_calls != 0 or model_calls != 1) return false; + return std.mem.trim(u8, final_text, " \t\r\n").len > 0; +} + +/// Handle a degenerate completion inside runTurn. Returns true when the +/// caller should `continue` the loop. pub fn handle(self: *Agent, final_text: []const u8, hist_len: usize) !bool { - if (!shouldRetry(final_text, self.empty_completion_retries)) return false; - self.empty_completion_retries += 1; - self.closeCodexWs(); - self.messages.shrinkRetainingCapacity(@min(hist_len, self.messages.items.len)); - try self.say("[model returned an empty completion — retrying ({d}/{d})]\n", .{ self.empty_completion_retries, max_consecutive }); + if (shouldRetry(final_text, self.empty_completion_retries)) { + self.empty_completion_retries += 1; + self.closeCodexWs(); + self.messages.shrinkRetainingCapacity(@min(hist_len, self.messages.items.len)); + try self.say("[model returned an empty completion — retrying ({d}/{d})]\n", .{ self.empty_completion_retries, max_consecutive }); + return true; + } + if (!shouldBounce(main_mod.unattended, no_local_tools.lean, self.text_only, self.review_mode, self.sub, self.tool_calls_this_turn, self.model_calls_this_turn, final_text)) + return false; + try self.messages.append(try messages.textMessage(self.arena, "user", bounce_note)); + try self.say("[described a change with no tool call — asking once more]\n", .{}); + if (self.tracer) |tr| tr.note("fake_done", "lean -p text-only; bounced"); return true; } @@ -47,3 +64,20 @@ test "whitespace-only completions are degenerate, real text never is" { try std.testing.expect(!shouldRetry("done", 0)); try std.testing.expect(!shouldRetry("<|eos|>", 0)); } + +test "lean -p text-only first completion bounces once" { + try std.testing.expect(shouldBounce(true, true, false, false, false, 0, 1, "I fixed validated.py")); + try std.testing.expect(!shouldBounce(true, true, false, false, false, 0, 2, "I fixed validated.py")); + try std.testing.expect(!shouldBounce(true, true, false, false, false, 1, 1, "I fixed validated.py")); + try std.testing.expect(!shouldBounce(false, true, false, false, false, 0, 1, "I fixed validated.py")); + try std.testing.expect(!shouldBounce(true, false, false, false, false, 0, 1, "I fixed validated.py")); + try std.testing.expect(!shouldBounce(true, true, true, false, false, 0, 1, "I fixed validated.py")); + try std.testing.expect(!shouldBounce(true, true, false, true, false, 0, 1, "I fixed validated.py")); + try std.testing.expect(!shouldBounce(true, true, false, false, true, 0, 1, "I fixed validated.py")); + try std.testing.expect(!shouldBounce(true, true, false, false, false, 0, 1, " ")); +} + +test "bounce note names the file tools" { + try std.testing.expect(std.mem.indexOf(u8, bounce_note, "edit_file") != null); + try std.testing.expect(std.mem.indexOf(u8, bounce_note, "write_file") != null); +} diff --git a/src/agent_gateway_retry.zig b/src/agent_gateway_retry.zig index a6ab0f04..71eb32ae 100644 --- a/src/agent_gateway_retry.zig +++ b/src/agent_gateway_retry.zig @@ -10,6 +10,12 @@ //! history it stays a fail-fast 400, exactly as before. //! 2. Retry trace notes carry the agent label, so a 5xx can be attributed to //! the subagent that drew it without ms-arithmetic across api spans. +//! 3. A short generic `api_error` / "Internal Server Error" / empty envelope +//! (the DeepSeek flash `-j 6` follow-up flake) is retried bounded. The +//! same 110-byte / ~450ms follow-up also arrives as `invalid_request_error` +//! / "Body must be valid JSON" (our stringify just succeeded on call 1). +//! That phrase is a flake; auth / quota / a real invalid prompt stay +//! fail-fast. const std = @import("std"); const Agent = @import("agent.zig").Agent; @@ -72,14 +78,70 @@ pub fn noteFlake(self: *Agent, state: *GatewayRetryState, err: anyerror) void { if (err == error.Timeout) state.transport_timeouts += 1; } -/// One gate for the envelope-fatal paths: the pre-existing transient -/// server-overload retry first (unchanged semantics), then the bounded -/// gateway-artifact retry when this request already endured >=2 timeouts. +/// One gate for the envelope-fatal paths: overload first, then a short +/// Codegraff follow-up flake (ADR 0053 DeepSeek `-j 6` 110-byte / ~450ms) +/// `api_error`), then the timeout-gated body-parse retry. pub fn afterServerErrorOrParseReject(self: *Agent, etype: []const u8, code: ?[]const u8, msg: []const u8, server_retries: *usize, state: *GatewayRetryState) !bool { if (try policy.retryTransientServerError(self, etype, code, msg, server_retries)) return true; + if (try retryShortGatewayFlake(self, etype, code, msg, server_retries)) return true; return retryBodyParseAfterTimeouts(self, msg, state); } +pub const max_short_flake_retries: usize = 2; + +/// Tiny / generic envelopes that are not a real client bug. The DeepSeek +/// SWE `-j 6` follow-up was ~110 bytes, ~450 ms, `is_error`, no +/// "overloaded" / "server_error" needle — isolated serial retry passed. +/// Do not treat invalid_request / auth / quota as a flake. +pub fn isShortGatewayFlake(etype: []const u8, code: ?[]const u8, msg: []const u8) bool { + // Gateway 110-byte follow-up. etype is often invalid_request_error, which + // would otherwise hard-fail on the "invalid" needle. Auth/quota still die. + if (isBodyParseRejection(msg)) return true; + const hard = [_][]const u8{ "invalid", "authentication", "unauthorized", "insufficient", "quota", "permission", "tool_choice", "not found" }; + for (hard) |n| { + if (util.indexOfIgnoreCase(etype, n) != null) return false; + if (util.indexOfIgnoreCase(msg, n) != null) return false; + if (code) |c| if (util.indexOfIgnoreCase(c, n) != null) return false; + } + const flakes = [_][]const u8{ "internal", "try again", "temporarily", "unavailable", "bad gateway", "upstream", "capacity", "unknown error", "something went wrong", "an error occurred" }; + for (flakes) |n| { + if (util.indexOfIgnoreCase(etype, n) != null) return true; + if (util.indexOfIgnoreCase(msg, n) != null) return true; + if (code) |c| if (util.indexOfIgnoreCase(c, n) != null) return true; + } + const generic = etype.len == 0 or std.mem.eql(u8, etype, "error") or std.mem.eql(u8, etype, "api_error"); + return generic and std.mem.trim(u8, msg, " \t\r\n").len == 0; +} + +/// Unparseable body that is keep-alive comments or a tiny truncated +/// payload (the 110-byte follow-up). Bounded. Real JSON error envelopes +/// do not reach this — they go through `afterServerErrorOrParseReject`. +pub fn retryDegenerateBody(self: *Agent, body: []const u8, retries: *usize) !bool { + const tiny = body.len > 0 and body.len <= 256; + if (!policy.sseKeepAliveOnly(body) and !tiny) return false; + if (retries.* >= max_short_flake_retries) return false; + retries.* += 1; + self.partial_text.clearRetainingCapacity(); + const delay_ms = RetryPlan.delayMs(true, retries.* - 1); + const what: []const u8 = if (policy.sseKeepAliveOnly(body)) "keep-alive only, no tokens" else "truncated gateway body"; + try self.say("[provider queued the request ({s}) — retrying in {d}s ({d}/{d})]\n", .{ what, delay_ms / 1000, retries.*, max_short_flake_retries }); + if (self.tracer) |tr| tr.note("retry", what); + self.sleepInterruptible(delay_ms) catch return error.Interrupted; + return true; +} + +fn retryShortGatewayFlake(self: *Agent, etype: []const u8, code: ?[]const u8, msg: []const u8, retries: *usize) !bool { + if (!isShortGatewayFlake(etype, code, msg)) return false; + if (retries.* >= max_short_flake_retries) return false; + retries.* += 1; + self.partial_text.clearRetainingCapacity(); + const delay_ms = RetryPlan.delayMs(true, retries.* - 1); + try self.say("[gateway flake — retrying in {d}s ({d}/{d})]\n", .{ delay_ms / 1000, retries.*, max_short_flake_retries }); + if (self.tracer) |tr| tr.note("retry", "short gateway flake"); + self.sleepInterruptible(delay_ms) catch return error.Interrupted; + return true; +} + /// The gate on the Agent: announce, trace, back off (1·2s — the gateway just /// answered, give it a beat), clear partial text for a fresh re-stream, and /// tell the caller to `continue`. Esc during the backoff still propagates. @@ -117,3 +179,19 @@ test "shouldRetryBodyParseAfterTimeouts (#gateway-artifact): timeout history gat // an unrelated message never retries, however many timeouts preceded it try std.testing.expect(!shouldRetryBodyParseAfterTimeouts("quota exceeded", 5, 0)); } + +test "isShortGatewayFlake: internal/empty api_error retry; invalid/auth/quota do not" { + try std.testing.expect(isShortGatewayFlake("api_error", null, "Internal Server Error")); + try std.testing.expect(isShortGatewayFlake("api_error", null, "")); + try std.testing.expect(isShortGatewayFlake("error", null, " ")); + try std.testing.expect(isShortGatewayFlake("", null, "unknown error")); + try std.testing.expect(isShortGatewayFlake("", null, "upstream connect error")); + try std.testing.expect(!isShortGatewayFlake("invalid_request_error", null, "invalid prompt")); + try std.testing.expect(!isShortGatewayFlake("api_error", null, "invalid tool_choice")); + try std.testing.expect(!isShortGatewayFlake("authentication_error", null, "invalid api key")); + try std.testing.expect(!isShortGatewayFlake("insufficient_quota", null, "You exceeded your current quota")); + try std.testing.expect(!isShortGatewayFlake("api_error", null, "model not found")); + try std.testing.expect(isShortGatewayFlake("invalid_request_error", null, "Body must be valid JSON")); + try std.testing.expect(isShortGatewayFlake("api_error", null, "Malformed JSON in request body")); + try std.testing.expect(!isShortGatewayFlake("invalid_request_error", null, "invalid prompt")); +} diff --git a/src/agent_request_body.zig b/src/agent_request_body.zig index de282796..091d837c 100644 --- a/src/agent_request_body.zig +++ b/src/agent_request_body.zig @@ -176,7 +176,7 @@ pub fn buildBody(self: *Agent, tools_in: ?[]const u8, force_tool: bool, stream: try s.objectField("prompt_cache_key"); try s.write(http_headers.requestCacheKey(self.io, self.label, self, self.provider.id, &ckbuf)); // reasoning_effort (codegraff/deepseek/zai) + Z.AI thinking + Vercel reasoning.effort. - try @import("zai_wire.zig").writeChatExtras(&s, self.provider.id, self.sendReasoningEffort(), @import("effort_route.zig").wireEffort(self.provider.model, @tagName(self.reasoning))); + try @import("zai_wire.zig").writeChatExtras(&s, self.provider.id, self.provider.model, self.sendReasoningEffort(), @import("effort_route.zig").wireEffort(self.provider.model, @tagName(self.reasoning))); // --output-schema: structured outputs (xAI docs' response_format). // A provider that rejected json_schema (#543, deepseek) degrades // dsh-style: the tools-off formatting turn carries the schema as a diff --git a/src/agent_request_scratch.zig b/src/agent_request_scratch.zig index c1eb1c39..0f0ceb98 100644 --- a/src/agent_request_scratch.zig +++ b/src/agent_request_scratch.zig @@ -6,13 +6,8 @@ const std = @import("std"); const Agent = @import("agent.zig").Agent; -const policy = @import("agent_request_policy.zig"); -const http = @import("http.zig"); -const RetryPlan = http.RetryPlan; const run_budget_mod = @import("run_budget.zig"); -const max_server_retries: usize = 3; - /// Keep the normal request hot path allocation-free while avoiding a permanent /// RSS high-water mark after one anomalously large stream. Small scratch arenas /// retain their pages for the next request; large ones return all pages to the @@ -38,14 +33,7 @@ pub fn showRecoveredTransportRetry(kind: run_budget_mod.CallKind) bool { /// means the gateway queued us and never produced tokens — back off and re-ask /// like a 5xx instead of dying on an "unparseable" JSON parse. pub fn retryKeepAliveOnly(self: *Agent, body: []const u8, retries: *usize) !bool { - if (!policy.sseKeepAliveOnly(body)) return false; - if (retries.* >= max_server_retries) return false; - retries.* += 1; - self.partial_text.clearRetainingCapacity(); - const delay_ms = RetryPlan.delayMs(true, retries.* - 1); // 1·2·4s - try self.say("[provider queued the request (keep-alive only, no tokens) — retrying in {d}s ({d}/{d})]\n", .{ delay_ms / 1000, retries.*, max_server_retries }); - self.sleepInterruptible(delay_ms) catch return error.Interrupted; - return true; + return @import("agent_gateway_retry.zig").retryDegenerateBody(self, body, retries); } test "recovered recap transport retries stay out of normal REPL output" { diff --git a/src/cli.zig b/src/cli.zig index 39bf3d02..0dcb2da9 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -27,6 +27,8 @@ pub const changelog_text = \\ • request-construction TLS failure rotates a leased HTTP client generation (ADR 0048) \\ • --resume SOURCE --branch DEST is clone-on-write (ADR 0049) \\ • MCP HTTP/WSS reuse warmed TLS; catalogs can arrive gzip (ADR 0050) + \\ • /teleport + /snapshot gc (ADR 0051); DeepSeek thinking off at low (ADR 0054) + \\ • lean -p bounces prose-only done (ADR 0052) and backgrounds bash at 15s (ADR 0055) \\ \\0.0.281 \\ • -p / --json skip learn auto-init — no 132M graff-pinned copy (ADR 0044) diff --git a/src/command_catalog.zig b/src/command_catalog.zig index fbc4b14a..41fb9236 100644 --- a/src/command_catalog.zig +++ b/src/command_catalog.zig @@ -59,7 +59,8 @@ pub const commands = [_]Item{ .{ .name = "/btw", .usage = "/btw ", .desc = "ask one side question about this conversation — billed, never added, rides the parent cache prefix" }, .{ .name = "/compact", .desc = "compact history into a fresh context (OpenAI server-side when available)" }, .{ .name = "/rewind", .usage = "/rewind [n|]", .desc = "list past prompts; /rewind drops prompt n+after & reverts its file edits; /rewind restores a sandbox filesystem instead, never the conversation" }, - .{ .name = "/snapshot", .usage = "/snapshot [attach [image]|detach|list]", .desc = "capture the attached sandbox's filesystem under .graff/sessions//snapshots; attach puts this session in a Docker sandbox, list shows what it has captured" }, + .{ .name = "/snapshot", .usage = "/snapshot [attach [image]|detach|list|gc [n]]", .desc = "capture the attached sandbox's filesystem under .graff/sessions//snapshots; attach puts this session in a Docker sandbox, list shows what it has captured, gc keeps the newest n (default 1)" }, + .{ .name = "/teleport", .usage = "/teleport [docker|container]", .desc = "restore a docker_image_tar snapshot onto a different CLI backend (Apple Container by default); filesystem only, never the conversation" }, .{ .name = "/image", .usage = "/image ", .desc = "attach an image to your next message (vision models only)" }, .{ .name = "/images", .desc = "open image URLs from the last response (e.g. issue attachments) in your browser" }, .{ .name = "/paste", .desc = "attach the clipboard image — macOS; also Ctrl-V (⌘V can't be captured)" }, diff --git a/src/commands_sandbox.zig b/src/commands_sandbox.zig index 04f6a342..a386abcd 100644 --- a/src/commands_sandbox.zig +++ b/src/commands_sandbox.zig @@ -1,6 +1,7 @@ //! #554's REPL surface: `/snapshot` captures the active sandbox's filesystem //! into `.graff/sessions//snapshots//`, `/rewind ` brings one -//! back up. +//! back up, `/teleport ` restores the same tar on a different CLI backend, +//! and `/snapshot gc` drops old snapshot trees. //! //! `/rewind` was already taken by the CONVERSATION rewind (commands_model.zig, //! `/rewind `), and the two are deliberately the same word: they are the @@ -32,10 +33,11 @@ const sandbox = @import("sandbox.zig"); const sandbox_docker = @import("sandbox_docker.zig"); const util = @import("util.zig"); -/// The process's Docker backend, once `/snapshot attach` has built one. A -/// live `Handle` points into a Sandbox that points back here, so this outlives -/// any one command — the same process-wide shape as sandbox.active(). +/// Process-wide CLI backends. A live `Handle` points into a Sandbox that +/// points back here, so these outlive any one command — the same shape as +/// sandbox.active(). `g_container` is the Apple Container slot teleport uses. var g_docker: sandbox_docker.Docker = undefined; +var g_container: sandbox_docker.Docker = undefined; /// The sentence every rewind output carries. A user who believed the /// transcript had moved too would read it as the record of a run that never @@ -56,15 +58,16 @@ pub fn explain(err: anyerror) []const u8 { error.SandboxUnavailable => "the sandbox backend is not usable here — `docker` is not on PATH, or the daemon refused the command", error.SnapshotUnsupported => "this backend cannot capture state (the local backend runs without isolation, so there is nothing to snapshot) — attach a Docker sandbox instead", error.SnapshotFailed => "the capture failed — `docker commit`/`docker save` did not succeed", - error.RestoreFailed => "the restore failed — `docker load`/`docker run` did not succeed, and the previous sandbox was already released; nothing is attached now, so `/snapshot attach` starts a fresh one", + error.RestoreFailed => "the restore failed — `load`/`run` did not succeed, and the previous sandbox was already released; nothing is attached now, so `/snapshot attach` starts a fresh one", error.ExecFailed => "the sandbox command could not be run", else => "the sandbox operation failed", }; } /// `/snapshot` (capture), `/snapshot attach [image]`, `/snapshot detach`, -/// `/snapshot list`, and `/rewind `. Returns false for anything else, -/// including `/rewind` with a numeric or absent argument. +/// `/snapshot list`, `/snapshot gc [n]`, `/teleport [backend]`, and +/// `/rewind `. Returns false for anything else, including `/rewind` with +/// a numeric or absent argument. pub fn tryHandle(root: *Agent, arena: Allocator, line: []const u8, out: *Io.Writer) !bool { if (argOf(line, "/snapshot")) |arg| { if (std.mem.eql(u8, arg, "list") or std.mem.eql(u8, arg, "ls")) { @@ -73,12 +76,19 @@ pub fn tryHandle(root: *Agent, arena: Allocator, line: []const u8, out: *Io.Writ try attach(root, image, out); } else if (std.mem.eql(u8, arg, "detach")) { try detach(out); + } else if (argOf(arg, "gc") != null or std.mem.eql(u8, arg, "gc")) { + try collect(root, arena, if (argOf(arg, "gc")) |n| n else "", out); } else { try capture(root, arena, out); } try out.flush(); return true; } + if (argOf(line, "/teleport")) |arg| { + try teleport(root, arena, arg, out); + try out.flush(); + return true; + } if (argOf(line, "/rewind")) |arg| { if (!isSnapshotArg(arg)) return false; // the conversation rewind owns it try restore(root, arena, arg, out); @@ -88,6 +98,14 @@ pub fn tryHandle(root: *Agent, arena: Allocator, line: []const u8, out: *Io.Writ return false; } +/// Named CLI backend. `docker` is the default attach; `container` is Apple +/// Container — same wire, different binary. Unknown names stay null so the +/// command can explain instead of guessing. +pub fn backendNamed(name: []const u8) ?[]const u8 { + if (std.mem.eql(u8, name, "docker") or std.mem.eql(u8, name, "container")) return name; + return null; +} + /// The argument after `cmd`, or null when `line` is a different command. /// `/rewinds` is not `/rewind`: the next byte has to be a space or the end. fn argOf(line: []const u8, cmd: []const u8) ?[]const u8 { @@ -109,11 +127,7 @@ fn attach(root: *Agent, image: []const u8, out: *Io.Writer) !void { try out.print("a sandbox is already attached ({s}) — /snapshot detach first\n", .{prev.handle.id}); return; } - g_docker = .{ - .io = root.io, - .path_env = main_mod.g_path_env, - .image = if (image.len > 0) image else sandbox_docker.default_image, - }; + g_docker = fillCli(root, "docker", image); const backend = g_docker.backend(); if (!backend.available()) { try out.print("docker backend: {s}\n", .{explain(error.SandboxUnavailable)}); @@ -211,6 +225,97 @@ fn restore(root: *Agent, arena: Allocator, id: []const u8, out: *Io.Writer) !voi try out.print("{s} {s}; processes inside the sandbox were relaunched, not resumed{s}\n", .{ style.dim, log_note, style.reset }); } +fn fillCli(root: *Agent, bin_name: []const u8, image: []const u8) sandbox_docker.Docker { + return .{ + .io = root.io, + .path_env = main_mod.g_path_env, + .image = if (image.len > 0) image else sandbox_docker.default_image, + .bin_name = bin_name, + }; +} + +fn slot(bin_name: []const u8) *sandbox_docker.Docker { + return if (std.mem.eql(u8, bin_name, "container")) &g_container else &g_docker; +} + +/// `/snapshot gc [n]` — keep the newest n snapshots (default 1). The exo miss +/// #554 named: leftover tars pile up unless something deletes them. +fn collect(root: *Agent, arena: Allocator, keep_arg: []const u8, out: *Io.Writer) !void { + if (!sandbox.safeName(root.session_name)) { + try out.writeAll("this session has no name a snapshot directory could be built from\n"); + return; + } + const keep: usize = if (keep_arg.len == 0) 1 else std.fmt.parseInt(usize, keep_arg, 10) catch { + try out.print("usage: /snapshot gc [n] — n is how many newest snapshots to keep (default 1)\n", .{}); + return; + }; + const result = try sandbox.gc(root.io, sandbox.workspace(), arena, root.session_name, keep); + if (result.removed == 0) { + try out.print("snapshot gc: nothing to drop — {d} kept\n", .{result.kept}); + return; + } + try out.print("snapshot gc: dropped {d} ({d} bytes), kept {d}\n", .{ result.removed, result.bytes, result.kept }); +} + +/// Restore `id` onto a backend other than the one that captured it. Dest +/// defaults to `container` when the live backend is docker (or nothing is +/// attached), and `docker` when the live backend is already container. +fn teleport(root: *Agent, arena: Allocator, arg: []const u8, out: *Io.Writer) !void { + var it = std.mem.tokenizeAny(u8, arg, " \t"); + const id = it.next() orelse { + try out.writeAll("usage: /teleport [docker|container]\n"); + return; + }; + const dest_arg = it.next() orelse ""; + const dest = if (dest_arg.len == 0) + defaultDest() + else + backendNamed(dest_arg) orelse { + try out.print("unknown backend {s} — teleport dest is docker or container\n", .{dest_arg}); + return; + }; + try restoreOnto(root, arena, id, dest, out); +} + +fn defaultDest() []const u8 { + const act = sandbox.active() orelse return "container"; + return if (std.mem.eql(u8, act.backend.name(), "container")) "docker" else "container"; +} + +fn restoreOnto(root: *Agent, arena: Allocator, id: []const u8, dest_name: []const u8, out: *Io.Writer) !void { + const found = sandbox.find(root.io, sandbox.workspace(), arena, root.session_name, id) orelse { + try out.print("no snapshot {s} in this session — {s}/snapshot list{s} shows what there is\n", .{ id, style.accent, style.reset }); + return; + }; + const dest_slot = slot(dest_name); + dest_slot.* = fillCli(root, dest_name, ""); + const dest = dest_slot.backend(); + if (!dest.available()) { + try out.print("{s} backend: {s}\n", .{ dest.name(), explain(error.SandboxUnavailable) }); + return; + } + const blob: sandbox.Blob = .{ + .io = root.io, + .dir = sandbox.workspace(), + .rel = try sandbox.payloadPath(arena, root.session_name, found.id), + }; + const key = if (sandbox.active()) |act| + try arena.dupe(u8, act.handle.key) + else + try arena.dupe(u8, root.session_name); + if (sandbox.active()) |act| { + act.handle.release(); + sandbox.detach(); + } + const fresh = dest.acquireFromSnapshot(root.gpa, key, found.payload(), blob) catch |err| { + try out.print("teleport failed — {s}\n", .{explain(err)}); + return; + }; + sandbox.attach(.{ .backend = dest, .handle = fresh }); + try out.print("teleported {s}{s}{s} onto {s} ({d} bytes)\n", .{ style.accent, found.id, style.reset, dest.name(), found.len }); + try out.print("{s} {s}; processes inside the sandbox were relaunched, not resumed{s}\n", .{ style.dim, log_note, style.reset }); +} + fn renderList(root: *Agent, arena: Allocator, out: *Io.Writer) !void { const items = try sandbox.list(root.io, sandbox.workspace(), arena, root.session_name); if (items.len == 0) { @@ -221,7 +326,7 @@ fn renderList(root: *Agent, arena: Allocator, out: *Io.Writer) !void { for (items) |m| { try out.print(" {s}{s}{s} {s:<8} {d:>10} B {s}\n", .{ style.accent, m.id, style.reset, m.backend, m.len, m.ref }); } - try out.print("{s}usage: /rewind — restores that filesystem; {s}{s}\n", .{ style.dim, log_note, style.reset }); + try out.print("{s}usage: /rewind restores here; /teleport [docker|container] restores onto another backend; /snapshot gc drops old tars; {s}{s}\n", .{ style.dim, log_note, style.reset }); } test "a numeric /rewind argument stays with the conversation rewind" { @@ -238,6 +343,14 @@ test "argOf claims only the exact command" { try std.testing.expect(argOf("/snapshots", "/snapshot") == null); try std.testing.expect(argOf("/rewinds 3", "/rewind") == null); try std.testing.expectEqualStrings("3", argOf("/rewind 3", "/rewind").?); + try std.testing.expectEqualStrings("id container", argOf("/teleport id container", "/teleport").?); +} + +test "teleport dest names are docker or container" { + try std.testing.expectEqualStrings("docker", backendNamed("docker").?); + try std.testing.expectEqualStrings("container", backendNamed("container").?); + try std.testing.expect(backendNamed("daytona") == null); + try std.testing.expect(backendNamed("") == null); } test "every sandbox failure has an explanation, and rewind never claims the log moved" { diff --git a/src/effort_route.zig b/src/effort_route.zig index 48bfa346..336a36b9 100644 --- a/src/effort_route.zig +++ b/src/effort_route.zig @@ -61,7 +61,9 @@ pub fn omitsDefaultFlashEffort(model: []const u8) bool { /// Wire `reasoning_effort` for an effort-capable provider. Flash / Gemini /// default `medium` becomes `low`: omit still thinks (27 reasoning_tokens -/// on a `pong`); `low` bills 0. `none` is a 400. `/effort high` unchanged. +/// on a `pong`); `low` bills 0 on GLM. DeepSeek V4 still thinks at `low` +/// until `thinking.type=disabled` (ADR 0054). `none` is a 400. `/effort high` +/// unchanged. pub fn wireEffort(model: []const u8, requested: []const u8) []const u8 { if (std.mem.eql(u8, requested, "medium") and omitsDefaultFlashEffort(model)) return "low"; if (std.mem.eql(u8, requested, "ultra")) return "max"; diff --git a/src/exec_bash.zig b/src/exec_bash.zig index 50486731..d2256c1c 100644 --- a/src/exec_bash.zig +++ b/src/exec_bash.zig @@ -27,6 +27,10 @@ const exec_bash_stream = @import("exec_bash_stream.zig"); /// the job registry instead of being killed (xai-org/grok-build BashTool). pub const root_wait_ms: u64 = 120 * 1000; +/// Lean `-p` has no human watching a hung test (ADR 0055). DeepSeek flash +/// SWE sat 120s on `python3 test_json_stream.py`. Interactive stays 120s. +pub const lean_oneshot_wait_ms: u64 = 15 * 1000; + /// Wall-clock ceiling for one *subagent* bash command. Subagents run on pool /// threads with no TTY, so there is no Esc to kill a runaway command — without /// this, a codedb refusal that pushes a subagent onto an unfiltered `grep ~/` @@ -81,9 +85,15 @@ fn startedText(gpa: Allocator, id: u32, cmd: []const u8, ssh: bool, auto_bg: boo return aw.toOwnedSlice(); } +fn defaultRootWaitMs() u64 { + const main_mod = @import("main.zig"); + if (main_mod.unattended and @import("no_local_tools.zig").lean) return lean_oneshot_wait_ms; + return root_wait_ms; +} + fn rootWaitMs(input: Value) u64 { - const t = intField(input, "timeout") orelse return root_wait_ms; - if (t <= 0) return root_wait_ms; + const t = intField(input, "timeout") orelse return defaultRootWaitMs(); + if (t <= 0) return defaultRootWaitMs(); return @min(@as(u64, @intCast(t)), job_wait.wait_cap_ms); } @@ -102,6 +112,27 @@ test "rootWaitMs: omitted/zero use 120s; positive values clamp to the 10h cap" { try std.testing.expectEqual(job_wait.wait_cap_ms, rootWaitMs(huge.value)); } +test "rootWaitMs: lean unattended oneshot defaults to 15s; timeout still wins" { + const main_mod = @import("main.zig"); + const nlt = @import("no_local_tools.zig"); + const saved_u = main_mod.unattended; + const saved_l = nlt.lean; + defer { + main_mod.unattended = saved_u; + nlt.lean = saved_l; + } + main_mod.unattended = true; + nlt.lean = true; + const empty = try std.json.parseFromSlice(Value, std.testing.allocator, "{}", .{}); + defer empty.deinit(); + try std.testing.expectEqual(lean_oneshot_wait_ms, rootWaitMs(empty.value)); + const custom = try std.json.parseFromSlice(Value, std.testing.allocator, "{\"timeout\":5000}", .{}); + defer custom.deinit(); + try std.testing.expectEqual(@as(u64, 5_000), rootWaitMs(custom.value)); + main_mod.unattended = false; + try std.testing.expectEqual(root_wait_ms, rootWaitMs(empty.value)); +} + fn formatJobDone(gpa: Allocator, cmd: []const u8, wait: jobs.FgDone) !ToolOutput { defer gpa.free(wait.output); var aw: Io.Writer.Allocating = .init(gpa); diff --git a/src/help.zig b/src/help.zig index 75dd6d0f..037430ab 100644 --- a/src/help.zig +++ b/src/help.zig @@ -28,7 +28,7 @@ const peers_blurb = ; pub const sections = [_]Section{ - .{ .title = "getting around", .names = &.{ "/new", "/clear", "/resume", "/save", "/sessions", "/workspace", "/experiment", "/rename", "/rewind", "/snapshot" } }, + .{ .title = "getting around", .names = &.{ "/new", "/clear", "/resume", "/save", "/sessions", "/workspace", "/experiment", "/rename", "/rewind", "/snapshot", "/teleport" } }, .{ .title = "the model", .names = &.{ "/model", "/models", "/effort", "/reasoning", "/fast", "/thinking", "/keepcontext", "/fallback", "/routes" } }, .{ .title = "working autonomously", .names = &.{ "/goal", "/loop", "/schedule", "/review", "/plan", "/todo", "/jobs", "/ultracode", "/strict", "/yolo", "/never" } }, .{ .title = "talking to other graffs", .names = &.{ "/tell", "/peek", "/adapter" }, .blurb = peers_blurb }, diff --git a/src/no_local_tools.zig b/src/no_local_tools.zig index 071ca8b0..1fcd3dd1 100644 --- a/src/no_local_tools.zig +++ b/src/no_local_tools.zig @@ -125,7 +125,7 @@ pub const lean_subagent_desc = "Spawn a sidecar for a self-contained task. Child /// One-shot tool prose (ADR 0046). Same names and JSON schemas; shorter /// descriptions so flash models start and finish instead of rereading /// essays. Do not drop to four tools (ADR 0024). -pub const lean_bash_desc = "Run /bin/sh -c in cwd. Returns stdout, stderr, exit. Foreground still running after 120s (or timeout ms) backgrounds — bash_output(wait_ms>0) waits; do not poll."; +pub const lean_bash_desc = "Run /bin/sh -c in cwd. Returns stdout, stderr, exit. Foreground still running after 15s (or timeout ms) backgrounds — bash_output(wait_ms>0) waits; do not poll."; pub const lean_read_file_desc = "Read a UTF-8 file. Call before editing. contains=exact-key numbered lines; start_line/end_line for a window."; pub const lean_edit_file_desc = "Replace exact text. Prefer one batched edits[] over many calls. Prefer over write_file for an existing file."; pub const lean_codedb_desc = "Indexed nav: context · around · callpath A B · list_dir · status. Prefer over bash grep/find/ls."; diff --git a/src/prompt_snapshot_tests.zig b/src/prompt_snapshot_tests.zig index 8cabcd94..cdd9ba61 100644 --- a/src/prompt_snapshot_tests.zig +++ b/src/prompt_snapshot_tests.zig @@ -572,6 +572,7 @@ test "lean drops the todo/constraint capabilities from the prompt, never the loc const a = a_state.allocator(); const lean_prompt = try prompts.composeBase(a, lean); try std.testing.expect(lean_prompt.len < prompts.main_system_prompt.len); + try std.testing.expect(std.mem.indexOf(u8, lean_prompt, "described in prose is not done") != null); } test "unattended one-shots are told the REAL approval map up front; attended sessions hear nothing" { diff --git a/src/prompt_text.zig b/src/prompt_text.zig index 543ec15f..1903da88 100644 --- a/src/prompt_text.zig +++ b/src/prompt_text.zig @@ -279,6 +279,7 @@ pub const lean_parallel_note = pub const lean_intro_note = \\You are a coding agent. Use the cataloged tools; never invent one. + \\A file change described in prose is not done — call edit_file or write_file. \\Independent reads belong in ONE response, not one per turn. \\A passing test and attempt_completion belong in ONE response. ; diff --git a/src/provider_codegraff_tests.zig b/src/provider_codegraff_tests.zig index 103fb16a..f26a0f2f 100644 --- a/src/provider_codegraff_tests.zig +++ b/src/provider_codegraff_tests.zig @@ -127,6 +127,7 @@ test "Codegraff Gemini sends low for default effort; /effort high still sends" { const ds = try deepseek.buildBody(null, false, true, true); defer std.testing.allocator.free(ds); try std.testing.expect(std.mem.indexOf(u8, ds, "\"reasoning_effort\":\"medium\"") != null); + try std.testing.expect(std.mem.indexOf(u8, ds, "\"thinking\":{\"type\":\"enabled\"}") != null); } test "Codegraff glm-5.3-flash sends low for default effort; /effort high still sends" { @@ -164,3 +165,48 @@ test "Codegraff glm-5.3-flash sends low for default effort; /effort high still s defer std.testing.allocator.free(high); try std.testing.expect(std.mem.indexOf(u8, high, "\"reasoning_effort\":\"high\"") != null); } + +test "Codegraff DeepSeek flash disables thinking at default low; /effort high still thinks" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var messages = std.json.Array.init(arena); + try messages.append(.{ .object = blk: { + var obj: std.json.ObjectMap = .empty; + try obj.put(arena, "role", .{ .string = "user" }); + try obj.put(arena, "content", .{ .string = "hello" }); + break :blk obj; + } }); + var agent: Agent = .{ + .gpa = std.testing.allocator, + .arena = arena, + .io = std.testing.io, + .client = undefined, + .provider = build("deepseek-v4-flash"), + .messages = messages, + .sub = false, + .label = "main", + .out = null, + .sys_normal = "system", + }; + const low = try agent.buildBody(null, false, true, true); + defer std.testing.allocator.free(low); + try std.testing.expect(std.mem.indexOf(u8, low, "\"thinking\":{\"type\":\"disabled\"}") != null); + try std.testing.expect(std.mem.indexOf(u8, low, "\"reasoning_effort\":\"low\"") != null); + try std.testing.expect(std.mem.indexOf(u8, low, "\"thinking\":{\"type\":\"enabled\"}") == null); + + agent.reasoning = .high; + const high = try agent.buildBody(null, false, true, true); + defer std.testing.allocator.free(high); + try std.testing.expect(std.mem.indexOf(u8, high, "\"thinking\":{\"type\":\"enabled\"}") != null); + try std.testing.expect(std.mem.indexOf(u8, high, "\"reasoning_effort\":\"high\"") != null); + try std.testing.expect(std.mem.indexOf(u8, high, "\"thinking\":{\"type\":\"disabled\"}") == null); + + var glm: Agent = agent; + glm.provider = build("glm-5.3-flash"); + glm.reasoning = .medium; + const glm_body = try glm.buildBody(null, false, true, true); + defer std.testing.allocator.free(glm_body); + try std.testing.expect(std.mem.indexOf(u8, glm_body, "\"thinking\":{\"type\":\"disabled\"}") == null); + try std.testing.expect(std.mem.indexOf(u8, glm_body, "\"reasoning_effort\":\"low\"") != null); +} diff --git a/src/sandbox.zig b/src/sandbox.zig index 68e3b118..1506af4d 100644 --- a/src/sandbox.zig +++ b/src/sandbox.zig @@ -288,6 +288,38 @@ pub fn find(io: Io, dir: Io.Dir, arena: Allocator, session: []const u8, id: []co return parseManifest(arena, bytes); } +/// Delete one snapshot tree (manifest + payload). Missing is success: GC is +/// idempotent and must not fail a command because a half-written dir vanished. +pub fn remove(io: Io, dir: Io.Dir, arena: Allocator, session: []const u8, id: []const u8) bool { + if (!safeName(session) or !safeName(id)) return false; + const path = snapshotDir(arena, session, id) catch return false; + dir.access(io, path, .{}) catch return true; + dir.deleteTree(io, path) catch return false; + return true; +} + +pub const GcResult = struct { + kept: usize = 0, + removed: usize = 0, + bytes: u64 = 0, +}; + +/// Keep the newest `keep` snapshots (list is oldest-first); delete the rest. +/// `keep == 0` deletes every snapshot this session has on disk. +pub fn gc(io: Io, dir: Io.Dir, arena: Allocator, session: []const u8, keep: usize) Allocator.Error!GcResult { + const items = try list(io, dir, arena, session); + var result: GcResult = .{ .kept = items.len }; + if (items.len <= keep) return result; + for (items[0 .. items.len - keep]) |m| { + if (remove(io, dir, arena, session, m.id)) { + result.removed += 1; + result.bytes += m.len; + result.kept -= 1; + } + } + return result; +} + // ── The active sandbox ───────────────────────────────────────────────────── // A process-wide pair rather than an Agent field: nothing in the MVP attaches // a sandbox automatically, so the REPL commands need a seam they can read that @@ -435,6 +467,45 @@ test "LocalProcess acquires but refuses to snapshot" { handle.release(); } +test "snapshot GC keeps the newest N and deletes the rest" { + const io = std.testing.io; + const gpa = std.testing.allocator; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var tmp = std.testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + + inline for (.{ + .{ .id = "old", .ms = 10, .len = 100 }, + .{ .id = "mid", .ms = 20, .len = 200 }, + .{ .id = "new", .ms = 30, .len = 300 }, + }) |row| { + try tmp.dir.createDirPath(io, try snapshotDir(arena, "s", row.id)); + try tmp.dir.writeFile(io, .{ .sub_path = try payloadPath(arena, "s", row.id), .data = "x" }); + try writeManifest(io, tmp.dir, arena, "s", .{ + .id = row.id, + .backend = "docker", + .kind = "docker_image_tar", + .ref = row.id, + .len = row.len, + .created_ms = row.ms, + }); + } + try std.testing.expectEqual(@as(usize, 3), (try list(io, tmp.dir, arena, "s")).len); + + const trimmed = try gc(io, tmp.dir, arena, "s", 1); + try std.testing.expectEqual(@as(usize, 2), trimmed.removed); + try std.testing.expectEqual(@as(usize, 1), trimmed.kept); + try std.testing.expectEqual(@as(u64, 300), trimmed.bytes); + const left = try list(io, tmp.dir, arena, "s"); + try std.testing.expectEqual(@as(usize, 1), left.len); + try std.testing.expectEqualStrings("new", left[0].id); + try std.testing.expect(find(io, tmp.dir, arena, "s", "old") == null); + try std.testing.expect(remove(io, tmp.dir, arena, "s", "missing")); + try std.testing.expect(!remove(io, tmp.dir, arena, "s", "..")); +} + test "the active sandbox is empty until something attaches one" { detach(); try std.testing.expect(active() == null); diff --git a/src/sandbox_docker.zig b/src/sandbox_docker.zig index 7ac54554..ef848373 100644 --- a/src/sandbox_docker.zig +++ b/src/sandbox_docker.zig @@ -52,12 +52,20 @@ pub const Docker = struct { /// backend; the wire below is unchanged. bin_name: []const u8 = "docker", - const backend_vtable: sandbox.BackendVTable = .{ + const docker_vtable: sandbox.BackendVTable = .{ .name = "docker", .available = availableImpl, .acquire = acquireImpl, .acquireFromSnapshot = acquireFromSnapshotImpl, }; + /// Same CLI wire, different binary — Apple Container (`container`) is the + /// second snapshot-capable backend #554's teleport path restores onto. + const container_vtable: sandbox.BackendVTable = .{ + .name = "container", + .available = availableImpl, + .acquire = acquireImpl, + .acquireFromSnapshot = acquireFromSnapshotImpl, + }; const handle_vtable: sandbox.HandleVTable = .{ .exec = execImpl, @@ -66,7 +74,10 @@ pub const Docker = struct { }; pub fn backend(self: *Docker) sandbox.Backend { - return .{ .ctx = @ptrCast(self), .vt = &backend_vtable }; + return .{ + .ctx = @ptrCast(self), + .vt = if (std.mem.eql(u8, self.bin_name, "container")) &container_vtable else &docker_vtable, + }; } /// One live sandbox. Allocated per `acquire` and freed by `release`, so two @@ -390,6 +401,13 @@ test "a snapshot tag is prefixed and unique" { try std.testing.expect(!std.mem.eql(u8, a, b)); } +test "bin_name container names the Apple Container backend" { + var docker: Docker = .{ .io = std.testing.io, .path_env = "", .bin_name = "container" }; + try std.testing.expectEqualStrings("container", docker.backend().name()); + var plain: Docker = .{ .io = std.testing.io, .path_env = "" }; + try std.testing.expectEqualStrings("docker", plain.backend().name()); +} + test "a missing docker resolves to nothing rather than spawning" { var buf: [4096]u8 = undefined; try std.testing.expect(resolveInto(std.testing.io, "", "docker", &buf) == null); diff --git a/src/sandbox_tests.zig b/src/sandbox_tests.zig index 0d6fa7bc..a77110e8 100644 --- a/src/sandbox_tests.zig +++ b/src/sandbox_tests.zig @@ -387,6 +387,138 @@ test "#554: /snapshot attach drives the seam by hand, and detach gives the sandb try std.testing.expectEqual(@as(usize, 1), (try sandbox.list(io, shim.tmp.dir, arena, "s")).len); } +test "#554: /snapshot gc keeps the newest snapshot and drops the rest" { + const io = std.testing.io; + const gpa = std.testing.allocator; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var tmp = std.testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + sandbox.setWorkspace(tmp.dir); + defer sandbox.setWorkspace(null); + + inline for (.{ + .{ .id = "old", .ms = 10 }, + .{ .id = "new", .ms = 20 }, + }) |row| { + try tmp.dir.createDirPath(io, try sandbox.snapshotDir(arena, "s", row.id)); + try tmp.dir.writeFile(io, .{ .sub_path = try sandbox.payloadPath(arena, "s", row.id), .data = canned_tar }); + try sandbox.writeManifest(io, tmp.dir, arena, "s", .{ + .id = row.id, + .backend = "docker", + .kind = "docker_image_tar", + .ref = row.id, + .len = canned_tar.len, + .created_ms = row.ms, + }); + } + + var root: Agent = undefined; + root.io = io; + root.gpa = gpa; + root.session_name = "s"; + var aw: Io.Writer.Allocating = .init(arena); + try std.testing.expect(try commands_sandbox.tryHandle(&root, arena, "/snapshot gc", &aw.writer)); + try std.testing.expect(std.mem.indexOf(u8, aw.writer.buffered(), "dropped 1") != null); + const left = try sandbox.list(io, tmp.dir, arena, "s"); + try std.testing.expectEqual(@as(usize, 1), left.len); + try std.testing.expectEqualStrings("new", left[0].id); +} + +test "#554: /teleport restores a docker_image_tar onto the container CLI" { + if (skipOnWindows()) return error.SkipZigTest; + const io = std.testing.io; + const gpa = std.testing.allocator; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var shim: Shim = undefined; + try shim.init(); + defer shim.deinit(); + try shim.tmp.dir.writeFile(io, .{ + .sub_path = "bin/container", + .data = shim_script, + .flags = .{ .permissions = .executable_file }, + }); + sandbox.setWorkspace(shim.tmp.dir); + defer sandbox.setWorkspace(null); + defer releaseActive(); + const saved_path = main_mod.g_path_env; + defer main_mod.g_path_env = saved_path; + main_mod.g_path_env = shim.bin_dir; + + try shim.tmp.dir.createDirPath(io, try sandbox.snapshotDir(arena, "s", "snap1")); + try shim.tmp.dir.writeFile(io, .{ + .sub_path = try sandbox.payloadPath(arena, "s", "snap1"), + .data = canned_tar, + }); + try sandbox.writeManifest(io, shim.tmp.dir, arena, "s", .{ + .id = "snap1", + .backend = "docker", + .kind = "docker_image_tar", + .ref = "graff-snap-from-manifest", + .len = canned_tar.len, + .created_ms = 1, + }); + shim.clearLog(); + + var root: Agent = undefined; + root.io = io; + root.gpa = gpa; + root.session_name = "s"; + var aw: Io.Writer.Allocating = .init(arena); + try std.testing.expect(try commands_sandbox.tryHandle(&root, arena, "/teleport snap1 container", &aw.writer)); + const text = aw.writer.buffered(); + try std.testing.expect(std.mem.indexOf(u8, text, "teleported") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "onto container") != null); + try std.testing.expect(std.mem.indexOf(u8, text, commands_sandbox.log_note) != null); + try std.testing.expectEqualStrings("container", sandbox.active().?.backend.name()); + const log = shim.log(arena); + try std.testing.expect(std.mem.indexOf(u8, log, "load") != null); + try std.testing.expect(std.mem.indexOf(u8, log, "run -d --name graff-sbx-s " ++ loaded_ref) != null); +} + +test "#554: /teleport with no dest CLI explains instead of failing" { + const gpa = std.testing.allocator; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var tmp = std.testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + sandbox.setWorkspace(tmp.dir); + defer sandbox.setWorkspace(null); + sandbox.detach(); + const saved_path = main_mod.g_path_env; + defer main_mod.g_path_env = saved_path; + main_mod.g_path_env = ""; + + try tmp.dir.createDirPath(std.testing.io, try sandbox.snapshotDir(arena, "s", "snap1")); + try sandbox.writeManifest(std.testing.io, tmp.dir, arena, "s", .{ + .id = "snap1", + .backend = "docker", + .kind = "docker_image_tar", + .ref = "x", + .len = 1, + .created_ms = 1, + }); + + var root: Agent = undefined; + root.io = std.testing.io; + root.gpa = gpa; + root.session_name = "s"; + var aw: Io.Writer.Allocating = .init(arena); + try std.testing.expect(try commands_sandbox.tryHandle(&root, arena, "/teleport snap1 container", &aw.writer)); + try std.testing.expect(std.mem.indexOf(u8, aw.writer.buffered(), "container backend:") != null); + try std.testing.expect(std.mem.indexOf(u8, aw.writer.buffered(), "not on PATH") != null); + try std.testing.expect(sandbox.active() == null); + + var usage: Io.Writer.Allocating = .init(arena); + try std.testing.expect(try commands_sandbox.tryHandle(&root, arena, "/teleport", &usage.writer)); + try std.testing.expect(std.mem.indexOf(u8, usage.writer.buffered(), "usage: /teleport") != null); +} + test "#554: /snapshot attach with no docker installed explains instead of failing" { const gpa = std.testing.allocator; var arena_state: std.heap.ArenaAllocator = .init(gpa); diff --git a/src/test_hooks.zig b/src/test_hooks.zig index 9dd39eed..987e5196 100644 --- a/src/test_hooks.zig +++ b/src/test_hooks.zig @@ -222,7 +222,7 @@ const proc_identity = @import("proc_identity.zig"); // (agent_context.zig, subagent.zig) sit at the 600-line cap. const usage_attribution_tests = @import("usage_attribution_tests.zig"); -// #554: the sandbox seam, its Docker backend, the /snapshot + /rewind +// #554: the sandbox seam, its Docker backend, /snapshot + /rewind + /teleport + gc // commands, and the fake-docker orchestration suite. Production reaches // commands_sandbox.zig from commands_misc.tryHandle and the rest only through // calls, so without these hooks the whole subsystem's tests compile to nothing. diff --git a/src/zai_wire.zig b/src/zai_wire.zig index 3b911a09..7731747b 100644 --- a/src/zai_wire.zig +++ b/src/zai_wire.zig @@ -33,14 +33,29 @@ pub fn vercelEffort(requested: []const u8) []const u8 { return "medium"; } +/// DeepSeek V4 (native or via codegraff/fireworks): thinking is ON at +/// high unless `thinking.type` is `disabled`. `reasoning_effort: low` +/// still emits `reasoning_content` (ADR 0054). +pub fn isDeepseekFamily(provider_id: []const u8, model: []const u8) bool { + if (std.mem.eql(u8, provider_id, "deepseek")) return true; + return std.mem.indexOf(u8, model, "deepseek") != null; +} + /// OpenAI-chat extras after `prompt_cache_key`: Z.AI thinking, Vercel -/// `reasoning.effort`, then the shared `reasoning_effort` hint. -pub fn writeChatExtras(s: *std.json.Stringify, provider_id: []const u8, send_effort: bool, requested: []const u8) !void { +/// `reasoning.effort`, DeepSeek thinking on/off, then `reasoning_effort`. +pub fn writeChatExtras(s: *std.json.Stringify, provider_id: []const u8, model: []const u8, send_effort: bool, requested: []const u8) !void { const is_zai = std.mem.eql(u8, provider_id, "zai"); const is_vercel = std.mem.eql(u8, provider_id, "vercel"); if (is_zai) { try s.objectField("thinking"); try s.print("{s}", .{"{\"type\":\"enabled\",\"clear_thinking\":false}"}); + } else if (isDeepseekFamily(provider_id, model) and send_effort) { + // Official off switch. GLM rejects type=disabled; do not send it there. + try s.objectField("thinking"); + try s.print("{s}", .{if (std.mem.eql(u8, requested, "low")) + "{\"type\":\"disabled\"}" + else + "{\"type\":\"enabled\"}"}); } if (is_vercel and send_effort) { try s.objectField("reasoning"); @@ -56,6 +71,14 @@ pub fn writeChatExtras(s: *std.json.Stringify, provider_id: []const u8, send_eff } } +test "DeepSeek family is native id or a deepseek model name" { + try std.testing.expect(isDeepseekFamily("deepseek", "deepseek-v4-pro")); + try std.testing.expect(isDeepseekFamily("codegraff", "deepseek-v4-flash")); + try std.testing.expect(isDeepseekFamily("fireworks", "accounts/fireworks/models/deepseek-v4-flash")); + try std.testing.expect(!isDeepseekFamily("codegraff", "glm-5.3-flash")); + try std.testing.expect(!isDeepseekFamily("zai", "glm-5.3")); +} + test "Z.AI maps graff efforts onto low|high|max" { try std.testing.expectEqualStrings("low", reasoningEffort("low")); try std.testing.expectEqualStrings("high", reasoningEffort("medium")); From f95f15656bf874e84fef6eca5babfe9dcb4edf2e Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:33:06 +0800 Subject: [PATCH 27/27] fix: route Windows mid-handshake TLS aborts through the construction recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The malformed-handshake test asserts error.TlsRequestConstructionFailed from both production constructor catches, and green on Linux/macOS. On Windows CI the handshake read dies with NTSTATUS LOCAL_DISCONNECT, which std surfaces as error.Unexpected — raw, bypassing the recovery rotation the test (and #694's contract) demands. constructionTlsFailure() is the shared predicate: TlsInitializationFailed always, plus error.Unexpected when the request is https (a plain-HTTP connect error must not be mislabeled a TLS failure). Both catches — http.post and the streaming path — consult it. Co-Authored-By: Codegraff --- src/agent_stream.zig | 2 +- src/http.zig | 2 +- src/http_client.zig | 10 ++++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/agent_stream.zig b/src/agent_stream.zig index 697dec6f..2cdafecc 100644 --- a/src/agent_stream.zig +++ b/src/agent_stream.zig @@ -124,7 +124,7 @@ pub fn postStreamWithClient(self: *Agent, client: *std.http.Client, body: []cons }, .extra_headers = extra, }) catch |err| { - if (err == error.TlsInitializationFailed) return http_client.constructionTlsError(transport); + if (http_client.constructionTlsFailure(err, provider.url)) return http_client.constructionTlsError(transport); return err; }; defer req.deinit(); diff --git a/src/http.zig b/src/http.zig index 38a35220..2817e66c 100644 --- a/src/http.zig +++ b/src/http.zig @@ -137,7 +137,7 @@ fn post(gpa: Allocator, client: *std.http.Client, provider: Provider, body: []co }, .extra_headers = extra, }) catch |err| { - if (err == error.TlsInitializationFailed) return http_client.constructionTlsError(transport); + if (http_client.constructionTlsFailure(err, provider.url)) return http_client.constructionTlsError(transport); return err; }; defer req.deinit(); diff --git a/src/http_client.zig b/src/http_client.zig index c476a5cd..490dcc1c 100644 --- a/src/http_client.zig +++ b/src/http_client.zig @@ -234,6 +234,16 @@ pub fn acquire(requested: *std.http.Client) Lease { return .{ .client = requested }; } +/// True when a `transport.request()` construction error must traverse the +/// TLS recovery path: the handshake failed outright — or (Windows) died +/// mid-read, where NTSTATUS LOCAL_DISCONNECT surfaces as error.Unexpected +/// out of the handshake read instead of TlsInitializationFailed. Gated to +/// https so a plain-HTTP connect error is never mislabeled a TLS failure. +pub fn constructionTlsFailure(err: anyerror, url: []const u8) bool { + return err == error.TlsInitializationFailed or + (err == error.Unexpected and std.mem.startsWith(u8, url, "https://")); +} + pub fn constructionTlsError(failed: *std.http.Client) anyerror { g_lifecycle_mutex.lockUncancelable(failed.io); defer g_lifecycle_mutex.unlock(failed.io);