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
@@ -1,5 +1,6 @@
1
- import { statSync, existsSync, readFileSync } from 'fs';
1
+ import { statSync, readFileSync } from 'fs';
2
2
  import { getPluginData } from '../../config.mjs';
3
+ import { readTopLevelLifecycleRecord, isLifecycleUnreadable } from '../lifecycle-scan.mjs';
3
4
 
4
5
  // Recent full-session reads are much hotter than writes while a user hops
5
6
  // between conversations. Verify the file identity on every access, but reuse
@@ -8,42 +9,224 @@ const SESSION_LOAD_CACHE_LIMIT = 8;
8
9
  const _sessionLoadCache = new Map(); // path → { signature, session }
9
10
  let _sessionLoadCacheDataDir = null;
10
11
 
11
- export function _readStoredSessionCached(id, path) {
12
- const dataDir = getPluginData();
13
- if (_sessionLoadCacheDataDir !== dataDir) {
14
- _sessionLoadCacheDataDir = dataDir;
15
- _sessionLoadCache.clear();
12
+ // A session file is only ever replaced atomically (write tmp → rename over
13
+ // the canonical path). A reader that stats one inode and then reads "the
14
+ // path" can therefore straddle a replacement: the stat describes the old
15
+ // file while the read returns the new bytes, or the old inode is already
16
+ // gone (ENOENT) even though a perfectly valid replacement exists — which the
17
+ // naive version reported as {exists:true, session:null} / {exists:false},
18
+ // i.e. loadSession()/getSession() returning null for a live session. Every
19
+ // observation below is therefore a STABLE SNAPSHOT: stat → read → stat, and
20
+ // the result is only trusted (and only cached) when the identity signature is
21
+ // unchanged around the read. An unstable observation is retried a bounded
22
+ // number of times, so the replacement window closes and we still fail closed
23
+ // on genuine corruption instead of spinning.
24
+ const SESSION_LOAD_STABLE_ATTEMPTS = 5;
25
+
26
+ // Deterministic fault injection point (tests only): invoked immediately
27
+ // before every stat and every read with { phase, path, attempt }. The hook
28
+ // may mutate the file system (simulating an atomic rename landing between
29
+ // the two syscalls) or return an Error to make that syscall fail.
30
+ //
31
+ // STRUCTURAL GATE: the seam is inert unless the process was started in
32
+ // explicit fault-injection test mode. Installing a hook without the gate is
33
+ // a no-op (returns false), and an already-installed hook is dropped the
34
+ // moment the gate stops being set, so a production build can never activate
35
+ // it accidentally — not via a stray import, not via a leaked reference.
36
+ const SESSION_LOAD_FAULT_ENV = 'MIXDOG_SESSION_LOAD_FAULT_HOOKS';
37
+ let _sessionLoadFaultHook = null;
38
+
39
+ function _faultHooksEnabled() {
40
+ return process.env[SESSION_LOAD_FAULT_ENV] === '1';
41
+ }
42
+
43
+ export function _setSessionLoadFaultHook(hook) {
44
+ if (!_faultHooksEnabled()) {
45
+ _sessionLoadFaultHook = null;
46
+ return false;
47
+ }
48
+ _sessionLoadFaultHook = typeof hook === 'function' ? hook : null;
49
+ return _sessionLoadFaultHook !== null;
50
+ }
51
+
52
+ // Test/diagnostic view of the cache: proves a signature is never paired with
53
+ // content that came from a different inode.
54
+ export function _inspectSessionLoadCache() {
55
+ return [..._sessionLoadCache.entries()].map(([path, entry]) => ({
56
+ path,
57
+ signature: entry.signature,
58
+ session: entry.session,
59
+ }));
60
+ }
61
+
62
+ function _fault(phase, path, attempt) {
63
+ if (!_sessionLoadFaultHook) return;
64
+ if (!_faultHooksEnabled()) {
65
+ _sessionLoadFaultHook = null; // gate revoked after install: stay inert
66
+ return;
16
67
  }
17
- let signature;
68
+ const injected = _sessionLoadFaultHook({ phase, path, attempt });
69
+ if (injected instanceof Error) throw injected;
70
+ }
71
+
72
+ // Only these mean "nothing is at this path right now". Everything else
73
+ // (EACCES, EPERM, EIO, EBUSY, EMFILE, ELOOP, unknown) means a file is very
74
+ // likely THERE and we simply cannot look at it: that must never be reported
75
+ // as absence, because absence lets loadSession() fall back to in-memory
76
+ // state and thereby mask an unreadable file that owns the identity.
77
+ const ABSENT_CODES = new Set(['ENOENT', 'ENOTDIR']);
78
+
79
+ // Observation states: 'present' (signature valid), 'absent' (ENOENT-class),
80
+ // 'unreadable' (persistent/transient failure — fail closed).
81
+ function _observe(path, attempt) {
18
82
  try {
83
+ _fault('stat', path, attempt);
19
84
  const info = statSync(path, { bigint: true });
20
- signature = `${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}`;
21
- } catch {
22
- _sessionLoadCache.delete(path);
23
- return { exists: existsSync(path), session: null };
85
+ return { state: 'present', signature: `${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}`, code: null };
86
+ } catch (err) {
87
+ const code = err?.code || 'EUNKNOWN';
88
+ if (ABSENT_CODES.has(code)) return { state: 'absent', signature: null, code };
89
+ return { state: 'unreadable', signature: null, code };
24
90
  }
25
- const cached = _sessionLoadCache.get(path);
26
- if (cached?.signature === signature) {
27
- _sessionLoadCache.delete(path);
28
- _sessionLoadCache.set(path, cached);
29
- return { exists: true, session: cached.session };
91
+ }
92
+
93
+ // A decision is always derived from ONE observation that was actually made —
94
+ // never from a second, independent existence probe (the old
95
+ // `stat → null → existsSync(path)` pair was a TOCTOU window that could turn a
96
+ // live file into {exists:false}).
97
+ function _decide(observation) {
98
+ if (observation.state === 'absent') return { exists: false, session: null };
99
+ return { exists: true, session: null };
100
+ }
101
+
102
+ // Content is only this session's when it parses UNAMBIGUOUSLY and claims this
103
+ // id. readTopLevelLifecycleRecord is the strict parse (no separate JSON.parse
104
+ // authority to diverge from): a duplicate top-level `id` is last-wins under
105
+ // JSON.parse, which would hand the runtime a foreign/ambiguous transcript to
106
+ // serve, re-save and tombstone from. Ambiguity reads as corruption here —
107
+ // `null` keeps the file owning the identity and fails closed.
108
+ function _ownedSession(id, text) {
109
+ const record = readTopLevelLifecycleRecord(text);
110
+ if (isLifecycleUnreadable(record)) return null;
111
+ return record.id === id ? record.doc : null;
112
+ }
113
+
114
+ function _cacheStable(path, signature, session) {
115
+ _sessionLoadCache.delete(path);
116
+ _sessionLoadCache.set(path, { signature, session });
117
+ while (_sessionLoadCache.size > SESSION_LOAD_CACHE_LIMIT) {
118
+ const oldest = _sessionLoadCache.keys().next().value;
119
+ if (oldest === undefined) break;
120
+ _sessionLoadCache.delete(oldest);
30
121
  }
122
+ }
123
+
124
+ // The inode we stat'ed vanished under the read on the LAST attempt: no retry
125
+ // is left, but a replacement may already own the path. Take ONE decisive
126
+ // snapshot (observe, then read what is actually there) instead of classifying
127
+ // from the observation alone — reporting {exists:true, session:null} for a
128
+ // perfectly readable replacement is the same false corruption the retry loop
129
+ // exists to avoid. Uncached: no identity was verified around this read.
130
+ function _decisiveReplacementSnapshot(id, path, attempt) {
131
+ const observed = _observe(path, attempt);
132
+ if (observed.state === 'absent') return { exists: false, session: null };
133
+ if (observed.state !== 'present') return { exists: true, session: null };
134
+ let text = null;
31
135
  try {
32
- const stored = JSON.parse(readFileSync(path, 'utf-8'));
33
- if (stored?.id !== id) {
136
+ _fault('read', path, attempt);
137
+ text = readFileSync(path, 'utf-8');
138
+ } catch (err) {
139
+ const code = err?.code || 'EUNKNOWN';
140
+ return ABSENT_CODES.has(code) ? { exists: false, session: null } : { exists: true, session: null };
141
+ }
142
+ return { exists: true, session: _ownedSession(id, text) };
143
+ }
144
+
145
+ export function _readStoredSessionCached(id, path) {
146
+ const dataDir = getPluginData();
147
+ if (_sessionLoadCacheDataDir !== dataDir) {
148
+ _sessionLoadCacheDataDir = dataDir;
149
+ _sessionLoadCache.clear();
150
+ }
151
+ let last = { state: 'absent', signature: null, code: 'ENOENT' };
152
+ for (let attempt = 0; attempt < SESSION_LOAD_STABLE_ATTEMPTS; attempt++) {
153
+ // The final attempt must DECIDE — it may not defer to yet another
154
+ // observation, so a replacement landing on that last boundary is
155
+ // parsed (if its bytes are readable and ours) or reported as
156
+ // present-but-invalid, never as absence.
157
+ const isFinal = attempt === SESSION_LOAD_STABLE_ATTEMPTS - 1;
158
+ const before = _observe(path, attempt);
159
+ last = before;
160
+ if (before.state !== 'present') {
161
+ // Absent: possibly the unlink → rename hole, retry.
162
+ // Unreadable: EACCES/EIO/EBUSY — retry in case it is transient,
163
+ // but this observation already means "present, fail closed".
34
164
  _sessionLoadCache.delete(path);
35
- return { exists: true, session: null };
165
+ continue;
166
+ }
167
+ const cached = _sessionLoadCache.get(path);
168
+ if (cached?.signature === before.signature) {
169
+ // The cached object was stored under a verified-stable signature,
170
+ // and dev/ino/size/mtimeNs still describe that same file.
171
+ _sessionLoadCache.delete(path);
172
+ _sessionLoadCache.set(path, cached);
173
+ return { exists: true, session: cached.session };
174
+ }
175
+ let text = null;
176
+ let readCode = null;
177
+ try {
178
+ _fault('read', path, attempt);
179
+ text = readFileSync(path, 'utf-8');
180
+ } catch (err) {
181
+ readCode = err?.code || 'EUNKNOWN';
36
182
  }
37
- _sessionLoadCache.delete(path);
38
- _sessionLoadCache.set(path, { signature, session: stored });
39
- while (_sessionLoadCache.size > SESSION_LOAD_CACHE_LIMIT) {
40
- const oldest = _sessionLoadCache.keys().next().value;
41
- if (oldest === undefined) break;
42
- _sessionLoadCache.delete(oldest);
183
+ if (readCode !== null) {
184
+ _sessionLoadCache.delete(path);
185
+ if (!ABSENT_CODES.has(readCode)) {
186
+ // EACCES/EIO/EBUSY on the read: the file is there and we
187
+ // cannot have it. Fail closed, now or after the retries.
188
+ last = { state: 'unreadable', signature: null, code: readCode };
189
+ if (isFinal) return { exists: true, session: null };
190
+ continue;
191
+ }
192
+ // The inode we stat'ed vanished under us: replacement window, not
193
+ // corruption. Retry and let the new file answer.
194
+ last = { state: 'absent', signature: null, code: readCode };
195
+ // On the last boundary there is no retry left: read the
196
+ // replacement rather than classify it from a bare stat.
197
+ if (isFinal) return _decisiveReplacementSnapshot(id, path, attempt);
198
+ continue;
199
+ }
200
+ const after = _observe(path, attempt);
201
+ if (after.state !== 'present' || after.signature !== before.signature) {
202
+ // A rename landed around the read — these bytes may belong to a
203
+ // different inode than `before`, so they must never be cached
204
+ // under it.
205
+ _sessionLoadCache.delete(path);
206
+ last = after;
207
+ if (!isFinal) continue;
208
+ // Last boundary: the bytes still came from a real file at this
209
+ // path. If they are a valid session for this id, answer with them
210
+ // (uncached, since no identity was verified) rather than invent an
211
+ // absence/null for a perfectly readable replacement; otherwise
212
+ // stay conservatively present-but-invalid.
213
+ return { exists: true, session: _ownedSession(id, text) };
214
+ }
215
+ // Stable snapshot: `text` provably belongs to the file identified by
216
+ // `before`. Whatever it says is now authoritative, including "this is
217
+ // not valid session JSON" / "this is a foreign id" — fail closed.
218
+ const stored = _ownedSession(id, text);
219
+ if (stored === null) {
220
+ _sessionLoadCache.delete(path);
221
+ return { exists: true, session: null };
43
222
  }
223
+ _cacheStable(path, before.signature, stored);
44
224
  return { exists: true, session: stored };
45
- } catch {
46
- _sessionLoadCache.delete(path);
47
- return { exists: true, session: null };
48
225
  }
226
+ // Bounded retries exhausted. `last` is the most recent observation we
227
+ // actually made, so the answer needs no further syscall: ENOENT-class ⇒
228
+ // genuinely absent, anything else ⇒ present-but-unreadable, which keeps
229
+ // owning the identity and fails closed like real external corruption.
230
+ _sessionLoadCache.delete(path);
231
+ return _decide(last);
49
232
  }
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Session-save commit faults and scratch-file hygiene.
3
+ *
4
+ * Two responsibilities, both about the *commit edge* of the store's atomic
5
+ * save (write `<id>.json.<hex>.tmp`, then rename it onto `<id>.json`):
6
+ *
7
+ * 1. A deterministic commit fault, STRUCTURALLY GATED to explicit test mode
8
+ * (MIXDOG_SESSION_SAVE_FAULT_HOOKS=1). The synthetic error is raised
9
+ * BEFORE renameSync runs, so the destination is never unlinked, truncated
10
+ * or replaced — the last-good canonical JSON survives the fault byte for
11
+ * byte. This replaces destructive "make the path a directory" fault rigs,
12
+ * which prove nothing about canonical integrity because they destroy the
13
+ * canonical file first. There is deliberately NO env-driven fault
14
+ * configuration: nothing in a production environment can arm this.
15
+ * 2. Conservative reclamation of the store's OWN abandoned scratch files.
16
+ * A failed commit can leave `<id>.json.<hex>.tmp` bytes behind; the store
17
+ * used to swallow the unlink failure and leak them forever. Only paths
18
+ * THIS realm minted and then failed to unlink are retried, and only when
19
+ * they match the store's exact scratch naming. The sessions directory is
20
+ * never scanned: an unregistered scratch file may be the save worker's or
21
+ * another process's ACTIVE commit, which no registry here can see.
22
+ */
23
+ import { unlinkSync } from 'fs';
24
+ import { basename } from 'path';
25
+ import { _renameWithRetrySync } from './serialize.mjs';
26
+
27
+ // `<id>.json.<12 hex>.tmp` — exactly what the store mints
28
+ // (`sessionPath(id) + '.' + randomBytes(6).toString('hex') + '.tmp'`).
29
+ // Anything else in sessions/ is somebody else's file and is left alone.
30
+ const SAVE_TMP_RE = /^[A-Za-z0-9_-]+\.json\.[0-9a-f]{12}\.tmp$/;
31
+ const ORPHAN_TMP_SWEEP_LIMIT = 64; // registered paths retried per chunk
32
+ const ORPHAN_TMP_DRAIN_MAX_ATTEMPTS = 4096; // hard ceiling for one drain pass
33
+ const ORPHAN_TMP_TRACK_MAX = 256; // soft cap: only reclaimed paths leave
34
+ const UNLINK_RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
35
+
36
+ const _activeSaveTmp = new Set(); // scratch paths this thread is writing now
37
+ const _orphanSaveTmp = new Set(); // scratch paths whose unlink failed
38
+
39
+ export function _trackSaveTmp(tmp) {
40
+ if (tmp) _activeSaveTmp.add(tmp);
41
+ return tmp;
42
+ }
43
+
44
+ export function _untrackSaveTmp(tmp) {
45
+ if (tmp) _activeSaveTmp.delete(tmp);
46
+ }
47
+
48
+ /**
49
+ * Register a scratch path as explicitly orphaned by THIS realm — the only
50
+ * class of file the sweeper may remove. Exported for the store's failure
51
+ * paths and for tests.
52
+ */
53
+ export function _registerOrphanSaveTmp(tmp) {
54
+ if (!tmp) return;
55
+ _activeSaveTmp.delete(tmp);
56
+ _orphanSaveTmp.add(tmp);
57
+ if (_orphanSaveTmp.size <= ORPHAN_TMP_TRACK_MAX) return;
58
+ // Over the soft cap: try to RECLAIM the oldest entries instead of evicting
59
+ // them. Dropping a path that is still on disk turns it into an orphan no
60
+ // realm owns any more, which is exactly how scratch files leaked forever.
61
+ // A path whose unlink still fails keeps its registry slot (retry ownership).
62
+ for (const oldest of [..._orphanSaveTmp]) {
63
+ if (_orphanSaveTmp.size <= ORPHAN_TMP_TRACK_MAX) break;
64
+ if (oldest === tmp) continue;
65
+ try {
66
+ unlinkSync(oldest);
67
+ _orphanSaveTmp.delete(oldest);
68
+ } catch (err) {
69
+ if (err?.code === 'ENOENT') _orphanSaveTmp.delete(oldest);
70
+ }
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Drop a scratch file. Retries the transient win32 codes once, and on final
76
+ * failure registers the path so the bounded sweeper retries it later instead
77
+ * of leaking it (the previous `catch { /* ignore *\/ }` was terminal).
78
+ */
79
+ export function _discardSaveTmp(tmp) {
80
+ if (!tmp) return true;
81
+ _activeSaveTmp.delete(tmp);
82
+ for (let attempt = 0; attempt < 2; attempt++) {
83
+ try {
84
+ unlinkSync(tmp);
85
+ _orphanSaveTmp.delete(tmp);
86
+ return true;
87
+ } catch (err) {
88
+ if (err?.code === 'ENOENT') {
89
+ _orphanSaveTmp.delete(tmp);
90
+ return true;
91
+ }
92
+ if (!UNLINK_RETRY_CODES.has(err?.code)) break;
93
+ }
94
+ }
95
+ _registerOrphanSaveTmp(tmp);
96
+ return false;
97
+ }
98
+
99
+ /**
100
+ * Retry the unlink of scratch files THIS realm minted and abandoned.
101
+ *
102
+ * Bounded: at most `limit` registered paths are retried per chunk (default
103
+ * 64). `drain: true` (exit) walks the WHOLE registry in such chunks, at most
104
+ * ONE attempt per path and at most `maxAttempts` in total, so a registry
105
+ * larger than one chunk cannot survive process exit while the pass still
106
+ * terminates.
107
+ * Conservative: no directory scan, so a scratch file this realm never
108
+ * registered — possibly the save worker's or another process's in-flight
109
+ * commit — is never even looked at, and a registered path whose name is not
110
+ * the store's exact scratch pattern is dropped from the registry rather than
111
+ * unlinked. A path whose unlink FAILS stays registered, so a transient
112
+ * failure is retried by the next pass instead of leaking.
113
+ * Returns the number of scratch files reclaimed.
114
+ */
115
+ export function sweepOrphanSessionTmpFiles(options = {}) {
116
+ const limit = Number.isFinite(options.limit) && options.limit > 0
117
+ ? options.limit
118
+ : ORPHAN_TMP_SWEEP_LIMIT;
119
+ if (options.drain !== true) return _sweepOrphanChunk(limit).removed;
120
+ const budget = Number.isFinite(options.maxAttempts) && options.maxAttempts > 0
121
+ ? options.maxAttempts
122
+ : ORPHAN_TMP_DRAIN_MAX_ATTEMPTS;
123
+ // One attempt per path per drain: `attempted` is what makes the loop
124
+ // terminate even when every remaining unlink keeps failing.
125
+ const attempted = new Set();
126
+ let removed = 0;
127
+ let spent = 0;
128
+ while (spent < budget) {
129
+ const chunk = _sweepOrphanChunk(Math.min(limit, budget - spent), attempted);
130
+ if (chunk.attempts === 0) break;
131
+ removed += chunk.removed;
132
+ spent += chunk.attempts;
133
+ }
134
+ return removed;
135
+ }
136
+
137
+ function _sweepOrphanChunk(limit, attempted = null) {
138
+ let removed = 0;
139
+ let attempts = 0;
140
+ for (const path of [..._orphanSaveTmp]) {
141
+ if (attempts >= limit) break;
142
+ if (attempted?.has(path)) continue; // already retried in this drain
143
+ if (_activeSaveTmp.has(path)) continue; // re-minted since it was registered
144
+ if (!SAVE_TMP_RE.test(basename(path))) { // never an unrelated/external file
145
+ _orphanSaveTmp.delete(path);
146
+ continue;
147
+ }
148
+ attempts++;
149
+ attempted?.add(path);
150
+ try {
151
+ unlinkSync(path);
152
+ _orphanSaveTmp.delete(path);
153
+ removed++;
154
+ } catch (err) {
155
+ if (err?.code === 'ENOENT') _orphanSaveTmp.delete(path);
156
+ }
157
+ }
158
+ return { removed, attempts };
159
+ }
160
+
161
+ // ── Deterministic commit-fault injection (test-gated) ───────────────────────
162
+ // STRUCTURAL GATE: the seam is inert unless the process was started in
163
+ // explicit fault-injection test mode. Arming it without the gate is refused
164
+ // (setSessionSaveFault returns false), and armed state is dropped the moment
165
+ // the gate stops being set, so no production process — and no environment
166
+ // variable — can make a real save fail.
167
+ const SESSION_SAVE_FAULT_ENV = 'MIXDOG_SESSION_SAVE_FAULT_HOOKS';
168
+ const _programmaticFault = { ids: null, remaining: 0, code: 'EIO' };
169
+ // Config epoch: bumped on every change so a realm that mirrors this state
170
+ // (the save worker) can detect a change — including a CLEAR — without diffing
171
+ // sets. Consumption of `remaining` never bumps it.
172
+ let _faultConfigEpoch = 0;
173
+ let _faultChangeListener = null;
174
+ // Authoritative state pushed in from the owning realm (parent → save worker).
175
+ // Once applied, it REPLACES programmatic resolution in this realm: the worker
176
+ // is long-lived and never runs setSessionSaveFault itself.
177
+ let _syncedFaultApplied = false;
178
+ let _syncedFault = null;
179
+
180
+ function _faultInjectionEnabled() {
181
+ return process.env[SESSION_SAVE_FAULT_ENV] === '1';
182
+ }
183
+
184
+ /**
185
+ * The store's explicit fault-injection test mode, shared by every structurally
186
+ * gated seam in the store (see save-worker's detach seam). Production never
187
+ * has this set, so those seams cannot be reached there.
188
+ */
189
+ export function _sessionStoreTestMode() {
190
+ return _faultInjectionEnabled();
191
+ }
192
+
193
+ function _clearFaultState() {
194
+ _programmaticFault.ids = null;
195
+ _programmaticFault.remaining = 0;
196
+ _programmaticFault.code = 'EIO';
197
+ _syncedFaultApplied = false;
198
+ _syncedFault = null;
199
+ }
200
+
201
+ function _idSet(ids) {
202
+ if (ids === '*' || ids === true) return '*';
203
+ const list = Array.isArray(ids) ? ids : [ids];
204
+ return new Set(list.map((v) => String(v)).filter(Boolean));
205
+ }
206
+
207
+ /**
208
+ * Configure (or clear with a falsy argument) in-process commit faults. Only
209
+ * honoured in explicit fault-injection test mode; returns whether the fault
210
+ * was armed.
211
+ * `{ ids: 'sess_x' | ['a','b'] | '*', count?: number, code?: string }`.
212
+ * `count` is the number of commits to fail (default: unlimited), so a test can
213
+ * fail exactly one save and let the NEXT save land without extra bookkeeping.
214
+ */
215
+ export function setSessionSaveFault(spec = null) {
216
+ if (!spec || !_faultInjectionEnabled()) {
217
+ _clearFaultState();
218
+ _faultConfigEpoch++;
219
+ _notifyFaultChange();
220
+ return false;
221
+ }
222
+ _programmaticFault.ids = _idSet(spec.ids ?? spec.id ?? '*');
223
+ const count = Number(spec.count);
224
+ _programmaticFault.remaining = Number.isFinite(count) && count >= 0 ? count : Infinity;
225
+ _programmaticFault.code = typeof spec.code === 'string' && spec.code ? spec.code : 'EIO';
226
+ _faultConfigEpoch++;
227
+ _notifyFaultChange();
228
+ return true;
229
+ }
230
+
231
+ function _notifyFaultChange() {
232
+ if (typeof _faultChangeListener === 'function') {
233
+ try { _faultChangeListener(); } catch { /* best-effort */ }
234
+ }
235
+ }
236
+
237
+ /** Register the realm-sync hook (save-worker.mjs pushes state to the worker). */
238
+ export function _onSessionSaveFaultChange(listener) {
239
+ _faultChangeListener = typeof listener === 'function' ? listener : null;
240
+ }
241
+
242
+ /**
243
+ * Cheap change token for the fault config. A mirroring realm re-syncs only
244
+ * when this token moves, so steady-state saves pay one compare and never a
245
+ * postMessage.
246
+ */
247
+ export function sessionSaveFaultSyncKey() {
248
+ return String(_faultConfigEpoch);
249
+ }
250
+
251
+ /** Wire form of the currently effective fault state (null when disabled). */
252
+ export function serializeSessionSaveFault() {
253
+ const state = _effectiveFaultState();
254
+ if (!state) return null;
255
+ return {
256
+ ids: state.ids === '*' ? '*' : [...state.ids],
257
+ remaining: state.remaining === Infinity ? null : state.remaining,
258
+ code: state.code,
259
+ };
260
+ }
261
+
262
+ /**
263
+ * Adopt fault state pushed from the owning realm. `null` DISABLES injection in
264
+ * this realm outright, which is what makes `setSessionSaveFault(null)` stop
265
+ * worker-thread faults too.
266
+ */
267
+ export function applySessionSaveFault(serialized) {
268
+ _syncedFaultApplied = true;
269
+ _syncedFault = (serialized && _faultInjectionEnabled())
270
+ ? {
271
+ ids: serialized.ids === '*' ? '*' : _idSet(serialized.ids),
272
+ remaining: serialized.remaining === null || serialized.remaining === undefined
273
+ ? Infinity
274
+ : Number(serialized.remaining),
275
+ code: serialized.code || 'EIO',
276
+ }
277
+ : null;
278
+ }
279
+
280
+ function _effectiveFaultState() {
281
+ // Gate revoked after arming: stay inert and forget the armed state.
282
+ if (!_faultInjectionEnabled()) {
283
+ if (_programmaticFault.ids || _syncedFault) _clearFaultState();
284
+ return null;
285
+ }
286
+ // A synced realm is fully governed by its owner — never by a programmatic
287
+ // call that never happened there.
288
+ const state = _syncedFaultApplied ? _syncedFault : (_programmaticFault.ids ? _programmaticFault : null);
289
+ return state && state.remaining > 0 ? state : null;
290
+ }
291
+
292
+ function _takeInjectedCommitFault(id) {
293
+ const state = _effectiveFaultState();
294
+ if (!state) return null;
295
+ if (state.ids !== '*' && !state.ids.has(id)) return null;
296
+ if (state.remaining !== Infinity) state.remaining -= 1;
297
+ const err = new Error(`[session-store] injected save commit fault for ${id}`);
298
+ err.code = state.code;
299
+ err.injectedSaveFault = true;
300
+ return err;
301
+ }
302
+
303
+ /**
304
+ * Commit a staged scratch file onto its canonical path. The injected fault is
305
+ * raised before the rename, so a fault leaves BOTH files exactly as they were:
306
+ * the canonical destination stays the last-good session JSON, and the scratch
307
+ * file is the caller's to discard (see _discardSaveTmp).
308
+ */
309
+ export function _commitSessionWrite(tmp, target, id) {
310
+ const injected = _takeInjectedCommitFault(id);
311
+ if (injected) throw injected;
312
+ return _renameWithRetrySync(tmp, target);
313
+ }