mixdog 0.9.87 → 0.9.89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/package.json +13 -7
  2. package/scripts/tool-failures.mjs +60 -24
  3. package/src/agents/heavy-worker/AGENT.md +3 -4
  4. package/src/agents/worker/AGENT.md +1 -2
  5. package/src/cli.mjs +8 -0
  6. package/src/defaults/cycle3-review-prompt.md +4 -4
  7. package/src/rules/agent/00-core.md +3 -0
  8. package/src/rules/agent/42-cycle3-agent.md +5 -6
  9. package/src/rules/lead/01-general.md +2 -0
  10. package/src/rules/shared/01-tool.md +29 -35
  11. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +3 -51
  12. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +59 -0
  13. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -2
  14. package/src/runtime/agent/orchestrator/agent-runtime/title-completion.mjs +67 -0
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +57 -4
  16. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +2 -3
  17. package/src/runtime/agent/orchestrator/config.mjs +29 -11
  18. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
  20. package/src/runtime/agent/orchestrator/mcp/client.mjs +0 -24
  21. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +3 -3
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +25 -17
  23. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +321 -22
  24. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -22
  25. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +28 -23
  26. package/src/runtime/agent/orchestrator/providers/gemini.mjs +10 -2
  27. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +0 -1
  28. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +0 -1
  29. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +2 -5
  30. package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +27 -0
  31. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +23 -0
  32. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +329 -0
  33. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +23 -1
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +107 -20
  35. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +4 -3
  36. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +124 -12
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +19 -2
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +110 -75
  41. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +96 -113
  42. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +160 -11
  43. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +18 -1
  44. package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +147 -117
  45. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +88 -0
  46. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +50 -8
  47. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -0
  48. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +47 -6
  50. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +485 -24
  51. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +100 -2
  52. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -0
  53. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +80 -14
  54. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +142 -2
  55. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +128 -32
  56. package/src/runtime/agent/orchestrator/session/manager.mjs +5 -1
  57. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +77 -4
  58. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +70 -39
  59. package/src/runtime/agent/orchestrator/session/store/fs-probe.mjs +91 -0
  60. package/src/runtime/agent/orchestrator/session/store/listing.mjs +141 -64
  61. package/src/runtime/agent/orchestrator/session/store/live-state.mjs +253 -0
  62. package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +211 -28
  63. package/src/runtime/agent/orchestrator/session/store/save-fault.mjs +313 -0
  64. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +622 -53
  65. package/src/runtime/agent/orchestrator/session/store/serialize.mjs +43 -10
  66. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +53 -8
  67. package/src/runtime/agent/orchestrator/session/store/summary-rebuild-worker.mjs +20 -0
  68. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +113 -9
  69. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +2 -0
  70. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +476 -52
  71. package/src/runtime/agent/orchestrator/session/store.mjs +721 -196
  72. package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -4
  73. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -0
  74. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +4 -6
  75. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  76. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +146 -90
  77. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +6 -0
  78. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +93 -17
  79. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -0
  80. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +7 -7
  81. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +6 -1
  82. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +17 -1
  83. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +85 -5
  84. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +52 -0
  85. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +54 -51
  86. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -15
  87. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +132 -8
  88. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +86 -3
  89. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +6 -1
  90. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +210 -38
  91. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +23 -1
  92. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +87 -19
  93. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  94. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -8
  95. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -2
  96. package/src/runtime/media/lanes.mjs +2 -2
  97. package/src/runtime/media/renditions.mjs +101 -17
  98. package/src/runtime/media/renditions.test.mjs +54 -0
  99. package/src/runtime/media/store.mjs +111 -43
  100. package/src/runtime/media/store.test.mjs +53 -11
  101. package/src/runtime/memory/index.mjs +26 -50
  102. package/src/runtime/memory/lib/core-memory-candidates.mjs +2 -7
  103. package/src/runtime/memory/lib/core-memory-store.mjs +2 -9
  104. package/src/runtime/memory/lib/cycle-scheduler.mjs +10 -0
  105. package/src/runtime/memory/lib/embedding-provider.mjs +3 -3
  106. package/src/runtime/memory/lib/embedding-worker.mjs +60 -15
  107. package/src/runtime/memory/lib/http-router.mjs +4 -0
  108. package/src/runtime/memory/lib/ko-morph.mjs +49 -6
  109. package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -18
  110. package/src/runtime/memory/lib/memory-cycle2.mjs +3 -3
  111. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -3
  112. package/src/runtime/memory/lib/memory-recall-store.mjs +24 -0
  113. package/src/runtime/memory/lib/query-handlers.mjs +26 -28
  114. package/src/runtime/memory/tool-defs.mjs +3 -3
  115. package/src/runtime/shared/atomic-file.mjs +5 -1
  116. package/src/runtime/shared/child-guardian.mjs +73 -2
  117. package/src/runtime/shared/provider-api-key.mjs +22 -5
  118. package/src/runtime/shared/tool-execution-contract.mjs +53 -0
  119. package/src/session-runtime/config-helpers.mjs +6 -5
  120. package/src/session-runtime/lifecycle-api.mjs +16 -4
  121. package/src/session-runtime/media-api.mjs +31 -16
  122. package/src/session-runtime/prewarm.mjs +2 -24
  123. package/src/session-runtime/runtime-core.mjs +33 -15
  124. package/src/session-runtime/runtime-tunables.mjs +0 -3
  125. package/src/session-runtime/session-lifecycle.mjs +0 -6
  126. package/src/session-runtime/session-title.mjs +228 -0
  127. package/src/session-runtime/session-turn-api.mjs +11 -7
  128. package/src/session-runtime/workflow-agents-api.mjs +16 -0
  129. package/src/session-runtime/workflow.mjs +7 -5
  130. package/src/standalone/agent-tool/job-views.mjs +90 -36
  131. package/src/standalone/agent-tool/spawn-flow.mjs +25 -216
  132. package/src/standalone/agent-tool/worker-index.mjs +8 -0
  133. package/src/standalone/agent-tool/worker-rows.mjs +2 -1
  134. package/src/standalone/agent-tool.mjs +17 -17
  135. package/src/standalone/agent-watchdog-registry.mjs +19 -2
  136. package/src/standalone/channel-daemon.mjs +8 -0
  137. package/src/standalone/explore-tool.mjs +1 -1
  138. package/src/standalone/memory-runtime-proxy.mjs +4 -7
  139. package/src/standalone/projects.mjs +4 -1
  140. package/src/standalone/usage-dashboard.mjs +25 -3
  141. package/src/tui/App.jsx +5 -3
  142. package/src/tui/app/resume-picker.mjs +7 -2
  143. package/src/tui/app/route-pickers.mjs +11 -117
  144. package/src/tui/app/slash-dispatch.mjs +13 -3
  145. package/src/tui/app/transcript-row-estimate.mjs +1 -1
  146. package/src/tui/app/transcript-window.mjs +17 -93
  147. package/src/tui/app/use-transcript-scroll.mjs +32 -2
  148. package/src/tui/app/use-transcript-window.mjs +64 -114
  149. package/src/tui/components/Markdown.jsx +19 -2
  150. package/src/tui/components/Message.jsx +8 -3
  151. package/src/tui/components/ToolExecution.jsx +7 -3
  152. package/src/tui/dist/index.mjs +243 -223
  153. package/src/tui/engine/live-share.mjs +15 -5
  154. package/src/tui/engine/render-timing.mjs +2 -2
  155. package/src/tui/engine/session-api-ext.mjs +74 -4
  156. package/src/tui/engine/session-flow.mjs +3 -1
  157. package/src/tui/engine.mjs +2 -2
  158. package/src/tui/hooks/useSharedTick.mjs +0 -2
  159. package/src/tui/index.jsx +5 -5
  160. package/src/ui/statusline-agents.mjs +44 -11
  161. package/src/vendor/statusline/bin/statusline-route.mjs +26 -3
  162. package/src/workflows/default/WORKFLOW.md +3 -3
  163. package/src/workflows/solo/WORKFLOW.md +5 -3
  164. package/src/runtime/media/index.mjs +0 -18
  165. package/src/runtime/memory/lib/embedding-warmup.mjs +0 -68
  166. package/src/standalone/agent-shard/shard-child.mjs +0 -300
  167. package/src/standalone/agent-shard/shard-pool.mjs +0 -443
@@ -12,7 +12,7 @@ import { getPluginData, loadConfig } from '../config.mjs';
12
12
  import { isAgentOwner } from '../agent-owner.mjs';
13
13
  import { renameWithRetrySync } from '../../../shared/atomic-file.mjs';
14
14
  import { sanitizeContentForStoredHistory } from '../providers/media-normalization.mjs';
15
- import { scanTopLevelLifecycle } from './lifecycle-scan.mjs';
15
+ import { readTopLevelLifecycleRecord, isLifecycleUnreadable } from './lifecycle-scan.mjs';
16
16
  import { rotateBoundedLog, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES } from '../../../../lib/mixdog-debug.cjs';
17
17
  import { resolveAgentTerminalReapMs } from '../../../../session-runtime/config-helpers.mjs';
18
18
  import { getStoreDir, sessionPath, publishHeartbeat, deleteHeartbeat, deleteSessionPresence } from './store/paths-heartbeat.mjs';
@@ -23,6 +23,10 @@ import {
23
23
  acquireWriteCommit as _acquireWriteCommit,
24
24
  releaseWriteCommit as _releaseWriteCommit,
25
25
  waitForWriteCommit as _waitForWriteCommit,
26
+ publishLandedWriteEpoch as _publishLandedWriteEpoch,
27
+ isStaleWriteEpoch as _isStaleWriteEpoch,
28
+ WRITE_COMMIT_TIMEOUT as _WRITE_COMMIT_TIMEOUT,
29
+ WRITE_COMMIT_STALE as _WRITE_COMMIT_STALE,
26
30
  } from './store/write-guards.mjs';
