Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions docs/releases/v0.302.74.65.md
Original file line number Diff line number Diff line change
@@ -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).
2 changes: 1 addition & 1 deletion scripts/eval/tier1-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"src/main.zig",
"src/repl.zig"
],
"test_count_baseline": 3945,
"test_count_baseline": 3947,
"test_count_slack": 25,
"required_invariants": [
{
Expand Down
37 changes: 30 additions & 7 deletions scripts/test-retry-cancellation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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']
Expand All @@ -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)
Expand All @@ -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):
Expand Down
7 changes: 3 additions & 4 deletions src/cli_text_source.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/git_dirs_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
49 changes: 47 additions & 2 deletions src/json_inbox.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
}
2 changes: 1 addition & 1 deletion src/mainloop.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading