Skip to content

bug(session ended): gentle-engram: mid-conversation session_shutdown permanently severs memory persistence ("session has already ended")Β #1307

Description

@carolitascl

Engram bug report β€” paste-ready body (form: .github/ISSUE_TEMPLATE/bug_report.yml)

πŸ“ Bug Description (field: description)

Mid-conversation, the gentle-engram Pi plugin permanently severs memory persistence: every later mem_save / mem_session_summary fails with the raw sentinel session has already ended, even though the Pi session is still running.

Root cause is a lifecycle-policy collision between Pi and Engram:

  • Pi fires the session_shutdown extension event not only on quit but on reload / new / resume / fork extension rebinds (pi docs/extensions.md Β§"session_shutdown", Β§432, Β§449). A reload rebinds extensions without changing the runtime session id β€” the conversation continues in the same runtime session.
  • The gentle-engram plugin (plugin/pi/index.ts, pi.on("session_shutdown")) unconditionally POSTs /sessions/{id}/end for the runtime session id β€” including on reload.
  • The Engram server treats an ended runtime session as one-shot forever: startSessionTx (internal/store/store.go) only matches rows WHERE sessions.ended_at IS NULL, so re-registration returns ErrSessionAlreadyEnded ("Choose a new session ID and retry mem_session_start", internal/mcp/mcp.go).

Result: one mid-session extension rebind permanently marks the row ended_at while the runtime keeps running, and every subsequent write self-heals against the same (now ended) session id and is rejected.

πŸ”„ Steps to Reproduce (field: steps)

  1. Start a Pi session in any project with the gentle-engram plugin; Engram registers the runtime session (row started_at = session start).
  2. Keep the session alive and idle >10 minutes so a runtime_lease_expires_at renewal happens shortly before the trigger (renewals happen on agent turns / tool activity).
  3. Trigger any Pi extension reload/rebind that emits session_shutdown with reason: "reload" while the runtime session id stays the same (observed: extension-stack recovery right after a long-blocking review-consent tool call returned, with a review-reminder receipt logged 0-3 s before the end).
  4. Observe the store: UPDATE sessions SET ended_at = datetime('now') has run for the still-live runtime session.
  5. Call mem_save (or wait for the delivery-guarantee summary at session close): it fails with session has already ended for the rest of the conversation.

βœ… Expected Behavior (field: expected)

A session_shutdown that does not retire the runtime session id (Pi reason: "reload") must not end the Engram session; and if an ended runtime session is re-registered while its runtime_lease_expires_at is still in the future, Engram should reopen it (the unexpired lease proves the same runtime never stopped). Ending should remain terminal only once the lease has lapsed (manual CreateSession rows have no lease and stay strictly terminal).

❌ Actual Behavior (field: actual)

Tool result (mem_save):  session has already ended
Tool result (mem_session_summary): session has already ended

Store evidence (sqlite, ~/.engram/engram.db, table sessions):
id                                   | project      | started_at         | ended_at           | runtime_lease_expires_at
01a0c0c3-... (this report's session) | pi-subagents | 2026-09-20 21:41:06 | 2026-09-20 22:45:36 | 2026-09-20 23:15:32
01a0c038-... (second victim, same day)| gentle-shell | 2026-09-20 19:11:23 | 2026-09-20 20:18:44 | 2026-09-20 20:48:29

Both rows: ended mid-life, transcript files continue past `ended_at` (runtime never quit),
lease = last successful renewal + 30 min, renewed seconds before the spurious end.
observations count for the ended session: 0 (every write rejected).
mem_doctor reports 9/9 checks ok β€” the store is consistent; this is lifecycle policy, not corruption.

πŸ–₯️ Environment (fields: os / engram-version / agent)

  • OS: macOS
  • Engram Version: engram main (Go server, engram serve); gentle-engram plugin 0.1.14 (plugin/pi)
  • Agent / Client: Other β€” pi coding agent (@earendil-works/pi-coding-agent 0.86.1) with the gentle-engram Pi package

πŸ“‹ Relevant Logs (field: logs)

Timeline (UTC, from the session transcript + store):
22:31:48  gentle_review START tool call issued (blocks ~14 min on a consent relay)
22:45:32.981  call returns: consent-binding-stale ("expired after 10 minutes")
22:45:33.910  gentle-pi.review-reminder-receipt/v1 custom event recorded
22:45:36      sessions.ended_at written for the STILL-RUNNING runtime session
22:45:42      user prompt arrives in the SAME session; conversation continues normally
23:35:50+     every mem_save / mem_session_summary -> "session has already ended"

Ruled out: subagent sessions (all five got their own ids with healthy start->end pairs),
store corruption (doctor ok), lease reaper (none exists; only /end writes ended_at),
manual-session interference (manual rows have no lease and are unaffected by the fix below).

πŸ’‘ Additional Context (field: context)

Suggested fix (draft, three parts)

A. Server (internal/store/store.go, startSessionTx + caller StartSessionWithOwnershipMode): extend the upsert's WHERE to also match ended rows whose runtime_lease_expires_at is still in the future, and clear ended_at in that case; report reopened to the caller so the sync upsert mutation is enqueued. Manual rows (no lease) and expired/malformed leases keep the current terminal refusal.

 ON CONFLICT(id) DO UPDATE SET
   ... existing project/ownership/directory COALESCE branches ...,
   ended_at = CASE WHEN sessions.ended_at IS NOT NULL
                     AND datetime(sessions.runtime_lease_expires_at) > datetime('now')
                    THEN NULL ELSE sessions.ended_at END,
   runtime_lease_expires_at = excluded.runtime_lease_expires_at
 WHERE sessions.ended_at IS NULL
    OR datetime(sessions.runtime_lease_expires_at) > datetime('now')

startSessionTx returns (reopened bool, err error); StartSessionWithOwnershipMode folds reopened into its identityRepaired sync-enqueue gate. Update the doc comment at the StartSessionWithOwnershipMode ("reopens an ended session only while its runtime lease is unexpired; once the lease lapses, EndSession remains terminal truth").

B. Plugin (plugin/pi/index.ts, pi.on("session_shutdown")): skip the /end POST when event.reason === "reload" (Pi rebinds extensions but keeps the runtime session id; quit/new/resume/fork keep today's behavior).

pi.on("session_shutdown", async (event: unknown, ctx: SessionContext) => {
  if ((event as { reason?: string } | undefined)?.reason === "reload") return;
  // ... existing closing-marker + endRegisteredSessionOnce + cleanup unchanged

C. Plugin UX (optional): map the session_already_ended error body on write paths to an actionable message ("Engram session was ended server-side; run mem_session_start with a new id or restart the Pi session") instead of the bare sentinel.

Test plan

  • store_test.go: split TestRuntimeSessionRegistrationRejectsEndedSessions into (1) reopen succeeds while lease is live (ended_at cleared, lease refreshed) and (2) refusal after the lease is expired (UPDATE ... runtime_lease_expires_at = datetime('now','-1 minute')), keeping the existing before/after DeepEqual. TestStartSessionRejectsEndedSessionWithoutMutation (manual CreateSession, no lease) stays green unchanged.
  • plugin/pi/test/native-tool-contract.test.mjs: new test β€” session_shutdown({reason:"reload"}) sends no /end, session_start + mem_save still succeed; session_shutdown({reason:"quit"}) still sends exactly one /end. Existing shutdown tests call the handler with ({}, ctx) (no reason) so they keep their current end-behavior.
  • Gates: gofmt -l internal/store && go build ./...; go test ./internal/store -run 'TestRuntimeSession|TestStartSession|TestCreateSession'; cd plugin/pi && npm test.

Note on already-severed sessions

Rows whose lease has already lapsed (e.g. the two victims above) stay terminal by design; the remedy for those remains "start a new session id". The fix prevents new occurrences and heals ends that happen while the lease is still live (the common case, since renewals trail activity by ≀30 min).

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions