LearnNewsExamplesServices
Frontmatter
id12833
titleBound session-summary synthesis with a timeout + degraded fallback
stateClosed
labels
bugaiarchitectureperformancemodel-experience
assigneesneo-opus-vega
createdAtJun 10, 2026, 12:52 PM
updatedAtJun 10, 2026, 2:37 PM
githubUrlhttps://github.com/neomjs/neo/issues/12833
authorneo-opus-vega
commentsCount0
parentIssue12065
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtJun 10, 2026, 2:37 PM

Bound session-summary synthesis with a timeout + degraded fallback

Closed v13.0.0/archive-v13-0-0-chunk-17 bugaiarchitectureperformancemodel-experience
neo-opus-vega
neo-opus-vega commented on Jun 10, 2026, 12:52 PM

Context

SessionService.summarizeSession can grind on a single session's synthesis for 30+ minutes, holding the SummarizationJobs lease past expires_at (the 6 stuck in_progress-past-expiry jobs @neo-opus-ada surfaced while root-causing corrupted memories, #12830). Because session-summarization is a HeavyMaintenanceLease task, a stalled run starves the REM/Sandman cycle — graph extraction defers behind it (documented live in this epic's own thread: #12065 comment IC_kwDODSospM8AAAABFNMkxA). This is the abort-guard gap @neo-opus-ada handed to this lane (#12830 findings, comment IC_kwDODSospM8AAAABFkXAfg), confirmed separable from the corrupted-memory work: the gap stalls a large clean session too.

The Problem

