Skip to content

feat(compact): tell the model what durable state survived compaction (#411) - #459

Merged
justrach merged 4 commits into
release/0.0.242from
feat/411-post-compaction-note
Aug 6, 2026
Merged

feat(compact): tell the model what durable state survived compaction (#411)#459
justrach merged 4 commits into
release/0.0.242from
feat/411-post-compaction-note

Conversation

@justrach

@justrach justrach commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Closes #411. Stacked on #457 (#441) — that must merge first; this branch is cut from it and uses its activePath accessor.

The most load-bearing piece of prime-agent's compaction design is telling the model what persists. Both halves are implemented.

Half 1: the summary request

A note appended to the compaction summary request: files on disk, goal/todo state and the session transcript all persist, so record paths and state worth remembering. Verified to lead with the byte-identical compact_instruction and to contain no durable-state text itself.

Half 2: the post-compaction note, with real state

field where it comes from
Active goal + todos goal_flow.compactionSnapshot (#318), unchanged. handoffMessage now routes it through compact_note.handoff rather than formatting it itself, so standing state comes first and this note last.
Files modified this session New Snapshots.modifiedPaths over /rewind's ledger, mutex-held, distinct paths in first-modified order.
Transcript path + message count session_transcript.activePath and lineCount(); null means the line is omitted entirely, so it can never cite a file that does not exist.
Tool-result handles Harvested from #409 spill-marker paths in the messages being discarded (see below).

"Files modified" is wired properly rather than omitted. It is exact for write_file/edit_file/imagegen and blind to bash, so the note states that bound inline instead of implying the list is the whole diff. A /rewind already drops the snapshots it undid, so a rewound file correctly stops being listed.

On handles, and an integration note

#440 is not an ancestor of this branch. Rather than emit a blank or fabricated field, handles are implemented from what actually exists on this branch: #409 spill-marker paths, harvested out of the discarded messages. tool_spill now exports its three marker fences and builds the marker from them, so reader and writer cannot drift, and the test drives the real writer rather than a copy of its format.

Only the current compaction's handles are listed, deliberately: re-harvesting the previous note would compound the list without bound.

Follow-up owed at integration: once #440 (PR #451) lands, this harvest should read #440's handle contract, which is the richer and now-default source, rather than only #409's spill markers. The seam is one function in compact_note.zig.

Verification

zig build test exit 0, 1056 tests, +6 from this branch's 1050 baseline. Reachability proven by break-and-revert, not just by count: flipping one assertion produced 1055 pass, 1 fail (1056 total) naming compact_note.zig:309. scripts/eval-tier1.sh green.

src/agent_compact.zig ends at 564 lines, net zero against its branch point — the now-unused prompts/compact_instruction aliases were dropped to pay for the new import, and handoffMessage gained a parameter at equal line count. That file is under heavy concurrent pressure from #445, #440 and #391, so this branch adds none.

Covered by test: the durable note appears only on the handoff; the transcript line vanishes when the live transcript belongs to another session; a duplicate-edited file is named once and the bash caveat is present; a real spill's artifact path is extracted (not the marker prose) and a spill-free history gets no handle line; a subagent gets nothing even with a ledger and a marker hung on it, and neither does a /review turn; with nothing durable the handoff is byte-identical to base; #379's empty-summary escalation still fires on the second unusable summary.

Not covered

compact() needs a live request, so no unit test drives its two call sites. That the request uses summaryRequest and the handoff receives live_messages.items[0..recent_start] is verified by reading and compilation only — the same gap compactPrelude was extracted to close for pinChildTask, not extended here. Multi-compaction behaviour and the emergencyTrim path are also uncovered end to end; the latter still carries only #318's standing state, since no summary is produced there for this note to ride.

Corrects a false premise. #438 (issue #410) shipped a prompt line telling the
model that the session file preserves what compaction discards. It does not:
`.graff/sessions/<name>.session.json` is a SINGLE JSON object whose `messages`
array is rewritten in place, so the next autosave after a compaction drops the
pre-compaction history permanently. "Grep your own conversation log" therefore
had no graff equivalent — the trace and trajectory JSONLs record events for
telemetry, not the conversation for recall.

`.graff/sessions/<name>.transcript.jsonl` is that equivalent: one line per
message as it FIRST enters history, appended by a single positional write at
the end of file and never rewritten. A line is the message's provider-native
JSON verbatim (JSON escapes every control character, so a message is always
exactly one line), which makes the file greppable with no tooling and makes a
resume's re-seed exact — hashing a line reproduces the digest that wrote it.

Hooked at session.queueSave, the point where the autosave already observes a
new message, so none of the history's ~25 mutation sites needs a hook of its
own. Identity is a MULTISET of the digests on disk, not a position: compact()
builds `[handoff summary] ++ recent_messages`, putting the summary at the front
of the history while it is the last line in the file, and any position-based
match would re-append the retained tail behind it. Counting instead records the
summary alone. An ordinary turn takes a fast path (two serializations plus one
per new message) and only a rewrite, a resume, or a session's first append pays
for a full walk.

Bounds, as the issue asks:

  - Subagents excluded. Their history is never persisted, so there is no
    durable session to attach a transcript to — #409's rule, unchanged.
  - One lifecycle, not two. #409's sweep now reclaims transcripts and artifact
    dirs together (tool_spill.sweepSessionsOnce), by the same rule — the
    session file is the ground truth for "this session is gone" — and the same
    grace window. It now also runs at the first transcript append, so a run
    that never spills still collects what deleted sessions left behind.
  - The size cap ROTATES rather than head-truncates. Head-truncation means
    reading the file, dropping a prefix and writing what is left back over it:
    an in-place rewrite of the history, which is the exact failure mode this
    change exists to fix, and one a crash halfway through turns into total
    loss. A rename is one atomic syscall, the old bytes survive intact under a
    sibling name the model greps identically, and disk use is bounded at two
    generations (16 MiB each) rather than merely slowed.

Tests 1043 -> 1050. The rewrite case asserts the pre-compaction file is an
exact byte PREFIX of the post-compaction one and that the discarded detail is
still greppable; repeated autosaves over an unchanged history add nothing; a
resume re-seeds from disk instead of re-appending its restored history; a
subagent writes nothing; rotation leaves the previous generation byte-identical.
Verified end to end against a mock provider: two processes, four turns, four
lines, no duplicates.

session_transcript.activePath(root, arena) is the accessor #411's
post-compaction note should use — null for a subagent or a session with no
transcript, so the note can never cite a file that does not exist, with
lineCount() for its "N messages".

Co-Authored-By: Codegraff <blackfloofie@codegraff.com>
justrach and others added 3 commits August 6, 2026 19:20
One genuine portability defect, not five test bugs. Every Windows failure —
four wrong line counts and one null deref — is the same root cause, and the
CI log names it: each count was "found 0", and activePath was null because
nothing had been recorded.

`appendWhole` opened the file write-only and then called `File.stat` on that
handle to find the append offset. On Windows, Io.Dir.createFile maps `read`
straight onto the NT access mask (`GENERIC = .{ .WRITE = true, .READ =
flags.read }`), so a write-only handle carries FILE_GENERIC_WRITE, which does
NOT include FILE_READ_ATTRIBUTES. `File.stat` is NtQueryInformationFile(.All),
which requires exactly that right, and std handles the resulting ACCESS_DENIED
explicitly. So on Windows the stat failed, `appendWhole` returned null having
ALREADY created the file, and every transcript was a 0-byte file: the feature
was inert on the platform, and #411's note would have cited an empty file.

`.read = true` on the create is the fix; a path-based `statFile` fallback keeps
a future std change from turning this back into a silent no-write rather than
an append. Nothing else about the write changed — it is still one positional
write at the end of file, still append-only.

This class is invisible to the POSIX suite: with the fix reverted, macOS still
runs 1050/1050 green. CI was the only possible signal, which is the argument
for the two hardenings below rather than for trusting a local pass.

The other two suspects, checked and reported rather than assumed:

  - Line endings: NOT a defect. '\n' is written explicitly and JSON escapes
    every control character, so a message is always exactly one line and no
    '\r' can appear. Hardened anyway, since the digests are over line bytes and
    a stray '\r' would silently re-append the entire history: `seed` trims a
    trailing '\r', and the tests now fail hard if a '\r' ever reaches the file.
  - Rotation's rename: NOT a defect. Windows rename does not overwrite, but
    Io.Dir.rename is the REPLACING variant on every OS (dirRenameWindows passes
    replace_if_exists=true; renamePreserve is the one that refuses a taken
    name). The rotation test only ever rotated onto a free name and could not
    have told the difference, so it now rotates twice and asserts the previous
    generation really was replaced.

INVARIANT, now stated where the paths are built (session_index.zig) because two
downstream branches depend on the answer: every `.graff/` path this harness
builds is forward-slashed on every platform, Windows included. Deliberately —
Windows accepts '/' in the paths reaching Io.Dir, and these strings are shown to
the model (#410's prompt line, #409's cap marker, #441's path inside #411's
note), so a shape that changes per platform buys nothing and costs goldens.
Separators were never the Windows failure here; the null deref was a downstream
symptom of the empty file. The corollary is for tests, and ee28d8c is the
precedent: assert on a basename or on a path built through the helpers, never by
matching a separator by hand. Both `activePath` assertions now go through the
accessor and check the cited file exists; the readers build their paths with
`transcriptPath`/`rotatedPath` so a test can no longer disagree with the code.

The tests moved to session_transcript_tests.zig: the hardening pushed the module
to 614 lines, over the ceiling. Reachability needed the `_ = ...` line in
test_hooks.zig's test block, not just the import — the count caught it at 1045
before it was added, and is 1050 again after.

Co-Authored-By: Codegraff <blackfloofie@codegraff.com>
Compaction never said what the summary did NOT have to carry, so the model
treated it as total loss: it hoarded file contents and quoted tool output into
the summary, and the state the harness itself keeps came back only as its own
recollection of it, drifting a little more at every compaction.

Both halves of prime-agent's compaction design, in a new module
(compact_note.zig) so agent_compact.zig keeps its line count:

BEFORE. The summary REQUEST now carries a note saying what persists - every
file on disk, any goal/checklist (restated in full straight after the summary),
and #441's append-only transcript, cited by its real path and message count when
one is live. It asks for NAMES rather than contents and points the summary at
what disk cannot give back: decisions, dead ends, constraints, unfinished work.
The instruction itself still LEADS the request, so #379 classifies an empty or
truncated reply exactly as before.

AFTER. The new history head carries the harness's own ground truth, re-derived
at every compaction rather than copied forward - so a later summary that
paraphrases it away costs nothing, the next compaction regenerates it exactly.
Three fields, each omitted unless real: the files this session modified (from
/rewind's snapshot ledger, which is exact for write_file/edit_file/imagegen and
blind to bash - the note says so rather than passing a partial list off as the
whole diff); the #409 artifact paths the discarded messages' own spill markers
cite, so every handle named was written by a spill that succeeded; and the
transcript path via session_transcript.activePath, which returns null rather
than let the note invent a file. A subagent and a /review turn get nothing, and
a session with nothing durable to report keeps a byte-identical handoff.

Co-Authored-By: Codegraff <blackfloofie@codegraff.com>
…name for #391

#391 independently created src/compact_note.zig for the OTHER side of the same
boundary: the buffer-reserved turn where the model writes notes to itself before
rollover. This module is the harness's own ground truth injected around the
handoff - which is what handoffMessage is and what the note actually rides - so
the name it now has says what the old one only implied, and a future reader
cannot confuse the two halves. #391 owns four files in that cluster, this owns
one, so the rename is the cheap side.

Pure rename plus the local alias (compact_note -> handoff_note) at its two call
sites and one test import. agent_compact.zig stays at 564, still net zero
against its branch point. Suite unchanged at 1056; the module keeps its
reachability through agent_compact.zig's production import, so no test_hooks
wiring changes.

Co-Authored-By: Codegraff <blackfloofie@codegraff.com>
@justrach
justrach force-pushed the feat/411-post-compaction-note branch from 49e6709 to 585d550 Compare August 6, 2026 11:22
@justrach
justrach changed the base branch from main to release/0.0.242 August 6, 2026 12:32
@justrach
justrach merged commit 0986daa into release/0.0.242 Aug 6, 2026
6 checks passed
@justrach
justrach deleted the feat/411-post-compaction-note branch August 6, 2026 12:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Post-compaction note: tell the model what durable state survived (files, goal, todos, transcript)

1 participant