mixdog 0.9.87 → 0.9.89

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 (167) hide show
  1. package/package.json +13 -7
  2. package/scripts/tool-failures.mjs +60 -24
  3. package/src/agents/heavy-worker/AGENT.md +3 -4
  4. package/src/agents/worker/AGENT.md +1 -2
  5. package/src/cli.mjs +8 -0
  6. package/src/defaults/cycle3-review-prompt.md +4 -4
  7. package/src/rules/agent/00-core.md +3 -0
  8. package/src/rules/agent/42-cycle3-agent.md +5 -6
  9. package/src/rules/lead/01-general.md +2 -0
  10. package/src/rules/shared/01-tool.md +29 -35
  11. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +3 -51
  12. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +59 -0
  13. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -2
  14. package/src/runtime/agent/orchestrator/agent-runtime/title-completion.mjs +67 -0
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +57 -4
  16. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +2 -3
  17. package/src/runtime/agent/orchestrator/config.mjs +29 -11
  18. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
  20. package/src/runtime/agent/orchestrator/mcp/client.mjs +0 -24
  21. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +3 -3
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +25 -17
  23. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +321 -22
  24. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -22
  25. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +28 -23
  26. package/src/runtime/agent/orchestrator/providers/gemini.mjs +10 -2
  27. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +0 -1
  28. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +0 -1
  29. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +2 -5
  30. package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +27 -0
  31. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +23 -0
  32. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +329 -0
  33. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +23 -1
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +107 -20
  35. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +4 -3
  36. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +124 -12
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +19 -2
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +110 -75
  41. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +96 -113
  42. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +160 -11
  43. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +18 -1
  44. package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +147 -117
  45. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +88 -0
  46. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +50 -8
  47. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -0
  48. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +47 -6
  50. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +485 -24
  51. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +100 -2
  52. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -0
  53. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +80 -14
  54. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +142 -2
  55. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +128 -32
  56. package/src/runtime/agent/orchestrator/session/manager.mjs +5 -1
  57. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +77 -4
  58. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +70 -39
  59. package/src/runtime/agent/orchestrator/session/store/fs-probe.mjs +91 -0
  60. package/src/runtime/agent/orchestrator/session/store/listing.mjs +141 -64
  61. package/src/runtime/agent/orchestrator/session/store/live-state.mjs +253 -0
  62. package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +211 -28
  63. package/src/runtime/agent/orchestrator/session/store/save-fault.mjs +313 -0
  64. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +622 -53
  65. package/src/runtime/agent/orchestrator/session/store/serialize.mjs +43 -10
  66. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +53 -8
  67. package/src/runtime/agent/orchestrator/session/store/summary-rebuild-worker.mjs +20 -0
  68. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +113 -9
  69. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +2 -0
  70. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +476 -52
  71. package/src/runtime/agent/orchestrator/session/store.mjs +721 -196
  72. package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -4
  73. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -0
  74. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +4 -6
  75. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  76. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +146 -90
  77. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +6 -0
  78. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +93 -17
  79. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -0
  80. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +7 -7
  81. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +6 -1
  82. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +17 -1
  83. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +85 -5
  84. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +52 -0
  85. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +54 -51
  86. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -15
  87. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +132 -8
  88. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +86 -3
  89. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +6 -1
  90. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +210 -38
  91. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +23 -1
  92. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +87 -19
  93. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  94. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -8
  95. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -2
  96. package/src/runtime/media/lanes.mjs +2 -2
  97. package/src/runtime/media/renditions.mjs +101 -17
  98. package/src/runtime/media/renditions.test.mjs +54 -0
  99. package/src/runtime/media/store.mjs +111 -43
  100. package/src/runtime/media/store.test.mjs +53 -11
  101. package/src/runtime/memory/index.mjs +26 -50
  102. package/src/runtime/memory/lib/core-memory-candidates.mjs +2 -7
  103. package/src/runtime/memory/lib/core-memory-store.mjs +2 -9
  104. package/src/runtime/memory/lib/cycle-scheduler.mjs +10 -0
  105. package/src/runtime/memory/lib/embedding-provider.mjs +3 -3
  106. package/src/runtime/memory/lib/embedding-worker.mjs +60 -15
  107. package/src/runtime/memory/lib/http-router.mjs +4 -0
  108. package/src/runtime/memory/lib/ko-morph.mjs +49 -6
  109. package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -18
  110. package/src/runtime/memory/lib/memory-cycle2.mjs +3 -3
  111. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -3
  112. package/src/runtime/memory/lib/memory-recall-store.mjs +24 -0
  113. package/src/runtime/memory/lib/query-handlers.mjs +26 -28
  114. package/src/runtime/memory/tool-defs.mjs +3 -3
  115. package/src/runtime/shared/atomic-file.mjs +5 -1
  116. package/src/runtime/shared/child-guardian.mjs +73 -2
  117. package/src/runtime/shared/provider-api-key.mjs +22 -5
  118. package/src/runtime/shared/tool-execution-contract.mjs +53 -0
  119. package/src/session-runtime/config-helpers.mjs +6 -5
  120. package/src/session-runtime/lifecycle-api.mjs +16 -4
  121. package/src/session-runtime/media-api.mjs +31 -16
  122. package/src/session-runtime/prewarm.mjs +2 -24
  123. package/src/session-runtime/runtime-core.mjs +33 -15
  124. package/src/session-runtime/runtime-tunables.mjs +0 -3
  125. package/src/session-runtime/session-lifecycle.mjs +0 -6
  126. package/src/session-runtime/session-title.mjs +228 -0
  127. package/src/session-runtime/session-turn-api.mjs +11 -7
  128. package/src/session-runtime/workflow-agents-api.mjs +16 -0
  129. package/src/session-runtime/workflow.mjs +7 -5
  130. package/src/standalone/agent-tool/job-views.mjs +90 -36
  131. package/src/standalone/agent-tool/spawn-flow.mjs +25 -216
  132. package/src/standalone/agent-tool/worker-index.mjs +8 -0
  133. package/src/standalone/agent-tool/worker-rows.mjs +2 -1
  134. package/src/standalone/agent-tool.mjs +17 -17
  135. package/src/standalone/agent-watchdog-registry.mjs +19 -2
  136. package/src/standalone/channel-daemon.mjs +8 -0
  137. package/src/standalone/explore-tool.mjs +1 -1
  138. package/src/standalone/memory-runtime-proxy.mjs +4 -7
  139. package/src/standalone/projects.mjs +4 -1
  140. package/src/standalone/usage-dashboard.mjs +25 -3
  141. package/src/tui/App.jsx +5 -3
  142. package/src/tui/app/resume-picker.mjs +7 -2
  143. package/src/tui/app/route-pickers.mjs +11 -117
  144. package/src/tui/app/slash-dispatch.mjs +13 -3
  145. package/src/tui/app/transcript-row-estimate.mjs +1 -1
  146. package/src/tui/app/transcript-window.mjs +17 -93
  147. package/src/tui/app/use-transcript-scroll.mjs +32 -2
  148. package/src/tui/app/use-transcript-window.mjs +64 -114
  149. package/src/tui/components/Markdown.jsx +19 -2
  150. package/src/tui/components/Message.jsx +8 -3
  151. package/src/tui/components/ToolExecution.jsx +7 -3
  152. package/src/tui/dist/index.mjs +243 -223
  153. package/src/tui/engine/live-share.mjs +15 -5
  154. package/src/tui/engine/render-timing.mjs +2 -2
  155. package/src/tui/engine/session-api-ext.mjs +74 -4
  156. package/src/tui/engine/session-flow.mjs +3 -1
  157. package/src/tui/engine.mjs +2 -2
  158. package/src/tui/hooks/useSharedTick.mjs +0 -2
  159. package/src/tui/index.jsx +5 -5
  160. package/src/ui/statusline-agents.mjs +44 -11
  161. package/src/vendor/statusline/bin/statusline-route.mjs +26 -3
  162. package/src/workflows/default/WORKFLOW.md +3 -3
  163. package/src/workflows/solo/WORKFLOW.md +5 -3
  164. package/src/runtime/media/index.mjs +0 -18
  165. package/src/runtime/memory/lib/embedding-warmup.mjs +0 -68
  166. package/src/standalone/agent-shard/shard-child.mjs +0 -300
  167. package/src/standalone/agent-shard/shard-pool.mjs +0 -443
