Repository navigation
Sessions never end on crash/SSH-drop/Ctrl-C — graph extraction + consolidation silently skipped, then evicted #308
Description
Summary
The current session lifecycle relies on Claude Code's SessionEnd hook firing reliably to trigger graph extraction and consolidation (event::session::stopped → mem::graph-extract, plus the consolidation pipeline in session-end.mjs). In practice, SessionEnd doesn't fire reliably across the common failure modes that real users hit: SSH disconnects, Ctrl+C in remote terminals, laptop sleep, network blips, /clear (per anthropics/claude-code#6428), and even when it does fire, hook async work is killed before completion (anthropics/claude-code#41577). Filing as a design discussion rather than a bug — would like the maintainer's take on intent.
What I see in the code
In dist/index.mjs (v0.9.9):
event::session::stoppedis the only path that firesmem::graph-extract(line ~16415). It's only triggered when something POSTs/agentmemory/session/end(i.e. via the SessionEnd hook, or a manual call).mem::evict(line ~5045-5077) sweeps sessions older thanstaleSessionDaysand deletes any that lack a summary, citingreason: "stale_session_without_summary". No graph extraction or consolidation runs before the delete.memory_diagnoseflags abandoned sessions at the 24-hour mark withstatus: "warn"but only logs — doesn't trigger any recovery action.
Net behaviour for a user whose Claude Code crashes / disconnects / SSHs drop:
- Session stays
status="active"forever. - No
event::session::stoppedever fires → no graph extraction, no consolidation. - After
staleSessionDays(default unclear — I couldn't find it in the env doc), the eviction sweep deletes the entire session including its observations. Graph would-have-extracted content is permanently lost.
Why this matters in practice
Per the Claude Code issues referenced above, SessionEnd reliability is far from perfect even for "graceful" cases:
- SessionEnd hook does not fire with /clear as documentation states. anthropics/claude-code#6428 —
/clearis documented to fire SessionEnd, but multiple users report it doesn't. - CLI crashes silently on intermittent network issues instead of recovering gracefully anthropics/claude-code#27734 — Claude Code dies silently on SSE-connection drops with no SessionEnd event emitted.
- SessionEnd hooks are killed before completion when running async work anthropics/claude-code#41577 — Even when SessionEnd fires, async work in the hook gets killed before it can finish, so
mem::graph-extractand the consolidation calls insession-end.mjsmay not complete.
For agentmemory's "remote/SSH-heavy" user base (this is precisely the audience for coding agents on cloud machines), these aren't edge cases — they're the modal experience. A user who runs Claude Code over SSH and accidentally Ctrl+C's, or whose laptop sleeps, or whose connection drops, has their session-end pipeline silently skipped and the data ultimately deleted by the stale-sweep.
Questions for the maintainer
- Is the current design intentional? I.e., is the assumption "users run
/exitcleanly, and if they don't, the data was disposable anyway"? Or is this a known gap that just hasn't been prioritized? /resumesemantics — since/resumekeeps the same session_id, the existing design does support cross-disconnect continuity… if the user remembers to resume. If they don't, the session ages out and is deleted. Was this the intended trade?- Should the stale-sweep run extraction + consolidation before deleting? That would convert "data loss" into "data preserved as graph/insights only", which feels like the right safety net even if the rest of the design stays as-is.
Proposed design (one option among several)
A backend-side idle-session sweep, since agentmemory's persistent process sees every observation's updatedAt and doesn't depend on any hook firing:
- New scheduled function in agentmemory, runs every ~30 min: iterate
kv.list(KV.sessions), find eachstatus === "active"wherenow - new Date(updatedAt || startedAt).getTime() > AGENTMEMORY_IDLE_SESSION_HOURS * HOUR_MS, and for each:- Trigger
event::session::stopped(graph extraction, slot reflection) - Trigger the consolidation pipeline equivalent to what
session-end.mjsdoes - Set
status="abandoned"(or"completed"— design call) andendedAt
- Trigger
- New env var
AGENTMEMORY_IDLE_SESSION_HOURS(default 24 or similar) - Idempotency is already handled — I verified empirically that running
/agentmemory/graph/extracton the same observation set multiple times produces+0 nodes / +0 edgeson the re-runs (dedup by extracted-entity content). Same is true for consolidation per the content-merge semantics. - The existing stale-sweep can still run AFTER this — and now it deletes sessions that were already extracted, so the data is preserved as graph nodes and semantic facts even if the raw observations are GC'd.
Variations worth considering:
- Use a shorter threshold (4h? 12h?) for "session went idle without
/exit" — most users would either/resumequickly or want extraction to fire soon after they walked away. - Distinguish "session updatedAt is fresh, session has just been idle for a while" from "session has been abandoned" — the first is just normal
/resumelag, the second is a genuine crash. - Optional: emit a Pushover/webhook on abandoned-session detection (configurable) so users notice they have an orphaned session worth resuming.
What I'm doing on my deployment in the meantime
Building a small systemd timer that polls agentmemory's API every few hours, finds active sessions with updatedAt older than 24h, and POSTs /agentmemory/session/end to trigger the full clean-end pipeline before the stale-sweep can delete them. Stopgap until upstream decides direction. Happy to share if useful as a reference for what an in-tree implementation might look like.
Context
I deploy agentmemory on a small production server (@agentmemory/agentmemory@0.9.9, host-systemd + docker compose for iii-engine). My session today has ~360 observations and the graph would have stayed at 0/0 forever without manually backfilling — that surfaced this whole design question. Earlier issues from same deployment: #299 (viewer port), #301 (volume perms) — both now merged in #304. Thanks for the responsive turnaround on those.
Plugin: @agentmemory/agentmemory@0.9.9. Verified end-to-end on a Hetzner Cloud host.
Activity
flamerged commentedon May 12, 2026
More angles worth considering
Thinking about this from a few more directions — wanted to lay out the full design space so the call you make has more options to compare against. Not advocating for any specific one; some of these compose with each other, some replace each other.
A. Decouple extraction from session-end entirely
The premise here: a session is just a logical grouping. The extraction work doesn't fundamentally need to wait for it to "end" — it could happen continuously as observations come in.
mem::graph-extractover the new observations since the last extraction watermark. Pros: data is in the graph within minutes, crashes never lose more than ~one batch worth. Cons: more LLM calls overall (though dedup makes the cost-per-extraction approach asymptotic), watermark bookkeeping per session.timestamp ∈ [bucket_start, bucket_end]. Crashes lose at most one bucket. Closer to how Hindsight / GBrain handle batching.B. Additional trigger paths (multiple firing routes)
Don't replace SessionEnd — add fallbacks so at least one fires.
cwd, find any other active session for the same cwd with staleupdatedAt, fireevent::session::stoppedon it. Cheap; assumes "you can't be in two active sessions of the same project at once", which is true in practice for the Claude Code workflow.activeper cwd at a time. When SessionStart fires for a cwd that already has an active session, auto-end the older one. Common in dev tools (Jest, esbuild dev server, etc.)./compact), treat that as a "session is about to lose context" signal and run extraction on the to-be-compacted observations. Won't help on crash but covers the long-session case where extraction would naturally lag.C. Make the existing failure mode safe (defensive, minimal change)
If the rest of the lifecycle stays as-is, the minimum-viable fix is making the stale-sweep non-destructive of insights.
mem::evictextracts before deleting. Before deleting astale_session_without_summary, run graph extraction + consolidation on its observations. Then delete the raw observations only; the graph nodes / semantic facts / procedural memories survive. One-function change insrc/functions/retention.ts(or wherever evict lives). Closest thing to a true bug-fix: prevents the worst outcome (data loss) without changing trigger semantics.AGENTMEMORY_STALE_SESSION_DAYSenv var (if not already configurable; couldn't tell from quick code read). Lets users with valuable orphaned sessions extend the window.D. User-facing escape hatches
agentmemory reapCLI subcommand. On-demand sweep that finds abandoned sessions and fires their end pipeline. Users wire it into their own cron / systemd timer if they want it automated. Smallest surface change; punts policy to the user./agentmemory/session/end. Manual but discoverable.Combinations I'd consider
Sentinel-side stopgap update
While the design discussion happens here, I'll build the equivalent of D1 on Sentinel as a systemd timer (cron-style). Happy to share the code once it's stable — could serve as a reference implementation if D1 or B4 is the direction you'd pick.