Skip to content

fix: dedupe double-triggered summaries and unchanged-input re-processing on session stop#1061

Open
yulin0629 wants to merge 3 commits into
rohitg00:mainfrom
yulin0629:fix/summarize-double-trigger
Open

fix: dedupe double-triggered summaries and unchanged-input re-processing on session stop#1061
yulin0629 wants to merge 3 commits into
rohitg00:mainfrom
yulin0629:fix/summarize-double-trigger

Conversation

@yulin0629

@yulin0629 yulin0629 commented Jul 15, 2026

Copy link
Copy Markdown

Fixes #1062. Fixes #1063.

Problem

Every session stop currently runs mem::summarize twice, and each run re-summarizes the whole session from scratch.

The stop hook fires two requests:

  1. POST /agentmemory/summarizeapi::summarizemem::summarize
  2. POST /agentmemory/session/endapi::session::end → fans out event::session::stopped, whose handler calls mem::summarize again (src/triggers/events.ts)

mem::summarize has no lock, no in-flight guard, and no unchanged-input short-circuit (unlike mem::observe, which already uses DedupMap + withKeyedLock), so both runs do the full LLM work.

Observed in production (self-hosted, v0.9.27): every session stop produced two byte-identical summarize requests at the provider (same second, same body size), growing each cycle as the session accumulated observations — e.g. a heartbeat-style session that stopped every 30 minutes reached duplicate ~230 KB (~110K input tokens) requests per cycle:

03:24:33  claude-sonnet-5  222635B   ← same request
03:24:33  claude-sonnet-5  222635B   ← twice
03:54:27  claude-sonnet-5  229115B
03:54:27  claude-sonnet-5  229115B
04:24:22  claude-sonnet-5  235636B
04:24:22  claude-sonnet-5  235636B

Fix

  • stop hook: drop the direct /agentmemory/summarize call — /agentmemory/session/end already fans out to the summarize path. One stop, one summarize.
  • mem::summarize: serialize per-session runs with withKeyedLock (same pattern as mem::observe), and short-circuit when the stored summary's observationCount already matches the current compressed count. Observations are append-only, so an unchanged count means the LLM input would be byte-identical to the previous run — re-running cannot produce new information. This also stops full re-summarization of long-lived sessions that stop repeatedly without new activity.

Relation to #572

#572 adds a time-window dedup (SUMMARIZE_DEDUP_WINDOW_MS, default 90 s). That helps, but:

  • it does not remove the duplicate trigger itself;
  • a pure time check does not close the race — the two stop-hook requests arrive in the same second and can both pass the freshness check before either writes a summary (the duplicate pairs above are exactly this case), whereas a keyed lock makes the second run wait and then hit the short-circuit;
  • after the window expires, an unchanged session is still fully re-summarized.

The two changes are complementary: count-based skip covers "nothing new" regardless of elapsed time, and a time window could still be layered on top for sessions that keep accumulating low-value observations.

