Skip to content

fix: resolve session identity from disk, not the registry (GIT-89) - #25

Merged
nTEG-dev merged 6 commits into
mainfrom
bugfix/GIT-89-session-identity
Aug 9, 2026
Merged

fix: resolve session identity from disk, not the registry (GIT-89)#25
nTEG-dev merged 6 commits into
mainfrom
bugfix/GIT-89-session-identity

Conversation

@nTEG-dev

@nTEG-dev nTEG-dev commented Aug 9, 2026

Copy link
Copy Markdown
Member

⛔ DO NOT MERGE ON GREEN CI. Merge requires Chris's explicit approval. Ruling of 2026-08-08.

Gate 1 (AC-2 consumer check) — ⬜ OPEN, blocking.
Gate 2 (E2E attribution) — ✅ CLEARED, pre-existing on main, does not block.

What

Fixes GIT-89 — Option A: the session id is the identity. PID is no longer an identity key, only a disambiguator, and the active-sessions.json registry is no longer consulted to answer "is there a session".

findResumableSessionOnDisk() replaces GIT-51's adoptSessionForCurrentProcess(). Identity resolves by scanning .gitmem/sessions/*/session.json; the registry is repaired from that scan rather than gating it. session_close deletes the session directory, so a directory that still exists is a session that was never closed — which is what makes the scan safe without a separate "closed" marker.

PID survives only to separate candidates:

PID state Behaviour
own pid fast path, no adoption
dead pid orphaned by an MCP restart — adopt newest, rebind
live foreign pid another server owns it — never touched

That last row is what preserves GIT-19..23 multi-session resolution now that PID is not identity. Brain's review is on record that this disambiguation rule satisfies the multi-session precondition.

Supporting changes: the resolved PID is written back to session.json via read-modify-write of the pid field only, so accumulated surfaced_scars / threads are not clobbered; getRegistryFingerprint() now covers the sessions directory as well as the registry, or the "already failed recovery" latch could stay closed over a session that had since appeared on disk.

Why

After any MCP server restart mid-session — app update, context compaction, npm run build, CLI relaunch — every session-required tool reported "No active session" for the rest of the session, while the session was demonstrably live and writes were landing.

GIT-51 already fixed more than the ticket assumed (recovery runs on any tool call, adoption exists, ADOPT_THRESHOLD_MS is gone, pruneStale no longer prunes on a dead PID). The defect that remained: both recovery paths iterated registry.sessions, and the registry is precisely the store that gets lost. Observed during triage in this repo — active-sessions.json holding {"sessions": []} with three intact sessions/<id>/session.json files sitting beside it. The durable evidence was present and unreachable.

The bug reproduced live in the session opened to fix it: recall returned 6 scars while printing "No active session", confirm_scars hard-rejected all 6, and every one was silently dropped from tracking.

Beyond the ticket — two discoveries

1. recallCalled was in-memory only. Even with identity fixed, every create_learning / create_decision / session_close after a restart hit enforcement Check 3 and warned "No recall() was run this session" — in sessions where it plainly had. Same class of untrue warning as the "No active session" banner, and directly against the acceptance criterion that enforcement warnings, when they do fire, are true. Found by the verification script, not by the ticket. Now persisted to session.json, restored on recovery, and preserved across session_start refresh (which rewrites the file wholesale).

2. Free-tier surfacing was never persisted at all. The inline write-through lived in recall's pro-tier branch only. This is pre-existing structural loss, not restart-dependent — free-tier surfacing has never survived a process boundary. Centralising the write in addSurfacedScars fixes it as a side effect.

Also in this change: addSurfacedScars resolves identity instead of reading in-memory state and returns whether tracking happened, so recall can emit a SURFACING NOT TRACKED block at the same volume as confirm_scars' rejection — closing the asymmetry. The notice returns "" on the healthy path, so a tracked recall costs no extra tokens (scar 55dd6d73). isRecallCalled() and getProject() now resolve rather than reading in-memory state; the latter was silently falling back to project "default" after a restart.

How to Test

Against the built dist/ in a fresh process, seeded with the exact production shape (active-sessions.json = {"sessions": []}, intact session.json written by a dead PID) — 9/9:

PASS  resolves session from disk when the registry is empty
PASS  zero 'No active session' warnings across session-required tools
      — recall, create_learning, create_thread, create_decision, session_close
