mixdog 0.9.37 → 0.9.39
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 +9 -4
- package/scripts/abort-recovery-test.mjs +43 -2
- package/scripts/agent-tag-reuse-smoke.mjs +146 -6
- package/scripts/agent-terminal-reap-test.mjs +127 -0
- package/scripts/agent-trace-io-test.mjs +69 -0
- package/scripts/code-graph-disk-hit-test.mjs +224 -0
- package/scripts/dispatch-persist-recovery-test.mjs +141 -0
- package/scripts/execution-completion-dedup-test.mjs +157 -0
- package/scripts/execution-pending-resume-kick-test.mjs +57 -2
- package/scripts/execution-resume-esc-integration-test.mjs +174 -0
- package/scripts/explore-bench.mjs +101 -8
- package/scripts/explore-prompt-policy-test.mjs +156 -11
- package/scripts/find-fuzzy-hidden-test.mjs +122 -0
- package/scripts/internal-comms-bench-test.mjs +226 -0
- package/scripts/internal-comms-bench.mjs +185 -58
- package/scripts/internal-comms-smoke.mjs +171 -23
- package/scripts/live-worker-smoke.mjs +38 -2
- package/scripts/memory-cycle-routing-test.mjs +111 -0
- package/scripts/memory-rule-contract-test.mjs +93 -0
- package/scripts/notify-completion-mirror-test.mjs +73 -0
- package/scripts/output-style-smoke.mjs +2 -2
- package/scripts/rg-runner-test.mjs +240 -0
- package/scripts/routing-corpus-test.mjs +349 -0
- package/scripts/routing-corpus.mjs +211 -32
- package/scripts/session-orphan-sweep-test.mjs +83 -0
- package/scripts/session-sweep.mjs +266 -0
- package/scripts/steering-drain-buckets-test.mjs +179 -0
- package/scripts/tool-smoke.mjs +21 -13
- package/scripts/tool-tui-presentation-test.mjs +202 -0
- package/src/agents/heavy-worker/AGENT.md +10 -7
- package/src/agents/reviewer/AGENT.md +6 -4
- package/src/agents/worker/AGENT.md +7 -5
- package/src/rules/agent/00-common.md +4 -4
- package/src/rules/agent/00-core.md +11 -14
- package/src/rules/agent/20-skip-protocol.md +3 -3
- package/src/rules/agent/30-explorer.md +56 -48
- package/src/rules/agent/40-cycle1-agent.md +15 -24
- package/src/rules/agent/41-cycle2-agent.md +33 -57
- package/src/rules/agent/42-cycle3-agent.md +28 -42
- package/src/rules/lead/01-general.md +7 -10
- package/src/rules/lead/lead-brief.md +11 -14
- package/src/rules/lead/lead-tool.md +6 -5
- package/src/rules/shared/01-tool.md +44 -41
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +37 -1
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +20 -5
- package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
- package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +21 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +17 -38
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -4
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +17 -8
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +270 -78
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +18 -52
- package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +123 -0
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +15 -2
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +41 -2
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/store.mjs +339 -63
- package/src/runtime/agent/orchestrator/stall-policy.mjs +37 -0
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +25 -20
- package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +6 -2
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +118 -53
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +106 -29
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +8 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +4 -2
- package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +3 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +33 -2
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +151 -64
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +37 -13
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +84 -49
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +172 -23
- package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +3 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +68 -5
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +17 -3
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +24 -10
- package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +6 -3
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -7
- package/src/runtime/agent/orchestrator/tools/code-graph.mjs +1 -0
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -4
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +4 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +12 -2
- package/src/runtime/shared/tool-primitives.mjs +4 -1
- package/src/runtime/shared/tool-status.mjs +27 -0
- package/src/runtime/shared/tool-surface.mjs +6 -3
- package/src/session-runtime/config-helpers.mjs +14 -0
- package/src/session-runtime/context-status.mjs +1 -0
- package/src/session-runtime/effort.mjs +6 -2
- package/src/session-runtime/model-recency.mjs +5 -2
- package/src/session-runtime/provider-models.mjs +3 -3
- package/src/session-runtime/runtime-core.mjs +78 -10
- package/src/session-runtime/tool-catalog.mjs +34 -0
- package/src/session-runtime/warmup-schedulers.mjs +7 -1
- package/src/standalone/agent-tool/notify.mjs +13 -0
- package/src/standalone/agent-tool.mjs +45 -69
- package/src/standalone/explore-tool.mjs +6 -7
- package/src/tui/App.jsx +31 -0
- package/src/tui/app/model-options.mjs +5 -3
- package/src/tui/app/model-picker.mjs +12 -24
- package/src/tui/app/transcript-window.mjs +1 -0
- package/src/tui/components/ToolExecution.jsx +11 -6
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/components/tool-execution/surface-detail.mjs +24 -10
- package/src/tui/components/tool-execution/text-format.mjs +10 -19
- package/src/tui/dist/index.mjs +533 -143
- package/src/tui/engine/agent-job-feed.mjs +153 -16
- package/src/tui/engine/agent-response-tail.mjs +68 -0
- package/src/tui/engine/notification-plan.mjs +16 -0
- package/src/tui/engine/queue-helpers.mjs +8 -0
- package/src/tui/engine/session-api.mjs +8 -2
- package/src/tui/engine/session-flow.mjs +34 -2
- package/src/tui/engine/tool-card-results.mjs +54 -32
- package/src/tui/engine/tool-result-status.mjs +75 -21
- package/src/tui/engine/turn.mjs +83 -43
- package/src/tui/engine.mjs +63 -2
- package/src/workflows/bench/WORKFLOW.md +25 -35
- package/src/workflows/default/WORKFLOW.md +38 -32
- package/src/workflows/solo/WORKFLOW.md +19 -22
- package/scripts/_jitter-fuzz.mjs +0 -44410
- package/scripts/_jitter-fuzz2.mjs +0 -44400
- package/scripts/_jitter-probe.mjs +0 -44397
- package/scripts/_jp2.mjs +0 -45614
|
@@ -5,6 +5,7 @@ import { resolvePluginData } from '../../../../shared/plugin-paths.mjs';
|
|
|
5
5
|
import { updateJsonAtomicSync, updateJsonAtomic } from '../../../../shared/atomic-file.mjs';
|
|
6
6
|
import { promptContentText, isInternalRuntimeNotificationText } from './prompt-utils.mjs';
|
|
7
7
|
import { loadSession } from '../store.mjs';
|
|
8
|
+
import { isDeliveredCompletion, logDuplicateSkip } from './delivered-completions.mjs';
|
|
8
9
|
|
|
9
10
|
const _sessionPendingMessages = new Map();
|
|
10
11
|
const PENDING_MESSAGES_FILE = 'session-pending-messages.json';
|
|
@@ -24,6 +25,31 @@ function isCompletionNotificationEntry(entry) {
|
|
|
24
25
|
&& entry.notificationKind === COMPLETION_NOTIFICATION_KIND;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
// Pre-marker completion notifications were persisted as plain strings,
|
|
29
|
+
// indistinguishable from genuine user/steering messages except by their
|
|
30
|
+
// model-visible wrapper shape. Such stale strings must never replay into a
|
|
31
|
+
// resumed session, but they carry no notificationKind marker, so the marker
|
|
32
|
+
// check alone leaves them behind. Because this is a SILENT-drop path, the
|
|
33
|
+
// shared lenient wrapper detector is too broad (a user message quoting a
|
|
34
|
+
// completion card could be dropped), so this uses its OWN strict recognizer:
|
|
35
|
+
// the string must be a verbatim full-card paste and nothing else —
|
|
36
|
+
// (1) start with the exact instruction preamble + "\n\nResult:\n",
|
|
37
|
+
// (2) have EVERY non-empty body line quoted with "> " (100%),
|
|
38
|
+
// (3) carry no extra leading/trailing prose (whitespace-trim only).
|
|
39
|
+
// Conservative by design: a false negative just keeps a legacy string, but a
|
|
40
|
+
// false positive on genuine user text would silently drop a real message.
|
|
41
|
+
const LEGACY_COMPLETION_CARD_PREAMBLE_RE = /^The async \S+ task \S+ has finished \([^)]*\) - review this result in your next step\.\n\nResult:\n/;
|
|
42
|
+
function isLegacyUnmarkedCompletionNotification(entry) {
|
|
43
|
+
if (typeof entry !== 'string') return false;
|
|
44
|
+
const value = entry.trim();
|
|
45
|
+
const match = LEGACY_COMPLETION_CARD_PREAMBLE_RE.exec(value);
|
|
46
|
+
if (!match) return false;
|
|
47
|
+
const body = value.slice(match[0].length);
|
|
48
|
+
const lines = body.split(/\r?\n/).filter((line) => line.length > 0);
|
|
49
|
+
if (lines.length === 0) return false;
|
|
50
|
+
return lines.every((line) => line.startsWith('> '));
|
|
51
|
+
}
|
|
52
|
+
|
|
27
53
|
// Canonical completion-enqueue tagger. Every deferred tool/agent completion
|
|
28
54
|
// notification MUST be enqueued through this so drain can discard it on resume
|
|
29
55
|
// (never replay out-of-order). Pass the model-visible completion text (or an
|
|
@@ -393,8 +419,21 @@ export function drainPendingMessages(sessionId) {
|
|
|
393
419
|
// in-memory queue is empty, so keeping it only ever delivers live entries.
|
|
394
420
|
// Genuine user/steering messages carry no marker and are kept in order in
|
|
395
421
|
// both paths.
|
|
396
|
-
|
|
397
|
-
|
|
422
|
+
// Drain-time belt: drop MARKED in-memory completion entries whose text hash
|
|
423
|
+
// was already delivered+ACKed (TUI execution-ui) this process — those would
|
|
424
|
+
// double-inject next turn. Only marked completion entries are eligible;
|
|
425
|
+
// genuine user/steering entries carry no marker and are always kept.
|
|
426
|
+
const memoryKept = memory.filter((m) => {
|
|
427
|
+
if (!isCompletionNotificationEntry(m)) return true;
|
|
428
|
+
const text = pendingMessageText(m);
|
|
429
|
+
if (text && isDeliveredCompletion({ text })) {
|
|
430
|
+
logDuplicateSkip('drain', { text });
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
return true;
|
|
434
|
+
});
|
|
435
|
+
const persistedKept = persisted.filter((m) => !isCompletionNotificationEntry(m)
|
|
436
|
+
&& !isLegacyUnmarkedCompletionNotification(m));
|
|
398
437
|
const memoryVisible = modelVisiblePendingMessages(memoryKept);
|
|
399
438
|
const persistedVisible = modelVisiblePendingMessages(persistedKept);
|
|
400
439
|
if (memoryVisible.length === 0) return persistedVisible;
|
|
@@ -24,7 +24,7 @@ import { _closeBashSessionLazy } from './runtime-loaders.mjs';
|
|
|
24
24
|
* and recreates the session in its pre-close state.
|
|
25
25
|
*
|
|
26
26
|
* Long-term cleanup: `sweepTombstones()` below unlinks tombstones older than
|
|
27
|
-
* TOMBSTONE_MAX_AGE_MS (
|
|
27
|
+
* TOMBSTONE_MAX_AGE_MS (1h — vastly longer than the microsecond in-flight race).
|
|
28
28
|
*/
|
|
29
29
|
export function closeSession(id, reason = 'manual', opts = {}) {
|
|
30
30
|
// tombstone=false: detach runtime resources (heartbeat, bash shells,
|
|
@@ -8,12 +8,13 @@ import * as fsp from 'fs/promises';
|
|
|
8
8
|
import { randomBytes } from 'crypto';
|
|
9
9
|
import { join } from 'path';
|
|
10
10
|
import { Worker } from 'worker_threads';
|
|
11
|
-
import { getPluginData } from '../config.mjs';
|
|
11
|
+
import { getPluginData, loadConfig } from '../config.mjs';
|
|
12
12
|
import { isAgentOwner } from '../agent-owner.mjs';
|
|
13
13
|
import { renameWithRetrySync } from '../../../shared/atomic-file.mjs';
|
|
14
14
|
import { sanitizeContentForStoredHistory } from '../providers/media-normalization.mjs';
|
|
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
|
+
import { resolveAgentTerminalReapMs } from '../../../../session-runtime/config-helpers.mjs';
|
|
17
18
|
import {
|
|
18
19
|
summaryIndexPath,
|
|
19
20
|
_sessionSummary,
|
|
@@ -256,7 +257,16 @@ function _flushScheduled(id) {
|
|
|
256
257
|
// Single long-lived Worker serializes all saveSessionAsync calls.
|
|
257
258
|
// The worker's message queue preserves generation-race ordering.
|
|
258
259
|
let _saveWorker = null;
|
|
259
|
-
|
|
260
|
+
// In-flight writes, keyed by reqId. Value: { id, session, opts, waiters:[{resolve,reject}] }.
|
|
261
|
+
// At most ONE entry per session id at a time (single-in-flight-per-id).
|
|
262
|
+
let _saveWorkerPending = new Map();
|
|
263
|
+
// Latest-wins queued payload per session, keyed by id. Value: { session, opts, waiters:[] }.
|
|
264
|
+
// At most ONE queued write per id: a newer saveSessionAsync while a write is in
|
|
265
|
+
// flight overwrites session/opts here and appends its resolver to waiters, so
|
|
266
|
+
// every superseded caller resolves when this single queued write finally lands.
|
|
267
|
+
let _saveAsyncQueued = new Map();
|
|
268
|
+
// id → reqId of the in-flight write for that id (enforces one-in-flight-per-id).
|
|
269
|
+
let _saveAsyncInflight = new Map();
|
|
260
270
|
let _saveWorkerReqId = 0;
|
|
261
271
|
let _saveWorkerRefCount = 0;
|
|
262
272
|
|
|
@@ -273,12 +283,37 @@ function _getOrSpawnWorker() {
|
|
|
273
283
|
// becomes unref'd again once all in-flight writes settle. _saveWorker
|
|
274
284
|
// null-check covers the error/exit race where the worker died first.
|
|
275
285
|
if (--_saveWorkerRefCount === 0 && _saveWorker) _saveWorker.unref();
|
|
276
|
-
|
|
277
|
-
|
|
286
|
+
const { id, waiters } = p;
|
|
287
|
+
_saveAsyncInflight.delete(id);
|
|
288
|
+
// Resolve/reject every caller whose payload this write represents
|
|
289
|
+
// (the originating call plus any that coalesced onto it before it was
|
|
290
|
+
// posted). A supersede never lands here as a rejection — only a real
|
|
291
|
+
// worker failure does.
|
|
292
|
+
if (ok) { for (const w of waiters) w.resolve(); }
|
|
293
|
+
else {
|
|
294
|
+
const e = new Error(`[session-store] worker save failed: ${error}`);
|
|
295
|
+
for (const w of waiters) w.reject(e);
|
|
296
|
+
}
|
|
297
|
+
// Promote the latest-wins queued payload (if any) into the now-free
|
|
298
|
+
// in-flight slot for this id. Runs regardless of ok: the queued write
|
|
299
|
+
// is a newer, independent payload and must still be attempted so its
|
|
300
|
+
// (possibly superseded) waiters resolve when it lands.
|
|
301
|
+
const q = _saveAsyncQueued.get(id);
|
|
302
|
+
if (q) {
|
|
303
|
+
_saveAsyncQueued.delete(id);
|
|
304
|
+
try {
|
|
305
|
+
_postAsyncWrite(id, q.session, q.opts, q.waiters);
|
|
306
|
+
} catch (err) {
|
|
307
|
+
for (const w of q.waiters) w.reject(err);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
278
310
|
});
|
|
279
311
|
_saveWorker.on('error', (err) => {
|
|
280
|
-
for (const [, p] of _saveWorkerPending) p.reject(err);
|
|
312
|
+
for (const [, p] of _saveWorkerPending) for (const w of p.waiters) w.reject(err);
|
|
281
313
|
_saveWorkerPending.clear();
|
|
314
|
+
for (const [, q] of _saveAsyncQueued) for (const w of q.waiters) w.reject(err);
|
|
315
|
+
_saveAsyncQueued.clear();
|
|
316
|
+
_saveAsyncInflight.clear();
|
|
282
317
|
_saveWorkerRefCount = 0;
|
|
283
318
|
_saveWorker = null;
|
|
284
319
|
});
|
|
@@ -290,8 +325,11 @@ function _getOrSpawnWorker() {
|
|
|
290
325
|
// saveSessionAsync registered a resolver but before the worker
|
|
291
326
|
// received the message.
|
|
292
327
|
const err = new Error(`[session-store] save worker exited with code ${code}`);
|
|
293
|
-
for (const [, p] of _saveWorkerPending) p.reject(err);
|
|
328
|
+
for (const [, p] of _saveWorkerPending) for (const w of p.waiters) w.reject(err);
|
|
294
329
|
_saveWorkerPending.clear();
|
|
330
|
+
for (const [, q] of _saveAsyncQueued) for (const w of q.waiters) w.reject(err);
|
|
331
|
+
_saveAsyncQueued.clear();
|
|
332
|
+
_saveAsyncInflight.clear();
|
|
295
333
|
_saveWorkerRefCount = 0;
|
|
296
334
|
_saveWorker = null;
|
|
297
335
|
});
|
|
@@ -299,14 +337,45 @@ function _getOrSpawnWorker() {
|
|
|
299
337
|
return _saveWorker;
|
|
300
338
|
}
|
|
301
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Post one in-flight write for `id` to the worker and register it as the
|
|
342
|
+
* single in-flight entry for that id. Callers guarantee no write is already
|
|
343
|
+
* in flight for `id`. Throws (after cleaning its own map entries) if the
|
|
344
|
+
* worker postMessage fails so the caller can reject the affected waiters.
|
|
345
|
+
*/
|
|
346
|
+
function _postAsyncWrite(id, session, opts, waiters) {
|
|
347
|
+
const reqId = ++_saveWorkerReqId;
|
|
348
|
+
_saveWorkerPending.set(reqId, { id, session, opts, waiters });
|
|
349
|
+
_saveAsyncInflight.set(id, reqId);
|
|
350
|
+
try {
|
|
351
|
+
const w = _getOrSpawnWorker();
|
|
352
|
+
w.postMessage({ session, opts, reqId });
|
|
353
|
+
// Ref AFTER successful postMessage so a queue/throw failure path does
|
|
354
|
+
// not leave the worker held alive with no pending message. Paired with
|
|
355
|
+
// the unref in the message handler when count hits 0.
|
|
356
|
+
if (++_saveWorkerRefCount === 1) w.ref();
|
|
357
|
+
} catch (err) {
|
|
358
|
+
_saveWorkerPending.delete(reqId);
|
|
359
|
+
_saveAsyncInflight.delete(id);
|
|
360
|
+
throw err;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
302
364
|
/**
|
|
303
365
|
* Async save via a dedicated Worker thread.
|
|
304
366
|
* Errors surface as thrown Errors — callers must not silently swallow them.
|
|
367
|
+
*
|
|
368
|
+
* Per-session latest-wins coalescing: for a given id there is at most one
|
|
369
|
+
* write in flight plus one queued follow-up. N rapid saves for the same id in
|
|
370
|
+
* a turn collapse to (in-flight + one queued-latest), keeping the single
|
|
371
|
+
* worker's backlog bounded. Per-id write ORDERING is preserved (a queued write
|
|
372
|
+
* is only posted once the prior in-flight write for that id settles); different
|
|
373
|
+
* ids interleave freely as before.
|
|
305
374
|
*/
|
|
306
375
|
export function saveSessionAsync(session, opts) {
|
|
307
376
|
_ensureLifecycleFields(session);
|
|
308
377
|
setLiveSession(session);
|
|
309
|
-
const
|
|
378
|
+
const id = session.id;
|
|
310
379
|
const safeOpts = opts || null;
|
|
311
380
|
// The Worker `postMessage` below structured-clones the whole session on the
|
|
312
381
|
// main thread. `session.liveTurnMessages` (live working transcript) and
|
|
@@ -322,18 +391,28 @@ export function saveSessionAsync(session, opts) {
|
|
|
322
391
|
? (() => { const { liveTurnMessages: _dropLTM, toolApprovalHook: _dropTAH, ...rest } = session; return rest; })()
|
|
323
392
|
: session;
|
|
324
393
|
return new Promise((resolve, reject) => {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
394
|
+
const waiter = { resolve, reject };
|
|
395
|
+
if (_saveAsyncInflight.has(id)) {
|
|
396
|
+
// A write is already on disk for this id — coalesce into the single
|
|
397
|
+
// latest-wins queued slot. Existing queued waiters carry over so a
|
|
398
|
+
// superseded caller resolves when THIS newer write lands (never
|
|
399
|
+
// hang, never reject on supersede).
|
|
400
|
+
const q = _saveAsyncQueued.get(id);
|
|
401
|
+
if (q) {
|
|
402
|
+
q.session = clonePayload;
|
|
403
|
+
q.opts = safeOpts;
|
|
404
|
+
q.waiters.push(waiter);
|
|
405
|
+
} else {
|
|
406
|
+
_saveAsyncQueued.set(id, { session: clonePayload, opts: safeOpts, waiters: [waiter] });
|
|
407
|
+
}
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
// Idle for this id — post immediately as the in-flight write. The
|
|
411
|
+
// in-flight entry persists {session, opts} so drainSessionStore can
|
|
412
|
+
// sync-flush outstanding writes if process exit interrupts the queue.
|
|
328
413
|
try {
|
|
329
|
-
|
|
330
|
-
w.postMessage({ session: clonePayload, opts: safeOpts, reqId });
|
|
331
|
-
// Ref AFTER successful postMessage so a queue/throw failure path
|
|
332
|
-
// does not leave the worker held alive with no pending message.
|
|
333
|
-
// Paired with the unref in the message handler when count hits 0.
|
|
334
|
-
if (++_saveWorkerRefCount === 1) w.ref();
|
|
414
|
+
_postAsyncWrite(id, clonePayload, safeOpts, [waiter]);
|
|
335
415
|
} catch (err) {
|
|
336
|
-
_saveWorkerPending.delete(reqId);
|
|
337
416
|
reject(err);
|
|
338
417
|
}
|
|
339
418
|
});
|
|
@@ -431,18 +510,35 @@ export function drainSessionStore() {
|
|
|
431
510
|
// is sync-flushed directly here. The Promise is then rejected so the
|
|
432
511
|
// caller's await site does not leak unresolved (caller is at process
|
|
433
512
|
// exit so the rejection is informational, not actionable).
|
|
513
|
+
const _drainErr = new Error('[session-store] drain: worker-queue interrupted by process exit');
|
|
514
|
+
// Flush in-flight writes FIRST, then the latest-wins queued payloads, so
|
|
515
|
+
// for any id with both an in-flight and a queued write the queued (newest)
|
|
516
|
+
// state is written LAST and wins on disk — no lost last write.
|
|
434
517
|
for (const [, pending] of _saveWorkerPending) {
|
|
435
|
-
if (
|
|
518
|
+
if (pending.session) {
|
|
519
|
+
try {
|
|
520
|
+
_saveSessionSync(pending.session, pending.opts);
|
|
521
|
+
} catch (err) {
|
|
522
|
+
process.stderr.write(`[session-store] drain worker-queue save failed: ${err?.message}\n`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
for (const w of pending.waiters) {
|
|
526
|
+
try { w.reject(_drainErr); } catch { /* best-effort */ }
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
for (const [, q] of _saveAsyncQueued) {
|
|
436
530
|
try {
|
|
437
|
-
_saveSessionSync(
|
|
531
|
+
_saveSessionSync(q.session, q.opts);
|
|
438
532
|
} catch (err) {
|
|
439
533
|
process.stderr.write(`[session-store] drain worker-queue save failed: ${err?.message}\n`);
|
|
440
534
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
}
|
|
535
|
+
for (const w of q.waiters) {
|
|
536
|
+
try { w.reject(_drainErr); } catch { /* best-effort */ }
|
|
537
|
+
}
|
|
444
538
|
}
|
|
445
539
|
_saveWorkerPending.clear();
|
|
540
|
+
_saveAsyncQueued.clear();
|
|
541
|
+
_saveAsyncInflight.clear();
|
|
446
542
|
_saveWorkerRefCount = 0;
|
|
447
543
|
}
|
|
448
544
|
|
|
@@ -505,8 +601,37 @@ export function markSessionClosed(id, reason = 'manual') {
|
|
|
505
601
|
_clearDebounce(id);
|
|
506
602
|
const existing = loadSession(id);
|
|
507
603
|
if (!existing) return null;
|
|
508
|
-
|
|
509
|
-
|
|
604
|
+
// Re-close idempotence: a session that is ALREADY tombstoned keeps its
|
|
605
|
+
// ORIGINAL close time (updatedAt) and generation. The old code refreshed
|
|
606
|
+
// updatedAt=Date.now() on every call, so the 5-min idle sweep re-closing a
|
|
607
|
+
// stale summary row reset the tombstone age each cycle — tombstones never
|
|
608
|
+
// matured past the sweep threshold (immortality loop). Preserving the
|
|
609
|
+
// original close time lets the age accumulate so the tombstone sweep can
|
|
610
|
+
// reclaim it.
|
|
611
|
+
//
|
|
612
|
+
// The alreadyClosed / original-close-time / generation decision MUST come
|
|
613
|
+
// from the ON-DISK JSON, read cache-bypassing — NOT from loadSession(),
|
|
614
|
+
// which can serve a stale in-memory OPEN payload (a pending debounced save
|
|
615
|
+
// or a _liveSessions entry) after a late save. Deciding off that stale open
|
|
616
|
+
// copy would make a re-close of an already-tombstoned session look like a
|
|
617
|
+
// FIRST close and reset updatedAt+generation, resurrecting the exact
|
|
618
|
+
// immortality refresh this guard prevents. The disk file is the
|
|
619
|
+
// authoritative tombstone state.
|
|
620
|
+
let onDisk = null;
|
|
621
|
+
try { onDisk = JSON.parse(readFileSync(sessionPath(id), 'utf-8')); }
|
|
622
|
+
catch { onDisk = null; }
|
|
623
|
+
const alreadyClosed = onDisk
|
|
624
|
+
? (onDisk.closed === true || onDisk.status === 'closed')
|
|
625
|
+
: (existing.closed === true);
|
|
626
|
+
// When the on-disk copy is already closed, base the (idempotent) tombstone
|
|
627
|
+
// rewrite on IT rather than on `existing`, so a stale open in-memory
|
|
628
|
+
// payload can never clobber the persisted tombstone's content/fields.
|
|
629
|
+
const base = (alreadyClosed && onDisk) ? onDisk : existing;
|
|
630
|
+
const closeTime = (alreadyClosed && typeof base.updatedAt === 'number' && base.updatedAt > 0)
|
|
631
|
+
? base.updatedAt
|
|
632
|
+
: Date.now();
|
|
633
|
+
const newGen = (typeof base.generation === 'number' ? base.generation : 0) + (alreadyClosed ? 0 : 1);
|
|
634
|
+
const tombstone = { ...base, closed: true, closedReason: alreadyClosed ? (base.closedReason || reason) : reason, status: 'closed', generation: newGen, updatedAt: closeTime };
|
|
510
635
|
// Bypass the queue + guard — this IS the tombstone write.
|
|
511
636
|
const target = sessionPath(id);
|
|
512
637
|
const tmp = target + '.' + randomBytes(6).toString('hex') + '.tmp';
|
|
@@ -527,7 +652,10 @@ export function markSessionClosed(id, reason = 'manual') {
|
|
|
527
652
|
// it reflects the session's full lifetime including the close turn.
|
|
528
653
|
try {
|
|
529
654
|
const _dataDir = getPluginData();
|
|
530
|
-
|
|
655
|
+
// Emit the close metric only on the FIRST close — a re-close of an
|
|
656
|
+
// already-tombstoned session is a no-op idempotent write and must not
|
|
657
|
+
// spam the close log or double-count lifetimes.
|
|
658
|
+
if (_dataDir && !alreadyClosed) {
|
|
531
659
|
const _ts = new Date().toISOString();
|
|
532
660
|
const _lifeMs = (typeof existing.createdAt === 'number' && existing.createdAt > 0)
|
|
533
661
|
? (tombstone.updatedAt - existing.createdAt)
|
|
@@ -627,16 +755,24 @@ export function deleteSession(id, options = {}) {
|
|
|
627
755
|
return removed;
|
|
628
756
|
}
|
|
629
757
|
const DEFAULT_SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes idle — aligned with Anthropic 5m messages tier and OpenAI in-memory cache window
|
|
630
|
-
// Completed agents (idle/done/error) live until terminal reap - must
|
|
631
|
-
// match TERMINAL_REAP_MS / _scheduleBridgeReap (3_600_000) in index.mjs and
|
|
632
|
-
// agent stall watchdog, so the store sweep is the durable 1h reaper.
|
|
633
|
-
const AGENT_TERMINAL_TTL_MS = 60 * 60 * 1000;
|
|
634
758
|
const AGENT_TERMINAL_STATUSES = new Set(['idle', 'done', 'error']);
|
|
635
759
|
// Hard wall-clock ceiling for sessions stuck in status='running'. The
|
|
636
760
|
// stream-watchdog should abort stalled streams within ~120s, but if it misses
|
|
637
761
|
// one (process crash, watchdog not started, provider never returned), this
|
|
638
762
|
// backstop reclaims the file so the sweep doesn't leak zombies indefinitely.
|
|
639
763
|
const RUNNING_STALL_MS = 10 * 60 * 1000;
|
|
764
|
+
// Retention cap for resumable OPEN (non-tombstone) sessions. Lead/user resume
|
|
765
|
+
// closes sessions with { tombstone:false } — the runtime detaches but the
|
|
766
|
+
// session JSON stays open/resumable and is never lifecycle-closed, so without
|
|
767
|
+
// a cap the sessions/ dir grows without bound (observed 782 open files). The
|
|
768
|
+
// sweep prunes open sessions past EITHER bound: older than 14d, or beyond the
|
|
769
|
+
// newest 300 (oldest first). The cap targets ONLY ephemeral agent/ownerless
|
|
770
|
+
// sessions — explicit USER-owned conversations are never auto-pruned (deleting
|
|
771
|
+
// a user's history, including the current foreground session which is idle
|
|
772
|
+
// during a gated sweep, is unacceptable). A session with a live runtime entry
|
|
773
|
+
// (options.isSessionLive) is additionally protected as defense-in-depth.
|
|
774
|
+
const RESUMABLE_OPEN_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
|
|
775
|
+
const RESUMABLE_OPEN_MAX_COUNT = 300;
|
|
640
776
|
|
|
641
777
|
export function listStoredSessions() {
|
|
642
778
|
const dir = getStoreDir();
|
|
@@ -705,12 +841,44 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
705
841
|
}
|
|
706
842
|
const maxAge = ttlMs || DEFAULT_SESSION_TTL_MS;
|
|
707
843
|
const sweepIdle = options.sweepIdle !== false;
|
|
844
|
+
let terminalReapConfig = null;
|
|
845
|
+
try { terminalReapConfig = loadConfig({ secrets: false }); } catch { /* built-ins remain available */ }
|
|
708
846
|
const tombstoneMaxAgeMs = Number(options.tombstoneMaxAgeMs);
|
|
709
847
|
const sweepTombstones = Number.isFinite(tombstoneMaxAgeMs) && tombstoneMaxAgeMs > 0;
|
|
848
|
+
// Retention cap for resumable open sessions runs only on the idle sweep
|
|
849
|
+
// (never on a tombstone-only pass). isSessionLive protects the current /
|
|
850
|
+
// actively-running sessions from being pruned by the retention cap.
|
|
851
|
+
const isSessionLive = typeof options.isSessionLive === 'function' ? options.isSessionLive : null;
|
|
852
|
+
const retainOpen = sweepIdle && options.retainOpenSessions !== false;
|
|
853
|
+
const _optAge = Number(options.openMaxAgeMs);
|
|
854
|
+
const _optCount = Number(options.openMaxCount);
|
|
855
|
+
const openMaxAgeMs = Number.isFinite(_optAge) && _optAge > 0 ? _optAge : RESUMABLE_OPEN_MAX_AGE_MS;
|
|
856
|
+
const openMaxCount = Number.isFinite(_optCount) && _optCount >= 0 ? _optCount : RESUMABLE_OPEN_MAX_COUNT;
|
|
710
857
|
const dir = getStoreDir();
|
|
711
858
|
if (!existsSync(dir))
|
|
712
859
|
return { cleaned: 0, remaining: 0, details: [], tombstonesCleaned: 0, tombstoneDetails: [], tombstoneErrors: [] };
|
|
713
|
-
|
|
860
|
+
// Reconcile the index-derived candidate set with a direct directory scan:
|
|
861
|
+
// the summary index is a best-effort sidecar that can lag far behind disk
|
|
862
|
+
// (thousands of on-disk .json files may be absent from a smaller index).
|
|
863
|
+
// Any such orphan closed+mature tombstone would otherwise be unreachable by
|
|
864
|
+
// this sweep and accumulate forever. Union the index rows with every
|
|
865
|
+
// on-disk .json id, deduped by id; synthetic { id } rows are sufficient
|
|
866
|
+
// because the loop below re-reads all lifecycle truth from disk. This stays
|
|
867
|
+
// sweep-local and does NOT change listStoredSessionSummaries for other
|
|
868
|
+
// callers. Steady-state cost is one readdirSync plus cheap per-orphan reads.
|
|
869
|
+
const indexRows = listStoredSessionSummaries();
|
|
870
|
+
const summaries = indexRows;
|
|
871
|
+
try {
|
|
872
|
+
const seen = new Set();
|
|
873
|
+
for (const row of indexRows) { if (row?.id) seen.add(row.id); }
|
|
874
|
+
for (const f of readdirSync(dir)) {
|
|
875
|
+
if (!f.endsWith('.json')) continue;
|
|
876
|
+
const id = f.slice(0, -5);
|
|
877
|
+
if (!id || seen.has(id)) continue;
|
|
878
|
+
seen.add(id);
|
|
879
|
+
summaries.push({ id });
|
|
880
|
+
}
|
|
881
|
+
} catch { /* dir scan failure — fall back to index rows only */ }
|
|
714
882
|
const now = Date.now();
|
|
715
883
|
let cleaned = 0;
|
|
716
884
|
let remaining = 0;
|
|
@@ -718,6 +886,11 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
718
886
|
const details = [];
|
|
719
887
|
const tombstoneDetails = [];
|
|
720
888
|
const tombstoneErrors = [];
|
|
889
|
+
// Retention-cap bookkeeping: collect surviving open (non-tombstone)
|
|
890
|
+
// sessions here, then prune oldest-first after the main loop.
|
|
891
|
+
const openCandidates = [];
|
|
892
|
+
let openPruned = 0;
|
|
893
|
+
const openPrunedDetails = [];
|
|
721
894
|
for (const row of summaries) {
|
|
722
895
|
try {
|
|
723
896
|
if (!row?.id) continue;
|
|
@@ -733,38 +906,116 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
733
906
|
const hbPath = join(dir, `${row.id}.hb`);
|
|
734
907
|
if (existsSync(hbPath)) heartbeatMtime = statSync(hbPath).mtimeMs || 0;
|
|
735
908
|
} catch { /* .hb unavailable — fall back to JSON fields */ }
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
909
|
+
// Truth source: the summary index is a deferred/best-effort sidecar,
|
|
910
|
+
// so a row can still claim status='idle'/open while the session JSON
|
|
911
|
+
// was already tombstoned. Read the real session JSON BEFORE the
|
|
912
|
+
// freshness gate so closed-ness is decided from AUTHORITATIVE on-disk
|
|
913
|
+
// state — otherwise idle-sweep re-closes an already-closed session via
|
|
914
|
+
// markSessionClosed (which, pre-fix, reset the tombstone age every
|
|
915
|
+
// 5-min cycle → immortality loop).
|
|
916
|
+
let raw = null;
|
|
917
|
+
try { raw = readFileSync(jsonPath, 'utf-8'); }
|
|
918
|
+
catch { /* racing unlink / transient read failure */ }
|
|
919
|
+
let actual = null;
|
|
920
|
+
let diskClosed;
|
|
921
|
+
if (raw == null) {
|
|
922
|
+
diskClosed = (row.closed === true || row.status === 'closed');
|
|
923
|
+
} else {
|
|
924
|
+
// Cheap top-level scan avoids allocating the whole messages array
|
|
925
|
+
// just to read the closed flag for the (common) fresh open
|
|
926
|
+
// session the gate will skip; full-parse only when the scan can't
|
|
927
|
+
// resolve the top-level flag.
|
|
928
|
+
const scan = scanTopLevelLifecycle(raw);
|
|
929
|
+
if (scan && typeof scan.closed === 'boolean') {
|
|
930
|
+
diskClosed = scan.closed;
|
|
931
|
+
} else {
|
|
932
|
+
try { actual = JSON.parse(raw); } catch { actual = null; }
|
|
933
|
+
diskClosed = actual
|
|
934
|
+
? (actual.closed === true || actual.status === 'closed')
|
|
935
|
+
: (row.closed === true || row.status === 'closed');
|
|
936
|
+
}
|
|
741
937
|
}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
tombstoneErrors.push({ id: row.id, message: err?.message || String(err) });
|
|
757
|
-
remaining++;
|
|
938
|
+
if (diskClosed) {
|
|
939
|
+
// Closed sessions are EXEMPT from the freshness gate: a tombstone
|
|
940
|
+
// whose file/hb mtime keeps getting bumped would otherwise stay
|
|
941
|
+
// perpetually "fresh" and never mature. Maturity is governed ONLY
|
|
942
|
+
// by the ORIGINAL close time (disk updatedAt, not row.updatedAt
|
|
943
|
+
// which a stale row may carry from before the close).
|
|
944
|
+
if (!actual && raw != null) { try { actual = JSON.parse(raw); } catch { actual = null; } }
|
|
945
|
+
const closedAt = Number(actual?.updatedAt ?? row.updatedAt);
|
|
946
|
+
const age = now - closedAt;
|
|
947
|
+
if (sweepTombstones && Number.isFinite(closedAt) && age >= tombstoneMaxAgeMs) {
|
|
948
|
+
try {
|
|
949
|
+
if (deleteSession(row.id, { deferSummaryUpdate: true })) {
|
|
950
|
+
tombstonesCleaned++;
|
|
951
|
+
tombstoneDetails.push({ id: row.id, ageSeconds: Math.floor(age / 1000) });
|
|
758
952
|
continue;
|
|
759
953
|
}
|
|
954
|
+
} catch (err) {
|
|
955
|
+
tombstoneErrors.push({ id: row.id, message: err?.message || String(err) });
|
|
956
|
+
remaining++;
|
|
957
|
+
continue;
|
|
760
958
|
}
|
|
761
959
|
}
|
|
960
|
+
// Repair a stale summary row that still claimed the session was
|
|
961
|
+
// open: reflect the real closed state so the next sweep sees the
|
|
962
|
+
// correct closed=true/updatedAt and never re-closes it.
|
|
963
|
+
if (actual && !(row.closed === true || row.status === 'closed')) {
|
|
964
|
+
try { _upsertSessionSummary(actual); } catch { /* best-effort */ }
|
|
965
|
+
}
|
|
762
966
|
remaining++;
|
|
763
967
|
continue;
|
|
764
968
|
}
|
|
969
|
+
// Parse the open record before its freshness gate: completed agents
|
|
970
|
+
// use their provider's Advanced terminal duration rather than the
|
|
971
|
+
// general sweep cadence. A short provider override must therefore
|
|
972
|
+
// not be hidden behind the default 5-minute gate.
|
|
973
|
+
if (!actual && raw != null) { try { actual = JSON.parse(raw); } catch { actual = null; } }
|
|
974
|
+
const gateOwner = (actual && typeof actual.owner === 'string' && actual.owner.length > 0)
|
|
975
|
+
? actual.owner : row.owner;
|
|
976
|
+
const gateStatus = (actual && typeof actual.status === 'string') ? actual.status : row.status;
|
|
977
|
+
const gateProvider = (actual && typeof actual.provider === 'string') ? actual.provider : row.provider;
|
|
978
|
+
const isCompletedAgentForGate = isAgentOwner({ owner: gateOwner })
|
|
979
|
+
&& AGENT_TERMINAL_STATUSES.has(gateStatus);
|
|
980
|
+
const terminalReapMsForGate = isCompletedAgentForGate
|
|
981
|
+
? resolveAgentTerminalReapMs(terminalReapConfig, gateProvider)
|
|
982
|
+
: null;
|
|
983
|
+
if (isCompletedAgentForGate && terminalReapMsForGate == null) {
|
|
984
|
+
remaining++;
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
// Freshness gate — OPEN sessions only (closed sessions handled and
|
|
988
|
+
// `continue`d above). Recently-touched open sessions are skipped
|
|
989
|
+
// cheaply here.
|
|
990
|
+
const freshnessGateMs = sweepIdle
|
|
991
|
+
? (terminalReapMsForGate ?? maxAge)
|
|
992
|
+
: (sweepTombstones ? tombstoneMaxAgeMs : 0);
|
|
993
|
+
const newestKnown = Math.max(row.updatedAt || 0, row.lastHeartbeatAt || 0, row.createdAt || 0, jsonMtime, heartbeatMtime);
|
|
994
|
+
if (freshnessGateMs > 0 && newestKnown > 0 && now - newestKnown <= freshnessGateMs) {
|
|
995
|
+
remaining++;
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
// Prefer the AUTHORITATIVE on-disk JSON over the best-effort (and
|
|
999
|
+
// possibly stale) summary row for every open/idle liveness and
|
|
1000
|
+
// ownership decision below — a stale row must not close or prune the
|
|
1001
|
+
// wrong session. Full-parse here if the cheap scan skipped it.
|
|
1002
|
+
if (!actual && raw != null) { try { actual = JSON.parse(raw); } catch { actual = null; } }
|
|
1003
|
+
const effOwner = (actual && typeof actual.owner === 'string' && actual.owner.length > 0)
|
|
1004
|
+
? actual.owner : row.owner;
|
|
1005
|
+
const ownerRef = { owner: effOwner };
|
|
1006
|
+
const effStatus = (actual && typeof actual.status === 'string') ? actual.status : row.status;
|
|
1007
|
+
const effUpdatedAt = Number(actual?.updatedAt) > 0 ? Number(actual.updatedAt) : (row.updatedAt || 0);
|
|
1008
|
+
const effLastHb = Number(actual?.lastHeartbeatAt) > 0 ? Number(actual.lastHeartbeatAt) : (row.lastHeartbeatAt || 0);
|
|
1009
|
+
const effCreatedAt = Number(actual?.createdAt) > 0 ? Number(actual.createdAt) : (row.createdAt || 0);
|
|
1010
|
+
const effBashId = (actual && actual.implicitBashSessionId) || row.implicitBashSessionId || null;
|
|
1011
|
+
const effProvider = (actual && typeof actual.provider === 'string') ? actual.provider : row.provider;
|
|
765
1012
|
// Sweep agent-owned and ownerless (legacy) sessions; skip explicit
|
|
766
|
-
// user sessions before touching heartbeat sidecars.
|
|
767
|
-
|
|
1013
|
+
// user sessions before touching heartbeat sidecars. USER-owned
|
|
1014
|
+
// conversations are NEVER added to the retention-cap candidate set —
|
|
1015
|
+
// the cap must not auto-delete user history (nor the current
|
|
1016
|
+
// foreground session, which is idle during a gated sweep). Only the
|
|
1017
|
+
// ephemeral agent/ownerless sessions below feed the cap.
|
|
1018
|
+
if (typeof effOwner === 'string' && effOwner.length > 0 && !isAgentOwner(ownerRef)) {
|
|
768
1019
|
remaining++;
|
|
769
1020
|
continue;
|
|
770
1021
|
}
|
|
@@ -775,18 +1026,19 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
775
1026
|
// Prefer .hb sidecar mtime — updated at tight cadence (≤5s) without
|
|
776
1027
|
// serialising the full JSON, so it reflects true liveness more
|
|
777
1028
|
// accurately than the JSON timestamp fields.
|
|
778
|
-
let lastActive =
|
|
1029
|
+
let lastActive = effLastHb || effUpdatedAt || effCreatedAt || 0;
|
|
779
1030
|
if (heartbeatMtime) lastActive = Math.max(lastActive, heartbeatMtime);
|
|
780
1031
|
// Running sessions are normally reaped by the stream-watchdog
|
|
781
1032
|
// within ~120s. Skip them here unless they've been silent past
|
|
782
1033
|
// RUNNING_STALL_MS, at which point they are treated as zombies.
|
|
783
|
-
if (
|
|
1034
|
+
if (effStatus === 'running' && now - lastActive <= RUNNING_STALL_MS) {
|
|
784
1035
|
remaining++;
|
|
785
1036
|
continue;
|
|
786
1037
|
}
|
|
787
|
-
const isCompletedAgent = isAgentOwner(
|
|
788
|
-
&& AGENT_TERMINAL_STATUSES.has(
|
|
789
|
-
const
|
|
1038
|
+
const isCompletedAgent = isAgentOwner(ownerRef)
|
|
1039
|
+
&& AGENT_TERMINAL_STATUSES.has(effStatus);
|
|
1040
|
+
const terminalReapMs = isCompletedAgent ? terminalReapMsForGate : null;
|
|
1041
|
+
const sessionMaxAge = terminalReapMs ?? maxAge;
|
|
790
1042
|
if (now - lastActive > sessionMaxAge) {
|
|
791
1043
|
try { markSessionClosed(row.id, 'idle-sweep'); }
|
|
792
1044
|
catch (err) {
|
|
@@ -796,16 +1048,40 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
796
1048
|
cleaned++;
|
|
797
1049
|
details.push({
|
|
798
1050
|
id: row.id,
|
|
799
|
-
owner:
|
|
1051
|
+
owner: effOwner || 'unknown',
|
|
800
1052
|
idleMinutes: Math.round((now - lastActive) / 60000),
|
|
801
|
-
bashSessionId:
|
|
1053
|
+
bashSessionId: effBashId,
|
|
802
1054
|
});
|
|
803
1055
|
} else {
|
|
1056
|
+
if (retainOpen) openCandidates.push({ id: row.id, lastActive });
|
|
804
1057
|
remaining++;
|
|
805
1058
|
}
|
|
806
1059
|
}
|
|
807
1060
|
catch { /* skip corrupt */ }
|
|
808
1061
|
}
|
|
1062
|
+
// ── Retention cap: prune resumable open (non-tombstone) sessions ──────────
|
|
1063
|
+
// Newest-first: keep the most recent openMaxCount, prune anything older than
|
|
1064
|
+
// openMaxAgeMs OR beyond the count. Live/current sessions (isSessionLive)
|
|
1065
|
+
// are never pruned but still occupy a kept slot.
|
|
1066
|
+
if (retainOpen && openCandidates.length > 0) {
|
|
1067
|
+
openCandidates.sort((a, b) => (b.lastActive || 0) - (a.lastActive || 0));
|
|
1068
|
+
let kept = 0;
|
|
1069
|
+
for (const c of openCandidates) {
|
|
1070
|
+
if (isSessionLive && isSessionLive(c.id)) { kept++; continue; }
|
|
1071
|
+
const tooOld = openMaxAgeMs > 0 && now - (c.lastActive || 0) > openMaxAgeMs;
|
|
1072
|
+
const overCount = kept >= openMaxCount;
|
|
1073
|
+
if (!tooOld && !overCount) { kept++; continue; }
|
|
1074
|
+
try {
|
|
1075
|
+
if (deleteSession(c.id, { deferSummaryUpdate: true })) {
|
|
1076
|
+
openPruned++;
|
|
1077
|
+
openPrunedDetails.push({ id: c.id, ageSeconds: Math.floor((now - (c.lastActive || 0)) / 1000) });
|
|
1078
|
+
if (remaining > 0) remaining--;
|
|
1079
|
+
} else {
|
|
1080
|
+
kept++;
|
|
1081
|
+
}
|
|
1082
|
+
} catch { kept++; }
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
809
1085
|
// Orphan .hb reap: a heartbeat sidecar whose .json no longer exists is dead
|
|
810
1086
|
// weight once it is also stale (older than maxAge) — the session JSON was
|
|
811
1087
|
// swept/closed but the .hb lingered (a pre-fix orphaned heartbeat). The
|
|
@@ -825,11 +1101,11 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
825
1101
|
// read-modify-write for the whole sweep instead of one per deleted id
|
|
826
1102
|
// (the index is multi-MB at scale; per-id rewrites made large sweeps
|
|
827
1103
|
// quadratic and stalled boot for seconds).
|
|
828
|
-
if (tombstoneDetails.length > 0) {
|
|
1104
|
+
if (tombstoneDetails.length > 0 || openPrunedDetails.length > 0) {
|
|
829
1105
|
try {
|
|
830
|
-
const deletedIds = new Set(tombstoneDetails.map((d) => d.id));
|
|
1106
|
+
const deletedIds = new Set([...tombstoneDetails, ...openPrunedDetails].map((d) => d.id));
|
|
831
1107
|
_pruneSummaryIndexIds(deletedIds);
|
|
832
1108
|
} catch { /* summary index is best-effort */ }
|
|
833
1109
|
}
|
|
834
|
-
return { cleaned, remaining, details, tombstonesCleaned, tombstoneDetails, tombstoneErrors };
|
|
1110
|
+
return { cleaned, remaining, details, tombstonesCleaned, tombstoneDetails, tombstoneErrors, openPruned, openPrunedDetails };
|
|
835
1111
|
}
|