Skip to content

feat(memory): suppress repeat loop-trip entries so the system_exchanges ring keeps what it is for - #1276

Merged
lilyshen0722 merged 2 commits into
mainfrom
feat/task-076-loop-trip-dedupe
Aug 26, 2026
Merged

feat(memory): suppress repeat loop-trip entries so the system_exchanges ring keeps what it is for#1276
lilyshen0722 merged 2 commits into
mainfrom
feat/task-076-loop-trip-dedupe

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

TASK-076 spec change 2. Last piece of that row; (c) shipped in #1271, change 1 in #1275.

The defect

sections.system_exchanges.entries is a fixed 50-slot ring (SYSTEM_EXCHANGE_ENTRY_CAP). recordAgentDmLoopTrip writes a constant takeaway — every trip, in every pod, is the byte-identical string 8 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-conclusion and one task-completed from 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

appendSystemExchange gains an opt-in dedupeWindowMs. 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 }revision is 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 — recordAgentDmConclusion reads the sender's last substantive message — must keep every append. recordAgentDmLoopTrip is 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. The console.warn in agentMentionService is untouched, so every trip stays observable; only the memory append is suppressed.

Two limits, pinned rather than left to be rediscovered

  • It races. Read-then-decide, not a filtered update — the update below it is an upsert, and a filter that failed to match would insert a second envelope rather than do nothing. Two simultaneous trips can both append. That is a bounded loss of exactly the property being bought (2 entries instead of 1) and it is not the failure this guards, which is 45 entries accumulated one at a time over days.
  • An interleaved entry reopens the window. Only the newest entry is compared, which is what keeps this one indexed read instead of a scan. There is a test asserting this by design, so a future reader finds the limit stated rather than inferring a bug.

Also handled: an out-of-order ts yields 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 backend previously 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:

  • delete the opt-in wiring → 1 red / 28 green
  • delete the dedupe logic → 2 red / 27 green

Both compiled (29 total, not 0 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

…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>
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Review at 77837996 (base e86a4a4a, 4 files, +268/-1). Approve on content — one non-blocking finding, reproduced rather than argued.

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. entries[0] really is the newest, because the append is $push with $position: 0 and $slice: SYSTEM_EXCHANGE_ENTRY_CAP — so the projection's { $slice: 1 } reads the newest, not the oldest. Had the ring appended at the tail, the guard would have compared against the wrong end and quietly never fired. Worth saying explicitly since the two $slices mean different things four lines apart.

Blind mutations on the diff's own lines, each run against the full 142:

mutation result
drop age >= 0 (the out-of-order guard) 1 red
drop String(newest.surfacePodId) === entry.surfacePodId 2 red
6 * 60 * 60 * 100024 * ... 1 red
drop newest.kind === entry.kind 142 green
drop String(newest.takeaway ?? '') === entry.takeaway 142 green

Pre-existing 113 stayed green under every mutation, so all reds are unique to the new files.

Finding: it('does not suppress a different pod, kind, or takeaway') discriminates on pod only. Two of the three terms it names are unpinned, and the reason is that its four appends mask each other. The dedupe compares against the newest entry, and the fixture varies pod on append 2 and never resets:

1. trip(podA, loop-trip, T)                      -> newest = (podA, loop-trip, T)
2. trip(podB, ...)          differs on pod       -> appended; newest = (podB, ...)
3. trip(kind=conclusion)    ALSO differs on pod  -> appended for the wrong reason
4. trip(takeaway='...')     ALSO differs on kind -> appended for the wrong reason

Delete the kind term and case 3 still passes, rescued by pod. Delete takeaway and case 4 still passes, rescued by kind. The test's own comment — "only on all three of kind/pod/takeaway" — is the property it does not check.

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 takeaway

With those present: unmutated 25/25; drop-kind1 red (the kind case alone); drop-takeaway1 red (the takeaway case alone). Both mutations were invisible before.

Non-blocking because neither term is load-bearing for the only caller that opts in today — recordAgentDmLoopTrip holds kind and takeaway constant, which is exactly why nothing noticed. They are forward-looking guards for a second opt-in caller, and that is the caller who would be hurt.

Things I checked that are fine. A suppressed append does not advance revision (asserted, and it matters — revision is the delta cursor drivers read). existing?.revision ?? 1 cannot misreport a legitimate revision: 0, since dedupe requires a prior append that already $inc'd. The dedupeWindowMs > 0 gate means the default path is byte-identical to today. The read-then-decide race is documented accurately and its worst case really is 2 entries instead of 1. And the interleave limit is pinned as a deliberate known limit rather than left to be rediscovered, which I'd rather see than a scan.

Not verified: I did not run the service tier (agent-memory-envelope, two-driver-memory-cross-check) or exercise this against a real Mongo — everything above is the unit tier with mongodb-memory-server. The $slice-in-projection semantics are the one thing I would want a real-DB run to confirm, since that is where an in-memory shim is most likely to differ from the server.

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>
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Re-gate at df321667. Taken, and it discriminates now. Approve.

Verified by re-running the two mutations that were invisible at 77837996, plus the one that already worked:

mutation at 77837996 at df321667
drop newest.kind === entry.kind 142 green 1 reddoes not suppress a different kind
drop String(newest.takeaway ?? '') === entry.takeaway 142 green 1 reddoes not suppress a different takeaway
drop String(newest.surfacePodId) === entry.surfacePodId 2 red 1 reddoes not suppress a different pod

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 ts, so age is 0 and well inside the window. The dedupe would fire if the terms matched, which is what makes the single changed field the only thing keeping the entry. A fixture that spaced the timestamps would pass for the wrong reason.

No further findings. The earlier notes stand unchanged: the $position: 0 / $slice: 1 pairing is correct, a suppressed append does not advance revision, and the interleave limit is pinned as deliberate.

Not verified, unchanged from the first pass: service tier and real Mongo. Everything here is the unit tier on mongodb-memory-server, and $slice-in-projection is the one behaviour where a shim is most likely to diverge from the server.

@lilyshen0722
lilyshen0722 merged commit 2733905 into main Aug 26, 2026
11 checks passed
@lilyshen0722
lilyshen0722 deleted the feat/task-076-loop-trip-dedupe branch August 26, 2026 22:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant