Skip to content

fix(core): recover a conversation whose engine session was pruned upstream - #114

Merged
jarethmt merged 2 commits into
jarethmt:mainfrom
scoutg001:fix/resume-vanished-session
Aug 18, 2026
Merged

fix(core): recover a conversation whose engine session was pruned upstream#114
jarethmt merged 2 commits into
jarethmt:mainfrom
scoutg001:fix/resume-vanished-session

Conversation

@scoutg001

Copy link
Copy Markdown
Contributor

The bug

A conversation whose engine session gets pruned upstream is wedged permanently. Every message to it fails, and nothing self-heals.

Claude Code deletes its transcript files under ~/.claude/projects/<slug>/<uuid>.jsonl after a retention window. The core stores engine_session_id forever under the default idle_policy = 'infinite', so a conversation idle past that window passes a uuid the engine no longer holds to options.resume. The SDK fails the turn:

[core] /v2/handle error: Claude Code returned an error result: No conversation found with session ID: e981c9b8-53ab-4c7d-9f73-a63b8ba4535e

Because sessions.recordEngineId() only runs on success, the dead id stays in the row. The next message resumes the same dead id and fails the same way. /v2/handle returns 500, /v2/stream emits an error frame, and the user sees their connector's generic error text after a clean identity allow.

On the install where this was found, 3 of 9 sessions were in that state, including the owner's Discord DM. The oldest surviving transcript was dated 2026-07-20; the three dead sessions were last active 2026-07-02, 07-14, and 07-15. One idle month is enough to brick a channel, and the only way out was editing the sessions table by hand.

The fix

engines.isMissingSessionError(err) recognizes that one failure. claude words it "No conversation found with session ID: ", codex words it "thread not found". gemini mints its own resume id, so it has no dead-resume failure mode.

sessions.clearEngineId(key) nulls engine_session_id, last_stable_hash, and last_stable_engine, keeping working_dir, idle policy, outbound route, and turn count. It isn't remove(): those columns describe the conversation, not the engine session. The stable marker has to go too, or inject-once would skip re-sending a stable block that the fresh session never received.

Both runTurn call sites (the inbound pipeline in dispatch, and the operator inject/steer) catch that error, clear the dead id, record a session-expired control event with the dead uuid and the engine's reason, then rerun the turn once with resume: null. The inbound pipeline reruns with the full system prompt, not the volatile tail. Recovery is one attempt, not a loop.

The engine-side history is already gone when this fires, so restarting the session loses nothing that failing would have preserved.

What is deliberately not retried

Retrying an unrelated failure as a fresh session would discard a live conversation's history for a fault that has nothing to do with resume. The matcher rejects every one of these, all taken from the install's own error log:

  • You've hit your monthly spend limit
  • You've hit your session limit · resets 5:50pm
  • API Error: 529 Overloaded
  • Failed to spawn Claude Code process: spawn node EACCES
  • request entity too large

An aborted turn (Stop button, or a steer with interrupt:true) is excluded as well, as is any turn that carried no resume id.

Verification

8 new tests in test/session-resume-recovery.test.js cover the matcher and the store change, against an ASMLTR_CORE_DB temp file so they never touch a live sessions table. Full suite: 85 of 85 pass.

End to end on the running install, with a session row rewritten to a bogus uuid:

before after
first request HTTP 500, No conversation found with session ID HTTP 200, reply delivered
repeat request HTTP 500, identical error HTTP 200
stored id dead uuid, unchanged new uuid persisted
follow-up turn n/a resumed the new id and recalled the prior turn's answer

The follow-up turn is the part that matters: the recovered session is a real continuing session, not a respawn on every message. The session-expired event lands in the collector, so a silent restart shows up in the dashboard rather than passing unnoticed.

Claude Code prunes its transcript files under ~/.claude/projects/<slug>/ after a
retention window. The core stores engine_session_id forever under the default
idle_policy 'infinite', so a conversation left idle past that window resumes a
uuid the engine no longer holds. On this install 3 of 9 sessions carried a pruned
id; the oldest surviving transcript is dated 2026-07-20 & the three dead sessions
were last active 2026-07-02, 07-14, & 07-15.