@@ -2,13 +2,15 @@
2
2
  // from store.mjs, which keeps the persistence half (save/load/close/delete).
3
3
  // The two halves share the in-flight save map so an unpersisted session still
4
4
  // shows up in listings; the cycle is import-only (calls happen at runtime).
5
- import { existsSync, readFileSync, readdirSync, statSync, unlinkSync } from 'fs';
5
+ import { readFileSync, readdirSync, statSync, unlinkSync } from 'fs';
6
6
  import { join } from 'path';
7
- import { loadConfig } from '../../config.mjs';
7
+ import { Worker } from 'worker_threads';
8
+ import { getPluginData, loadConfig } from '../../config.mjs';
8
9
  import { isAgentOwner } from '../../agent-owner.mjs';
9
- import { scanTopLevelLifecycle } from '../lifecycle-scan.mjs';
10
+ import { readTopLevelLifecycleRecord, isLifecycleUnreadable } from '../lifecycle-scan.mjs';
10
11
  import { resolveAgentTerminalReapMs } from '../../../../../session-runtime/config-helpers.mjs';
11
12
  import { getStoreDir, sessionPath } from './paths-heartbeat.mjs';
13
+ import { probePath, PROBE_PRESENT, PROBE_ABSENT } from './fs-probe.mjs';
12
14
  import { isCancelledWrite as _isCancelledWrite } from './write-guards.mjs';
13
15
  import {
14
16
  SESSION_SUMMARY_INDEX_VERSION,
@@ -30,12 +32,14 @@ import {
30
32
  _summaryRowsCache,
31
33
  } from './summary-cache.mjs';
32
34
  import { _saveAsyncQueued, _saveWorkerPending } from './save-worker.mjs';
33
- import { _ensureLifecycleFields, _storedSessionFromFile } from './serialize.mjs';
35
+ import { STORED_SESSION_UNREADABLE, _ensureLifecycleFields, _storedSessionFromFile } from './serialize.mjs';
34
36
  import { _savePending, deleteSession, markSessionClosed } from '../store.mjs';
35
37
 
36
38
  // Disk mtime of the summary index when the in-memory cache was last refreshed
37
39
  // from it — the cross-process staleness detector.
38
40
  let _summaryIndexMtimeSeen = 0;
41
+ let _summaryRebuildWorker = null;
42
+ let _summaryRebuildDataDir = '';
39
43
 
40
44
  const DEFAULT_SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes idle — aligned with Anthropic 5m messages tier and OpenAI in-memory cache window
41
45
  const AGENT_TERMINAL_STATUSES = new Set(['idle', 'done', 'error']);
@@ -63,8 +67,10 @@ const BLANK_SCRATCH_MAX_AGE_MS = 60 * 60 * 1000; // 1h
63
67
 
64
68
  export function listStoredSessions(options = {}) {
65
69
  const dir = getStoreDir();
66
- if (!existsSync(dir))
67
- return [];
70
+ // Only ENOENT/ENOTDIR is absence. An unreadable directory (EACCES/EIO) is
71
+ // NOT "no sessions": listing yields nothing, but nothing may be treated as
72
+ // deleted/absent on that basis either.
73
+ if (probePath(dir).state !== PROBE_PRESENT) return [];
68
74
  const files = readdirSync(dir).filter(f => f.endsWith('.json'));
69
75
  const sessionsById = new Map();
70
76
  const invalidStorageIds = options._invalidStorageIds instanceof Set
@@ -72,10 +78,12 @@ export function listStoredSessions(options = {}) {
72
78
  : new Set();
73
79
  for (const f of files) {
74
80
  const session = _storedSessionFromFile(dir, f);
75
- if (session) {
81
+ if (session && session !== STORED_SESSION_UNREADABLE) {
76
82
  sessionsById.set(session.id, session);
77
83
  continue;
78
84
  }
85
+ // Present-but-unreadable owns its identity too: it may never be
86
+ // replaced by a live/pending overlay, so it is marked invalid.
79
87
  const storageId = f.slice(0, -5);
80
88
  if (/^[A-Za-z0-9_-]+$/.test(storageId)) invalidStorageIds.add(storageId);
81
89
  }
@@ -128,20 +136,40 @@ function _overlayUnpersistedSummaryRows(rows, invalidStorageIds = new Set()) {
128
136
  return [...byId.values()].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
129
137
  }
130
138
 
131
- function rebuildSessionSummaryIndex() {
132
- _ensureSummaryCacheDataDir();
133
- const { rows } = _scanStoredSessionSummaryRows();
134
- const indexedRows = _writeSummaryIndex(rows);
135
- // This process just authored the new cache base. Remember its mtime so the
136
- // next local save cannot mistake the rebuild for a cross-process update
137
- // and replace a newer in-memory row with the just-obsoleted sidecar.
138
- try { _summaryIndexMtimeSeen = statSync(summaryIndexPath()).mtimeMs || 0; } catch { /* stat only */ }
139
- return _setSummaryRowsCache(indexedRows);
139
+ function scheduleSessionSummaryIndexRebuild() {
140
+ const dataDir = getPluginData();
141
+ if (_summaryRebuildWorker && _summaryRebuildDataDir === dataDir) return;
142
+ if (_summaryRebuildWorker) {
143
+ try { void _summaryRebuildWorker.terminate(); } catch { /* stale worker exits independently */ }
144
+ }
145
+ let worker;
146
+ try {
147
+ worker = new Worker(new URL('./summary-rebuild-worker.mjs', import.meta.url), {
148
+ execArgv: [],
149
+ workerData: { dataDir },
150
+ });
151
+ } catch {
152
+ return;
153
+ }
154
+ _summaryRebuildWorker = worker;
155
+ _summaryRebuildDataDir = dataDir;
156
+ const clear = () => {
157
+ if (_summaryRebuildWorker !== worker) return;
158
+ _summaryRebuildWorker = null;
159
+ _summaryRebuildDataDir = '';
160
+ };
161
+ worker.on('message', (message) => {
162
+ if (message?.ok !== true || message.dataDir !== getPluginData() || !Array.isArray(message.rows)) return;
163
+ try { _summaryIndexMtimeSeen = statSync(summaryIndexPath()).mtimeMs || 0; } catch { /* stat only */ }
164
+ _setSummaryRowsCache(message.rows);
165
+ });
166
+ worker.on('error', clear);
167
+ worker.on('exit', clear);
168
+ worker.unref();
140
169
  }
141
170
 
142
171
  export function listStoredSessionSummaries(options = {}) {
143
172
  _ensureSummaryCacheDataDir();
144
- const rebuildIfMissing = options.rebuildIfMissing !== false;
145
173
  // This is intentionally the only path that rescans every session JSON:
146
174
  // callers use it as an on-demand authoritative refresh (including resume
147
175
  // authorization), so it must not trust either the cache or sidecar.
@@ -195,11 +223,26 @@ export function listStoredSessionSummaries(options = {}) {
195
223
  let indexedRows = [];
196
224
  let p;
197
225
  let hasIndex = false;
226
+ let indexUnreadable = false;
198
227
  try {
199
228
  p = summaryIndexPath();
200
- hasIndex = existsSync(p);
229
+ const probe = probePath(p);
230
+ hasIndex = probe.state === PROBE_PRESENT;
231
+ if (probe.state !== PROBE_PRESENT && probe.state !== PROBE_ABSENT) indexUnreadable = true;
201
232
  if (hasIndex) {
202
- const raw = JSON.parse(readFileSync(p, 'utf-8'));
233
+ let text;
234
+ try {
235
+ text = readFileSync(p, 'utf-8');
236
+ } catch (err) {
237
+ // Stat said PRESENT and the read failed: the sidecar is there
238
+ // and we cannot have it. FAIL CLOSED — keep serving the cache,
239
+ // never emit empty rows and never schedule a rebuild that
240
+ // would overwrite a sidecar we could not read.
241
+ const code = err?.code || 'EUNKNOWN';
242
+ if (code !== 'ENOENT' && code !== 'ENOTDIR') indexUnreadable = true;
243
+ throw err;
244
+ }
245
+ const raw = JSON.parse(text);
203
246
  hasIndex = Number(raw?.version) === SESSION_SUMMARY_INDEX_VERSION;
204
247
  if (hasIndex) indexedRows = _normalizeSummaryIndex(raw).rows;
205
248
  if (hasIndex) {
@@ -208,18 +251,27 @@ export function listStoredSessionSummaries(options = {}) {
208
251
  }
209
252
  } catch { /* unreadable/malformed sidecar falls through to rebuild */ }
210
253
 
254
+ if (indexUnreadable) {
255
+ // Present-but-unreadable sidecar: retain authority. A COLD cache has
256
+ // no rows yet (null) — return an empty result rather than crashing on
257
+ // `.slice()`, and never rebuild/replace the sidecar we could not read.
258
+ const cached = _cachedSummaryRows();
259
+ return Array.isArray(cached) ? cached.slice() : [];
260
+ }
211
261
  if (!p || !hasIndex) {
212
- try { return rebuildIfMissing ? rebuildSessionSummaryIndex() : _setSummaryRowsCache([]); }
213
- catch { return _setSummaryRowsCache(indexedRows); }
262
+ scheduleSessionSummaryIndexRebuild();
263
+ return _setSummaryRowsCache(indexedRows);
214
264
  }
215
265
  try {
216
266
  if (indexedRows.length > 0) return _setSummaryRowsCache(indexedRows);
217
267
  const dir = getStoreDir();
218
- const hasSessionFiles = existsSync(dir) && readdirSync(dir).some((f) => f.endsWith('.json'));
219
- return hasSessionFiles && rebuildIfMissing ? rebuildSessionSummaryIndex() : _setSummaryRowsCache(indexedRows);
268
+ const hasSessionFiles = probePath(dir).state === PROBE_PRESENT
269
+ && readdirSync(dir).some((f) => f.endsWith('.json'));
270
+ if (hasSessionFiles) scheduleSessionSummaryIndexRebuild();
271
+ return _setSummaryRowsCache(indexedRows);
220
272
  } catch {
221
- try { return rebuildIfMissing ? rebuildSessionSummaryIndex() : _setSummaryRowsCache(indexedRows); }
222
- catch { return _setSummaryRowsCache(indexedRows); }
273
+ scheduleSessionSummaryIndexRebuild();
274
+ return _setSummaryRowsCache(indexedRows);
223
275
  }
224
276
  }
225
277
 
@@ -230,12 +282,12 @@ export function listStoredSessionSummaries(options = {}) {
230
282
  */
231
283
  export function getStoredSessionsRaw() {
232
284
  const dir = getStoreDir();
233
- if (!existsSync(dir)) return [];
285
+ if (probePath(dir).state !== PROBE_PRESENT) return [];
234
286
  const files = readdirSync(dir).filter(f => f.endsWith('.json'));
235
287
  const sessions = [];
236
288
  for (const f of files) {
237
289
  const session = _storedSessionFromFile(dir, f, false);
238
- if (session) sessions.push(session);
290
+ if (session && session !== STORED_SESSION_UNREADABLE) sessions.push(session);
239
291
  }
240
292
  return sessions;
241
293
  }
@@ -265,7 +317,9 @@ function* sweepStaleSessionSteps(ttlMs, options = {}) {
265
317
  const openMaxAgeMs = Number.isFinite(_optAge) && _optAge > 0 ? _optAge : RESUMABLE_OPEN_MAX_AGE_MS;
266
318
  const openMaxCount = Number.isFinite(_optCount) && _optCount >= 0 ? _optCount : RESUMABLE_OPEN_MAX_COUNT;
267
319
  const dir = getStoreDir();
268
- if (!existsSync(dir))
320
+ // An unreadable store dir is not an empty one: sweeping (deleting,
321
+ // closing, pruning) on a probe we could not make is never allowed.
322
+ if (probePath(dir).state !== PROBE_PRESENT)
269
323
  return { cleaned: 0, remaining: 0, details: [], tombstonesCleaned: 0, tombstoneDetails: [], tombstoneErrors: [] };
270
324
  // Reconcile the index-derived candidate set with a direct directory scan:
271
325
  // the summary index is a best-effort sidecar that can lag far behind disk
@@ -308,17 +362,28 @@ function* sweepStaleSessionSteps(ttlMs, options = {}) {
308
362
  try {
309
363
  if (!row?.id) continue;
310
364
  const jsonPath = sessionPath(row.id);
311
- if (!existsSync(jsonPath)) {
365
+ const jsonProbe = probePath(jsonPath);
366
+ // ONLY a proven-absent record may retire its summary row. An
367
+ // EACCES/EIO probe means the file is very likely still there:
368
+ // queueing a removal would delete the row of a live session.
369
+ if (jsonProbe.state === PROBE_ABSENT) {
312
370
  _queueSessionSummaryRemoval(row.id);
313
371
  continue;
314
372
  }
315
- let jsonMtime = 0;
373
+ if (jsonProbe.state !== PROBE_PRESENT) {
374
+ remaining++;
375
+ continue;
376
+ }
377
+ let jsonMtime = jsonProbe.mtimeMs || 0;
316
378
  let heartbeatMtime = 0;
317
- try { jsonMtime = statSync(jsonPath).mtimeMs || 0; } catch {}
318
- try {
319
- const hbPath = join(dir, `${row.id}.hb`);
320
- if (existsSync(hbPath)) heartbeatMtime = statSync(hbPath).mtimeMs || 0;
321
- } catch { /* .hb unavailable — fall back to JSON fields */ }
379
+ const hbProbe = probePath(join(dir, `${row.id}.hb`));
380
+ if (hbProbe.state === PROBE_PRESENT) heartbeatMtime = hbProbe.mtimeMs || 0;
381
+ else if (hbProbe.state !== PROBE_ABSENT) {
382
+ // Liveness sidecar unreadable: this record's freshness cannot
383
+ // be judged, so it is not a sweep candidate at all.
384
+ remaining++;
385
+ continue;
386
+ }
322
387
  // Truth source: the summary index is a deferred/best-effort sidecar,
323
388
  // so a row can still claim status='idle'/open while the session JSON
324
389
  // was already tombstoned. Read the real session JSON BEFORE the
@@ -329,25 +394,35 @@ function* sweepStaleSessionSteps(ttlMs, options = {}) {
329
394
  let raw = null;
330
395
  try { raw = readFileSync(jsonPath, 'utf-8'); }
331
396
  catch { /* racing unlink / transient read failure */ }
332
- let actual = null;
333
- let diskClosed;
397
+ // NOTHING ambiguous is ever swept. This loop deletes files, plants
398
+ // tombstones and rewrites summary rows, so a record it cannot read
399
+ // authoritatively (read fault, malformed JSON, duplicate top-level
400
+ // id/closed/generation) is left EXACTLY as it is — not deleted, not
401
+ // closed, not repaired, and never resolved by a last-wins
402
+ // JSON.parse fallback or by the best-effort summary row (which may
403
+ // describe a completely different generation of this id).
334
404
  if (raw == null) {
335
- diskClosed = (row.closed === true || row.status === 'closed');
336
- } else {
337
- // Cheap top-level scan avoids allocating the whole messages array
338
- // just to read the closed flag for the (common) fresh open
339
- // session the gate will skip; full-parse only when the scan can't
340
- // resolve the top-level flag.
341
- const scan = scanTopLevelLifecycle(raw);
342
- if (scan && typeof scan.closed === 'boolean') {
343
- diskClosed = scan.closed;
344
- } else {
345
- try { actual = JSON.parse(raw); } catch { actual = null; }
346
- diskClosed = actual
347
- ? (actual.closed === true || actual.status === 'closed')
348
- : (row.closed === true || row.status === 'closed');
349
- }
405
+ remaining++;
406
+ continue;
407
+ }
408
+ const record = readTopLevelLifecycleRecord(raw);
409
+ if (isLifecycleUnreadable(record)) {
410
+ remaining++;
411
+ continue;
350
412
  }
413
+ // OWNERSHIP: the record must name the id this row/file claims.
414
+ // A foreign record (including a foreign TOMBSTONE that would look
415
+ // perfectly mature) is preserved untouched — never deleted, never
416
+ // closed, never upserted into the summary index under the wrong id.
417
+ if (record.id !== row.id) {
418
+ remaining++;
419
+ continue;
420
+ }
421
+ // One strict parse for the whole record: every decision below reads
422
+ // the SAME authoritative document (owner/status/timestamps/
423
+ // messages), so there is no second, divergent parse of this file.
424
+ const actual = record.doc;
425
+ const diskClosed = record.closed === true || actual.status === 'closed';
351
426
  if (diskClosed) {
352
427
  // A shared store can be tombstoned by another process while
353
428
  // this process still owns an in-flight controller for the same
@@ -363,7 +438,6 @@ function* sweepStaleSessionSteps(ttlMs, options = {}) {
363
438
  // perpetually "fresh" and never mature. Maturity is governed ONLY
364
439
  // by the ORIGINAL close time (disk updatedAt, not row.updatedAt
365
440
  // which a stale row may carry from before the close).
366
- if (!actual && raw != null) { try { actual = JSON.parse(raw); } catch { actual = null; } }
367
441
  const closedAt = Number(actual?.updatedAt ?? row.updatedAt);
368
442
  const age = now - closedAt;
369
443
  if (sweepTombstones && Number.isFinite(closedAt) && age >= tombstoneMaxAgeMs) {
@@ -382,7 +456,7 @@ function* sweepStaleSessionSteps(ttlMs, options = {}) {
382
456
  // Repair a stale summary row that still claimed the session was
383
457
  // open: reflect the real closed state so the next sweep sees the
384
458
  // correct closed=true/updatedAt and never re-closes it.
385
- if (actual && !(row.closed === true || row.status === 'closed')) {
459
+ if (!(row.closed === true || row.status === 'closed')) {
386
460
  try { _queueSessionSummaryUpsert(actual); } catch { /* best-effort */ }
387
461
  }
388
462
  remaining++;
@@ -392,7 +466,6 @@ function* sweepStaleSessionSteps(ttlMs, options = {}) {
392
466
  // use their provider's Advanced terminal duration rather than the
393
467
  // general sweep cadence. A short provider override must therefore
394
468
  // not be hidden behind the default 5-minute gate.
395
- if (!actual && raw != null) { try { actual = JSON.parse(raw); } catch { actual = null; } }
396
469
  const gateOwner = (actual && typeof actual.owner === 'string' && actual.owner.length > 0)
397
470
  ? actual.owner : row.owner;
398
471
  const gateStatus = (actual && typeof actual.status === 'string') ? actual.status : row.status;
@@ -433,8 +506,7 @@ function* sweepStaleSessionSteps(ttlMs, options = {}) {
433
506
  // Prefer the AUTHORITATIVE on-disk JSON over the best-effort (and
434
507
  // possibly stale) summary row for every open/idle liveness and
435
508
  // ownership decision below — a stale row must not close or prune the
436
- // wrong session. Full-parse here if the cheap scan skipped it.
437
- if (!actual && raw != null) { try { actual = JSON.parse(raw); } catch { actual = null; } }
509
+ // wrong session.
438
510
  const effOwner = (actual && typeof actual.owner === 'string' && actual.owner.length > 0)
439
511
  ? actual.owner : row.owner;
440
512
  const ownerRef = { owner: effOwner };
@@ -518,10 +590,13 @@ function* sweepStaleSessionSteps(ttlMs, options = {}) {
518
590
  continue;
519
591
  }
520
592
  let preCloseHeartbeatMtime = 0;
521
- try {
522
- const hbPath = join(dir, `${row.id}.hb`);
523
- if (existsSync(hbPath)) preCloseHeartbeatMtime = statSync(hbPath).mtimeMs || 0;
524
- } catch { /* sidecar unavailable — retain scan-time gates */ }
593
+ const preCloseProbe = probePath(join(dir, `${row.id}.hb`));
594
+ if (preCloseProbe.state === PROBE_PRESENT) preCloseHeartbeatMtime = preCloseProbe.mtimeMs || 0;
595
+ else if (preCloseProbe.state !== PROBE_ABSENT) {
596
+ // Unknown liveness at the destructive edge: do not close.
597
+ remaining++;
598
+ continue;
599
+ }
525
600
  if (preCloseHeartbeatMtime > 0 && now - preCloseHeartbeatMtime <= sessionMaxAge) {
526
601
  remaining++;
527
602
  continue;
@@ -597,9 +672,11 @@ function* sweepStaleSessionSteps(ttlMs, options = {}) {
597
672
  try {
598
673
  for (const h of readdirSync(dir).filter(f => f.endsWith('.hb') || f.endsWith('.own'))) {
599
674
  yield undefined;
600
- if (existsSync(join(dir, h.replace(/\.(hb|own)$/, '.json')))) continue;
601
- let hbMtime = 0;
602
- try { hbMtime = statSync(join(dir, h)).mtimeMs; } catch { continue; }
675
+ // Only a PROVEN-absent session file makes its sidecar an orphan.
676
+ if (!(probePath(join(dir, h.replace(/\.(hb|own)$/, '.json'))).state === PROBE_ABSENT)) continue;
677
+ const sidecarProbe = probePath(join(dir, h));
678
+ if (sidecarProbe.state !== PROBE_PRESENT) continue;
679
+ const hbMtime = sidecarProbe.mtimeMs;
603
680
  if (now - hbMtime > maxAge) {
604
681
  try { unlinkSync(join(dir, h)); cleaned++; } catch { /* ignore */ }
605
682
  }
@@ -12,6 +12,138 @@ export const _liveSessions = new Map();
12
12
  // it must not be evicted (data loss) nor shadowed by the stale disk copy.
13
13
  export const _droppedSaveIds = new Set();
14
14
 
15
+ // ── Save identity (epoch) ───────────────────────────────────────────────────
16
+ // Every save ATTEMPT takes a monotonically increasing epoch when it is issued.
17
+ // A failure/drop records the epoch of the state that never landed, so only a
18
+ // save at least that new may clear the id-wide error/drop markers. Without it
19
+ // an OLDER worker write landing after a NEWER sync/async failure cleared the
20
+ // markers, un-pinning (and then evicting) the only copy of the newest turn.
21
+ let _saveEpochSeq = 0;
22
+ const _failureEpochs = new Map(); // id -> epoch of the newest failed/dropped save
23
+ // The EXACT snapshot whose own save failed in this process, per id. This is
24
+ // the only evidence that may let an in-memory object stand in for an
25
+ // unreadable canonical file (see loadSession's read-after-failure contract):
26
+ // "some live/pending object exists for this id" proves nothing about disk,
27
+ // while "THIS object's write to disk failed" proves the bytes never landed
28
+ // and that this copy is strictly newer than whatever the file holds. Lives
29
+ // exactly as long as _lastSaveError (cleared by the next landed save).
30
+ // Stored as an IMMUTABLE point-in-time clone (see _failedSaveClone), never the
31
+ // live reference.
32
+ const _failedSaveSnapshots = new Map(); // id -> frozen-in-time session copy
33
+
34
+ export function _nextSaveEpoch() {
35
+ return ++_saveEpochSeq;
36
+ }
37
+
38
+ // ── Save outcomes ───────────────────────────────────────────────────────────
39
+ // A refused write is NOT one thing: an ownership/cancellation drop means this
40
+ // process holds state disk does not (pin it, flag the split-brain), while a
41
+ // stale-epoch refusal means a strictly NEWER write already landed — nothing
42
+ // was lost, so no marker may move and the snapshot stays immediately
43
+ // evictable. Every save path reports which one happened.
44
+ export const SAVE_OUTCOME_SAVED = 'saved';
45
+ export const SAVE_OUTCOME_DROPPED = 'dropped';
46
+ export const SAVE_OUTCOME_STALE = 'stale';
47
+
48
+ // ── Session incarnations ────────────────────────────────────────────────────
49
+ // Work (worker write, queued payload, parked snapshot, in-process save) is
50
+ // stamped with the OBJECT identifying the id's current incarnation. A
51
+ // successful hard delete retires that object; a re-created id mints a brand
52
+ // new one. Identity comparison — not a counter — is what makes this exact:
53
+ // no wrap, no reset, no reuse, so pre-delete work can never look current
54
+ // again, however many deletes happen and however often an id is reused.
55
+ //
56
+ // Entries are pinned while work references them; the (generous) cap only ever
57
+ // evicts UNREFERENCED entries, and an evicted entry can at worst make some
58
+ // later settlement inert — never make stale work current.
59
+ let _incarnationSeq = 0;
60
+ const _sessionIncarnations = new Map(); // id -> { id, token, refs }
61
+ const SESSION_INCARNATION_MAX = 4096;
62
+
63
+ function _evictSessionIncarnations() {
64
+ if (_sessionIncarnations.size <= SESSION_INCARNATION_MAX) return;
65
+ for (const [key, incarnation] of _sessionIncarnations) {
66
+ if (_sessionIncarnations.size <= SESSION_INCARNATION_MAX) break;
67
+ if (incarnation.refs > 0) continue; // still referenced by outstanding work
68
+ _sessionIncarnations.delete(key);
69
+ }
70
+ }
71
+
72
+ // Strict diagnostics: a release without a matching acquire is a BUG, not a
73
+ // clamp. Tests assert this stays zero instead of relying on the clamp.
74
+ let _incarnationReleaseUnderflows = 0;
75
+
76
+ export function _sessionIncarnationStats() {
77
+ return {
78
+ size: _sessionIncarnations.size,
79
+ max: SESSION_INCARNATION_MAX,
80
+ releaseUnderflows: _incarnationReleaseUnderflows,
81
+ };
82
+ }
83
+
84
+ /** Stamp for a new unit of work; keeps the incarnation alive until released. */
85
+ export function _acquireSessionIncarnation(id) {
86
+ if (!id) return null;
87
+ let incarnation = _sessionIncarnations.get(id);
88
+ if (incarnation) _sessionIncarnations.delete(id);
89
+ else incarnation = { id, token: ++_incarnationSeq, refs: 0 };
90
+ incarnation.refs += 1;
91
+ _sessionIncarnations.set(id, incarnation); // LRU touch
92
+ _evictSessionIncarnations();
93
+ return incarnation;
94
+ }
95
+
96
+ export function _releaseSessionIncarnation(incarnation) {
97
+ if (!incarnation) return;
98
+ if (incarnation.refs > 0) incarnation.refs -= 1;
99
+ else _incarnationReleaseUnderflows += 1; // double release — surfaced, not hidden
100
+ // The cap is enforced on RELEASE too: a token that just became
101
+ // unreferenced is exactly the one eviction is allowed to reclaim.
102
+ if (incarnation.refs === 0) _evictSessionIncarnations();
103
+ }
104
+
105
+ /** Diagnostics/tests: outstanding references to an id's incarnation. */
106
+ export function _sessionIncarnationRefs(id) {
107
+ const incarnation = id && _sessionIncarnations.get(id);
108
+ return incarnation ? incarnation.refs : 0;
109
+ }
110
+
111
+ /** False once the id was hard-deleted (or re-created) after this stamp. */
112
+ export function _isCurrentSessionIncarnation(id, incarnation) {
113
+ if (!incarnation) return true; // unstamped work keeps its legacy behavior
114
+ return _sessionIncarnations.get(id) === incarnation;
115
+ }
116
+
117
+ /**
118
+ * Retire an id's incarnation on a SUCCESSFUL hard delete. The object is
119
+ * dropped from the registry and never handed out again: outstanding work that
120
+ * still holds it is permanently non-current, and a re-created id gets a fresh
121
+ * object of its own.
122
+ */
123
+ export function _retireSessionIncarnation(id) {
124
+ if (id) _sessionIncarnations.delete(id);
125
+ }
126
+
127
+ /** Clear every save marker for an id (successful hard delete). */
128
+ export function _clearSessionSaveState(id) {
129
+ if (!id) return;
130
+ _lastSaveError.delete(id);
131
+ _failureEpochs.delete(id);
132
+ // The recovery evidence dies with the file it was evidence ABOUT: a
133
+ // re-created id must never inherit the deleted incarnation's snapshot
134
+ // (that would resurrect a deleted transcript as the "only good copy").
135
+ _failedSaveSnapshots.delete(id);
136
+ _droppedSaveIds.delete(id);
137
+ }
138
+
139
+ function _markFailureEpoch(id, epoch) {
140
+ // An unknown epoch is treated as "newest": callers without an identity
141
+ // must never weaken the marker.
142
+ const value = Number.isFinite(epoch) ? epoch : _nextSaveEpoch();
143
+ const previous = _failureEpochs.get(id);
144
+ if (previous === undefined || value > previous) _failureEpochs.set(id, value);
145
+ }
146
+
15
147
  export function setLiveSession(session) {
16
148
  if (!session?.id) return;
17
149
  _liveSessions.set(session.id, session);
@@ -46,4 +178,125 @@ export function getSessionSaveError(id) {
46
178
 
47
179
  export function clearSessionSaveError(id) {
48
180
  _lastSaveError.delete(id);
181
+ _failureEpochs.delete(id);
182
+ _failedSaveSnapshots.delete(id);
183
+ }
184
+
185
+ /**
186
+ * Record a failed atomic save. The disk copy is now BEHIND this process's live
187
+ * snapshot, so the id is flagged on BOTH channels:
188
+ * - _lastSaveError: surfaced to callers (getSessionSaveError) and used by the
189
+ * idle sweep / loadSession to keep the only-good in-memory state readable.
190
+ * - _droppedSaveIds: the existing "disk is behind the snapshot" marker, so a
191
+ * stale/lower-generation disk record cannot shadow the live transcript.
192
+ * Both are cleared by the next successful save (clearSessionSaveError +
193
+ * _droppedSaveIds.delete in the store's commit path).
194
+ */
195
+ /**
196
+ * Point-in-time COPY of the exact payload whose save failed.
197
+ *
198
+ * Never the live reference: the caller keeps mutating that object (the next
199
+ * turn appends messages, status flips, the turn hook is re-attached), and a
200
+ * fallback read must reproduce what the failed write actually attempted —
201
+ * not a later state that was never offered to disk and is not backed by any
202
+ * failure evidence. The JSON round-trip detaches every nested container and
203
+ * drops functions/symbols (turn hooks), so no later mutation can reach it.
204
+ * An unclonable payload (circular ref) records NO evidence: readers then
205
+ * report the unreadable file instead of masking it.
206
+ */
207
+ function _failedSaveClone(session) {
208
+ try {
209
+ const clone = JSON.parse(JSON.stringify(session));
210
+ return clone && typeof clone === 'object' && !Array.isArray(clone) ? clone : null;
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
215
+
216
+ export function _recordSaveFailure(id, err, epoch = null, session = null) {
217
+ if (!id) return;
218
+ _lastSaveError.set(id, { message: err?.message ?? String(err), at: Date.now() });
219
+ _droppedSaveIds.add(id);
220
+ _markFailureEpoch(id, epoch);
221
+ // Only a snapshot that IS this id's failed payload is retained; a caller
222
+ // without one records no recovery evidence at all (readers then report the
223
+ // unreadable file instead of masking it).
224
+ if (session && session.id === id) {
225
+ const clone = _failedSaveClone(session);
226
+ if (clone && clone.id === id) _failedSaveSnapshots.set(id, clone);
227
+ }
228
+ }
229
+
230
+ /**
231
+ * The in-memory snapshot whose own save failed for `id`, or null.
232
+ * Deliberately NOT "the live session for id": it is the narrow proof that a
233
+ * same-process copy is newer than the canonical file.
234
+ */
235
+ export function getFailedSaveSnapshot(id) {
236
+ if (!id) return null;
237
+ const snapshot = _failedSaveSnapshots.get(id);
238
+ if (!snapshot) return null;
239
+ // Hand out a COPY as well: a reader that mutates what it got back (every
240
+ // session consumer does) must not be able to edit the stored evidence.
241
+ return _failedSaveClone(snapshot);
242
+ }
243
+
244
+ /**
245
+ * Record an ownership-DROPPED save (nothing was written, disk moved past this
246
+ * snapshot). Same identity rule as a failure: an older landed write may not
247
+ * clear it.
248
+ */
249
+ export function _recordSaveDrop(id, epoch = null) {
250
+ if (!id) return;
251
+ _droppedSaveIds.add(id);
252
+ _markFailureEpoch(id, epoch);
253
+ }
254
+
255
+ /**
256
+ * Clear the id's failure/drop markers on behalf of a LANDED save — but only
257
+ * when that save is at least as new as the newest recorded failure/drop.
258
+ * Returns whether the markers were cleared.
259
+ */
260
+ export function _clearSaveStateIfCurrent(id, epoch = null) {
261
+ if (!id) return false;
262
+ const marked = _failureEpochs.get(id);
263
+ if (marked !== undefined && Number.isFinite(epoch) && epoch < marked) return false;
264
+ _failureEpochs.delete(id);
265
+ _lastSaveError.delete(id);
266
+ _failedSaveSnapshots.delete(id);
267
+ _droppedSaveIds.delete(id);
268
+ return true;
269
+ }
270
+
271
+ /** True while this process's last save attempt for `id` failed. */
272
+ export function hasSessionSaveFailure(id) {
273
+ return !!id && _lastSaveError.has(id);
274
+ }
275
+
276
+ // ── Lifecycle (tombstone/detach) commit failures ────────────────────────────
277
+ // markSessionClosed/bumpSessionGeneration return null for BOTH a legitimate
278
+ // veto (session gone, liveness veto, contended commit lock) and a genuine
279
+ // durable-write failure. closeSession must react very differently to the two:
280
+ // a failed lifecycle barrier means NOTHING was fenced on disk, so tearing the
281
+ // runtime down would strand a live turn behind an un-planted tombstone. This
282
+ // map is the disambiguator — set only on a real commit failure, cleared when
283
+ // the barrier lands.
284
+ const _lifecycleCommitErrors = new Map(); // id -> { message, at, code, reason }
285
+
286
+ export function _recordLifecycleCommitFailure(id, err, reason = null) {
287
+ if (!id) return;
288
+ _lifecycleCommitErrors.set(id, {
289
+ message: err?.message ?? String(err),
290
+ code: err?.code ?? null,
291
+ reason,
292
+ at: Date.now(),
293
+ });
294
+ }
295
+
296
+ export function getSessionLifecycleCommitError(id) {
297
+ return _lifecycleCommitErrors.get(id) ?? null;
298
+ }
299
+
300
+ export function clearSessionLifecycleCommitError(id) {
301
+ _lifecycleCommitErrors.delete(id);
49
302
  }