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)
- Start a Pi session in any project with the gentle-engram plugin; Engram registers the runtime session (row
started_at = session start).
- 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).
- 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).
- Observe the store:
UPDATE sessions SET ended_at = datetime('now') has run for the still-live runtime session.
- 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).
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_summaryfails with the raw sentinelsession has already ended, even though the Pi session is still running.Root cause is a lifecycle-policy collision between Pi and Engram:
session_shutdownextension 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.plugin/pi/index.ts,pi.on("session_shutdown")) unconditionally POSTs/sessions/{id}/endfor the runtime session id β including on reload.startSessionTx(internal/store/store.go) only matches rowsWHERE sessions.ended_at IS NULL, so re-registration returnsErrSessionAlreadyEnded("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_atwhile 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)
started_at= session start).runtime_lease_expires_atrenewal happens shortly before the trigger (renewals happen on agent turns / tool activity).session_shutdownwithreason: "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).UPDATE sessions SET ended_at = datetime('now')has run for the still-live runtime session.mem_save(or wait for the delivery-guarantee summary at session close): it fails withsession has already endedfor the rest of the conversation.β Expected Behavior (field: expected)
A
session_shutdownthat does not retire the runtime session id (Pireason: "reload") must not end the Engram session; and if an ended runtime session is re-registered while itsruntime_lease_expires_atis 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 (manualCreateSessionrows have no lease and stay strictly terminal).β Actual Behavior (field: actual)
π₯οΈ Environment (fields: os / engram-version / agent)
engram serve); gentle-engram plugin 0.1.14 (plugin/pi)@earendil-works/pi-coding-agent0.86.1) with the gentle-engram Pi packageπ Relevant Logs (field: logs)
π‘ Additional Context (field: context)
Suggested fix (draft, three parts)
A. Server (
internal/store/store.go,startSessionTx+ callerStartSessionWithOwnershipMode): extend the upsert'sWHEREto also match ended rows whoseruntime_lease_expires_atis still in the future, and clearended_atin that case; reportreopenedto the caller so the sync upsert mutation is enqueued. Manual rows (no lease) and expired/malformed leases keep the current terminal refusal.startSessionTxreturns(reopened bool, err error);StartSessionWithOwnershipModefoldsreopenedinto itsidentityRepairedsync-enqueue gate. Update the doc comment at theStartSessionWithOwnershipMode("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/endPOST whenevent.reason === "reload"(Pi rebinds extensions but keeps the runtime session id;quit/new/resume/forkkeep today's behavior).C. Plugin UX (optional): map the
session_already_endederror 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: splitTestRuntimeSessionRegistrationRejectsEndedSessionsinto (1) reopen succeeds while lease is live (ended_atcleared, 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(manualCreateSession, 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_savestill 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.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).