isMissingSessionError() matches the phrasing each engine uses for that failure.
claude returns "No conversation found with session ID: <uuid>" & codex returns
"thread not found"; gemini mints its own resume id, so it has no dead-resume
failure mode. The regex deliberately doesn't match a spend-limit result, a 529,
a "spawn node EACCES", or a 413, all of which this box has logged. Retrying those
as a fresh session would throw away a live conversation's history for a fault
that has nothing to do with resume.

sessions.clearEngineId() nulls engine_session_id plus the last_stable_hash &
last_stable_engine markers, keeping working_dir, idle policy, outbound route, &
turn count. It isn't remove(): those four columns describe the conversation, not
the engine session, & the conversation is still alive. Dropping the stable marker
matters on codex, where inject-once would otherwise skip re-sending a stable
block the fresh session never received.

8 tests cover the matcher & the store change, using an ASMLTR_CORE_DB temp file
so they never touch the live sessions table.
Engine session ids are only persisted on success, so a pruned id was never
cleared. The SDK threw "No conversation found with session ID: <uuid>", the error
propagated to /v2/handle as a 500 & to /v2/stream as an error frame, & the row
kept the dead id. Every later message on that conversation failed identically.
One idle month bricked a channel for good, with no way back short of hand-editing
the sessions table. Three of this box's Discord conversations were sitting in
that state, including the owner's DM.

Both runTurn call sites (the inbound pipeline & the operator inject/steer) now
catch that one error, clear the dead id, record a `session-expired` control event
carrying the dead uuid & the engine's reason, & rerun the turn once with
resume:null. The inbound pipeline reruns with the FULL system prompt, because a
fresh session has never been sent the stable block regardless of what inject-once
decided for the resumed one. A turn whose AbortController fired (Stop button, or
a steer with interrupt) is excluded, as is any turn that carried no resume id.

Recovery is one attempt, not a loop: if the fresh session also fails, that error
propagates as before.

Verified against the running install. A session row rewritten to a bogus uuid
returned HTTP 500 twice in a row before the change. After it, the same request
returned HTTP 200 with the reply, persisted a new id, & the follow-up turn
resumed that id & recalled the prior turn's answer, so the recovered session is a
real continuing session rather than a respawn per turn. 85 of 85 tests pass.
@jarethmt
jarethmt merged commit d2b29d3 into jarethmt:main Aug 18, 2026
1 check passed
jarethmt pushed a commit that referenced this pull request Aug 24, 2026
session-resume-recovery.test.js opened the sessions DB on import and never closed
it. On Node 24 in CI, better-sqlite3's native Statement finalizers ran during the
environment teardown and tripped `Assertion failed: (env) != nullptr`, crashing the
test process on exit — every assertion passed, only the exit crashed, which is what
turned `test` red on main since PR #114. Closing the handle in an after() hook
finalizes the statements while the env is still alive.
jarethmt pushed a commit that referenced this pull request Aug 24, 2026
Every CI run since #114 merged has failed, always the same way:

  node::RemoveEnvironmentCleanupHook(v8::Isolate*, CleanupHook, void*) at ../src/api/hooks.cc:142
  Assertion failed: (env) != nullptr
  Statement::~Statement() [core/node_modules/better-sqlite3/build/Release/better_sqlite3.node]

Nothing asserts. Every test in the file passes and then the process aborts, which marks the file
failed and takes the run with it. #114 is not at fault beyond being the first test to load the
session store, and neither is the test: the abort is raised by the library's own destructor.

