mixdog 0.9.68 → 0.9.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/package.json +8 -4
  2. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -6
  3. package/src/runtime/agent/orchestrator/context/collect.mjs +42 -27
  4. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +1 -1
  5. package/src/runtime/agent/orchestrator/providers/gemini.mjs +0 -6
  6. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +161 -0
  7. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +8 -204
  8. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +26 -0
  9. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +25 -13
  10. package/src/runtime/agent/orchestrator/session/manager.mjs +0 -11
  11. package/src/runtime/agent/orchestrator/session/store/listing.mjs +613 -0
  12. package/src/runtime/agent/orchestrator/session/store.mjs +9 -575
  13. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +41 -0
  14. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +154 -0
  15. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +78 -18
  16. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +9 -144
  17. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +7 -3
  18. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +40 -3
  19. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +24 -8
  20. package/src/runtime/channels/lib/owned-runtime.mjs +8 -1
  21. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +6 -2
  22. package/src/runtime/channels/lib/webhook.mjs +13 -1
  23. package/src/runtime/media/adapters/codex-image.mjs +109 -0
  24. package/src/runtime/media/adapters/gemini-image.mjs +68 -0
  25. package/src/runtime/media/adapters/gemini-video.mjs +119 -0
  26. package/src/runtime/media/adapters/xai-media.mjs +116 -0
  27. package/src/runtime/media/auth.mjs +51 -0
  28. package/src/runtime/media/index.mjs +17 -0
  29. package/src/runtime/media/jobs.mjs +174 -0
  30. package/src/runtime/media/lanes.mjs +230 -0
  31. package/src/runtime/media/store.mjs +164 -0
  32. package/src/runtime/media/upstream-error.mjs +39 -0
  33. package/src/session-runtime/channel-config-api.mjs +12 -1
  34. package/src/session-runtime/media-api.mjs +47 -0
  35. package/src/session-runtime/memory-daemon-probe.mjs +61 -0
  36. package/src/session-runtime/model-capabilities.mjs +6 -4
  37. package/src/session-runtime/model-route-api.mjs +4 -1
  38. package/src/session-runtime/notification-bus.mjs +82 -0
  39. package/src/session-runtime/provider-auth-api.mjs +16 -1
  40. package/src/session-runtime/provider-usage.mjs +3 -0
  41. package/src/session-runtime/remote-control.mjs +166 -0
  42. package/src/session-runtime/remote-transcript.mjs +126 -0
  43. package/src/session-runtime/remote-transition-queue.mjs +14 -0
  44. package/src/session-runtime/runtime-core.mjs +160 -706
  45. package/src/session-runtime/runtime-tunables.mjs +57 -0
  46. package/src/session-runtime/self-update.mjs +129 -0
  47. package/src/session-runtime/skills-api.mjs +77 -0
  48. package/src/session-runtime/tool-surface.mjs +83 -0
  49. package/src/session-runtime/warmup-schedulers.mjs +14 -1
  50. package/src/session-runtime/workflow-agents-api.mjs +235 -7
  51. package/src/session-runtime/workflow.mjs +84 -17
  52. package/src/standalone/agent-tool/worker-index.mjs +287 -0
  53. package/src/standalone/agent-tool.mjs +22 -346
  54. package/src/standalone/agent-watchdog-registry.mjs +101 -0
  55. package/src/standalone/channel-worker.mjs +7 -13
  56. package/src/tui/components/StatusLine.jsx +6 -5
  57. package/src/tui/dist/index.mjs +144 -95
  58. package/src/tui/engine/labels.mjs +0 -8
  59. package/src/tui/engine/session-api-ext.mjs +31 -8
  60. package/src/tui/engine/turn-watchdog.mjs +92 -0
  61. package/src/tui/engine/turn.mjs +18 -70
  62. package/src/tui/engine.mjs +39 -1
  63. package/src/workflows/default/WORKFLOW.md +1 -1
  64. package/src/workflows/solo/WORKFLOW.md +1 -1
@@ -0,0 +1,613 @@
1
+ // Session listing, summary projection and stale-session sweeping. Extracted
2
+ // from store.mjs, which keeps the persistence half (save/load/close/delete).
3
+ // The two halves share the in-flight save map so an unpersisted session still
4
+ // shows up in listings; the cycle is import-only (calls happen at runtime).
5
+ import { existsSync, readFileSync, readdirSync, statSync, unlinkSync } from 'fs';
6
+ import { loadConfig } from '../../config.mjs';
7
+ import { isAgentOwner } from '../../agent-owner.mjs';
8
+ import { scanTopLevelLifecycle } from '../lifecycle-scan.mjs';
9
+ import { resolveAgentTerminalReapMs } from '../../../../../session-runtime/config-helpers.mjs';
10
+ import { getStoreDir, sessionPath } from './paths-heartbeat.mjs';
11
+ import { isCancelledWrite as _isCancelledWrite } from './write-guards.mjs';
12
+ import {
13
+ summaryIndexPath,
14
+ _sessionSummary,
15
+ _normalizeSummaryIndex,
16
+ _writeSummaryIndex,
17
+ _hasUnsettledSummaryOps,
18
+ } from '../store-summary-index.mjs';
19
+ import {
20
+ _ensureSummaryCacheDataDir,
21
+ _cachedSummaryRows,
22
+ _setSummaryRowsCache,
23
+ _queueSummaryIndexPrune,
24
+ _scanStoredSessionSummaryRows,
25
+ _queueSessionSummaryUpsert,
26
+ _queueSessionSummaryRemoval,
27
+ _summaryCacheRemovals,
28
+ _summaryRowsCache,
29
+ } from './summary-cache.mjs';
30
+ import { _saveAsyncQueued, _saveWorkerPending } from './save-worker.mjs';
31
+ import { _ensureLifecycleFields, _storedSessionFromFile } from './serialize.mjs';
32
+ import { _savePending, deleteSession, markSessionClosed } from '../store.mjs';
33
+
34
+ // Disk mtime of the summary index when the in-memory cache was last refreshed
35
+ // from it — the cross-process staleness detector.
36
+ let _summaryIndexMtimeSeen = 0;
37
+
38
+ const DEFAULT_SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes idle — aligned with Anthropic 5m messages tier and OpenAI in-memory cache window
39
+ const AGENT_TERMINAL_STATUSES = new Set(['idle', 'done', 'error']);
40
+ // Hard wall-clock ceiling for sessions stuck in status='running'. The
41
+ // stream-watchdog should abort stalled streams within ~120s, but if it misses
42
+ // one (process crash, watchdog not started, provider never returned), this
43
+ // backstop reclaims the file so the sweep doesn't leak zombies indefinitely.
44
+ const RUNNING_STALL_MS = 10 * 60 * 1000;
45
+ // Retention cap for resumable OPEN (non-tombstone) sessions. Lead/user resume
46
+ // closes sessions with { tombstone:false } — the runtime detaches but the
47
+ // session JSON stays open/resumable and is never lifecycle-closed, so without
48
+ // a cap the sessions/ dir grows without bound (observed 782 open files). The
49
+ // sweep prunes open sessions past EITHER bound: older than 14d, or beyond the
50
+ // newest 300 (oldest first). The cap targets ONLY ephemeral agent/ownerless
51
+ // sessions — explicit USER-owned conversations are never auto-pruned (deleting
52
+ // a user's history, including the current foreground session which is idle
53
+ // during a gated sweep, is unacceptable). A session with a live runtime entry
54
+ // (options.isSessionLive) is additionally protected as defense-in-depth.
55
+ const RESUMABLE_OPEN_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
56
+ const RESUMABLE_OPEN_MAX_COUNT = 300;
57
+ // Blank scratch sessions (zero user/assistant conversation) are reaped once
58
+ // idle this long — see the blank-scratch branch in sweepStaleSessions.
59
+ const BLANK_SCRATCH_MAX_AGE_MS = 60 * 60 * 1000; // 1h
60
+
61
+
62
+ export function listStoredSessions(options = {}) {
63
+ const dir = getStoreDir();
64
+ if (!existsSync(dir))
65
+ return [];
66
+ const files = readdirSync(dir).filter(f => f.endsWith('.json'));
67
+ const sessionsById = new Map();
68
+ const invalidStorageIds = options._invalidStorageIds instanceof Set
69
+ ? options._invalidStorageIds
70
+ : new Set();
71
+ for (const f of files) {
72
+ const session = _storedSessionFromFile(dir, f);
73
+ if (session) {
74
+ sessionsById.set(session.id, session);
75
+ continue;
76
+ }
77
+ const storageId = f.slice(0, -5);
78
+ if (/^[A-Za-z0-9_-]+$/.test(storageId)) invalidStorageIds.add(storageId);
79
+ }
80
+ const stored = [...sessionsById.values()];
81
+ return options.includeLive === true
82
+ ? _withUnpersistedSessions(stored, invalidStorageIds)
83
+ : stored.sort((a, b) => b.updatedAt - a.updatedAt);
84
+ }
85
+
86
+ function _withUnpersistedSessions(stored, invalidStorageIds = new Set()) {
87
+ const sessionsById = new Map(stored.map((session) => [session.id, session]));
88
+ const addIfUnpersisted = (id, session, opts) => {
89
+ // A valid on-disk record is authoritative for refresh/resume. In
90
+ // particular, a long-lived runtime object must never replace a
91
+ // tombstone or changed desktop authorization metadata. Only active
92
+ // local writes with no disk record get read-your-writes visibility.
93
+ if (sessionsById.has(id) || invalidStorageIds.has(id) || _isCancelledWrite(opts)) return;
94
+ if (session?.id === id) sessionsById.set(id, _ensureLifecycleFields(session));
95
+ };
96
+ for (const [id, pending] of _savePending) {
97
+ const payload = pending.queued || pending.payload;
98
+ addIfUnpersisted(id, payload?.session, payload?.opts);
99
+ }
100
+ for (const [, pending] of _saveWorkerPending) {
101
+ addIfUnpersisted(pending.id, pending.session, pending.opts);
102
+ }
103
+ for (const [id, pending] of _saveAsyncQueued) {
104
+ addIfUnpersisted(id, pending.session, pending.opts);
105
+ }
106
+ return [...sessionsById.values()].sort((a, b) => b.updatedAt - a.updatedAt);
107
+ }
108
+
109
+
110
+ // Summary-level twin of _withUnpersistedSessions: overlay queued/in-flight
111
+ // saves that have no disk record yet (read-your-writes for brand-new sessions).
112
+ function _overlayUnpersistedSummaryRows(rows, invalidStorageIds = new Set()) {
113
+ const byId = new Map(rows.map((row) => [row.id, row]));
114
+ const addIfUnpersisted = (id, session, opts) => {
115
+ if (!id || byId.has(id) || invalidStorageIds.has(id) || _isCancelledWrite(opts)) return;
116
+ if (session?.id !== id) return;
117
+ const row = _sessionSummary(_ensureLifecycleFields(session));
118
+ if (row) byId.set(id, row);
119
+ };
120
+ for (const [id, pending] of _savePending) {
121
+ const payload = pending.queued || pending.payload;
122
+ addIfUnpersisted(id, payload?.session, payload?.opts);
123
+ }
124
+ for (const [, pending] of _saveWorkerPending) addIfUnpersisted(pending.id, pending.session, pending.opts);
125
+ for (const [id, pending] of _saveAsyncQueued) addIfUnpersisted(id, pending.session, pending.opts);
126
+ return [...byId.values()].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
127
+ }
128
+
129
+ function rebuildSessionSummaryIndex() {
130
+ _ensureSummaryCacheDataDir();
131
+ const { rows } = _scanStoredSessionSummaryRows();
132
+ const indexedRows = _writeSummaryIndex(rows);
133
+ // This process just authored the new cache base. Remember its mtime so the
134
+ // next local save cannot mistake the rebuild for a cross-process update
135
+ // and replace a newer in-memory row with the just-obsoleted sidecar.
136
+ try { _summaryIndexMtimeSeen = statSync(summaryIndexPath()).mtimeMs || 0; } catch { /* stat only */ }
137
+ return _setSummaryRowsCache(indexedRows);
138
+ }
139
+
140
+ export function listStoredSessionSummaries(options = {}) {
141
+ _ensureSummaryCacheDataDir();
142
+ const rebuildIfMissing = options.rebuildIfMissing !== false;
143
+ // This is intentionally the only path that rescans every session JSON:
144
+ // callers use it as an on-demand authoritative refresh (including resume
145
+ // authorization), so it must not trust either the cache or sidecar.
146
+ if (options.refreshFromStorage === true) {
147
+ try {
148
+ const { rows: persistedRows, invalidStorageIds, changed } = _scanStoredSessionSummaryRows();
149
+ const rows = _overlayUnpersistedSummaryRows(persistedRows, invalidStorageIds);
150
+ // Unchanged scans skip the sidecar rewrite — refresh is called on
151
+ // every sidebar poll/push and must not grind a multi-MB atomic
152
+ // write when no session actually changed.
153
+ if (changed) {
154
+ try { _writeSummaryIndex(persistedRows); } catch { /* sidecar remains best-effort */ }
155
+ }
156
+ // A direct scan settles deletion state too; retain only active
157
+ // optimistic write overlays, never a stale local removal.
158
+ _summaryCacheRemovals.clear();
159
+ _setSummaryRowsCache(persistedRows);
160
+ return rows;
161
+ } catch {
162
+ // A refresh is an authorization boundary for desktop resume. If
163
+ // authoritative storage cannot be enumerated, stale cached/sidecar
164
+ // rows must not be treated as proof that a session is available.
165
+ return [];
166
+ }
167
+ }
168
+ if (_summaryRowsCache !== null) {
169
+ // A local session save has already updated the in-memory cache but its
170
+ // non-blocking sidecar merge may still be queued/in flight. Re-reading
171
+ // the older sidecar in that window would temporarily erase the new row.
172
+ if (_hasUnsettledSummaryOps()) return _cachedSummaryRows().slice();
173
+ // Cross-process freshness: another live process (terminal CLI owning a
174
+ // session this surface only views) advances messageCount/updatedAt by
175
+ // rewriting the summary index FILE — an in-memory cache that never
176
+ // looks back at disk serves frozen rows forever (user: the unread dot
177
+ // never fired for terminal-owned growth). One stat per call; when the
178
+ // index advanced, re-read the cheap index JSON as the new cache base
179
+ // (local optimistic overlays stay applied on top).
180
+ let diskMtime = 0;
181
+ try { diskMtime = statSync(summaryIndexPath()).mtimeMs || 0; } catch { /* no index yet */ }
182
+ if (diskMtime <= _summaryIndexMtimeSeen) return _cachedSummaryRows().slice();
183
+ try {
184
+ const raw = JSON.parse(readFileSync(summaryIndexPath(), 'utf-8'));
185
+ if (Number(raw?.version) === SESSION_SUMMARY_INDEX_VERSION) {
186
+ _summaryIndexMtimeSeen = diskMtime;
187
+ return _setSummaryRowsCache(_normalizeSummaryIndex(raw).rows).slice();
188
+ }
189
+ } catch { /* torn concurrent write — keep serving the cache; retry next call */ }
190
+ return _cachedSummaryRows().slice();
191
+ }
192
+
193
+ let indexedRows = [];
194
+ let p;
195
+ let hasIndex = false;
196
+ try {
197
+ p = summaryIndexPath();
198
+ hasIndex = existsSync(p);
199
+ if (hasIndex) {
200
+ const raw = JSON.parse(readFileSync(p, 'utf-8'));
201
+ hasIndex = Number(raw?.version) === SESSION_SUMMARY_INDEX_VERSION;
202
+ if (hasIndex) indexedRows = _normalizeSummaryIndex(raw).rows;
203
+ if (hasIndex) {
204
+ try { _summaryIndexMtimeSeen = statSync(p).mtimeMs || 0; } catch { /* stat only */ }
205
+ }
206
+ }
207
+ } catch { /* unreadable/malformed sidecar falls through to rebuild */ }
208
+
209
+ if (!p || !hasIndex) {
210
+ try { return rebuildIfMissing ? rebuildSessionSummaryIndex() : _setSummaryRowsCache([]); }
211
+ catch { return _setSummaryRowsCache(indexedRows); }
212
+ }
213
+ try {
214
+ if (indexedRows.length > 0) return _setSummaryRowsCache(indexedRows);
215
+ const dir = getStoreDir();
216
+ const hasSessionFiles = existsSync(dir) && readdirSync(dir).some((f) => f.endsWith('.json'));
217
+ return hasSessionFiles && rebuildIfMissing ? rebuildSessionSummaryIndex() : _setSummaryRowsCache(indexedRows);
218
+ } catch {
219
+ try { return rebuildIfMissing ? rebuildSessionSummaryIndex() : _setSummaryRowsCache(indexedRows); }
220
+ catch { return _setSummaryRowsCache(indexedRows); }
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Raw directory scan — returns every parseable session file without any
226
+ * TTL-based inline deletion. Callers (e.g. sweepTombstones) need to own the
227
+ * unlink decision and log it themselves.
228
+ */
229
+ export function getStoredSessionsRaw() {
230
+ const dir = getStoreDir();
231
+ if (!existsSync(dir)) return [];
232
+ const files = readdirSync(dir).filter(f => f.endsWith('.json'));
233
+ const sessions = [];
234
+ for (const f of files) {
235
+ const session = _storedSessionFromFile(dir, f, false);
236
+ if (session) sessions.push(session);
237
+ }
238
+ return sessions;
239
+ }
240
+
241
+ /**
242
+ * Background sweep: delete session files idle longer than ttlMs.
243
+ * Returns { cleaned, remaining, details } for logging.
244
+ */
245
+ export function sweepStaleSessions(ttlMs, options = {}) {
246
+ if (ttlMs && typeof ttlMs === 'object') {
247
+ options = ttlMs;
248
+ ttlMs = options.ttlMs;
249
+ }
250
+ const maxAge = ttlMs || DEFAULT_SESSION_TTL_MS;
251
+ const sweepIdle = options.sweepIdle !== false;
252
+ let terminalReapConfig = null;
253
+ try { terminalReapConfig = loadConfig({ secrets: false }); } catch { /* built-ins remain available */ }
254
+ const tombstoneMaxAgeMs = Number(options.tombstoneMaxAgeMs);
255
+ const sweepTombstones = Number.isFinite(tombstoneMaxAgeMs) && tombstoneMaxAgeMs > 0;
256
+ // Retention cap for resumable open sessions runs only on the idle sweep
257
+ // (never on a tombstone-only pass). isSessionLive protects the current /
258
+ // actively-running sessions from being pruned by the retention cap.
259
+ const isSessionLive = typeof options.isSessionLive === 'function' ? options.isSessionLive : null;
260
+ const retainOpen = sweepIdle && options.retainOpenSessions !== false;
261
+ const _optAge = Number(options.openMaxAgeMs);
262
+ const _optCount = Number(options.openMaxCount);
263
+ const openMaxAgeMs = Number.isFinite(_optAge) && _optAge > 0 ? _optAge : RESUMABLE_OPEN_MAX_AGE_MS;
264
+ const openMaxCount = Number.isFinite(_optCount) && _optCount >= 0 ? _optCount : RESUMABLE_OPEN_MAX_COUNT;
265
+ const dir = getStoreDir();
266
+ if (!existsSync(dir))
267
+ return { cleaned: 0, remaining: 0, details: [], tombstonesCleaned: 0, tombstoneDetails: [], tombstoneErrors: [] };
268
+ // Reconcile the index-derived candidate set with a direct directory scan:
269
+ // the summary index is a best-effort sidecar that can lag far behind disk
270
+ // (thousands of on-disk .json files may be absent from a smaller index).
271
+ // Any such orphan closed+mature tombstone would otherwise be unreachable by
272
+ // this sweep and accumulate forever. Union the index rows with every
273
+ // on-disk .json id, deduped by id; synthetic { id } rows are sufficient
274
+ // because the loop below re-reads all lifecycle truth from disk. This stays
275
+ // sweep-local and does NOT change listStoredSessionSummaries for other
276
+ // callers. Steady-state cost is one readdirSync plus cheap per-orphan reads.
277
+ const indexRows = listStoredSessionSummaries();
278
+ const summaries = indexRows;
279
+ try {
280
+ const seen = new Set();
281
+ for (const row of indexRows) { if (row?.id) seen.add(row.id); }
282
+ for (const f of readdirSync(dir)) {
283
+ if (!f.endsWith('.json')) continue;
284
+ const id = f.slice(0, -5);
285
+ if (!id || seen.has(id)) continue;
286
+ seen.add(id);
287
+ summaries.push({ id });
288
+ }
289
+ } catch { /* dir scan failure — fall back to index rows only */ }
290
+ const now = Date.now();
291
+ let cleaned = 0;
292
+ let remaining = 0;
293
+ let tombstonesCleaned = 0;
294
+ const details = [];
295
+ const tombstoneDetails = [];
296
+ const tombstoneErrors = [];
297
+ // Retention-cap bookkeeping: collect surviving open (non-tombstone)
298
+ // sessions here, then prune oldest-first after the main loop.
299
+ const openCandidates = [];
300
+ let openPruned = 0;
301
+ const openPrunedDetails = [];
302
+ for (const row of summaries) {
303
+ try {
304
+ if (!row?.id) continue;
305
+ const jsonPath = sessionPath(row.id);
306
+ if (!existsSync(jsonPath)) {
307
+ _queueSessionSummaryRemoval(row.id);
308
+ continue;
309
+ }
310
+ let jsonMtime = 0;
311
+ let heartbeatMtime = 0;
312
+ try { jsonMtime = statSync(jsonPath).mtimeMs || 0; } catch {}
313
+ try {
314
+ const hbPath = join(dir, `${row.id}.hb`);
315
+ if (existsSync(hbPath)) heartbeatMtime = statSync(hbPath).mtimeMs || 0;
316
+ } catch { /* .hb unavailable — fall back to JSON fields */ }
317
+ // Truth source: the summary index is a deferred/best-effort sidecar,
318
+ // so a row can still claim status='idle'/open while the session JSON
319
+ // was already tombstoned. Read the real session JSON BEFORE the
320
+ // freshness gate so closed-ness is decided from AUTHORITATIVE on-disk
321
+ // state — otherwise idle-sweep re-closes an already-closed session via
322
+ // markSessionClosed (which, pre-fix, reset the tombstone age every
323
+ // 5-min cycle → immortality loop).
324
+ let raw = null;
325
+ try { raw = readFileSync(jsonPath, 'utf-8'); }
326
+ catch { /* racing unlink / transient read failure */ }
327
+ let actual = null;
328
+ let diskClosed;
329
+ if (raw == null) {
330
+ diskClosed = (row.closed === true || row.status === 'closed');
331
+ } else {
332
+ // Cheap top-level scan avoids allocating the whole messages array
333
+ // just to read the closed flag for the (common) fresh open
334
+ // session the gate will skip; full-parse only when the scan can't
335
+ // resolve the top-level flag.
336
+ const scan = scanTopLevelLifecycle(raw);
337
+ if (scan && typeof scan.closed === 'boolean') {
338
+ diskClosed = scan.closed;
339
+ } else {
340
+ try { actual = JSON.parse(raw); } catch { actual = null; }
341
+ diskClosed = actual
342
+ ? (actual.closed === true || actual.status === 'closed')
343
+ : (row.closed === true || row.status === 'closed');
344
+ }
345
+ }
346
+ if (diskClosed) {
347
+ // A shared store can be tombstoned by another process while
348
+ // this process still owns an in-flight controller for the same
349
+ // id. Exclude it before unlinking: clearing only the local
350
+ // runtime after deletion is too late because its eventual save
351
+ // would see no tombstone and could resurrect the session.
352
+ if (isSessionLive && isSessionLive(row.id)) {
353
+ remaining++;
354
+ continue;
355
+ }
356
+ // Closed sessions are EXEMPT from the freshness gate: a tombstone
357
+ // whose file/hb mtime keeps getting bumped would otherwise stay
358
+ // perpetually "fresh" and never mature. Maturity is governed ONLY
359
+ // by the ORIGINAL close time (disk updatedAt, not row.updatedAt
360
+ // which a stale row may carry from before the close).
361
+ if (!actual && raw != null) { try { actual = JSON.parse(raw); } catch { actual = null; } }
362
+ const closedAt = Number(actual?.updatedAt ?? row.updatedAt);
363
+ const age = now - closedAt;
364
+ if (sweepTombstones && Number.isFinite(closedAt) && age >= tombstoneMaxAgeMs) {
365
+ try {
366
+ if (deleteSession(row.id, { deferSummaryUpdate: true })) {
367
+ tombstonesCleaned++;
368
+ tombstoneDetails.push({ id: row.id, ageSeconds: Math.floor(age / 1000) });
369
+ continue;
370
+ }
371
+ } catch (err) {
372
+ tombstoneErrors.push({ id: row.id, message: err?.message || String(err) });
373
+ remaining++;
374
+ continue;
375
+ }
376
+ }
377
+ // Repair a stale summary row that still claimed the session was
378
+ // open: reflect the real closed state so the next sweep sees the
379
+ // correct closed=true/updatedAt and never re-closes it.
380
+ if (actual && !(row.closed === true || row.status === 'closed')) {
381
+ try { _queueSessionSummaryUpsert(actual); } catch { /* best-effort */ }
382
+ }
383
+ remaining++;
384
+ continue;
385
+ }
386
+ // Parse the open record before its freshness gate: completed agents
387
+ // use their provider's Advanced terminal duration rather than the
388
+ // general sweep cadence. A short provider override must therefore
389
+ // not be hidden behind the default 5-minute gate.
390
+ if (!actual && raw != null) { try { actual = JSON.parse(raw); } catch { actual = null; } }
391
+ const gateOwner = (actual && typeof actual.owner === 'string' && actual.owner.length > 0)
392
+ ? actual.owner : row.owner;
393
+ const gateStatus = (actual && typeof actual.status === 'string') ? actual.status : row.status;
394
+ const gateProvider = (actual && typeof actual.provider === 'string') ? actual.provider : row.provider;
395
+ const isCompletedAgentForGate = isAgentOwner({ owner: gateOwner })
396
+ && AGENT_TERMINAL_STATUSES.has(gateStatus);
397
+ const terminalReapMsForGate = isCompletedAgentForGate
398
+ ? resolveAgentTerminalReapMs(terminalReapConfig, gateProvider)
399
+ : null;
400
+ if (isCompletedAgentForGate && terminalReapMsForGate == null) {
401
+ remaining++;
402
+ continue;
403
+ }
404
+ // Freshness gate — OPEN sessions only (closed sessions handled and
405
+ // `continue`d above). Recently-touched open sessions are skipped
406
+ // cheaply here.
407
+ const freshnessGateMs = sweepIdle
408
+ ? (terminalReapMsForGate ?? maxAge)
409
+ : (sweepTombstones ? tombstoneMaxAgeMs : 0);
410
+ const newestKnown = Math.max(row.updatedAt || 0, row.lastHeartbeatAt || 0, row.createdAt || 0, jsonMtime, heartbeatMtime);
411
+ if (freshnessGateMs > 0 && newestKnown > 0 && now - newestKnown <= freshnessGateMs) {
412
+ // Fresh agent/legacy sessions survive idle close but still
413
+ // participate in the resumable-open retention cap. The cap
414
+ // performs its own commit-edge liveness veto before deletion.
415
+ if (retainOpen && sweepIdle
416
+ && (!(typeof gateOwner === 'string' && gateOwner.length > 0)
417
+ || isAgentOwner({ owner: gateOwner }))) {
418
+ openCandidates.push({
419
+ id: row.id,
420
+ lastActive: newestKnown,
421
+ heartbeatSnapshotMtime: heartbeatMtime,
422
+ heartbeatFreshMs: terminalReapMsForGate ?? maxAge,
423
+ });
424
+ }
425
+ remaining++;
426
+ continue;
427
+ }
428
+ // Prefer the AUTHORITATIVE on-disk JSON over the best-effort (and
429
+ // possibly stale) summary row for every open/idle liveness and
430
+ // ownership decision below — a stale row must not close or prune the
431
+ // wrong session. Full-parse here if the cheap scan skipped it.
432
+ if (!actual && raw != null) { try { actual = JSON.parse(raw); } catch { actual = null; } }
433
+ const effOwner = (actual && typeof actual.owner === 'string' && actual.owner.length > 0)
434
+ ? actual.owner : row.owner;
435
+ const ownerRef = { owner: effOwner };
436
+ const effStatus = (actual && typeof actual.status === 'string') ? actual.status : row.status;
437
+ const effUpdatedAt = Number(actual?.updatedAt) > 0 ? Number(actual.updatedAt) : (row.updatedAt || 0);
438
+ const effLastHb = Number(actual?.lastHeartbeatAt) > 0 ? Number(actual.lastHeartbeatAt) : (row.lastHeartbeatAt || 0);
439
+ const effCreatedAt = Number(actual?.createdAt) > 0 ? Number(actual.createdAt) : (row.createdAt || 0);
440
+ const effBashId = (actual && actual.implicitBashSessionId) || row.implicitBashSessionId || null;
441
+ const effProvider = (actual && typeof actual.provider === 'string') ? actual.provider : row.provider;
442
+ // Sweep agent-owned and ownerless (legacy) sessions; skip explicit
443
+ // user sessions before touching heartbeat sidecars. USER-owned
444
+ // conversations are NEVER added to the retention-cap candidate set —
445
+ // the cap must not auto-delete user history (nor the current
446
+ // foreground session, which is idle during a gated sweep). Only the
447
+ // ephemeral agent/ownerless sessions below feed the cap.
448
+ if (typeof effOwner === 'string' && effOwner.length > 0 && !isAgentOwner(ownerRef)) {
449
+ // Blank-scratch exception to user-session permanence: a session
450
+ // with ZERO user/assistant conversation (engine boot artifact,
451
+ // force-killed window, crashed host) has nothing to preserve.
452
+ // Relaunch storms otherwise pile hundreds of "(blank)" rows
453
+ // that no sweep may touch. Reap once cold; liveness/heartbeat
454
+ // vetoes inside deleteSession still protect an in-flight boot.
455
+ const _msgsArr = Array.isArray(actual?.messages) ? actual.messages : null;
456
+ const _convCount = _msgsArr
457
+ ? _msgsArr.filter((m) => m && (m.role === 'user' || m.role === 'assistant')).length
458
+ : (Number(row.messageCount) || 0);
459
+ const _blankLastActive = Math.max(effUpdatedAt, effLastHb, effCreatedAt, heartbeatMtime || 0);
460
+ if (sweepIdle && _convCount === 0
461
+ && now - _blankLastActive > BLANK_SCRATCH_MAX_AGE_MS
462
+ && !(isSessionLive && isSessionLive(row.id))) {
463
+ try {
464
+ if (deleteSession(row.id, {
465
+ deferSummaryUpdate: true,
466
+ isSessionLive,
467
+ heartbeatSnapshotMtime: heartbeatMtime,
468
+ heartbeatFreshMs: BLANK_SCRATCH_MAX_AGE_MS,
469
+ })) {
470
+ openPruned++;
471
+ openPrunedDetails.push({ id: row.id, ageSeconds: Math.floor((now - _blankLastActive) / 1000) });
472
+ continue;
473
+ }
474
+ } catch { /* keep the row on failure */ }
475
+ }
476
+ remaining++;
477
+ continue;
478
+ }
479
+ if (!sweepIdle) {
480
+ remaining++;
481
+ continue;
482
+ }
483
+ // The manager may sweep while unrelated sessions are active. Protect
484
+ // this specific locally-current/in-flight session regardless of stale
485
+ // on-disk timestamps; its controller/heartbeat owner decides when it
486
+ // is safe to become an idle-sweep candidate.
487
+ if (isSessionLive && isSessionLive(row.id)) {
488
+ remaining++;
489
+ continue;
490
+ }
491
+ // Prefer .hb sidecar mtime — updated at tight cadence (≤5s) without
492
+ // serialising the full JSON, so it reflects true liveness more
493
+ // accurately than the JSON timestamp fields.
494
+ let lastActive = effLastHb || effUpdatedAt || effCreatedAt || 0;
495
+ if (heartbeatMtime) lastActive = Math.max(lastActive, heartbeatMtime);
496
+ // Running sessions are normally reaped by the stream-watchdog
497
+ // within ~120s. Skip them here unless they've been silent past
498
+ // RUNNING_STALL_MS, at which point they are treated as zombies.
499
+ if (effStatus === 'running' && now - lastActive <= RUNNING_STALL_MS) {
500
+ remaining++;
501
+ continue;
502
+ }
503
+ const isCompletedAgent = isAgentOwner(ownerRef)
504
+ && AGENT_TERMINAL_STATUSES.has(effStatus);
505
+ const terminalReapMs = isCompletedAgent ? terminalReapMsForGate : null;
506
+ const sessionMaxAge = terminalReapMs ?? maxAge;
507
+ if (now - lastActive > sessionMaxAge) {
508
+ // Close is destructive and the earlier heartbeat stat can race a
509
+ // different process publishing fresh liveness. Re-check both
510
+ // local runtime ownership and the sidecar at the commit edge.
511
+ if (isSessionLive && isSessionLive(row.id)) {
512
+ remaining++;
513
+ continue;
514
+ }
515
+ let preCloseHeartbeatMtime = 0;
516
+ try {
517
+ const hbPath = join(dir, `${row.id}.hb`);
518
+ if (existsSync(hbPath)) preCloseHeartbeatMtime = statSync(hbPath).mtimeMs || 0;
519
+ } catch { /* sidecar unavailable — retain scan-time gates */ }
520
+ if (preCloseHeartbeatMtime > 0 && now - preCloseHeartbeatMtime <= sessionMaxAge) {
521
+ remaining++;
522
+ continue;
523
+ }
524
+ let closeResult = null;
525
+ try {
526
+ closeResult = markSessionClosed(row.id, 'idle-sweep', {
527
+ isSessionLive,
528
+ heartbeatSnapshotMtime: heartbeatMtime,
529
+ heartbeatFreshMs: sessionMaxAge,
530
+ });
531
+ }
532
+ catch (err) {
533
+ process.stderr.write(`[session-store] idle-sweep close failed for ${row.id}: ${err?.message}\n`);
534
+ continue;
535
+ }
536
+ if (closeResult == null) {
537
+ remaining++;
538
+ continue;
539
+ }
540
+ cleaned++;
541
+ details.push({
542
+ id: row.id,
543
+ owner: effOwner || 'unknown',
544
+ idleMinutes: Math.round((now - lastActive) / 60000),
545
+ bashSessionId: effBashId,
546
+ });
547
+ } else {
548
+ if (retainOpen) openCandidates.push({
549
+ id: row.id,
550
+ lastActive,
551
+ heartbeatSnapshotMtime: heartbeatMtime,
552
+ heartbeatFreshMs: sessionMaxAge,
553
+ });
554
+ remaining++;
555
+ }
556
+ }
557
+ catch { /* skip corrupt */ }
558
+ }
559
+ // ── Retention cap: prune resumable open (non-tombstone) sessions ──────────
560
+ // Newest-first: keep the most recent openMaxCount, prune anything older than
561
+ // openMaxAgeMs OR beyond the count. Live/current sessions (isSessionLive)
562
+ // are never pruned but still occupy a kept slot.
563
+ if (retainOpen && openCandidates.length > 0) {
564
+ openCandidates.sort((a, b) => (b.lastActive || 0) - (a.lastActive || 0));
565
+ let kept = 0;
566
+ for (const c of openCandidates) {
567
+ if (isSessionLive && isSessionLive(c.id)) { kept++; continue; }
568
+ const tooOld = openMaxAgeMs > 0 && now - (c.lastActive || 0) > openMaxAgeMs;
569
+ const overCount = kept >= openMaxCount;
570
+ if (!tooOld && !overCount) { kept++; continue; }
571
+ try {
572
+ if (deleteSession(c.id, {
573
+ deferSummaryUpdate: true,
574
+ isSessionLive,
575
+ heartbeatSnapshotMtime: c.heartbeatSnapshotMtime,
576
+ heartbeatFreshMs: c.heartbeatFreshMs,
577
+ })) {
578
+ openPruned++;
579
+ openPrunedDetails.push({ id: c.id, ageSeconds: Math.floor((now - (c.lastActive || 0)) / 1000) });
580
+ if (remaining > 0) remaining--;
581
+ } else {
582
+ kept++;
583
+ }
584
+ } catch { kept++; }
585
+ }
586
+ }
587
+ // Orphan .hb/.own reap: a heartbeat/presence sidecar whose .json no longer
588
+ // exists is dead weight once it is also stale (older than maxAge) — the
589
+ // session JSON was swept/closed but the sidecar lingered (crashed owner or
590
+ // pre-fix orphan). The staleness gate avoids nuking the sidecar of a
591
+ // session mid-create whose .json write has not landed yet.
592
+ try {
593
+ for (const h of readdirSync(dir).filter(f => f.endsWith('.hb') || f.endsWith('.own'))) {
594
+ if (existsSync(join(dir, h.replace(/\.(hb|own)$/, '.json')))) continue;
595
+ let hbMtime = 0;
596
+ try { hbMtime = statSync(join(dir, h)).mtimeMs; } catch { continue; }
597
+ if (now - hbMtime > maxAge) {
598
+ try { unlinkSync(join(dir, h)); cleaned++; } catch { /* ignore */ }
599
+ }
600
+ }
601
+ } catch { /* dir scan failure — non-fatal */ }
602
+ // Batched summary-index prune for deferred tombstone deletions: one
603
+ // read-modify-write for the whole sweep instead of one per deleted id
604
+ // (the index is multi-MB at scale; per-id rewrites made large sweeps
605
+ // quadratic and stalled boot for seconds).
606
+ if (tombstoneDetails.length > 0 || openPrunedDetails.length > 0) {
607
+ try {
608
+ const deletedIds = new Set([...tombstoneDetails, ...openPrunedDetails].map((d) => d.id));
609
+ _queueSummaryIndexPrune(deletedIds);
610
+ } catch { /* summary index is best-effort */ }
611
+ }
612
+ return { cleaned, remaining, details, tombstonesCleaned, tombstoneDetails, tombstoneErrors, openPruned, openPrunedDetails };
613
+ }