Also: graph extraction has the same shape (#1063)

The session-stopped handler sent the session's full compressed observation set to mem::graph-extract on every stop with no change detection — for a 500-observation heartbeat session that was a ~50K-token LLM call every 30 minutes with identical input. The last commit reuses the same fingerprint helper: store the set's fingerprint per session (KV.graphExtractState) and trigger extraction only when it changed.

Tests

Three new cases in test/summarize.test.ts:

  • unchanged observation count → second call returns the stored summary with skipped: true, no second LLM call
  • two concurrent calls for the same session collapse into a single LLM run
  • new observations since the last summary → re-summarizes as before

npm test: no new failures (the 4 pre-existing failures in auto-compress.test.ts / embedding-provider.test.ts reproduce on a clean checkout and come from environment leakage, unrelated to this change).

Summary by CodeRabbit

  • Bug Fixes
    • Prevented duplicate session summaries by serializing summarization per session and skipping re-runs using a deterministic input fingerprint; added lock-timeout handling to avoid blocking.
    • Updated session shutdown/stop flow to trigger summarization once at session end, avoiding duplicate LLM calls.
    • Made graph extraction idempotent: graph-extract now skips when the extracted input fingerprint matches the last processed value.
  • Tests
    • Expanded summarization tests for deduping, concurrency collapse, re-summarization on input changes, and lock-timeout behavior.
    • Added graph-extract dedup tests for fingerprinting and skip/persist behavior.

Copilot AI review requested due to automatic review settings July 15, 2026 04:00
@vercel

vercel Bot commented Jul 15, 2026

Copy link
Copy Markdown

@yulin0629 is attempting to deploy a commit to the rohitg00's projects Team on Vercel.

A member of the Team first needs to authorize it.

A single stop-hook invocation used to run mem::summarize twice: once via
the direct POST /agentmemory/summarize, and once via
/agentmemory/session/end -> event::session::stopped. Each run
re-summarized the entire session from scratch, so every session stop
cost 2x full-history LLM calls (observed: duplicate ~230KB requests per
stop, growing as the session accumulated observations).

- stop hook: drop the direct summarize call; session/end already fans
  out to the summarize path.
- mem::summarize: serialize per-session runs with withKeyedLock (same
  pattern as mem::observe) and short-circuit when the stored summary's
  observationCount already matches — observations are append-only, so an
  unchanged count means the LLM input would be identical.
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Shutdown flows now rely on session-end handling for summarization. The summarize handler serializes work per session, skips identical observation sets using fingerprints, handles lock timeouts, and reruns when observations change. Session-stop graph extraction also skips unchanged observation sets.

Changes

Session-stop deduplication

Layer / File(s) Summary
Route shutdown through session end
plugin/scripts/stop.mjs, src/hooks/stop.ts
Shutdown paths remove direct /agentmemory/summarize requests, retain session-end handling, and shorten the exit delay.
Serialize and fingerprint summaries
src/types.ts, src/functions/input-fingerprint.ts, src/functions/summarize.ts
Summarization uses a session-keyed lock, timeout, and observation-set fingerprint to reuse matching summaries or persist new fingerprints.
Deduplicate graph extraction
src/state/schema.ts, src/triggers/events.ts, test/session-end-triggers-graph.test.ts
Session-stop graph extraction persists observation fingerprints and skips extraction for unchanged inputs, with coverage for the trigger logic.
Validate summarization behavior
test/summarize.test.ts
Tests cover sequential and concurrent duplicates, changed observation sets, lock timeouts, and new observations.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Sequence Diagram(s)

sequenceDiagram
  participant StopFlow
  participant SessionEnd
  participant SummarizeHandler
  participant GraphTrigger
  participant KV
  participant LLMProvider
  StopFlow->>SessionEnd: POST /agentmemory/session/end
  SessionEnd->>SummarizeHandler: Trigger mem::summarize
  SummarizeHandler->>KV: Compare inputFingerprint
  alt Fingerprint differs
    SummarizeHandler->>LLMProvider: Generate summary
    LLMProvider-->>SummarizeHandler: Return summary
    SummarizeHandler->>KV: Persist summary and fingerprint
  else Fingerprint matches
    KV-->>SummarizeHandler: Return existing summary
  end
  SessionEnd->>GraphTrigger: Handle event::session::stopped
  GraphTrigger->>KV: Compare graph extraction fingerprint
  alt Fingerprint differs
    GraphTrigger->>KV: Persist fingerprint
    GraphTrigger->>LLMProvider: Trigger mem::graph-extract
  else Fingerprint matches
    KV-->>GraphTrigger: Skip graph extraction
  end
Loading

Possibly related PRs

Suggested reviewers: rohitg00

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR also adds graph-extract fingerprint deduplication and related state/tests, which are unrelated to the linked summarization issue. Split the graph-extract deduplication work into a separate PR or link the relevant issue if it is intended scope.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The duplicate stop trigger is removed, summaries are serialized per session, and unchanged inputs are skipped while re-summarization still occurs when input changes.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main fix: removing duplicate stop-triggered summaries and skipping re-processing for unchanged input on session stop.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses redundant, double-triggered session summarization by removing an extra stop-hook summarize call and adding a per-session in-flight guard plus an “unchanged input” short-circuit in mem::summarize, with accompanying tests to cover the race/skip behavior.

Changes:

  • Remove the direct /agentmemory/summarize call from stop hooks so stop only triggers /agentmemory/session/end (which already fans out to summarization).
  • Serialize mem::summarize per session via withKeyedLock and add a skip path when the stored summary appears up-to-date.
  • Add tests covering the skip path, concurrency collapse, and re-summarize-on-growth behavior.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
src/functions/summarize.ts Adds per-session keyed lock and an “up-to-date” short-circuit to avoid duplicate LLM work.
src/hooks/stop.ts Removes the extra summarize request, relying on session-end fanout.
plugin/scripts/stop.mjs Mirrors stop hook behavior change in the packaged script output.
test/summarize.test.ts Adds tests for the new dedup/locking behavior.
Comments suppressed due to low confidence (1)

src/hooks/stop.ts:49

  • This hook now only fires a single HTTP request (/agentmemory/session/end), but the forced-exit timer is still 1500ms. Since an unawaited fetch() keeps the event loop alive, this can unnecessarily delay hook completion vs. other single-request hooks in src/hooks/ which use 500ms.
  fetch(`${REST_URL}/agentmemory/session/end`, {
    method: "POST",
    headers: authHeaders(),
    body: JSON.stringify({ sessionId }),
    signal: AbortSignal.timeout(5000),

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/functions/summarize.ts Outdated
Comment on lines +272 to +276
const existing = await kv.get<SessionSummary>(
KV.summaries,
sessionId,
});
return {
success: false,
error: "no_provider",
reason:
"No LLM provider key set; Summarize is a no-op. Set ANTHROPIC_API_KEY (or GEMINI/OPENROUTER/MINIMAX) in ~/.agentmemory/.env to enable.",
};
}
);
if (existing && existing.observationCount === compressed.length) {
Comment thread plugin/scripts/stop.mjs
Comment on lines 26 to 33
@@ -38,7 +32,7 @@ async function main() {
setTimeout(() => process.exit(0), 1500).unref();
}

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/functions/summarize.ts`:
- Around line 244-282: The session lock in the summarize handler can remain held
indefinitely while produceSummaryXml runs. Bound the provider call used by
produceSummaryXml with the existing timeout mechanism, covering Anthropic and
agent-sdk as well as the already bounded providers, so hung summarization
releases withKeyedLock; alternatively, move the existing observation-count
up-to-date check before lock acquisition only if it remains race-safe.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b7c79df3-c4c1-4e44-bfd0-4f7bd40b4182

📥 Commits

Reviewing files that changed from the base of the PR and between 93ae9bc and d5741ae.

📒 Files selected for processing (4)
  • plugin/scripts/stop.mjs
  • src/functions/summarize.ts
  • src/hooks/stop.ts
  • test/summarize.test.ts

Comment thread src/functions/summarize.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
test/summarize.test.ts (1)

548-573: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Good coverage for the "permanently hung" case; consider also covering "eventually resolves after timeout".

This test only proves the lock frees when the provider never settles. It doesn't exercise a provider that resolves shortly after the lock timeout fires — which is the scenario that can produce a stale overwrite of a newer summary (see the critical-issue comment on src/functions/summarize.ts Lines 68-91,473-475). Once that's fixed, a regression test resolving the hung provider after the timeout and asserting the later summary isn't clobbered would be valuable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/summarize.test.ts` around lines 548 - 573, Extend the timeout coverage
around the handler setup and provider summarize flow to use a provider promise
that resolves after SUMMARIZE_LOCK_TIMEOUT_MS, then complete a newer follow-up
summary before resolving the timed-out run. Assert that the late result does not
overwrite the newer summary, while preserving the existing timeout and
lock-release assertions.
src/functions/summarize.ts (1)

306-328: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Independent kv operations can run in parallel.

kv.list(KV.observations(sessionId)) (Line 306-308) and the later kv.get(KV.summaries, sessionId) (Line 325-328) don't depend on each other and could be issued together via Promise.all. Likewise kv.set(KV.summaries, ...) (Line 434) and safeAudit(...) (Line 435-438) write to independent keys and don't need to be sequential.

♻️ Proposed parallelization
-        const observations = await kv.list<CompressedObservation>(
-          KV.observations(sessionId),
-        );
+        const [observations, existingBeforeRun] = await Promise.all([
+          kv.list<CompressedObservation>(KV.observations(sessionId)),
+          kv.get<SessionSummary>(KV.summaries, sessionId),
+        ]);
         const compressed = observations.filter((o) => o.title);
 
         if (compressed.length === 0) {
           ...
         }
 
         const inputFingerprint = computeInputFingerprint(compressed);
-        const existing = await kv.get<SessionSummary>(
-          KV.summaries,
-          sessionId,
-        );
+        const existing = existingBeforeRun;
         if (existing && existing.inputFingerprint === inputFingerprint) {
           summary.inputFingerprint = inputFingerprint;
-        await kv.set(KV.summaries, sessionId, summary);
-          await safeAudit(kv, "compress", "mem::summarize", [sessionId], {
-            title: summary.title,
-            observationCount: compressed.length,
-          });
+          await Promise.all([
+            kv.set(KV.summaries, sessionId, summary),
+            safeAudit(kv, "compress", "mem::summarize", [sessionId], {
+              title: summary.title,
+              observationCount: compressed.length,
+            }),
+          ]);

As per coding guidelines: "Use parallel operations where possible with Promise.all for independent kv operations/reads."

Also applies to: 433-438

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/functions/summarize.ts` around lines 306 - 328, Update the summarize flow
around the observations read and existing summary lookup to issue the
independent kv.list and kv.get operations together with Promise.all, while
preserving the existing no-observations and fingerprint logic. Near the summary
persistence block, likewise run kv.set and safeAudit concurrently with
Promise.all, ensuring both operations still complete and retain their current
arguments and behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/functions/summarize.ts`:
- Around line 68-91: The timeout path must prevent a still-running runSummarize
from persisting stale output after the session lock is released. Add a
supersession/timeout check immediately before the KV summary write and safeAudit
calls, using the existing session/run state, and skip persistence when the
timeout has won; ensure the check also handles providers that resolve after
timeout while preserving normal successful persistence.

---

Nitpick comments:
In `@src/functions/summarize.ts`:
- Around line 306-328: Update the summarize flow around the observations read
and existing summary lookup to issue the independent kv.list and kv.get
operations together with Promise.all, while preserving the existing
no-observations and fingerprint logic. Near the summary persistence block,
likewise run kv.set and safeAudit concurrently with Promise.all, ensuring both
operations still complete and retain their current arguments and behavior.

In `@test/summarize.test.ts`:
- Around line 548-573: Extend the timeout coverage around the handler setup and
provider summarize flow to use a provider promise that resolves after
SUMMARIZE_LOCK_TIMEOUT_MS, then complete a newer follow-up summary before
resolving the timed-out run. Assert that the late result does not overwrite the
newer summary, while preserving the existing timeout and lock-release
assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c802e413-58f5-4fb1-8c2d-cdcabc24b2ab

📥 Commits

Reviewing files that changed from the base of the PR and between d5741ae and b531494.

📒 Files selected for processing (5)
  • plugin/scripts/stop.mjs
  • src/functions/summarize.ts
  • src/hooks/stop.ts
  • src/types.ts
  • test/summarize.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/hooks/stop.ts
  • plugin/scripts/stop.mjs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 1

🧹 Nitpick comments (2)
test/summarize.test.ts (1)

548-573: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Good coverage for the "permanently hung" case; consider also covering "eventually resolves after timeout".

This test only proves the lock frees when the provider never settles. It doesn't exercise a provider that resolves shortly after the lock timeout fires — which is the scenario that can produce a stale overwrite of a newer summary (see the critical-issue comment on src/functions/summarize.ts Lines 68-91,473-475). Once that's fixed, a regression test resolving the hung provider after the timeout and asserting the later summary isn't clobbered would be valuable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/summarize.test.ts` around lines 548 - 573, Extend the timeout coverage
around the handler setup and provider summarize flow to use a provider promise
that resolves after SUMMARIZE_LOCK_TIMEOUT_MS, then complete a newer follow-up
summary before resolving the timed-out run. Assert that the late result does not
overwrite the newer summary, while preserving the existing timeout and
lock-release assertions.
src/functions/summarize.ts (1)

306-328: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Independent kv operations can run in parallel.

kv.list(KV.observations(sessionId)) (Line 306-308) and the later kv.get(KV.summaries, sessionId) (Line 325-328) don't depend on each other and could be issued together via Promise.all. Likewise kv.set(KV.summaries, ...) (Line 434) and safeAudit(...) (Line 435-438) write to independent keys and don't need to be sequential.

♻️ Proposed parallelization
-        const observations = await kv.list<CompressedObservation>(
-          KV.observations(sessionId),
-        );
+        const [observations, existingBeforeRun] = await Promise.all([
+          kv.list<CompressedObservation>(KV.observations(sessionId)),
+          kv.get<SessionSummary>(KV.summaries, sessionId),
+        ]);
         const compressed = observations.filter((o) => o.title);
 
         if (compressed.length === 0) {
           ...
         }
 
         const inputFingerprint = computeInputFingerprint(compressed);
-        const existing = await kv.get<SessionSummary>(
-          KV.summaries,
-          sessionId,
-        );
+        const existing = existingBeforeRun;
         if (existing && existing.inputFingerprint === inputFingerprint) {
           summary.inputFingerprint = inputFingerprint;
-        await kv.set(KV.summaries, sessionId, summary);
-          await safeAudit(kv, "compress", "mem::summarize", [sessionId], {
-            title: summary.title,
-            observationCount: compressed.length,
-          });
+          await Promise.all([
+            kv.set(KV.summaries, sessionId, summary),
+            safeAudit(kv, "compress", "mem::summarize", [sessionId], {
+              title: summary.title,
+              observationCount: compressed.length,
+            }),
+          ]);

As per coding guidelines: "Use parallel operations where possible with Promise.all for independent kv operations/reads."

Also applies to: 433-438

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/functions/summarize.ts` around lines 306 - 328, Update the summarize flow
around the observations read and existing summary lookup to issue the
independent kv.list and kv.get operations together with Promise.all, while
preserving the existing no-observations and fingerprint logic. Near the summary
persistence block, likewise run kv.set and safeAudit concurrently with
Promise.all, ensuring both operations still complete and retain their current
arguments and behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/functions/summarize.ts`:
- Around line 68-91: The timeout path must prevent a still-running runSummarize
from persisting stale output after the session lock is released. Add a
supersession/timeout check immediately before the KV summary write and safeAudit
calls, using the existing session/run state, and skip persistence when the
timeout has won; ensure the check also handles providers that resolve after
timeout while preserving normal successful persistence.

---

Nitpick comments:
In `@src/functions/summarize.ts`:
- Around line 306-328: Update the summarize flow around the observations read
and existing summary lookup to issue the independent kv.list and kv.get
operations together with Promise.all, while preserving the existing
no-observations and fingerprint logic. Near the summary persistence block,
likewise run kv.set and safeAudit concurrently with Promise.all, ensuring both
operations still complete and retain their current arguments and behavior.

In `@test/summarize.test.ts`:
- Around line 548-573: Extend the timeout coverage around the handler setup and
provider summarize flow to use a provider promise that resolves after
SUMMARIZE_LOCK_TIMEOUT_MS, then complete a newer follow-up summary before
resolving the timed-out run. Assert that the late result does not overwrite the
newer summary, while preserving the existing timeout and lock-release
assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c802e413-58f5-4fb1-8c2d-cdcabc24b2ab

📥 Commits

Reviewing files that changed from the base of the PR and between d5741ae and b531494.

📒 Files selected for processing (5)
  • plugin/scripts/stop.mjs
  • src/functions/summarize.ts
  • src/hooks/stop.ts
  • src/types.ts
  • test/summarize.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/hooks/stop.ts
  • plugin/scripts/stop.mjs
🛑 Comments failed to post (1)
src/functions/summarize.ts (1)

68-91: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -B3 -A15 'withKeyedLock' src -g'*.ts'
rg -n 'Promise\.race' src -g'*.ts'

Repository: rohitg00/agentmemory

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant files first
ast-grep outline src/functions/summarize.ts --view expanded
printf '\n--- keyed-mutex ---\n'
ast-grep outline src/state/keyed-mutex.ts --view expanded

printf '\n--- summarize.ts key slices ---\n'
sed -n '1,220p' src/functions/summarize.ts | cat -n

printf '\n--- keyed-mutex.ts ---\n'
sed -n '1,220p' src/state/keyed-mutex.ts | cat -n

printf '\n--- summarize tests around timeout ---\n'
sed -n '520,620p' src/functions/summarize.ts | cat -n

Repository: rohitg00/agentmemory

Length of output: 10917


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Focus on summarize persistence and any freshness checks
rg -n -A6 -B6 'kv\.set\(KV\.summaries|inputFingerprint|summarize_timeout|withKeyedLock|raceLockTimeout|runSummarize' src/functions/summarize.ts

Repository: rohitg00/agentmemory

Length of output: 158


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the exact file size before reading more if needed
wc -l src/functions/summarize.ts src/state/keyed-mutex.ts

Repository: rohitg00/agentmemory

Length of output: 234


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read only the parts that define the timeout/lock behavior
sed -n '1,220p' src/functions/summarize.ts | nl -ba | sed -n '1,220p'
printf '\n---\n'
sed -n '1,220p' src/state/keyed-mutex.ts | nl -ba | sed -n '1,220p'

Repository: rohitg00/agentmemory

Length of output: 198


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for summary writes and freshness checks in the repository
rg -n -A4 -B4 'KV\.summaries|inputFingerprint|summarize_timeout|summarize|SessionSummary' src/functions src/state src/types -g'*.ts'

Repository: rohitg00/agentmemory

Length of output: 17669


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show summarize.ts around the timeout and persistence paths only
nl -ba src/functions/summarize.ts | sed -n '1,240p'

Repository: rohitg00/agentmemory

Length of output: 198


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show keyed mutex implementation
nl -ba src/state/keyed-mutex.ts | sed -n '1,220p'

Repository: rohitg00/agentmemory

Length of output: 198


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the summarize implementation around the write path.
sed -n '220,478p' src/functions/summarize.ts | cat -n

printf '\n--- tests around summarize timeout ---\n'
rg -n -A20 -B10 'summarize_timeout|lock timeout|raceLockTimeout|mem::summarize|runSummarize' src -g'*.ts'

Repository: rohitg00/agentmemory

Length of output: 19396


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the summarize implementation around the write path using cat -n.
sed -n '220,478p' src/functions/summarize.ts | cat -n

printf '\n--- nearby tests/mentions ---\n'
rg -n -A8 -B8 'summarize_timeout|raceLockTimeout|runSummarize|mem::summarize' src -g'*.ts'

Repository: rohitg00/agentmemory

Length of output: 16092


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n 'kv\.set|kv\.delete|recordAudit|safeAudit|metricsStore|KV\.summaries|inputFingerprint|createdAt|updatedAt|sessionId' src/functions/summarize.ts

Repository: rohitg00/agentmemory

Length of output: 215


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -A20 -B20 'registerSummarizeFunction|mem::summarize|summarize\(' src -g'*.ts'

Repository: rohitg00/agentmemory

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the bottom half of summarize.ts directly.
sed -n '280,478p' src/functions/summarize.ts | cat -n

Repository: rohitg00/agentmemory

Length of output: 8703


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '280,478p' src/functions/summarize.ts | cat -n

Repository: rohitg00/agentmemory

Length of output: 8703


🏁 Script executed:

#!/bin/bash
set -euo pipefail

grep -nE 'summaries|inputFingerprint|safeAudit|metricsStore|kv\.set|kv\.get|recordAudit|SessionSummary' src/functions/summarize.ts

Repository: rohitg00/agentmemory

Length of output: 212


Prevent timed-out summarize runs from overwriting newer summaries

Promise.race only ends the caller; it doesn’t cancel runSummarize(). Once the timeout branch wins, the lock is released and the old run can still reach kv.set(KV.summaries, sessionId, summary) / safeAudit, overwriting a newer result. Add a supersession check right before persist (or abort the provider call on timeout) and cover the case where the provider resolves after the timeout.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/functions/summarize.ts` around lines 68 - 91, The timeout path must
prevent a still-running runSummarize from persisting stale output after the
session lock is released. Add a supersession/timeout check immediately before
the KV summary write and safeAudit calls, using the existing session/run state,
and skip persistence when the timeout has won; ensure the check also handles
providers that resolve after timeout while preserving normal successful
persistence.

… exit timer

- Replace the observationCount-based skip with an input fingerprint
  (sha256 over ordered observation ids + timestamps). Count alone is not
  safe: evict/auto-forget can delete observations, so the set can change
  while the count returns to a previous value. Old summaries have no
  fingerprint and simply re-summarize once.
- Bound how long one run may hold the per-session lock
  (SUMMARIZE_LOCK_TIMEOUT_MS, default 5 min): anthropic/agent-sdk
  providers have no request timeout, and a hung call must not block every
  later summarize for that session.
- Align the stop hook forced-exit timer with other single-request hooks
  (1500ms -> 500ms) now that it only sends one request.
…hanged

The session-stopped handler sent the session's full compressed observation
set to mem::graph-extract on every stop. Sessions that stop repeatedly
without new activity (heartbeat-style loops) re-extracted the identical
set each time — for a 500-observation session that is a ~50K-token LLM
call per stop. Fingerprint the set (same helper as the summarize skip)
and store it per session; trigger extraction only when it changed.
@yulin0629 yulin0629 force-pushed the fix/summarize-double-trigger branch from b531494 to b6a002b Compare July 15, 2026 08:07

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/triggers/events.ts (1)

66-80: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Parallelize independent KV reads using Promise.all.

As per coding guidelines, use parallel operations where possible with Promise.all for independent kv writes/reads. You can fetch the previous fingerprint state concurrently with the observations list to save a network round trip.

⚡ Proposed refactor
-        const observations = await kv.list<CompressedObservation>(
-          KV.observations(data.sessionId),
-        );
+        const [observations, prev] = await Promise.all([
+          kv.list<CompressedObservation>(KV.observations(data.sessionId)),
+          kv.get<{ fingerprint: string }>(KV.graphExtractState, data.sessionId).catch(() => null),
+        ]);
         const compressed = observations.filter((o) => o.title);
         if (compressed.length > 0) {
           // Sessions that stop repeatedly without new observations (e.g.
           // heartbeat loops) would re-extract the identical set every time;
           // skip when the fingerprint of the set is unchanged.
           const fingerprint = computeInputFingerprint(compressed);
-          const prev = await kv
-            .get<{ fingerprint: string }>(
-              KV.graphExtractState,
-              data.sessionId,
-            )
-            .catch(() => null);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/triggers/events.ts` around lines 66 - 80, Update the event handling flow
around KV.observations and KV.graphExtractState to start both independent reads
concurrently with Promise.all, then derive compressed observations and the
previous fingerprint state from the results while preserving the existing
filtering and fallback behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/session-end-triggers-graph.test.ts`:
- Around line 94-118: Replace the source-text regex tests in the
“event::session::stopped graph-extract dedup” suite with runtime tests that
invoke the handler registered by registerEventTriggers using local mock sdk and
kv objects. Mock sdk.trigger, kv.get, kv.set, and kv.list, then assert that the
compressed fingerprint is persisted, unchanged fingerprints skip extraction, and
mem::graph-extract triggers only for new fingerprints.

---

Nitpick comments:
In `@src/triggers/events.ts`:
- Around line 66-80: Update the event handling flow around KV.observations and
KV.graphExtractState to start both independent reads concurrently with
Promise.all, then derive compressed observations and the previous fingerprint
state from the results while preserving the existing filtering and fallback
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1db93c90-2e2b-47cf-a727-747ab884f1dc

📥 Commits

Reviewing files that changed from the base of the PR and between b531494 and b6a002b.

📒 Files selected for processing (9)
  • plugin/scripts/stop.mjs
  • src/functions/input-fingerprint.ts
  • src/functions/summarize.ts
  • src/hooks/stop.ts
  • src/state/schema.ts
  • src/triggers/events.ts
  • src/types.ts
  • test/session-end-triggers-graph.test.ts
  • test/summarize.test.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/types.ts
  • src/hooks/stop.ts
  • src/functions/summarize.ts
  • test/summarize.test.ts
  • plugin/scripts/stop.mjs

Comment on lines +94 to +118

// Sessions that stop repeatedly without new observations (e.g. heartbeat
// loops) used to re-extract the identical observation set on every stop.
// The handler now fingerprints the set and skips when unchanged.
describe("event::session::stopped graph-extract dedup", () => {
const events = readFileSync("src/triggers/events.ts", "utf-8");

it("fingerprints the compressed set before triggering mem::graph-extract", () => {
expect(events).toMatch(
/const fingerprint = computeInputFingerprint\(compressed\);[\s\S]*?function_id:\s*"mem::graph-extract"/,
);
});

it("skips extraction when the stored fingerprint matches", () => {
expect(events).toMatch(
/prev\.fingerprint === fingerprint[\s\S]*?Graph extraction skipped/,
);
});

it("persists the fingerprint under KV.graphExtractState before triggering", () => {
expect(events).toMatch(
/kv\.set\(KV\.graphExtractState,\s*data\.sessionId,\s*\{\s*fingerprint,[\s\S]*?\}\);[\s\S]*?function_id:\s*"mem::graph-extract"/,
);
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Refactor test to execute runtime behavior using mocks.

As per coding guidelines, test files must use the mock pattern with mock implementations of sdk.trigger, kv.get, kv.set, and kv.list. Testing logic by reading the source file as a string and matching regular expressions against it is highly brittle and fails to verify actual runtime execution.

Based on learnings, since the registerEventTriggers function accepts sdk and kv directly as arguments, you can pass local mock objects (e.g., mockSdk()/mockKV()) to invoke the target handler and assert on the executed state changes and triggers instead of attempting to mock the module.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/session-end-triggers-graph.test.ts` around lines 94 - 118, Replace the
source-text regex tests in the “event::session::stopped graph-extract dedup”
suite with runtime tests that invoke the handler registered by
registerEventTriggers using local mock sdk and kv objects. Mock sdk.trigger,
kv.get, kv.set, and kv.list, then assert that the compressed fingerprint is
persisted, unchanged fingerprints skip extraction, and mem::graph-extract
triggers only for new fingerprints.

Sources: Coding guidelines, Learnings

@yulin0629 yulin0629 changed the title fix(summarize): dedupe double-triggered session summaries fix: dedupe double-triggered summaries and unchanged-input re-processing on session stop Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants