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
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
// from store.mjs, which keeps the persistence half (save/load/close/delete).
|
|
3
3
|
// The two halves share the in-flight save map so an unpersisted session still
|
|
4
4
|
// shows up in listings; the cycle is import-only (calls happen at runtime).
|
|
5
|
-
import {
|
|
5
|
+
import { readFileSync, readdirSync, statSync, unlinkSync } from 'fs';
|
|
6
6
|
import { join } from 'path';
|
|
7
|
-
import {
|
|
7
|
+
import { Worker } from 'worker_threads';
|
|
8
|
+
import { getPluginData, loadConfig } from '../../config.mjs';
|
|
8
9
|
import { isAgentOwner } from '../../agent-owner.mjs';
|
|
9
|
-
import {
|
|
10
|
+
import { readTopLevelLifecycleRecord, isLifecycleUnreadable } from '../lifecycle-scan.mjs';
|
|
10
11
|
import { resolveAgentTerminalReapMs } from '../../../../../session-runtime/config-helpers.mjs';
|
|
11
12
|
import { getStoreDir, sessionPath } from './paths-heartbeat.mjs';
|
|
13
|
+
import { probePath, PROBE_PRESENT, PROBE_ABSENT } from './fs-probe.mjs';
|
|
12
14
|
import { isCancelledWrite as _isCancelledWrite } from './write-guards.mjs';
|
|
13
15
|
import {
|
|
14
16
|
SESSION_SUMMARY_INDEX_VERSION,
|
|
@@ -30,12 +32,14 @@ import {
|
|
|
30
32
|
_summaryRowsCache,
|
|
31
33
|
} from './summary-cache.mjs';
|
|
32
34
|
import { _saveAsyncQueued, _saveWorkerPending } from './save-worker.mjs';
|
|
33
|
-
import { _ensureLifecycleFields, _storedSessionFromFile } from './serialize.mjs';
|
|
35
|
+
import { STORED_SESSION_UNREADABLE, _ensureLifecycleFields, _storedSessionFromFile } from './serialize.mjs';
|
|
34
36
|
import { _savePending, deleteSession, markSessionClosed } from '../store.mjs';
|
|
35
37
|
|
|
36
38
|
// Disk mtime of the summary index when the in-memory cache was last refreshed
|
|
37
39
|
// from it — the cross-process staleness detector.
|
|
38
40
|
let _summaryIndexMtimeSeen = 0;
|
|
41
|
+
let _summaryRebuildWorker = null;
|
|
42
|
+
let _summaryRebuildDataDir = '';
|
|
39
43
|
|
|
40
44
|
const DEFAULT_SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes idle — aligned with Anthropic 5m messages tier and OpenAI in-memory cache window
|
|
41
45
|
const AGENT_TERMINAL_STATUSES = new Set(['idle', 'done', 'error']);
|
|
@@ -63,8 +67,10 @@ const BLANK_SCRATCH_MAX_AGE_MS = 60 * 60 * 1000; // 1h
|
|
|
63
67
|
|
|
64
68
|
export function listStoredSessions(options = {}) {
|
|
65
69
|
const dir = getStoreDir();
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
// Only ENOENT/ENOTDIR is absence. An unreadable directory (EACCES/EIO) is
|
|
71
|
+
// NOT "no sessions": listing yields nothing, but nothing may be treated as
|
|
72
|
+
// deleted/absent on that basis either.
|
|
73
|
+
if (probePath(dir).state !== PROBE_PRESENT) return [];
|
|
68
74
|
const files = readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
69
75
|
const sessionsById = new Map();
|
|
70
76
|
const invalidStorageIds = options._invalidStorageIds instanceof Set
|
|
@@ -72,10 +78,12 @@ export function listStoredSessions(options = {}) {
|
|
|
72
78
|
: new Set();
|
|
73
79
|
for (const f of files) {
|
|
74
80
|
const session = _storedSessionFromFile(dir, f);
|
|
75
|
-
if (session) {
|
|
81
|
+
if (session && session !== STORED_SESSION_UNREADABLE) {
|
|
76
82
|
sessionsById.set(session.id, session);
|
|
77
83
|
continue;
|
|
78
84
|
}
|
|
85
|
+
// Present-but-unreadable owns its identity too: it may never be
|
|
86
|
+
// replaced by a live/pending overlay, so it is marked invalid.
|
|
79
87
|
const storageId = f.slice(0, -5);
|
|
80
88
|
if (/^[A-Za-z0-9_-]+$/.test(storageId)) invalidStorageIds.add(storageId);
|
|
81
89
|
}
|
|
@@ -128,20 +136,40 @@ function _overlayUnpersistedSummaryRows(rows, invalidStorageIds = new Set()) {
|
|
|
128
136
|
return [...byId.values()].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
129
137
|
}
|
|
130
138
|
|
|
131
|
-
function
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
try {
|
|
139
|
-
|
|
139
|
+
function scheduleSessionSummaryIndexRebuild() {
|
|
140
|
+
const dataDir = getPluginData();
|
|
141
|
+
if (_summaryRebuildWorker && _summaryRebuildDataDir === dataDir) return;
|
|
142
|
+
if (_summaryRebuildWorker) {
|
|
143
|
+
try { void _summaryRebuildWorker.terminate(); } catch { /* stale worker exits independently */ }
|
|
144
|
+
}
|
|
145
|
+
let worker;
|
|
146
|
+
try {
|
|
147
|
+
worker = new Worker(new URL('./summary-rebuild-worker.mjs', import.meta.url), {
|
|
148
|
+
execArgv: [],
|
|
149
|
+
workerData: { dataDir },
|
|
150
|
+
});
|
|
151
|
+
} catch {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
_summaryRebuildWorker = worker;
|
|
155
|
+
_summaryRebuildDataDir = dataDir;
|
|
156
|
+
const clear = () => {
|
|
157
|
+
if (_summaryRebuildWorker !== worker) return;
|
|
158
|
+
_summaryRebuildWorker = null;
|
|
159
|
+
_summaryRebuildDataDir = '';
|
|
160
|
+
};
|
|
161
|
+
worker.on('message', (message) => {
|
|
162
|
+
if (message?.ok !== true || message.dataDir !== getPluginData() || !Array.isArray(message.rows)) return;
|
|
163
|
+
try { _summaryIndexMtimeSeen = statSync(summaryIndexPath()).mtimeMs || 0; } catch { /* stat only */ }
|
|
164
|
+
_setSummaryRowsCache(message.rows);
|
|
165
|
+
});
|
|
166
|
+
worker.on('error', clear);
|
|
167
|
+
worker.on('exit', clear);
|
|
168
|
+
worker.unref();
|
|
140
169
|
}
|
|
141
170
|
|
|
142
171
|
export function listStoredSessionSummaries(options = {}) {
|
|
143
172
|
_ensureSummaryCacheDataDir();
|
|
144
|
-
const rebuildIfMissing = options.rebuildIfMissing !== false;
|
|
145
173
|
// This is intentionally the only path that rescans every session JSON:
|
|
146
174
|
// callers use it as an on-demand authoritative refresh (including resume
|
|
147
175
|
// authorization), so it must not trust either the cache or sidecar.
|
|
@@ -195,11 +223,26 @@ export function listStoredSessionSummaries(options = {}) {
|
|
|
195
223
|
let indexedRows = [];
|
|
196
224
|
let p;
|
|
197
225
|
let hasIndex = false;
|
|
226
|
+
let indexUnreadable = false;
|
|
198
227
|
try {
|
|
199
228
|
p = summaryIndexPath();
|
|
200
|
-
|
|
229
|
+
const probe = probePath(p);
|
|
230
|
+
hasIndex = probe.state === PROBE_PRESENT;
|
|
231
|
+
if (probe.state !== PROBE_PRESENT && probe.state !== PROBE_ABSENT) indexUnreadable = true;
|
|
201
232
|
if (hasIndex) {
|
|
202
|
-
|
|
233
|
+
let text;
|
|
234
|
+
try {
|
|
235
|
+
text = readFileSync(p, 'utf-8');
|
|
236
|
+
} catch (err) {
|
|
237
|
+
// Stat said PRESENT and the read failed: the sidecar is there
|
|
238
|
+
// and we cannot have it. FAIL CLOSED — keep serving the cache,
|
|
239
|
+
// never emit empty rows and never schedule a rebuild that
|
|
240
|
+
// would overwrite a sidecar we could not read.
|
|
241
|
+
const code = err?.code || 'EUNKNOWN';
|
|
242
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR') indexUnreadable = true;
|
|
243
|
+
throw err;
|
|
244
|
+
}
|
|
245
|
+
const raw = JSON.parse(text);
|
|
203
246
|
hasIndex = Number(raw?.version) === SESSION_SUMMARY_INDEX_VERSION;
|
|
204
247
|
if (hasIndex) indexedRows = _normalizeSummaryIndex(raw).rows;
|
|
205
248
|
if (hasIndex) {
|
|
@@ -208,18 +251,27 @@ export function listStoredSessionSummaries(options = {}) {
|
|
|
208
251
|
}
|
|
209
252
|
} catch { /* unreadable/malformed sidecar falls through to rebuild */ }
|
|
210
253
|
|
|
254
|
+
if (indexUnreadable) {
|
|
255
|
+
// Present-but-unreadable sidecar: retain authority. A COLD cache has
|
|
256
|
+
// no rows yet (null) — return an empty result rather than crashing on
|
|
257
|
+
// `.slice()`, and never rebuild/replace the sidecar we could not read.
|
|
258
|
+
const cached = _cachedSummaryRows();
|
|
259
|
+
return Array.isArray(cached) ? cached.slice() : [];
|
|
260
|
+
}
|
|
211
261
|
if (!p || !hasIndex) {
|
|
212
|
-
|
|
213
|
-
|
|
262
|
+
scheduleSessionSummaryIndexRebuild();
|
|
263
|
+
return _setSummaryRowsCache(indexedRows);
|
|
214
264
|
}
|
|
215
265
|
try {
|
|
216
266
|
if (indexedRows.length > 0) return _setSummaryRowsCache(indexedRows);
|
|
217
267
|
const dir = getStoreDir();
|
|
218
|
-
const hasSessionFiles =
|
|
219
|
-
|
|
268
|
+
const hasSessionFiles = probePath(dir).state === PROBE_PRESENT
|
|
269
|
+
&& readdirSync(dir).some((f) => f.endsWith('.json'));
|
|
270
|
+
if (hasSessionFiles) scheduleSessionSummaryIndexRebuild();
|
|
271
|
+
return _setSummaryRowsCache(indexedRows);
|
|
220
272
|
} catch {
|
|
221
|
-
|
|
222
|
-
|
|
273
|
+
scheduleSessionSummaryIndexRebuild();
|
|
274
|
+
return _setSummaryRowsCache(indexedRows);
|
|
223
275
|
}
|
|
224
276
|
}
|
|
225
277
|
|
|
@@ -230,12 +282,12 @@ export function listStoredSessionSummaries(options = {}) {
|
|
|
230
282
|
*/
|
|
231
283
|
export function getStoredSessionsRaw() {
|
|
232
284
|
const dir = getStoreDir();
|
|
233
|
-
if (
|
|
285
|
+
if (probePath(dir).state !== PROBE_PRESENT) return [];
|
|
234
286
|
const files = readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
235
287
|
const sessions = [];
|
|
236
288
|
for (const f of files) {
|
|
237
289
|
const session = _storedSessionFromFile(dir, f, false);
|
|
238
|
-
if (session) sessions.push(session);
|
|
290
|
+
if (session && session !== STORED_SESSION_UNREADABLE) sessions.push(session);
|
|
239
291
|
}
|
|
240
292
|
return sessions;
|
|
241
293
|
}
|
|
@@ -265,7 +317,9 @@ function* sweepStaleSessionSteps(ttlMs, options = {}) {
|
|
|
265
317
|
const openMaxAgeMs = Number.isFinite(_optAge) && _optAge > 0 ? _optAge : RESUMABLE_OPEN_MAX_AGE_MS;
|
|
266
318
|
const openMaxCount = Number.isFinite(_optCount) && _optCount >= 0 ? _optCount : RESUMABLE_OPEN_MAX_COUNT;
|
|
267
319
|
const dir = getStoreDir();
|
|
268
|
-
|
|
320
|
+
// An unreadable store dir is not an empty one: sweeping (deleting,
|
|
321
|
+
// closing, pruning) on a probe we could not make is never allowed.
|
|
322
|
+
if (probePath(dir).state !== PROBE_PRESENT)
|
|
269
323
|
return { cleaned: 0, remaining: 0, details: [], tombstonesCleaned: 0, tombstoneDetails: [], tombstoneErrors: [] };
|
|
270
324
|
// Reconcile the index-derived candidate set with a direct directory scan:
|
|
271
325
|
// the summary index is a best-effort sidecar that can lag far behind disk
|
|
@@ -308,17 +362,28 @@ function* sweepStaleSessionSteps(ttlMs, options = {}) {
|
|
|
308
362
|
try {
|
|
309
363
|
if (!row?.id) continue;
|
|
310
364
|
const jsonPath = sessionPath(row.id);
|
|
311
|
-
|
|
365
|
+
const jsonProbe = probePath(jsonPath);
|
|
366
|
+
// ONLY a proven-absent record may retire its summary row. An
|
|
367
|
+
// EACCES/EIO probe means the file is very likely still there:
|
|
368
|
+
// queueing a removal would delete the row of a live session.
|
|
369
|
+
if (jsonProbe.state === PROBE_ABSENT) {
|
|
312
370
|
_queueSessionSummaryRemoval(row.id);
|
|
313
371
|
continue;
|
|
314
372
|
}
|
|
315
|
-
|
|
373
|
+
if (jsonProbe.state !== PROBE_PRESENT) {
|
|
374
|
+
remaining++;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
let jsonMtime = jsonProbe.mtimeMs || 0;
|
|
316
378
|
let heartbeatMtime = 0;
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
379
|
+
const hbProbe = probePath(join(dir, `${row.id}.hb`));
|
|
380
|
+
if (hbProbe.state === PROBE_PRESENT) heartbeatMtime = hbProbe.mtimeMs || 0;
|
|
381
|
+
else if (hbProbe.state !== PROBE_ABSENT) {
|
|
382
|
+
// Liveness sidecar unreadable: this record's freshness cannot
|
|
383
|
+
// be judged, so it is not a sweep candidate at all.
|
|
384
|
+
remaining++;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
322
387
|
// Truth source: the summary index is a deferred/best-effort sidecar,
|
|
323
388
|
// so a row can still claim status='idle'/open while the session JSON
|
|
324
389
|
// was already tombstoned. Read the real session JSON BEFORE the
|
|
@@ -329,25 +394,35 @@ function* sweepStaleSessionSteps(ttlMs, options = {}) {
|
|
|
329
394
|
let raw = null;
|
|
330
395
|
try { raw = readFileSync(jsonPath, 'utf-8'); }
|
|
331
396
|
catch { /* racing unlink / transient read failure */ }
|
|
332
|
-
|
|
333
|
-
|
|
397
|
+
// NOTHING ambiguous is ever swept. This loop deletes files, plants
|
|
398
|
+
// tombstones and rewrites summary rows, so a record it cannot read
|
|
399
|
+
// authoritatively (read fault, malformed JSON, duplicate top-level
|
|
400
|
+
// id/closed/generation) is left EXACTLY as it is — not deleted, not
|
|
401
|
+
// closed, not repaired, and never resolved by a last-wins
|
|
402
|
+
// JSON.parse fallback or by the best-effort summary row (which may
|
|
403
|
+
// describe a completely different generation of this id).
|
|
334
404
|
if (raw == null) {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
if (scan && typeof scan.closed === 'boolean') {
|
|
343
|
-
diskClosed = scan.closed;
|
|
344
|
-
} else {
|
|
345
|
-
try { actual = JSON.parse(raw); } catch { actual = null; }
|
|
346
|
-
diskClosed = actual
|
|
347
|
-
? (actual.closed === true || actual.status === 'closed')
|
|
348
|
-
: (row.closed === true || row.status === 'closed');
|
|
349
|
-
}
|
|
405
|
+
remaining++;
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const record = readTopLevelLifecycleRecord(raw);
|
|
409
|
+
if (isLifecycleUnreadable(record)) {
|
|
410
|
+
remaining++;
|
|
411
|
+
continue;
|
|
350
412
|
}
|
|
413
|
+
// OWNERSHIP: the record must name the id this row/file claims.
|
|
414
|
+
// A foreign record (including a foreign TOMBSTONE that would look
|
|
415
|
+
// perfectly mature) is preserved untouched — never deleted, never
|
|
416
|
+
// closed, never upserted into the summary index under the wrong id.
|
|
417
|
+
if (record.id !== row.id) {
|
|
418
|
+
remaining++;
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
// One strict parse for the whole record: every decision below reads
|
|
422
|
+
// the SAME authoritative document (owner/status/timestamps/
|
|
423
|
+
// messages), so there is no second, divergent parse of this file.
|
|
424
|
+
const actual = record.doc;
|
|
425
|
+
const diskClosed = record.closed === true || actual.status === 'closed';
|
|
351
426
|
if (diskClosed) {
|
|
352
427
|
// A shared store can be tombstoned by another process while
|
|
353
428
|
// this process still owns an in-flight controller for the same
|
|
@@ -363,7 +438,6 @@ function* sweepStaleSessionSteps(ttlMs, options = {}) {
|
|
|
363
438
|
// perpetually "fresh" and never mature. Maturity is governed ONLY
|
|
364
439
|
// by the ORIGINAL close time (disk updatedAt, not row.updatedAt
|
|
365
440
|
// which a stale row may carry from before the close).
|
|
366
|
-
if (!actual && raw != null) { try { actual = JSON.parse(raw); } catch { actual = null; } }
|
|
367
441
|
const closedAt = Number(actual?.updatedAt ?? row.updatedAt);
|
|
368
442
|
const age = now - closedAt;
|
|
369
443
|
if (sweepTombstones && Number.isFinite(closedAt) && age >= tombstoneMaxAgeMs) {
|
|
@@ -382,7 +456,7 @@ function* sweepStaleSessionSteps(ttlMs, options = {}) {
|
|
|
382
456
|
// Repair a stale summary row that still claimed the session was
|
|
383
457
|
// open: reflect the real closed state so the next sweep sees the
|
|
384
458
|
// correct closed=true/updatedAt and never re-closes it.
|
|
385
|
-
if (
|
|
459
|
+
if (!(row.closed === true || row.status === 'closed')) {
|
|
386
460
|
try { _queueSessionSummaryUpsert(actual); } catch { /* best-effort */ }
|
|
387
461
|
}
|
|
388
462
|
remaining++;
|
|
@@ -392,7 +466,6 @@ function* sweepStaleSessionSteps(ttlMs, options = {}) {
|
|
|
392
466
|
// use their provider's Advanced terminal duration rather than the
|
|
393
467
|
// general sweep cadence. A short provider override must therefore
|
|
394
468
|
// not be hidden behind the default 5-minute gate.
|
|
395
|
-
if (!actual && raw != null) { try { actual = JSON.parse(raw); } catch { actual = null; } }
|
|
396
469
|
const gateOwner = (actual && typeof actual.owner === 'string' && actual.owner.length > 0)
|
|
397
470
|
? actual.owner : row.owner;
|
|
398
471
|
const gateStatus = (actual && typeof actual.status === 'string') ? actual.status : row.status;
|
|
@@ -433,8 +506,7 @@ function* sweepStaleSessionSteps(ttlMs, options = {}) {
|
|
|
433
506
|
// Prefer the AUTHORITATIVE on-disk JSON over the best-effort (and
|
|
434
507
|
// possibly stale) summary row for every open/idle liveness and
|
|
435
508
|
// ownership decision below — a stale row must not close or prune the
|
|
436
|
-
// wrong session.
|
|
437
|
-
if (!actual && raw != null) { try { actual = JSON.parse(raw); } catch { actual = null; } }
|
|
509
|
+
// wrong session.
|
|
438
510
|
const effOwner = (actual && typeof actual.owner === 'string' && actual.owner.length > 0)
|
|
439
511
|
? actual.owner : row.owner;
|
|
440
512
|
const ownerRef = { owner: effOwner };
|
|
@@ -518,10 +590,13 @@ function* sweepStaleSessionSteps(ttlMs, options = {}) {
|
|
|
518
590
|
continue;
|
|
519
591
|
}
|
|
520
592
|
let preCloseHeartbeatMtime = 0;
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
593
|
+
const preCloseProbe = probePath(join(dir, `${row.id}.hb`));
|
|
594
|
+
if (preCloseProbe.state === PROBE_PRESENT) preCloseHeartbeatMtime = preCloseProbe.mtimeMs || 0;
|
|
595
|
+
else if (preCloseProbe.state !== PROBE_ABSENT) {
|
|
596
|
+
// Unknown liveness at the destructive edge: do not close.
|
|
597
|
+
remaining++;
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
525
600
|
if (preCloseHeartbeatMtime > 0 && now - preCloseHeartbeatMtime <= sessionMaxAge) {
|
|
526
601
|
remaining++;
|
|
527
602
|
continue;
|
|
@@ -597,9 +672,11 @@ function* sweepStaleSessionSteps(ttlMs, options = {}) {
|
|
|
597
672
|
try {
|
|
598
673
|
for (const h of readdirSync(dir).filter(f => f.endsWith('.hb') || f.endsWith('.own'))) {
|
|
599
674
|
yield undefined;
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
675
|
+
// Only a PROVEN-absent session file makes its sidecar an orphan.
|
|
676
|
+
if (!(probePath(join(dir, h.replace(/\.(hb|own)$/, '.json'))).state === PROBE_ABSENT)) continue;
|
|
677
|
+
const sidecarProbe = probePath(join(dir, h));
|
|
678
|
+
if (sidecarProbe.state !== PROBE_PRESENT) continue;
|
|
679
|
+
const hbMtime = sidecarProbe.mtimeMs;
|
|
603
680
|
if (now - hbMtime > maxAge) {
|
|
604
681
|
try { unlinkSync(join(dir, h)); cleaned++; } catch { /* ignore */ }
|
|
605
682
|
}
|
|
@@ -12,6 +12,138 @@ export const _liveSessions = new Map();
|
|
|
12
12
|
// it must not be evicted (data loss) nor shadowed by the stale disk copy.
|
|
13
13
|
export const _droppedSaveIds = new Set();
|
|
14
14
|
|
|
15
|
+
// ── Save identity (epoch) ───────────────────────────────────────────────────
|
|
16
|
+
// Every save ATTEMPT takes a monotonically increasing epoch when it is issued.
|
|
17
|
+
// A failure/drop records the epoch of the state that never landed, so only a
|
|
18
|
+
// save at least that new may clear the id-wide error/drop markers. Without it
|
|
19
|
+
// an OLDER worker write landing after a NEWER sync/async failure cleared the
|
|
20
|
+
// markers, un-pinning (and then evicting) the only copy of the newest turn.
|
|
21
|
+
let _saveEpochSeq = 0;
|
|
22
|
+
const _failureEpochs = new Map(); // id -> epoch of the newest failed/dropped save
|
|
23
|
+
// The EXACT snapshot whose own save failed in this process, per id. This is
|
|
24
|
+
// the only evidence that may let an in-memory object stand in for an
|
|
25
|
+
// unreadable canonical file (see loadSession's read-after-failure contract):
|
|
26
|
+
// "some live/pending object exists for this id" proves nothing about disk,
|
|
27
|
+
// while "THIS object's write to disk failed" proves the bytes never landed
|
|
28
|
+
// and that this copy is strictly newer than whatever the file holds. Lives
|
|
29
|
+
// exactly as long as _lastSaveError (cleared by the next landed save).
|
|
30
|
+
// Stored as an IMMUTABLE point-in-time clone (see _failedSaveClone), never the
|
|
31
|
+
// live reference.
|
|
32
|
+
const _failedSaveSnapshots = new Map(); // id -> frozen-in-time session copy
|
|
33
|
+
|
|
34
|
+
export function _nextSaveEpoch() {
|
|
35
|
+
return ++_saveEpochSeq;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Save outcomes ───────────────────────────────────────────────────────────
|
|
39
|
+
// A refused write is NOT one thing: an ownership/cancellation drop means this
|
|
40
|
+
// process holds state disk does not (pin it, flag the split-brain), while a
|
|
41
|
+
// stale-epoch refusal means a strictly NEWER write already landed — nothing
|
|
42
|
+
// was lost, so no marker may move and the snapshot stays immediately
|
|
43
|
+
// evictable. Every save path reports which one happened.
|
|
44
|
+
export const SAVE_OUTCOME_SAVED = 'saved';
|
|
45
|
+
export const SAVE_OUTCOME_DROPPED = 'dropped';
|
|
46
|
+
export const SAVE_OUTCOME_STALE = 'stale';
|
|
47
|
+
|
|
48
|
+
// ── Session incarnations ────────────────────────────────────────────────────
|
|
49
|
+
// Work (worker write, queued payload, parked snapshot, in-process save) is
|
|
50
|
+
// stamped with the OBJECT identifying the id's current incarnation. A
|
|
51
|
+
// successful hard delete retires that object; a re-created id mints a brand
|
|
52
|
+
// new one. Identity comparison — not a counter — is what makes this exact:
|
|
53
|
+
// no wrap, no reset, no reuse, so pre-delete work can never look current
|
|
54
|
+
// again, however many deletes happen and however often an id is reused.
|
|
55
|
+
//
|
|
56
|
+
// Entries are pinned while work references them; the (generous) cap only ever
|
|
57
|
+
// evicts UNREFERENCED entries, and an evicted entry can at worst make some
|
|
58
|
+
// later settlement inert — never make stale work current.
|
|
59
|
+
let _incarnationSeq = 0;
|
|
60
|
+
const _sessionIncarnations = new Map(); // id -> { id, token, refs }
|
|
61
|
+
const SESSION_INCARNATION_MAX = 4096;
|
|
62
|
+
|
|
63
|
+
function _evictSessionIncarnations() {
|
|
64
|
+
if (_sessionIncarnations.size <= SESSION_INCARNATION_MAX) return;
|
|
65
|
+
for (const [key, incarnation] of _sessionIncarnations) {
|
|
66
|
+
if (_sessionIncarnations.size <= SESSION_INCARNATION_MAX) break;
|
|
67
|
+
if (incarnation.refs > 0) continue; // still referenced by outstanding work
|
|
68
|
+
_sessionIncarnations.delete(key);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Strict diagnostics: a release without a matching acquire is a BUG, not a
|
|
73
|
+
// clamp. Tests assert this stays zero instead of relying on the clamp.
|
|
74
|
+
let _incarnationReleaseUnderflows = 0;
|
|
75
|
+
|
|
76
|
+
export function _sessionIncarnationStats() {
|
|
77
|
+
return {
|
|
78
|
+
size: _sessionIncarnations.size,
|
|
79
|
+
max: SESSION_INCARNATION_MAX,
|
|
80
|
+
releaseUnderflows: _incarnationReleaseUnderflows,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Stamp for a new unit of work; keeps the incarnation alive until released. */
|
|
85
|
+
export function _acquireSessionIncarnation(id) {
|
|
86
|
+
if (!id) return null;
|
|
87
|
+
let incarnation = _sessionIncarnations.get(id);
|
|
88
|
+
if (incarnation) _sessionIncarnations.delete(id);
|
|
89
|
+
else incarnation = { id, token: ++_incarnationSeq, refs: 0 };
|
|
90
|
+
incarnation.refs += 1;
|
|
91
|
+
_sessionIncarnations.set(id, incarnation); // LRU touch
|
|
92
|
+
_evictSessionIncarnations();
|
|
93
|
+
return incarnation;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function _releaseSessionIncarnation(incarnation) {
|
|
97
|
+
if (!incarnation) return;
|
|
98
|
+
if (incarnation.refs > 0) incarnation.refs -= 1;
|
|
99
|
+
else _incarnationReleaseUnderflows += 1; // double release — surfaced, not hidden
|
|
100
|
+
// The cap is enforced on RELEASE too: a token that just became
|
|
101
|
+
// unreferenced is exactly the one eviction is allowed to reclaim.
|
|
102
|
+
if (incarnation.refs === 0) _evictSessionIncarnations();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Diagnostics/tests: outstanding references to an id's incarnation. */
|
|
106
|
+
export function _sessionIncarnationRefs(id) {
|
|
107
|
+
const incarnation = id && _sessionIncarnations.get(id);
|
|
108
|
+
return incarnation ? incarnation.refs : 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** False once the id was hard-deleted (or re-created) after this stamp. */
|
|
112
|
+
export function _isCurrentSessionIncarnation(id, incarnation) {
|
|
113
|
+
if (!incarnation) return true; // unstamped work keeps its legacy behavior
|
|
114
|
+
return _sessionIncarnations.get(id) === incarnation;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Retire an id's incarnation on a SUCCESSFUL hard delete. The object is
|
|
119
|
+
* dropped from the registry and never handed out again: outstanding work that
|
|
120
|
+
* still holds it is permanently non-current, and a re-created id gets a fresh
|
|
121
|
+
* object of its own.
|
|
122
|
+
*/
|
|
123
|
+
export function _retireSessionIncarnation(id) {
|
|
124
|
+
if (id) _sessionIncarnations.delete(id);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Clear every save marker for an id (successful hard delete). */
|
|
128
|
+
export function _clearSessionSaveState(id) {
|
|
129
|
+
if (!id) return;
|
|
130
|
+
_lastSaveError.delete(id);
|
|
131
|
+
_failureEpochs.delete(id);
|
|
132
|
+
// The recovery evidence dies with the file it was evidence ABOUT: a
|
|
133
|
+
// re-created id must never inherit the deleted incarnation's snapshot
|
|
134
|
+
// (that would resurrect a deleted transcript as the "only good copy").
|
|
135
|
+
_failedSaveSnapshots.delete(id);
|
|
136
|
+
_droppedSaveIds.delete(id);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function _markFailureEpoch(id, epoch) {
|
|
140
|
+
// An unknown epoch is treated as "newest": callers without an identity
|
|
141
|
+
// must never weaken the marker.
|
|
142
|
+
const value = Number.isFinite(epoch) ? epoch : _nextSaveEpoch();
|
|
143
|
+
const previous = _failureEpochs.get(id);
|
|
144
|
+
if (previous === undefined || value > previous) _failureEpochs.set(id, value);
|
|
145
|
+
}
|
|
146
|
+
|
|
15
147
|
export function setLiveSession(session) {
|
|
16
148
|
if (!session?.id) return;
|
|
17
149
|
_liveSessions.set(session.id, session);
|
|
@@ -46,4 +178,125 @@ export function getSessionSaveError(id) {
|
|
|
46
178
|
|
|
47
179
|
export function clearSessionSaveError(id) {
|
|
48
180
|
_lastSaveError.delete(id);
|
|
181
|
+
_failureEpochs.delete(id);
|
|
182
|
+
_failedSaveSnapshots.delete(id);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Record a failed atomic save. The disk copy is now BEHIND this process's live
|
|
187
|
+
* snapshot, so the id is flagged on BOTH channels:
|
|
188
|
+
* - _lastSaveError: surfaced to callers (getSessionSaveError) and used by the
|
|
189
|
+
* idle sweep / loadSession to keep the only-good in-memory state readable.
|
|
190
|
+
* - _droppedSaveIds: the existing "disk is behind the snapshot" marker, so a
|
|
191
|
+
* stale/lower-generation disk record cannot shadow the live transcript.
|
|
192
|
+
* Both are cleared by the next successful save (clearSessionSaveError +
|
|
193
|
+
* _droppedSaveIds.delete in the store's commit path).
|
|
194
|
+
*/
|
|
195
|
+
/**
|
|
196
|
+
* Point-in-time COPY of the exact payload whose save failed.
|
|
197
|
+
*
|
|
198
|
+
* Never the live reference: the caller keeps mutating that object (the next
|
|
199
|
+
* turn appends messages, status flips, the turn hook is re-attached), and a
|
|
200
|
+
* fallback read must reproduce what the failed write actually attempted —
|
|
201
|
+
* not a later state that was never offered to disk and is not backed by any
|
|
202
|
+
* failure evidence. The JSON round-trip detaches every nested container and
|
|
203
|
+
* drops functions/symbols (turn hooks), so no later mutation can reach it.
|
|
204
|
+
* An unclonable payload (circular ref) records NO evidence: readers then
|
|
205
|
+
* report the unreadable file instead of masking it.
|
|
206
|
+
*/
|
|
207
|
+
function _failedSaveClone(session) {
|
|
208
|
+
try {
|
|
209
|
+
const clone = JSON.parse(JSON.stringify(session));
|
|
210
|
+
return clone && typeof clone === 'object' && !Array.isArray(clone) ? clone : null;
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function _recordSaveFailure(id, err, epoch = null, session = null) {
|
|
217
|
+
if (!id) return;
|
|
218
|
+
_lastSaveError.set(id, { message: err?.message ?? String(err), at: Date.now() });
|
|
219
|
+
_droppedSaveIds.add(id);
|
|
220
|
+
_markFailureEpoch(id, epoch);
|
|
221
|
+
// Only a snapshot that IS this id's failed payload is retained; a caller
|
|
222
|
+
// without one records no recovery evidence at all (readers then report the
|
|
223
|
+
// unreadable file instead of masking it).
|
|
224
|
+
if (session && session.id === id) {
|
|
225
|
+
const clone = _failedSaveClone(session);
|
|
226
|
+
if (clone && clone.id === id) _failedSaveSnapshots.set(id, clone);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The in-memory snapshot whose own save failed for `id`, or null.
|
|
232
|
+
* Deliberately NOT "the live session for id": it is the narrow proof that a
|
|
233
|
+
* same-process copy is newer than the canonical file.
|
|
234
|
+
*/
|
|
235
|
+
export function getFailedSaveSnapshot(id) {
|
|
236
|
+
if (!id) return null;
|
|
237
|
+
const snapshot = _failedSaveSnapshots.get(id);
|
|
238
|
+
if (!snapshot) return null;
|
|
239
|
+
// Hand out a COPY as well: a reader that mutates what it got back (every
|
|
240
|
+
// session consumer does) must not be able to edit the stored evidence.
|
|
241
|
+
return _failedSaveClone(snapshot);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Record an ownership-DROPPED save (nothing was written, disk moved past this
|
|
246
|
+
* snapshot). Same identity rule as a failure: an older landed write may not
|
|
247
|
+
* clear it.
|
|
248
|
+
*/
|
|
249
|
+
export function _recordSaveDrop(id, epoch = null) {
|
|
250
|
+
if (!id) return;
|
|
251
|
+
_droppedSaveIds.add(id);
|
|
252
|
+
_markFailureEpoch(id, epoch);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Clear the id's failure/drop markers on behalf of a LANDED save — but only
|
|
257
|
+
* when that save is at least as new as the newest recorded failure/drop.
|
|
258
|
+
* Returns whether the markers were cleared.
|
|
259
|
+
*/
|
|
260
|
+
export function _clearSaveStateIfCurrent(id, epoch = null) {
|
|
261
|
+
if (!id) return false;
|
|
262
|
+
const marked = _failureEpochs.get(id);
|
|
263
|
+
if (marked !== undefined && Number.isFinite(epoch) && epoch < marked) return false;
|
|
264
|
+
_failureEpochs.delete(id);
|
|
265
|
+
_lastSaveError.delete(id);
|
|
266
|
+
_failedSaveSnapshots.delete(id);
|
|
267
|
+
_droppedSaveIds.delete(id);
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** True while this process's last save attempt for `id` failed. */
|
|
272
|
+
export function hasSessionSaveFailure(id) {
|
|
273
|
+
return !!id && _lastSaveError.has(id);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── Lifecycle (tombstone/detach) commit failures ────────────────────────────
|
|
277
|
+
// markSessionClosed/bumpSessionGeneration return null for BOTH a legitimate
|
|
278
|
+
// veto (session gone, liveness veto, contended commit lock) and a genuine
|
|
279
|
+
// durable-write failure. closeSession must react very differently to the two:
|
|
280
|
+
// a failed lifecycle barrier means NOTHING was fenced on disk, so tearing the
|
|
281
|
+
// runtime down would strand a live turn behind an un-planted tombstone. This
|
|
282
|
+
// map is the disambiguator — set only on a real commit failure, cleared when
|
|
283
|
+
// the barrier lands.
|
|
284
|
+
const _lifecycleCommitErrors = new Map(); // id -> { message, at, code, reason }
|
|
285
|
+
|
|
286
|
+
export function _recordLifecycleCommitFailure(id, err, reason = null) {
|
|
287
|
+
if (!id) return;
|
|
288
|
+
_lifecycleCommitErrors.set(id, {
|
|
289
|
+
message: err?.message ?? String(err),
|
|
290
|
+
code: err?.code ?? null,
|
|
291
|
+
reason,
|
|
292
|
+
at: Date.now(),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function getSessionLifecycleCommitError(id) {
|
|
297
|
+
return _lifecycleCommitErrors.get(id) ?? null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function clearSessionLifecycleCommitError(id) {
|
|
301
|
+
_lifecycleCommitErrors.delete(id);
|
|
49
302
|
}
|