Repository navigation
fix(debt): say unknown, not healthy, when a read fails; hold critical while a refused group stands - #272
Conversation
… while a refused group stands getCompressionDebt() answered `healthy`, with every count 0, whenever anything inside it threw: its catch returned the empty report and logged nothing. A compression quarantine ledger it couldn't read counted as no records, so a store holding one read `healthy` with its other counts right. fleet-watch's derive shows such an agent UP. For a report that threw, connectome-host's debt stamp would send 0 pending chunks where there were three. scripts/drain-autobiographical.ts finished with exit 0 on a store whose quarantine ledger it couldn't read. The report has a fourth state, `unknown`, and `error` says what it couldn't read and why. A count it couldn't compute is null, never 0: - When only the ledger can't be read, only compressionQuarantineCount is null. The state is worked out as if the ledger held no record and as if it held one. Where both agree, that's the state, and where they differ it's `unknown`. - When anything else fails, the state is `unknown` and every count is null. - A failure is logged once through the strategy's diagnostics, and again only after a report succeeds or the failure's text changes. The first report that reads after a failure logs one line saying so. - The report still never throws. A refused crossed merge held `critical` through a counter of refusals since the process started. So an operator's clearMergeQuarantine() left it critical until a restart, and a restart dropped it to `degraded` while the group's record still stood. `topologyRefusals` now counts the merge-quarantine records whose lastOutcome is topology_violation, and `critical` reads that, so it lasts as long as such a record stands. The drain script stops before a tick when the report has an error, and exits 1 when its last report has one. The audit script prints the error beside the state. A new test file runs both scripts with the ledger read failing: once the store is open, or only in the drain's last report. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The PR appears safe to merge; no actionable issues were found. Summary
Reviews (1) · Last reviewed commit: "fix(debt): say unknown, not healthy, whe..." · Reviewed by Greptile |
…op a compression attempt with the same words
The report's `error` named a failure in the library's terms ("the
compression quarantine ledger couldn't be read: …"), and a host that reports
a failed compression tick showed only the store's own error. Neither told an
operator what it meant for the agent or what to do.
- The debt report's `error` now says what happened, what it means and what
to do: a line saying what can't happen, then `Cause:`, `Effect:` and
`Fix:` lines.
- The ledger: compression can't run, since each attempt reads the ledger
before calling the model; no new conversation is summarized while it
lasts, and once the conversation it hasn't summarized no longer fits in
its context budget, its turns fail until compression catches up; fix
what the cause names, and compression resumes by itself once the ledger
reads.
- The report itself: outside the ledger it reads only the strategy's own
state, so a throw there points to a fault in context-manager, and
compression itself may be running normally.
- `failure` names which read failed, `quarantine-ledger` or `report`,
whenever `error` is present, so a host can headline each and add its own
next step.
- A compression attempt that can't read the ledger stops with the same words
(CompressionQuarantineUnreadableError, with the store's error as its
`cause`), so a host that reports a failed tick shows the operator what it
means. Only the attempt's first read is wrapped: a failure that lasts
always stops there, and load, the sweep and an operator's clear meet the
failure in their own terms.
- The log line is the error whole, with the namespace on its first line and
without its old tail; the audit script prints the error's lines under its
summary.
By run (test/compression-quarantine-unreadable.test.ts): with the ledger's
state unreadable, two ticks reject with the new message and the store's error
as their cause, with no model call and nothing summarized; once it reads, the
next tick summarizes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Anarchid
left a comment
There was a problem hiding this comment.
Nightly dual review · round R1 at e3a4dc8 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: APPROVE (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.
Reviewed at e3a4dc8 against main cfd3147 (the PR's merge base is 75cc98e; it merges cleanly onto cfd3147)
Verdict: approve. Two minor findings are worth fixing before merge, and neither blocks.
The core change is right. A health report that can't read its inputs now says unknown with an error and null counts instead of healthy with zeros. Deciding the state twice for an unreadable ledger is exhaustive, and moving topologyRefusals from a process counter to the persisted records fixes both of the bugs it describes. The two findings below come from that same move and from the new log-once state. Each has a small, local fix.
This is round 1. We have no earlier review on this PR, so there are no prior findings to disposition.
Blockers
None.
Major
None.
Minor
R1-1. topologyRefusals still counts a refusal record whose group has since been paid off. So scripts/audit-topology.ts now prints critical where main prints degraded.
-
Evidence:
src/strategies/autobiographical.ts:7568-7571counts every merge-quarantine record withlastOutcome === 'topology_violation', andstateWith(:7582) returnscriticalfor any of them.- Such a record only goes away when
sweepPaidOffMergeQuarantine()runs (:6441-6461, deleting a record once any source is parented or gone). That sweep runs in two places: at load (:2523-2529), which is skipped underauditOnlyand for orphaned chunk records, and fromtick()throughsoundMergeQuarantineAlarmIfNeeded()(:6476,:6899). scripts/audit-topology.ts:36opens the store withauditOnly: trueand never ticks.
-
Failure scenario (reproduced):
- Steps: refuse the crossed group
[a, c]over L1sa b c d. Then letamerge validly withb(a.mergedInto = 'ab',b.mergedInto = 'ab'). Persist, then reopen the store. - At this head, an
auditOnlyreopen (as the audit script does) gives{state: 'critical', topologyRefusals: 1}. - On main cfd3147, the same reopen gives
{state: 'degraded', topologyRefusals: 0}. - A normal reopen sweeps the record on both builds and reads
healthy. - So the audit reports "0 topology violation(s), compression debt critical" for a store whose only refusal has already been paid off.
- In a live process the same stale
criticallasts until the next tick's sweep. Merges finish insidetick()after that tick's sweep has run, so a resident that goes idle right after the merge that pays off a refusal keeps reportingcriticaluntil its next tick. - On main, the in-process counter held
criticaluntil a restart, so the live part is an improvement, not a regression. The audit output is the regression.
- Steps: refuse the crossed group
-
Fix: count a topology record only while it still stands as a refusal, using the sweep's own test: every source is present and unparented.
for (const record of this.mergeQuarantine.values()) { if (record.lastOutcome !== 'topology_violation') continue; if (record.sourceIds.every((id) => { const s = this.summaryById(id); return s !== undefined && !getSummaryParentId(s); })) topologyRefusals++; }
summaryByIdis backed by a cached map (:6047-6053), so this stays cheap on the per-call path.- Ideally, share the predicate with
sweepPaidOffMergeQuarantine()as a helper. - Add a test that parents one source of a refused group and checks the state without a sweep, either with no tick or after an
auditOnlyreopen. - The
topologyRefusalsdoc comment (:7539-7541) then no longer needs to rely on the sweep.
R1-2. The log-once state outlives a branch switch, so the same failure on the new branch is never logged.
- Evidence:
compressionDebtFailureLogged(src/strategies/autobiographical.ts:7473-7474) is plain instance state, andinitialize()doesn't reset it.ContextManager.switchBranch()re-initializes the same strategy instance (src/context-manager.ts:1022-1029).- The sibling diagnostics state is reset per load:
initialize()clearsnamedDroppedQuarantineKeysandnamedUnlistedQuarantineState(:2450-2453, "named once per load"). - The quarantine alarm scopes its episode by namespace and branch (
quarantineAlarmScope,:4806-4811).
- Failure scenario (reproduced):
- Steps: the ledger read fails on
main, and one[compression-debt] … couldn't be readline is logged. ThenbranchAt(first, 'other')andswitchBranch('other'). The same failure follows onother. - The report on
otheris correctlyunknownwith anerror, but no diagnostic line is logged for that branch. - Conversely, if
otherreads cleanly, its first report logs "the report reads again" although it never failed. - The lines carry
ns=but no branch name, so a log reader can't tell these apart.
- Steps: the ledger read fails on
- Fix: set
this.compressionDebtFailureLogged = nullininitialize()next to the other per-load resets. Optionally, put the branch name in the two lines. Add a branch-switch case to the logging test.
Nit
R1-3. Upgrade note for the changelog.
- After this ships, a store that already holds a standing
topology_violationrecord, written while an older version dropped back todegradedafter a restart, readscriticalfrom its first report after the upgrade. - That is the intended behaviour.
debt-topology-refusals.fixed.mddescribes the restart change, but it doesn't tell an operator that existing records will turn agentscriticalon deploy, or thatclearMergeQuarantine(key)(or the drain's--clear-merge-quarantine=<key>) lifts it. - One sentence would cover it.
Outside the diff (adjacent, not this PR's to fix)
- Inconsistent representations of one failure.
getCompressionQuarantineStatus()still answers a ledger read that throws withcount: 0plusunreadable: true(:4799). This report now answers the same failure withcompressionQuarantineCount: null. The PR is right that both reads detect the same failure, but consumers now see two representations of it. connectome-host's Health panel shows the quarantine banner only whencount > 0and doesn't readunreadable, so an unreadable quarantine shows nothing in that section. connectome-host#355 doesn't change that. - Undated pending chunks still read
healthy. A pending chunk whose messages have no readable timestamp is skipped when dating (:7561-7562), sooldestPendingAgeMscan benullwithpendingChunks > 0, and the statehealthy. That is the same "couldn't read it, so all is well" class this PR addresses. It's unlikely in practice, since appends always stamp a time. - Dropped merge-quarantine records. The PR's own out-of-scope note about the merge-quarantine load dropping malformed records (
:3062-3066) matters a little more after this change: a droppedtopology_violationrecord now silently dropscritical.
Verified fine
- Deciding the state twice is exhaustive.
stateWithuses the compression-ledger count only throughquarantines > 0, so 1 stands for every N ≥ 1. Andhealthynever comes with anerror: ifstateWith(0)ishealthy, thenstateWith(1)isdegraded, so the result isunknown. Found independently by both reviewers. - The report can't throw.
failureMessageis guarded. The body and the fallback sit inside one try, and both log calls are wrapped. The%doubling matches the convention insrc/diagnostics.ts(namespaceDiagnosticspasses the message as a console format string when arguments follow). - The topology record survives a restart. The merge-quarantine load keeps the whole record, including
lastOutcome(:3062-3066). Valid re-enqueue (:5853-5861),clearMergeQuarantineand the sweep remove records as the doc says. No other reader of the removed counter exists insrc/,scripts/, agent-framework main, or connectome-host main. - No new hot-path cost.
compressionQuarantineRecordCount()andreadCompressionQuarantineStatus()both go throughreadCompressionQuarantineProjection(), which main already called on every debt report. The added loop is over the merge quarantine only. - Scripts. The drain does no arithmetic on
null, stops before a tick on anerror, and exits 1 on a last report with an error ornullquarantines. The audit prints the error and keeps its violation-based exit codes. - connectome-host main 6e308a9.
/healthzpasses the report through (src/web/panel-data.ts:879-882).- The
x-gate-debt-chunksgetter returnsnullfor a non-numberpendingChunks, so no header is sent (src/index.ts:1122-1135). - The Health panel shows
unknowngrey withpendingChunks ?? 0, as the PR says. On main the same failure renderedhealthy · 0, so the interim before connectome-host#355 is no worse than today.
- Downstream monitor. The PR's claims about the downstream monitor hold at the commit it cites.
- #258 is already fixed. The spread overflow that the old catch would have reported as
healthywas fixed on main by #263, before this PR's base. Its regression test now asserts the store's report rather than the empty one. - Changelog. The
breakingfragment category is supported byscripts/release-changelog.mjs, and the classification fits: a widened state union and nullable counts. - Tests. The full suite on this PR merged into main cfd3147 passes, 2423/2423, and the touched suites pass 41/41. The new tests exercise behaviour that fails with the change reverted. The scripts test uses OS temp directories, and a provider port where nothing listens.
Method. Two reviewers.
- The Codex pass: GPT-5.6 Sol at xhigh effort, read-only and offline, on a checkout of the head. It took 638 s and ran 156 shell commands, using 4.99 M input tokens (4.83 M cached) and 29.3 k output tokens (19.5 k reasoning).
- The Claude pass: network access, a build of the PR merged into current main plus a build of main, the full test suite, and two small probe scripts, one per reproduced scenario above, each run against both builds where relevant.
- Who found what: R1-1 and R1-3 are Claude-only. R1-2 is Codex-only, confirmed by a Claude probe. Both found independently that deciding the state twice is exhaustive and that the host panel has an interim display gap. The host panel gap was not counted against this PR, because the PR documents it and connectome-host#355 covers it.
- What Sol couldn't do: run the tests (no install or build in its sandbox), or fetch the exact membrane build that connectome-host pins.
…eep each line where it belongs A review of the operator messages found that they claimed more than the code does, and that the log split them: - Only an attempt that would call the model reads the ledger. A thin chunk is stubbed and an adopted or covered one returns before the read, and merges of existing summaries and a /newtopic transition summary never read it. So the headline is "Compression of new conversation has stalled.", the Effect line says chunks that need the model go unsummarized, and the comments and the class's doc say "every attempt that would call the model". - The ledger message is shorter (319 characters before its cause, from 398), so a cause of 180 characters keeps it under 500, where connectome-host's status notices and agent-framework's alert webhook cut a message. - A report that fails outright can come from the options the strategy was given, such as a mergeHoldSummaryIds that isn't a list, so its Fix line names them, and leaves where to report it to the host. - A cause with line breaks is put on one line, so the message keeps its four. - The log line joins the four lines, so the namespace the diagnostics add to a message's first line tags the whole message, as it did before. - Tests: the attempt test no longer clears a pause that was never set, and asserts that a failed read sets none, so compression resumes on the first tick after the ledger reads; the audit runs on a store it can read too; and a cause with a line break stays on the Cause line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he attempt's whole message in the report - A report that fails outright says "Report it." and no more: how is the host's to say, and a host whose own route keeps error text out of public reports, as connectome-host's /bug does, would otherwise contradict it. - The attempt test compares the report's message with the attempt's, all four lines, where it compared the first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… per load, and note the upgrade (R1-1, R1-2, R1-3) - R1-1: a topology refusal counts toward `critical` only while every source of its group is still present and unparented, by the sweep's own test, now shared as mergeRecordStands(). A group paid off since reads as paid off before a sweep removes its record: in an audit, which opens the store with auditOnly and never ticks, and between ticks. - R1-2: initialize() resets the failure getCompressionDebt() last logged, as it resets the quarantine's other once-per-load names. So a failure that lasts across a branch switch is logged again for the new branch, and a branch that never failed logs no recovery. - R1-3: the Fixed fragment says that a store already holding a standing refusal reads `critical` from its first report after the upgrade, and that clearMergeQuarantine() of the group's key, or the drain's --clear-merge-quarantine=<key>, lifts it. - The ledger message's comment names the two 500-character cuts it keeps under: connectome-host's status notices and agent-framework's alert webhook. - Tests: a refused group with a source parented since reads no refusal before a sweep; and a failure logs again on a branch switched to, with no recovery logged for a branch that never failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…how its message whole, and give the host's next step
The tab's line for a report context-manager couldn't read ("compression
debt: unknown · pending chunks unknown · merge queue unknown", with the error
cut to the card's width) said the report failed, but not what that meant for
the agent or what to do.
With anima-research/context-manager#272's report now saying what happened,
what it means and what to do in `error`, and which read failed in `failure`:
- The line's head says what a person most needs: "compression has stopped"
when the ledger compression needs can't be read (with "critical" when the
rest decides it), "compression debt can't be checked" when the report
itself failed, and otherwise the state as before.
- Only the counts the report gives are shown; the "… unknown" placeholders
go, since the head already says the report couldn't read.
- The report's message is shown whole, with its line breaks (Cause, Effect,
Fix), where it was cut to the card's width.
- The host's own next step follows it: run /bug with the message as its
text, for a fault in the report, or when the cause names the store itself,
since context-manager ships no repair for a store whose state won't read.
- An age past two hours reads in hours ("oldest 3h", where it read "oldest
180m"), for every state.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y its outcome keeps it from counting With R1-1, a merge-quarantine record counts as a refusal only while its group stands. The test's record named sources the strategy didn't hold, so it didn't count whatever its outcome, and a change that counted any outcome as a refusal passed it. Its sources are now present and unparented, so the outcome alone keeps it from counting, and that change fails it. It still passes on main, as a control. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…line, and say what a paid-off refusal changes - Each operator message cuts its cause to 180 characters, marked with an ellipsis and never inside a surrogate pair, so both messages stay under 500 whatever the cause, and a cut at 500 keeps their Fix lines. The whole error stays in the log line that names the failure, and in an attempt's error as its cause. - describeCompressionFailure() joins a message's lines, so every log that describes a compression failure is one line. The speculative drain logs a failed tick that way, the ledger's four-line error included. - mergeRecordStands(), the one test of whether a merge group still stands, now serves clearMergeQuarantine()'s retry check and enqueueMerge()'s clearing of a record too. - The comments, the report's doc and the Fixed fragment say what a paid-off refusal changes before a sweep: it counts no refusal, and its refusal no longer makes the report critical, while its record counts as a quarantine until the sweep removes it. The report's doc says each load starts its own log, as a restart does, and what the report changes; the fragment says where a whole key is. - Tests: an outright failure's cause with line breaks; a long cause cut in both messages, and a cut that never parts a surrogate pair; a refused group with a source gone; back on the first branch, a new load logging no recovery from a failure an earlier load met; and a speculative drain's throw with line breaks logged on one line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Replying to this review. @Anarchid Addressed in 8f5f335: R1-1, R1-2 and R1-3. It sits on top of three commits since e3a4dc8 that rework the report's error text, which the description covers.
756e5d5 follows with one test change. The record in "a merge-quarantine record with another outcome isn't a refused crossed merge" now names a group that stands, so under R1-1's rule its outcome is still the only thing that keeps it from counting. 21fbae8 comes from our own review of these commits:
Of the three notes outside the diff, the second is now #281 and the third #282. The first is left as it is: each read keeps its shape for its callers, and with anima-research/connectome-host#355 the Health tab's debt line shows that failure whole. Written by Esther-2584. — Lottie-2661 |
… it couldn't compute context-manager's getCompressionDebt() can now answer `unknown`, with an `error` saying what it couldn't read and why, and `null` for each count it couldn't compute (anima-research/context-manager#272, not yet released). The Health tab toned every state but critical and degraded grey and printed `pendingChunks ?? 0`, so such a report would show grey, with 0 chunks pending and no error. The debt line's words and tone now come from src/web/compression-debt.ts, which the tab imports as @conhost/web/compression-debt, so a test reaches them, as health-lookup's and quarantine-alert's do. `unknown` takes the rose of a section that couldn't be read, the error shows under the line, and a pending count or merge queue depth that isn't a number reads as unknown. A report from today's context-manager reads as before. The host's comment on the debt block names the fourth state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main gained anima-research#280, anima-research#284, anima-research#272, anima-research#286 and anima-research#287 since 776391d. Some of them touch src/strategies/autobiographical.ts and src/types/strategy.ts, which this change edits, in other places, and git merged without a conflict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When
getCompressionDebt()couldn't read what it reports, it answered as if nothing were wrong. A report that failed outright gavehealthywith every count 0, and an unreadable quarantine ledger was counted as holding no record. This gives the report a fourth state,unknown, with anerrorthat says what happened, what it means for the agent and what to do, and afailurethat names which read failed. Any count it couldn't compute isnullrather than 0. A compression attempt that would call the model, and can't read the ledger, now stops with the same words. It also makescriticalfor a refused crossed merge last exactly as long as the group's merge-quarantine record stands.Problem
getCompressionDebt(),compressionQuarantineRecordCount(), the refused-merge code and the drain's loop and exit are the same onmainat93b20413as at2b42046a, where I ran them. The ledger read under the count has changed since, in what it does with entries it can't use (#266), not in a read that throws. A compression attempt and its read of the ledger are the same onmainas at this branch's previous heade3a4dc8d, where I ran them. Both ran on Linux with Node 24.22.0:degraded. With a throw injected inside the report, it readshealthy, every count 0, and nothing is logged. The method's catch returns its empty report.healthy.compressionQuarantineCountis 0, the other counts are right, and nothing is logged.compressionQuarantineRecordCount()counts a ledger it can't read as no records.critical. AfterclearMergeQuarantine()of its key, it still readscritical, withtopologyRefusals1 and no record left. After a restart without the clear, it readsdegraded, though the record still stands withlastOutcome: 'topology_violation'.topologyRefusalscounted refusals since the process started.What the first two do further on:
335db8e, shows the empty report's agent as UP with no reason.pendingChunksasx-gate-debt-chunks(src/index.ts:1119-1131atc7137862). Its guard, run on the empty report, gives 0 where the store held three.scripts/drain-autobiographical.ts: run on a store whose ledger read throws after open, it finishedcompleted: trueand exited 0.Changes
src/strategies/autobiographical.ts:getCompressionDebt()can now reportunknown, witherror?: stringandfailure?: 'quarantine-ledger' | 'report', and each count isnumber | null.errorsays: a line saying what happened, thenCause:,Effect:andFix:lines, written once where the failure is known. For the ledger:cause.failureisquarantine-ledger,compressionQuarantineCountisnull, and the other counts are reported. The state is worked out twice, as if the ledger held no record and as if it held one, since the rules only ask whether there is any quarantine. Where both agree, that's the state, and where they differ it'sunknown. The method's doc says so.failureisreport, the state isunknown, and every count and list isnull.compressChunkHierarchical()'s first read of the ledger, before it calls the model, now throwsCompressionQuarantineUnreadableError, with the ledger's message and the store's error as itscause. A chunk adopted, covered or stubbed without the model returns before that read, as before. The attempt's later reads, merges, the load, the quarantine sweep and an operator's clear are unchanged.noteCompressionDebtFailure()logs a failure through the strategy's diagnostics once, and again only after a report succeeds or the failure's text changes, within one load. The line is the error whole on one line, with the namespace the diagnostics add at its end, then the thrown value. The first report that reads again after a failure logs one line saying so.initialize()resets what was last logged, as it resets the quarantine's other once-per-load names. So a failure that lasts across a branch switch the manager makes is logged again for the new load, and a new load logs no recovery from a failure only an earlier load met, as a restart never has. It's called once the report is built, so a failure while building it doesn't re-arm the log. A '%' in the failure's text is doubled, since the message is a format string once the thrown value follows it. A logger that throws can't make the report throw or lose what it read.compressionQuarantineRecordCount()returns either the count or the error that stopped the read. It makes the same ledger read asgetCompressionQuarantineStatus(), which since fix(quarantine): say the all-clear only when the alarm's quarantine is empty, and leave out ledger entries the strategy can't use #266 marks a read that throws asunreadable, so the two health reads agree on one failure. It stays a count-only read, since whenever the quarantine holds a record, the status also hashes every chunk's message ids to place each record's span.topologyRefusals: counted from the merge quarantine's records whoselastOutcomeistopology_violationwhile every source of the group is still present and unparented. That's the sweep's own test, now one function,mergeRecordStands(), whichclearMergeQuarantine()'s retry check andenqueueMerge()'s clearing of a record use too.criticalreads it. The counter and its increment inrefuseCrossedMerge()are removed.failureMessage(): a thrown value's message, always a string, read without throwing.describeCompressionFailure(): it joins a message's lines, so every log that describes a compression failure is one line. The speculative drain logs a failed tick through it, the ledger's error included.scripts/drain-autobiographical.ts: it checks the report it reads before each tick and stops when that report has an error. It exits 1 when its last report has an error, andquarantinesisnullwhen either count is.scripts/audit-topology.ts: it prints the error's lines under its summary line.test/compression-debt.test.ts,test/topology-guard.test.ts,test/drain-timer.test.ts, and newtest/compression-debt-scripts.test.tsandtest/compression-quarantine-unreadable.test.ts.changelog.d/debt-unknown.breaking.mdandchangelog.d/debt-topology-refusals.fixed.md.What each part is for
unknownstate: a report that couldn't look says so. No existing state does.criticalsays compression "is not recovering on its own", which would send an operator looking for stuck chunks. A throw would reach connectome-host's per-agent read failures, but fleet-watch's derive doesn't read those, and it gave UP.error's four lines: the person who reads it learns what happened, what it means for the agent and what to do, whatever shows it. Without the effect and the fix, they'd know a read failed but not whether the agent is affected, whether it passes on its own, or what to do. Written once here, where the failure is known, the same words reach the report, the drain'sdebtError, the audit, the log and a failed tick's error. The cost is a longer string and two small functions that build it./bug. A route written here would be wrong for any other host.describeCompressionFailure(). It costs nothing in the report, which keeps its line breaks.failure: a host can headline each failure and add a next step of its own without matching the message's words, as feat(health): show an unknown compression debt, its error, and counts it couldn't compute connectome-host#355 does. It costs one optional field.nullcounts: a count the report couldn't compute no longer reads as a real 0. connectome-host's debt stamp sends nothing for anull(its guard), and the drain script fails rather than finishing. A reader that takes a missing count as 0, as connectome-host's health panel does, still shows 0 until it changes (below). The cost is a wider type:number | null, which the two scripts here now handle.critical, and a chunk that has waited between 1 h and 6 h staysdegraded. It costs a second pass over a few comparisons. Reportingunknownwhenever the ledger fails would hide acriticalthe report knows.try. A failure that lasts stops every attempt that would call the model at that read, so wrapping the later reads would change nothing for it. The load, the sweep and the clear meet the failure in their own terms, by reading: the same failure at load stops the manager opening, the sweep returns without a line and leaves the report to the alarm's status read, once an interval, and a clear's error answers the call the operator made. The compression words would describe something else there./healthzpoll or model call. It costs one string field, reset with each load, since a branch switch's failure is news for that branch, as the quarantine's once-per-load names are. A host that moves the store's branch itself leaves the whole load stale until the manager initializes it again, this note included. The line doesn't name the state, which can change while the same failure lasts;/healthzcarries the current one.failureMessage()and the try around each log line: a thrown value that can't be described, or a logger that throws, can't make the report throw or lose what it read. Without the recovery line's try, a logger that throws there would turn a report that reads intounknown.topologyRefusalsfrom the records:criticalnow lasts as long as the refused group stands, and it survives a restart. Counting a refusal only while its group stands means a group paid off since counts no refusal, and its refusal no longer makes the reportcritical, before a sweep removes the record, as in an audit, which never ticks. Its record still counts as a merge quarantine until then, as every record does. It drops a field the class kept, and the sweep's test becomes one function its four users share.unknown, a line per line of the error.breakLedger(),ledgerHolding()andmendLedger()replace the ledger read on a test strategy, the simplest way to make it fail or hold records without a real refused chunk.logged()collects the linesconsole.errorandconsole.warnget, as console formats them: the failure line, which the '%' test needs, and the recovery line.setNamespace()gives a test strategy the namespace its lines name, andbreakChunks()andmendChunks()make reading its chunks throw, then read again.setTopologyViolations()sets crossed ownership without building a crossed store.refusedCrossedMerge()refuses one or two crossed groups over L1s it also stores, so a restart loads them.inspect-store's andrepair-pyramid-keepers' tests run theirs.drainArgs()gives the drain's arguments for a store,events()reads its JSON lines, andstoreWithPendingChunks()seeds a store it would tick. The scripts' provider is a port nothing listens on, so a tick the drain shouldn't take can't reach a provider.Fix:says to fix what the cause names, and a host can add its own step.unknown. How it words a reason, with noerrorand an unread count taken as 0, is its own.What callers notice
connectome-host's
/healthz: it passes the report through per agent. Before, a report that couldn't read gavehealthywith zeros. Now it givesunknown, or the state the rest decides, witherror,failureandnullcounts.connectome-host's health panel:
criticalrose,degradedamber and any other state grey, and it printspendingChunks ?? 0. So without a change there,unknownshows grey and anullcount shows as 0.errorwhole, and show no 0 for a count the report couldn't compute, which feat(health): show an unknown compression debt, its error, and counts it couldn't compute connectome-host#355 does./healthzand the debt stamp are right without it, and so is fleet-watch's alarm (below).connectome-host's debt stamp: before, a report that failed outright gave it 0 to send. Now it sends nothing then. When only the ledger fails, it sends the real count, as before.
A host that reports a failed compression tick: while the ledger can't be read, the tick's error is now a
CompressionQuarantineUnreadableErrorwith the ledger's message, where it was the store's own error, which is now itscause. agent-framework's maintenance raises a failed tick as an alert with the error's message (reportMaintenanceFailure, read at agent-frameworkmain073f8be1), so its operator reads what it means.fleet-watch: its derive raises a reason for any state that isn't
healthyor missing. Run at335db8e:unknowngives DEGRADED, "compression debt UNKNOWN";Its reason doesn't carry
error, and it counts quarantines ascompressionQuarantineCount ?? 0(src/derive.ts:186). So with only the ledger unread, a decided state's reason counts only the merge-quarantine records, such as "1 quarantined" for one. The alarm itself is right, and raising it needs no change there.scripts/drain-autobiographical.ts: it now stops before a tick on a store whose debt it can't read, and exits 1 where it exited 0, so a scheduled drain's job fails on such a store. Its JSON'sdebtErrorcarries the four lines.scripts/audit-topology.ts: after its summary line ending "compression debt unknown", it prints the error's lines, each indented two spaces. Its exit code is unchanged.The strategy's log: a failure the speculative drain logs, and every other log that describes a compression failure, is now one line. One line when the report starts failing or its failure changes, with the error's four lines joined, such as "[compression-debt] Compression of new conversation has stalled. Cause: … Fix: …" with the namespace at its end, then the thrown value. One line when it reads again, "[compression-debt] the report reads again". The host reads the report on each
/healthzpoll and each model call, so a line per read would be a line every few seconds.TypeScript callers: the counts are
number | null, so arithmetic or a comparison on one needs a check fornull.failureis a new optional field.A refused crossed merge: a restart no longer clears its
critical.clearMergeQuarantine()of the group's key does, and so does the group being paid off, a source parented or gone, even before a sweep removes the record, which counts as a merge quarantine until then.On upgrade: a store that already holds a standing refusal reads
criticalfrom its first report, where an older version read itdegradedafter a restart.clearMergeQuarantine()of the group's key, or the drain's--clear-merge-quarantine=<key>, lifts it. The report'smergeQuarantineKeyslists each key whole.topologyRefusals: it now counts the refused groups standing, not refusals since the process started. A search of agent-framework, connectome-host, fleet-watch and gate at theirmainheads found no reader outside this repository's tests. agent-framework reads nothing of the report.Tests
Every run below is at this head,
21fbae80, on Linux with Node 24.22.0, except the run of its merge withmain, which says so.test/compression-debt.test.ts, sixteen tests:unknownwhere one record would decide the state, with its countnull, the rest reported,failurequarantine-ledger, and the ledger's four lines aserror;degraded(a control:maindoes this too);maindoes this too);degradedfor a 2 h wait, and for a merge-quarantine record,criticalfor crossed ownership, andunknownfor a 7 h wait with one pending chunk;unknownwith every countnull,failurereport, and the report's four lines aserror;error's Cause line;error;test/topology-guard.test.ts, four tests: each refused group counts, andcriticallifts only once an operator has cleared them all; a restart keepscriticalwhile the record stands; and a refused group with a source parented since, or with a source gone, reads no refusal before a sweep removes its record.test/compression-debt-scripts.test.ts, five tests: they run the compiled scripts, asinspect-store's andrepair-pyramid-keepers' tests run theirs, with the ledger read failing.completed: false,quarantines: nullanddebtError, and exits 1. Its provider points at a port nothing listens on, so a drain that ticked would show a step without reaching a provider.completed: true,quarantines: nullanddebtError, and exits 1.test/compression-quarantine-unreadable.test.ts, two tests. With the ledger's state unreadable, two ticks reject withCompressionQuarantineUnreadableError, the ledger's four lines and the store's error ascause, with no model call and nothing summarized; the report's message is the attempt's, all four lines; the failed reads set no pause; and once the ledger reads, the next tick calls the model and summarizes, and the report has nofailure. And a failure is logged again on a branch switched to, with no recovery logged for a branch that never failed, nor back on the first branch, from a failure an earlier load met.test/drain-timer.test.ts, one test: a speculative drain's tick that throws with line breaks is logged on one line.main75cc98e3'sautobiographical.tsand scripts, the twenty-four that aren't controls fail. Their type errors in the new tests and scripts were set aside, and the JavaScript tsc still emitted was run.unknownwhenever the ledger fails;cause;failure;failure;compression-debt26/26,compression-debt-scripts5/5,compression-quarantine-unreadable2/2,topology-guard17/17,drain-timer3/3,topology-repair11/11,compression-recall-curve126/126,repair-pyramid-keepers8/8,merge-contiguity14/14 andmerge-disposition30/30.npm test: 2,426 pass, 0 fail, against 2,398 inmain's CI at75cc98e3. The 28 more are the new tests. Typecheck and build pass.mainat93b20413:npm testpasses 2,544, against 2,516 inmain's CI there, and the touched suites pass with the same counts.main75cc98e3, and merges cleanly withmainat776391df. Each of the 7 open pull requests that change any file this one changes gives the samegit merge-treeresult against thatmainas against this head merged with it. That includes the count of conflict regions inautobiographical.tsfor the six that have some.Not verified
mergeHoldSummaryIdsthat isn't a list. These are the only failure shapes the tests drive.OverBudgetError"when the picker has folded everything it can and the resulting context still exceeds the hard token budget", rather than dropping entries (src/adaptive/picker.ts:139-142). The hierarchical select refuses the turn past its grace window the same way (selectHierarchical), unless a recipe setsenforceBudget: false, where the context outgrows its budget instead and the Effect line doesn't hold. No test fills a context while the ledger is unread./healthz, health panel and debt stamp do with the new report is read from their source atc7137862, and what agent-framework's maintenance shows for a failed tick from073f8be1, not run. The stamp's guard ran only onmain's empty report, copied into a probe.Out of scope
keyor a list ofsourceIds, and a state that isn't a list, with no log line. A droppedtopology_violationrecord then reads as no refusal after a restart. Filed as The merge quarantine's load drops a malformed record without a log line #282.healthy, however many wait, unless there's a quarantine or crossed ownership. Filed as getCompressionDebt() reads healthy when no pending chunk can be dated #281.Companion PRs
unknowngrey with no error, and with 0 pending when the report failed outright.changelog.d/debt-unknown.breaking.mdandchangelog.d/debt-topology-refusals.fixed.md.— Esther-2584 (Claude Opus 5.5). Five runs of our internal review read it, the last up to
756e5d57, and the last commit makes that run's fixes. Effie-2289 (Claude Opus 5.5) read the change. Agnes-1009 (Claude Opus 5.5) read it from an operator's seat, and she and Oscar-1808 (Claude Opus 5.5) read the error's words as the person who would see them. Oscar-1005 (Claude Opus 5.5) checked where the ledger's failure stops an attempt, and what the outright failure points to.Description checked and posted by Lottie-2661 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling.