fix: summarize once idle, refresh on growth, and bound failed-summary retries#109
fix: summarize once idle, refresh on growth, and bound failed-summary retries#109minyek wants to merge 2 commits into
Conversation
… they grow
Summaries were write-once and could freeze a still-active conversation into a partial mid-session snapshot that was then never refreshed, so the one-line summary shown beside search results for long or resumed conversations was misleading. This fixes both halves: defer summarizing until a conversation has gone idle, and refresh a summary when its transcript later grows. (Search itself was always complete — it indexes exchanges; this fixes only the displayed summary.)
- Coverage header `__COVERAGE__ {bytes,lastExchange,schema}` records the archived JSONL byte size a summary reflects.
- shouldQueueForSummary re-queues a real summary once the archive grows past its covered bytes (transcripts are append-only).
- Quiescence gate (isQuiescent + EPISODIC_MEMORY_SUMMARY_QUIESCENCE_HOURS, default 1) only (re)summarizes after the conversation has been idle, measured from the last exchange's timestamp, so a mid-session snapshot is never frozen.
- Legacy header-less summaries get a no-LLM baseline stamp on upgrade — no mass re-summarization.
- A re-summary failure preserves the prior real summary; an error sentinel is written only for a first-time failure.
- search strips the coverage header from the displayed summary.
Gating and writing are shared (needsSummary, writeErrorSentinelIfNew, summarizeIfQuiescent) across the indexer paths and the sync loop; the _HOURS config defaults convert to ms at a single boundary.
… retrying forever A re-summary of a grown, quiescent conversation had no backoff: every sync re-invoked the summarizer with no limit, so a transcript that never summarizes re-called the LLM on each sync forever. First-time failures (obra#96 error sentinels) backed off ~1h but never gave up either — and since sync is SessionStart-driven (not a fixed timer), a 1h floor rarely even skips a sync. Unify both failure paths under one policy, shouldRetryAfterFailure: hold off until the retry floor (EPISODIC_MEMORY_SUMMARY_ERROR_RETRY_HOURS, default 1h) elapses, then give up after EPISODIC_MEMORY_SUMMARY_MAX_ATTEMPTS (default 5), keeping whatever good summary already exists. Attempt state ({attempts, lastAttempt}) is stored as a JSON line in the error sentinel and in the coverage header's resummary field; a successful summary clears it. parseErrorSentinel still reads the legacy v1.4.2 ISO-timestamp sentinel. Order never-summarized conversations ahead of re-summary retries before the per-run summaryLimit, so failing re-summaries can't starve fresh work (the obra#91 head-of-queue failure mode). Drops the fragile mtime-based retry timing and the duplicated floor/cap logic.
PR update — bound failed-summary retries (
|
Description
Summaries are write-once. A conversation that gets summarized while still in
progress keeps a summary of only its early portion forever — every later turn
is invisible to the summary. For long-running or resumed conversations, the
one-line summary shown next to search results is therefore misleading.
The fix has two complementary halves: defer summarizing a conversation until
it has gone idle (so a still-active conversation is never frozen into a partial
snapshot), and refresh a summary when its transcript later grows. Note the
behavioral change from the defer half: a conversation is no longer summarized
until it has been idle for the quiescence window (
EPISODIC_MEMORY_SUMMARY_QUIESCENCE_HOURS,default 1h), so very recent conversations intentionally have no summary yet.
Because the refresh half makes us re-summarize, failures can now recur — so the
same change also makes failed (re-)summaries back off and eventually give up
instead of preserving a good summary but re-attempting on every sync. That retry
policy is unified with the existing first-failure error-sentinel path (see Fix 5).
(Search results themselves were always complete — search runs over indexed
exchanges, kept fresh via
INSERT OR REPLACE. This fixes only the summarydisplayed alongside them.)
Symptom
There is no error in the logs — the symptom is silently wrong output, plus
silently wasted work:
<archive>/<project>/<session>-summary.txtfor an active or long conversationdescribes only the first handful of exchanges.
stats/verifycount the file as "summarized", so it is never revisited.always errors) re-invokes the summarizer on every sync, indefinitely.
Environment
sessions.
Steps to Reproduce
syncagain (or let the SessionStart background sync run).<archive>/<project>/<session>-summary.txt.even though the transcript has grown substantially.
Root Cause
shouldQueueForSummaryonly re-queued a conversation when its sentinel wasmissing or a stale error marker. Once a real summary existed, the
conversation was treated as done permanently:
summary reflected, so transcript growth was undetectable.
refreshed at exchange 500 — the file only ever grows, but nothing acted on
that growth.
in-progress conversation would freeze a mid-session snapshot that is itself
immediately stale.
And once re-summarization exists, a fourth gap appears in failure handling:
but recorded nothing, so the file re-queued and re-invoked the LLM on every
sync forever. First-ever failures (
#96error sentinels) backed off ~1h butnever gave up. And since sync is
SessionStart-driven, not a fixed timer, a1h floor rarely even skips a sync — the retry effectively fired every sync.
Fix Summary
__COVERAGE__ {"bytes":<n>,"lastExchange":"<iso>","schema":1}— recording thearchived JSONL byte size the summary reflects (
formatSummaryFile/parseSummaryFile,buildCoverage,writeSummary).shouldQueueForSummary(path, currentArchiveBytes)re-queues a real summary when the archive has grown past its covered bytes
(append-only ⇒ a size increase means new content).
isQuiescent+EPISODIC_MEMORY_SUMMARY_QUIESCENCE_HOURS(default
1) only (re)summarize after the conversation has been idle for thewindow, so a mid-session snapshot is never frozen. Idle is measured from the
last exchange's timestamp — the archive copy's mtime is reset on every
sync and is unreliable.
current-size baseline (
ensureCoverageBaseline) — a header rewrite, no LLMcall — so existing installs gain a growth baseline without a mass
re-summarization on upgrade.
any prior real summary and records the attempt; one shared policy
(
shouldRetryAfterFailure) then holds the file until the retry floor(
EPISODIC_MEMORY_SUMMARY_ERROR_RETRY_HOURS, default 1h) elapses and abandonsit after
EPISODIC_MEMORY_SUMMARY_MAX_ATTEMPTS(default 5) — keeping the lastgood summary rather than thrashing. This same policy governs both first-ever
failures (
#96error sentinels, now attempt-counted) and re-summary failures(state in the coverage header's
resummaryfield), viarecordSummaryFailure.It replaces the prior mtime-based, never-give-up retry and the duplicated
floor/cap logic.
re-summary retries before the per-run
summaryLimit, so a backlog of failingre-summaries can't starve fresh work (the
#91head-of-queue failure mode).searchstrips the coverage header from the summary shownalongside results.
Internally, the gate + write + failure logic is shared (
needsSummary,recordSummaryFailure,shouldRetryAfterFailure,summarizeIfQuiescent) acrossthe three indexer paths and the sync loop, and the
_HOURSconfig defaultsconvert to milliseconds at a single boundary (
MS_PER_HOUR).Configuration
EPISODIC_MEMORY_SUMMARY_QUIESCENCE_HOURS(default1) — idle time before aconversation is (re)summarized.
EPISODIC_MEMORY_SUMMARY_ERROR_RETRY_HOURS(default1) — minimum wait betweenfailed summary attempts (the retry floor).
EPISODIC_MEMORY_SUMMARY_MAX_ATTEMPTS(default5) — failed attempts beforegiving up and keeping any existing summary. (new)
Error-sentinel format change
The
#96error sentinel's second line changes from a bare ISO timestamp to aJSON failure-state line, so it can carry the attempt count:
Backward compatible:
parseErrorSentinelreads the legacy v1.4.2 ISO form(→
attempts: 0,lastAttemptfrom the timestamp), so sentinels already on diskkeep backing off correctly and are rewritten in the new form on their next
failure. An unrecognised second line degrades safely to
{attempts: 0, lastAttempt: 0}→ retried, never lost.Tests added
test/summary-coverage.test.ts— coverage header round-trip and legacy baseline stamping.test/summary-quiescence.test.ts— quiescence verdicts and_HOURSenv parsing.test/summary-sentinel.test.ts— growth-aware gate decision table; unifiedbackoff/give-up for both error sentinels and re-summaries;
parseErrorSentinelround-trip + legacy ISO fallback;
getMaxSummaryAttempts;recordSummaryFailure.test/summary-queue-order.test.ts— fresh-before-retry ordering (#91guard).test/sync-resummary.test.ts— growth-driven re-summary, no-op when unchanged,preserve-on-failure (now also asserts the attempt is recorded).
test/search-summary-strip.test.ts— the header never leaks into the displayed summary.sync-error-sentinel(mtime-aging → stored-timestamp; sentinel-formatassertion),
show, andsynctests for the new header/gate.Fix Verified
dist(isolated temp dirs, real summarizer):"bytes":8237."bytes":41069.Caveat / Notes
is deliberate: transcripts are append-only and the per-sync coverage scan is
~23 ms even against a multi-GB archive, so no recency cutoff or minimum-delta
threshold is needed. The quiescence gate bounds work to one re-summary per
idle-after-growth episode.
summary, if any): the attempt cap bounds cost on a transcript that never
summarizes. The cap is the real bound, since the coarse, variable sync cadence
makes the wall-clock floor too short to reliably skip a sync; the floor's job
is to stop rapid session restarts from spending the budget on a transient blip.