PASS  registry repaired from disk
PASS  surfacing is tracked after restart (recall/confirm symmetry)
PASS  surfacing written through to session.json
PASS  session.json pid rebound to this process
PASS  surfaced scars survive a second restart
PASS  never claims another live process's session (multi-session unregressed)
PASS  a genuinely absent session still warns

Unit suite: 1172 passing, 69 files, zero failures (was 1154 — +18 tests: 13 for the disk resolver and surfacing/recall symmetry, 5 for the untracked-surfacing notice).

Note: verifying any fix here needs a fresh MCP server process — there is no hot-reload after npm run build (scar cf6736fb).


🚦 Blocking gates

Gate 1 — AC-2 consumer check ⬜ NOT STARTED

One real CLI session spanning an actual MCP server relaunch on this build:

  1. session_start on the new build
  2. work the session, run recall
  3. relaunch the CLI mid-session
  4. session_close

Passes when: zero false "No active session" warnings, and closing surfaced_scars count matches what was actually recalled (no silent discard). The next natural CLI restart is the test.

Gate 2 — E2E attribution ✅ CLEARED — pre-existing, does not block

main @ c091a75 stalls identically. Same ~15-minute cap, isolated worktree with its own build: 2 of 7 files completed (31 tests, both passing), then nothing for the remaining ~14 minutes. Killed at 900s, exit 143.

✓ tests/e2e/free-tier.test.ts        (19 tests) 602ms
✓ tests/e2e/git-67-fail-open.test.ts (12 tests) 821ms
KILLED AFTER 900s

Per the decision rule: hangs on main too → pre-existing, does not block merge.

Correction to my first report. I claimed the branch run "produced no output for ~15 minutes." The cause was my own command — npx vitest ... | grep | tail -15, and tail holds all input until upstream exits, so an empty file meant "not finished," not "hung." The unbuffered main run shows the suite running normally. The observation was an artifact; the conclusion below rests on the re-run, not on it.

The stall is environmental. The 5 non-completing files need Docker testcontainers and/or a reachable Supabase; the log is full of fetch failed. pro-fresh.test.ts gates on DOCKER_AVAILABLE, which is true here, so it attempts container startup rather than skipping.

Branch checked on what is actually runnable. The 2 network-free files on bc69b95: 31/31 passing, exit 0 — identical to main. These include git-67-fail-open.test.ts, which exercises the sessionless-write guard and is squarely in this change's blast radius.

Full E2E remains unverified end-to-end on both main and this branch, for the same environmental reason. That is a pre-existing CI/environment gap, not a property of this PR.


Checklist

  • npm run test:unit passes — 1172/1172
  • npm run test:e2e passes — cannot complete in this environment, on main or here (Gate 2). The 2 network-free files pass 31/31 on both.
  • New tests added for new functionality — +18
  • No breaking changes
  • Docs updated — n/a, no user-facing surface change beyond the new warning text

Breaking Changes

None. adoptSessionForCurrentProcess() is removed, but it was internal and had exactly one caller; findSessionByHostPid() is retained as a registry query. Existing session.json files without recall_called read as false, which is the prior behaviour.

Follow-up candidates — explicitly NOT in scope

Both are recorded on GIT-89 as deliberate exclusions, not oversights:

  1. Supabase last-resort fallback (step 3 of the recorded resolution order). resolveCurrentSession() is synchronous and called from the sync enforcement path; adding a network call means making it async and threading that through every caller. The local scan covers every failure mode reproduced so far.
  2. Deterministic .gitmem root. Resolution is still order-dependent (env → cache → cwd walk-up → ~/.gitmem). Pinning it to the git root would change where .gitmem lives for existing installs — a migration, not a bugfix.

Separately noticed and already spun off: src/tools/recall.test.ts (11 tests) is never executed — no vitest config includes src/**/*.test.ts.

🤖 Generated with Claude Code

Option A: the session id is the identity. PID is no longer an identity key,
only a disambiguator, and the active-sessions registry is no longer consulted
to answer "is there a session".

findResumableSessionOnDisk() replaces GIT-51's adoptSessionForCurrentProcess().
The old path iterated registry.sessions, so an empty or diverged registry meant
"no session" regardless of what was on disk — and the registry is precisely the
store that gets lost. Observed in the wild: active-sessions.json holding
{"sessions": []} with intact sessions/<id>/session.json files beside it. The
session directories are the durable evidence (session_close deletes them), so
the scan reads those and repairs the registry from what it finds.

PID still separates candidates: own pid takes a fast path, a dead pid is an
orphan left by a restart, and a live foreign pid is another server's session
that is never touched — which is what keeps GIT-19..23 multi-session
resolution intact.

