mixdog 0.9.83 → 0.9.84

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.83",
3
+ "version": "0.9.84",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -2,7 +2,7 @@
2
2
  // Periodic idle-session + tombstone sweep extracted verbatim from manager.mjs.
3
3
  // Drives sweepStaleSessions on an unref'd interval; closeSession is imported
4
4
  // from session-close.mjs (one-way dependency, no cycle).
5
- import { sweepStaleSessions, evictIdleLiveSessions } from '../store.mjs';
5
+ import { sweepStaleSessions, sweepStaleSessionsCooperative, evictIdleLiveSessions } from '../store.mjs';
6
6
  import { sweepOrphanedPendingMessages } from './pending-messages.mjs';
7
7
  import {
8
8
  _getRuntimeEntry,
@@ -23,6 +23,7 @@ const CLEANUP_SLOW_LOG_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_SLOW_LOG_M
23
23
  const TOMBSTONE_MAX_AGE_MS = 60 * 60 * 1000; // 1h
24
24
  let _cleanupTimer = null;
25
25
  let _cleanupInitialTimer = null;
26
+ let _cleanupRun = null;
26
27
 
27
28
  // A session is "live" when it still owns a non-closed runtime entry. Passed to
28
29
  // the retention cap so the active/current and any in-flight session is never
@@ -65,10 +66,10 @@ const _sweepLog = (line) => {
65
66
  if (process.env.MIXDOG_DEBUG_SESSION_LOG) process.stderr.write(line);
66
67
  };
67
68
 
68
- function sweepIdleSessions({ includeTombstones = true, sweepIdle = true } = {}) {
69
+ async function sweepIdleSessions({ includeTombstones = true, sweepIdle = true } = {}) {
69
70
  const startedAt = Date.now();
70
71
  try {
71
- const result = sweepStaleSessions({
72
+ const result = await sweepStaleSessionsCooperative({
72
73
  sweepIdle,
73
74
  tombstoneMaxAgeMs: includeTombstones ? TOMBSTONE_MAX_AGE_MS : 0,
74
75
  isSessionLive: _isSessionLive,
@@ -144,33 +145,43 @@ export function sweepTombstones() {
144
145
  }
145
146
 
146
147
  export function _runCleanupCycle() {
147
- // Drain every settled runtime entry on each pass, not just the one or two
148
- // sessions whose on-disk idle TTL happened to expire in this interval.
149
- _sweepTerminalSessionRuntimes();
150
- sweepOrphanedPendingMessages();
151
- sweepIdleSessions({ includeTombstones: true });
152
- // Reclaim same-process session snapshots whose state is durable on disk
153
- // (memory-leak guard: _liveSessions used to grow for process lifetime).
154
- try { evictIdleLiveSessions({ isSessionLive: _isSessionLive }); } catch { /* best-effort */ }
148
+ if (_cleanupRun) return _cleanupRun;
149
+ const run = (async () => {
150
+ // Drain every settled runtime entry on each pass, not just the one or two
151
+ // sessions whose on-disk idle TTL happened to expire in this interval.
152
+ _sweepTerminalSessionRuntimes();
153
+ sweepOrphanedPendingMessages();
154
+ await sweepIdleSessions({ includeTombstones: true });
155
+ // Reclaim same-process session snapshots whose state is durable on disk
156
+ // (memory-leak guard: _liveSessions used to grow for process lifetime).
157
+ try { evictIdleLiveSessions({ isSessionLive: _isSessionLive }); } catch { /* best-effort */ }
158
+ })().catch((error) => {
159
+ try { process.stderr.write(`[agent-session] cleanup cycle failed: ${error?.message || error}\n`); } catch {}
160
+ });
161
+ const tracked = run.finally(() => {
162
+ if (_cleanupRun === tracked) _cleanupRun = null;
163
+ });
164
+ _cleanupRun = tracked;
165
+ return tracked;
155
166
  }
156
167
 
157
168
  function _startCleanupInterval() {
158
169
  if (_cleanupTimer) return;
159
170
  if (CLEANUP_INTERVAL_MS <= 0) return;
160
- _cleanupTimer = setInterval(_runCleanupCycle, CLEANUP_INTERVAL_MS);
171
+ _cleanupTimer = setInterval(() => { void _runCleanupCycle(); }, CLEANUP_INTERVAL_MS);
161
172
  if (_cleanupTimer.unref) _cleanupTimer.unref(); // don't block process exit
162
173
  }
163
174
 
164
175
  export function startIdleCleanup() {
165
176
  if (_cleanupTimer || _cleanupInitialTimer) return;
166
177
  if (CLEANUP_INITIAL_DELAY_MS <= 0) {
167
- _runCleanupCycle();
178
+ void _runCleanupCycle();
168
179
  _startCleanupInterval();
169
180
  return;
170
181
  }
171
182
  _cleanupInitialTimer = setTimeout(() => {
172
183
  _cleanupInitialTimer = null;
173
- _runCleanupCycle();
184
+ void _runCleanupCycle();
174
185
  _startCleanupInterval();
175
186
  }, CLEANUP_INITIAL_DELAY_MS);
176
187
  if (_cleanupInitialTimer.unref) _cleanupInitialTimer.unref();
@@ -244,7 +244,7 @@ export function getStoredSessionsRaw() {
244
244
  * Background sweep: delete session files idle longer than ttlMs.
245
245
  * Returns { cleaned, remaining, details } for logging.
246
246
  */
247
- export function sweepStaleSessions(ttlMs, options = {}) {
247
+ function* sweepStaleSessionSteps(ttlMs, options = {}) {
248
248
  if (ttlMs && typeof ttlMs === 'object') {
249
249
  options = ttlMs;
250
250
  ttlMs = options.ttlMs;
@@ -302,6 +302,9 @@ export function sweepStaleSessions(ttlMs, options = {}) {
302
302
  let openPruned = 0;
303
303
  const openPrunedDetails = [];
304
304
  for (const row of summaries) {
305
+ // Cooperative callers pause between records so large stores never hold
306
+ // an interactive host's event loop for the full directory scan.
307
+ yield undefined;
305
308
  try {
306
309
  if (!row?.id) continue;
307
310
  const jsonPath = sessionPath(row.id);
@@ -593,6 +596,7 @@ export function sweepStaleSessions(ttlMs, options = {}) {
593
596
  // session mid-create whose .json write has not landed yet.
594
597
  try {
595
598
  for (const h of readdirSync(dir).filter(f => f.endsWith('.hb') || f.endsWith('.own'))) {
599
+ yield undefined;
596
600
  if (existsSync(join(dir, h.replace(/\.(hb|own)$/, '.json')))) continue;
597
601
  let hbMtime = 0;
598
602
  try { hbMtime = statSync(join(dir, h)).mtimeMs; } catch { continue; }
@@ -613,3 +617,36 @@ export function sweepStaleSessions(ttlMs, options = {}) {
613
617
  }
614
618
  return { cleaned, remaining, details, tombstonesCleaned, tombstoneDetails, tombstoneErrors, openPruned, openPrunedDetails };
615
619
  }
620
+
621
+ /** Synchronous compatibility surface for explicit maintenance commands/tests. */
622
+ export function sweepStaleSessions(ttlMs, options = {}) {
623
+ const steps = sweepStaleSessionSteps(ttlMs, options);
624
+ let next = steps.next();
625
+ while (!next.done) next = steps.next();
626
+ return next.value;
627
+ }
628
+
629
+ /**
630
+ * Interactive-host sweep: preserve the exact synchronous lifecycle decisions
631
+ * while yielding between records. A single large session remains atomic, but a
632
+ * directory worth of reads/parses can no longer become one multi-second task.
633
+ */
634
+ export async function sweepStaleSessionsCooperative(ttlMs, options = {}) {
635
+ const cooperativeOptions = ttlMs && typeof ttlMs === 'object' ? ttlMs : options;
636
+ const configuredSliceMs = Number(cooperativeOptions?.cooperativeSliceMs);
637
+ const sliceMs = Number.isFinite(configuredSliceMs)
638
+ ? Math.min(50, Math.max(0, configuredSliceMs))
639
+ : 8;
640
+ const steps = sweepStaleSessionSteps(ttlMs, options);
641
+ let next = steps.next();
642
+ while (!next.done) {
643
+ const sliceStartedAt = performance.now();
644
+ do {
645
+ next = steps.next();
646
+ } while (!next.done && performance.now() - sliceStartedAt < sliceMs);
647
+ if (!next.done) {
648
+ await new Promise((resolve) => setImmediate(resolve));
649
+ }
650
+ }
651
+ return next.value;
652
+ }
@@ -757,5 +757,6 @@ export {
757
757
  listStoredSessionSummaries,
758
758
  getStoredSessionsRaw,
759
759
  sweepStaleSessions,
760
+ sweepStaleSessionsCooperative,
760
761
  } from './store/listing.mjs';
761
762
  export { _savePending };