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.
- package/package.json +13 -7
- package/scripts/tool-failures.mjs +60 -24
- package/src/agents/heavy-worker/AGENT.md +3 -4
- package/src/agents/worker/AGENT.md +1 -2
- package/src/cli.mjs +8 -0
- package/src/defaults/cycle3-review-prompt.md +4 -4
- package/src/rules/agent/00-core.md +3 -0
- package/src/rules/agent/42-cycle3-agent.md +5 -6
- package/src/rules/lead/01-general.md +2 -0
- package/src/rules/shared/01-tool.md +29 -35
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +3 -51
- package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +59 -0
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -2
- package/src/runtime/agent/orchestrator/agent-runtime/title-completion.mjs +67 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +57 -4
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +2 -3
- package/src/runtime/agent/orchestrator/config.mjs +29 -11
- package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
- package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
- package/src/runtime/agent/orchestrator/mcp/client.mjs +0 -24
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +3 -3
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +25 -17
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +321 -22
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -22
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +28 -23
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +10 -2
- package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +0 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +0 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +2 -5
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +27 -0
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +23 -0
- package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +329 -0
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +23 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +107 -20
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +4 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +124 -12
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +19 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +110 -75
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +96 -113
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +160 -11
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +18 -1
- package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +147 -117
- package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +88 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +50 -8
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +47 -6
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +485 -24
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +100 -2
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +80 -14
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +142 -2
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +128 -32
- package/src/runtime/agent/orchestrator/session/manager.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +77 -4
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +70 -39
- package/src/runtime/agent/orchestrator/session/store/fs-probe.mjs +91 -0
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +141 -64
- package/src/runtime/agent/orchestrator/session/store/live-state.mjs +253 -0
- package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +211 -28
- package/src/runtime/agent/orchestrator/session/store/save-fault.mjs +313 -0
- package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +622 -53
- package/src/runtime/agent/orchestrator/session/store/serialize.mjs +43 -10
- package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +53 -8
- package/src/runtime/agent/orchestrator/session/store/summary-rebuild-worker.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +113 -9
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +476 -52
- package/src/runtime/agent/orchestrator/session/store.mjs +721 -196
- package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -4
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +4 -6
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +146 -90
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +6 -0
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +93 -17
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +7 -7
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +17 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +85 -5
- package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +52 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +54 -51
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -15
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +132 -8
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +86 -3
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +210 -38
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +23 -1
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +87 -19
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -8
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -2
- package/src/runtime/media/lanes.mjs +2 -2
- package/src/runtime/media/renditions.mjs +101 -17
- package/src/runtime/media/renditions.test.mjs +54 -0
- package/src/runtime/media/store.mjs +111 -43
- package/src/runtime/media/store.test.mjs +53 -11
- package/src/runtime/memory/index.mjs +26 -50
- package/src/runtime/memory/lib/core-memory-candidates.mjs +2 -7
- package/src/runtime/memory/lib/core-memory-store.mjs +2 -9
- package/src/runtime/memory/lib/cycle-scheduler.mjs +10 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +3 -3
- package/src/runtime/memory/lib/embedding-worker.mjs +60 -15
- package/src/runtime/memory/lib/http-router.mjs +4 -0
- package/src/runtime/memory/lib/ko-morph.mjs +49 -6
- package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -18
- package/src/runtime/memory/lib/memory-cycle2.mjs +3 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +1 -3
- package/src/runtime/memory/lib/memory-recall-store.mjs +24 -0
- package/src/runtime/memory/lib/query-handlers.mjs +26 -28
- package/src/runtime/memory/tool-defs.mjs +3 -3
- package/src/runtime/shared/atomic-file.mjs +5 -1
- package/src/runtime/shared/child-guardian.mjs +73 -2
- package/src/runtime/shared/provider-api-key.mjs +22 -5
- package/src/runtime/shared/tool-execution-contract.mjs +53 -0
- package/src/session-runtime/config-helpers.mjs +6 -5
- package/src/session-runtime/lifecycle-api.mjs +16 -4
- package/src/session-runtime/media-api.mjs +31 -16
- package/src/session-runtime/prewarm.mjs +2 -24
- package/src/session-runtime/runtime-core.mjs +33 -15
- package/src/session-runtime/runtime-tunables.mjs +0 -3
- package/src/session-runtime/session-lifecycle.mjs +0 -6
- package/src/session-runtime/session-title.mjs +228 -0
- package/src/session-runtime/session-turn-api.mjs +11 -7
- package/src/session-runtime/workflow-agents-api.mjs +16 -0
- package/src/session-runtime/workflow.mjs +7 -5
- package/src/standalone/agent-tool/job-views.mjs +90 -36
- package/src/standalone/agent-tool/spawn-flow.mjs +25 -216
- package/src/standalone/agent-tool/worker-index.mjs +8 -0
- package/src/standalone/agent-tool/worker-rows.mjs +2 -1
- package/src/standalone/agent-tool.mjs +17 -17
- package/src/standalone/agent-watchdog-registry.mjs +19 -2
- package/src/standalone/channel-daemon.mjs +8 -0
- package/src/standalone/explore-tool.mjs +1 -1
- package/src/standalone/memory-runtime-proxy.mjs +4 -7
- package/src/standalone/projects.mjs +4 -1
- package/src/standalone/usage-dashboard.mjs +25 -3
- package/src/tui/App.jsx +5 -3
- package/src/tui/app/resume-picker.mjs +7 -2
- package/src/tui/app/route-pickers.mjs +11 -117
- package/src/tui/app/slash-dispatch.mjs +13 -3
- package/src/tui/app/transcript-row-estimate.mjs +1 -1
- package/src/tui/app/transcript-window.mjs +17 -93
- package/src/tui/app/use-transcript-scroll.mjs +32 -2
- package/src/tui/app/use-transcript-window.mjs +64 -114
- package/src/tui/components/Markdown.jsx +19 -2
- package/src/tui/components/Message.jsx +8 -3
- package/src/tui/components/ToolExecution.jsx +7 -3
- package/src/tui/dist/index.mjs +243 -223
- package/src/tui/engine/live-share.mjs +15 -5
- package/src/tui/engine/render-timing.mjs +2 -2
- package/src/tui/engine/session-api-ext.mjs +74 -4
- package/src/tui/engine/session-flow.mjs +3 -1
- package/src/tui/engine.mjs +2 -2
- package/src/tui/hooks/useSharedTick.mjs +0 -2
- package/src/tui/index.jsx +5 -5
- package/src/ui/statusline-agents.mjs +44 -11
- package/src/vendor/statusline/bin/statusline-route.mjs +26 -3
- package/src/workflows/default/WORKFLOW.md +3 -3
- package/src/workflows/solo/WORKFLOW.md +5 -3
- package/src/runtime/media/index.mjs +0 -18
- package/src/runtime/memory/lib/embedding-warmup.mjs +0 -68
- package/src/standalone/agent-shard/shard-child.mjs +0 -300
- 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 {
|
|
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 {
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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.
|
|
189
|
-
*
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 (
|
|
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((
|
|
224
|
-
if (
|
|
369
|
+
_doSave(payload).then((outcome) => {
|
|
370
|
+
if (outcome !== SAVE_OUTCOME_SAVED) _rollbackCachedSessionSummary(id, summaryVersion);
|
|
225
371
|
}).catch(err => {
|
|
226
372
|
_rollbackCachedSessionSummary(id, summaryVersion);
|
|
227
|
-
|
|
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((
|
|
271
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
480
|
+
attempted = JSON.stringify(_sessionForDisk(session));
|
|
481
|
+
writeFileSync(tmp, attempted, 'utf-8');
|
|
297
482
|
if (_shouldDrop(id, opts)) {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
return
|
|
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
|
-
|
|
503
|
+
_discardSaveTmp(tmp);
|
|
305
504
|
_releaseWriteCommit(commitControl);
|
|
306
|
-
|
|
307
|
-
return
|
|
505
|
+
if (mayMutate()) _recordSaveDrop(id, epoch);
|
|
506
|
+
return SAVE_OUTCOME_DROPPED;
|
|
308
507
|
}
|
|
309
508
|
try {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
|
519
|
+
return SAVE_OUTCOME_SAVED;
|
|
318
520
|
} catch (err) {
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
380
|
-
//
|
|
381
|
-
for
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
397
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
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
|
|
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((
|
|
442
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
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
|
-
|
|
851
|
+
_discardSaveTmp(tmp);
|
|
472
852
|
process.stderr.write(`[session-store] ${id}: dropped stale save (tombstone planted during write)\n`);
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
866
|
+
_discardSaveTmp(tmp);
|
|
480
867
|
_releaseWriteCommit(commitControl);
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
868
|
+
if (mayMutate()) _recordSaveDrop(id, epoch);
|
|
869
|
+
_releasePayloadIncarnation(payload);
|
|
870
|
+
_drainQueue(id, payload);
|
|
871
|
+
return SAVE_OUTCOME_DROPPED;
|
|
484
872
|
}
|
|
485
873
|
try {
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
494
|
-
|
|
884
|
+
_releasePayloadIncarnation(payload);
|
|
885
|
+
_drainQueue(id, payload);
|
|
886
|
+
return SAVE_OUTCOME_SAVED;
|
|
495
887
|
} catch (err) {
|
|
496
|
-
|
|
497
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|