Also closes two false-alarm paths that survive the identity fix:

- addSurfacedScars returned void and no-op'd without a session, so recall
  printed scars, nothing recorded them, and confirm_scars later rejected with
  nothing to confirm. It now resolves identity, writes through to session.json,
  and returns whether tracking happened; recall says so loudly when it did not.
- recall_called lived only in memory, so after a restart every create_learning
  / create_decision / session_close warned that recall had never run in
  sessions where it had. Persisted to session.json and restored on recovery.

+18 tests (1154 -> 1172): 13 unit for the disk resolver and surfacing/recall
symmetry, 5 for recall's untracked-surfacing notice. Verified against the built
dist in a fresh process reproducing the production shape (empty registry +
session on disk): zero warnings across recall, create_learning, create_thread,
create_decision and session_close over two simulated restarts, registry
repaired, surfacing persisted, live foreign session never claimed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nTEG-dev

nTEG-dev commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

Gate 2 — E2E attribution: pre-existing on main. Does not block merge.

Result

main @ c091a75, same ~15-minute cap, isolated worktree with its own build:

✓ tests/e2e/free-tier.test.ts        (19 tests) 602ms
✓ tests/e2e/git-67-fail-open.test.ts (12 tests) 821ms
KILLED AFTER 900s                    (exit 143)

2 of 7 files completed, then nothing for the remaining ~14 minutes. main stalls the same way the branch did, so per the decision rule this is pre-existing and does not block.

Correction to my first report

I said the branch run "produced no output for ~15 minutes." That was true of the file, but the cause was my own command:

npx vitest run --config vitest.e2e.config.ts 2>&1 | grep -E "..." | tail -15

tail buffers all input until upstream exits, so an empty output file meant "hasn't finished," not "hung." The unbuffered main run shows the suite running normally and emitting per-file results. The original observation was an artifact of my instrumentation; the attribution above rests on the re-run, not on it.

Why it stalls

Environmental, not code. The 5 non-completing files need Docker testcontainers and/or a reachable Supabase — the log is full of fetch failed. pro-fresh.test.ts gates on DOCKER_AVAILABLE, which is true on this machine, so it attempts container startup rather than skipping. No .env is present, so there are no Supabase credentials for the tests that expect a live endpoint.

What was actually verifiable on the branch

The 2 network-free files on bc69b95:

✓ tests/e2e/free-tier.test.ts        (19 tests) 746ms
✓ tests/e2e/git-67-fail-open.test.ts (12 tests) 1144ms
  Test Files  2 passed (2)
       Tests  31 passed (31)          exit 0

Identical to main. Worth noting that git-67-fail-open.test.ts exercises the sessionless-write guard — squarely inside this change's blast radius — and is unaffected.

Honest limit

Full E2E is unverified end-to-end on both main and this branch, for the same environmental reason. I am not claiming the suite passes; I am claiming this PR does not change its behaviour. Closing that gap means giving the E2E suite working Supabase credentials or making the container-dependent files skip cleanly — a CI/environment issue, not this PR's.

Gate 1 (AC-2 consumer check) remains open and blocking.

Claude and others added 5 commits August 8, 2026 22:16
checkEnforcement() runs on every session-required tool call and its header
still advertised "pure in-memory checks, no I/O". GIT-89 routed
getCurrentSession() through resolveCurrentSession(), which scans the
per-session directories to rebind identity after an MCP restart — that is
the fix working as designed, but it makes the stated invariant false for
anyone reasoning about enforcement's cost.

Documents what the cost actually is: bounded, not per-call. The scan runs
until identity binds, and a failed scan is memoised against the registry
fingerprint so a session-less process does not rescan on every tool call.

No behaviour change. 1172 tests, typecheck clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…GIT-89 AC#4)

AC#4 asks that session_close resolve the correct session after an MCP restart
without the agent passing session_id. It could not, and the reason was not in
the resolution logic — that was already correct and already reachable-looking.

sessionClose() was written for an absent id throughout: it guards
`params.session_id &&` before validating the format (session-close.ts:834) and
recovers identity from resolveCurrentSession() when the field is empty (:857).
But SessionCloseParamsSchema declared session_id required, so validateToolArgs
rejected the call with "session_id: Required" before sessionClose() ran. The
recovery branch was dead code in the only scenario it existed for.

This is the failure GIT-89 describes landing at its worst point: a restart, or a
context compaction, is exactly what takes the id away from the agent, and close
is where the reflection has already been written and has nowhere else to go.

