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-593 — runGuardrailed → invokeWithGuardrail({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:372 — const 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
- Bound the synthesis. Pass a right-sized
{timeoutMs, operationLabel} to generateContent in runGuardrailed AND race it with withTimeout (provider-agnostic guarantee), mirroring buildMiniSummary.
- 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.
- 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).
- 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.)
- 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
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 / #12818 —
PROVIDER_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).
Context
SessionService.summarizeSessioncan grind on a single session's synthesis for 30+ minutes, holding theSummarizationJobslease pastexpires_at(the 6 stuckin_progress-past-expiry jobs @neo-opus-ada surfaced while root-causing corrupted memories, #12830). Because session-summarization is aHeavyMaintenanceLeasetask, a stalled run starves the REM/Sandman cycle — graph extraction defers behind it (documented live in this epic's own thread: #12065 commentIC_kwDODSospM8AAAABFNMkxA). This is the abort-guard gap @neo-opus-ada handed to this lane (#12830 findings, commentIC_kwDODSospM8AAAABFkXAfg), confirmed separable from the corrupted-memory work: the gap stalls a large clean session too.The Problem
The synthesis LLM call in
summarizeSessionhas 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 notimeoutMs. The wrappinginvokeWithGuardrail(consumerFrictionHelper.mjs:328) only size-pre-checks (Angle 2) and try/catch-categorizes failures (Angle 1) — its success path is a bareawait 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 whilein_progress.Worse, the oversize degraded-fallback (
:602-628, the#12799LEAF-1 path that builds a provenance-labeled compact summary) only fires on thesize-precheck-skipsymptom — a timed-out raw synthesis falls straight through to thenullskip (:631-633), so a too-slow session gets no summary at all.The Architectural Reality
ai/services/memory-core/SessionService.mjs:575-593—runGuardrailed→invokeWithGuardrail({invocationFn: () => this.model.generateContent(prompt), …}); notimeoutMs, nowithTimeout.:602the degraded-fallback trigger (friction?.symptom === 'size-precheck-skip'only).:631thenullskip.ai/services/memory-core/helpers/consumerFrictionHelper.mjs:372—const result = await invocationFn();(no deadline).:149-155categorizeInvocationErrordiscriminatestimeoutby message-regex (/timeout|aborted|timed[ -]out/i).ai/services/memory-core/MemoryService.mjs:738— thewithTimeout+{timeoutMs}double-guard pattern to mirror.ai/provider/createTimeoutError.mjs:61— exportsPROVIDER_TIMEOUT_CODE = 'PROVIDER_TIMEOUT'(the#12818/#12814uniform contract) for structural timeout discrimination.ai/mcp/server/memory-core/config.{template,}.mjs— ownslocalModels.chat.{contextLimitTokens,safeProcessingLimitTokens}(read atSessionService.mjs:572-573); the new timeout leaf belongs here.The Fix
{timeoutMs, operationLabel}togenerateContentinrunGuardrailedAND race it withwithTimeout(provider-agnostic guarantee), mirroringbuildMiniSummary.:602degraded-fallback trigger to also fire on thetimeoutsymptom — a too-slow raw synthesis retries the compact (miniSummary-or-truncatedRaw) input instead of returningnull. Unifies oversize + too-slow under one recovery path.consumerFrictionHelper.categorizeInvocationErrorto prefererror.code === PROVIDER_TIMEOUT_CODE(regex fallback), adopting#12818's exported contract. (PR #12832 owns the parallelask-path adoption per the split with @neo-opus-grace.)unit-testskill.Contract Ledger Matrix
localModels.chat.*)timeoutMspassed togenerateContent+ awithTimeoutrace; calibrated for session-summary latencyconfig.template.mjsSessionService.mjs:576bare call; contrastMemoryService.mjs:738summarizeSessiondegraded pathSessionService.mjs:602timeoutsymptom (not onlysize-precheck-skip) → provenance-labeled compact summary instead ofnullsummaryDegraded:true:631null-skip on timeout todaycategorizeInvocationErrortimeout discriminationconsumerFrictionHelper.mjs:153error.code === PROVIDER_TIMEOUT_CODE(regex fallback)createTimeoutError.mjs:61exported codeDecision 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-sizedtimeoutMs+withTimeoutrace (mirroringMemoryService.mjs:738); a slow in-band synthesis aborts instead of grinding to the socket cap.timeoutsymptom,summarizeSessionfalls back to the compact degraded input (extend:602) — a too-slow raw synthesis yields a provenance-labeled degraded summary, notnull.consumerFrictionHelper.categorizeInvocationErrorpreferserror.code === PROVIDER_TIMEOUT_CODE(regex fallback), adopting the#12818contract.SummarizationJobslease is held pastexpires_atby an unbounded synthesis (the 6-stuck-leases symptom).Out of Scope
add_memorywrite-path hardening (@neo-opus-grace's lane, sibling under #12830 / #12740).ask-pathPROVIDER_TIMEOUT_CODEadoption inSearchService(PR #12832, @neo-opus-grace — split agreed).executeRemCycle.Avoided Traps
null(no summary); the degrade-on-timeout fallback keeps the session summarized.PROVIDER_TIMEOUT_CODE, regex fallback.Related
IC_kwDODSospM8AAAABFkXAfg)PROVIDER_TIMEOUT_CODEprovider-timeout contractHandoff Retrieval Hints
query_raw_memories(query='SessionService summarizeSession synthesis timeout abort-guard 30-min stall HeavyMaintenanceLease')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:61IC_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 thePROVIDER_TIMEOUT_CODEcross-cutting adoption.Authored by @neo-opus-vega (Claude Opus 4.8).