27
31
  import {
28
32
  SESSION_SUMMARY_INDEX_VERSION,
@@ -59,12 +63,50 @@ export {
59
63
  isProcessAlive,
60
64
  } from './store/paths-heartbeat.mjs';
61
65
  import { _readStoredSessionCached } from './store/load-cache.mjs';
62
- import { _sessionForDisk, _renameWithRetrySync, _ensureLifecycleFields, _storedSessionFromFile } from './store/serialize.mjs';
66
+ import { probePath, PROBE_PRESENT, PROBE_ABSENT } from './store/fs-probe.mjs';
67
+ // Every canonical commit now goes through _commitSessionWrite (fault-aware
68
+ // rename + scratch ownership), so the raw rename helper is no longer imported.
69
+ import { _sessionForDisk, _ensureLifecycleFields, _storedSessionFromFile } from './store/serialize.mjs';
63
70
  import { _ensureSummaryCacheDataDir, _cachedSummaryRows, _setSummaryRowsCache, _cacheSessionSummary, _uncacheSessionSummary, _rollbackCachedSessionSummary, _queueSessionSummaryUpsert, _queueSessionSummaryRemoval, _queueSummaryIndexPrune, _scanStoredSessionSummaryRows, _summaryCacheVersions, _summaryCacheRemovals, _summaryRowsCache } from './store/summary-cache.mjs';
64
- import { _lastSaveError, _liveSessions, _droppedSaveIds, setLiveSession, _clearLiveSession, LIVE_MEDIA_RETENTION_MS, _messagesCarryLiveMedia, getSessionSaveError, clearSessionSaveError } from './store/live-state.mjs';
71
+ import { _lastSaveError, _liveSessions, _droppedSaveIds, setLiveSession, _clearLiveSession, LIVE_MEDIA_RETENTION_MS, _messagesCarryLiveMedia, getSessionSaveError, clearSessionSaveError, _recordSaveFailure, _recordSaveDrop, _clearSaveStateIfCurrent, _nextSaveEpoch, _acquireSessionIncarnation, _releaseSessionIncarnation, _isCurrentSessionIncarnation, _retireSessionIncarnation, _clearSessionSaveState, SAVE_OUTCOME_SAVED, SAVE_OUTCOME_DROPPED, SAVE_OUTCOME_STALE, hasSessionSaveFailure, getFailedSaveSnapshot, _recordLifecycleCommitFailure, clearSessionLifecycleCommitError } from './store/live-state.mjs';
65
72
  import { _saveWorkerPending, _saveAsyncQueued, _saveAsyncInflight, _deferredSessionSaves, saveSessionAsync, saveSessionAsyncDeferred, _resetSaveWorkerBookkeeping } from './store/save-worker.mjs';
73
+ import { purgeSessionSaveBookkeeping as _purgeSessionSaveBookkeeping } from './store/save-worker.mjs';
74
+ import { _setSessionWriteAuthorityCheck } from './store/save-worker.mjs';
75
+ import {
76
+ _commitSessionWrite,
77
+ _discardSaveTmp,
78
+ _trackSaveTmp,
79
+ _untrackSaveTmp,
80
+ sweepOrphanSessionTmpFiles,
81
+ } from './store/save-fault.mjs';
66
82
  export { setLiveSession, getSessionSaveError, clearSessionSaveError } from './store/live-state.mjs';
83
+ export { getSessionLifecycleCommitError, clearSessionLifecycleCommitError } from './store/live-state.mjs';
84
+ // Test-gated save-fault seam + scratch-file hygiene (see store/save-fault.mjs).
85
+ export { setSessionSaveFault, sweepOrphanSessionTmpFiles } from './store/save-fault.mjs';
67
86
  export { saveSessionAsync, saveSessionAsyncDeferred } from './store/save-worker.mjs';
87
+ // Deferred-snapshot registration for modules that throttle their own saves
88
+ // (usage-metrics): merges into the canonical drain instead of a second exit
89
+ // flush path.
90
+ export { parkSessionSnapshotForDrain, unparkSessionSnapshotForDrain } from './store/save-worker.mjs';
91
+
92
+ // ── Hard-delete purge hooks ─────────────────────────────────────────────────
93
+ // Owners of parked snapshots (usage-metrics) register a SYNCHRONOUS cleanup
94
+ // here. deleteSession runs them while it still holds the id's commit lock, so
95
+ // no timer retry and no exit-drain entry survives the unlink. Layering stays
96
+ // intact: the store never imports manager code.
97
+ const _sessionPurgeHooks = new Set();
98
+
99
+ export function registerSessionPurgeHook(hook) {
100
+ if (typeof hook !== 'function') return () => {};
101
+ _sessionPurgeHooks.add(hook);
102
+ return () => _sessionPurgeHooks.delete(hook);
103
+ }
104
+
105
+ function _runSessionPurgeHooks(id) {
106
+ for (const hook of _sessionPurgeHooks) {
107
+ try { hook(id); } catch { /* a cleanup hook never breaks delete */ }
108
+ }
109
+ }
68
110
 
69
111
 
70
112
 
@@ -72,6 +114,37 @@ export { saveSessionAsync, saveSessionAsyncDeferred } from './store/save-worker.
72
114
 
73
115
  /** Module-level map tracking in-flight saves per session ID to prevent concurrent write corruption. */
74
116
  const _savePending = new Map();
117
+
118
+ /**
119
+ * Release a payload's incarnation reference exactly once. Payloads that are
120
+ * coalesced away (replaced in a pending slot), dropped by a delete or retired
121
+ * by the drain never reach _doSave*, so their reference is freed here.
122
+ */
123
+ function _releasePayloadIncarnation(payload) {
124
+ if (!payload?.incarnation) return;
125
+ _releaseSessionIncarnation(payload.incarnation);
126
+ payload.incarnation = null;
127
+ }
128
+
129
+ /** Drop every payload reference a pending slot still owns. */
130
+ function _releasePendingSlot(pending) {
131
+ if (!pending) return;
132
+ _releasePayloadIncarnation(pending.payload);
133
+ _releasePayloadIncarnation(pending.queued);
134
+ }
135
+
136
+ /**
137
+ * Surface an async save rejection. The id-wide error marker may only be
138
+ * stamped while this payload still speaks for the id — after a hard delete (or
139
+ * an id reuse) it belongs to another incarnation.
140
+ */
141
+ function _recordAsyncSaveError(id, payload, err) {
142
+ // DIAGNOSTIC ONLY. The authoritative marker (with its immutable failure
143
+ // snapshot and incarnation check) is stamped inside _doSave; stamping a
144
+ // second, unconditional one here is exactly how a delayed rejection used
145
+ // to mark a hard-deleted or re-created id.
146
+ process.stderr.write(`[session-store] save failed: ${err?.message}\n`);
147
+ }
75
148
  // Disk mtime of the summary index at the last time it was read into (or
76
149
  // refreshed as) the in-memory cache base — cross-process staleness detector.
77
150
  let _summaryIndexMtimeSeen = 0;
@@ -81,22 +154,57 @@ let _summaryIndexMtimeSeen = 0;
81
154
  * Cheap authoritative lifecycle read straight from disk (no live/pending
82
155
  * cache). Used by askSession's split-brain re-adoption: a new ask on a
83
156
  * non-closed session claims ownership by adopting the on-disk generation.
157
+ * Returns null for BOTH true absence and an unreadable record; callers that
158
+ * must tell those apart use readSessionLifecycleStateFromDisk below.
84
159
  */
85
160
  export function readSessionLifecycleFromDisk(id) {
86
- if (!id) return null;
87
- const target = sessionPath(id);
88
- if (!existsSync(target)) return null;
161
+ const state = readSessionLifecycleStateFromDisk(id);
162
+ if (state.state !== 'open' && state.state !== 'closed') return null;
163
+ return { generation: state.generation, closed: state.state === 'closed' };
164
+ }
165
+
166
+ /**
167
+ * Same durable read, but with the outcome discriminated so a caller can fail
168
+ * CLOSED on an unreadable/corrupt/foreign record while still allowing true
169
+ * absence (a never-saved session):
170
+ * 'absent' — no such file (ENOENT/ENOTDIR)
171
+ * 'open' — durable record, not tombstoned (generation carried)
172
+ * 'closed' — durable tombstone (generation carried)
173
+ * 'unreadable' — IO error, malformed JSON, an ambiguous record (duplicate
174
+ * top-level `id`/lifecycle keys), or an identity that is not
175
+ * EXACTLY this session: missing, empty, non-string or
176
+ * foreign `id` all fail closed. Only a truly absent file is
177
+ * 'absent'; a malformed/identity-less legacy record is not.
178
+ */
179
+ export function readSessionLifecycleStateFromDisk(id) {
180
+ if (!id) return { state: 'unreadable', generation: 0 };
181
+ let raw;
89
182
  try {
90
- const raw = readFileSync(target, 'utf-8');
91
- let onDisk = scanTopLevelLifecycle(raw);
92
- if (onDisk === null) onDisk = JSON.parse(raw);
93
- return {
94
- generation: typeof onDisk.generation === 'number' ? onDisk.generation : 0,
95
- closed: onDisk.closed === true,
96
- };
97
- } catch {
98
- return null;
183
+ raw = readFileSync(sessionPath(id), 'utf-8');
184
+ } catch (err) {
185
+ const code = err?.code;
186
+ if (code === 'ENOENT' || code === 'ENOTDIR') return { state: 'absent', generation: 0 };
187
+ return { state: 'unreadable', generation: 0 };
188
+ }
189
+ // ONE authority, no fallback: readTopLevelLifecycleRecord already IS the
190
+ // strict parse, so a malformed/ambiguous document ends here. A JSON.parse
191
+ // retry would resolve duplicate keys last-wins and defeat the check —
192
+ // even (especially) when one of the duplicates matches the requested id.
193
+ const onDisk = readTopLevelLifecycleRecord(raw);
194
+ if (isLifecycleUnreadable(onDisk)) return { state: 'unreadable', generation: 0 };
195
+ // Durable identity is MANDATORY: the record is this session's authority
196
+ // only when its top-level `id` is a non-empty string exactly equal to the
197
+ // requested id. Missing / empty / non-string identity is not "probably
198
+ // ours" — it is an unowned or malformed record and fails closed exactly
199
+ // like a foreign one. (A never-written session is reported 'absent'
200
+ // above; that is the only backwards-compatible opening.)
201
+ if (onDisk.id !== id) {
202
+ return { state: 'unreadable', generation: 0 };
99
203
  }
204
+ return {
205
+ state: onDisk.closed === true ? 'closed' : 'open',
206
+ generation: typeof onDisk.generation === 'number' ? onDisk.generation : 0,
207
+ };
100
208
  }
101
209
 
102
210
  /**
@@ -147,11 +255,20 @@ export function evictIdleLiveSessions(options = {}) {
147
255
  for (const [id, session] of [..._liveSessions.entries()]) {
148
256
  if (isSessionLive && isSessionLive(id)) continue;
149
257
  if (_hasPendingPersistence(id)) continue;
150
- if (!existsSync(sessionPath(id))) continue;
258
+ // Durability proof for the eviction: only a POSITIVELY observed file
259
+ // may replace the snapshot. An unreadable probe is not a durable copy.
260
+ if (probePath(sessionPath(id)).state !== PROBE_PRESENT) continue;
151
261
  // A dropped last save means the disk copy is BEHIND this snapshot
152
262
  // (ownership split-brain). Evicting would lose the only complete
153
263
  // transcript; keep it until a save lands again (re-adoption).
154
264
  if (_droppedSaveIds.has(id)) continue;
265
+ // Same reasoning for a save that FAILED at the commit edge (rename/IO
266
+ // fault): the file on disk is the last-good copy from BEFORE the
267
+ // failed write, so this snapshot is the only good state for the newest
268
+ // turn. `existsSync` above is satisfied by exactly that stale file, so
269
+ // without this guard the idle sweep silently discards the newer
270
+ // transcript. Pinned only until a save lands (clearSessionSaveError).
271
+ if (hasSessionSaveFailure(id)) continue;
155
272
  if (_messagesCarryLiveMedia(session?.messages)) {
156
273
  const lastActive = Math.max(session?.updatedAt || 0, session?.lastUsedAt || 0);
157
274
  if (lastActive > 0 && now - lastActive <= LIVE_MEDIA_RETENTION_MS) continue;
@@ -185,21 +302,48 @@ process.on('exit', drainSessionStore);
185
302
  * Persist a session. `opts.expectedGeneration` guards against resurrecting a
186
303
  * session that was closed mid-flight: before the rename, we re-read the file
187
304
  * on disk and, if it's already marked closed with a >= generation, drop the
188
- * write. `opts.allowClosed=true` is used by `markSessionClosed` itself when
189
- * writing the tombstone.
305
+ * write. There is NO bypass of the canonical ownership check — the lifecycle
306
+ * barriers (markSessionClosed / bumpSessionGeneration) write their tombstone
307
+ * directly, under their own authority read, so no save path needs one.
190
308
  */
191
309
  export function saveSession(session, opts) {
192
310
  _ensureLifecycleFields(session);
193
311
  const id = session.id;
194
- setLiveSession(session);
195
- const summaryVersion = _cacheSessionSummary(session);
196
- const payload = { session, opts: _guardedSaveOptions(id, opts), summaryVersion };
312
+ // PRE-ADMISSION for EVERY entry mode (sync, immediate, debounced): a
313
+ // foreign/ambiguous/unreadable canonical record is never cached as locally
314
+ // owned, not even transiently, so this runs BEFORE setLiveSession and the
315
+ // optimistic summary row. True absence stays creatable.
316
+ const refusal = _sessionWriteAuthorityRefusal(id);
317
+ // A refused record publishes NOTHING: no live snapshot, no optimistic
318
+ // summary row. The write itself still travels the normal path, where the
319
+ // strict under-lock admission refuses it and produces the established
320
+ // outcome/markers — only the local "owned" caches are never touched.
321
+ if (!refusal) setLiveSession(session);
322
+ const summaryVersion = refusal ? null : _cacheSessionSummary(session);
323
+ // Identity of THIS attempt: only a landed save at least this new may clear
324
+ // the id's failure/drop markers (see live-state _clearSaveStateIfCurrent),
325
+ // and a write older than what already landed may not commit at all (the
326
+ // epoch travels inside the write guard, so the worker realm sees it too).
327
+ const epoch = _nextSaveEpoch();
328
+ // Incarnation of the id at ISSUE time: a hard delete (or a re-created id)
329
+ // makes this payload's markers inert on settlement.
330
+ const incarnation = _acquireSessionIncarnation(id);
331
+ const payload = {
332
+ session,
333
+ opts: _guardedSaveOptions(id, opts, epoch),
334
+ summaryVersion,
335
+ epoch,
336
+ // `incarnation` is the releasable OWNERSHIP reference; `settlement` is
337
+ // the immutable identity a late settlement proves membership with.
338
+ incarnation,
339
+ settlement: incarnation,
340
+ };
197
341
  // Synchronous durability path — explicit flush (tombstones, drain hooks).
198
342
  // createSession uses async debounced save + _liveSessions for same-process
199
343
  // read-your-writes; sync remains for callers that require immediate disk.
200
344
  if (opts?.sync) {
201
345
  try {
202
- if (!_doSaveSync(payload)) _rollbackCachedSessionSummary(id, summaryVersion);
346
+ if (_doSaveSync(payload) !== SAVE_OUTCOME_SAVED) _rollbackCachedSessionSummary(id, summaryVersion);
203
347
  } catch (err) {
204
348
  _rollbackCachedSessionSummary(id, summaryVersion);
205
349
  throw err;
@@ -213,19 +357,20 @@ export function saveSession(session, opts) {
213
357
  const pending = _savePending.get(id);
214
358
  if (pending) {
215
359
  if (pending.writing) {
360
+ _releasePayloadIncarnation(pending.queued);
216
361
  _savePending.set(id, { ...pending, queued: payload });
217
362
  } else {
363
+ _releasePayloadIncarnation(pending.payload);
218
364
  _savePending.set(id, { ...pending, payload });
219
365
  _flushScheduled(id);
220
366
  }
221
367
  } else {
222
368
  _savePending.set(id, { writing: true, payload });
223
- _doSave(payload).then((saved) => {
224
- if (!saved) _rollbackCachedSessionSummary(id, summaryVersion);
369
+ _doSave(payload).then((outcome) => {
370
+ if (outcome !== SAVE_OUTCOME_SAVED) _rollbackCachedSessionSummary(id, summaryVersion);
225
371
  }).catch(err => {
226
372
  _rollbackCachedSessionSummary(id, summaryVersion);
227
- process.stderr.write(`[session-store] save failed: ${err?.message}\n`);
228
- _lastSaveError.set(id, { message: err?.message ?? String(err), at: Date.now() });
373
+ _recordAsyncSaveError(id, payload, err);
229
374
  });
230
375
  }
231
376
  return;
@@ -236,13 +381,16 @@ export function saveSession(session, opts) {
236
381
  // Write in flight — overwrite the queued slot. Multiple async
237
382
  // saves for the same id while one is on disk collapse into a
238
383
  // single follow-up write.
384
+ _releasePayloadIncarnation(pending.queued);
239
385
  _savePending.set(id, { ...pending, queued: payload });
240
386
  } else if (pending.scheduled) {
241
387
  // setImmediate already scheduled — coalesce into the same tick
242
388
  // by overwriting the pending payload with the latest state.
389
+ _releasePayloadIncarnation(pending.payload);
243
390
  _savePending.set(id, { scheduled: true, payload });
244
391
  } else if (pending.debouncing) {
245
392
  // 150 ms debounce window active — overwrite payload, timer keeps running.
393
+ _releasePayloadIncarnation(pending.payload);
246
394
  _savePending.set(id, { debouncing: true, payload });
247
395
  }
248
396
  return;
@@ -267,12 +415,11 @@ function _flushScheduled(id) {
267
415
  const cur = _savePending.get(id);
268
416
  if (!cur || !cur.scheduled) return;
269
417
  _savePending.set(id, { writing: true, payload: cur.payload });
270
- _doSave(cur.payload).then((saved) => {
271
- if (!saved) _rollbackCachedSessionSummary(id, cur.payload.summaryVersion);
418
+ _doSave(cur.payload).then((outcome) => {
419
+ if (outcome !== SAVE_OUTCOME_SAVED) _rollbackCachedSessionSummary(id, cur.payload.summaryVersion);
272
420
  }).catch(err => {
273
421
  _rollbackCachedSessionSummary(id, cur.payload.summaryVersion);
274
- process.stderr.write(`[session-store] save failed: ${err?.message}\n`);
275
- _lastSaveError.set(id, { message: err?.message ?? String(err), at: Date.now() });
422
+ _recordAsyncSaveError(id, cur.payload, err);
276
423
  });
277
424
  }
278
425
 
@@ -280,221 +427,476 @@ function _flushScheduled(id) {
280
427
  /**
281
428
  * Exported for save-session-worker — not part of the public API.
282
429
  * External callers should use saveSession / saveSessionAsync.
430
+ * Returns a SAVE_OUTCOME_* reason, never a bare boolean: the worker relays it
431
+ * so the parent can tell an ownership drop from a stale-epoch refusal.
283
432
  */
284
- export function _saveSessionSync(session, opts) {
433
+ export function _saveSessionSync(session, opts, options = {}) {
285
434
  _ensureLifecycleFields(session);
286
- return _doSaveSync({ session, opts: opts || null });
435
+ // The worker realm mints no identity of its own: the authoritative epoch
436
+ // arrives with the guard the parent stamped.
437
+ const guardEpoch = opts?._sessionWriteGuard?.epoch;
438
+ return _doSaveSync({
439
+ session,
440
+ opts: opts || null,
441
+ // A caller that already owns the snapshot's identity (the exit drain)
442
+ // passes it through; minting a fresh epoch there would make an OLD
443
+ // snapshot look like the newest attempt and let it clear markers.
444
+ epoch: Number.isFinite(options.epoch)
445
+ ? options.epoch
446
+ : (Number.isFinite(guardEpoch) ? guardEpoch : _nextSaveEpoch()),
447
+ commitTimeoutMs: options.commitTimeoutMs,
448
+ // Own reference to the id's current incarnation (released by
449
+ // _doSaveSync): a hard delete during this write makes its markers
450
+ // inert without affecting anybody else's stamp.
451
+ incarnation: _acquireSessionIncarnation(session.id),
452
+ });
287
453
  }
288
454
 
289
455
  function _doSaveSync(payload) {
290
- const { session, opts, summaryVersion = null } = payload;
456
+ const { session, opts, summaryVersion = null, epoch = null, commitTimeoutMs = null, incarnation = null } = payload;
291
457
  const id = session.id;
292
- if (_shouldDrop(id, opts)) { _droppedSaveIds.add(id); return false; }
458
+ // Settlement identity is IMMUTABLE and separate from the (releasable)
459
+ // ownership reference: purging may null the ref, but a late settlement
460
+ // must still be able to prove which incarnation it belonged to.
461
+ const settlement = payload.settlement ?? incarnation;
462
+ const mayMutate = () => _isCurrentSessionIncarnation(id, settlement);
463
+ // EVERY exit — stale, drop, refusal, success, throw — releases the
464
+ // ownership reference exactly once through this finally.
465
+ try {
466
+ // A newer write for this id already landed: committing these bytes would
467
+ // REVERT durable history. Refuse before any scratch file is written and
468
+ // before any marker is touched (no failure, no drop — nothing was lost).
469
+ if (_isStaleWriteEpoch(opts)) return SAVE_OUTCOME_STALE;
470
+ if (_shouldDrop(id, opts)) {
471
+ if (mayMutate()) _recordSaveDrop(id, epoch);
472
+ return SAVE_OUTCOME_DROPPED;
473
+ }
293
474
  const target = sessionPath(id);
294
- const tmp = target + '.' + randomBytes(6).toString('hex') + '.tmp';
475
+ const tmp = _trackSaveTmp(target + '.' + randomBytes(6).toString('hex') + '.tmp');
476
+ // The EXACT bytes this attempt tried to commit; failure evidence is
477
+ // rebuilt from them, never re-read from the later mutable live session.
478
+ let attempted = null;
295
479
  try {
296
- writeFileSync(tmp, JSON.stringify(_sessionForDisk(session)), 'utf-8');
480
+ attempted = JSON.stringify(_sessionForDisk(session));
481
+ writeFileSync(tmp, attempted, 'utf-8');
297
482
  if (_shouldDrop(id, opts)) {
298
- try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
299
- _droppedSaveIds.add(id);
300
- return false;
483
+ _discardSaveTmp(tmp);
484
+ if (mayMutate()) _recordSaveDrop(id, epoch);
485
+ return SAVE_OUTCOME_DROPPED;
486
+ }
487
+ const commitControl = _acquireWriteCommit(opts, { timeoutMs: commitTimeoutMs });
488
+ if (commitControl === _WRITE_COMMIT_STALE) {
489
+ // The newer write landed while we waited for the lock.
490
+ _discardSaveTmp(tmp);
491
+ return SAVE_OUTCOME_STALE;
492
+ }
493
+ if (commitControl === _WRITE_COMMIT_TIMEOUT) {
494
+ // Bounded acquisition (exit drain): another realm still holds the
495
+ // rename lock. Surface it as a save failure — canonical file is
496
+ // untouched, the live snapshot stays pinned — instead of blocking
497
+ // process exit on an unbounded wait.
498
+ const busy = new Error(`[session-store] ${id}: commit lock busy after ${commitTimeoutMs}ms`);
499
+ busy.code = 'ECOMMITBUSY';
500
+ throw busy;
301
501
  }
302
- const commitControl = _acquireWriteCommit(opts);
303
502
  if (commitControl === false || _shouldDrop(id, opts)) {
304
- try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
503
+ _discardSaveTmp(tmp);
305
504
  _releaseWriteCommit(commitControl);
306
- _droppedSaveIds.add(id);
307
- return false;
505
+ if (mayMutate()) _recordSaveDrop(id, epoch);
506
+ return SAVE_OUTCOME_DROPPED;
308
507
  }
309
508
  try {
310
- _renameWithRetrySync(tmp, target);
311
- _queueSessionSummaryUpsert(session, summaryVersion);
312
- clearSessionSaveError(id);
313
- _droppedSaveIds.delete(id);
509
+ _commitSessionWrite(tmp, target, id);
510
+ _publishLandedWriteEpoch(opts, epoch);
511
+ _untrackSaveTmp(tmp);
512
+ if (mayMutate()) {
513
+ _queueSessionSummaryUpsert(session, summaryVersion);
514
+ _clearSaveStateIfCurrent(id, epoch);
515
+ }
314
516
  } finally {
315
517
  _releaseWriteCommit(commitControl);
316
518
  }
317
- return true;
519
+ return SAVE_OUTCOME_SAVED;
318
520
  } catch (err) {
319
- try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
521
+ // The canonical file was never renamed over, so it still holds the
522
+ // last-good session JSON. Flag the id (live snapshot becomes the only
523
+ // good copy of the newest turn), reclaim the scratch file, and rethrow
524
+ // so the caller SEES the failure instead of a silent no-op.
525
+ _discardSaveTmp(tmp);
526
+ // The snapshot is recorded WITH the failure: it is the evidence that
527
+ // lets loadSession serve this exact copy while the canonical file is
528
+ // unreadable (and nothing else may).
529
+ if (mayMutate()) _recordSaveFailure(id, err, epoch, _snapshotFromAttempt(attempted, id));
320
530
  throw err;
321
531
  }
532
+ } finally {
533
+ _releasePayloadIncarnation(payload);
534
+ }
535
+ }
536
+
537
+ /**
538
+ * Rebuild failure evidence from the EXACT bytes an attempt tried to commit.
539
+ * Never the live session: by settlement time that object may already carry a
540
+ * newer (unattempted) turn, which must not be published as the recovery copy.
541
+ */
542
+ function _snapshotFromAttempt(attemptedJson, id) {
543
+ if (typeof attemptedJson !== 'string') return null;
544
+ try {
545
+ const parsed = JSON.parse(attemptedJson);
546
+ return parsed && parsed.id === id ? parsed : null;
547
+ } catch {
548
+ return null;
549
+ }
322
550
  }
323
551
 
552
+ /**
553
+ * Write admission for ONE save attempt. Consulted upfront, after the scratch
554
+ * write and again while the commit lock is held, so the final verdict is
555
+ * taken under the lock, immediately before the rename.
556
+ *
557
+ * OWNERSHIP FIRST, freshness second. Every save — guarded or not — must find
558
+ * OUR record (or nothing) at the canonical path:
559
+ * absent → this write creates the file;
560
+ * ambiguous/unreadable → refuse (never "open at generation 0");
561
+ * foreign / identity-less id → refuse, with or without a generation
562
+ * guard: an ordinary appendMessage save
563
+ * must not rename over another session's
564
+ * record just because it carries no
565
+ * expectedGeneration;
566
+ * ours → apply the generation rules below.
567
+ * NOTHING is exempt. The old `allowClosed` opt-out is gone: it existed for a
568
+ * tombstone plant that has not gone through this path in a long time (the
569
+ * barriers write the canonical file themselves), and any surviving caller of
570
+ * it would have been able to skip the absent-vs-owned-vs-ambiguous check
571
+ * entirely — the exact hole this guard exists to close.
572
+ */
324
573
  function _shouldDrop(id, opts) {
325
574
  if (_isCancelledWrite(opts)) return true;
326
- if (!opts || opts.allowClosed) return false;
327
- const expected = typeof opts.expectedGeneration === 'number' ? opts.expectedGeneration : null;
328
- if (expected === null) return false;
329
- // Re-read current tombstone state from disk. If the session is closed with
330
- // a generation >= expected, our write is stale — drop it.
575
+ const expected = typeof opts?.expectedGeneration === 'number' ? opts.expectedGeneration : null;
331
576
  const target = sessionPath(id);
332
- if (!existsSync(target)) return false;
577
+ let record;
578
+ try {
579
+ record = _readCanonicalRecord(target);
580
+ } catch {
581
+ // The guard could not establish WHAT is on disk. Refusing the write is
582
+ // the only safe verdict: the alternative renames over a record whose
583
+ // ownership/tombstone state is unknown.
584
+ return true;
585
+ }
586
+ // Ambiguous/corrupt record (duplicate top-level lifecycle keys, malformed
587
+ // JSON, unreadable file that nonetheless exists). It must NEVER be read as
588
+ // "open at generation 0" — that is precisely the fail-open that lets a
589
+ // late save resurrect a session over an ambiguous tombstone. Drop.
590
+ if (record === LIFECYCLE_AMBIGUOUS) return true;
591
+ if (!record) return false; // no file on disk → this save creates it
592
+ // Durable identity is mandatory, exactly as for every other authority
593
+ // read: a record naming another session (or naming none at all) is not
594
+ // ours to overwrite.
595
+ if (record.id !== id) return true;
596
+ if (expected === null) return false; // unguarded save over our own record
597
+ const generation = typeof record.generation === 'number' ? record.generation : 0;
598
+ // Closed with a generation at least as new as ours: our write is stale.
599
+ if (record.closed === true) return generation >= expected;
600
+ // Not closed, but `generation` also doubles as an ownership counter:
601
+ // normal in-place saves (updateSession/appendMessage/etc.) never bump
602
+ // it, only closeSession()-family calls do (markSessionClosed and its
603
+ // non-tombstoning sibling bumpSessionGeneration). So if disk
604
+ // generation is strictly greater than what this write expected,
605
+ // ownership moved on (session was detached-closed and possibly
606
+ // resumed) after our turn started — drop the stale write rather than
607
+ // let it clobber whatever happened after the handoff.
608
+ return generation > expected;
609
+ }
610
+
611
+ // ── Lifecycle read for the ownership guard ───────────────────────────────────
612
+ // _shouldDrop consults this up to three times per save (upfront, post-temp
613
+ // write, in-commit) and every consult re-reads the file, deliberately: this is
614
+ // the input to a DROP decision, and the only cheap identity a memo could key
615
+ // on (mtimeMs + size) does NOT move for a same-size rewrite inside one clock
616
+ // tick — a generation bump such as 1 → 2 — so a cached lifecycle could hide
617
+ // the very ownership move this guard exists to detect.
618
+ //
619
+ // Three distinct outcomes, never collapsed: `null` = no file (write freely),
620
+ // LIFECYCLE_AMBIGUOUS = a file exists but its bytes cannot be trusted (refuse
621
+ // the write), otherwise the strict record itself ({ doc, id, closed,
622
+ // generation }) — the single disk authority shared by the save guard and the
623
+ // lifecycle barriers.
624
+ const LIFECYCLE_AMBIGUOUS = Symbol('lifecycle-ambiguous');
625
+
626
+ function _readCanonicalRecord(target) {
627
+ let raw;
628
+ try {
629
+ raw = readFileSync(target, 'utf-8');
630
+ } catch (err) {
631
+ const code = err?.code;
632
+ // Absence is the ONLY benign read failure. An EACCES/EBUSY/EIO file
633
+ // that exists but cannot be read is ambiguous, not "no tombstone".
634
+ if (code === 'ENOENT' || code === 'ENOTDIR') return null;
635
+ return LIFECYCLE_AMBIGUOUS;
636
+ }
637
+ // AUTHORITATIVE by construction: a message body can contain literal text
638
+ // like `{"closed":true}` (tool result, pasted JSON) and a duplicate
639
+ // top-level key would be silently last-wins under a plain JSON.parse.
640
+ // readTopLevelLifecycleRecord is the single strict authority; there is no
641
+ // parse fallback to differ from.
642
+ const onDisk = readTopLevelLifecycleRecord(raw);
643
+ if (isLifecycleUnreadable(onDisk)) return LIFECYCLE_AMBIGUOUS;
644
+ return onDisk;
645
+ }
646
+
647
+ // ONE absolute budget for the WHOLE drain: the commit-lock waits and the
648
+ // bounded commit acquisitions of every id share it, so exit cost cannot scale
649
+ // with the number of contended sessions. Exit must never hang on a stuck
650
+ // writer; an id left unflushed is recorded + live-pinned instead.
651
+ // Pre-admission authority for the async/worker path (registered here because
652
+ // save-worker.mjs cannot import this module back). A refusal keeps the caller
653
+ // from publishing ANY owned state — no live snapshot, no optimistic summary.
654
+ function _sessionWriteAuthorityRefusal(id) {
655
+ if (!id) return null;
656
+ let authority;
333
657
  try {
334
- const raw = readFileSync(target, 'utf-8');
335
- // Tombstone check only needs top-level `closed`/`generation`. A plain
336
- // substring/regex pre-check is unsafe on its own: a message body can
337
- // contain literal text like `{"closed":true}` (tool result, pasted
338
- // JSON) which would spoof the guard if trusted directly. Instead of
339
- // JSON.parse'ing the whole document on every guarded save (expensive:
340
- // allocates the entire messages array just to read two scalars),
341
- // scanTopLevelLifecycle walks the raw text with bracket-depth +
342
- // string-escape awareness and only *interprets* key/value pairs at
343
- // depth 1 — nested "closed"/"generation" inside messages are skipped
344
- // by depth counting, never parsed, so they cannot be mistaken for the
345
- // real top-level fields. Falls back to a full parse only if the scan
346
- // reports malformed/truncated JSON (should not happen for files we
347
- // wrote ourselves, but stay correct over clever).
348
- let onDisk = scanTopLevelLifecycle(raw);
349
- if (onDisk === null) onDisk = JSON.parse(raw);
350
- const diskGen = typeof onDisk.generation === 'number' ? onDisk.generation : 0;
351
- if (onDisk.closed === true) return diskGen >= expected;
352
- // Not closed, but `generation` also doubles as an ownership counter:
353
- // normal in-place saves (updateSession/appendMessage/etc.) never bump
354
- // it, only closeSession()-family calls do (markSessionClosed and its
355
- // non-tombstoning sibling bumpSessionGeneration). So if disk
356
- // generation is strictly greater than what this write expected,
357
- // ownership moved on (session was detached-closed and possibly
358
- // resumed) after our turn started — drop the stale write rather than
359
- // let it clobber whatever happened after the handoff.
360
- return diskGen > expected;
658
+ authority = _readCanonicalRecord(sessionPath(id));
361
659
  } catch {
362
- return false;
660
+ // A THROWN authority check is never acceptance: fail closed.
661
+ return 'unreadable';
363
662
  }
663
+ if (authority === LIFECYCLE_AMBIGUOUS) return 'ambiguous';
664
+ if (authority && authority.id !== id) return 'foreign';
665
+ return null; // absent (creatable) or ours
364
666
  }
365
667
 
366
- /** Sync-flush every pending save on exit; per-entry catch matches _flushScheduled. */
668
+ _setSessionWriteAuthorityCheck(_sessionWriteAuthorityRefusal);
669
+
670
+ const DRAIN_BUDGET_MS = 400;
671
+
672
+ /**
673
+ * Sync-flush every pending save on exit.
674
+ *
675
+ * Ordering contract (each step depends on the previous one):
676
+ * 1. collect the NEWEST payload per id across debounce/pending, the worker's
677
+ * in-flight + latest-wins queued slots and the deferred snapshots, ranked
678
+ * by the SAVE EPOCH each snapshot was issued with (never by which slot it
679
+ * happens to sit in: a deferred snapshot can be older than a later sync
680
+ * or async attempt);
681
+ * 2. CANCEL every one of those ids, invalidating the write guards those
682
+ * async payloads carry — a worker write still in the queue drops itself
683
+ * (_shouldDrop) and one already past that check is refused under the lock
684
+ * by _acquireWriteCommit;
685
+ * 3. retire commits already in progress within the shared deadline;
686
+ * 4. sync-write the newest payload per id with a FRESH guard, under its OWN
687
+ * epoch and the remaining budget.
688
+ * So no older worker write can rename over the newest state afterwards, and
689
+ * no step can block process exit indefinitely.
690
+ */
367
691
  export function drainSessionStore() {
368
692
  for (const t of _debounceTimers.values()) clearTimeout(t);
369
693
  _debounceTimers.clear();
370
- for (const [, pending] of _savePending) {
371
- if (!pending.payload) continue;
694
+ // ── 1. newest payload per id, by SAVE EPOCH ────────────────────────────
695
+ const newest = new Map(); // id → { session, opts, epoch, revision, ok }
696
+ // Ranked by (epoch, revision). The revision breaks ties WITHIN one
697
+ // issuance identity: an in-flight worker payload and a parked snapshot can
698
+ // share an epoch while the park already holds a NEWER distinct payload
699
+ // (the ref was replaced behind the write). Map/source order must never
700
+ // decide that, and minting a newer epoch here would hand an old snapshot
701
+ // fresh write authority.
702
+ const record = (id, session, opts, epoch, revision) => {
703
+ if (!id || !session) return;
704
+ const issued = Number.isFinite(epoch) ? epoch : 0;
705
+ const rev = Number.isFinite(revision) ? revision : 0;
706
+ const current = newest.get(id);
707
+ if (current && (current.epoch > issued || (current.epoch === issued && current.revision >= rev))) return;
708
+ newest.set(id, { session, opts: opts || null, epoch: issued, revision: rev, ok: false });
709
+ };
710
+ for (const [id, pending] of _savePending) {
711
+ // Both slots are candidates; the payload's own epoch decides, so a
712
+ // `queued` follow-up wins because it is NEWER, not because of its slot.
713
+ record(id, pending.payload?.session, pending.payload?.opts, pending.payload?.epoch, 0);
714
+ record(id, pending.queued?.session, pending.queued?.opts, pending.queued?.epoch, 0);
715
+ }
716
+ for (const [, pending] of _saveWorkerPending) record(pending.id, pending.session, pending.opts, pending.epoch, pending.revision);
717
+ for (const [id, q] of _saveAsyncQueued) record(id, q.session, q.opts, q.epoch, q.revision);
718
+ for (const [, pending] of _deferredSessionSaves) record(pending.session?.id, pending.session, pending.opts, pending.epoch, pending.revision);
719
+ // ── 2/3. cancel, then retire in-flight commits inside ONE deadline ─────
720
+ const deadline = Date.now() + DRAIN_BUDGET_MS;
721
+ const remainingMs = () => Math.max(0, deadline - Date.now());
722
+ for (const id of newest.keys()) _cancelSessionWrites(id);
723
+ for (const id of newest.keys()) {
724
+ const budget = remainingMs();
725
+ if (budget === 0) break; // deadline spent: no further lock waits
726
+ _waitForWriteCommit(id, { timeoutMs: budget });
727
+ }
728
+ // ── 4. write the newest state per id under a FRESH (uncancelled) guard ──
729
+ for (const [id, entry] of newest) {
372
730
  try {
373
- _doSaveSync(pending.payload);
731
+ // The commit shares the SAME deadline and keeps the snapshot's own
732
+ // epoch. Once the deadline is spent the budget is 0: an UNCONTENDED
733
+ // id still commits (no wait is needed for a free lock), a contended
734
+ // one is refused immediately — never another lock wait. A refused
735
+ // write fails loudly (recorded + live-pinned) and exit proceeds.
736
+ // The guard carries the snapshot's OWN epoch, so a payload older
737
+ // than what already LANDED is refused instead of reverting disk.
738
+ _saveSessionSync(entry.session, _guardedSaveOptions(id, entry.opts, entry.epoch), {
739
+ epoch: entry.epoch,
740
+ commitTimeoutMs: remainingMs(),
741
+ });
742
+ entry.ok = true;
374
743
  } catch (err) {
375
744
  process.stderr.write(`[session-store] drain save failed: ${err?.message}\n`);
376
745
  }
377
746
  }
747
+ for (const [, pending] of _savePending) _releasePendingSlot(pending);
378
748
  _savePending.clear();
379
- // Invalidate older worker writes for sessions whose newer terminal snapshot
380
- // is deferred, then wait out any commit already holding the write guard.
381
- for (const [, pending] of _deferredSessionSaves) {
382
- _cancelSessionWrites(pending.session.id);
383
- _waitForWriteCommit(pending.session.id);
384
- pending.opts = _guardedSaveOptions(pending.session.id, pending.opts);
385
- }
386
- // Summary-index ops queued by the deferred/no-wait flush path: give them
387
- // one last best-effort flush before exit (still zero-wait; losing them is
388
- // acceptable — the index self-heals on next rebuild).
389
- try { _flushPendingSummaryOps({ sync: true }); } catch { /* best-effort */ }
390
- // Outstanding worker-queue writes: process exit may interrupt the worker
391
- // thread before it processes its message queue, so each pending payload
392
- // is sync-flushed directly here. The Promise is then rejected so the
393
- // caller's await site does not leak unresolved (caller is at process
394
- // exit so the rejection is informational, not actionable).
749
+ // Settle every async waiter: the drain already wrote their newest state,
750
+ // so the promise result is informational (the caller is at process exit).
751
+ // The marker makes this settlement TERMINAL for its owner: the drain took
752
+ // ownership of durability for these ids and cleared their deferred
753
+ // handles, so a receiver must not re-park or retry (that would mint a
754
+ // fresh epoch and could overwrite a save that lands after the drain).
395
755
  const _drainErr = new Error('[session-store] drain: worker-queue interrupted by process exit');
396
- // Flush in-flight writes FIRST, then the latest-wins queued payloads, so
397
- // for any id with both an in-flight and a queued write the queued (newest)
398
- // state is written LAST and wins on disk — no lost last write.
756
+ _drainErr.code = 'ESESSIONSTOREDRAINED';
757
+ _drainErr.sessionStoreDrained = true;
399
758
  for (const [, pending] of _saveWorkerPending) {
400
- if (pending.session) {
401
- try {
402
- _saveSessionSync(pending.session, pending.opts);
403
- } catch (err) {
404
- process.stderr.write(`[session-store] drain worker-queue save failed: ${err?.message}\n`);
405
- }
406
- }
407
759
  for (const w of pending.waiters) {
408
760
  try { w.reject(_drainErr); } catch { /* best-effort */ }
409
761
  }
762
+ // This map is cleared below, so its references are freed here (the
763
+ // worker-side reset then sees an empty map).
764
+ _releaseSessionIncarnation(pending.incarnation);
410
765
  }
411
766
  for (const [, q] of _saveAsyncQueued) {
412
- try {
413
- _saveSessionSync(q.session, q.opts);
414
- } catch (err) {
415
- process.stderr.write(`[session-store] drain worker-queue save failed: ${err?.message}\n`);
416
- }
417
767
  for (const w of q.waiters) {
418
768
  try { w.reject(_drainErr); } catch { /* best-effort */ }
419
769
  }
420
770
  }
421
- // Terminal/deferred snapshots are newest and must be written LAST.
422
771
  for (const [, pending] of _deferredSessionSaves) {
423
- try {
424
- _saveSessionSync(pending.session, pending.opts);
425
- pending.resolve();
426
- } catch (err) {
427
- pending.reject(err);
428
- process.stderr.write(`[session-store] drain deferred save failed: ${err?.message}\n`);
429
- }
772
+ const entry = newest.get(pending.session?.id);
773
+ if (entry?.ok) pending.resolve();
774
+ else pending.reject(_drainErr);
775
+ _releaseSessionIncarnation(pending.incarnation);
430
776
  }
431
777
  _deferredSessionSaves.clear();
432
778
  _saveWorkerPending.clear();
779
+ // Also unrefs the worker for writes retired above, so a superseded write
780
+ // can never keep the process alive.
433
781
  _resetSaveWorkerBookkeeping();
782
+ // Summary-index ops queued by the writes above: one last best-effort sync
783
+ // flush before exit (losing them is acceptable — the index self-heals).
784
+ try { _flushPendingSummaryOps({ sync: true }); } catch { /* best-effort */ }
785
+ // Last retry of the scratch files THIS realm minted and failed to unlink.
786
+ // `drain` walks the WHOLE registry in bounded chunks (bounded total
787
+ // attempts, one attempt per path) so more than one chunk of orphans cannot
788
+ // survive exit; registry-only, nothing in sessions/ is scanned.
789
+ try { sweepOrphanSessionTmpFiles({ drain: true }); } catch { /* best-effort */ }
434
790
  }
435
791
 
436
- function _drainQueue(id) {
792
+ /**
793
+ * Promote the queued follow-up of the slot THIS payload owns. A stale
794
+ * completion (hard-deleted id, bookkeeping already re-created by a new
795
+ * incarnation) must never promote or clear somebody else's queue, so the slot
796
+ * is matched by payload identity — never by id alone.
797
+ */
798
+ function _drainQueue(id, payload = null) {
437
799
  const pending = _savePending.get(id);
438
- if (pending && pending.queued) {
800
+ if (!pending) return;
801
+ if (payload && pending.payload !== payload && pending.queued !== payload) return;
802
+ if (pending.queued) {
439
803
  const next = pending.queued;
804
+ _releasePayloadIncarnation(pending.payload === next ? null : pending.payload);
440
805
  _savePending.set(id, { writing: true, payload: next });
441
- _doSave(next).then((saved) => {
442
- if (!saved) _rollbackCachedSessionSummary(id, next.summaryVersion);
806
+ _doSave(next).then((outcome) => {
807
+ if (outcome !== SAVE_OUTCOME_SAVED) _rollbackCachedSessionSummary(id, next.summaryVersion);
443
808
  }).catch(err => {
444
809
  _rollbackCachedSessionSummary(id, next.summaryVersion);
445
- process.stderr.write(`[session-store] save failed: ${err?.message}\n`);
446
- _lastSaveError.set(id, { message: err?.message ?? String(err), at: Date.now() });
810
+ _recordAsyncSaveError(id, next, err);
447
811
  });
448
812
  } else {
813
+ _releasePendingSlot(pending);
449
814
  _savePending.delete(id);
450
815
  }
451
816
  }
452
817
 
453
818
  async function _doSave(payload) {
454
- const { session, opts, summaryVersion = null } = payload;
819
+ const { session, opts, summaryVersion = null, epoch = null, incarnation = null } = payload;
455
820
  const id = session.id;
821
+ // Same fences as the sync path, on the IMMUTABLE settlement identity: a
822
+ // delayed failure/drop for a hard-deleted (or re-created) id moves nothing.
823
+ const settlement = payload.settlement ?? incarnation;
824
+ const mayMutate = () => _isCurrentSessionIncarnation(id, settlement);
825
+ // Same freshness fence as the sync path (see _doSaveSync).
826
+ if (_isStaleWriteEpoch(opts)) {
827
+ _releasePayloadIncarnation(payload);
828
+ _drainQueue(id, payload);
829
+ return SAVE_OUTCOME_STALE;
830
+ }
456
831
  // First check: upfront, before any disk I/O. Cheap short-circuit when a
457
832
  // tombstone is already on disk when the caller arrives.
458
833
  if (_shouldDrop(id, opts)) {
459
- _droppedSaveIds.add(id);
460
- _drainQueue(id);
461
- return false;
834
+ if (mayMutate()) _recordSaveDrop(id, epoch);
835
+ _releasePayloadIncarnation(payload);
836
+ _drainQueue(id, payload);
837
+ return SAVE_OUTCOME_DROPPED;
462
838
  }
463
839
  const target = sessionPath(id);
464
- const tmp = target + '.' + randomBytes(6).toString('hex') + '.tmp';
840
+ const tmp = _trackSaveTmp(target + '.' + randomBytes(6).toString('hex') + '.tmp');
841
+ // Serialized BEFORE the first await: failure evidence is this exact
842
+ // payload, never the live session as it looks at settlement time.
843
+ let attempted = null;
465
844
  try {
466
- await fsp.writeFile(tmp, JSON.stringify(_sessionForDisk(session)), 'utf-8');
845
+ attempted = JSON.stringify(_sessionForDisk(session));
846
+ await fsp.writeFile(tmp, attempted, 'utf-8');
467
847
  // Second check: between the temp write and the rename, closeSession()
468
848
  // may have planted a tombstone. Re-check on disk; if a newer tombstone
469
849
  // now exists, discard our temp file rather than let rename clobber it.
470
850
  if (_shouldDrop(id, opts)) {
471
- try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
851
+ _discardSaveTmp(tmp);
472
852
  process.stderr.write(`[session-store] ${id}: dropped stale save (tombstone planted during write)\n`);
473
- _droppedSaveIds.add(id);
474
- _drainQueue(id);
475
- return false;
853
+ if (mayMutate()) _recordSaveDrop(id, epoch);
854
+ _releasePayloadIncarnation(payload);
855
+ _drainQueue(id, payload);
856
+ return SAVE_OUTCOME_DROPPED;
476
857
  }
477
858
  const commitControl = _acquireWriteCommit(opts);
859
+ if (commitControl === _WRITE_COMMIT_STALE) {
860
+ _discardSaveTmp(tmp);
861
+ _releasePayloadIncarnation(payload);
862
+ _drainQueue(id, payload);
863
+ return SAVE_OUTCOME_STALE;
864
+ }
478
865
  if (commitControl === false || _shouldDrop(id, opts)) {
479
- try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
866
+ _discardSaveTmp(tmp);
480
867
  _releaseWriteCommit(commitControl);
481
- _droppedSaveIds.add(id);
482
- _drainQueue(id);
483
- return false;
868
+ if (mayMutate()) _recordSaveDrop(id, epoch);
869
+ _releasePayloadIncarnation(payload);
870
+ _drainQueue(id, payload);
871
+ return SAVE_OUTCOME_DROPPED;
484
872
  }
485
873
  try {
486
- _renameWithRetrySync(tmp, target);
487
- _queueSessionSummaryUpsert(session, summaryVersion);
488
- clearSessionSaveError(id);
489
- _droppedSaveIds.delete(id);
874
+ _commitSessionWrite(tmp, target, id);
875
+ _publishLandedWriteEpoch(opts, epoch);
876
+ _untrackSaveTmp(tmp);
877
+ if (mayMutate()) {
878
+ _queueSessionSummaryUpsert(session, summaryVersion);
879
+ _clearSaveStateIfCurrent(id, epoch);
880
+ }
490
881
  } finally {
491
882
  _releaseWriteCommit(commitControl);
492
883
  }
493
- _drainQueue(id);
494
- return true;
884
+ _releasePayloadIncarnation(payload);
885
+ _drainQueue(id, payload);
886
+ return SAVE_OUTCOME_SAVED;
495
887
  } catch (err) {
496
- try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
497
- _savePending.delete(id);
888
+ _discardSaveTmp(tmp);
889
+ if (mayMutate()) {
890
+ _recordSaveFailure(id, err, epoch, _snapshotFromAttempt(attempted, id));
891
+ // Only OUR slot may be cleared: after a delete the id's pending
892
+ // bookkeeping can already belong to a new incarnation.
893
+ const pending = _savePending.get(id);
894
+ if (pending && (pending.payload === payload || pending.queued === payload)) {
895
+ _releasePendingSlot(pending);
896
+ _savePending.delete(id);
897
+ }
898
+ }
899
+ _releasePayloadIncarnation(payload);
498
900
  throw err;
499
901
  }
500
902
  }
@@ -537,6 +939,9 @@ function _deleteHeartbeatUnlessNewer(id, options = {}) {
537
939
  }
538
940
 
539
941
  export function markSessionClosed(id, reason = 'manual', options = {}) {
942
+ // Only a fresh failure from THIS attempt may be observed by closeSession's
943
+ // durable-barrier check — a veto must not surface a stale error.
944
+ clearSessionLifecycleCommitError(id);
540
945
  // Caller-provided probes may re-enter the store, so evaluate them before
541
946
  // taking the non-reentrant Atomics commit lock. A veto must also precede
542
947
  // pending-save cancellation so debounce durability remains intact.
@@ -549,6 +954,20 @@ export function markSessionClosed(id, reason = 'manual', options = {}) {
549
954
  // best-effort race: the tombstone resurrection guard is the authoritative
550
955
  // post-race arbiter. Re-stat here, but never invoke caller code under lock.
551
956
  if (_heartbeatLivenessVeto(id, options)) return null;
957
+ // ── Durable authority, read under the commit lock, BEFORE any disruption ──
958
+ // The tombstone rewrites the canonical file, so the bytes it is derived
959
+ // from must be provably ours. A duplicate/ambiguous/foreign/identity-less
960
+ // record is NEVER re-serialized through a lenient parse or replaced from
961
+ // live memory: refuse the barrier and surface the cause, exactly like a
962
+ // failed commit (closeSession must not report a close that never fenced
963
+ // anything).
964
+ const authority = _readCanonicalRecord(sessionPath(id));
965
+ if (authority === LIFECYCLE_AMBIGUOUS || (authority && authority.id !== id)) {
966
+ const err = new Error(`[session-store] ${id}: refusing to close — canonical record is unreadable or foreign`);
967
+ err.code = 'ELIFECYCLEUNREADABLE';
968
+ _recordLifecycleCommitFailure(id, err, reason);
969
+ return null;
970
+ }
552
971
  // Only a committed close may disrupt pending persistence.
553
972
  _clearDebounce(id);
554
973
  _cancelSessionWrites(id);
@@ -564,16 +983,14 @@ export function markSessionClosed(id, reason = 'manual', options = {}) {
564
983
  // reclaim it.
565
984
  //
566
985
  // The alreadyClosed / original-close-time / generation decision MUST come
567
- // from the ON-DISK JSON, read cache-bypassing — NOT from loadSession(),
986
+ // from the ON-DISK record, read cache-bypassing — NOT from loadSession(),
568
987
  // which can serve a stale in-memory OPEN payload (a pending debounced save
569
988
  // or a _liveSessions entry) after a late save. Deciding off that stale open
570
989
  // copy would make a re-close of an already-tombstoned session look like a
571
990
  // FIRST close and reset updatedAt+generation, resurrecting the exact
572
991
  // immortality refresh this guard prevents. The disk file is the
573
992
  // authoritative tombstone state.
574
- let onDisk = null;
575
- try { onDisk = JSON.parse(readFileSync(sessionPath(id), 'utf-8')); }
576
- catch { onDisk = null; }
993
+ const onDisk = authority ? authority.doc : null;
577
994
  const alreadyClosed = onDisk
578
995
  ? (onDisk.closed === true || onDisk.status === 'closed')
579
996
  : (existing.closed === true);
@@ -588,12 +1005,20 @@ export function markSessionClosed(id, reason = 'manual', options = {}) {
588
1005
  const tombstone = { ...base, closed: true, closedReason: alreadyClosed ? (base.closedReason || reason) : reason, status: 'closed', generation: newGen, updatedAt: closeTime };
589
1006
  // Bypass the queue + guard — this IS the tombstone write.
590
1007
  const target = sessionPath(id);
591
- const tmp = target + '.' + randomBytes(6).toString('hex') + '.tmp';
1008
+ const tmp = _trackSaveTmp(target + '.' + randomBytes(6).toString('hex') + '.tmp');
592
1009
  try {
593
1010
  writeFileSync(tmp, JSON.stringify(_sessionForDisk(tombstone)), 'utf-8');
594
- _renameWithRetrySync(tmp, target);
595
- } catch {
596
- try { unlinkSync(tmp); } catch { /* ignore */ }
1011
+ _commitSessionWrite(tmp, target, id);
1012
+ _untrackSaveTmp(tmp);
1013
+ } catch (err) {
1014
+ // The durable lifecycle barrier did NOT land: no tombstone, no
1015
+ // generation bump, nothing fencing a late save. Same contract as a
1016
+ // failed session save — reclaim the scratch file, pin the live
1017
+ // snapshot (non-evictable, not shadowed by the stale disk copy) and
1018
+ // publish the cause so closeSession refuses to report success.
1019
+ _discardSaveTmp(tmp);
1020
+ _recordSaveFailure(id, err, null, existing);
1021
+ _recordLifecycleCommitFailure(id, err, reason);
597
1022
  return null;
598
1023
  }
599
1024
  _savePending.delete(id);
@@ -647,29 +1072,62 @@ export function markSessionClosed(id, reason = 'manual', options = {}) {
647
1072
  * Returns the new generation, or null if the session file doesn't exist.
648
1073
  */
649
1074
  export function bumpSessionGeneration(id, reason = 'detach') {
650
- _clearDebounce(id);
651
- _cancelSessionWrites(id);
652
- _uncacheSessionSummary(id);
653
- const existing = loadSession(id);
654
- if (!existing) return null;
655
- const newGen = (typeof existing.generation === 'number' ? existing.generation : 0) + 1;
656
- const detached = { ...existing, generation: newGen, updatedAt: Date.now(), detachedReason: reason };
657
- const target = sessionPath(id);
658
- const tmp = target + '.' + randomBytes(6).toString('hex') + '.tmp';
1075
+ clearSessionLifecycleCommitError(id);
1076
+ // The detach barrier is a canonical write and MUST take the same commit
1077
+ // lock as markSessionClosed. Cancellation alone is not a barrier: a writer
1078
+ // that already passed its cancellation check can still be holding (or
1079
+ // about to take) the rename lock, and would then land AFTER the generation
1080
+ // bump — exactly the late-save clobber this function exists to prevent.
1081
+ // Holding the lock serialises it before us; taking it after cancellation
1082
+ // makes _acquireWriteCommit refuse it (it re-checks cancellation while
1083
+ // holding the lock).
1084
+ const detachGuard = _guardedSaveOptions(id);
1085
+ const commitControl = _acquireWriteCommit(detachGuard);
1086
+ if (commitControl === false) return null;
659
1087
  try {
660
- writeFileSync(tmp, JSON.stringify(_sessionForDisk(detached)), 'utf-8');
661
- _renameWithRetrySync(tmp, target);
662
- } catch {
663
- try { unlinkSync(tmp); } catch { /* ignore */ }
664
- return null;
1088
+ // Same durable authority as the tombstone barrier: the detach write
1089
+ // replaces the canonical file, so unreadable/ambiguous/foreign bytes
1090
+ // are refused (and surfaced) instead of rewritten from memory.
1091
+ const authority = _readCanonicalRecord(sessionPath(id));
1092
+ if (authority === LIFECYCLE_AMBIGUOUS || (authority && authority.id !== id)) {
1093
+ const err = new Error(`[session-store] ${id}: refusing to detach — canonical record is unreadable or foreign`);
1094
+ err.code = 'ELIFECYCLEUNREADABLE';
1095
+ _recordLifecycleCommitFailure(id, err, reason);
1096
+ return null;
1097
+ }
1098
+ // Only a VALIDATED barrier may disrupt pending persistence: on an
1099
+ // ambiguous/foreign refusal above the debounced save stays scheduled
1100
+ // and usable.
1101
+ _clearDebounce(id);
1102
+ _cancelSessionWrites(id);
1103
+ _uncacheSessionSummary(id);
1104
+ const existing = loadSession(id);
1105
+ if (!existing) return null;
1106
+ const newGen = (typeof existing.generation === 'number' ? existing.generation : 0) + 1;
1107
+ const detached = { ...existing, generation: newGen, updatedAt: Date.now(), detachedReason: reason };
1108
+ const target = sessionPath(id);
1109
+ const tmp = _trackSaveTmp(target + '.' + randomBytes(6).toString('hex') + '.tmp');
1110
+ try {
1111
+ writeFileSync(tmp, JSON.stringify(_sessionForDisk(detached)), 'utf-8');
1112
+ _commitSessionWrite(tmp, target, id);
1113
+ _untrackSaveTmp(tmp);
1114
+ } catch (err) {
1115
+ // Detach barrier failed — identical contract to the tombstone path.
1116
+ _discardSaveTmp(tmp);
1117
+ _recordSaveFailure(id, err, null, existing);
1118
+ _recordLifecycleCommitFailure(id, err, reason);
1119
+ return null;
1120
+ }
1121
+ _savePending.delete(id);
1122
+ clearSessionSaveError(id);
1123
+ _clearLiveSession(id);
1124
+ _deleteHeartbeat(id);
1125
+ _queueSessionSummaryUpsert(detached);
1126
+ _droppedSaveIds.delete(id);
1127
+ return newGen;
1128
+ } finally {
1129
+ _releaseWriteCommit(commitControl);
665
1130
  }
666
- _savePending.delete(id);
667
- clearSessionSaveError(id);
668
- _clearLiveSession(id);
669
- _deleteHeartbeat(id);
670
- _queueSessionSummaryUpsert(detached);
671
- _droppedSaveIds.delete(id);
672
- return newGen;
673
1131
  }
674
1132
 
675
1133
  export function loadSession(id) {
@@ -678,12 +1136,32 @@ export function loadSession(id) {
678
1136
  // fresher in-memory state is allowed to shadow it. Unchanged atomic files
679
1137
  // reuse their recent parsed object instead of reparsing the full transcript.
680
1138
  const disk = _readStoredSessionCached(id, path);
681
- if (disk.exists && !disk.session) return null;
682
- const stored = disk.session;
683
1139
  // Read-your-writes: if a save is pending (debouncing, scheduled, or queued
684
1140
  // behind an in-flight write) return that payload instead of stale disk state.
685
1141
  // The most-recently-queued slot is checked first (queued > payload).
686
1142
  const pending = _savePending.get(id);
1143
+ if (disk.exists && !disk.session) {
1144
+ // An existing-but-unreadable file OWNS the identity: an externally
1145
+ // corrupted, foreign, ambiguous or half-written file from another
1146
+ // writer is REPORTED (null), never masked by whatever this process
1147
+ // happens to hold in memory.
1148
+ //
1149
+ // Exactly ONE exception, and it is evidence-based rather than
1150
+ // state-based: the snapshot whose OWN write to this path failed in
1151
+ // this process (getFailedSaveSnapshot). That failure proves the bytes
1152
+ // never landed, so this copy is strictly newer than the file and is
1153
+ // the only good same-process copy — hiding it would turn a surfaced
1154
+ // save failure into silent session loss for every public reader.
1155
+ // A generic pending payload / _liveSessions entry proves nothing about
1156
+ // disk and may NOT stand in: unrelated corruption stays visible.
1157
+ // Lifecycle and pending-ownership authorities do not come through
1158
+ // here at all (they read the durable record directly), so they keep
1159
+ // seeing 'unreadable' and failing closed.
1160
+ const recovered = getFailedSaveSnapshot(id);
1161
+ if (recovered?.id === id) return _ensureLifecycleFields(recovered);
1162
+ return null;
1163
+ }
1164
+ const stored = disk.session;
687
1165
  if (pending) {
688
1166
  const inMemory = (pending.queued || pending.payload)?.session;
689
1167
  if (inMemory?.id === id) return _ensureLifecycleFields(inMemory);
@@ -722,29 +1200,76 @@ export function deleteSession(id, options = {}) {
722
1200
  // Cross-process revival after full-TTL silence remains best-effort; the
723
1201
  // tombstone resurrection guard is authoritative. Only re-stat .hb here.
724
1202
  if (_heartbeatLivenessVeto(id, options)) return false;
725
- _cancelSessionWrites(id);
726
- _clearDebounce(id);
727
- _savePending.delete(id);
1203
+ // ── 1. unlink FIRST, still holding the commit lock ─────────────────────
1204
+ // A failed unlink is NOT a delete: the canonical bytes are still there, so
1205
+ // nothing may be purged, cancelled or closed — every pending write, parked
1206
+ // snapshot and metrics timer stays usable and persistable.
728
1207
  const path = sessionPath(id);
729
- let removed = false;
730
- if (existsSync(path)) {
731
- try {
732
- unlinkSync(path);
733
- removed = true;
1208
+ const probe = probePath(path);
1209
+ // An unreadable probe is NOT absence: purging markers/pending state while
1210
+ // the canonical bytes survive would strand a live session behind a file
1211
+ // nobody owns any more. Nothing was deleted, so report exactly that.
1212
+ if (probe.state !== PROBE_PRESENT && probe.state !== PROBE_ABSENT) return false;
1213
+ // CONTRACT: an ABSENT canonical record is a SUCCESSFUL (idempotent)
1214
+ // delete — the session does not exist once this call returns, so the id's
1215
+ // local state is purged and `true` is reported. `false` is reserved for
1216
+ // "nothing was deleted AND nothing was mutated": a liveness veto, a
1217
+ // contended commit lock, an unreadable probe, ambiguous/foreign bytes, or
1218
+ // a failed unlink.
1219
+ let removed = false; // diagnostics only: whether canonical bytes were unlinked
1220
+ if (probe.state === PROBE_PRESENT) {
1221
+ // STRICT OWNERSHIP AT THE COMMIT EDGE. The bytes are re-read here,
1222
+ // under the same commit lock, immediately before the unlink — not
1223
+ // inferred from the earlier stat, and not from any cache: between the
1224
+ // probe and this read another process can rename a different session's
1225
+ // record (or a torn write) onto this path, and an unlink is
1226
+ // irreversible. Ambiguous/foreign bytes are therefore never deleted.
1227
+ const record = _readCanonicalRecord(path);
1228
+ if (record === LIFECYCLE_AMBIGUOUS) return false;
1229
+ if (record !== null) {
1230
+ if (record.id !== id) return false;
1231
+ try {
1232
+ unlinkSync(path);
1233
+ removed = true;
1234
+ } catch {
1235
+ return false; // canonical file survives → nothing was deleted
1236
+ }
734
1237
  }
735
- catch { /* fall through to .hb cleanup */ }
1238
+ // record === null: the file vanished inside this window — fall through
1239
+ // to the absent contract (nothing removed, local state still purged).
736
1240
  }
1241
+ // ── 2. finalize UNDER THE SAME LOCK ────────────────────────────────────
1242
+ // The file is gone. Cancellation + purge happen before the lock is
1243
+ // released, so a writer waiting for the lock is refused by its own
1244
+ // cancellation re-check instead of renaming the file back, and no parked
1245
+ // snapshot, timer or deferred entry survives to resurrect it.
1246
+ // Retire the id's incarnation: every write, queued payload and parked
1247
+ // snapshot stamped BEFORE this point becomes permanently non-current, so
1248
+ // its settlement moves no marker and touches no bookkeeping — and a
1249
+ // re-created id gets a brand new incarnation of its own.
1250
+ _retireSessionIncarnation(id);
1251
+ _cancelSessionWrites(id);
1252
+ _clearDebounce(id);
1253
+ _releasePendingSlot(_savePending.get(id));
1254
+ _savePending.delete(id);
1255
+ _purgeSessionSaveBookkeeping(id);
1256
+ _runSessionPurgeHooks(id);
737
1257
  // Preserve a sidecar published strictly after the sweep's scan snapshot.
738
1258
  _deleteHeartbeatUnlessNewer(id, options);
739
1259
  deleteSessionPresence(id);
740
1260
  _clearLiveSession(id);
741
- if (removed || !existsSync(path)) clearSessionSaveError(id);
1261
+ // The file is gone: every marker of the retired incarnation goes with it
1262
+ // (save error + failed-snapshot evidence, split-brain drop flag, lifecycle
1263
+ // commit error), so a re-created id starts clean.
1264
+ _clearSessionSaveState(id);
1265
+ clearSessionLifecycleCommitError(id);
742
1266
  // deferSummaryUpdate: bulk callers (tombstone sweep) remove thousands of
743
1267
  // rows — a per-id _removeSessionSummary would parse+rewrite the multi-MB
744
1268
  // summary index once PER DELETION. They batch the index update themselves.
745
1269
  if (options.deferSummaryUpdate === true) _uncacheSessionSummary(id);
746
1270
  else _queueSessionSummaryRemoval(id);
747
- return removed;
1271
+ // Both PRESENT-and-unlinked and ABSENT reach here: the session is gone.
1272
+ return true;
748
1273
  } finally {
749
1274
  _releaseWriteCommit(commitControl);
750
1275
  }