Found by driving the built server over the MCP stdio protocol across a real
SIGKILL and respawn — a consumer-level check rather than an in-process one.
Calling session_close with only close_type returned
{"error":"Invalid parameters for session_close: session_id: Required"}; the same
call with the id succeeded, isolating the fault to the resolution path rather
than to close itself. After this change the same harness closes the recovered
session across the restart with no id passed.

Also fails loudly when resolution genuinely comes up empty, instead of letting
an undefined id reach Supabase and surface as a "session not found" that reads
like data loss.

Two existing tests asserted the old contract (registry.test.ts,
session-close.test.ts) — they encoded the requirement that made the branch
unreachable, so they are updated to the intended contract rather than worked
around. +8 tests (1172 -> 1180), including schema-contract and disk-resolution
coverage in tests/unit/tools/session-close-identity.test.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…T-89)

The verification for this fix lived in a scratchpad script, which meant the
acceptance criteria were checked once by hand and could regress silently. This
lands it as a suite that runs in CI, using the existing e2e MCP client and its
restartServer() helper rather than a bespoke harness.

The restart is real: the server process is killed and a new one takes over the
same GITMEM_DIR, driven over MCP stdio. An in-process test of this bug proves
nothing, because the thing that fails is the process boundary.

Covers AC#2 (no false "No active session" from recall / create_learning /
create_thread / create_decision after a restart; registry reclaimed by a live
PID; recall_called restored), AC#4 (close resolves identity with no session_id
passed), and AC#5 (a genuinely absent session still warns — the fix must not
invent sessions).

Confirmed to actually catch the defect: with session_id restored to required in
SessionCloseParamsSchema, the AC#4 case fails; with the fix in place it passes.
A regression test that passes against the broken build would be worse than none.

AC#3 (surfacing continuity) is deliberately not asserted here and is documented
as such in the file header. recall() surfaces nothing against an empty free-tier
store, so the assertion would read before=0/after=0 and pass while testing
nothing. It stays covered at unit level where surfacing can be seeded.

Two notes for reviewers:

- Assertions read the PID from the registry rather than McpTestClient.process.
  That field is typed ChildProcess but createMcpClient never assigns it
  (`process: serverProcess!` with serverProcess left null), so any test touching
  it throws at runtime. The registry PID is the better witness regardless: it is
  what the product wrote, not what the harness happens to know.
- The suite pins GITMEM_DIR and HOME to a temp dir, so it cannot read or write
  the developer's real .gitmem (GIT-92).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI ran unit + smoke only, so the restart behaviour this branch fixes had no
automated gate — and the publish job needs: build, which meant a regression
could reach npm with a green pipeline.

Runs the single e2e file rather than `npm run test:e2e`: the Pro suites in that
config gate on Docker and Supabase and hang instead of skipping when neither is
present (GIT-90), which would make CI unreliable. This file spawns the built
server directly on the free tier with GITMEM_DIR pinned to a temp dir — no
Docker, no credentials, no developer state touched — so it is safe to run
everywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every clean-room image had been failing to build. `RUN npm install -g npm@latest`
on a node:20-slim base stopped working once npm@latest began requiring
node ^22.22.2 || ^24.15.0 || >=26:

  npm error notsup Required: {"node":"^22.22.2 || ^24.15.0 || >=26.0.0"}
  npm error notsup Actual:   {"npm":"10.8.2","node":"v20.20.2"}

The build died at that layer, so `build-and-run.sh <client> local` — the step
that tests the packaged tarball the way a user installs it — could not run at
all. The pre-publish gate was gone, and silently: nobody hits it until they try
to use it, which is exactly when it is needed.

The line was cosmetic (its comment said "suppresses upgrade nag"). Removed
rather than pinned: the npm bundled with the base image is what a real user on
node 20 actually has, which is what a clean room is supposed to reproduce.
node 20 stays a supported engine (package.json engines >=18, CI matrix 18/20/22).

All four images were affected; all four fixed.

Verified: gitmem-claude-local now builds, and the packaged tarball installed
globally inside it survives a real MCP restart — recall, create_learning,
create_thread, create_decision all clean, registry reclaimed, and session_close
resolving with no session_id passed (GIT-89 AC#4) on node 20.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nTEG-dev
nTEG-dev merged commit 4bdec50 into main Aug 9, 2026
4 checks passed
@nTEG-dev
nTEG-dev deleted the bugfix/GIT-89-session-identity branch August 9, 2026 02:57
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.

1 participant