feat(session-cursor): session-namespaced cursor store for /activity adapters (v1.4.0) #30

Merged
hongming merged 1 commits from feat/session-cursor-module into main 2026-05-28 23:50:13 +00:00
Owner

Part 1 of 2 for the concurrent-session hardening (RFC: internal#726; resolves the secondary item of molecule-mcp-claude-channel#26). Adaptor PR follows once this is released as v1.4.0.

What

New subpath export @molecule-ai/mcp-server/session-cursor: a session-namespaced, durable since_id cursor store for /activity-polling adapters — the same role inbox-uploads/targets play as shared cross-adapter contract surfaces.

Surface:

  • CursorStoreload() (tolerant of missing/corrupt; optional onLoadError), get/has/set/delete/entries/size, save() (atomic temp+rename, mode 0600, PID-suffixed temp), unlink().
  • cursorFileName(sessionKey?)cursor.json (primary, no key) / cursor.<key>.json (secondary).
  • parseSessionKey(fileName) — inverse; null for primary/unrelated.
  • pruneOrphanCursors(stateDir, isAlive) — removes dead per-session files; never touches the primary or files whose liveness probe throws.

Why

A host can run >1 adapter session (two claude invocations both loading the plugin). The platform is fully concurrent — register/heartbeat are workspace-keyed last-writer-wins and GET /activity is read-only with a client-driven since_id (verified in molecule-core registry.go:393-407,569-595 / activity.go:302-619). The only thing that races between sessions is a shared cursor file. Keying the cursor file by session removes that race. Extracting it here (vs. inline in the channel) means hermes-ts / codex-ts inherit the fix instead of re-implementing it.

Tradeoff

The store is intentionally logging-agnostic: load() swallows corruption and save() throws, so each adapter keeps its own stderr/pino phrasing and decides whether a failed tick is fatal. (The channel will wrap both to preserve its existing messages.)

Test plan / verification

  • src/__tests__/session-cursor.test.ts: 16 tests — filename mapping + cursorFileNameparseSessionKey round-trip, tolerant load (missing/corrupt→onLoadError), non-string/empty value filtering, set/save/reload + delete round-trip, atomic-write (no .tmp. lingers), unlink idempotence, and pruneOrphanCursors (removes dead only; keeps primary + live + unrelated; never deletes on a throwing probe; tolerates missing dir).
  • npm run build (tsc) clean; npm test = 199 passed, 1 skipped, 0 failed (8 suites).

Rollback

git revert. Additive only — new subpath export; existing 1.3.x consumers unaffected.

Release

After merge: tag v1.4.0publish.yml publishes to the Gitea npm registry, then the adaptor PR bumps its dep.


🤖 Generated with Claude Code

Part 1 of 2 for the concurrent-session hardening (RFC: internal#726; resolves the secondary item of molecule-mcp-claude-channel#26). Adaptor PR follows once this is released as v1.4.0. ## What New subpath export `@molecule-ai/mcp-server/session-cursor`: a session-namespaced, durable `since_id` cursor store for `/activity`-polling adapters — the same role `inbox-uploads`/`targets` play as shared cross-adapter contract surfaces. Surface: - `CursorStore` — `load()` (tolerant of missing/corrupt; optional `onLoadError`), `get/has/set/delete/entries/size`, `save()` (atomic temp+rename, mode 0600, PID-suffixed temp), `unlink()`. - `cursorFileName(sessionKey?)` — `cursor.json` (primary, no key) / `cursor.<key>.json` (secondary). - `parseSessionKey(fileName)` — inverse; null for primary/unrelated. - `pruneOrphanCursors(stateDir, isAlive)` — removes dead per-session files; never touches the primary or files whose liveness probe throws. ## Why A host can run >1 adapter session (two `claude` invocations both loading the plugin). The platform is fully concurrent — `register`/`heartbeat` are workspace-keyed last-writer-wins and `GET /activity` is read-only with a client-driven `since_id` (verified in `molecule-core` `registry.go:393-407,569-595` / `activity.go:302-619`). The only thing that races between sessions is a **shared** cursor file. Keying the cursor file by session removes that race. Extracting it here (vs. inline in the channel) means hermes-ts / codex-ts inherit the fix instead of re-implementing it. ## Tradeoff The store is intentionally logging-agnostic: `load()` swallows corruption and `save()` throws, so each adapter keeps its own stderr/pino phrasing and decides whether a failed tick is fatal. (The channel will wrap both to preserve its existing messages.) ## Test plan / verification - `src/__tests__/session-cursor.test.ts`: 16 tests — filename mapping + `cursorFileName`↔`parseSessionKey` round-trip, tolerant load (missing/corrupt→onLoadError), non-string/empty value filtering, set/save/reload + delete round-trip, atomic-write (no `.tmp.` lingers), `unlink` idempotence, and `pruneOrphanCursors` (removes dead only; keeps primary + live + unrelated; never deletes on a throwing probe; tolerates missing dir). - `npm run build` (tsc) clean; `npm test` = **199 passed, 1 skipped, 0 failed** (8 suites). ## Rollback `git revert`. Additive only — new subpath export; existing 1.3.x consumers unaffected. ## Release After merge: tag `v1.4.0` → `publish.yml` publishes to the Gitea npm registry, then the adaptor PR bumps its dep. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code)
hongming added 1 commit 2026-05-28 22:48:07 +00:00
Adds `./session-cursor` — a shared, session-keyed durable since_id cursor
store, beside `inbox-uploads`/`targets`, so every /activity-polling TS
adapter (channel today; hermes-ts / codex-ts next) uses one implementation
of the polling-cursor contract instead of re-implementing (and re-bugging)
it inline.

Why session-namespaced: a host can run more than one adapter session
(two `claude` invocations both loading the plugin). The platform is fully
concurrent — register/heartbeat are workspace-keyed last-writer-wins and
/activity is read-only with a client-driven since_id (molecule-core
registry.go / activity.go) — so the ONLY thing that races is a *shared*
cursor file. Keying the file by session removes that race:
  - primary (no key)  -> cursor.json        (survives restarts; resumes)
  - secondary (key)   -> cursor.<key>.json  (independent; pruned when gone)

Surface: `CursorStore` (load/get/has/set/delete/entries/save/unlink, atomic
temp+rename, 0600), `cursorFileName(sessionKey?)`, `parseSessionKey`,
`pruneOrphanCursors(stateDir, isAlive)`. Logging-agnostic: load() swallows
corruption (optional onLoadError hook) and save() throws — the adapter owns
its phrasing and fatal-vs-recoverable policy.

Additive: new subpath export only; existing 1.3.x consumers unaffected.
Context: molecule-mcp-claude-channel#26 (secondary) / internal#726.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
hongming added the tier:medium label 2026-05-28 22:48:17 +00:00
Author
Owner

Adversarial review (2 dispatched reviewers — correctness/edge-cases + API/SSOT/security)

No blocking findings; both recommend merge. Verified to hold under active probing: filename round-trip (parseSessionKey(cursorFileName(k))===k), primary never misclassified, .tmp.<pid> files never matched by prune, same-process primary+secondary temp paths can't collide, load() corruption-tolerance + non-string filtering, isAlive-throws-fails-safe, path-traversal blocked by the [A-Za-z0-9_-]+ whitelist, and dist/ ships in npm pack (publish builds via tsc).

Optional/Nit (non-blocking, not addressed here — tracked for follow-up):

  • set("") is accepted in-memory but dropped by load()'s length>0 filter (latent; activity ids are never empty).
  • empty-store save() overwrites a good file with {} (correct-by-design for "corrupt→re-seed"; only matters if a caller saves before load — none do).
  • stale-temp file-mode leak on PID reuse (cursors aren't secret → negligible).
  • a trySave(onError?) convenience would save each future adapter re-implementing the channel's try/catch wrapper — nice SSOT win, deferred.
### Adversarial review (2 dispatched reviewers — correctness/edge-cases + API/SSOT/security) No blocking findings; both recommend merge. Verified to hold under active probing: filename round-trip (`parseSessionKey(cursorFileName(k))===k`), primary never misclassified, `.tmp.<pid>` files never matched by prune, same-process primary+secondary temp paths can't collide, `load()` corruption-tolerance + non-string filtering, `isAlive`-throws-fails-safe, path-traversal blocked by the `[A-Za-z0-9_-]+` whitelist, and `dist/` ships in `npm pack` (publish builds via tsc). Optional/Nit (non-blocking, not addressed here — tracked for follow-up): - `set("")` is accepted in-memory but dropped by `load()`'s `length>0` filter (latent; activity ids are never empty). - empty-store `save()` overwrites a good file with `{}` (correct-by-design for "corrupt→re-seed"; only matters if a caller saves before load — none do). - stale-temp file-mode leak on PID reuse (cursors aren't secret → negligible). - a `trySave(onError?)` convenience would save each future adapter re-implementing the channel's try/catch wrapper — nice SSOT win, deferred.
core-lead approved these changes 2026-05-28 23:29:32 +00:00
core-lead left a comment
Member

Reviewed as core owner of the base MCP. session-cursor matches the inbox-uploads/targets cross-adapter pattern; filename round-trip, prune fail-safe (never deletes primary/live/.tmp), atomic temp+rename, and path-traversal whitelist all verified. Additive v1.4.0, publish builds dist via tsc. Optionals (empty-string set asymmetry, trySave convenience) are non-blocking follow-ups. Approve.

Reviewed as core owner of the base MCP. session-cursor matches the inbox-uploads/targets cross-adapter pattern; filename round-trip, prune fail-safe (never deletes primary/live/.tmp), atomic temp+rename, and path-traversal whitelist all verified. Additive v1.4.0, publish builds dist via tsc. Optionals (empty-string set asymmetry, trySave convenience) are non-blocking follow-ups. Approve.
dev-lead approved these changes 2026-05-28 23:29:32 +00:00
dev-lead left a comment
Member

Reviewed as owner of the TS adapters that will consume this. API is sufficient for the channel and future hermes-ts/codex-ts; the since_secs cold-start boundary correctly stays in the consumer. SSOT placement is right. Approve.

Reviewed as owner of the TS adapters that will consume this. API is sufficient for the channel and future hermes-ts/codex-ts; the since_secs cold-start boundary correctly stays in the consumer. SSOT placement is right. Approve.
hongming merged commit 1f369086bd into main 2026-05-28 23:50:13 +00:00
Sign in to join this conversation.
No Reviewers
3 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: molecule-ai/molecule-mcp-server#30