mixdog 0.9.55 → 0.9.59
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 +3 -1
- package/scripts/_smoke_wd.mjs +7 -0
- package/scripts/agent-terminal-reap-test.mjs +127 -2
- package/scripts/compact-file-reattach-test.mjs +88 -0
- package/scripts/compact-pressure-test.mjs +45 -21
- package/scripts/compact-recall-digest-test.mjs +57 -0
- package/scripts/compact-smoke.mjs +35 -39
- package/scripts/desktop-session-bridge-test.mjs +271 -9
- package/scripts/generate-oc-icons.mjs +11 -0
- package/scripts/live-share-test.mjs +148 -0
- package/scripts/live-worker-smoke.mjs +1 -1
- package/scripts/max-output-recovery-test.mjs +12 -1
- package/scripts/mouse-tracking-restore-smoke.mjs +107 -0
- package/scripts/provider-admission-scheduler-test.mjs +100 -0
- package/scripts/provider-contract-test.mjs +49 -0
- package/scripts/resource-admission-test.mjs +5 -2
- package/scripts/session-ingest-compaction-smoke.mjs +38 -0
- package/scripts/steering-drain-buckets-test.mjs +15 -0
- package/scripts/submit-commandbusy-race-test.mjs +54 -3
- package/scripts/tool-smoke.mjs +2 -3
- package/scripts/tui-ambiguous-width-test.mjs +113 -0
- package/scripts/tui-runtime-load-bench.mjs +5 -0
- package/scripts/tui-transcript-perf-test.mjs +167 -0
- package/src/app.mjs +23 -0
- package/src/cli.mjs +6 -0
- package/src/lib/keychain-cjs.cjs +70 -20
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +4 -2
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +18 -17
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +18 -0
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +111 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +61 -2
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -4
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +43 -2
- package/src/runtime/agent/orchestrator/session/compact/file-reattach.mjs +112 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +203 -49
- package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +6 -2
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +110 -15
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +7 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +109 -2
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -7
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +39 -39
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +108 -4
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +19 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -11
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +132 -24
- package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager.mjs +16 -1
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +39 -21
- package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +18 -0
- package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +91 -1
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +14 -33
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +169 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +474 -42
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +17 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +36 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +20 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +249 -11
- package/src/runtime/channels/data/voice-runtime-manifest.json +21 -5
- package/src/runtime/channels/lib/scheduler.mjs +8 -6
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +71 -8
- package/src/runtime/channels/lib/voice-transcription.mjs +2 -2
- package/src/runtime/channels/lib/whisper-language.mjs +4 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +7 -0
- package/src/runtime/memory/lib/embedding-worker.mjs +20 -0
- package/src/runtime/memory/lib/query-handlers.mjs +10 -4
- package/src/runtime/memory/lib/recall-format.mjs +43 -2
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +90 -64
- package/src/runtime/shared/err-text.mjs +4 -1
- package/src/runtime/shared/resource-admission.mjs +11 -0
- package/src/runtime/shared/schedule-model-ref.mjs +28 -0
- package/src/runtime/shared/schedule-session-run.mjs +63 -0
- package/src/runtime/shared/schedules-db.mjs +8 -3
- package/src/runtime/shared/tool-result-summary.mjs +4 -1
- package/src/runtime/shared/tool-surface.mjs +7 -7
- package/src/runtime/shared/transcript-writer.mjs +17 -0
- package/src/session-runtime/channel-config-api.mjs +11 -0
- package/src/session-runtime/config-helpers.mjs +9 -6
- package/src/session-runtime/context-status.mjs +80 -3
- package/src/session-runtime/lifecycle-api.mjs +154 -40
- package/src/session-runtime/provider-auth-api.mjs +21 -2
- package/src/session-runtime/provider-usage.mjs +4 -1
- package/src/session-runtime/resource-api.mjs +12 -11
- package/src/session-runtime/runtime-core.mjs +45 -11
- package/src/session-runtime/session-text.mjs +56 -6
- package/src/session-runtime/session-turn-api.mjs +70 -2
- package/src/session-runtime/tool-catalog.mjs +2 -1
- package/src/session-runtime/workflow-agents-api.mjs +2 -2
- package/src/standalone/agent-tool/render.mjs +5 -1
- package/src/standalone/agent-tool.mjs +63 -3
- package/src/standalone/channel-admin.mjs +19 -3
- package/src/standalone/channel-daemon.mjs +40 -0
- package/src/tui/app/resume-picker.mjs +5 -1
- package/src/tui/app/settings-picker.mjs +19 -0
- package/src/tui/app/transcript-window.mjs +45 -8
- package/src/tui/app/use-mouse-input.mjs +101 -31
- package/src/tui/app/use-transcript-window.mjs +53 -9
- package/src/tui/components/ToolExecution.jsx +3 -4
- package/src/tui/components/TranscriptItem.jsx +3 -0
- package/src/tui/dist/index.mjs +17391 -14920
- package/src/tui/engine/context-state.mjs +10 -1
- package/src/tui/engine/live-share.mjs +390 -0
- package/src/tui/engine/queue-helpers.mjs +23 -0
- package/src/tui/engine/session-api-ext.mjs +439 -40
- package/src/tui/engine/session-api.mjs +7 -1
- package/src/tui/engine/session-flow.mjs +21 -7
- package/src/tui/engine/tool-card-results.mjs +3 -1
- package/src/tui/engine/turn.mjs +57 -4
- package/src/tui/engine.mjs +211 -7
- package/src/tui/index.jsx +2 -0
- package/src/tui/lib/voice-setup.mjs +10 -4
- package/src/tui/paste-attachments.mjs +4 -1
- package/src/vendor/statusline/src/gateway/session-routes.mjs +24 -1
- package/src/workflows/default/WORKFLOW.md +3 -0
- package/vendor/ink/build/display-width.js +14 -0
- package/vendor/ink/build/output.js +24 -3
- package/vendor/ink/build/wrap-text.d.ts +2 -0
- package/vendor/ink/build/wrap-text.js +45 -4
|
@@ -15,7 +15,7 @@ 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';
|
|
18
|
+
import { getStoreDir, sessionPath, publishHeartbeat, deleteHeartbeat, deleteSessionPresence } from './store/paths-heartbeat.mjs';
|
|
19
19
|
import {
|
|
20
20
|
guardedSaveOptions as _guardedSaveOptions,
|
|
21
21
|
cancelSessionWrites as _cancelSessionWrites,
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
waitForWriteCommit as _waitForWriteCommit,
|
|
26
26
|
} from './store/write-guards.mjs';
|
|
27
27
|
import {
|
|
28
|
+
SESSION_SUMMARY_INDEX_VERSION,
|
|
28
29
|
summaryIndexPath,
|
|
29
30
|
_sessionSummary,
|
|
30
31
|
_normalizeSummaryIndex,
|
|
@@ -45,10 +46,64 @@ export {
|
|
|
45
46
|
_upsertSessionSummary,
|
|
46
47
|
_removeSessionSummary,
|
|
47
48
|
} from './store-summary-index.mjs';
|
|
48
|
-
export {
|
|
49
|
+
export {
|
|
50
|
+
publishHeartbeat,
|
|
51
|
+
deleteHeartbeat,
|
|
52
|
+
publishSessionPresence,
|
|
53
|
+
deleteSessionPresence,
|
|
54
|
+
readSessionPresenceMtime,
|
|
55
|
+
isSessionPresenceOwnerDead,
|
|
56
|
+
} from './store/paths-heartbeat.mjs';
|
|
49
57
|
|
|
50
58
|
const _lastSaveError = new Map(); // id -> { message, at }
|
|
51
59
|
|
|
60
|
+
// Recent full-session reads are much hotter than writes while a user hops
|
|
61
|
+
// between conversations. Verify the file identity on every access, but reuse
|
|
62
|
+
// the parsed object while the atomic file has not changed.
|
|
63
|
+
const SESSION_LOAD_CACHE_LIMIT = 8;
|
|
64
|
+
const _sessionLoadCache = new Map(); // path → { signature, session }
|
|
65
|
+
let _sessionLoadCacheDataDir = null;
|
|
66
|
+
|
|
67
|
+
function _readStoredSessionCached(id, path) {
|
|
68
|
+
const dataDir = getPluginData();
|
|
69
|
+
if (_sessionLoadCacheDataDir !== dataDir) {
|
|
70
|
+
_sessionLoadCacheDataDir = dataDir;
|
|
71
|
+
_sessionLoadCache.clear();
|
|
72
|
+
}
|
|
73
|
+
let signature;
|
|
74
|
+
try {
|
|
75
|
+
const info = statSync(path, { bigint: true });
|
|
76
|
+
signature = `${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}`;
|
|
77
|
+
} catch {
|
|
78
|
+
_sessionLoadCache.delete(path);
|
|
79
|
+
return { exists: existsSync(path), session: null };
|
|
80
|
+
}
|
|
81
|
+
const cached = _sessionLoadCache.get(path);
|
|
82
|
+
if (cached?.signature === signature) {
|
|
83
|
+
_sessionLoadCache.delete(path);
|
|
84
|
+
_sessionLoadCache.set(path, cached);
|
|
85
|
+
return { exists: true, session: cached.session };
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const stored = JSON.parse(readFileSync(path, 'utf-8'));
|
|
89
|
+
if (stored?.id !== id) {
|
|
90
|
+
_sessionLoadCache.delete(path);
|
|
91
|
+
return { exists: true, session: null };
|
|
92
|
+
}
|
|
93
|
+
_sessionLoadCache.delete(path);
|
|
94
|
+
_sessionLoadCache.set(path, { signature, session: stored });
|
|
95
|
+
while (_sessionLoadCache.size > SESSION_LOAD_CACHE_LIMIT) {
|
|
96
|
+
const oldest = _sessionLoadCache.keys().next().value;
|
|
97
|
+
if (oldest === undefined) break;
|
|
98
|
+
_sessionLoadCache.delete(oldest);
|
|
99
|
+
}
|
|
100
|
+
return { exists: true, session: stored };
|
|
101
|
+
} catch {
|
|
102
|
+
_sessionLoadCache.delete(path);
|
|
103
|
+
return { exists: true, session: null };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
52
107
|
// Listing is much hotter than writing, especially while the desktop session
|
|
53
108
|
// browser is open. Keep the compact sidecar in memory after the first read;
|
|
54
109
|
// local durability mutations update this cache synchronously, while an
|
|
@@ -65,6 +120,7 @@ function _ensureSummaryCacheDataDir() {
|
|
|
65
120
|
if (_summaryCacheDataDir === dataDir) return;
|
|
66
121
|
_summaryCacheDataDir = dataDir;
|
|
67
122
|
_summaryRowsCache = null;
|
|
123
|
+
_summaryScanCache.clear();
|
|
68
124
|
_summaryCacheUpserts.clear();
|
|
69
125
|
_summaryCacheRemovals.clear();
|
|
70
126
|
_summaryCacheVersions.clear();
|
|
@@ -212,15 +268,124 @@ const _savePending = new Map();
|
|
|
212
268
|
/** Same-process authoritative session snapshots (createSession → loadSession / askSession). */
|
|
213
269
|
const _liveSessions = new Map();
|
|
214
270
|
|
|
271
|
+
// Session ids whose most recent save attempt was DROPPED by the ownership
|
|
272
|
+
// guard (_shouldDrop: disk generation moved past the caller's expected
|
|
273
|
+
// generation) or failed outright. For these ids the local live snapshot is
|
|
274
|
+
// AHEAD of disk — the on-disk transcript froze at the last landed save — so
|
|
275
|
+
// it must not be evicted (data loss) nor shadowed by the stale disk copy.
|
|
276
|
+
const _droppedSaveIds = new Set();
|
|
277
|
+
|
|
215
278
|
export function setLiveSession(session) {
|
|
216
279
|
if (!session?.id) return;
|
|
217
280
|
_liveSessions.set(session.id, session);
|
|
218
281
|
}
|
|
219
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Cheap authoritative lifecycle read straight from disk (no live/pending
|
|
285
|
+
* cache). Used by askSession's split-brain re-adoption: a new ask on a
|
|
286
|
+
* non-closed session claims ownership by adopting the on-disk generation.
|
|
287
|
+
*/
|
|
288
|
+
export function readSessionLifecycleFromDisk(id) {
|
|
289
|
+
if (!id) return null;
|
|
290
|
+
const target = sessionPath(id);
|
|
291
|
+
if (!existsSync(target)) return null;
|
|
292
|
+
try {
|
|
293
|
+
const raw = readFileSync(target, 'utf-8');
|
|
294
|
+
let onDisk = scanTopLevelLifecycle(raw);
|
|
295
|
+
if (onDisk === null) onDisk = JSON.parse(raw);
|
|
296
|
+
return {
|
|
297
|
+
generation: typeof onDisk.generation === 'number' ? onDisk.generation : 0,
|
|
298
|
+
closed: onDisk.closed === true,
|
|
299
|
+
};
|
|
300
|
+
} catch {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Freshness of a session's `.hb` heartbeat sidecar (0 when absent). Used by
|
|
307
|
+
* the fork-on-resume guard: a fresh heartbeat published by another process
|
|
308
|
+
* means the session is actively being driven there RIGHT NOW.
|
|
309
|
+
*/
|
|
310
|
+
export function readSessionHeartbeatMtime(id) {
|
|
311
|
+
if (!id) return 0;
|
|
312
|
+
return _heartbeatMtime(id);
|
|
313
|
+
}
|
|
314
|
+
|
|
220
315
|
function _clearLiveSession(id) {
|
|
221
316
|
if (id) _liveSessions.delete(id);
|
|
222
317
|
}
|
|
223
318
|
|
|
319
|
+
/** True while any pending/in-flight persistence still references this id. */
|
|
320
|
+
function _hasPendingPersistence(id) {
|
|
321
|
+
if (_savePending.has(id) || _saveAsyncInflight.has(id) || _saveAsyncQueued.has(id)) return true;
|
|
322
|
+
for (const [, pending] of _deferredSessionSaves) {
|
|
323
|
+
if (pending?.session?.id === id) return true;
|
|
324
|
+
}
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Drop one session's same-process snapshot once its state is durable on disk.
|
|
330
|
+
* No-op while any write for the id is still pending/in flight.
|
|
331
|
+
*/
|
|
332
|
+
export function evictLiveSession(id) {
|
|
333
|
+
if (!id || _hasPendingPersistence(id)) return false;
|
|
334
|
+
return _liveSessions.delete(id);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Live snapshots that still carry raw media bytes (images are placeholder'd
|
|
338
|
+
// in the persisted JSON) stay resident for this long after their last use so
|
|
339
|
+
// multi-turn image recognition keeps working across an idle gap. Beyond the
|
|
340
|
+
// TTL the memory cost wins and the snapshot is reclaimed like any other.
|
|
341
|
+
const LIVE_MEDIA_RETENTION_MS = 60 * 60 * 1000; // 1h
|
|
342
|
+
|
|
343
|
+
function _messagesCarryLiveMedia(messages) {
|
|
344
|
+
if (!Array.isArray(messages)) return false;
|
|
345
|
+
for (const m of messages) {
|
|
346
|
+
if (!m || typeof m !== 'object') continue;
|
|
347
|
+
if (sanitizeContentForStoredHistory(m.content) !== m.content) return true;
|
|
348
|
+
}
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Idle sweep for the same-process snapshot cache. _liveSessions previously
|
|
354
|
+
* grew without bound — every clear-fork and every touched user session pinned
|
|
355
|
+
* its FULL message array (image bytes included) for process lifetime, the
|
|
356
|
+
* observed multi-GB RSS leak. Disk is the source of truth for anything not
|
|
357
|
+
* actively owned by this process, so an entry is dropped when it (a) has no
|
|
358
|
+
* live runtime owner, (b) has no pending persistence, and (c) already exists
|
|
359
|
+
* on disk — loadSession then falls back to the session file. Media-carrying
|
|
360
|
+
* snapshots get a grace TTL (see LIVE_MEDIA_RETENTION_MS) because eviction is
|
|
361
|
+
* lossy for them; text-only snapshots evict losslessly right away.
|
|
362
|
+
*/
|
|
363
|
+
export function evictIdleLiveSessions(options = {}) {
|
|
364
|
+
const isSessionLive = typeof options.isSessionLive === 'function' ? options.isSessionLive : null;
|
|
365
|
+
const now = Date.now();
|
|
366
|
+
let evicted = 0;
|
|
367
|
+
for (const [id, session] of [..._liveSessions.entries()]) {
|
|
368
|
+
if (isSessionLive && isSessionLive(id)) continue;
|
|
369
|
+
if (_hasPendingPersistence(id)) continue;
|
|
370
|
+
if (!existsSync(sessionPath(id))) continue;
|
|
371
|
+
// A dropped last save means the disk copy is BEHIND this snapshot
|
|
372
|
+
// (ownership split-brain). Evicting would lose the only complete
|
|
373
|
+
// transcript; keep it until a save lands again (re-adoption).
|
|
374
|
+
if (_droppedSaveIds.has(id)) continue;
|
|
375
|
+
if (_messagesCarryLiveMedia(session?.messages)) {
|
|
376
|
+
const lastActive = Math.max(session?.updatedAt || 0, session?.lastUsedAt || 0);
|
|
377
|
+
if (lastActive > 0 && now - lastActive <= LIVE_MEDIA_RETENTION_MS) continue;
|
|
378
|
+
}
|
|
379
|
+
_liveSessions.delete(id);
|
|
380
|
+
// With no pending persistence the rollback-race version counter for
|
|
381
|
+
// this id is dead weight — reclaim it too (it regrows from 1 on the
|
|
382
|
+
// next save, which is safe precisely because nothing is in flight).
|
|
383
|
+
_summaryCacheVersions.delete(id);
|
|
384
|
+
evicted++;
|
|
385
|
+
}
|
|
386
|
+
return evicted;
|
|
387
|
+
}
|
|
388
|
+
|
|
224
389
|
const _deleteHeartbeat = deleteHeartbeat;
|
|
225
390
|
|
|
226
391
|
// ── 150 ms debounce window ────────────────────────────────────────────────────
|
|
@@ -355,7 +520,20 @@ function _getOrSpawnWorker() {
|
|
|
355
520
|
_saveWorker = new Worker(new URL('./save-session-worker.mjs', import.meta.url), {
|
|
356
521
|
execArgv: [],
|
|
357
522
|
});
|
|
358
|
-
|
|
523
|
+
// Worker logs arrive as `{ __log }` messages, NOT via piped stdio
|
|
524
|
+
// (stdout:true/stderr:true): once the parent starts reading a worker's
|
|
525
|
+
// piped stdio, the underlying MessagePort stays ref'd for the worker's
|
|
526
|
+
// lifetime regardless of worker.unref(), so every process that ever
|
|
527
|
+
// saved a session could no longer exit (test runners hung after
|
|
528
|
+
// completion). The worker overrides its own process.stdout/stderr.write
|
|
529
|
+
// to forward through this channel, which keeps stray prints off the TUI
|
|
530
|
+
// frame (routed through the parent's guardable stderr) without holding
|
|
531
|
+
// the event loop.
|
|
532
|
+
_saveWorker.on('message', ({ __log, ok, saved, error, reqId }) => {
|
|
533
|
+
if (__log !== undefined) {
|
|
534
|
+
try { process.stderr.write(String(__log)); } catch { /* best-effort */ }
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
359
537
|
const p = _saveWorkerPending.get(reqId);
|
|
360
538
|
if (!p) return;
|
|
361
539
|
_saveWorkerPending.delete(reqId);
|
|
@@ -373,8 +551,16 @@ function _getOrSpawnWorker() {
|
|
|
373
551
|
// A close/delete may have completed while the worker was writing.
|
|
374
552
|
// Do not let this older completion put an open row back in the
|
|
375
553
|
// process-local cache after its tombstone/removal.
|
|
376
|
-
if (saved)
|
|
377
|
-
|
|
554
|
+
if (saved) {
|
|
555
|
+
_queueSessionSummaryUpsert(session, summaryVersion);
|
|
556
|
+
_droppedSaveIds.delete(id);
|
|
557
|
+
} else {
|
|
558
|
+
// The worker's _shouldDrop declined the write: disk ownership
|
|
559
|
+
// moved past this snapshot — flag the split-brain so eviction
|
|
560
|
+
// and disk-over-live arbitration keep the richer local copy.
|
|
561
|
+
_rollbackCachedSessionSummary(id, summaryVersion);
|
|
562
|
+
_droppedSaveIds.add(id);
|
|
563
|
+
}
|
|
378
564
|
clearSessionSaveError(id);
|
|
379
565
|
for (const w of waiters) w.resolve();
|
|
380
566
|
}
|
|
@@ -570,25 +756,28 @@ export function _saveSessionSync(session, opts) {
|
|
|
570
756
|
function _doSaveSync(payload) {
|
|
571
757
|
const { session, opts, summaryVersion = null } = payload;
|
|
572
758
|
const id = session.id;
|
|
573
|
-
if (_shouldDrop(id, opts)) return false;
|
|
759
|
+
if (_shouldDrop(id, opts)) { _droppedSaveIds.add(id); return false; }
|
|
574
760
|
const target = sessionPath(id);
|
|
575
761
|
const tmp = target + '.' + randomBytes(6).toString('hex') + '.tmp';
|
|
576
762
|
try {
|
|
577
763
|
writeFileSync(tmp, JSON.stringify(_sessionForDisk(session)), 'utf-8');
|
|
578
764
|
if (_shouldDrop(id, opts)) {
|
|
579
765
|
try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
|
|
766
|
+
_droppedSaveIds.add(id);
|
|
580
767
|
return false;
|
|
581
768
|
}
|
|
582
769
|
const commitControl = _acquireWriteCommit(opts);
|
|
583
770
|
if (commitControl === false || _shouldDrop(id, opts)) {
|
|
584
771
|
try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
|
|
585
772
|
_releaseWriteCommit(commitControl);
|
|
773
|
+
_droppedSaveIds.add(id);
|
|
586
774
|
return false;
|
|
587
775
|
}
|
|
588
776
|
try {
|
|
589
777
|
_renameWithRetrySync(tmp, target);
|
|
590
778
|
_queueSessionSummaryUpsert(session, summaryVersion);
|
|
591
779
|
clearSessionSaveError(id);
|
|
780
|
+
_droppedSaveIds.delete(id);
|
|
592
781
|
} finally {
|
|
593
782
|
_releaseWriteCommit(commitControl);
|
|
594
783
|
}
|
|
@@ -736,6 +925,7 @@ async function _doSave(payload) {
|
|
|
736
925
|
// First check: upfront, before any disk I/O. Cheap short-circuit when a
|
|
737
926
|
// tombstone is already on disk when the caller arrives.
|
|
738
927
|
if (_shouldDrop(id, opts)) {
|
|
928
|
+
_droppedSaveIds.add(id);
|
|
739
929
|
_drainQueue(id);
|
|
740
930
|
return false;
|
|
741
931
|
}
|
|
@@ -749,6 +939,7 @@ async function _doSave(payload) {
|
|
|
749
939
|
if (_shouldDrop(id, opts)) {
|
|
750
940
|
try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
|
|
751
941
|
process.stderr.write(`[session-store] ${id}: dropped stale save (tombstone planted during write)\n`);
|
|
942
|
+
_droppedSaveIds.add(id);
|
|
752
943
|
_drainQueue(id);
|
|
753
944
|
return false;
|
|
754
945
|
}
|
|
@@ -756,6 +947,7 @@ async function _doSave(payload) {
|
|
|
756
947
|
if (commitControl === false || _shouldDrop(id, opts)) {
|
|
757
948
|
try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
|
|
758
949
|
_releaseWriteCommit(commitControl);
|
|
950
|
+
_droppedSaveIds.add(id);
|
|
759
951
|
_drainQueue(id);
|
|
760
952
|
return false;
|
|
761
953
|
}
|
|
@@ -763,6 +955,7 @@ async function _doSave(payload) {
|
|
|
763
955
|
_renameWithRetrySync(tmp, target);
|
|
764
956
|
_queueSessionSummaryUpsert(session, summaryVersion);
|
|
765
957
|
clearSessionSaveError(id);
|
|
958
|
+
_droppedSaveIds.delete(id);
|
|
766
959
|
} finally {
|
|
767
960
|
_releaseWriteCommit(commitControl);
|
|
768
961
|
}
|
|
@@ -781,10 +974,51 @@ async function _doSave(payload) {
|
|
|
781
974
|
* Used by closeSession() to plant a tombstone that races against in-flight
|
|
782
975
|
* saveSession() calls.
|
|
783
976
|
*/
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
977
|
+
function _heartbeatMtime(id) {
|
|
978
|
+
try {
|
|
979
|
+
const path = join(getStoreDir(), `${id}.hb`);
|
|
980
|
+
return existsSync(path) ? (statSync(path).mtimeMs || 0) : 0;
|
|
981
|
+
} catch {
|
|
982
|
+
return 0;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function _runtimeLivenessVeto(id, options = {}) {
|
|
987
|
+
return typeof options.isSessionLive === 'function' && options.isSessionLive(id);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function _heartbeatLivenessVeto(id, options = {}) {
|
|
991
|
+
const heartbeatMtime = _heartbeatMtime(id);
|
|
992
|
+
if (!(heartbeatMtime > 0)) return false;
|
|
993
|
+
const hasHeartbeatSnapshot = Object.prototype.hasOwnProperty.call(options, 'heartbeatSnapshotMtime');
|
|
994
|
+
const snapshotMtime = Number(options.heartbeatSnapshotMtime) || 0;
|
|
995
|
+
if (hasHeartbeatSnapshot && heartbeatMtime > snapshotMtime) return true;
|
|
996
|
+
const freshMs = Number(options.heartbeatFreshMs);
|
|
997
|
+
return Number.isFinite(freshMs) && freshMs > 0 && Date.now() - heartbeatMtime <= freshMs;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function _deleteHeartbeatUnlessNewer(id, options = {}) {
|
|
1001
|
+
const hasHeartbeatSnapshot = Object.prototype.hasOwnProperty.call(options, 'heartbeatSnapshotMtime');
|
|
1002
|
+
const snapshotMtime = Number(options.heartbeatSnapshotMtime) || 0;
|
|
1003
|
+
if (!hasHeartbeatSnapshot || _heartbeatMtime(id) <= snapshotMtime) {
|
|
1004
|
+
_deleteHeartbeat(id);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
export function markSessionClosed(id, reason = 'manual', options = {}) {
|
|
1009
|
+
// Caller-provided probes may re-enter the store, so evaluate them before
|
|
1010
|
+
// taking the non-reentrant Atomics commit lock. A veto must also precede
|
|
1011
|
+
// pending-save cancellation so debounce durability remains intact.
|
|
1012
|
+
if (_runtimeLivenessVeto(id, options) || _heartbeatLivenessVeto(id, options)) return null;
|
|
1013
|
+
const closeGuard = _guardedSaveOptions(id);
|
|
1014
|
+
const commitControl = _acquireWriteCommit(closeGuard);
|
|
1015
|
+
if (commitControl === false) return null;
|
|
1016
|
+
try {
|
|
1017
|
+
// Cross-process heartbeat revival after full-TTL silence is accepted as a
|
|
1018
|
+
// best-effort race: the tombstone resurrection guard is the authoritative
|
|
1019
|
+
// post-race arbiter. Re-stat here, but never invoke caller code under lock.
|
|
1020
|
+
if (_heartbeatLivenessVeto(id, options)) return null;
|
|
1021
|
+
// Only a committed close may disrupt pending persistence.
|
|
788
1022
|
_clearDebounce(id);
|
|
789
1023
|
_cancelSessionWrites(id);
|
|
790
1024
|
_uncacheSessionSummary(id);
|
|
@@ -834,8 +1068,10 @@ export function markSessionClosed(id, reason = 'manual') {
|
|
|
834
1068
|
_savePending.delete(id);
|
|
835
1069
|
clearSessionSaveError(id);
|
|
836
1070
|
_clearLiveSession(id);
|
|
837
|
-
|
|
1071
|
+
// Preserve a sidecar published strictly after the sweep's scan snapshot.
|
|
1072
|
+
_deleteHeartbeatUnlessNewer(id, options);
|
|
838
1073
|
_queueSessionSummaryUpsert(tombstone);
|
|
1074
|
+
_droppedSaveIds.delete(id);
|
|
839
1075
|
// Structured close metric. Single emission point because every close
|
|
840
1076
|
// path funnels through markSessionClosed. lifeMs = updatedAt-createdAt
|
|
841
1077
|
// straddles the tombstone (updatedAt was just set to Date.now()), so
|
|
@@ -861,6 +1097,9 @@ export function markSessionClosed(id, reason = 'manual') {
|
|
|
861
1097
|
}
|
|
862
1098
|
} catch { /* logger never breaks the close path */ }
|
|
863
1099
|
return newGen;
|
|
1100
|
+
} finally {
|
|
1101
|
+
_releaseWriteCommit(commitControl);
|
|
1102
|
+
}
|
|
864
1103
|
}
|
|
865
1104
|
|
|
866
1105
|
/**
|
|
@@ -898,22 +1137,18 @@ export function bumpSessionGeneration(id, reason = 'detach') {
|
|
|
898
1137
|
_clearLiveSession(id);
|
|
899
1138
|
_deleteHeartbeat(id);
|
|
900
1139
|
_queueSessionSummaryUpsert(detached);
|
|
1140
|
+
_droppedSaveIds.delete(id);
|
|
901
1141
|
return newGen;
|
|
902
1142
|
}
|
|
903
1143
|
|
|
904
1144
|
export function loadSession(id) {
|
|
905
1145
|
const path = sessionPath(id);
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
if (stored?.id !== id) return null;
|
|
913
|
-
} catch {
|
|
914
|
-
return null;
|
|
915
|
-
}
|
|
916
|
-
}
|
|
1146
|
+
// An existing file owns this identity. Its contents must validate before
|
|
1147
|
+
// fresher in-memory state is allowed to shadow it. Unchanged atomic files
|
|
1148
|
+
// reuse their recent parsed object instead of reparsing the full transcript.
|
|
1149
|
+
const disk = _readStoredSessionCached(id, path);
|
|
1150
|
+
if (disk.exists && !disk.session) return null;
|
|
1151
|
+
const stored = disk.session;
|
|
917
1152
|
// Read-your-writes: if a save is pending (debouncing, scheduled, or queued
|
|
918
1153
|
// behind an in-flight write) return that payload instead of stale disk state.
|
|
919
1154
|
// The most-recently-queued slot is checked first (queued > payload).
|
|
@@ -923,7 +1158,24 @@ export function loadSession(id) {
|
|
|
923
1158
|
if (inMemory?.id === id) return _ensureLifecycleFields(inMemory);
|
|
924
1159
|
}
|
|
925
1160
|
const live = _liveSessions.get(id);
|
|
926
|
-
if (live?.id === id)
|
|
1161
|
+
if (live?.id === id) {
|
|
1162
|
+
// Terminal ↔ desktop interop: `generation` only moves on close/detach
|
|
1163
|
+
// (markSessionClosed / bumpSessionGeneration). A disk record with a
|
|
1164
|
+
// HIGHER generation means another process took ownership of this
|
|
1165
|
+
// session after our snapshot was cached — the local copy is stale and
|
|
1166
|
+
// must not shadow the newer on-disk transcript (its late saves would
|
|
1167
|
+
// be dropped by _shouldDrop's ownership rule anyway).
|
|
1168
|
+
const liveGen = typeof live.generation === 'number' ? live.generation : 0;
|
|
1169
|
+
const storedGen = stored && typeof stored.generation === 'number' ? stored.generation : 0;
|
|
1170
|
+
// A flagged dropped save means the generation moved WITHOUT newer
|
|
1171
|
+
// content landing (detach re-persists stale content) — the local
|
|
1172
|
+
// snapshot is the only complete transcript, so it keeps winning.
|
|
1173
|
+
if (stored && storedGen > liveGen && !_droppedSaveIds.has(id)) {
|
|
1174
|
+
_liveSessions.delete(id);
|
|
1175
|
+
} else {
|
|
1176
|
+
return _ensureLifecycleFields(live);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
927
1179
|
return stored ? _ensureLifecycleFields(stored) : null;
|
|
928
1180
|
}
|
|
929
1181
|
|
|
@@ -940,10 +1192,19 @@ export function clearSessionSaveError(id) {
|
|
|
940
1192
|
}
|
|
941
1193
|
|
|
942
1194
|
export function deleteSession(id, options = {}) {
|
|
1195
|
+
// Keep caller probes and all vetoes ahead of the non-reentrant lock and
|
|
1196
|
+
// ahead of pending-save disruption, matching markSessionClosed().
|
|
1197
|
+
if (_runtimeLivenessVeto(id, options) || _heartbeatLivenessVeto(id, options)) return false;
|
|
1198
|
+
const deleteGuard = _guardedSaveOptions(id);
|
|
1199
|
+
const commitControl = _acquireWriteCommit(deleteGuard);
|
|
1200
|
+
if (commitControl === false) return false;
|
|
1201
|
+
try {
|
|
1202
|
+
// Cross-process revival after full-TTL silence remains best-effort; the
|
|
1203
|
+
// tombstone resurrection guard is authoritative. Only re-stat .hb here.
|
|
1204
|
+
if (_heartbeatLivenessVeto(id, options)) return false;
|
|
943
1205
|
_cancelSessionWrites(id);
|
|
944
1206
|
_clearDebounce(id);
|
|
945
1207
|
_savePending.delete(id);
|
|
946
|
-
_waitForWriteCommit(id);
|
|
947
1208
|
const path = sessionPath(id);
|
|
948
1209
|
let removed = false;
|
|
949
1210
|
if (existsSync(path)) {
|
|
@@ -953,7 +1214,9 @@ export function deleteSession(id, options = {}) {
|
|
|
953
1214
|
}
|
|
954
1215
|
catch { /* fall through to .hb cleanup */ }
|
|
955
1216
|
}
|
|
956
|
-
|
|
1217
|
+
// Preserve a sidecar published strictly after the sweep's scan snapshot.
|
|
1218
|
+
_deleteHeartbeatUnlessNewer(id, options);
|
|
1219
|
+
deleteSessionPresence(id);
|
|
957
1220
|
_clearLiveSession(id);
|
|
958
1221
|
if (removed || !existsSync(path)) clearSessionSaveError(id);
|
|
959
1222
|
// deferSummaryUpdate: bulk callers (tombstone sweep) remove thousands of
|
|
@@ -962,6 +1225,9 @@ export function deleteSession(id, options = {}) {
|
|
|
962
1225
|
if (options.deferSummaryUpdate === true) _uncacheSessionSummary(id);
|
|
963
1226
|
else _queueSessionSummaryRemoval(id);
|
|
964
1227
|
return removed;
|
|
1228
|
+
} finally {
|
|
1229
|
+
_releaseWriteCommit(commitControl);
|
|
1230
|
+
}
|
|
965
1231
|
}
|
|
966
1232
|
const DEFAULT_SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes idle — aligned with Anthropic 5m messages tier and OpenAI in-memory cache window
|
|
967
1233
|
const AGENT_TERMINAL_STATUSES = new Set(['idle', 'done', 'error']);
|
|
@@ -982,6 +1248,9 @@ const RUNNING_STALL_MS = 10 * 60 * 1000;
|
|
|
982
1248
|
// (options.isSessionLive) is additionally protected as defense-in-depth.
|
|
983
1249
|
const RESUMABLE_OPEN_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
|
|
984
1250
|
const RESUMABLE_OPEN_MAX_COUNT = 300;
|
|
1251
|
+
// Blank scratch sessions (zero user/assistant conversation) are reaped once
|
|
1252
|
+
// idle this long — see the blank-scratch branch in sweepStaleSessions.
|
|
1253
|
+
const BLANK_SCRATCH_MAX_AGE_MS = 60 * 60 * 1000; // 1h
|
|
985
1254
|
|
|
986
1255
|
function _storedSessionFromFile(dir, filename, ensureLifecycle = true) {
|
|
987
1256
|
if (!filename.endsWith('.json')) return null;
|
|
@@ -1043,9 +1312,82 @@ function _withUnpersistedSessions(stored, invalidStorageIds = new Set()) {
|
|
|
1043
1312
|
return [...sessionsById.values()].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
1044
1313
|
}
|
|
1045
1314
|
|
|
1315
|
+
// ── Incremental storage scan ────────────────────────────────────────────────
|
|
1316
|
+
// refreshFromStorage used to re-parse EVERY session JSON (full transcripts,
|
|
1317
|
+
// multi-MB files) on each desktop sidebar refresh. A summary only changes when
|
|
1318
|
+
// its file changes, so key a per-file row cache on (mtimeMs, size): unchanged
|
|
1319
|
+
// files reuse the cached row, changed/new files re-parse, vanished files drop
|
|
1320
|
+
// out. Storage stays the truth source — the sidecar index is never trusted.
|
|
1321
|
+
const _summaryScanCache = new Map(); // filename → { mtimeMs, size, row|null }
|
|
1322
|
+
|
|
1323
|
+
function _scanStoredSessionSummaryRows() {
|
|
1324
|
+
const dir = getStoreDir();
|
|
1325
|
+
if (!existsSync(dir)) {
|
|
1326
|
+
const changed = _summaryScanCache.size > 0;
|
|
1327
|
+
_summaryScanCache.clear();
|
|
1328
|
+
return { rows: [], invalidStorageIds: new Set(), changed };
|
|
1329
|
+
}
|
|
1330
|
+
const files = readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
1331
|
+
const present = new Set(files);
|
|
1332
|
+
let changed = false;
|
|
1333
|
+
for (const key of [..._summaryScanCache.keys()]) {
|
|
1334
|
+
if (!present.has(key)) {
|
|
1335
|
+
_summaryScanCache.delete(key);
|
|
1336
|
+
changed = true;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
const rows = [];
|
|
1340
|
+
const invalidStorageIds = new Set();
|
|
1341
|
+
const markInvalid = (filename) => {
|
|
1342
|
+
const storageId = filename.slice(0, -5);
|
|
1343
|
+
if (/^[A-Za-z0-9_-]+$/.test(storageId)) invalidStorageIds.add(storageId);
|
|
1344
|
+
};
|
|
1345
|
+
for (const f of files) {
|
|
1346
|
+
let fileStat = null;
|
|
1347
|
+
try { fileStat = statSync(join(dir, f)); } catch { /* deleted mid-scan */ }
|
|
1348
|
+
if (!fileStat) {
|
|
1349
|
+
if (_summaryScanCache.delete(f)) changed = true;
|
|
1350
|
+
continue;
|
|
1351
|
+
}
|
|
1352
|
+
const cached = _summaryScanCache.get(f);
|
|
1353
|
+
if (cached && cached.mtimeMs === fileStat.mtimeMs && cached.size === fileStat.size) {
|
|
1354
|
+
if (cached.row) rows.push(cached.row);
|
|
1355
|
+
else markInvalid(f);
|
|
1356
|
+
continue;
|
|
1357
|
+
}
|
|
1358
|
+
const session = _storedSessionFromFile(dir, f);
|
|
1359
|
+
const row = session ? _sessionSummary(session) : null;
|
|
1360
|
+
_summaryScanCache.set(f, { mtimeMs: fileStat.mtimeMs, size: fileStat.size, row });
|
|
1361
|
+
changed = true;
|
|
1362
|
+
if (row) rows.push(row);
|
|
1363
|
+
else markInvalid(f);
|
|
1364
|
+
}
|
|
1365
|
+
rows.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
1366
|
+
return { rows, invalidStorageIds, changed };
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// Summary-level twin of _withUnpersistedSessions: overlay queued/in-flight
|
|
1370
|
+
// saves that have no disk record yet (read-your-writes for brand-new sessions).
|
|
1371
|
+
function _overlayUnpersistedSummaryRows(rows, invalidStorageIds = new Set()) {
|
|
1372
|
+
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
1373
|
+
const addIfUnpersisted = (id, session, opts) => {
|
|
1374
|
+
if (!id || byId.has(id) || invalidStorageIds.has(id) || _isCancelledWrite(opts)) return;
|
|
1375
|
+
if (session?.id !== id) return;
|
|
1376
|
+
const row = _sessionSummary(_ensureLifecycleFields(session));
|
|
1377
|
+
if (row) byId.set(id, row);
|
|
1378
|
+
};
|
|
1379
|
+
for (const [id, pending] of _savePending) {
|
|
1380
|
+
const payload = pending.queued || pending.payload;
|
|
1381
|
+
addIfUnpersisted(id, payload?.session, payload?.opts);
|
|
1382
|
+
}
|
|
1383
|
+
for (const [, pending] of _saveWorkerPending) addIfUnpersisted(pending.id, pending.session, pending.opts);
|
|
1384
|
+
for (const [id, pending] of _saveAsyncQueued) addIfUnpersisted(id, pending.session, pending.opts);
|
|
1385
|
+
return [...byId.values()].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1046
1388
|
export function rebuildSessionSummaryIndex() {
|
|
1047
1389
|
_ensureSummaryCacheDataDir();
|
|
1048
|
-
const rows =
|
|
1390
|
+
const { rows } = _scanStoredSessionSummaryRows();
|
|
1049
1391
|
return _setSummaryRowsCache(_writeSummaryIndex(rows));
|
|
1050
1392
|
}
|
|
1051
1393
|
|
|
@@ -1057,11 +1399,14 @@ export function listStoredSessionSummaries(options = {}) {
|
|
|
1057
1399
|
// authorization), so it must not trust either the cache or sidecar.
|
|
1058
1400
|
if (options.refreshFromStorage === true) {
|
|
1059
1401
|
try {
|
|
1060
|
-
const invalidStorageIds =
|
|
1061
|
-
const
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1402
|
+
const { rows: persistedRows, invalidStorageIds, changed } = _scanStoredSessionSummaryRows();
|
|
1403
|
+
const rows = _overlayUnpersistedSummaryRows(persistedRows, invalidStorageIds);
|
|
1404
|
+
// Unchanged scans skip the sidecar rewrite — refresh is called on
|
|
1405
|
+
// every sidebar poll/push and must not grind a multi-MB atomic
|
|
1406
|
+
// write when no session actually changed.
|
|
1407
|
+
if (changed) {
|
|
1408
|
+
try { _writeSummaryIndex(persistedRows); } catch { /* sidecar remains best-effort */ }
|
|
1409
|
+
}
|
|
1065
1410
|
// A direct scan settles deletion state too; retain only active
|
|
1066
1411
|
// optimistic write overlays, never a stale local removal.
|
|
1067
1412
|
_summaryCacheRemovals.clear();
|
|
@@ -1083,7 +1428,9 @@ export function listStoredSessionSummaries(options = {}) {
|
|
|
1083
1428
|
p = summaryIndexPath();
|
|
1084
1429
|
hasIndex = existsSync(p);
|
|
1085
1430
|
if (hasIndex) {
|
|
1086
|
-
|
|
1431
|
+
const raw = JSON.parse(readFileSync(p, 'utf-8'));
|
|
1432
|
+
hasIndex = Number(raw?.version) === SESSION_SUMMARY_INDEX_VERSION;
|
|
1433
|
+
if (hasIndex) indexedRows = _normalizeSummaryIndex(raw).rows;
|
|
1087
1434
|
}
|
|
1088
1435
|
} catch { /* unreadable/malformed sidecar falls through to rebuild */ }
|
|
1089
1436
|
|
|
@@ -1290,6 +1637,19 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
1290
1637
|
: (sweepTombstones ? tombstoneMaxAgeMs : 0);
|
|
1291
1638
|
const newestKnown = Math.max(row.updatedAt || 0, row.lastHeartbeatAt || 0, row.createdAt || 0, jsonMtime, heartbeatMtime);
|
|
1292
1639
|
if (freshnessGateMs > 0 && newestKnown > 0 && now - newestKnown <= freshnessGateMs) {
|
|
1640
|
+
// Fresh agent/legacy sessions survive idle close but still
|
|
1641
|
+
// participate in the resumable-open retention cap. The cap
|
|
1642
|
+
// performs its own commit-edge liveness veto before deletion.
|
|
1643
|
+
if (retainOpen && sweepIdle
|
|
1644
|
+
&& (!(typeof gateOwner === 'string' && gateOwner.length > 0)
|
|
1645
|
+
|| isAgentOwner({ owner: gateOwner }))) {
|
|
1646
|
+
openCandidates.push({
|
|
1647
|
+
id: row.id,
|
|
1648
|
+
lastActive: newestKnown,
|
|
1649
|
+
heartbeatSnapshotMtime: heartbeatMtime,
|
|
1650
|
+
heartbeatFreshMs: terminalReapMsForGate ?? maxAge,
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1293
1653
|
remaining++;
|
|
1294
1654
|
continue;
|
|
1295
1655
|
}
|
|
@@ -1314,6 +1674,33 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
1314
1674
|
// foreground session, which is idle during a gated sweep). Only the
|
|
1315
1675
|
// ephemeral agent/ownerless sessions below feed the cap.
|
|
1316
1676
|
if (typeof effOwner === 'string' && effOwner.length > 0 && !isAgentOwner(ownerRef)) {
|
|
1677
|
+
// Blank-scratch exception to user-session permanence: a session
|
|
1678
|
+
// with ZERO user/assistant conversation (engine boot artifact,
|
|
1679
|
+
// force-killed window, crashed host) has nothing to preserve.
|
|
1680
|
+
// Relaunch storms otherwise pile hundreds of "(blank)" rows
|
|
1681
|
+
// that no sweep may touch. Reap once cold; liveness/heartbeat
|
|
1682
|
+
// vetoes inside deleteSession still protect an in-flight boot.
|
|
1683
|
+
const _msgsArr = Array.isArray(actual?.messages) ? actual.messages : null;
|
|
1684
|
+
const _convCount = _msgsArr
|
|
1685
|
+
? _msgsArr.filter((m) => m && (m.role === 'user' || m.role === 'assistant')).length
|
|
1686
|
+
: (Number(row.messageCount) || 0);
|
|
1687
|
+
const _blankLastActive = Math.max(effUpdatedAt, effLastHb, effCreatedAt, heartbeatMtime || 0);
|
|
1688
|
+
if (sweepIdle && _convCount === 0
|
|
1689
|
+
&& now - _blankLastActive > BLANK_SCRATCH_MAX_AGE_MS
|
|
1690
|
+
&& !(isSessionLive && isSessionLive(row.id))) {
|
|
1691
|
+
try {
|
|
1692
|
+
if (deleteSession(row.id, {
|
|
1693
|
+
deferSummaryUpdate: true,
|
|
1694
|
+
isSessionLive,
|
|
1695
|
+
heartbeatSnapshotMtime: heartbeatMtime,
|
|
1696
|
+
heartbeatFreshMs: BLANK_SCRATCH_MAX_AGE_MS,
|
|
1697
|
+
})) {
|
|
1698
|
+
openPruned++;
|
|
1699
|
+
openPrunedDetails.push({ id: row.id, ageSeconds: Math.floor((now - _blankLastActive) / 1000) });
|
|
1700
|
+
continue;
|
|
1701
|
+
}
|
|
1702
|
+
} catch { /* keep the row on failure */ }
|
|
1703
|
+
}
|
|
1317
1704
|
remaining++;
|
|
1318
1705
|
continue;
|
|
1319
1706
|
}
|
|
@@ -1321,6 +1708,14 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
1321
1708
|
remaining++;
|
|
1322
1709
|
continue;
|
|
1323
1710
|
}
|
|
1711
|
+
// The manager may sweep while unrelated sessions are active. Protect
|
|
1712
|
+
// this specific locally-current/in-flight session regardless of stale
|
|
1713
|
+
// on-disk timestamps; its controller/heartbeat owner decides when it
|
|
1714
|
+
// is safe to become an idle-sweep candidate.
|
|
1715
|
+
if (isSessionLive && isSessionLive(row.id)) {
|
|
1716
|
+
remaining++;
|
|
1717
|
+
continue;
|
|
1718
|
+
}
|
|
1324
1719
|
// Prefer .hb sidecar mtime — updated at tight cadence (≤5s) without
|
|
1325
1720
|
// serialising the full JSON, so it reflects true liveness more
|
|
1326
1721
|
// accurately than the JSON timestamp fields.
|
|
@@ -1338,11 +1733,38 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
1338
1733
|
const terminalReapMs = isCompletedAgent ? terminalReapMsForGate : null;
|
|
1339
1734
|
const sessionMaxAge = terminalReapMs ?? maxAge;
|
|
1340
1735
|
if (now - lastActive > sessionMaxAge) {
|
|
1341
|
-
|
|
1736
|
+
// Close is destructive and the earlier heartbeat stat can race a
|
|
1737
|
+
// different process publishing fresh liveness. Re-check both
|
|
1738
|
+
// local runtime ownership and the sidecar at the commit edge.
|
|
1739
|
+
if (isSessionLive && isSessionLive(row.id)) {
|
|
1740
|
+
remaining++;
|
|
1741
|
+
continue;
|
|
1742
|
+
}
|
|
1743
|
+
let preCloseHeartbeatMtime = 0;
|
|
1744
|
+
try {
|
|
1745
|
+
const hbPath = join(dir, `${row.id}.hb`);
|
|
1746
|
+
if (existsSync(hbPath)) preCloseHeartbeatMtime = statSync(hbPath).mtimeMs || 0;
|
|
1747
|
+
} catch { /* sidecar unavailable — retain scan-time gates */ }
|
|
1748
|
+
if (preCloseHeartbeatMtime > 0 && now - preCloseHeartbeatMtime <= sessionMaxAge) {
|
|
1749
|
+
remaining++;
|
|
1750
|
+
continue;
|
|
1751
|
+
}
|
|
1752
|
+
let closeResult = null;
|
|
1753
|
+
try {
|
|
1754
|
+
closeResult = markSessionClosed(row.id, 'idle-sweep', {
|
|
1755
|
+
isSessionLive,
|
|
1756
|
+
heartbeatSnapshotMtime: heartbeatMtime,
|
|
1757
|
+
heartbeatFreshMs: sessionMaxAge,
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1342
1760
|
catch (err) {
|
|
1343
1761
|
process.stderr.write(`[session-store] idle-sweep close failed for ${row.id}: ${err?.message}\n`);
|
|
1344
1762
|
continue;
|
|
1345
1763
|
}
|
|
1764
|
+
if (closeResult == null) {
|
|
1765
|
+
remaining++;
|
|
1766
|
+
continue;
|
|
1767
|
+
}
|
|
1346
1768
|
cleaned++;
|
|
1347
1769
|
details.push({
|
|
1348
1770
|
id: row.id,
|
|
@@ -1351,7 +1773,12 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
1351
1773
|
bashSessionId: effBashId,
|
|
1352
1774
|
});
|
|
1353
1775
|
} else {
|
|
1354
|
-
if (retainOpen) openCandidates.push({
|
|
1776
|
+
if (retainOpen) openCandidates.push({
|
|
1777
|
+
id: row.id,
|
|
1778
|
+
lastActive,
|
|
1779
|
+
heartbeatSnapshotMtime: heartbeatMtime,
|
|
1780
|
+
heartbeatFreshMs: sessionMaxAge,
|
|
1781
|
+
});
|
|
1355
1782
|
remaining++;
|
|
1356
1783
|
}
|
|
1357
1784
|
}
|
|
@@ -1370,7 +1797,12 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
1370
1797
|
const overCount = kept >= openMaxCount;
|
|
1371
1798
|
if (!tooOld && !overCount) { kept++; continue; }
|
|
1372
1799
|
try {
|
|
1373
|
-
if (deleteSession(c.id, {
|
|
1800
|
+
if (deleteSession(c.id, {
|
|
1801
|
+
deferSummaryUpdate: true,
|
|
1802
|
+
isSessionLive,
|
|
1803
|
+
heartbeatSnapshotMtime: c.heartbeatSnapshotMtime,
|
|
1804
|
+
heartbeatFreshMs: c.heartbeatFreshMs,
|
|
1805
|
+
})) {
|
|
1374
1806
|
openPruned++;
|
|
1375
1807
|
openPrunedDetails.push({ id: c.id, ageSeconds: Math.floor((now - (c.lastActive || 0)) / 1000) });
|
|
1376
1808
|
if (remaining > 0) remaining--;
|
|
@@ -1380,14 +1812,14 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
1380
1812
|
} catch { kept++; }
|
|
1381
1813
|
}
|
|
1382
1814
|
}
|
|
1383
|
-
// Orphan .hb reap: a heartbeat sidecar whose .json no longer
|
|
1384
|
-
// weight once it is also stale (older than maxAge) — the
|
|
1385
|
-
// swept/closed but the
|
|
1386
|
-
// staleness gate avoids nuking the
|
|
1387
|
-
// write has not landed yet.
|
|
1815
|
+
// Orphan .hb/.own reap: a heartbeat/presence sidecar whose .json no longer
|
|
1816
|
+
// exists is dead weight once it is also stale (older than maxAge) — the
|
|
1817
|
+
// session JSON was swept/closed but the sidecar lingered (crashed owner or
|
|
1818
|
+
// pre-fix orphan). The staleness gate avoids nuking the sidecar of a
|
|
1819
|
+
// session mid-create whose .json write has not landed yet.
|
|
1388
1820
|
try {
|
|
1389
|
-
for (const h of readdirSync(dir).filter(f => f.endsWith('.hb'))) {
|
|
1390
|
-
if (existsSync(join(dir, h.replace(/\.hb$/, '.json')))) continue;
|
|
1821
|
+
for (const h of readdirSync(dir).filter(f => f.endsWith('.hb') || f.endsWith('.own'))) {
|
|
1822
|
+
if (existsSync(join(dir, h.replace(/\.(hb|own)$/, '.json')))) continue;
|
|
1391
1823
|
let hbMtime = 0;
|
|
1392
1824
|
try { hbMtime = statSync(join(dir, h)).mtimeMs; } catch { continue; }
|
|
1393
1825
|
if (now - hbMtime > maxAge) {
|