fix(ledger): report the data a state read discards instead of losing it silently - #6017
fix(ledger): report the data a state read discards instead of losing it silently#6017leonlaiyc wants to merge 1 commit into
Conversation
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of All checks done. The base corroborates the cited residual ( First-Principles-Verdict: CONCERNS The cited #5970 residual names the write-side What this change shipsIntent: make the ledger's read-time coercion report the caller data it permanently discards, instead of trimming and persisting it silently — a FIX (observability of an existing loss), with checkable provenance: the drift-guard at Inventory (5 items)
WatchPoint patch on the loss class it cites. [FIRST-PRINCIPLES-REVIEWED] 7745c92 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed |
Design Review (Fable 5, fork) — 🟡 CONCERNSDesign-level review of The base module confirms the PR's claims: Design-Verdict: CONCERNS The loud-about-loss report lands on the rare read path; the motivating loss — Watch
Suggestions
[DESIGN-REVIEWED] 7745c92 |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
c4a99e8 to
eabf879
Compare
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
|
Kiro Crew [operator: chenmingwei23]: This PR has been inactive for 7+ days with failing CI. I've assessed the blockers and they appear resolvable -- I'll push fixes directly to this branch as a co-author. Assessment: Merge conflict with main. GPT 5.6 review passes clean; the other review lanes only 'could not complete' (transient model errors, not findings). The one substantive red is Gateway Tests (macOS), and the failing test (TestAdoptedDriftRecheck::test_the_recheck_is_rate_limited_to_its_own_interval) is unrelated to this ledger-coercion change and ran on a memory-starved runner -- a pre-existing flake, not your regression. Plan: rebase onto main, re-run the macOS lane, confirm it clears. If you'd prefer I don't touch this PR, add the pr-no-autofix label. |
…it silently `_coerce_state` applies six caps, and each one throws caller data away: `_clamp` shortens any text past `_MAX_TEXT`, `tried` and `events` keep only a bounded tail, and `artifacts` stops accepting entries at `_MAX_ARTIFACTS` — in insertion order, so which ones survive is arbitrary from the writer's side. None of it was reported. `record` reads, mutates and writes the coerced record straight back, so the first overflow is persisted and the data is gone for good. The same reader already WARNs when it discards a whole file over `_MAX_STATE_BYTES`. A partial discard is the same loss in a smaller quantity, so report it the same way: one WARNING per read naming each bound hit and how many entries it cost. A wrong-typed field reset to its default is the documented coercion contract, not a discard, and is deliberately not counted. The line is self-limiting — the write-back leaves the next read nothing to trim. Residual named by the reviewer on merged kirodotdev#5970. Spec updated in the same commit per AGENTS.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
eabf879 to
7745c92
Compare
|
Rebased onto main Conflicts and resolutions:
Gates run locally on changed files: black, isort, flake8, and pytest on Please review the resolution. The maintainer push makes the maintainer the last pusher, so under the repo's last-push rule a second approver is needed. Reply if anything looks wrong. |
Problem / Motivation
_coerce_statefolds whatever is on disk into a well-typed ledger record. Sixof its rules throw caller data away, and none of them said so:
_clamp(..., _MAX_TEXT)goal,phase,next, and the timestamps_clampinsidetried[]/events[]tried[-_MAX_TRIED:]events[-_MAX_EVENTS:]len(arts) < _MAX_ARTIFACTSThis is not a display-time trim.
record()opens withstate = _read_state_unlocked(dir_path)and closes by writing the record back,so the first read that overflows a cap persists the shortened version. The
data is then gone from disk permanently, and nothing anywhere says it existed.
The same function's neighbour is already loud about exactly this failure at a
coarser grain:
So the module discards a whole file with a warning and discards half its
contents in silence.
This is the residual the reviewer named twice on merged #5970:
Why it matters
The ledger is the conductor's memory of what it has already tried. The two
biggest caps are the two that hurt most when they fire quietly:
triedages out the oldest approaches. Those are precisely the ones theagent is most likely to repeat, because nothing in its context says it tried
them. A silent cap turns "the ledger stops it looping" into "the ledger loops
it back to the start".
artifactsdrops by insertion order once past 32, so the writer cannot evenpredict which ones it lost.
Because the write-back persists the trim, the loss is unrecoverable and leaves
no trace — no log line, no counter, nothing in the record itself. An operator
debugging a conductor that keeps re-trying a rejected approach has no way to
discover that its history was truncated three cycles ago.
Of the two remedies the reviewer offered, this takes the second. Rejecting
an oversized write would change acceptance semantics for every caller and needs
a product decision about what a writer should do when refused. Logging needs
none: it changes nothing about what is stored and makes the existing loss
diagnosable. The stored values are byte-identical before and after this diff,
and a test asserts that.
What changed (motivation → approach → change)
The root cause is that the caps were written as expressions (
value[:limit],items[-cap:],len(arts) < cap) whose discard is invisible at the point ofuse. Making it visible means tallying at each site and reporting once:
_clamp_tallied(value, limit, label, lost)sits beside_clamp, whichstays pure for the write-side callers that do not participate in a read's
tally. It records a truncation only when it actually shortened a string the
caller supplied in full.
tried,eventsandartifactscaps record a count rather than oneline per entry, so a record 40 artifacts over the limit produces one clause,
not forty.
_coerce_stateemits a single WARNING at the end naming every bound it hit,widest loss first, tagged with the ledger directory name — which the module
documents as a non-decodable fold, so it identifies the record without
revealing the slot key.
artifactsloop is restructured so the wrong-type skip and the cap skipare separate branches. They were one condition, which is why the cap drop had
nowhere to be counted.
Deliberately not counted: a wrong-typed field reset to its default. That is
the coercion contract this function documents ("known fields with the wrong type
are reset to their defaults"), and a newer writer's forward-compatible field
would otherwise produce a warning on every read for behaving exactly as
specified.
docs/system-specs/features/session-work-ledger.md§8 is updated in the samecommit per
AGENTS.md, beside the existing over-ceiling entry it now parallels.Tests
New
TestCoercionIsLoudAboutLossintest/test_session_ledger.py, driving_coerce_statedirectly and readingcaplog:test_an_oversized_field_is_named— the field is named, and the value isstill trimmed to exactly
_MAX_TEXT.test_aged_out_entries_are_counted—triedandeventsseven over theircaps report
x7each, and the kept lengths are unchanged.test_dropped_artifacts_are_counted— five over the cap reportsx5.test_one_line_per_read_not_one_per_field— three oversized events collapseto
events[].text truncated x3inside a single warning.Three controls, which pass both before and after, because they are what
makes this safe rather than what proves the defect:
test_a_clean_record_logs_nothing— the one that makes it shippable. Withoutit, a WARNING on every ledger read would be noise.
test_a_wrong_typed_value_is_not_reported_as_loss— pins the deliberateexclusion above, and that the values still reset as documented.
test_the_report_is_self_limiting_across_a_record_cycle— the answer to"won't this warn forever?". It seeds a real ledger, corrupts the file with an
oversized
goal, and asserts the nextrecord()warns once and the oneafter that warns not at all, because the write-back removed the overflow.
Red-before, measured against pristine
origin/mainproduction code(
1fab46224) with the new tests in place — 5 failed / 46 passed / 1 skipped:The three controls pass on main, as controls should.
Green-after: 51 passed / 1 skipped in that module; 115 passed / 1 skipped
across
test_session_ledger.py,test_conductor_ledger_entry.pyandtest_capability_ledger.py.flake8,isort,mypyandblackare clean on both files, andscripts/check_black_formatting.py/scripts/check_subprocess_encoding.pyboth pass on the real
origin/main...HEADscope.After rebasing over the merged system-spec audit (#6946), the loss-reporting contract is integrated into the current compact
State record and bounded writessection rather than restoring the superseded proposal-era document.scripts/docs_lint.py --test, the baseline-aware Black gate, the subprocess-encoding gate, andgit diff --checkpass on the rebased 3-file scope.Manual verification
N/A — unit coverage sufficient: the change is a log line derived from data the
function already computes, and the tests assert both the message and that the
coerced values are unchanged.
Related Issues
Residual named by the reviewer on merged #5970 (
feat(conductor): one code owner for the ledger item entry (#5912)). No separate issue was filed. #4371 alsotouches
test/test_session_ledger.py, in a different area of the file — atextual conflict is possible, a semantic one is not.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)Contribution License Agreement