feat(memory): suppress repeat loop-trip entries so the system_exchanges ring keeps what it is for - #1276
Conversation
…t it is for
`sections.system_exchanges.entries` is a fixed 50-slot ring. Every
agent-dm loop trip writes the SAME constant takeaway ("8 consecutive bot
turns within 30 min — guard tripped"), so a pod stuck in a loop fills the
ring with one indistinguishable notice. Measured on a live envelope
before this landed: 50 entries, ~45 of them that exact string, and
exactly two entries of any other kind surviving — one agent-dm-conclusion
and one task-completed.
That makes "seats save durable state" unwinnable on its own terms: a seat
can save perfectly and still watch it evicted by a writer it does not
control.
`appendSystemExchange` gains an opt-in `dedupeWindowMs`. When set, an
append is skipped if the NEWEST entry already carries the same
(kind, surfacePodId, takeaway) and is younger than the window. Off by
default — a caller whose takeaway carries real per-event content must
keep every append. `recordAgentDmLoopTrip` is the one caller that opts
in, at 6h.
Deliberately read-then-decide rather than a filtered update: the update
is an upsert, and a filter that failed to match would insert a second
envelope rather than do nothing. Two simultaneous trips can therefore
both append — a bounded loss of exactly the property being bought
(2 entries instead of 1), and not the failure this guards, which is 45
identical entries accumulated one at a time over days.
Adds the first test of any kind for this trigger: grep for
recordAgentDmLoopTrip previously returned two source files and zero
tests, so the opt-in wiring had no cover — and since the option is off by
default, a helper-only test passes with the wiring deleted. Verified by
mutation: deleting the wiring goes 1 red, deleting the dedupe logic goes
2 red, both compiling.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Review at Verified, not read. Baseline on Node 22 across all 7 memory + system-exchange unit suites: 142/142. The two new files contribute 29. The mechanism is right where it would most easily be wrong. Blind mutations on the diff's own lines, each run against the full 142:
Pre-existing 113 stayed green under every mutation, so all reds are unique to the new files. Finding: Delete the Confirmed by construction, not inference. Two cases, each varying exactly one term against an otherwise-identical newest entry: it('kind alone', async () => {
const ts = new Date('2026-05-03T00:00:00Z');
await appendSystemExchange({ ...trip, ts });
await appendSystemExchange({ ...trip, kind: 'agent-dm-conclusion', ts: new Date('2026-05-03T01:00:00Z') });
// ...expect 2 entries
});
// same shape for takeawayWith those present: unmutated 25/25; drop- Non-blocking because neither term is load-bearing for the only caller that opts in today — Things I checked that are fine. A suppressed append does not advance Not verified: I did not run the service tier ( |
The chained four-append case proved nothing about the terms it named. Dedupe compares only against the NEWEST entry, so each variant was checked against the previous variant — which already differed on some other term — rather than against the base. Deleting the kind term or the takeaway term from the comparison left it green. Split into three two-append cases: base, then exactly one changed field. Mutation-verified — deleting any one of the kind, pod or takeaway term now turns exactly one case red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Re-gate at Verified by re-running the two mutations that were invisible at
Baseline 144/144 across the seven memory + system-exchange suites. Each red is now the case whose name matches the deleted term, which is the property the chained version could not give you — it went red on the wrong case or not at all. The fixture choice is right and worth noting so it survives a future edit: both appends in each case share the same No further findings. The earlier notes stand unchanged: the Not verified, unchanged from the first pass: service tier and real Mongo. Everything here is the unit tier on |
TASK-076 spec change 2. Last piece of that row; (c) shipped in #1271, change 1 in #1275.
The defect
sections.system_exchanges.entriesis a fixed 50-slot ring (SYSTEM_EXCHANGE_ENTRY_CAP).recordAgentDmLoopTripwrites a constant takeaway — every trip, in every pod, is the byte-identical string8 consecutive bot turns within 30 min — guard tripped. A repeat writer with an unvarying payload against a fixed-size ring evicts precisely the entries the ring exists to preserve.Measured on a live envelope before this landed: 50 entries, ~45 of them that one notice, and two entries of any other kind surviving — one
agent-dm-conclusionand onetask-completedfrom four days earlier.That is what makes TASK-076(b) unwinnable on its own terms. A seat can adopt the save-durable-state habit perfectly and still watch its state evicted by a writer it does not control.
The change
appendSystemExchangegains an opt-indedupeWindowMs. When set, the append is skipped if the newest entry already carries the same(kind, surfacePodId, takeaway)and is younger than the window. It returns{ revision, deduped: true }—revisionis not advanced, so a suppressed append does not make a driver re-fetch an unchanged envelope.Off by default. A caller whose takeaway carries real per-event content —
recordAgentDmConclusionreads the sender's last substantive message — must keep every append.recordAgentDmLoopTripis the sole opt-in, at 6h: long enough that a looping pod contributes one entry rather than dozens, short enough that a genuine recurrence the next day is still recorded. Theconsole.warninagentMentionServiceis untouched, so every trip stays observable; only the memory append is suppressed.Two limits, pinned rather than left to be rediscovered
Also handled: an out-of-order
tsyields a negative age, and treating that as "inside the window" would silently drop an entry genuinely older than the one it is compared against.Tests
grep -rn recordAgentDmLoopTrip backendpreviously returned two source files and zero tests. This adds the first cover for the trigger — necessary because the option is off by default, so a helper-only test passes with the opt-in wiring deleted and every trip appending as before. Suppression is asserted on both peers' envelopes; the trigger fans out per peer and a one-sided check would miss half a regression.Verified by mutation, not asserted:
Both compiled (
29 total, not0 total). The three "does not suppress" controls stay green under both mutations, which is the point of having them.Green: 142 tests across all
agentMemory*+systemExchange*suites. Backend typecheck is 51 errors with and without this change — identical baseline, none in either touched file.🤖 Generated with Claude Code