mixdog 0.9.52 → 0.9.53
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 +1 -1
- package/scripts/bench-run.mjs +2 -2
- package/scripts/compact-pressure-test.mjs +104 -0
- package/scripts/desktop-session-bridge-test.mjs +704 -0
- package/scripts/freevar-smoke.mjs +7 -4
- package/scripts/lifecycle-api-test.mjs +65 -4
- package/scripts/max-output-recovery-test.mjs +31 -0
- package/scripts/memory-core-input-test.mjs +10 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +63 -3
- package/scripts/openai-ws-early-settle-test.mjs +40 -0
- package/scripts/parent-abort-link-test.mjs +24 -0
- package/scripts/process-lifecycle-test.mjs +80 -22
- package/scripts/provider-contract-test.mjs +257 -0
- package/scripts/provider-toolcall-test.mjs +172 -10
- package/scripts/session-orphan-sweep-test.mjs +27 -1
- package/src/lib/keychain-cjs.cjs +36 -23
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -13
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +7 -8
- package/src/runtime/agent/orchestrator/agent-trace.mjs +33 -9
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +7 -2
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +14 -300
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +2 -4
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +18 -266
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +18 -1
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +15 -130
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +5 -115
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +224 -0
- package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +6 -0
- package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +119 -0
- package/src/runtime/agent/orchestrator/providers/lib/grok-tool-schema.mjs +109 -0
- package/src/runtime/agent/orchestrator/providers/lib/openai-tool-args.mjs +70 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -71
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +47 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +1 -7
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +72 -77
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -20
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +10 -10
- package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +30 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +37 -28
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +5 -7
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +170 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +20 -223
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +11 -17
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +12 -5
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +21 -5
- package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +3 -58
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +24 -0
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +57 -16
- package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -1
- package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +52 -0
- package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +62 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +305 -127
- package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +46 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/search-grep-chunks.mjs +173 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/search-input-helpers.mjs +117 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-insights.mjs +199 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-spawn-helpers.mjs +107 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +1 -40
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +19 -277
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +22 -298
- package/src/runtime/agent/orchestrator/tools/lib/shell-spawn-retry.mjs +67 -0
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -71
- package/src/runtime/channels/backends/discord-gateway.mjs +1 -1
- package/src/runtime/channels/backends/discord.mjs +6 -6
- package/src/runtime/channels/lib/config.mjs +15 -2
- package/src/runtime/channels/lib/memory-client.mjs +20 -3
- package/src/runtime/channels/lib/owned-runtime.mjs +19 -12
- package/src/runtime/channels/lib/status-snapshot.mjs +9 -0
- package/src/runtime/channels/lib/tool-dispatch.mjs +9 -5
- package/src/runtime/channels/lib/worker-main.mjs +16 -5
- package/src/runtime/memory/index.mjs +24 -198
- package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -53
- package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +115 -0
- package/src/runtime/memory/lib/memory-port-advertiser.mjs +105 -0
- package/src/runtime/memory/lib/pg/supervisor.mjs +0 -4
- package/src/runtime/memory/lib/query-handlers.mjs +2 -122
- package/src/runtime/memory/lib/query-maintenance-handlers.mjs +126 -0
- package/src/runtime/memory/lib/tool-call-handler.mjs +57 -0
- package/src/runtime/search/lib/http-fetch.mjs +1 -1
- package/src/runtime/shared/config.mjs +58 -13
- package/src/runtime/shared/llm/cost.mjs +14 -4
- package/src/runtime/shared/memory-snapshot.mjs +236 -0
- package/src/runtime/shared/process-lifecycle.mjs +92 -19
- package/src/runtime/shared/process-shutdown.mjs +6 -0
- package/src/runtime/shared/resource-admission.mjs +7 -2
- package/src/session-runtime/channel-config-api.mjs +7 -7
- package/src/session-runtime/config-lifecycle.mjs +20 -17
- package/src/session-runtime/cwd-plugins.mjs +9 -7
- package/src/session-runtime/env.mjs +1 -2
- package/src/session-runtime/hitch-profile.mjs +45 -0
- package/src/session-runtime/lifecycle-api.mjs +36 -9
- package/src/session-runtime/mcp-glue.mjs +6 -11
- package/src/session-runtime/provider-init-key.mjs +17 -0
- package/src/session-runtime/runtime-core.mjs +44 -103
- package/src/session-runtime/runtime-paths.mjs +20 -0
- package/src/session-runtime/runtime-tool-routing.mjs +55 -0
- package/src/session-runtime/tool-catalog-data.mjs +51 -0
- package/src/session-runtime/tool-catalog.mjs +15 -89
- package/src/standalone/agent-tool/spawn-preset.mjs +73 -0
- package/src/standalone/agent-tool/worker-rows.mjs +93 -0
- package/src/standalone/agent-tool.mjs +12 -162
- package/src/standalone/channel-admin.mjs +29 -0
- package/src/tui/App.jsx +11 -5
- package/src/tui/dist/index.mjs +202 -65
- package/src/tui/engine/session-api-ext.mjs +37 -4
- package/src/tui/index.jsx +1 -0
- package/src/tui/lib/voice-setup.mjs +5 -5
- package/scripts/_devtools-stub.mjs +0 -1
- package/src/runtime/lib/keychain-cjs.cjs +0 -288
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Sessions are saved to disk so CLI and MCP server can share state,
|
|
4
4
|
* and sessions survive server restarts (resume).
|
|
5
5
|
*/
|
|
6
|
-
import { readFileSync, writeFileSync, existsSync,
|
|
6
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync, statSync } from 'fs';
|
|
7
7
|
import * as fsp from 'fs/promises';
|
|
8
8
|
import { randomBytes } from 'crypto';
|
|
9
9
|
import { join } from 'path';
|
|
@@ -15,6 +15,15 @@ import { sanitizeContentForStoredHistory } from '../providers/media-normalizatio
|
|
|
15
15
|
import { scanTopLevelLifecycle } 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
|
+
import { getStoreDir, sessionPath, publishHeartbeat, deleteHeartbeat } from './store/paths-heartbeat.mjs';
|
|
19
|
+
import {
|
|
20
|
+
guardedSaveOptions as _guardedSaveOptions,
|
|
21
|
+
cancelSessionWrites as _cancelSessionWrites,
|
|
22
|
+
isCancelledWrite as _isCancelledWrite,
|
|
23
|
+
acquireWriteCommit as _acquireWriteCommit,
|
|
24
|
+
releaseWriteCommit as _releaseWriteCommit,
|
|
25
|
+
waitForWriteCommit as _waitForWriteCommit,
|
|
26
|
+
} from './store/write-guards.mjs';
|
|
18
27
|
import {
|
|
19
28
|
summaryIndexPath,
|
|
20
29
|
_sessionSummary,
|
|
@@ -36,9 +45,112 @@ export {
|
|
|
36
45
|
_upsertSessionSummary,
|
|
37
46
|
_removeSessionSummary,
|
|
38
47
|
} from './store-summary-index.mjs';
|
|
48
|
+
export { publishHeartbeat, deleteHeartbeat } from './store/paths-heartbeat.mjs';
|
|
39
49
|
|
|
40
50
|
const _lastSaveError = new Map(); // id -> { message, at }
|
|
41
51
|
|
|
52
|
+
// Listing is much hotter than writing, especially while the desktop session
|
|
53
|
+
// browser is open. Keep the compact sidecar in memory after the first read;
|
|
54
|
+
// local durability mutations update this cache synchronously, while an
|
|
55
|
+
// explicit refresh remains the authoritative cross-process/disk reconciliation
|
|
56
|
+
// path. Pending overlays cover a write that lands before the first listing.
|
|
57
|
+
let _summaryRowsCache = null;
|
|
58
|
+
const _summaryCacheUpserts = new Map();
|
|
59
|
+
const _summaryCacheRemovals = new Set();
|
|
60
|
+
const _summaryCacheVersions = new Map();
|
|
61
|
+
let _summaryCacheDataDir = null;
|
|
62
|
+
|
|
63
|
+
function _ensureSummaryCacheDataDir() {
|
|
64
|
+
const dataDir = getPluginData();
|
|
65
|
+
if (_summaryCacheDataDir === dataDir) return;
|
|
66
|
+
_summaryCacheDataDir = dataDir;
|
|
67
|
+
_summaryRowsCache = null;
|
|
68
|
+
_summaryCacheUpserts.clear();
|
|
69
|
+
_summaryCacheRemovals.clear();
|
|
70
|
+
_summaryCacheVersions.clear();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function _summaryRowsWithLocalMutations(rows, { discardLocalMutations = false } = {}) {
|
|
74
|
+
if (discardLocalMutations) {
|
|
75
|
+
_summaryCacheUpserts.clear();
|
|
76
|
+
_summaryCacheRemovals.clear();
|
|
77
|
+
}
|
|
78
|
+
const byId = new Map(_normalizeSummaryIndex({ rows }).rows.map((row) => [row.id, row]));
|
|
79
|
+
for (const id of _summaryCacheRemovals) byId.delete(id);
|
|
80
|
+
for (const [id, row] of _summaryCacheUpserts) byId.set(id, row);
|
|
81
|
+
return [...byId.values()].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function _setSummaryRowsCache(rows, options) {
|
|
85
|
+
if (options?.discardLocalMutations === true) {
|
|
86
|
+
_summaryCacheUpserts.clear();
|
|
87
|
+
_summaryCacheRemovals.clear();
|
|
88
|
+
}
|
|
89
|
+
_summaryRowsCache = _normalizeSummaryIndex({ rows }).rows;
|
|
90
|
+
return _summaryRowsWithLocalMutations(_summaryRowsCache);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function _cachedSummaryRows() {
|
|
94
|
+
return _summaryRowsCache === null ? null : _summaryRowsWithLocalMutations(_summaryRowsCache);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function _setCachedBaseSummary(row) {
|
|
98
|
+
if (!row || _summaryRowsCache === null) return;
|
|
99
|
+
const byId = new Map(_summaryRowsCache.map((existing) => [existing.id, existing]));
|
|
100
|
+
byId.set(row.id, row);
|
|
101
|
+
_summaryRowsCache = [...byId.values()].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function _removeCachedBaseSummary(id) {
|
|
105
|
+
if (_summaryRowsCache === null) return;
|
|
106
|
+
_summaryRowsCache = _summaryRowsCache.filter((row) => row.id !== id);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function _cacheSessionSummary(session) {
|
|
110
|
+
_ensureSummaryCacheDataDir();
|
|
111
|
+
const row = _sessionSummary(session);
|
|
112
|
+
if (!row) return;
|
|
113
|
+
_summaryCacheVersions.set(row.id, (_summaryCacheVersions.get(row.id) || 0) + 1);
|
|
114
|
+
_summaryCacheRemovals.delete(row.id);
|
|
115
|
+
_summaryCacheUpserts.set(row.id, row);
|
|
116
|
+
return _summaryCacheVersions.get(row.id);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function _uncacheSessionSummary(id) {
|
|
120
|
+
_ensureSummaryCacheDataDir();
|
|
121
|
+
if (!id) return;
|
|
122
|
+
_summaryCacheVersions.set(id, (_summaryCacheVersions.get(id) || 0) + 1);
|
|
123
|
+
_summaryCacheUpserts.delete(id);
|
|
124
|
+
_summaryCacheRemovals.add(id);
|
|
125
|
+
_removeCachedBaseSummary(id);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function _rollbackCachedSessionSummary(id, version) {
|
|
129
|
+
if ((_summaryCacheVersions.get(id) || 0) !== version) return;
|
|
130
|
+
_summaryCacheUpserts.delete(id);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function _queueSessionSummaryUpsert(session, version = null) {
|
|
134
|
+
const row = _sessionSummary(session);
|
|
135
|
+
if (!row) return;
|
|
136
|
+
_setCachedBaseSummary(row);
|
|
137
|
+
if (version === null || (_summaryCacheVersions.get(row.id) || 0) === version) {
|
|
138
|
+
_summaryCacheUpserts.delete(row.id);
|
|
139
|
+
_summaryCacheRemovals.delete(row.id);
|
|
140
|
+
}
|
|
141
|
+
_upsertSessionSummary(session);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function _queueSessionSummaryRemoval(id) {
|
|
145
|
+
_uncacheSessionSummary(id);
|
|
146
|
+
_removeSessionSummary(id);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function _queueSummaryIndexPrune(ids) {
|
|
150
|
+
for (const id of ids) _uncacheSessionSummary(id);
|
|
151
|
+
_pruneSummaryIndexIds(ids);
|
|
152
|
+
}
|
|
153
|
+
|
|
42
154
|
// The live in-memory session (and every model request)
|
|
43
155
|
// retains attached image bytes across turns so multi-turn recognition works.
|
|
44
156
|
// The persisted session JSON, however, replaces image content with a short
|
|
@@ -82,21 +194,6 @@ function _renameWithRetrySync(tmp, target) {
|
|
|
82
194
|
return renameWithRetrySync(tmp, target);
|
|
83
195
|
}
|
|
84
196
|
|
|
85
|
-
function getStoreDir() {
|
|
86
|
-
const dir = join(getPluginData(), 'sessions');
|
|
87
|
-
if (!existsSync(dir))
|
|
88
|
-
mkdirSync(dir, { recursive: true });
|
|
89
|
-
return dir;
|
|
90
|
-
}
|
|
91
|
-
function sessionPath(id) {
|
|
92
|
-
// Enforce minted session-id shape before using it in a path to prevent
|
|
93
|
-
// `../` traversal. session IDs are generated by createSession as
|
|
94
|
-
// `sess_<timestamp>_<hex>` — reject anything that doesn't match.
|
|
95
|
-
if (!id || typeof id !== 'string' || !/^[A-Za-z0-9_-]+$/.test(id)) {
|
|
96
|
-
throw new Error(`[session-store] invalid session id: ${JSON.stringify(id)}`);
|
|
97
|
-
}
|
|
98
|
-
return join(getStoreDir(), `${id}.json`);
|
|
99
|
-
}
|
|
100
197
|
/**
|
|
101
198
|
* Ensure generation/closed defaults on every session object.
|
|
102
199
|
* Older persisted sessions predate these fields; we normalise at load and save.
|
|
@@ -124,37 +221,6 @@ function _clearLiveSession(id) {
|
|
|
124
221
|
if (id) _liveSessions.delete(id);
|
|
125
222
|
}
|
|
126
223
|
|
|
127
|
-
// ── Heartbeat publish ─────────────────────────────────────
|
|
128
|
-
// Lightweight per-session timestamp file (`<id>.hb`) consumed by the
|
|
129
|
-
// status aggregator for fresh-session detection. Decoupled from the
|
|
130
|
-
// full session JSON save so it can fire at a tight cadence (≤5s)
|
|
131
|
-
// without serialising the whole payload. The .hb file holds a single
|
|
132
|
-
// ASCII line: `<msTimestamp>\n`. Aggregator scans the same sessions/
|
|
133
|
-
// directory and matches `<id>.hb` to `<id>.json`.
|
|
134
|
-
const _HEARTBEAT_THROTTLE_MS = 5_000;
|
|
135
|
-
const _hbLastAt = new Map();
|
|
136
|
-
|
|
137
|
-
function _heartbeatPath(id) {
|
|
138
|
-
if (!id || typeof id !== 'string' || !/^[A-Za-z0-9_-]+$/.test(id)) {
|
|
139
|
-
throw new Error(`[session-store] invalid session id: ${JSON.stringify(id)}`);
|
|
140
|
-
}
|
|
141
|
-
return join(getStoreDir(), `${id}.hb`);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
export function publishHeartbeat(id, ts) {
|
|
145
|
-
if (!id) return;
|
|
146
|
-
const now = ts || Date.now();
|
|
147
|
-
const last = _hbLastAt.get(id) || 0;
|
|
148
|
-
if (now - last < _HEARTBEAT_THROTTLE_MS) return;
|
|
149
|
-
const target = _heartbeatPath(id);
|
|
150
|
-
_hbLastAt.set(id, now);
|
|
151
|
-
void fsp.writeFile(target, `${now}\n`, 'utf8').catch(() => {});
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
export function deleteHeartbeat(id) {
|
|
155
|
-
try { unlinkSync(_heartbeatPath(id)); } catch { /* ignore */ }
|
|
156
|
-
_hbLastAt.delete(id);
|
|
157
|
-
}
|
|
158
224
|
const _deleteHeartbeat = deleteHeartbeat;
|
|
159
225
|
|
|
160
226
|
// ── 150 ms debounce window ────────────────────────────────────────────────────
|
|
@@ -181,12 +247,18 @@ export function saveSession(session, opts) {
|
|
|
181
247
|
_ensureLifecycleFields(session);
|
|
182
248
|
const id = session.id;
|
|
183
249
|
setLiveSession(session);
|
|
184
|
-
const
|
|
250
|
+
const summaryVersion = _cacheSessionSummary(session);
|
|
251
|
+
const payload = { session, opts: _guardedSaveOptions(id, opts), summaryVersion };
|
|
185
252
|
// Synchronous durability path — explicit flush (tombstones, drain hooks).
|
|
186
253
|
// createSession uses async debounced save + _liveSessions for same-process
|
|
187
254
|
// read-your-writes; sync remains for callers that require immediate disk.
|
|
188
255
|
if (opts?.sync) {
|
|
189
|
-
|
|
256
|
+
try {
|
|
257
|
+
if (!_doSaveSync(payload)) _rollbackCachedSessionSummary(id, summaryVersion);
|
|
258
|
+
} catch (err) {
|
|
259
|
+
_rollbackCachedSessionSummary(id, summaryVersion);
|
|
260
|
+
throw err;
|
|
261
|
+
}
|
|
190
262
|
return;
|
|
191
263
|
}
|
|
192
264
|
// Immediate-flush override: tombstone plants and explicit flushes skip the
|
|
@@ -202,8 +274,11 @@ export function saveSession(session, opts) {
|
|
|
202
274
|
_flushScheduled(id);
|
|
203
275
|
}
|
|
204
276
|
} else {
|
|
205
|
-
_savePending.set(id, { writing: true });
|
|
206
|
-
_doSave(payload).
|
|
277
|
+
_savePending.set(id, { writing: true, payload });
|
|
278
|
+
_doSave(payload).then((saved) => {
|
|
279
|
+
if (!saved) _rollbackCachedSessionSummary(id, summaryVersion);
|
|
280
|
+
}).catch(err => {
|
|
281
|
+
_rollbackCachedSessionSummary(id, summaryVersion);
|
|
207
282
|
process.stderr.write(`[session-store] save failed: ${err?.message}\n`);
|
|
208
283
|
_lastSaveError.set(id, { message: err?.message ?? String(err), at: Date.now() });
|
|
209
284
|
});
|
|
@@ -247,7 +322,10 @@ function _flushScheduled(id) {
|
|
|
247
322
|
const cur = _savePending.get(id);
|
|
248
323
|
if (!cur || !cur.scheduled) return;
|
|
249
324
|
_savePending.set(id, { writing: true, payload: cur.payload });
|
|
250
|
-
_doSave(cur.payload).
|
|
325
|
+
_doSave(cur.payload).then((saved) => {
|
|
326
|
+
if (!saved) _rollbackCachedSessionSummary(id, cur.payload.summaryVersion);
|
|
327
|
+
}).catch(err => {
|
|
328
|
+
_rollbackCachedSessionSummary(id, cur.payload.summaryVersion);
|
|
251
329
|
process.stderr.write(`[session-store] save failed: ${err?.message}\n`);
|
|
252
330
|
_lastSaveError.set(id, { message: err?.message ?? String(err), at: Date.now() });
|
|
253
331
|
});
|
|
@@ -275,7 +353,7 @@ function _getOrSpawnWorker() {
|
|
|
275
353
|
_saveWorker = new Worker(new URL('./save-session-worker.mjs', import.meta.url), {
|
|
276
354
|
execArgv: [],
|
|
277
355
|
});
|
|
278
|
-
_saveWorker.on('message', ({ ok, error, reqId }) => {
|
|
356
|
+
_saveWorker.on('message', ({ ok, saved, error, reqId }) => {
|
|
279
357
|
const p = _saveWorkerPending.get(reqId);
|
|
280
358
|
if (!p) return;
|
|
281
359
|
_saveWorkerPending.delete(reqId);
|
|
@@ -283,13 +361,18 @@ function _getOrSpawnWorker() {
|
|
|
283
361
|
// becomes unref'd again once all in-flight writes settle. _saveWorker
|
|
284
362
|
// null-check covers the error/exit race where the worker died first.
|
|
285
363
|
if (--_saveWorkerRefCount === 0 && _saveWorker) _saveWorker.unref();
|
|
286
|
-
const { id, waiters } = p;
|
|
364
|
+
const { id, session, summaryVersion, waiters } = p;
|
|
287
365
|
_saveAsyncInflight.delete(id);
|
|
288
366
|
// Resolve/reject every caller whose payload this write represents
|
|
289
367
|
// (the originating call plus any that coalesced onto it before it was
|
|
290
368
|
// posted). A supersede never lands here as a rejection — only a real
|
|
291
369
|
// worker failure does.
|
|
292
370
|
if (ok) {
|
|
371
|
+
// A close/delete may have completed while the worker was writing.
|
|
372
|
+
// Do not let this older completion put an open row back in the
|
|
373
|
+
// process-local cache after its tombstone/removal.
|
|
374
|
+
if (saved) _queueSessionSummaryUpsert(session, summaryVersion);
|
|
375
|
+
else _rollbackCachedSessionSummary(id, summaryVersion);
|
|
293
376
|
clearSessionSaveError(id);
|
|
294
377
|
for (const w of waiters) w.resolve();
|
|
295
378
|
}
|
|
@@ -305,16 +388,23 @@ function _getOrSpawnWorker() {
|
|
|
305
388
|
if (q) {
|
|
306
389
|
_saveAsyncQueued.delete(id);
|
|
307
390
|
try {
|
|
308
|
-
_postAsyncWrite(id, q.session, q.opts, q.waiters);
|
|
391
|
+
_postAsyncWrite(id, q.session, q.opts, q.waiters, q.summaryVersion);
|
|
309
392
|
} catch (err) {
|
|
393
|
+
_rollbackCachedSessionSummary(id, q.summaryVersion);
|
|
310
394
|
for (const w of q.waiters) w.reject(err);
|
|
311
395
|
}
|
|
312
396
|
}
|
|
313
397
|
});
|
|
314
398
|
_saveWorker.on('error', (err) => {
|
|
315
|
-
for (const [, p] of _saveWorkerPending)
|
|
399
|
+
for (const [, p] of _saveWorkerPending) {
|
|
400
|
+
_rollbackCachedSessionSummary(p.id, p.summaryVersion);
|
|
401
|
+
for (const w of p.waiters) w.reject(err);
|
|
402
|
+
}
|
|
316
403
|
_saveWorkerPending.clear();
|
|
317
|
-
for (const [, q] of _saveAsyncQueued)
|
|
404
|
+
for (const [id, q] of _saveAsyncQueued) {
|
|
405
|
+
_rollbackCachedSessionSummary(id, q.summaryVersion);
|
|
406
|
+
for (const w of q.waiters) w.reject(err);
|
|
407
|
+
}
|
|
318
408
|
_saveAsyncQueued.clear();
|
|
319
409
|
_saveAsyncInflight.clear();
|
|
320
410
|
_saveWorkerRefCount = 0;
|
|
@@ -328,9 +418,15 @@ function _getOrSpawnWorker() {
|
|
|
328
418
|
// saveSessionAsync registered a resolver but before the worker
|
|
329
419
|
// received the message.
|
|
330
420
|
const err = new Error(`[session-store] save worker exited with code ${code}`);
|
|
331
|
-
for (const [, p] of _saveWorkerPending)
|
|
421
|
+
for (const [, p] of _saveWorkerPending) {
|
|
422
|
+
_rollbackCachedSessionSummary(p.id, p.summaryVersion);
|
|
423
|
+
for (const w of p.waiters) w.reject(err);
|
|
424
|
+
}
|
|
332
425
|
_saveWorkerPending.clear();
|
|
333
|
-
for (const [, q] of _saveAsyncQueued)
|
|
426
|
+
for (const [id, q] of _saveAsyncQueued) {
|
|
427
|
+
_rollbackCachedSessionSummary(id, q.summaryVersion);
|
|
428
|
+
for (const w of q.waiters) w.reject(err);
|
|
429
|
+
}
|
|
334
430
|
_saveAsyncQueued.clear();
|
|
335
431
|
_saveAsyncInflight.clear();
|
|
336
432
|
_saveWorkerRefCount = 0;
|
|
@@ -346,9 +442,15 @@ function _getOrSpawnWorker() {
|
|
|
346
442
|
* in flight for `id`. Throws (after cleaning its own map entries) if the
|
|
347
443
|
* worker postMessage fails so the caller can reject the affected waiters.
|
|
348
444
|
*/
|
|
349
|
-
function _postAsyncWrite(id, session, opts, waiters) {
|
|
445
|
+
function _postAsyncWrite(id, session, opts, waiters, summaryVersion) {
|
|
350
446
|
const reqId = ++_saveWorkerReqId;
|
|
351
|
-
_saveWorkerPending.set(reqId, {
|
|
447
|
+
_saveWorkerPending.set(reqId, {
|
|
448
|
+
id,
|
|
449
|
+
session,
|
|
450
|
+
opts,
|
|
451
|
+
summaryVersion,
|
|
452
|
+
waiters,
|
|
453
|
+
});
|
|
352
454
|
_saveAsyncInflight.set(id, reqId);
|
|
353
455
|
try {
|
|
354
456
|
const w = _getOrSpawnWorker();
|
|
@@ -379,7 +481,8 @@ export function saveSessionAsync(session, opts) {
|
|
|
379
481
|
_ensureLifecycleFields(session);
|
|
380
482
|
setLiveSession(session);
|
|
381
483
|
const id = session.id;
|
|
382
|
-
const
|
|
484
|
+
const summaryVersion = _cacheSessionSummary(session);
|
|
485
|
+
const safeOpts = _guardedSaveOptions(id, opts);
|
|
383
486
|
// The Worker `postMessage` below structured-clones the whole session on the
|
|
384
487
|
// main thread. `session.liveTurnMessages` (live working transcript) and
|
|
385
488
|
// `session.toolApprovalHook` (askOpts.onToolApproval callback) are transient
|
|
@@ -404,9 +507,15 @@ export function saveSessionAsync(session, opts) {
|
|
|
404
507
|
if (q) {
|
|
405
508
|
q.session = clonePayload;
|
|
406
509
|
q.opts = safeOpts;
|
|
510
|
+
q.summaryVersion = summaryVersion;
|
|
407
511
|
q.waiters.push(waiter);
|
|
408
512
|
} else {
|
|
409
|
-
_saveAsyncQueued.set(id, {
|
|
513
|
+
_saveAsyncQueued.set(id, {
|
|
514
|
+
session: clonePayload,
|
|
515
|
+
opts: safeOpts,
|
|
516
|
+
summaryVersion,
|
|
517
|
+
waiters: [waiter],
|
|
518
|
+
});
|
|
410
519
|
}
|
|
411
520
|
return;
|
|
412
521
|
}
|
|
@@ -414,8 +523,9 @@ export function saveSessionAsync(session, opts) {
|
|
|
414
523
|
// in-flight entry persists {session, opts} so drainSessionStore can
|
|
415
524
|
// sync-flush outstanding writes if process exit interrupts the queue.
|
|
416
525
|
try {
|
|
417
|
-
_postAsyncWrite(id, clonePayload, safeOpts, [waiter]);
|
|
526
|
+
_postAsyncWrite(id, clonePayload, safeOpts, [waiter], summaryVersion);
|
|
418
527
|
} catch (err) {
|
|
528
|
+
_rollbackCachedSessionSummary(id, summaryVersion);
|
|
419
529
|
reject(err);
|
|
420
530
|
}
|
|
421
531
|
});
|
|
@@ -427,24 +537,35 @@ export function saveSessionAsync(session, opts) {
|
|
|
427
537
|
*/
|
|
428
538
|
export function _saveSessionSync(session, opts) {
|
|
429
539
|
_ensureLifecycleFields(session);
|
|
430
|
-
_doSaveSync({ session, opts: opts || null });
|
|
540
|
+
return _doSaveSync({ session, opts: opts || null });
|
|
431
541
|
}
|
|
432
542
|
|
|
433
543
|
function _doSaveSync(payload) {
|
|
434
|
-
const { session, opts } = payload;
|
|
544
|
+
const { session, opts, summaryVersion = null } = payload;
|
|
435
545
|
const id = session.id;
|
|
436
|
-
if (_shouldDrop(id, opts)) return;
|
|
546
|
+
if (_shouldDrop(id, opts)) return false;
|
|
437
547
|
const target = sessionPath(id);
|
|
438
548
|
const tmp = target + '.' + randomBytes(6).toString('hex') + '.tmp';
|
|
439
549
|
try {
|
|
440
550
|
writeFileSync(tmp, JSON.stringify(_sessionForDisk(session)), 'utf-8');
|
|
441
551
|
if (_shouldDrop(id, opts)) {
|
|
442
552
|
try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
|
|
443
|
-
return;
|
|
553
|
+
return false;
|
|
444
554
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
555
|
+
const commitControl = _acquireWriteCommit(opts);
|
|
556
|
+
if (commitControl === false || _shouldDrop(id, opts)) {
|
|
557
|
+
try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
|
|
558
|
+
_releaseWriteCommit(commitControl);
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
try {
|
|
562
|
+
_renameWithRetrySync(tmp, target);
|
|
563
|
+
_queueSessionSummaryUpsert(session, summaryVersion);
|
|
564
|
+
clearSessionSaveError(id);
|
|
565
|
+
} finally {
|
|
566
|
+
_releaseWriteCommit(commitControl);
|
|
567
|
+
}
|
|
568
|
+
return true;
|
|
448
569
|
} catch (err) {
|
|
449
570
|
try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
|
|
450
571
|
throw err;
|
|
@@ -452,6 +573,7 @@ function _doSaveSync(payload) {
|
|
|
452
573
|
}
|
|
453
574
|
|
|
454
575
|
function _shouldDrop(id, opts) {
|
|
576
|
+
if (_isCancelledWrite(opts)) return true;
|
|
455
577
|
if (!opts || opts.allowClosed) return false;
|
|
456
578
|
const expected = typeof opts.expectedGeneration === 'number' ? opts.expectedGeneration : null;
|
|
457
579
|
if (expected === null) return false;
|
|
@@ -551,7 +673,10 @@ function _drainQueue(id) {
|
|
|
551
673
|
if (pending && pending.queued) {
|
|
552
674
|
const next = pending.queued;
|
|
553
675
|
_savePending.set(id, { writing: true, payload: next });
|
|
554
|
-
_doSave(next).
|
|
676
|
+
_doSave(next).then((saved) => {
|
|
677
|
+
if (!saved) _rollbackCachedSessionSummary(id, next.summaryVersion);
|
|
678
|
+
}).catch(err => {
|
|
679
|
+
_rollbackCachedSessionSummary(id, next.summaryVersion);
|
|
555
680
|
process.stderr.write(`[session-store] save failed: ${err?.message}\n`);
|
|
556
681
|
_lastSaveError.set(id, { message: err?.message ?? String(err), at: Date.now() });
|
|
557
682
|
});
|
|
@@ -561,13 +686,13 @@ function _drainQueue(id) {
|
|
|
561
686
|
}
|
|
562
687
|
|
|
563
688
|
async function _doSave(payload) {
|
|
564
|
-
const { session, opts } = payload;
|
|
689
|
+
const { session, opts, summaryVersion = null } = payload;
|
|
565
690
|
const id = session.id;
|
|
566
691
|
// First check: upfront, before any disk I/O. Cheap short-circuit when a
|
|
567
692
|
// tombstone is already on disk when the caller arrives.
|
|
568
693
|
if (_shouldDrop(id, opts)) {
|
|
569
694
|
_drainQueue(id);
|
|
570
|
-
return;
|
|
695
|
+
return false;
|
|
571
696
|
}
|
|
572
697
|
const target = sessionPath(id);
|
|
573
698
|
const tmp = target + '.' + randomBytes(6).toString('hex') + '.tmp';
|
|
@@ -580,17 +705,29 @@ async function _doSave(payload) {
|
|
|
580
705
|
try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
|
|
581
706
|
process.stderr.write(`[session-store] ${id}: dropped stale save (tombstone planted during write)\n`);
|
|
582
707
|
_drainQueue(id);
|
|
583
|
-
return;
|
|
708
|
+
return false;
|
|
584
709
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
710
|
+
const commitControl = _acquireWriteCommit(opts);
|
|
711
|
+
if (commitControl === false || _shouldDrop(id, opts)) {
|
|
712
|
+
try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
|
|
713
|
+
_releaseWriteCommit(commitControl);
|
|
714
|
+
_drainQueue(id);
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
try {
|
|
718
|
+
_renameWithRetrySync(tmp, target);
|
|
719
|
+
_queueSessionSummaryUpsert(session, summaryVersion);
|
|
720
|
+
clearSessionSaveError(id);
|
|
721
|
+
} finally {
|
|
722
|
+
_releaseWriteCommit(commitControl);
|
|
723
|
+
}
|
|
724
|
+
_drainQueue(id);
|
|
725
|
+
return true;
|
|
588
726
|
} catch (err) {
|
|
589
727
|
try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
|
|
590
728
|
_savePending.delete(id);
|
|
591
729
|
throw err;
|
|
592
730
|
}
|
|
593
|
-
_drainQueue(id);
|
|
594
731
|
}
|
|
595
732
|
|
|
596
733
|
/**
|
|
@@ -604,6 +741,8 @@ export function markSessionClosed(id, reason = 'manual') {
|
|
|
604
741
|
// that we are about to plant. The _shouldDrop() guard inside _doSave()
|
|
605
742
|
// provides a second line of defence, but cancelling here is cheaper.
|
|
606
743
|
_clearDebounce(id);
|
|
744
|
+
_cancelSessionWrites(id);
|
|
745
|
+
_uncacheSessionSummary(id);
|
|
607
746
|
const existing = loadSession(id);
|
|
608
747
|
if (!existing) return null;
|
|
609
748
|
// Re-close idempotence: a session that is ALREADY tombstoned keeps its
|
|
@@ -651,7 +790,7 @@ export function markSessionClosed(id, reason = 'manual') {
|
|
|
651
790
|
clearSessionSaveError(id);
|
|
652
791
|
_clearLiveSession(id);
|
|
653
792
|
_deleteHeartbeat(id);
|
|
654
|
-
|
|
793
|
+
_queueSessionSummaryUpsert(tombstone);
|
|
655
794
|
// Structured close metric. Single emission point because every close
|
|
656
795
|
// path funnels through markSessionClosed. lifeMs = updatedAt-createdAt
|
|
657
796
|
// straddles the tombstone (updatedAt was just set to Date.now()), so
|
|
@@ -694,6 +833,8 @@ export function markSessionClosed(id, reason = 'manual') {
|
|
|
694
833
|
*/
|
|
695
834
|
export function bumpSessionGeneration(id, reason = 'detach') {
|
|
696
835
|
_clearDebounce(id);
|
|
836
|
+
_cancelSessionWrites(id);
|
|
837
|
+
_uncacheSessionSummary(id);
|
|
697
838
|
const existing = loadSession(id);
|
|
698
839
|
if (!existing) return null;
|
|
699
840
|
const newGen = (typeof existing.generation === 'number' ? existing.generation : 0) + 1;
|
|
@@ -711,7 +852,7 @@ export function bumpSessionGeneration(id, reason = 'detach') {
|
|
|
711
852
|
clearSessionSaveError(id);
|
|
712
853
|
_clearLiveSession(id);
|
|
713
854
|
_deleteHeartbeat(id);
|
|
714
|
-
|
|
855
|
+
_queueSessionSummaryUpsert(detached);
|
|
715
856
|
return newGen;
|
|
716
857
|
}
|
|
717
858
|
|
|
@@ -754,6 +895,10 @@ export function clearSessionSaveError(id) {
|
|
|
754
895
|
}
|
|
755
896
|
|
|
756
897
|
export function deleteSession(id, options = {}) {
|
|
898
|
+
_cancelSessionWrites(id);
|
|
899
|
+
_clearDebounce(id);
|
|
900
|
+
_savePending.delete(id);
|
|
901
|
+
_waitForWriteCommit(id);
|
|
757
902
|
const path = sessionPath(id);
|
|
758
903
|
let removed = false;
|
|
759
904
|
if (existsSync(path)) {
|
|
@@ -764,11 +909,13 @@ export function deleteSession(id, options = {}) {
|
|
|
764
909
|
catch { /* fall through to .hb cleanup */ }
|
|
765
910
|
}
|
|
766
911
|
_deleteHeartbeat(id);
|
|
912
|
+
_clearLiveSession(id);
|
|
767
913
|
if (removed || !existsSync(path)) clearSessionSaveError(id);
|
|
768
914
|
// deferSummaryUpdate: bulk callers (tombstone sweep) remove thousands of
|
|
769
915
|
// rows — a per-id _removeSessionSummary would parse+rewrite the multi-MB
|
|
770
916
|
// summary index once PER DELETION. They batch the index update themselves.
|
|
771
|
-
if (
|
|
917
|
+
if (options.deferSummaryUpdate === true) _uncacheSessionSummary(id);
|
|
918
|
+
else _queueSessionSummaryRemoval(id);
|
|
772
919
|
return removed;
|
|
773
920
|
}
|
|
774
921
|
const DEFAULT_SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes idle — aligned with Anthropic 5m messages tier and OpenAI in-memory cache window
|
|
@@ -810,7 +957,9 @@ export function listStoredSessions(options = {}) {
|
|
|
810
957
|
return [];
|
|
811
958
|
const files = readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
812
959
|
const sessionsById = new Map();
|
|
813
|
-
const invalidStorageIds =
|
|
960
|
+
const invalidStorageIds = options._invalidStorageIds instanceof Set
|
|
961
|
+
? options._invalidStorageIds
|
|
962
|
+
: new Set();
|
|
814
963
|
for (const f of files) {
|
|
815
964
|
const session = _storedSessionFromFile(dir, f);
|
|
816
965
|
if (session) {
|
|
@@ -820,71 +969,91 @@ export function listStoredSessions(options = {}) {
|
|
|
820
969
|
const storageId = f.slice(0, -5);
|
|
821
970
|
if (/^[A-Za-z0-9_-]+$/.test(storageId)) invalidStorageIds.add(storageId);
|
|
822
971
|
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
972
|
+
const stored = [...sessionsById.values()];
|
|
973
|
+
return options.includeLive === true
|
|
974
|
+
? _withUnpersistedSessions(stored, invalidStorageIds)
|
|
975
|
+
: stored.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function _withUnpersistedSessions(stored, invalidStorageIds = new Set()) {
|
|
979
|
+
const sessionsById = new Map(stored.map((session) => [session.id, session]));
|
|
980
|
+
const addIfUnpersisted = (id, session, opts) => {
|
|
981
|
+
// A valid on-disk record is authoritative for refresh/resume. In
|
|
982
|
+
// particular, a long-lived runtime object must never replace a
|
|
983
|
+
// tombstone or changed desktop authorization metadata. Only active
|
|
984
|
+
// local writes with no disk record get read-your-writes visibility.
|
|
985
|
+
if (sessionsById.has(id) || invalidStorageIds.has(id) || _isCancelledWrite(opts)) return;
|
|
986
|
+
if (session?.id === id) sessionsById.set(id, _ensureLifecycleFields(session));
|
|
987
|
+
};
|
|
988
|
+
for (const [id, pending] of _savePending) {
|
|
989
|
+
const payload = pending.queued || pending.payload;
|
|
990
|
+
addIfUnpersisted(id, payload?.session, payload?.opts);
|
|
991
|
+
}
|
|
992
|
+
for (const [, pending] of _saveWorkerPending) {
|
|
993
|
+
addIfUnpersisted(pending.id, pending.session, pending.opts);
|
|
994
|
+
}
|
|
995
|
+
for (const [id, pending] of _saveAsyncQueued) {
|
|
996
|
+
addIfUnpersisted(id, pending.session, pending.opts);
|
|
838
997
|
}
|
|
839
998
|
return [...sessionsById.values()].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
840
999
|
}
|
|
841
1000
|
|
|
842
1001
|
export function rebuildSessionSummaryIndex() {
|
|
1002
|
+
_ensureSummaryCacheDataDir();
|
|
843
1003
|
const rows = listStoredSessions().map(_sessionSummary).filter(Boolean);
|
|
844
|
-
return _writeSummaryIndex(rows);
|
|
1004
|
+
return _setSummaryRowsCache(_writeSummaryIndex(rows));
|
|
845
1005
|
}
|
|
846
1006
|
|
|
847
1007
|
export function listStoredSessionSummaries(options = {}) {
|
|
1008
|
+
_ensureSummaryCacheDataDir();
|
|
848
1009
|
const rebuildIfMissing = options.rebuildIfMissing !== false;
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
p = summaryIndexPath();
|
|
853
|
-
if (existsSync(p)) {
|
|
854
|
-
indexedRows = _normalizeSummaryIndex(JSON.parse(readFileSync(p, 'utf-8'))).rows;
|
|
855
|
-
}
|
|
856
|
-
} catch { /* unreadable/malformed sidecar falls through to rebuild */ }
|
|
857
|
-
|
|
858
|
-
// Desktop history is user-facing and must not miss a just-saved session
|
|
859
|
-
// merely because the best-effort summary sidecar lost/delayed an upsert.
|
|
860
|
-
// Explicit refresh callers scan the authoritative session JSON files. Keep
|
|
861
|
-
// the scan fail-closed per listStoredSessions (corrupt files are skipped),
|
|
862
|
-
// and still return valid scanned rows if only the sidecar rewrite fails.
|
|
1010
|
+
// This is intentionally the only path that rescans every session JSON:
|
|
1011
|
+
// callers use it as an on-demand authoritative refresh (including resume
|
|
1012
|
+
// authorization), so it must not trust either the cache or sidecar.
|
|
863
1013
|
if (options.refreshFromStorage === true) {
|
|
864
1014
|
try {
|
|
865
|
-
const
|
|
866
|
-
|
|
1015
|
+
const invalidStorageIds = new Set();
|
|
1016
|
+
const stored = listStoredSessions({ _invalidStorageIds: invalidStorageIds });
|
|
1017
|
+
const persistedRows = stored.map(_sessionSummary).filter(Boolean);
|
|
1018
|
+
const rows = _withUnpersistedSessions(stored, invalidStorageIds).map(_sessionSummary).filter(Boolean);
|
|
1019
|
+
try { _writeSummaryIndex(persistedRows); } catch { /* sidecar remains best-effort */ }
|
|
1020
|
+
// A direct scan settles deletion state too; retain only active
|
|
1021
|
+
// optimistic write overlays, never a stale local removal.
|
|
1022
|
+
_summaryCacheRemovals.clear();
|
|
1023
|
+
_setSummaryRowsCache(persistedRows);
|
|
867
1024
|
return rows;
|
|
868
1025
|
} catch {
|
|
869
1026
|
// A refresh is an authorization boundary for desktop resume. If
|
|
870
|
-
// authoritative storage cannot be enumerated, stale sidecar
|
|
871
|
-
// must not be treated as proof that a session is available.
|
|
1027
|
+
// authoritative storage cannot be enumerated, stale cached/sidecar
|
|
1028
|
+
// rows must not be treated as proof that a session is available.
|
|
872
1029
|
return [];
|
|
873
1030
|
}
|
|
874
1031
|
}
|
|
1032
|
+
if (_summaryRowsCache !== null) return _cachedSummaryRows().slice();
|
|
1033
|
+
|
|
1034
|
+
let indexedRows = [];
|
|
1035
|
+
let p;
|
|
1036
|
+
let hasIndex = false;
|
|
1037
|
+
try {
|
|
1038
|
+
p = summaryIndexPath();
|
|
1039
|
+
hasIndex = existsSync(p);
|
|
1040
|
+
if (hasIndex) {
|
|
1041
|
+
indexedRows = _normalizeSummaryIndex(JSON.parse(readFileSync(p, 'utf-8'))).rows;
|
|
1042
|
+
}
|
|
1043
|
+
} catch { /* unreadable/malformed sidecar falls through to rebuild */ }
|
|
875
1044
|
|
|
876
|
-
if (!p || !
|
|
877
|
-
try { return rebuildIfMissing ? rebuildSessionSummaryIndex() : []; }
|
|
878
|
-
catch { return indexedRows; }
|
|
1045
|
+
if (!p || !hasIndex) {
|
|
1046
|
+
try { return rebuildIfMissing ? rebuildSessionSummaryIndex() : _setSummaryRowsCache([]); }
|
|
1047
|
+
catch { return _setSummaryRowsCache(indexedRows); }
|
|
879
1048
|
}
|
|
880
1049
|
try {
|
|
881
|
-
if (indexedRows.length > 0) return indexedRows;
|
|
1050
|
+
if (indexedRows.length > 0) return _setSummaryRowsCache(indexedRows);
|
|
882
1051
|
const dir = getStoreDir();
|
|
883
1052
|
const hasSessionFiles = existsSync(dir) && readdirSync(dir).some((f) => f.endsWith('.json'));
|
|
884
|
-
return hasSessionFiles && rebuildIfMissing ? rebuildSessionSummaryIndex() : indexedRows;
|
|
1053
|
+
return hasSessionFiles && rebuildIfMissing ? rebuildSessionSummaryIndex() : _setSummaryRowsCache(indexedRows);
|
|
885
1054
|
} catch {
|
|
886
|
-
try { return rebuildIfMissing ? rebuildSessionSummaryIndex() : indexedRows; }
|
|
887
|
-
catch { return indexedRows; }
|
|
1055
|
+
try { return rebuildIfMissing ? rebuildSessionSummaryIndex() : _setSummaryRowsCache(indexedRows); }
|
|
1056
|
+
catch { return _setSummaryRowsCache(indexedRows); }
|
|
888
1057
|
}
|
|
889
1058
|
}
|
|
890
1059
|
|
|
@@ -971,7 +1140,7 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
971
1140
|
if (!row?.id) continue;
|
|
972
1141
|
const jsonPath = sessionPath(row.id);
|
|
973
1142
|
if (!existsSync(jsonPath)) {
|
|
974
|
-
|
|
1143
|
+
_queueSessionSummaryRemoval(row.id);
|
|
975
1144
|
continue;
|
|
976
1145
|
}
|
|
977
1146
|
let jsonMtime = 0;
|
|
@@ -1011,6 +1180,15 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
1011
1180
|
}
|
|
1012
1181
|
}
|
|
1013
1182
|
if (diskClosed) {
|
|
1183
|
+
// A shared store can be tombstoned by another process while
|
|
1184
|
+
// this process still owns an in-flight controller for the same
|
|
1185
|
+
// id. Exclude it before unlinking: clearing only the local
|
|
1186
|
+
// runtime after deletion is too late because its eventual save
|
|
1187
|
+
// would see no tombstone and could resurrect the session.
|
|
1188
|
+
if (isSessionLive && isSessionLive(row.id)) {
|
|
1189
|
+
remaining++;
|
|
1190
|
+
continue;
|
|
1191
|
+
}
|
|
1014
1192
|
// Closed sessions are EXEMPT from the freshness gate: a tombstone
|
|
1015
1193
|
// whose file/hb mtime keeps getting bumped would otherwise stay
|
|
1016
1194
|
// perpetually "fresh" and never mature. Maturity is governed ONLY
|
|
@@ -1036,7 +1214,7 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
1036
1214
|
// open: reflect the real closed state so the next sweep sees the
|
|
1037
1215
|
// correct closed=true/updatedAt and never re-closes it.
|
|
1038
1216
|
if (actual && !(row.closed === true || row.status === 'closed')) {
|
|
1039
|
-
try {
|
|
1217
|
+
try { _queueSessionSummaryUpsert(actual); } catch { /* best-effort */ }
|
|
1040
1218
|
}
|
|
1041
1219
|
remaining++;
|
|
1042
1220
|
continue;
|
|
@@ -1179,7 +1357,7 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
1179
1357
|
if (tombstoneDetails.length > 0 || openPrunedDetails.length > 0) {
|
|
1180
1358
|
try {
|
|
1181
1359
|
const deletedIds = new Set([...tombstoneDetails, ...openPrunedDetails].map((d) => d.id));
|
|
1182
|
-
|
|
1360
|
+
_queueSummaryIndexPrune(deletedIds);
|
|
1183
1361
|
} catch { /* summary index is best-effort */ }
|
|
1184
1362
|
}
|
|
1185
1363
|
return { cleaned, remaining, details, tombstonesCleaned, tombstoneDetails, tombstoneErrors, openPruned, openPrunedDetails };
|