LearnNewsExamplesServices
Frontmatter
id16313
titleThe miniSummary backfill loop has no attempt budget
stateClosed
labels
bugai
assigneesneo-opus-ada
createdAtAug 2, 2026, 1:21 AM
updatedAtAug 2, 2026, 11:14 AM
githubUrlhttps://github.com/neomjs/neo/issues/16313
authorneo-opus-ada
commentsCount0
parentIssue16223
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 2, 2026, 11:14 AM

The miniSummary backfill loop has no attempt budget

neo-opus-ada
neo-opus-ada commented on Aug 2, 2026, 1:21 AM

Context

Split out of #16223 (@neo-opus-vega) so each half has a close-target it genuinely resolves. #16223 retains its primary fix — the homeostatic timeout controller (ADR 0025 detect → ADR 0026 actuate) — and this leaf owns the bounded-loop half, which is independent of it and needs no new control theory.

Live latest-open sweep at 2026-08-01T23:20:39Z, latest 8: no equivalent. A2A claim sweep over the last 5: @neo-kimi-phoebe on #15252 L2/L3, @neo-fable on the film epic — no overlap.

The Problem

MemoryService.backfillMiniSummaries defers a failed row and never counts the failure, so the same rows are retried on every sweep forever. On a CPU-only deployment that is ~2.3 cores burned for days at 0 updated, 30 deferred per pass — the pending set grows with inflow, so the loop is permanent.

The timeout is per-attempt and nothing counts attempts across passes, which is why each pass looks identical to the first and no amount of observation from inside one pass reveals the loop.

Two paths defer. Both must count.

if (!miniSummary) { deferred++; continue; }            // falsy return — THE TIMEOUT LANDS HERE
…
} catch (error) { …; deferred++; }                     // only what escapes buildMiniSummary's own catch

⚠️ Correction (2026-08-02) — the original framing of this section was inverted

This section originally read "Two paths defer, and the dominant one is the thrown path", annotated the catch with "the TIMEOUT lands here", and concluded "a budget that counts only the falsy return would look complete and leave the actual burn unbounded."

That is backwards, and @neo-gpt-emmy falsified it. buildMiniSummary wraps its entire body in try/catch and returns null on any error — including its own withTimeout rejection (MemoryService.mjs:1670-1673). The observed 20s timeout therefore reaches the falsy branch. The catch in the sweep covers only what escapes that guard: a provider throwing outside the timeout window, or an injected test summarizer.

Counting both paths is still correct and is what shipped — the fix is unchanged. What was wrong was the rationale: it is a budget on the thrown path alone that would have bounded nothing, not the reverse.

Retained rather than silently edited because I published this as the design point I would defend hardest. An inverted claim I argued from strength is exactly the one a future reader needs to see corrected, not erased.

The fact underneath, found because the original stayed visible

@neo-opus-vega went to the source to verify the correction and found that there are two nested timeouts, mapping exactly onto the two paths:

leaf value site branch
generateMiniSummaryTimeoutMs 20000 MemoryService.mjs:1639, inside buildMiniSummary caught → nullfalsy
miniSummaryTimeoutMs 30000 MemoryService.mjs:1975, wrapping summarize(…) from outside escapes → thrown

So the dominant branch is not a fixed property of the code. Under 30s the inner cap fires (falsy); past 30s the outer fires first (thrown). It is a function of the generation window — the quantity #16223's controller actuates at runtime.

That is the correct justification for counting both: not "the thrown path dominates" (false), not "we cannot tell" (true but weak), but the dominant path is not constant. A single-branch budget would be correct on the day it ships and wrong the first time the window is widened.

Consequence for #16223, recorded by @neo-opus-vega on that ticket: if the actuation widens only the inner leaf, then at 30s the outer timeout starts firing first — widening becomes a silent no-op, every item flips from the falsy path to the thrown one, and the controller reads "widening stopped helping" while the real ceiling is a leaf it never touches.

The Architectural Reality

  • ai/services/memory-core/MemoryService.mjs — the backfill loop's two deferral paths, and the existing no-content archive whose comment already states the principle: "archive the node so it leaves the pending set AND counts as progress, instead of skipping it forever (a permanent backlog floor that also misfires the scheduler's no-progress backoff)."
  • archiveMemoryNode({id, reason}) — the reversible exit, json_set on $.properties.archivedAt / archivedReason; pending and recall queries already exclude archivedAt.
  • memoryService.graphProjectionMaxAttempts — the attempt-budget leaf precedent in the same config block.

The Fix

  1. Count failures on the node, not in memory — the failure mode is that consecutive passes cannot see each other, and a process-local counter reproduces it exactly.
  2. Count both deferral paths.
  3. At the budget, reversibly archive with reason: 'generation-timeout', mirroring the no-content exit.
  4. Tally the exits so a pass reports them.
  5. Budget as a config leaf, mirroring graphProjectionMaxAttempts.

Contract Ledger Matrix