Node 24.19.0, released 2026-08-03, added cleanup hooks to node::ObjectWrap (nodejs/node#63642).
NAN-style native addons register through that path, so their destructors now call
RemoveEnvironmentCleanupHook after the environment is gone, and that is an assert rather than a
throw. better-sqlite3 11.10.0 is NAN-based. 13.0.0 is the first release built on N-API, which never
touches node::ObjectWrap, so the assertion cannot fire.

Measured, one variable at a time, each with a fresh npm ci:

  better-sqlite3 11.10.0 + node 24.19.0   abort, 5 runs out of 5
  better-sqlite3 11.10.0 + node 24.18.0   clean
  better-sqlite3 13.0.3  + node 24.19.0   88 pass, 0 fail
  better-sqlite3 13.0.3  + node 24.18.0   88 pass, 0 fail

Pinning the workflow to 24.18.x would also have turned CI green and was the wrong fix. The core
loads better-sqlite3 at import and runs under pm2 on the host, so the exposure is not limited to
CI: whenever the host moves to 24.19.0, the core starts aborting at teardown. A pin would have hidden
that behind a green badge.

Upgrade path checked rather than assumed. v12.0.0 dropped EOL node 18 and old Electron, and this
repo already requires node >=24. v13.0.0's breaking change is packaging only: prebuild-install is
gone and prebuilt binaries ship with the package. Neither changes the API, and the repo's whole
usage is prepare, exec, close, pragma and transaction.

Regenerating the lock hoists better-sqlite3 to one root entry where there were three per-workspace
entries. Verified that core, connectors and insights/collector all still resolve it, on both node
lines, from the committed package.json and lock via npm ci.

Worth knowing when reproducing: on node 24.18.0 there is nothing to see. A prebuilt binary compiled
against 24.18 headers also stays clean when run under 24.19.0, so a partial upgrade hides it too.
The abort needs a fresh build against 24.19.0 headers.
gtwy pushed a commit to gtwy/asmltr that referenced this pull request Sep 1, 2026
…ssion

fix(core): recover a conversation whose engine session was pruned upstream
gtwy pushed a commit to gtwy/asmltr that referenced this pull request Sep 1, 2026
session-resume-recovery.test.js opened the sessions DB on import and never closed
it. On Node 24 in CI, better-sqlite3's native Statement finalizers ran during the
environment teardown and tripped `Assertion failed: (env) != nullptr`, crashing the
test process on exit — every assertion passed, only the exit crashed, which is what
turned `test` red on main since PR jarethmt#114. Closing the handle in an after() hook
finalizes the statements while the env is still alive.
gtwy pushed a commit to gtwy/asmltr that referenced this pull request Sep 1, 2026
Every CI run since jarethmt#114 merged has failed, always the same way:

  node::RemoveEnvironmentCleanupHook(v8::Isolate*, CleanupHook, void*) at ../src/api/hooks.cc:142
  Assertion failed: (env) != nullptr
  Statement::~Statement() [core/node_modules/better-sqlite3/build/Release/better_sqlite3.node]

Nothing asserts. Every test in the file passes and then the process aborts, which marks the file
failed and takes the run with it. jarethmt#114 is not at fault beyond being the first test to load the
session store, and neither is the test: the abort is raised by the library's own destructor.

Node 24.19.0, released 2026-08-03, added cleanup hooks to node::ObjectWrap (nodejs/node#63642).
NAN-style native addons register through that path, so their destructors now call
RemoveEnvironmentCleanupHook after the environment is gone, and that is an assert rather than a
throw. better-sqlite3 11.10.0 is NAN-based. 13.0.0 is the first release built on N-API, which never
touches node::ObjectWrap, so the assertion cannot fire.

Measured, one variable at a time, each with a fresh npm ci:

  better-sqlite3 11.10.0 + node 24.19.0   abort, 5 runs out of 5
  better-sqlite3 11.10.0 + node 24.18.0   clean
  better-sqlite3 13.0.3  + node 24.19.0   88 pass, 0 fail
  better-sqlite3 13.0.3  + node 24.18.0   88 pass, 0 fail

Pinning the workflow to 24.18.x would also have turned CI green and was the wrong fix. The core
loads better-sqlite3 at import and runs under pm2 on the host, so the exposure is not limited to
CI: whenever the host moves to 24.19.0, the core starts aborting at teardown. A pin would have hidden
that behind a green badge.

Upgrade path checked rather than assumed. v12.0.0 dropped EOL node 18 and old Electron, and this
repo already requires node >=24. v13.0.0's breaking change is packaging only: prebuild-install is
gone and prebuilt binaries ship with the package. Neither changes the API, and the repo's whole
usage is prepare, exec, close, pragma and transaction.

Regenerating the lock hoists better-sqlite3 to one root entry where there were three per-workspace
entries. Verified that core, connectors and insights/collector all still resolve it, on both node
lines, from the committed package.json and lock via npm ci.

Worth knowing when reproducing: on node 24.18.0 there is nothing to see. A prebuilt binary compiled
against 24.18 headers also stays clean when run under 24.19.0, so a partial upgrade hides it too.
The abort needs a fresh build against 24.19.0 headers.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants