mixdog 0.9.68 → 0.9.69
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 +6 -4
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -6
- package/src/runtime/agent/orchestrator/context/collect.mjs +42 -27
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +0 -6
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +161 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +8 -204
- package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +25 -13
- package/src/runtime/agent/orchestrator/session/manager.mjs +0 -11
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +613 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +9 -575
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +41 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +154 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +78 -18
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +9 -144
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +7 -3
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +40 -3
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +24 -8
- package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +6 -2
- package/src/runtime/channels/lib/webhook.mjs +13 -1
- package/src/session-runtime/memory-daemon-probe.mjs +61 -0
- package/src/session-runtime/model-capabilities.mjs +6 -4
- package/src/session-runtime/notification-bus.mjs +82 -0
- package/src/session-runtime/provider-auth-api.mjs +16 -1
- package/src/session-runtime/provider-usage.mjs +3 -0
- package/src/session-runtime/remote-control.mjs +166 -0
- package/src/session-runtime/remote-transcript.mjs +126 -0
- package/src/session-runtime/remote-transition-queue.mjs +14 -0
- package/src/session-runtime/runtime-core.mjs +143 -705
- package/src/session-runtime/runtime-tunables.mjs +57 -0
- package/src/session-runtime/self-update.mjs +129 -0
- package/src/session-runtime/skills-api.mjs +77 -0
- package/src/session-runtime/tool-surface.mjs +83 -0
- package/src/session-runtime/workflow-agents-api.mjs +206 -3
- package/src/session-runtime/workflow.mjs +84 -17
- package/src/standalone/agent-tool/worker-index.mjs +287 -0
- package/src/standalone/agent-tool.mjs +22 -346
- package/src/standalone/agent-watchdog-registry.mjs +101 -0
- package/src/standalone/channel-worker.mjs +7 -13
- package/src/tui/components/StatusLine.jsx +6 -5
- package/src/tui/dist/index.mjs +124 -94
- package/src/tui/engine/labels.mjs +0 -8
- package/src/tui/engine/session-api-ext.mjs +17 -8
- package/src/tui/engine/turn-watchdog.mjs +92 -0
- package/src/tui/engine/turn.mjs +18 -70
- package/src/workflows/default/WORKFLOW.md +1 -1
- 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
|
+
}
|