The synthesis LLM call in summarizeSession has no timeout / abort-guard, unlike its hardened siblings:

  • MemoryService.buildMiniSummary (:738) double-guards: withTimeout(model.generateContent(prompt, {timeoutMs, operationLabel}), TIMEOUT_MS, …) — 20s cap (#12805 / #12722).
  • SearchService.ask (:283) passes {timeoutMs: askSynthesisTimeoutMs, …} (#12831 / PR #12832).

summarizeSession (V-B-A'd in source) passes the call bare: invocationFn: () => this.model.generateContent(prompt) (SessionService.mjs:576), with no timeoutMs. The wrapping invokeWithGuardrail (consumerFrictionHelper.mjs:328) only size-pre-checks (Angle 2) and try/catch-categorizes failures (Angle 1) — its success path is a bare await invocationFn() (:372) with no deadline. So a slow-but-in-band synthesis (legitimate, just slow on local gemma) grinds to the provider's socket cap → the 30-min stall → lease expiry while in_progress.

Worse, the oversize degraded-fallback (:602-628, the #12799 LEAF-1 path that builds a provenance-labeled compact summary) only fires on the size-precheck-skip symptom — a timed-out raw synthesis falls straight through to the null skip (:631-633), so a too-slow session gets no summary at all.

The Architectural Reality

  • ai/services/memory-core/SessionService.mjs:575-593runGuardrailedinvokeWithGuardrail({invocationFn: () => this.model.generateContent(prompt), …}); no timeoutMs, no withTimeout. :602 the degraded-fallback trigger (friction?.symptom === 'size-precheck-skip' only). :631 the null skip.
  • ai/services/memory-core/helpers/consumerFrictionHelper.mjs:372const result = await invocationFn(); (no deadline). :149-155 categorizeInvocationError discriminates timeout by message-regex (/timeout|aborted|timed[ -]out/i).
  • ai/services/memory-core/MemoryService.mjs:738 — the withTimeout + {timeoutMs} double-guard pattern to mirror.
  • ai/provider/createTimeoutError.mjs:61 — exports PROVIDER_TIMEOUT_CODE = 'PROVIDER_TIMEOUT' (the #12818 / #12814 uniform contract) for structural timeout discrimination.
  • ai/mcp/server/memory-core/config.{template,}.mjs — owns localModels.chat.{contextLimitTokens,safeProcessingLimitTokens} (read at SessionService.mjs:572-573); the new timeout leaf belongs here.

The Fix

  1. Bound the synthesis. Pass a right-sized {timeoutMs, operationLabel} to generateContent in runGuardrailed AND race it with withTimeout (provider-agnostic guarantee), mirroring buildMiniSummary.
  2. Degrade-on-timeout. Extend the :602 degraded-fallback trigger to also fire on the timeout symptom — a too-slow raw synthesis retries the compact (miniSummary-or-truncatedRaw) input instead of returning null. Unifies oversize + too-slow under one recovery path.
  3. Calibrate the timeout as a config-driven, env-overridable leaf in the memory-core config, sized for session-summary latency (sessions are far larger than the 20s mini cap; cite the calibration basis — a representative session-synthesis measurement, or a conservative multiple of the mini benchmark, kept below the retry-amplified worst case).
  4. Structural cause-discrimination (cross-cutting follow-up surfaced reviewing PR #12832): teach consumerFrictionHelper.categorizeInvocationError to prefer error.code === PROVIDER_TIMEOUT_CODE (regex fallback), adopting #12818's exported contract. (PR #12832 owns the parallel ask-path adoption per the split with @neo-opus-grace.)
  5. Regression per the unit-test skill.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
session-summary synthesis timeout (NEW config leaf, localModels.chat.*) this ticket config-driven, env-overridable timeoutMs passed to generateContent + a withTimeout race; calibrated for session-summary latency current: none (unbounded) leaf JSDoc + config.template.mjs SessionService.mjs:576 bare call; contrast MemoryService.mjs:738
summarizeSession degraded path SessionService.mjs:602 also fires on the timeout symptom (not only size-precheck-skip) → provenance-labeled compact summary instead of null references/raw remain canonical; summaryDegraded:true inline JSDoc :631 null-skip on timeout today
categorizeInvocationError timeout discrimination consumerFrictionHelper.mjs:153 prefer error.code === PROVIDER_TIMEOUT_CODE (regex fallback) message-regex inline JSDoc createTimeoutError.mjs:61 exported code

Decision Record impact

none (no ADR governs the summarization timeout; embodies the same "bound the local-gemma synthesis" principle as #12805 / #12831).

Acceptance Criteria

  • summarizeSession's synthesis call (SessionService.mjs:576) is bounded by a right-sized timeoutMs + withTimeout race (mirroring MemoryService.mjs:738); a slow in-band synthesis aborts instead of grinding to the socket cap.
  • The timeout is a config-driven, env-overridable leaf calibrated for session-summary latency (cite the calibration basis; NOT the 20s mini cap).
  • On a timeout symptom, summarizeSession falls back to the compact degraded input (extend :602) — a too-slow raw synthesis yields a provenance-labeled degraded summary, not null.
  • consumerFrictionHelper.categorizeInvocationError prefers error.code === PROVIDER_TIMEOUT_CODE (regex fallback), adopting the #12818 contract.
  • Unit regression: a slow/timed-out synthesis yields a bounded degraded summary (no 30-min grind, no null skip); a normal synthesis is unaffected.
  • No SummarizationJobs lease is held past expires_at by an unbounded synthesis (the 6-stuck-leases symptom).

Out of Scope

  • add_memory write-path hardening (@neo-opus-grace's lane, sibling under #12830 / #12740).
  • Corrupted-memory measurement + gated deletion (#12830 AC2 + follow-ups, @neo-opus-ada).
  • The ask-path PROVIDER_TIMEOUT_CODE adoption in SearchService (PR #12832, @neo-opus-grace — split agreed).
  • Unifying REM entry points (#12069 Sub 3) — this is the summarization heavy-maintenance task, not executeRemCycle.
  • Intra-run progress logging for long summary runs (#12812 — complementary observability).

Avoided Traps

  • Reusing the 20s miniSummary cap — session summaries are far larger; 20s would abort legitimate session synthesis. Calibrate session-sized.
  • Fixing only the timeout — a too-slow synthesis would still return null (no summary); the degrade-on-timeout fallback keeps the session summarized.
  • Regex-only cause-discrimination — brittle to provider message wording; prefer the structured PROVIDER_TIMEOUT_CODE, regex fallback.
  • Treating this as a corrupted-memory problem — @neo-opus-ada confirmed the abort-guard gap is independent: it stalls a large clean session too. Corrupted memories only worsen it.

Related

  • #12065 — [Epic] Orchestrator-as-SSOT for the REM (Sandman) Pipeline (parent)
  • #12830 — corrupted-memory root-cause (@neo-opus-ada; handed this lane the L576 finding, comment IC_kwDODSospM8AAAABFkXAfg)
  • #12831 / PR #12832 — sibling KB-ask degraded-references on synthesis failure (@neo-opus-grace)
  • #12805 / #12722 — miniSummary timeout right-size / abort-hung (the pattern to mirror)
  • #12814 / #12818PROVIDER_TIMEOUT_CODE provider-timeout contract
  • #12450 — query_summaries empty (downstream surface of the backlog stall)
  • #12812 — intra-run progress logs for session-summary (complementary observability)

Handoff Retrieval Hints

  • query_raw_memories(query='SessionService summarizeSession synthesis timeout abort-guard 30-min stall HeavyMaintenanceLease')
  • File anchors: ai/services/memory-core/SessionService.mjs:575-633, ai/services/memory-core/helpers/consumerFrictionHelper.mjs:372, ai/services/memory-core/MemoryService.mjs:738, ai/provider/createTimeoutError.mjs:61
  • Provenance chain: @neo-opus-ada #12830 findings (IC_kwDODSospM8AAAABFkXAfg) → @neo-opus-grace sync confirm (L576 stall = this #12065 sub; release-bar = land-if-ready, not a hard v13 blocker) → PR #12832 review surfaced the PROVIDER_TIMEOUT_CODE cross-cutting adoption.

Authored by @neo-opus-vega (Claude Opus 4.8).

tobiu referenced in commit b6273b0 - "fix(memory-core): bound session-summary synthesis with a timeout + degraded fallback (#12833) (#12837) on Jun 10, 2026, 2:37 PM
tobiu closed this issue on Jun 10, 2026, 2:37 PM