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
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,23 @@ The release workflow uses a tag's section here as its release notes (a
hand-written `docs/releases/<tag>.md` wins if present), so keeping this file
current is part of cutting a release.

## v0.0.240 (unreleased)
## v0.0.241 (unreleased)

- A no-progress `eval` is no longer paid for (#412). A goal/loop run
re-verifies on every continuation, so a model that has edited nothing since
the last RED re-ran the whole `--eval` command — plus a `--judge` model call,
plus 1500 bytes of output tail — to re-derive a verdict that could not have
changed. graff now fingerprints the verification before running it (the eval
command text, `git status --porcelain -z -uall`, `git diff --binary HEAD`,
and the contents of every untracked file, since the first two are blind to an
untracked edit); identical to the tree the last verification failed on means
the verifier is not run at all. The attempt still counts, so a stuck loop
converges on its iteration cap instead of spinning for free, and the model is
steered at the real blocker: the workspace has not changed, edit something
first. Fail-open throughout — no repo, no git, a timed-out or truncated
probe, an unreadable file all read as "changed" and the verifier runs.

## v0.0.240 (2026-08-06)

- The REPL/engine separation began (#422): agent output now flows through a
typed event vocabulary and a strict sink boundary (`engine_events.zig` /
Expand Down
1 change: 1 addition & 0 deletions src/agent.zig
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ pub const Agent = struct {
eval_verified: bool = false, // latest workspace state has a target-meeting verifier result
eval_repair_pending: bool = false, // a contradiction blocks completion until a fresh green eval
eval_repair_grants: u8 = 0, // RED continuations consumed (agent_steps.grantRepairTurn); reset by any green eval, capped by eval_control.max_repair_grants
eval_fp: ?[32]u8 = null, // #412: worktree fingerprint at the last RED eval (verify_fingerprint.Digest); an identical tree skips the re-verify instead of paying for it
strict: bool = false,
completed: ?[]const u8 = null,
last_context_tokens: u64 = 0,
Expand Down
25 changes: 25 additions & 0 deletions src/agent_eval.zig
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const playbook_reflect = @import("playbook_reflect.zig"); // #383 Reflector: one
const orch_rows = @import("orchestration_rows.zig"); // the orchestration outcome rides the same score funnel
const shapes = @import("shapes.zig");
const failure_evidence = @import("failure_evidence.zig"); // a RED verdict is parked as escalation evidence
const verify_fingerprint = @import("verify_fingerprint.zig"); // #412 no-progress guard: an unmoved worktree is not re-verified

test {
_ = @import("agent_eval_tests.zig");
Expand All @@ -43,6 +44,21 @@ pub fn runEval(self: *Agent, note: []const u8) !ExecResult {
.is_error = true,
};

// #412 no-progress guard. The tree the verifier is about to run against is
// fingerprinted BEFORE the command runs (the command itself may write), and
// when the last verdict was RED over a byte-identical tree the verifier is
// not run at all: nothing it could report can have changed, so the whole
// scoring command, its judge model call and its output tail are saved and
// the model is steered at the real blocker instead. The attempt still
// counts - a model that keeps calling eval without editing has to converge
// on the iteration cap, not spin for free. Unknown (no repo, git error,
// truncated probe) never matches, so the guard fails open.
const tree_fp = verify_fingerprint.capture(self.gpa, self.io, cmd);
if (verify_fingerprint.skipReverify(self.eval_repair_pending, self.eval_fp, tree_fp)) {
self.eval_iter += 1;
return .{ .text = try verify_fingerprint.noProgressText(self.arena, self.eval_iter), .is_error = true };
}

// Behavioral commitment (issue #256): the eval-driven loop is the first
// production caller of turn_committed/model_mispredicted. The commitment
// asserts the loop's own belief - the command will meet the target -
Expand All @@ -64,6 +80,11 @@ pub fn runEval(self: *Agent, note: []const u8) !ExecResult {
if (behavior) |bt| bt.recordMisprediction(eval_turn, commitment_id, .{ .pass = true }, .{ .pass = false, .exit = @as(i32, -1) }, "eval command could not run");
self.eval_verified = false;
self.eval_repair_pending = true;
// #412: a command that never ran is not a verdict about the workspace,
// and "edit source files" is not the fix for it - disarm the guard so
// the next call really does try the command again (and so a fingerprint
// left by an earlier RED cannot suppress it).
self.eval_fp = null;
eval_memory.record(self, note, null, -1, false);
return .{ .text = try std.fmt.allocPrint(self.arena, "eval command could not run: {t}", .{e}), .is_error = true };
};
Expand Down Expand Up @@ -104,6 +125,10 @@ pub fn runEval(self: *Agent, note: []const u8) !ExecResult {
const met = if (combined) |s| s >= target_f else false;
self.eval_repair_pending = exit_code != 0 or !met;
self.eval_verified = !self.eval_repair_pending;
// #412: a RED remembers the tree it failed on, so the next eval over that
// same tree is skipped; a green forgets it, because the next RED must be
// measured against its own tree and never against a stale one.
self.eval_fp = if (self.eval_repair_pending) tree_fp else null;
// A RED verdict is harness ground truth about a FAILED attempt: park a
// capped excerpt so the next escalation decision carries the evidence
// (the R0d revision advisory, or an R3 fleet's briefs).
Expand Down
111 changes: 111 additions & 0 deletions src/agent_eval_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const trace = @import("trace.zig");
const Tracer = trace.Tracer;
const btrace = @import("behavior_trace.zig");
const BehaviorTrace = btrace.BehaviorTrace;
const process_runner = @import("process_runner.zig");
const verify_fingerprint = @import("verify_fingerprint.zig");

test "runEval: commits before the command runs, mispredicts on a missed target, and leaves a met target commitment-only (#256)" {
// The chdir below is POSIX-only, matching session_start.zig's own
Expand Down Expand Up @@ -130,3 +132,112 @@ test "runEval: commits before the command runs, mispredicts on a missed target,
// success signal (docs/behavioral-trajectories.md).
try std.testing.expect(lines.next() == null);
}

/// Run a command in the process cwd, failing the test if it did not succeed.
/// The #412 test needs a REAL git repo: reading git is the whole feature, and
/// a mocked probe would prove nothing about the porcelain/diff/untracked split.
fn fixtureCmd(gpa: std.mem.Allocator, io: Io, argv: []const []const u8) !void {
const r = process_runner.runCapped(gpa, io, argv, 1 << 16, 1 << 16, 60_000) catch
return error.SkipZigTest; // no git on this machine: skip rather than fail on the environment
defer gpa.free(r.stdout);
defer gpa.free(r.stderr);
if (!process_runner.ranOk(r)) return error.FixtureCommandFailed;
}

/// How many times the --eval command was actually SPAWNED. The counter lives
/// under .graff/, which the fixture repo gitignores, so counting can never
/// itself move the fingerprint the guard is reading.
fn verifierCalls(gpa: std.mem.Allocator, io: Io) usize {
const body = Io.Dir.cwd().readFileAlloc(io, ".graff/verifier-calls", gpa, .limited(4096)) catch return 0;
defer gpa.free(body);
return body.len;
}

test "#412: an unchanged worktree is not re-verified, and any change re-arms the verifier" {
// POSIX-only for the same reason as the test above: the fchdir isolation.
if (builtin.os.tag == .windows) return;
const gpa = std.testing.allocator;
const io = std.testing.io;

var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var orig_dir = try Io.Dir.cwd().openDir(io, ".", .{});
defer orig_dir.close(io);
defer _ = std.posix.system.fchdir(orig_dir.handle);
if (std.posix.system.fchdir(tmp.dir.handle) != 0) return error.ChdirFailed;

// A real single-commit repo. `.graff/` is ignored so the harness's own eval
// log, its notes and the call counter can never read as work the model did
// - without that the tree would move on every eval and never skip.
try Io.Dir.cwd().writeFile(io, .{ .sub_path = ".gitignore", .data = ".graff/\n" });
try Io.Dir.cwd().writeFile(io, .{ .sub_path = "work.txt", .data = "one\n" });
try fixtureCmd(gpa, io, &.{ "git", "init", "-q" });
try fixtureCmd(gpa, io, &.{ "git", "add", "-A" });
try fixtureCmd(gpa, io, &.{ "git", "-c", "user.email=t@example.com", "-c", "user.name=t", "-c", "commit.gpgsign=false", "commit", "-q", "--no-verify", "-m", "fixture" });

var arena_state = std.heap.ArenaAllocator.init(gpa);
defer arena_state.deinit();
var agent: Agent = .{
.gpa = gpa,
.arena = arena_state.allocator(),
.io = io,
.client = undefined,
.provider = undefined,
.messages = undefined,
.sub = false,
.label = "test",
.out = null,
.tracer = null,
// Always RED, and it leaves one byte behind per real invocation.
.eval_cmd = "mkdir -p .graff && printf x >> .graff/verifier-calls; exit 1",
.eval_target = 90,
};

// Attempt 1: nothing to compare against, so the verifier runs. Its RED
// verdict arms the guard over the tree it failed on.
const first = try agent.runEval("");
try std.testing.expect(!first.is_error); // a RED is a verdict, not a tool error
try std.testing.expectEqual(@as(usize, 1), verifierCalls(gpa, io));
try std.testing.expect(agent.eval_repair_pending);
try std.testing.expect(agent.eval_fp != null);

// Attempt 2, workspace untouched. This is the whole feature: before it, a
// /goal continuation re-ran the entire scoring command (plus a --judge
// model call) to re-derive a verdict that could not have changed.
const second = try agent.runEval("");
try std.testing.expectEqual(@as(usize, 1), verifierCalls(gpa, io)); // NOT spawned
try std.testing.expectEqual(@as(u32, 2), agent.eval_iter); // but the attempt still counts
try std.testing.expect(second.is_error);
try std.testing.expect(std.mem.indexOf(u8, second.text, verify_fingerprint.no_progress_steer) != null);
try std.testing.expect(std.mem.indexOf(u8, second.text, "eval output (tail)") == null); // no verdict was manufactured
// A skipped run verifies nothing, so completion stays blocked.
try std.testing.expect(agent.eval_repair_pending and !agent.eval_verified);

// A TRACKED edit is progress: the verifier runs again...
try Io.Dir.cwd().writeFile(io, .{ .sub_path = "work.txt", .data = "two\n" });
_ = try agent.runEval("");
try std.testing.expectEqual(@as(usize, 2), verifierCalls(gpa, io));
// ...and its RED arms the skip over the NEW tree, not the old one.
_ = try agent.runEval("");
try std.testing.expectEqual(@as(usize, 2), verifierCalls(gpa, io));

// An UNTRACKED file is progress too, and its CONTENTS are what prove it:
// `git status --porcelain` says `?? scratch.txt` either way and
// `git diff HEAD` stays empty, so a fingerprint built from those two alone
// would skip both of these attempts over real work.
try Io.Dir.cwd().writeFile(io, .{ .sub_path = "scratch.txt", .data = "a" });
_ = try agent.runEval("");
try std.testing.expectEqual(@as(usize, 3), verifierCalls(gpa, io));
try Io.Dir.cwd().writeFile(io, .{ .sub_path = "scratch.txt", .data = "b" });
_ = try agent.runEval("");
try std.testing.expectEqual(@as(usize, 4), verifierCalls(gpa, io));

// Six attempts, four verifier runs: the two no-progress ones were free.
try std.testing.expectEqual(@as(u32, 6), agent.eval_iter);

// A green eval disarms the guard entirely - the next RED must be measured
// against its own tree, never against one this run already left behind.
agent.eval_cmd = "printf 'score: 100\\n'";
_ = try agent.runEval("");
try std.testing.expect(agent.eval_verified and agent.eval_fp == null);
}
5 changes: 5 additions & 0 deletions src/test_hooks.zig
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ const credential_store = @import("credential_store.zig");
const engine_events = @import("engine_events.zig");
const engine_sink = @import("engine_sink.zig");

// #412: the worktree fingerprint behind the no-progress verify guard.
// agent_eval.zig reaches it through a CALL only, which analyses nothing.
const verify_fingerprint = @import("verify_fingerprint.zig");

test {
_ = learn_holdout;
_ = learn_receipt;
Expand Down Expand Up @@ -169,6 +173,7 @@ test {
_ = credential_store;
_ = engine_events;
_ = engine_sink;
_ = verify_fingerprint;
_ = escalation;
_ = escalation_tests;
_ = edit_contract;
Expand Down
Loading