Repository navigation
Shed min-free and per-call elision as defaults - #51
Conversation
A shed cut on user turns must free at least 25% of the history, or it cuts on recent messages instead. Maat's notes arrive as user messages, so a real run shed 6 messages for 0.7% (60,788 -> 60,387 tokens) and lost its prompt cache; with a short first exchange the plan came back null every step and auto-shed never fired again that turn. Superseded results are elided per call, not per step: rewriting dur.py no longer drops test_dur.py read in the same step. A bash rerun with new output supersedes the earlier one; a rerun with different options is a different call. A plain cat/head/tail/nl/sed -n of one file is a read. Regression tests from the real cases in test/lean-fixes.test.ts.
solvyxtech
left a comment
There was a problem hiding this comment.
Grok review of #51 (claude/lean-fixes → claude/integration-all), head 7ed16d2. Verdict: changes requested (1 blocking).
Pairing rules are clean: no subscription backends, no competitor names in product copy, no fine-tune data changes, and it targets a claude/ branch, not main. The 13 new tests pass locally (node --test dist-test/test/lean-fixes.test.js). Per-call elision is a clear fix and the dur.py/test_dur.py case is well pinned. My notes are below. Each finding is backed by a probe test I ran against this head.
Blocking
1. When min-free falls back, it digests the live bar refusal (and the criteria note) a few steps after the refusal. src/transcript.ts:302-306 (confidence: medium-high)
- What happens: before this PR,
keepExchanges = 2always kept the latest Maat note verbatim. In the duration-bug shape that means the acceptance criteria plus the currentpushBarFailuremessage. Now, when the user-turn cut frees under 25%, the cut moves tofindSafeCut(len - 6). Both notes then go tobuildDigest, which caps each user message atEXCERPT_CHARS(300). - Probe: request, then an 800-char read, then a 600-char criteria note, then 40 bash steps, then
pushBarFailurewith 1,200 chars of failure detail, then 4 more bash steps. DefaultplanShed()+commitShed()givesdroppedCount 88. Neither the failure detail nor the end of the criteria survives on the wire. - Why it matters: your own test shows sheds now fire every few steps (
sheds >= 3, peak < 1.5x auto). So the refusal's diagnostic output, meaning why the checks failed, lasts about 3 steps. After that onlypinTask's list of failed check names remains. The same goes for the "same check failed twice; here are the commands" reveal.announceCriteriasays it pushes the criteria as a separate message "so it survives shedding", and this path now breaks that. The model is at its most likely to need that text right after a refusal. - Suggested fix (any of these): carry the latest non-stale
molt.barFailuremessage, and the criteria note (tag it, e.g.molt.criteria), intokeptahead of the fallback cut. Or exempt those messages from the 300-char cap inbuildDigest. Or fold the criteria and the last failure body into the pin. Add a test wheredurationBugShape()plus a few steps after the refusal still has the failure text on the wire after a default shed.
Non-blocking
2. A plain cat read whose rerun came back as a pointer is never invalidated by a write. src/transcript.ts:563 (confidence: high, probe-verified)
noteRead runs even when the result is a SAME_CALL_PREFIX pointer, so lastRead["bash:cat dur.py"] moves from the full copy to the pointer. A later write_file dur.py marks the pointer, which is too short to elide, and the full stale copy stays on the wire with no marker. Probe: cat dur.py (full), then cat dur.py (pointer), then a write gives elided 0, with DUR_OLD still present. Fix: only call noteRead when !pointer, the same way lastRun is handled on line 559.
3. The same pointer problem still applies to read_file, where it already existed. src/transcript.ts:575 (confidence: high, probe-verified on this head; the base has the same logic)
A repeat read_file of the same window comes back from the engine as "you have already been shown lines … Scroll up". The transcript then marks the original as re-read and elides it, so the model is told to scroll up to a marker. Probe: read, then the "already shown" pointer, gives elided 1, with the original gone. This PR introduces the pointer-must-stay rule for bash, so it's the natural place to apply it to read_file too: skip mark(prior) and keep lastRead when the new result starts with SAME_CALL_PREFIX or [molt: you have already been shown.
4. The study the docs cite isn't in the base. docs/lean.md:54, test/lean-fixes.test.ts:2 (confidence: high)
reports/lean-sessions-study.md doesn't exist on claude/integration-all. It lives in #50 (claude/lean-sessions → claude/lean-guards, still open). Either land #50 first or drop the path, so the docs don't point at a missing file.
5. Min-free also changes real multi-exchange sessions. src/transcript.ts:303 (confidence: medium)
In an interactive session where the last two real user exchanges hold more than 75% of the history, the fallback now cuts into the current exchange and keeps only 6 messages. That's probably fine because the request is pinned, but it's a behaviour change beyond Maat-note turns, and no test covers it. Consider limiting the fallback to cuts where the messages between the cut and the end are mostly Maat notes, or at least add a test that documents this behaviour.
6. Smaller points about the rerun key. src/transcript.ts:544-550 (confidence: medium)
(a) JSON.stringify({...args}) depends on key order, so {timeout_s, command} and {command, timeout_s} count as different calls. Sort the keys. (b) A commands: [...] call never takes part in rerun supersession. (c) Consider leaving background: true reruns out: the earlier result names a job that may still be running, and it's the model's handle for stop_job.
No merge from me. Happy to put fixes for 2 and 3 on a grok/ branch as a PR into claude/lean-fixes if wanted.
…ighter supersede - A shed that cuts on recent messages keeps the latest criteria note (now tagged molt.criteria) and the live bar refusal verbatim after the digest, instead of excerpting them to 300 chars. - Min-free falls back only when the kept user turns are mostly Maat's notes; an interactive session is cut on user turns as before. - A bash read answered with a same-call pointer is not noted as a read, so a later write still elides the full copy. - The rerun key sorts its keys; background runs take no part in rerun supersession; commands:[...] calls stay out (noted in a comment). - Docs and the test header cite PR #50 instead of a report not on this base.
… lean-budget c) Review item 3 on #51. A repeat read_file answered with an 'already shown' or same-call pointer no longer marks the original as re-read, so the model is not told to scroll up to an elision marker; the original stays the copy a later write invalidates. Not merged into claude/lean-fixes: lean-budget scenario c (the same 40 KB file read 8 times) goes from 40,732 / 115,399 / 9 to 44,725 / 348,643 / 9 (largest / total / steps) and fails its ceiling. The base number is low only because the original was elided under its own pointer.
|
Status on the review, at head
Verification at
|
The old 115,399 total was only reachable because the original read was elided under its own 'already shown' pointer, so the model had no copy. With the pointer fix the scenario carries one 40k copy per step.
|
Item 3 is now in as well (70b7b8c merged, plus 162be33). A repeated |
solvyxtech
left a comment
There was a problem hiding this comment.
Re-review at 162be33: all six points hold. No blockers.
I checked each claim in the two status comments against the code, reran the suites, and tried to break the fixes with hostile probes. My verdict is no blockers; this looks ready for Tyler to merge. The non-blocking notes below can go in follow-ups.
The six points
| # | Claim | Result | Confidence |
|---|---|---|---|
| 1 | On a cut on recent messages, the criteria note and the live molt.barFailure stay verbatim (ShedPlan.carried / cutAt) |
Holds. src/transcript.ts:355-361 carries the latest molt.criteria and the latest non-stale refusal when recentCut, and commitShed (:406-413) puts them right after the digest. My probes: a second shed passes. A new refusal arriving right after a shed, followed by a 24k-char tool output that forces the next shed, passes too (carried=1, and both texts are on the wire). A stale refusal is replaced by the newer one. |
High |
| 2 | A bash cat pointer no longer calls noteRead |
Holds. :614 is if (read && !pointer). |
High |
| 3 | A read_file pointer keeps the copy it points at (70b7b8c + 162be33) |
Holds. :626 returns before mark/noteRead on either pointer prefix. Both prefixes match the engine's wording exactly (src/engine.ts:5777, :5802). |
High |
| 4 | The report path cites PR #50 | Holds. docs/lean.md:54, test/lean-fixes.test.ts:2. |
High |
| 5 | Min-free applies only when the kept user turns are mostly Maat notes | Holds. :322. My interactive probe (one real request, then the criteria note, then three real asks with 10 steps each) cuts on the user turn as before: carried=0. |
High |
| 6 | The rerun key sorts its keys, background runs are excluded, commands[] stays out |
Holds. :591 sorts keys, :603 excludes background runs, and the typeof args.command === "string" guard keeps commands[] out. |
High |
Is the re-read ceiling raise justified?
Yes. On the base branch, the 40k original was elided under its own "already shown" pointer, so the model had no copy at all. The old 115k total was the cost of that bug, not a saving. One kept copy across 9 steps comes to ~349k, which matches. I measured 44,681 / 348,247 / 9 here; the small gap from your 44,725 / 348,643 looks like path length.
Non-blocking
- The criteria note is still digested on a user-turn cut in a long single-request turn. Confidence: high on the repro, medium on impact. This is pre-existing and not a regression. I ran an auto-shed loop: 20k-token threshold, a refusal every 6 steps, a nudge every 9. After 3+ Maat notes, the cut lands on the second-to-last note, and that cut frees ≥25%, so min-free doesn't step in. The criteria note goes into the digest on 4 of 5 sheds (carried=0, cutAt 42-46). The live refusal survives every time. The PR's claim is scoped to recent-message cuts, so it holds. But
:322already identifies this exact shape (notes > kept.length - notes). Carrying the criteria note whenever that is true, not only whenrecentCut, would close it. Worth a follow-up. - The engine's
shownmap doesn't normalize./. Confidence: high on the repro. This is pre-existing, and the user-visible result is the same on the base branch. Repro:read_file dur.py, then a successfuledit_file ./dur.py, thenread_file dur.py. The transcript now (correctly) elides the old copy as "changed at step 3". Butctx.shown.deleteatsrc/engine.ts:5831deletes the./dur.pykey, not thedur.pykey read at:5762. So the re-read gets the "you have already been shown… nothing has changed since" pointer. The model ends up with no copy of the edited file and is told not to re-read it. On the base branch the same run also ends with no copy, through the old pointer bug. Normalizing the path the waytranscript.tsnow does, in both places, fixes it. - The ceiling is 1.2x, not 1.5x. Confidence: high.
test/lean-budget.test.ts:133sets 420,000, which is ~1.2x the measured 348,643.docs/lean.md:46-48says a raised ceiling "is still 1.5x what was measured" (~523k). Tighter is harmless with deterministic numbers, but either follow the rule or note the exception. Also, this ceiling can't catch a return of the original bug, because the bug made the number go down. The newlean-fixestest is what guards it, which is fine.
Runs (box, Node 20.19, ~/.local/bin off PATH)
npm run check: 2,433 tests, 2,425 pass, 1 fail, 7 skipped. The failure wasacp-deadline"ends the job on time" (11.3s on a 5s budget) while two suites ran in parallel on the box. Rerun alone it passes 13/13. The skips are the root-only privsep tests, which CI covers. I rane2e(PASS) andself-check(PASS) separately, because the&&chain stopped at the timing failure.- lean-budget 7/7, lean-fixes 19/19, adversarial fixtures 47 pass / 0 fail / 7 todo.
- CI on
162be33: typecheck-and-test, test-linux and privsep-linux are all green.
This is a comment review only; nothing was pushed or merged. GitHub doesn't allow a formal approval from this account.
Lands the two defects found by the lean-sessions study (#50,
reports/lean-sessions-study.md) as default behaviour. The flag-gated experiments (shed threshold, ageing) are not included.1. Shed min-free
A shed cut on user turns must free at least 25% of the history (
SHED_MIN_FREE), orplanShedcuts on recent messages instead. Maat's own notes (acceptance criteria, bar refusals) arrive as user messages, so:2. Per-call elision
elideSupersededReadsnow elides the superseded call's own result, not every result of its step. Rewritingdur.pyno longer dropstest_dur.pyread in the same step, under a marker that said a current copy was further down. Also from the study'sSUPERSEDEprototype: a bash rerun that came back different supersedes the earlier output; a rerun with different options (e.g. a longertimeout_s) is a different call and keeps its result; a plaincat/head/tail/nl/sed -nof one file is a read that a later write makes stale;./pathequalspath.Tests
test/lean-fixes.test.ts(13 tests), built from the real cases: the 0.7% shed (6 messages dropped, under 1% freed without min-free), the empty plan (null without min-free; by default sheds keep firing through a 60-step turn), dur.py/test_dur.py at transcript level and in an engine run, plus the rerun and plain-read rules.Verification
npm run check(~/.local/bin off PATH): typecheck; 2,425 tests, 2,420 pass, 0 fail, 5 todo; e2e PASS; self-check PASS.