From 193f46b1024875e33db34767093aa9045410efef Mon Sep 17 00:00:00 2001 From: justrach <54503978+justrach@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:33:44 +0800 Subject: [PATCH 1/3] fix: stabilize release checks and notes Skip unsupported test fixtures, synchronize retry readiness, and keep release notes concise. --- CHANGELOG.md | 9 +++----- docs/releases/v0.302.74.65.md | 3 +++ scripts/test-retry-cancellation.py | 37 ++++++++++++++++++++++++------ src/git_dirs_tests.zig | 2 ++ 4 files changed, 38 insertions(+), 13 deletions(-) create mode 100644 docs/releases/v0.302.74.65.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a653fb2e..0b4d8f33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,12 +12,9 @@ current is part of cutting a release. ## v0.302.74.65 -- Startup: a `-p` one-shot prewarms its companion handshake, and the join is now a 250 ms budget (1500 ms on Windows; `GRAFF_MCP_COMPANION_BOOT_MS`, capped at 60 s) instead of an unbounded await — a companion that spawns and never answers held the first request for 20,134 ms and now costs the budget, merging late through the single merge point or stopping quietly at teardown, on POSIX and Windows alike (ADR 0339, #1896). Medians over hermetic runs: silent companion 20,134.0 ms → 268 ms; 600 ms companion 624.6 ms → 265.9 ms; healthy 6 ms companion unchanged at ~23 ms with its tools on the first request. -- Startup: a cold provider model-catalog cache never holds boot — the fetch detaches like a stale cache's refresh and the landed cache activates at the next query/model-option beat (REPL picker and ACP `session/set_config_option` alike), while a `--model` naming a row the baked table lacks still joins the bounded batch so startup never accepts a model the provider does not serve (ADR 0340, #1896). Third-party cold-start rig, first keystroke: 312.0 ms → 57.2 ms. -- Startup: REPL/ACP boot resolves worktree identity in-process (memoized single-spawn fallback) instead of spawning `git rev-parse` up to 7–9 times, and the boot worktree reap skips git when nothing is prunable — zero git spawns before ready (#1896). -- Startup: the interactive update banner reads a cached release tag instead of a blocking GitHub GET (~300 ms) on every launch; a missing or >24 h cache refreshes on a detached thread neither boot nor exit waits on (ADR 0336, #1896). -- Startup: the `-p` companion prewarms after credential resolution — one attempt, discarded on a cwd/settings mismatch (ADR 0337) — and an ACP `initialize` received during boot is answered immediately, with the AcpEarlyInit Lean kernel proving stdout and dispatch history equal the late path on every boot trace (ADR 0338, #1896). -- Fixed ACP surfacing output-length continuation exhaustion as an opaque `{"code":-32603,"message":"OutputLengthExceeded"}` (#1893, #1894). `describeError` now hands the client the same explanation the REPL and one-shot paths print — the work is partial and preserved; send a new message or resume to continue. +- Improve startup responsiveness across the REPL, one-shot, and ACP paths. +- Avoid blocking startup on optional companion handshakes and model-catalog refreshes. +- Explain output-length continuation exhaustion in ACP while preserving partial work (#1893). ## v0.302.74.63 diff --git a/docs/releases/v0.302.74.65.md b/docs/releases/v0.302.74.65.md new file mode 100644 index 00000000..21fa3832 --- /dev/null +++ b/docs/releases/v0.302.74.65.md @@ -0,0 +1,3 @@ +- Improve startup responsiveness across the REPL, one-shot, and ACP paths. +- Avoid blocking startup on optional companion handshakes and model-catalog refreshes. +- Explain output-length continuation exhaustion in ACP while preserving partial work (#1893). diff --git a/scripts/test-retry-cancellation.py b/scripts/test-retry-cancellation.py index 199172cc..5e90eb70 100644 --- a/scripts/test-retry-cancellation.py +++ b/scripts/test-retry-cancellation.py @@ -88,7 +88,7 @@ def close(self): class Client: def __init__(self, binary, root, model, mode): - self.root, self.mode = root, mode + self.root, self.mode, self.model = root, mode, model self.seen, self.events = [], queue.Queue() self.counter = 0 self.sid = None @@ -126,7 +126,14 @@ def read(): self.reader = threading.Thread(target=read, daemon=True) self.reader.start() try: - if mode != 'json': + if mode == 'json': + # A control ack proves boot reached the inbox without making a + # model request. Keep startup out of the retry-backoff deadline, + # just as initialize/session-new already do for ACP. + self.send({'type':'set_mode', 'mode':'normal'}) + ready = self.until(lambda event: event.get('type') == 'mode') + assert ready.get('ok') is True and ready.get('mode') == 'normal', ready + else: init = self.call('initialize', {'protocolVersion':2 if mode == 'acp2' else 1}) assert init['result']['protocolVersion'] == (2 if mode == 'acp2' else 1), init self.sid = self.call('session/new', {'cwd':str(root), 'mcpServers':[]})['result']['sessionId'] @@ -143,15 +150,31 @@ def request(self, method, params): self.send(dict(jsonrpc='2.0', id=self.counter, method=method, params=params)) return self.counter + def diagnostics(self): + with self.events.mutex: + pending = list(self.events.queue)[-3:] + return dict(mode=self.mode, returncode=self.proc.poll(), + requests=len(self.model.requests()), + recent_events=[value.get('type', value.get('method')) + for value in self.seen[-3:]], + pending_events=[value.get('type', value.get('method')) + if isinstance(value, dict) else type(value).__name__ + for value in pending], + trace_events=[row.get('ev') for row in self.trace()[-5:]], + stderr=(self.root/'stderr').read_text(errors='replace')[-4096:]) + def until(self, predicate, timeout=8): deadline = time.monotonic() + timeout while True: - value = self.events.get(timeout=max(.01, deadline-time.monotonic())) - assert isinstance(value, dict), ('invalid/closed protocol stream', value) + try: + value = self.events.get(timeout=max(.01, deadline-time.monotonic())) + except queue.Empty as error: + raise AssertionError(('protocol deadline', self.diagnostics())) from error + assert isinstance(value, dict), ('invalid/closed protocol stream', value, self.diagnostics()) self.seen.append(value) if predicate(value): return value - assert time.monotonic() < deadline, ('protocol deadline', self.seen[-3:]) + assert time.monotonic() < deadline, ('protocol deadline', self.diagnostics()) def call(self, method, params): identifier = self.request(method, params) @@ -172,8 +195,8 @@ def begin(self): def wait_retry(self): deadline = time.monotonic() + 8 while sum(row.get('ev') == 'retry' for row in self.trace()) <= self.retry_count: - assert self.proc.poll() is None, 'worker exited before retry' - assert time.monotonic() < deadline, 'retry backoff not reached' + assert self.proc.poll() is None, ('worker exited before retry', self.diagnostics()) + assert time.monotonic() < deadline, ('retry backoff not reached', self.diagnostics()) time.sleep(.01) def cancel(self, wrong=False, client=False): diff --git a/src/git_dirs_tests.zig b/src/git_dirs_tests.zig index 543a5101..f723ef79 100644 --- a/src/git_dirs_tests.zig +++ b/src/git_dirs_tests.zig @@ -113,6 +113,7 @@ fn expectRow(io: Io, arena: Allocator, path: []const u8, want: Want) !void { } test "git_dirs: fixture matrix matches git rev-parse byte for byte" { + if (comptime !supported) return error.SkipZigTest; if (git_dirs.envOverride()) return error.SkipZigTest; const io = std.testing.io; var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); @@ -363,6 +364,7 @@ pub fn oldGit(gpa: Allocator, io: Io, argv: []const []const u8) anyerror!process } test "currentIdentity: a git answer of \"no repository\" is asked again, so a later git init is seen" { + if (comptime !supported) return error.SkipZigTest; if (git_dirs.envOverride()) return error.SkipZigTest; const io = std.testing.io; var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); From 7876355424eced65904e5a9f5e32480a4943033c Mon Sep 17 00:00:00 2001 From: justrach <54503978+justrach@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:35:44 +0800 Subject: [PATCH 2/3] docs: align current CLI release notes Use the approved summary consistently in the bundled release text. --- src/cli_text_source.zig | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/cli_text_source.zig b/src/cli_text_source.zig index 52c3cc43..95f49fd8 100644 --- a/src/cli_text_source.zig +++ b/src/cli_text_source.zig @@ -6,10 +6,9 @@ pub const changelog_text = \\What's new \\────────── \\0.302.74.65 - \\ • startup: a `-p` companion that never answers held the first request for 20.1 s; the prewarm join is now a 250 ms budget (`GRAFF_MCP_COMPANION_BOOT_MS`) and the attempt merges late or stops quietly at teardown — silent companion 20,134 ms -> 268 ms, a healthy one unchanged with its tools on the first request (ADR 0339, #1896) - \\ • startup: a cold provider model-catalog cache no longer blocks boot — the fetch detaches and activates at the next picker beat (REPL and ACP alike); third-party first-keystroke rig 312.0 -> 57.2 ms (ADR 0340, #1896) - \\ • startup: REPL/ACP boot spawns zero git (in-process worktree identity, gated worktree reap), the update banner reads a cached tag instead of a blocking GET, and ACP `initialize` is answered during boot under the AcpEarlyInit Lean kernel (ADRs 0336-0338, #1896) - \\ • acp: output-length continuation exhaustion is explained to the client (partial work preserved; send a new message or resume) instead of surfacing as a bare `OutputLengthExceeded` internal error (#1893) + \\ • Improve startup responsiveness across the REPL, one-shot, and ACP paths. + \\ • Avoid blocking startup on optional companion handshakes and model-catalog refreshes. + \\ • Explain output-length continuation exhaustion in ACP while preserving partial work (#1893). \\ \\0.302.74.64 \\ • mcp: tool schemas are deep-cloned before normalization — mutating the Value copy through shared ArrayHashMap storage corrupted the parsed tools tree, so debug builds segfaulted in mcp_cache.store right after a successful connect (#1892) From cc3e50e76f8612bb805bbd48e960d2f3cfa0afba Mon Sep 17 00:00:00 2001 From: justrach <54503978+justrach@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:59:58 +0800 Subject: [PATCH 3/3] fix: keep JSON input off the dispatch thread Require concurrent input reading so worker saturation cannot block protocol handling. Cover exhausted workers and unavailable concurrency, and ratchet the test baseline. --- scripts/eval/tier1-manifest.json | 2 +- src/json_inbox.zig | 49 ++++++++++++++++++++++++++++++-- src/mainloop.zig | 2 +- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/scripts/eval/tier1-manifest.json b/scripts/eval/tier1-manifest.json index 41101ac1..ca6070dd 100644 --- a/scripts/eval/tier1-manifest.json +++ b/scripts/eval/tier1-manifest.json @@ -14,7 +14,7 @@ "src/main.zig", "src/repl.zig" ], - "test_count_baseline": 3945, + "test_count_baseline": 3947, "test_count_slack": 25, "required_invariants": [ { diff --git a/src/json_inbox.zig b/src/json_inbox.zig index 5ce5b236..3a700937 100644 --- a/src/json_inbox.zig +++ b/src/json_inbox.zig @@ -26,7 +26,7 @@ var queued_cancel = false; var reply_cancel_epoch: u64 = 0; var future: ?Io.Future(void) = null; -pub fn start(gpa: Allocator, io: Io, reader: *Io.Reader) void { +pub fn start(gpa: Allocator, io: Io, reader: *Io.Reader) Io.ConcurrentError!void { std.debug.assert(inbox_io == null); inbox_io = io; inbox_gpa = gpa; @@ -38,7 +38,15 @@ pub fn start(gpa: Allocator, io: Io, reader: *Io.Reader) void { queued_cancel = false; turn_active.store(false, .release); turn_pending.store(false, .release); - future = io.async(readerTask, .{}); + // The pump waits for the next input even after queuing a complete record. + // async may run inline when its worker budget is full, preventing mainloop + // from handling that record (or answering a control request) indefinitely. + future = io.concurrent(readerTask, .{}) catch |err| { + inbox_io = null; + inbox_gpa = null; + inbox_reader = null; + return err; + }; } pub fn stop() void { @@ -198,3 +206,40 @@ test "cancel classification is exact" { try std.testing.expect(!isCancel(std.testing.allocator, "{\"type\":\"user\",\"text\":\"cancel\"}")); try std.testing.expect(!isCancel(std.testing.allocator, "not json")); } + +test "JSON inbox reader never runs inline when async workers are exhausted" { + var threaded = Io.Threaded.init(std.testing.allocator, .{ .async_limit = .nothing }); + defer threaded.deinit(); + const io = threaded.io(); + const Probe = struct { + interface: Io.Reader = .{ .vtable = &.{ .stream = stream }, .buffer = &.{}, .seek = 0, .end = 0 }, + thread: ?std.Thread.Id = null, + + fn stream(r: *Io.Reader, _: *Io.Writer, _: Io.Limit) Io.Reader.StreamError!usize { + const self: *@This() = @alignCast(@fieldParentPtr("interface", r)); + self.thread = std.Thread.getCurrentId(); + return error.EndOfStream; + } + }; + var probe: Probe = .{}; + var buffer: [1]u8 = undefined; + probe.interface.buffer = &buffer; + try start(std.testing.allocator, io, &probe.interface); + defer stop(); + if (future) |*f| f.await(io); + try std.testing.expect(probe.thread != null); + try std.testing.expect(probe.thread.? != std.Thread.getCurrentId()); +} + +test "JSON inbox unavailable concurrency leaves startup retryable" { + var reader: Io.Reader = .fixed("{\"type\":\"set_mode\",\"mode\":\"normal\"}\n"); + const no_concurrency = Io.Threaded.global_single_threaded.io(); + try std.testing.expectError(error.ConcurrencyUnavailable, start(std.testing.allocator, no_concurrency, &reader)); + try std.testing.expect(inbox_io == null and inbox_gpa == null and inbox_reader == null); + try std.testing.expect(future == null); + try start(std.testing.allocator, std.testing.io, &reader); + defer stop(); + const line = (try request(std.testing.allocator, &reader)).?; + defer std.testing.allocator.free(line); + try std.testing.expectEqualStrings("{\"type\":\"set_mode\",\"mode\":\"normal\"}", line); +} diff --git a/src/mainloop.zig b/src/mainloop.zig index 7cf1db33..27e60152 100644 --- a/src/mainloop.zig +++ b/src/mainloop.zig @@ -66,7 +66,7 @@ pub fn run(ctx: *Ctx) !void { // --json, serve and piped stdin have no idle wake: a turn waits for its background work. @import("run_idle.zig").enabled = !ctx.interactive; defer @import("run_idle.zig").enabled = false; - if (main_mod.json_mode) json_inbox.start(ctx.gpa, ctx.io, ctx.in); + if (main_mod.json_mode) try json_inbox.start(ctx.gpa, ctx.io, ctx.in); defer if (main_mod.json_mode) json_inbox.stop(); var title_jobs: mainloop_title.Jobs = .{}; defer title_jobs.deinit(ctx);