Added per @neo-gpt-emmy's audit: this ticket changes a config leaf and a consumed return shape, so the originating ticket owes the ledger — a PR-only one does not satisfy the ticket-side audit, because the ticket is what a future reader inherits.

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
memoryService.miniSummaryMaxAttempts (new leaf) ai/configBase.mjs; mirrors graphProjectionMaxAttempts consecutive failures before a reversible archive <= 0 disables the budget and restores prior behaviour, checked before any write so a disabled budget mutates nothing; unresolved throws at sweep entry — the config provider owns defaults, a consumer-local fallback would silently restore the unbounded loop leaf JSDoc config-leaf parity snapshot; unit
backfillMiniSummaries return this ticket adds exhausted to the tally additive — present on every exit incl. no-SQLite and zero-row, so no caller sees a shape that sometimes lacks it. This row was published before the code satisfied it — both early exits omitted exhausted, caught by @neo-gpt-emmy; the code now meets the claim @returns both exits pinned as exact objects (toMatchObject would pass on a missing key)
buildMiniSummary({prompt, response}) input surface — privacy #12671 AC5; queryRecentTurns projection gate the private thought field is NOT an input, and must not become one without a private summary tier unchanged from dev. Why it is a hard constraint: miniSummary is returned ungated by both public shapes — the full projection emits it before the private-projection gate, and the summary projection takes no projection argument at all. So any thought-derived text in a summary reaches a default/public peer read. Derived text cannot launder that boundary method JSDoc + inline rationale at the input site deterministic echo falsifier: summarizer echoes its whole input, canary in thought, asserted absent from the input and from default summary/full reads, with a positive control that the canary is in the stored row
recordMiniSummaryAttempt (new) this ticket increments $.properties.miniSummaryAttempts, returns the total unreachable row ⇒ 0, never archives JSDoc unit
$.properties.miniSummaryAttempts (new node property) this ticket per-row failure count, persisted, monotonic — no reset, no consumer on dev absent ⇒ treated as 0 JSDoc unit
archivedReason: 'generation-timeout' this ticket; mirrors no-content distinguishes a budget exit from a structural one reversible marker, not a delete JSDoc unit asserts it is not no-content

Acceptance Criteria

  • A row that fails N consecutive times leaves the pending set instead of being retried forever.
  • Both deferral paths count toward the budget — the falsy return and the thrown path. (Originally worded "The thrown-timeout path counts toward the budget, not only the falsy return", which implied the thrown path was the one that mattered; see the correction above. The requirement itself is unchanged: neither path may loop.)
  • The archive is reversible and carries a machine-readable reason distinguishing it from no-content.
  • A pass reports the exhausted count.
  • The budget is a config leaf; <= 0 disables it and restores prior behaviour.
  • Under an always-failing stub the loop converges instead of running forever.

Out of Scope

  • The homeostatic controller. #16223 keeps it. This leaf makes the loop terminate; that one makes it succeed.
  • Cancelling timed-out requests provider-side. Needs an abort signal threaded through the provider surface — a different seam, stays on #16223.
  • Summarization quality.

Avoided Traps

  • Counting only one deferral path. Either single-path budget leaves the other unbounded. The branch the symptom names is not the branch that burns; the timeout throws.inverted, see the correction above: the timeout returns null, so it is the falsy branch that carries the burn and a thrown-only budget that would have bounded nothing.
  • An in-memory counter. It reproduces the exact defect — passes that cannot see each other.
  • A non-reversible exit. A widened window should be able to restore these rows, so the archive must be a marker with a reason, not a delete.
  • Recording the attempt before checking the budget. @neo-gpt-emmy's second-round finding: a <= 0 budget then means "disabled but still counting", and re-enabling it later archives rows off an invisible tally accumulated while the feature was off. The budget check precedes the write.
  • Feeding thought to the summarizer to enrich the input. This ticket's parent asked for it and it shipped in the first PR round; @neo-gpt-emmy blocked it. It is a privacy change wearing input-quality clothes: the gate that keeps thought off a peer read sits a few lines above the summarizer call, and routing the field through derived text defeats that gate rather than passing through it. Doing it properly needs a private summary tier the public shapes can withhold — an architecture change, and one that doubles generation cost on the very loop being bounded for burn.
  • Hardcoding the budget. A hardware-dependent constant that needs a human to recalibrate is the failure #16223's primary fix exists to remove; a leaf at least lets a deployment tune it meanwhile.

Related

  • #16223 — parent; retains the homeostatic controller and the provider-cancellation item. Per @neo-opus-vega, the monotonic tally arms a trap on that half: restoring an archived row without clearing the tally leaves zero remaining budget, silently turning "N consecutive at the maximum window" into "one". Tracked there as a restore-on-widen AC.
  • #16222 — the failure-handling family framing

Origin Session ID: 56105163-6e66-44b6-8c6f-9e81bc1be08c

Retrieval Hint: query_raw_memories("miniSummary backfill attempt budget generation-timeout archive"), or ai/services/memory-core/MemoryService.mjs backfillMiniSummaries.

tobiu referenced in commit aa23081 - "fix(memory-core): bound the miniSummary backfill and complete its input (#16313) (#16314) on Aug 2, 2026, 11:14 AM
tobiu closed this issue on Aug 2, 2026, 11:14 AM