Skip to content

fix(debt): say unknown, not healthy, when a read fails; hold critical while a refused group stands - #272

Merged
nissa-seru merged 7 commits into
anima-research:mainfrom
nissa-seru:fix/943-debt-unknown
Oct 20, 2026
Merged

nissa-seru merged 7 commits into
anima-research:mainfrom
nissa-seru:fix/943-debt-unknown

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

When getCompressionDebt() couldn't read what it reports, it answered as if nothing were wrong. A report that failed outright gave healthy with every count 0, and an unreadable quarantine ledger was counted as holding no record. This gives the report a fourth state, unknown, with an error that says what happened, what it means for the agent and what to do, and a failure that names which read failed. Any count it couldn't compute is null rather than 0. A compression attempt that would call the model, and can't read the ledger, now stops with the same words. It also makes critical for 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 on main at 93b20413 as at 2b42046a, 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 on main as at this branch's previous head e3a4dc8d, where I ran them. Both ran on Linux with Node 24.22.0:

  • Anything in the report throws: a store with three pending chunks and one compression quarantine record reads degraded. With a throw injected inside the report, it reads healthy, every count 0, and nothing is logged. The method's catch returns its empty report.
  • The compression quarantine ledger can't be read: with the ledger read injected to throw, the same kind of store reads healthy. compressionQuarantineCount is 0, the other counts are right, and nothing is logged. compressionQuarantineRecordCount() counts a ledger it can't read as no records.
    • Compression of new conversation stalls meanwhile: each compression attempt that would call the model reads the ledger first. With the read injected to throw, three ticks each rejected with the injected error itself ("EACCES: permission denied (probe)"), with no model call and no summary. Once the ledger read again, the next two ticks made two summaries and left nothing pending. A host that reports a failed tick shows that error, which doesn't say that compression has stalled. agent-framework's maintenance does, by its source (below).
  • A refused crossed merge: it reads critical. After clearMergeQuarantine() of its key, it still reads critical, with topologyRefusals 1 and no record left. After a restart without the clear, it reads degraded, though the record still stands with lastOutcome: 'topology_violation'. topologyRefusals counted refusals since the process started.

What the first two do further on:

  • fleet-watch: its derive, run at 335db8e, shows the empty report's agent as UP with no reason.
  • connectome-host's debt stamp: it sends pendingChunks as x-gate-debt-chunks (src/index.ts:1119-1131 at c7137862). 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 finished completed: true and exited 0.

Changes

  • src/strategies/autobiographical.ts:
    • The states: getCompressionDebt() can now report unknown, with error?: string and failure?: 'quarantine-ledger' | 'report', and each count is number | null.
    • What error says: a line saying what happened, then Cause:, Effect: and Fix: lines, written once where the failure is known. For the ledger:
      Compression of new conversation has stalled.
      Cause: the store couldn't read this agent's compression quarantine ledger (<the store's error>).
      Effect: chunks that need the model go unsummarized, so in time its context fills and its turns fail until compression catches up.
      Fix: fix what the cause names; compression then resumes by itself.
      
      For any other failure:
      The compression debt report failed.
      Cause: <the thrown value's message>
      Effect: the report can't say whether compression is keeping up. Compression itself may be running normally.
      Fix: this points to a fault in context-manager or in the options it was given. Report it.
      
      A cause with line breaks is put on one line, so each message keeps its four, and a cause past 180 characters is cut, with an ellipsis, never inside a surrogate pair. The whole cause stays in the log line and, for an attempt, in its error's cause.
    • An unreadable ledger: failure is quarantine-ledger, compressionQuarantineCount is null, 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's unknown. The method's doc says so.
    • Any other failure: failure is report, the state is unknown, and every count and list is null.
    • A compression attempt that can't read the ledger: compressChunkHierarchical()'s first read of the ledger, before it calls the model, now throws CompressionQuarantineUnreadableError, with the ledger's message and the store's error as its cause. 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.
    • Logging: 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.
    • The ledger count: compressionQuarantineRecordCount() returns either the count or the error that stopped the read. It makes the same ledger read as getCompressionQuarantineStatus(), 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 as unreadable, 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 whose lastOutcome is topology_violation while every source of the group is still present and unparented. That's the sweep's own test, now one function, mergeRecordStands(), which clearMergeQuarantine()'s retry check and enqueueMerge()'s clearing of a record use too. critical reads it. The counter and its increment in refuseCrossedMerge() 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, and quarantines is null when 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 new test/compression-debt-scripts.test.ts and test/compression-quarantine-unreadable.test.ts.
  • changelog.d/debt-unknown.breaking.md and changelog.d/debt-topology-refusals.fixed.md.

What each part is for

  • The unknown state: a report that couldn't look says so. No existing state does. critical says 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's debtError, the audit, the log and a failed tick's error. The cost is a longer string and two small functions that build it.
  • The messages' length and lines: the ledger message is 319 characters before its cause, so with the cause cut to 180 it stays under 500, where connectome-host's status notices and agent-framework's alert webhook cut a message. connectome-host's notice cuts from the end, so an uncut long cause would cost the Fix line first. The cut costs the cause's tail where it's longer, which the log line keeps whole. Each message opens with what happened, so a one-line view shows that first. A cause with line breaks would otherwise add lines that read as the message's own.
  • The outright failure's Fix line: outside the ledger, the report reads only the strategy's own state and the options it was given, so a throw there points to a fault in context-manager or in those options. It ends "Report it." and leaves the route to the host, which knows where its operator reports: feat(health): show an unknown compression debt, its error, and counts it couldn't compute connectome-host#355 gives /bug. A route written here would be wrong for any other host.
  • The logs on one line: the diagnostics tag a message's first line with its namespace, so one line keeps the whole message tagged, as the log's line was before. That holds for the speculative drain's log of a failed tick too, through 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.
  • null counts: a count the report couldn't compute no longer reads as a real 0. connectome-host's debt stamp sends nothing for a null (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.
  • Working the state out twice for an unreadable ledger: it keeps every state the rest of the report decides. Crossed ownership stays critical, and a chunk that has waited between 1 h and 6 h stays degraded. It costs a second pass over a few comparisons. Reporting unknown whenever the ledger fails would hide a critical the report knows.
  • The attempt's error: a host that reports a failed compression tick showed the store's own error, which doesn't say that compression has stalled or what that means. Wrapping the attempt's first read costs one 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.
  • Logging once per failure, and once when it reads again: an operator sees when it started and when it ended, without a line on every /healthz poll 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; /healthz carries the current one.
  • The '%' doubling: a '%' in a failure's text would otherwise be read as a format directive and take the thrown value's place.
  • 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 into unknown.
  • topologyRefusals from the records: critical now 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 report critical, 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.
  • The scripts: the drain shouldn't spend provider calls on a store whose debt it can't read, or report it drained. The audit says why its state is unknown, a line per line of the error.
  • The test helpers:
    • breakLedger(), ledgerHolding() and mendLedger() 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 lines console.error and console.warn get, 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, and breakChunks() and mendChunks() 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.
    • The scripts' test loads a module before a script that makes the ledger read throw: once the store is open, or from the drain's fourth debt report, its last on an empty store. It runs the compiled scripts as an operator does, as inspect-store's and repair-pyramid-keepers' tests run theirs. drainArgs() gives the drain's arguments for a store, events() reads its JSON lines, and storeWithPendingChunks() 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.
    • The attempt's test makes the store's state read throw for the ledger's two state ids only, so the rest of the store reads as usual, and counts the model calls a stub provider gets.
  • Considered and left out:
    • Advice keyed to the cause's error code: the cause, given whole, already names what to fix.
    • A repair step for a store that won't read: this repository ships none. Restoring a backup loses the conversation since it, and clearing the ledger by hand would retry every quarantined chunk, so Fix: says to fix what the cause names, and a host can add its own step.
    • connectome-host's health panel: it needs a change of its own, described below.
    • fleet-watch: it needs no change to raise the alarm, since its derive already raises a reason for unknown. How it words a reason, with no error and 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 gave healthy with zeros. Now it gives unknown, or the state the rest decides, with error, failure and null counts.

  • connectome-host's health panel:

  • 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 CompressionQuarantineUnreadableError with the ledger's message, where it was the store's own error, which is now its cause. agent-framework's maintenance raises a failed tick as an alert with the error's message (reportMaintenanceFailure, read at agent-framework main 073f8be1), so its operator reads what it means.

  • fleet-watch: its derive raises a reason for any state that isn't healthy or missing. Run at 335db8e:

    • unknown gives DEGRADED, "compression debt UNKNOWN";
    • with only the ledger unread, it gives "compression debt UNKNOWN — 3 span(s) pending (oldest 0h)";
    • before, it gave UP.

    Its reason doesn't carry error, and it counts quarantines as compressionQuarantineCount ?? 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's debtError carries 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 /healthz poll 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 for null. failure is 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 critical from its first report, where an older version read it degraded after a restart. clearMergeQuarantine() of the group's key, or the drain's --clear-merge-quarantine=<key>, lifts it. The report's mergeQuarantineKeys lists 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 their main heads 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 with main, which says so.

  • Twenty-eight new tests:
    • test/compression-debt.test.ts, sixteen tests:
      • an unreadable ledger reads unknown where one record would decide the state, with its count null, the rest reported, failure quarantine-ledger, and the ledger's four lines as error;
      • a ledger that reads gives its record count, and a record makes it degraded (a control: main does this too);
      • a merge-quarantine record whose group stands, with another outcome, isn't counted as a refused crossed merge (a control: main does this too);
      • an unreadable ledger leaves the state the rest decides, and the counts it could read: degraded for a 2 h wait, and for a merge-quarantine record, critical for crossed ownership, and unknown for a 7 h wait with one pending chunk;
      • a report that fails outright reads unknown with every count null, failure report, and the report's four lines as error;
      • a report that reads with no failure before it logs nothing; a failure logs once across polls and again when its text changes, on one line, with the strategy's namespace at its end and then the thrown value; the first report that reads again logs that it does, once; and the same failure logs again after that;
      • an outright failure, a partial one and a report that reads log a line each, the recovery last;
      • a failure while the report is being built logs once across polls;
      • a '%' in a failure's text is logged as itself;
      • a cause with line breaks stays on the Cause line, in each message;
      • a cause past 180 characters is cut, so each message stays under 500 and keeps its Fix line, and a cut never parts a surrogate pair;
      • a thrown value that isn't an Error is the text of error's Cause line;
      • an Error whose message isn't text still gives the report an error;
      • a logger that throws is called, on a failure and when the report reads again, and the report doesn't throw either time.
    • test/topology-guard.test.ts, four tests: each refused group counts, and critical lifts only once an operator has cleared them all; a restart keeps critical while 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, as inspect-store's and repair-pyramid-keepers' tests run theirs, with the ledger read failing.
      • The drain finishes an empty store it can read (a control).
      • On a store whose closed chunks wait for compression, with the ledger unread, the drain stops before any tick, with completed: false, quarantines: null and debtError, 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.
      • On an empty store whose ledger read fails only in the drain's last report, the drain finishes, with completed: true, quarantines: null and debtError, and exits 1.
      • The audit prints its summary, then the error's lines, and exits 0 on a store with no topology violations.
      • The audit prints no error on a store it can read, and exits 0 (a control).
    • test/compression-quarantine-unreadable.test.ts, two tests. With the ledger's state unreadable, two ticks reject with CompressionQuarantineUnreadableError, the ledger's four lines and the store's error as cause, 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 no failure. 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.
  • On main 75cc98e3's autobiographical.ts and 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.
  • Each of forty-five changes to the code fails at least one of them:
    • an unreadable ledger counted as 0;
    • unknown whenever the ledger fails;
    • no once-only;
    • no re-arm after a success;
    • no line when the report reads again;
    • a line on every report that reads;
    • a recovery line that can throw;
    • a recovery line that bypasses the strategy's diagnostics;
    • an outright failure not noted;
    • 0 pending on an outright failure;
    • refused groups not counted;
    • a refused group not critical;
    • a log line that can throw;
    • the failure noted before the report is built;
    • no '%' doubling;
    • a non-string message used as is;
    • a thrown value that isn't an Error losing its text;
    • a ledger that reads counted as 0;
    • a partial failure zeroing the pending count;
    • refused groups counted as one;
    • another outcome counted as a refused group;
    • the drain not stopping at an error;
    • the drain stopping at an error only when no chunk is pending;
    • the drain's exit taking a last report with an error as finished;
    • the audit leaving the error out;
    • the audit printing the error on its summary line;
    • the attempt's read of the ledger left unwrapped;
    • the attempt's error without the store's error as its cause;
    • an unreadable ledger with no failure;
    • an outright failure with no failure;
    • the ledger's message without its effect line;
    • the outright message without "Compression itself may be running normally";
    • a log line that adds text after the error's Fix line;
    • the cause keeping its line breaks;
    • the log keeping the message's line breaks;
    • a failed read pausing the lane;
    • the audit splitting an error that isn't there;
    • the outright Fix line naming only context-manager;
    • the log-once state kept across a branch switch;
    • a refusal counted after its group is paid off;
    • the cause never cut;
    • a cut that can part a surrogate pair;
    • the outright Cause quoting the thrown value's text as it is;
    • a failure's description keeping its line breaks;
    • a gone source counted as standing.
  • The touched suites pass: compression-debt 26/26, compression-debt-scripts 5/5, compression-quarantine-unreadable 2/2, topology-guard 17/17, drain-timer 3/3, topology-repair 11/11, compression-recall-curve 126/126, repair-pyramid-keepers 8/8, merge-contiguity 14/14 and merge-disposition 30/30.
  • npm test: 2,426 pass, 0 fail, against 2,398 in main's CI at 75cc98e3. The 28 more are the new tests. Typecheck and build pass.
  • Merged with main at 93b20413: npm test passes 2,544, against 2,516 in main's CI there, and the touched suites pass with the same counts.
  • Merges: the head is seven commits on main 75cc98e3, and merges cleanly with main at 776391df. Each of the 7 open pull requests that change any file this one changes gives the same git merge-tree result against that main as against this head merged with it. That includes the count of conflict regions in autobiographical.ts for the six that have some.

Not verified

  • A ledger that fails in a real store: the failure was injected by replacing the store's state read for the ledger, not made in a store.
  • An outright failure: it was injected two ways, a chunk list that throws and a recipe's mergeHoldSummaryIds that isn't a list. These are the only failure shapes the tests drive.
  • "Its turns fail": read from both selects. The adaptive picker throws 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 sets enforceBudget: 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.
  • Hosts: what connectome-host's /healthz, health panel and debt stamp do with the new report is read from their source at c7137862, and what agent-framework's maintenance shows for a failed tick from 073f8be1, not run. The stamp's guard ran only on main's empty report, copied into a probe.
  • Other runtimes: I ran Linux and Node 24.22.0 only. CI runs the other Node versions and macOS.

Out of scope

Companion PRs


  • Changelog fragment added: changelog.d/debt-unknown.breaking.md and changelog.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.

… 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>
@greptile-apps

greptile-apps Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

[Medium impact] The PR appears safe to merge; no actionable issues were found.

Summary

getCompressionDebt() now reports read failures instead of returning healthy zeros.

  • Debt reports show unknown reads and keep refused merges critical.
  • The drain stops on unreadable debt, and the audit shows why.

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 Anarchid left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-7571 counts every merge-quarantine record with lastOutcome === 'topology_violation', and stateWith (:7582) returns critical for 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 under auditOnly and for orphaned chunk records, and from tick() through soundMergeQuarantineAlarmIfNeeded() (:6476, :6899).
    • scripts/audit-topology.ts:36 opens the store with auditOnly: true and never ticks.
  • Failure scenario (reproduced):

    • Steps: refuse the crossed group [a, c] over L1s a b c d. Then let a merge validly with b (a.mergedInto = 'ab', b.mergedInto = 'ab'). Persist, then reopen the store.
    • At this head, an auditOnly reopen (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 critical lasts until the next tick's sweep. Merges finish inside tick() after that tick's sweep has run, so a resident that goes idle right after the merge that pays off a refusal keeps reporting critical until its next tick.
    • On main, the in-process counter held critical until a restart, so the live part is an improvement, not a regression. The audit output is the regression.
  • 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++;
    }
    • summaryById is 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 auditOnly reopen.
    • The topologyRefusals doc 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, and initialize() 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() clears namedDroppedQuarantineKeys and namedUnlistedQuarantineState (: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 read line is logged. Then branchAt(first, 'other') and switchBranch('other'). The same failure follows on other.
    • The report on other is correctly unknown with an error, but no diagnostic line is logged for that branch.
    • Conversely, if other reads 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.
  • Fix: set this.compressionDebtFailureLogged = null in initialize() 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_violation record, written while an older version dropped back to degraded after a restart, reads critical from its first report after the upgrade.
  • That is the intended behaviour. debt-topology-refusals.fixed.md describes the restart change, but it doesn't tell an operator that existing records will turn agents critical on deploy, or that clearMergeQuarantine(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 with count: 0 plus unreadable: true (:4799). This report now answers the same failure with compressionQuarantineCount: 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 when count > 0 and doesn't read unreadable, 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), so oldestPendingAgeMs can be null with pendingChunks > 0, and the state healthy. 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 dropped topology_violation record now silently drops critical.

Verified fine

  • Deciding the state twice is exhaustive. stateWith uses the compression-ledger count only through quarantines > 0, so 1 stands for every N ≥ 1. And healthy never comes with an error: if stateWith(0) is healthy, then stateWith(1) is degraded, so the result is unknown. Found independently by both reviewers.
  • The report can't throw. failureMessage is guarded. The body and the fallback sit inside one try, and both log calls are wrapped. The % doubling matches the convention in src/diagnostics.ts (namespaceDiagnostics passes 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), clearMergeQuarantine and the sweep remove records as the doc says. No other reader of the removed counter exists in src/, scripts/, agent-framework main, or connectome-host main.
  • No new hot-path cost. compressionQuarantineRecordCount() and readCompressionQuarantineStatus() both go through readCompressionQuarantineProjection(), 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 an error, and exits 1 on a last report with an error or null quarantines. The audit prints the error and keeps its violation-based exit codes.
  • connectome-host main 6e308a9.
    • /healthz passes the report through (src/web/panel-data.ts:879-882).
    • The x-gate-debt-chunks getter returns null for a non-number pendingChunks, so no header is sent (src/index.ts:1122-1135).
    • The Health panel shows unknown grey with pendingChunks ?? 0, as the PR says. On main the same failure rendered healthy · 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 healthy was 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 breaking fragment category is supported by scripts/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.

Esther-2584 and others added 3 commits October 19, 2026 22:33
…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>
nissa-seru pushed a commit to nissa-seru/connectome-host that referenced this pull request Oct 20, 2026
…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>
Esther-2584 and others added 2 commits October 19, 2026 22:49
…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>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

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.

  • R1-1: a topology_violation record counts toward critical only while every source of its group is still present and unparented. That's the sweep's own test, now one function, mergeRecordStands(), which both use. test/topology-guard.test.ts parents a source of a refused group and reads no refusal before a sweep.
  • R1-2: initialize() resets the failure last logged, beside namedDroppedQuarantineKeys and namedUnlistedQuarantineState. A new test in test/compression-quarantine-unreadable.test.ts covers both cases: the same failure is logged again on a branch switched to, and no recovery is logged on a branch that never failed.
  • R1-3: changelog.d/debt-topology-refusals.fixed.md now 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.

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:

  • The causes: each operator message cuts its cause to 180 characters, so the message stays under 500 and keeps its Fix line.
  • The logs: every log that describes a compression failure is one line, the speculative drain's included.
  • mergeRecordStands(): it also serves clearMergeQuarantine()'s retry check and enqueueMerge().
  • The fragment: it now says what R1-1 changes before a sweep. A paid-off group counts no refusal, and its refusal no longer makes the report critical. Its record still counts as a merge quarantine until the sweep removes it, as every record does on main too.

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

nissa-seru pushed a commit to anima-research/connectome-host that referenced this pull request Oct 20, 2026
… 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>
@nissa-seru
nissa-seru merged commit 8f5958f into anima-research:main Oct 20, 2026
7 checks passed
nissa-seru pushed a commit to nissa-seru/context-manager that referenced this pull request Oct 21, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants