diff --git a/src/cli.zig b/src/cli.zig index 64a8038f5..1232859a3 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.201 + \\ • A send-failed request no longer re-pools its dead connection, so one network blip can't storm every later compaction, title, and subagent call + \\ • The "ultracode" codeword is matched only on what you actually typed (not on appended goal/todo notes), and /clear now clears the standing goal and ultracode mode + \\ • `graff login codex` lands on a branded, dark-mode-aware confirmation page instead of a bare line of text + \\ \\0.0.200 \\ • Codex WS delta bodies are never replayed over SSE — fixes "Unsupported parameter: previous_response_id" after a long idle \\ • The held codex WS now re-anchors preemptively after 4 min idle (GRAFF_CODEX_WS_IDLE_SECS to tune) instead of eating a dead-socket round trip diff --git a/src/commands_session.zig b/src/commands_session.zig index adff2ae57..ccd111b2e 100644 --- a/src/commands_session.zig +++ b/src/commands_session.zig @@ -51,6 +51,25 @@ const skills_registry = skills.skills_registry; const title_mod = @import("title.zig"); const setTerminalTitle = title_mod.setTerminalTitle; +/// Steering state that must not outlive the conversation it was set in. +/// /clear used to keep goal + ultracode_mode (only /new reset them), so a +/// standing goal containing the word "ultracode" was re-appended to every +/// assembled prompt and kept re-triggering the explicit-codeword banner +/// after "context cleared" (#178). +fn resetConversationSteering(root: *Agent) void { + root.goal = null; + root.ultracode_mode = false; +} + +test "/clear + /new reset conversation steering — goal and ultracode_mode don't survive (#178)" { + var root: Agent = undefined; + root.goal = "ultracode: index the statutes"; + root.ultracode_mode = true; + resetConversationSteering(&root); + try std.testing.expect(root.goal == null); + try std.testing.expect(!root.ultracode_mode); +} + /// Try to handle a session/environment slash command. Returns false (line /// unhandled) if `line` doesn't match any command in this file — the caller /// (handleCommand in main.zig) then tries the next peer module. @@ -64,9 +83,12 @@ pub fn tryHandle(root: *Agent, keys: *Keys, arena: Allocator, line: []const u8, root.session_title = null; // re-summarize the now-empty conversation root.ai_title_done = false; root.todos.clearRetainingCapacity(); + const had_goal = root.goal != null; + resetConversationSteering(root); saveSession(root, arena, root.session_name) catch {}; setTerminalTitle(out, "Chat", main_mod.g_cwd_display); try out.writeAll("context cleared — fresh conversation\n"); + if (had_goal) try out.writeAll("(standing goal dropped — /goal to set a new one)\n"); try out.flush(); return true; } @@ -75,8 +97,7 @@ pub fn tryHandle(root: *Agent, keys: *Keys, arena: Allocator, line: []const u8, root.last_context_tokens = 0; root.last_cache_read = 0; root.todos.clearRetainingCapacity(); - root.goal = null; - root.ultracode_mode = false; + resetConversationSteering(root); root.session_title = null; root.ai_title_done = false; // let the new session earn its own AI title root.tui_header_shown = false; diff --git a/src/http.zig b/src/http.zig index 4d2e86029..d487cc64c 100644 --- a/src/http.zig +++ b/src/http.zig @@ -95,8 +95,15 @@ pub fn capture5xxBodyStream(gpa: Allocator, response: *std.http.Client.Response) } /// POST the request body; returns the raw response body (caller frees). -/// std.http.Client.fetch is thread-safe, so subagents on pool threads share -/// this client (and its connection pool). +/// 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. fn post(gpa: Allocator, client: *std.http.Client, provider: Provider, body: []const u8) ![]u8 { var aw: Io.Writer.Allocating = .init(gpa); errdefer aw.deinit(); @@ -110,22 +117,47 @@ fn post(gpa: Allocator, client: *std.http.Client, provider: Provider, body: []co var headers_buf: [6]std.http.Header = undefined; const extra = providerHeaders(provider, bearer, &headers_buf); - const res = try client.fetch(.{ - .location = .{ .url = provider.url }, - .method = .POST, - .payload = body, - .response_writer = &aw.writer, + var req = try client.request(.POST, try std.Uri.parse(provider.url), .{ + .redirect_behavior = .unhandled, .headers = .{ .content_type = .{ .override = "application/json" }, .user_agent = providerUserAgent(provider), }, .extra_headers = extra, }); - const code = @intFromEnum(res.status); + 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 + // every later request to this host) dials fresh. + errdefer if (req.connection) |conn| { + conn.closing = true; + }; + + var response: std.http.Client.Response = undefined; + try sendHeadTask(&req, body, &response); + + const code = @intFromEnum(response.head.status); if (code == 429 or code >= 500) { - capture5xxBody(aw.writer.buffered()); + // Drain a snippet of the error body for the retry message, then + // poison: the connection still holds unread body bytes. + capture5xxBodyStream(gpa, &response); + if (req.connection) |conn| conn.closing = true; return if (code == 429) error.RateLimited else error.ServerError; } + + // Any other status (200 or a 4xx API error envelope): return the body — + // request() parses error envelopes out of non-2xx JSON itself. + const dbuf: []u8 = switch (response.head.content_encoding) { + .identity => &.{}, + .zstd => try gpa.alloc(u8, std.compress.zstd.default_window_len), + .deflate, .gzip => try gpa.alloc(u8, std.compress.flate.max_window_len), + .compress => return error.UnsupportedCompressionMethod, + }; + defer if (dbuf.len > 0) gpa.free(dbuf); + var tbuf: [64]u8 = undefined; + var dec: std.http.Decompress = undefined; + const reader = response.readerDecompressing(&tbuf, &dec, dbuf); + _ = try reader.streamRemaining(&aw.writer); return aw.toOwnedSlice(); } @@ -194,8 +226,10 @@ const head_stall_ms: u64 = 30 * 1000; /// (HttpConnectionClosing / WriteFailed / reset / truncated TLS) are now far /// more patient: a real network blip (wifi hiccup, gateway redeploy) outlasts /// the old 3-try / ~1.75s window and was wrongly giving up the whole turn. The -/// connection is poisoned + re-dialed fresh on each retry (postStream/postWatched -/// errdefer), so these tries hit new sockets. 429/5xx throttles keep their longer +/// connection is poisoned + re-dialed fresh on each retry (postStream's and +/// post()'s errdefer — the latter since #177; this comment used to claim a +/// postWatched poison that was never implemented, which is exactly how one +/// dead pooled connection could fail all 6 tries). 429/5xx throttles keep their longer /// server-directed waits. pub const RetryPlan = struct { /// Total attempts before the turn gives up. @@ -320,3 +354,209 @@ pub fn postWatched(gpa: Allocator, io: Io, client: *std.http.Client, provider: P .watchdog => |w| watchdogError(w, error.HungRequest), }; } + +// #177 regression: a POST whose body SEND fails must NOT return its dead +// connection to the keep-alive pool. Before the fix (post() built on +// client.fetch), std re-pooled it — 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 +// (compaction, [title], subagents): WriteFailed forever after. Real loopback +// server: connection 1 is accepted and closed unread, so the large body send +// hits a reset mid-flight; only a poisoned pool makes request 2 dial a fresh +// connection 2 and reach the 200. +test "post (#177): a send-failed connection is not re-pooled — the next request dials fresh" { + const gpa = std.testing.allocator; + const io = std.testing.io; + + const Srv = struct { + fn run(io_: Io, server: *std.Io.net.Server) void { + // conn 1: close before reading anything. The client's + // multi-megabyte body overruns the socket buffers, the kernel + // resets data arriving on the closed socket, and the send fails + // mid-write — the same shape as the codex backend killing an + // over-cap request at write time (#163). + const c1 = server.accept(io_) catch return; + c1.close(io_); + + // conn 2 only ever arrives on a fresh dial: drain the (tiny) + // request, then serve a minimal 200. + const c2 = server.accept(io_) catch return; + defer c2.close(io_); + var rbuf: [4096]u8 = undefined; + var sr = std.Io.net.Stream.Reader.init(c2, io_, &rbuf); + while (true) { + const line = (sr.interface.takeDelimiter('\n') catch break) orelse break; + if (line.len == 0 or (line.len == 1 and line[0] == '\r')) break; // end of headers + } + _ = sr.interface.take(2) catch {}; // the 2-byte "{}" body + var wbuf: [256]u8 = undefined; + var sw = std.Io.net.Stream.Writer.init(c2, io_, &wbuf); + sw.interface.writeAll("HTTP/1.1 200 OK\r\ncontent-length: 2\r\nconnection: close\r\n\r\nok") catch {}; + sw.interface.flush() catch {}; + } + }; + + 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 }); + defer fut.await(io); + defer server.deinit(io); + + var 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/x", .{bound.getPort()}); + const provider: Provider = .{ + .id = "test", + .kind = .openai, + .auth = .x_api_key, + .url = url, + .api_key = "k", + .model = "m", + .context = 0, + }; + + var client: std.http.Client = .{ .allocator = gpa, .io = io }; + defer client.deinit(); + + // Large enough that the send cannot complete inside socket buffers, so + // the closed conn 1 fails the WRITE (the #177 signature) rather than + // buffering silently and failing later at the head read. + const big = try gpa.alloc(u8, 8 * 1024 * 1024); + defer gpa.free(big); + @memset(big, 'x'); + // Assert the send fails with WriteFailed (the #177 signature). If it ever + // doesn't (a platform where the 8MB write buffers and the failure only + // surfaces later at head-read), release the server task's still-pending + // second accept() with a throwaway dial before failing — otherwise the + // deferred fut.await would hang the test binary, same as the second post. + if (post(gpa, &client, provider, big)) |resp| { + gpa.free(resp); + if (std.Io.net.IpAddress.connect(&bound, io, .{ .mode = .stream })) |s| s.close(io) else |_| {} + return error.TestExpectedError; // the send to a closed peer must fail + } else |err| if (err != error.WriteFailed) { + if (std.Io.net.IpAddress.connect(&bound, io, .{ .mode = .stream })) |s| s.close(io) else |_| {} + return err; + } + + // The regression: without the poison this pulls dead conn 1 back out of + // the pool (no fresh dial) and fails with WriteFailed instead of + // reaching conn 2's 200. + const second = post(gpa, &client, provider, "{}"); + if (second) |resp| { + defer gpa.free(resp); + try std.testing.expectEqualStrings("ok", resp); + } else |err| { + // Regression path: conn 2 never arrived, so the server task is still + // blocked in accept — release it with a throwaway dial before + // failing, or the deferred await would hang the test binary. + if (std.Io.net.IpAddress.connect(&bound, io, .{ .mode = .stream })) |s| s.close(io) else |_| {} + return err; + } +} + +// post's 429/5xx branch: an oversized error body is partial-drained +// (capture5xxBodyStream stops at the 600-byte g_5xx_body_buf), the request +// surfaces as error.ServerError, and the session recovers on the next request. +// This is the dominant real-world failure (gateway throttles / 500s), and +// before this test the whole 5xx branch was uncovered. NOTE: unlike the +// send-failed case above, std will NOT re-pool a partially-read response +// connection on its own, so post's explicit 5xx poison is defensive; this test +// exercises the path end-to-end rather than isolating that poison. The +// load-bearing poison regression — a failed SEND that leaves the reader +// deceptively .ready so std DOES re-pool the corpse — is the test above. +test "post (#177 5xx path): an oversized 5xx surfaces as ServerError and the session recovers" { + const gpa = std.testing.allocator; + const io = std.testing.io; + + // A 5xx body well over the 600-byte capture buffer, with newlines so + // capture5xxBodyStream grabs a snippet and stops, leaving the rest unread. + var body500: [4096]u8 = undefined; + { + const line = "upstream gateway error (simulated 5xx)\n"; + for (&body500, 0..) |*b, i| b.* = line[i % line.len]; + } + + const Srv = struct { + fn readReq(sr: *std.Io.net.Stream.Reader) void { + while (true) { + const l = (sr.interface.takeDelimiter('\n') catch return) orelse return; + if (l.len == 0 or (l.len == 1 and l[0] == '\r')) break; // end of headers + } + _ = sr.interface.take(2) catch {}; // the 2-byte "{}" body + } + fn run(io_: Io, server: *std.Io.net.Server, body: []const u8) void { + // conn 1: keep-alive 500 (no `connection: close`) with an oversized + // body — without the poison std would re-pool this half-read corpse. + const c1 = server.accept(io_) catch return; + { + defer c1.close(io_); + var rbuf: [4096]u8 = undefined; + var sr = std.Io.net.Stream.Reader.init(c1, io_, &rbuf); + readReq(&sr); + var hbuf: [96]u8 = undefined; + const head = std.fmt.bufPrint(&hbuf, "HTTP/1.1 500 Internal Server Error\r\ncontent-length: {d}\r\n\r\n", .{body.len}) catch return; + var wbuf: [1024]u8 = undefined; + var sw = std.Io.net.Stream.Writer.init(c1, io_, &wbuf); + sw.interface.writeAll(head) catch {}; + sw.interface.writeAll(body) catch {}; + sw.interface.flush() catch {}; + } + // conn 2 only ever arrives on a fresh dial (the poison worked). + const c2 = server.accept(io_) catch return; + defer c2.close(io_); + var rbuf: [4096]u8 = undefined; + var sr = std.Io.net.Stream.Reader.init(c2, io_, &rbuf); + readReq(&sr); + var wbuf: [256]u8 = undefined; + var sw = std.Io.net.Stream.Writer.init(c2, io_, &wbuf); + sw.interface.writeAll("HTTP/1.1 200 OK\r\ncontent-length: 2\r\nconnection: close\r\n\r\nok") catch {}; + sw.interface.flush() catch {}; + } + }; + + 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, @as([]const u8, &body500) }); + defer fut.await(io); + defer server.deinit(io); + + var 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/x", .{bound.getPort()}); + const provider: Provider = .{ + .id = "test", + .kind = .openai, + .auth = .x_api_key, + .url = url, + .api_key = "k", + .model = "m", + .context = 0, + }; + + var client: std.http.Client = .{ .allocator = gpa, .io = io }; + defer client.deinit(); + + // The 5xx surfaces as error.ServerError and poisons conn 1. Guard the + // assertion the same way as the send-failed test: on any other outcome, + // release the server's still-pending second accept() before failing. + if (post(gpa, &client, provider, "{}")) |resp| { + gpa.free(resp); + if (std.Io.net.IpAddress.connect(&bound, io, .{ .mode = .stream })) |s| s.close(io) else |_| {} + return error.TestExpectedError; // a 500 must surface as ServerError + } else |err| if (err != error.ServerError) { + if (std.Io.net.IpAddress.connect(&bound, io, .{ .mode = .stream })) |s| s.close(io) else |_| {} + return err; + } + + // Recovery: the next request reaches conn 2's 200. (Unlike the send-failed + // case above, std does not re-pool a partially-read connection on its own, + // so this asserts end-to-end recovery, not that the 5xx poison is required.) + const second = post(gpa, &client, provider, "{}"); + if (second) |resp| { + defer gpa.free(resp); + try std.testing.expectEqualStrings("ok", resp); + } else |err| { + if (std.Io.net.IpAddress.connect(&bound, io, .{ .mode = .stream })) |s| s.close(io) else |_| {} + return err; + } +} diff --git a/src/mainloop.zig b/src/mainloop.zig index 010d0ad03..5c4d6c5d7 100644 --- a/src/mainloop.zig +++ b/src/mainloop.zig @@ -500,7 +500,7 @@ pub fn run(ctx: *Ctx) !void { } // "ultracode" codeword or persistent /ultracode mode: opt turns into multi-agent workflow mode. - const ultracode_msg = try pickers.applyUltracodeSteering(ctx.arena, msg, ctx.root.ultracode_mode or ctx.root.reasoning == .ultra); + const ultracode_msg = try pickers.applyUltracodeSteering(ctx.arena, msg, base_msg, ctx.root.ultracode_mode or ctx.root.reasoning == .ultra); if (ultracode_msg.explicit) { if (!main_mod.json_mode) { if (ctx.interactive) { diff --git a/src/oauth.zig b/src/oauth.zig index 12c3c6d54..116ba2327 100644 --- a/src/oauth.zig +++ b/src/oauth.zig @@ -134,6 +134,38 @@ fn writeCodexAuth(io: Io, arena: Allocator, home: []const u8, id_token: []const try fw.interface.flush(); } +/// The page the Codex OAuth callback tab lands on after a successful login. +/// A branded, dark-mode-aware confirmation card; the local server writes this +/// back, then reads the ?code out of the same request for the token exchange. +const codex_login_page_html = + \\ + \\ + \\ + \\graff — logged in + \\ + \\
+ \\
graff ◆
+ \\

You're all set ✓

+ \\

Codex is connected. You can close this tab and return to graff.

+ \\
+; /// `harness login [--refresh]`: the ChatGPT/Codex OAuth flow. Fresh login runs /// PKCE (open browser → localhost:1455 callback → code→token exchange); refresh /// uses the stored refresh_token. Either way writes ~/.codex/auth.json. @@ -191,9 +223,11 @@ pub fn codexLogin(io: Io, gpa: Allocator, arena: Allocator, home: []const u8, re var sr = std.Io.net.Stream.Reader.init(stream, io, &rbuf); const req_line = (sr.interface.takeDelimiter('\n') catch null) orelse return error.BadOAuthResponse; - // Send a friendly page back so the browser tab isn't left hanging. - const page = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n

Logged in ✓

You can close this tab and return to the harness.

"; - var wbuf: [1024]u8 = undefined; + // Branded, dark-mode-aware confirmation card (charset utf-8 so the ✓/◆ + // render) so the callback tab shows a real "you're all set" page, not a + // bare line — then we read the code out of the same request below. + const page = "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\n" ++ codex_login_page_html; + var wbuf: [4096]u8 = undefined; var sw = std.Io.net.Stream.Writer.init(stream, io, &wbuf); sw.interface.writeAll(page) catch {}; sw.interface.flush() catch {}; diff --git a/src/pickers.zig b/src/pickers.zig index 8ec89f886..940dbb7e4 100644 --- a/src/pickers.zig +++ b/src/pickers.zig @@ -433,8 +433,15 @@ const ultracode_persistent_note = \\structural.] ; -pub fn applyUltracodeSteering(arena: Allocator, msg: []const u8, persistent_enabled: bool) !UltracodeMessage { - const explicit = std.ascii.indexOfIgnoreCase(msg, "ultracode") != null; +/// `raw` is what the user actually typed this turn; `msg` is the assembled +/// turn message (goal/eval/loop/plan notes may already be appended). The +/// explicit-codeword scan runs ONLY on `raw`: harness-assembled notes replay +/// prior context — a /goal set during an ultracode task, a todo echoed +/// through the goal note — and scanning them made the codeword sticky, so +/// every turn after /clear bannered as explicit even though the user never +/// typed the word (#178). +pub fn applyUltracodeSteering(arena: Allocator, msg: []const u8, raw: []const u8, persistent_enabled: bool) !UltracodeMessage { + const explicit = std.ascii.indexOfIgnoreCase(raw, "ultracode") != null; if (explicit) { return .{ .text = try std.fmt.allocPrint(arena, "{s}\n\n{s}", .{ msg, ultracode_explicit_note }), .explicit = true }; } @@ -444,6 +451,27 @@ pub fn applyUltracodeSteering(arena: Allocator, msg: []const u8, persistent_enab return .{ .text = msg, .explicit = false }; } +test "applyUltracodeSteering (#178): the codeword scan runs on the raw typed text, not the assembled msg" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + // The word arriving via an appended harness note (e.g. a standing goal) + // must NOT count as an invocation — and with persistent mode off, the + // message passes through untouched. + const assembled = "write the report\n\n[harness note: goal — ultracode the pipeline]"; + const via_note = try applyUltracodeSteering(a, assembled, "write the report", false); + try std.testing.expect(!via_note.explicit); + try std.testing.expectEqualStrings(assembled, via_note.text); + // The user actually typing it does (case-insensitive) and appends the note. + const typed = try applyUltracodeSteering(a, "ULTRACODE fix the bug", "ULTRACODE fix the bug", false); + try std.testing.expect(typed.explicit); + try std.testing.expect(std.mem.indexOf(u8, typed.text, "codeword") != null); + // Persistent mode still applies its note without ever claiming explicit. + const persistent = try applyUltracodeSteering(a, assembled, "write the report", true); + try std.testing.expect(!persistent.explicit); + try std.testing.expect(std.mem.indexOf(u8, persistent.text, "ultracode mode is enabled") != null); +} + const ultracode_on_first = [_]PickItem{ .{ .name = "on", .desc = "Enable ultracode orchestration" }, .{ .name = "off", .desc = "Disable ultracode orchestration" }, @@ -711,15 +739,15 @@ test "applyUltracodeSteering handles explicit and persistent modes" { defer arena_state.deinit(); const a = arena_state.allocator(); - const plain = try applyUltracodeSteering(a, "fix this", false); + const plain = try applyUltracodeSteering(a, "fix this", "fix this", false); try std.testing.expect(!plain.explicit); try std.testing.expectEqualStrings("fix this", plain.text); - const persistent = try applyUltracodeSteering(a, "fix this", true); + const persistent = try applyUltracodeSteering(a, "fix this", "fix this", true); try std.testing.expect(!persistent.explicit); try std.testing.expect(std.mem.indexOf(u8, persistent.text, "ultracode mode is enabled") != null); - const explicit = try applyUltracodeSteering(a, "ultracode fix this", true); + const explicit = try applyUltracodeSteering(a, "ultracode fix this", "ultracode fix this", true); try std.testing.expect(explicit.explicit); try std.testing.expect(std.mem.indexOf(u8, explicit.text, "user invoked the \"ultracode\" codeword") != null); try std.testing.expect(std.mem.indexOf(u8, explicit.text, "ultracode mode is enabled") == null); diff --git a/src/session_run.zig b/src/session_run.zig index 9b12fb4f8..d4d6d6988 100644 --- a/src/session_run.zig +++ b/src/session_run.zig @@ -102,7 +102,7 @@ pub fn runOneshotPrompt(gpa: Allocator, io: Io, arena: Allocator, root: *agent_m root.in = null; // gate: deny instead of prompt; ask_user: self-decide root.out = null; // tool progress → stderr; stdout carries only the answer root.stream_quiet = true; - const ultracode_msg = try pickers.applyUltracodeSteering(arena, prompt_text, root.ultracode_mode or root.reasoning == .ultra); + const ultracode_msg = try pickers.applyUltracodeSteering(arena, prompt_text, prompt_text, root.ultracode_mode or root.reasoning == .ultra); if (ultracode_msg.explicit) { tracer.note("ultracode", prompt_text[0..@min(prompt_text.len, 120)]); if (telemetry.g_telem) |t| t.ultracode();