mixdog 0.9.50 → 0.9.52

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 (134) hide show
  1. package/package.json +5 -3
  2. package/scripts/abort-recovery-test.mjs +17 -1
  3. package/scripts/agent-model-liveness-test.mjs +79 -2
  4. package/scripts/anthropic-admission-retry-integration-test.mjs +119 -0
  5. package/scripts/anthropic-transport-policy-test.mjs +466 -0
  6. package/scripts/atomic-lock-tryonce-test.mjs +60 -1
  7. package/scripts/build-tui.mjs +13 -1
  8. package/scripts/channel-daemon-smoke.mjs +630 -10
  9. package/scripts/code-graph-aggregate-cwd-test.mjs +41 -37
  10. package/scripts/code-graph-disk-hit-test.mjs +11 -0
  11. package/scripts/code-graph-root-federation-test.mjs +273 -0
  12. package/scripts/compact-pressure-test.mjs +55 -1
  13. package/scripts/compact-smoke.mjs +80 -0
  14. package/scripts/context-mcp-metering-test.mjs +1350 -19
  15. package/scripts/deferred-tool-loading-test.mjs +17 -0
  16. package/scripts/gemini-provider-test.mjs +1053 -0
  17. package/scripts/hook-bus-test.mjs +23 -0
  18. package/scripts/internal-tools-normalization-test.mjs +10 -0
  19. package/scripts/interrupted-turn-history-test.mjs +371 -0
  20. package/scripts/lifecycle-api-test.mjs +76 -0
  21. package/scripts/max-output-recovery-test.mjs +55 -0
  22. package/scripts/mcp-grace-deferred-test.mjs +89 -13
  23. package/scripts/memory-pg-recovery-test.mjs +59 -0
  24. package/scripts/openai-oauth-ws-1006-retry-test.mjs +391 -4
  25. package/scripts/process-lifecycle-test.mjs +389 -0
  26. package/scripts/provider-admission-scheduler-test.mjs +582 -0
  27. package/scripts/provider-contract-test.mjs +268 -0
  28. package/scripts/provider-toolcall-test.mjs +520 -3
  29. package/scripts/reactive-compact-persist-smoke.mjs +59 -0
  30. package/scripts/resource-admission-test.mjs +789 -0
  31. package/scripts/session-bench-cache-break-test.mjs +102 -0
  32. package/scripts/session-bench.mjs +101 -42
  33. package/scripts/shell-failure-diagnostics-test.mjs +73 -4
  34. package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
  35. package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
  36. package/scripts/smoke-loop-failure-summary.mjs +16 -0
  37. package/scripts/smoke-loop.mjs +2 -7
  38. package/scripts/steering-drain-buckets-test.mjs +18 -0
  39. package/scripts/tool-failures.mjs +15 -1
  40. package/scripts/toolcall-args-test.mjs +14 -6
  41. package/scripts/tui-transcript-perf-test.mjs +43 -7
  42. package/scripts/web-fetch-routing-test.mjs +158 -0
  43. package/src/cli.mjs +15 -2
  44. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +26 -0
  45. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
  46. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -0
  47. package/src/runtime/agent/orchestrator/internal-tools.mjs +2 -0
  48. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +331 -0
  49. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +118 -26
  50. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +29 -17
  51. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -38
  52. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +24 -4
  53. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
  54. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
  55. package/src/runtime/agent/orchestrator/providers/gemini.mjs +95 -46
  56. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -4
  57. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
  58. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
  59. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -3
  60. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
  61. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +89 -17
  62. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +93 -26
  63. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +144 -51
  64. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
  65. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
  66. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +111 -6
  67. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -17
  68. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
  69. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
  70. package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
  71. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +224 -104
  72. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +127 -55
  73. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
  74. package/src/runtime/agent/orchestrator/session/context-utils.mjs +17 -1
  75. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +38 -0
  76. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -0
  77. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +69 -37
  78. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
  79. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
  80. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +6 -7
  81. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
  82. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
  83. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
  84. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
  85. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
  86. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +24 -4
  87. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
  88. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
  89. package/src/runtime/agent/orchestrator/session/store.mjs +89 -22
  90. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
  91. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
  92. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
  93. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
  94. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +380 -37
  95. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
  96. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
  97. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
  98. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
  99. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
  100. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -14
  101. package/src/runtime/memory/index.mjs +22 -4
  102. package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
  103. package/src/runtime/memory/lib/pg/process.mjs +91 -47
  104. package/src/runtime/memory/lib/pg/supervisor.mjs +50 -13
  105. package/src/runtime/search/index.mjs +41 -0
  106. package/src/runtime/search/lib/http-fetch.mjs +154 -0
  107. package/src/runtime/search/tool-defs.mjs +23 -0
  108. package/src/runtime/shared/atomic-file.mjs +28 -150
  109. package/src/runtime/shared/process-lifecycle.mjs +363 -0
  110. package/src/runtime/shared/process-shutdown.mjs +27 -4
  111. package/src/runtime/shared/resource-admission.mjs +359 -0
  112. package/src/runtime/shared/staged-child-result.mjs +19 -0
  113. package/src/session-runtime/context-status.mjs +38 -27
  114. package/src/session-runtime/lifecycle-api.mjs +26 -6
  115. package/src/session-runtime/provider-request-tools.mjs +72 -0
  116. package/src/session-runtime/runtime-core.mjs +43 -18
  117. package/src/session-runtime/session-turn-api.mjs +5 -4
  118. package/src/session-runtime/tool-catalog.mjs +375 -15
  119. package/src/standalone/agent-tool.mjs +17 -38
  120. package/src/standalone/channel-daemon-client.mjs +200 -38
  121. package/src/standalone/channel-daemon-transport.mjs +136 -9
  122. package/src/standalone/channel-worker.mjs +79 -12
  123. package/src/standalone/hook-bus/handlers.mjs +4 -0
  124. package/src/standalone/hook-bus/rules.mjs +7 -1
  125. package/src/standalone/hook-bus.mjs +10 -2
  126. package/src/tui/App.jsx +17 -11
  127. package/src/tui/app/live-spinner-visibility.mjs +20 -0
  128. package/src/tui/dist/index.mjs +101 -281
  129. package/src/tui/engine/session-api-ext.mjs +13 -2
  130. package/src/tui/engine/session-api.mjs +1 -1
  131. package/src/tui/engine/turn.mjs +10 -6
  132. package/src/tui/engine.mjs +13 -4
  133. package/src/tui/index.jsx +4 -1
  134. package/src/tui/lib/voice-setup.mjs +16 -0
@@ -100,9 +100,8 @@ export function withFileLockSync(lockPath, fn, opts = {}) {
100
100
  contErr.cause = err;
101
101
  throw contErr;
102
102
  }
103
- // Stale-lock reclaim (shared with the async variant). Covers a dead
104
- // owner pid, a same-pid-but-foreign-token corpse from a reused pid, and
105
- // a pidless/empty corpse lock; a live foreign holder is never stolen.
103
+ // Reclaim only when owner death is positively established. Age, an
104
+ // unreadable owner, or a same-pid foreign token remain contended.
106
105
  try {
107
106
  if (_tryReclaimStaleLock(lockPath, staleMs)) continue;
108
107
  } catch {}
@@ -127,11 +126,9 @@ export function withFileLockSync(lockPath, fn, opts = {}) {
127
126
  return fn();
128
127
  } finally {
129
128
  try { closeSync(fd); } catch {}
130
- // Only unlink if we still own the lock. If our hold exceeded
131
- // staleMs and another process already stole + replaced the
132
- // file, unconditionally unlinking here would destroy the new
133
- // owner's lock and break mutual exclusion for whoever is
134
- // queued behind it.
129
+ // Only unlink if we still own the lock. If the path was externally
130
+ // replaced, unconditionally unlinking would destroy the new owner's
131
+ // lock and break mutual exclusion for whoever is queued behind it.
135
132
  try {
136
133
  if (_lockOwnedBySelf(lockPath)) unlinkSync(lockPath);
137
134
  } catch {}
@@ -147,33 +144,6 @@ export function withFileLockSync(lockPath, fn, opts = {}) {
147
144
  // token is the owner pid (see the writeFileSync above). Best-effort
148
145
  // only: a parse failure or stat error is treated as "not ours" /
149
146
  // "owner unknown" so we err on the side of NOT stealing.
150
- function _readLockOwnerPid(lockPath) {
151
- try {
152
- const raw = readFileSync(lockPath, 'utf8');
153
- const tok = String(raw).trim().split(/\s+/)[0];
154
- const pid = Number.parseInt(tok, 10);
155
- return Number.isFinite(pid) && pid > 0 ? pid : null;
156
- } catch {
157
- return null;
158
- }
159
- }
160
-
161
- function _pidIsDead(pid) {
162
- // Unreadable / unparseable pid: be conservative — treat as alive so
163
- // we wait instead of stealing.
164
- if (pid === null) return false;
165
- if (pid === process.pid) return false;
166
- try {
167
- process.kill(pid, 0);
168
- return false; // signal delivered → process exists
169
- } catch (err) {
170
- // ESRCH = no such process → owner is gone, safe to steal.
171
- // EPERM = process exists but we can't signal it → still alive,
172
- // do NOT steal.
173
- return err?.code === 'ESRCH';
174
- }
175
- }
176
-
177
147
  // Full owner identity: pid plus per-instance token. token is null for an
178
148
  // old-format (`<pid> <ts>`) lock, in which case callers fall back to
179
149
  // pid-authoritative handling so a still-running old-format holder is
@@ -196,13 +166,8 @@ function _readLockOwner(lockPath) {
196
166
  // - pid null (empty/unparseable): not live (unknown owner).
197
167
  // - pid === our pid: treated as LIVE. A foreign/absent token on our own
198
168
  // pid is ambiguous — it may be a recycled-pid corpse, but it may also
199
- // be a live sibling worker_thread (which shares process.pid yet owns a
200
- // separate _OWNER_TOKEN module instance). We cannot distinguish them
201
- // from the token alone, so we never call a same-pid owner instantly
202
- // dead; the reclaim path gates a same-pid foreign-token steal behind
203
- // stale mtime (see _ownerIsSelfForeign / _tryReclaimStaleLock) so a
204
- // live sibling is never yanked, while a real corpse still frees once
205
- // mtime goes stale.
169
+ // be a live sibling worker_thread (which shares process.pid). Ambiguity
170
+ // is never reclaimed.
206
171
  // - foreign pid: probed with kill(pid, 0); ESRCH ⇒ dead, else live.
207
172
  function _ownerIsLive(owner) {
208
173
  if (owner.pid === null) return false;
@@ -215,15 +180,6 @@ function _ownerIsLive(owner) {
215
180
  }
216
181
  }
217
182
 
218
- // Our own pid but a foreign (present, non-matching) token: either a
219
- // recycled-pid corpse or a live sibling worker_thread. Reclaimable only
220
- // once mtime is past staleMs (handled by the pidless-style stale gate).
221
- function _ownerIsSelfForeign(owner) {
222
- return owner.pid === process.pid
223
- && owner.token !== null
224
- && owner.token !== _OWNER_TOKEN;
225
- }
226
-
227
183
  // Release-time guard: unlink only if the lock still carries OUR identity
228
184
  // (our pid AND our token). Protects against deleting a lock another
229
185
  // instance stole+recreated, and against a same-pid sibling.
@@ -238,23 +194,15 @@ function _lockOwnedBySelf(lockPath) {
238
194
  // they wait between attempts. Returns true iff lockPath was reclaimed.
239
195
  //
240
196
  // Reclaim decision table (owner = identity recorded in the lock file):
241
- // owner pid DEAD (ESRCH / self-pid+foreign-token corpse) → reclaim
242
- // owner pid NULL (empty/unparseable) AND mtime past staleMsreclaim
197
+ // owner pid DEAD (ESRCH) → reclaim
198
+ // owner pid NULL/uncertain or same-pid foreign tokenback off
243
199
  // owner token MISMATCH on guarded re-read (new holder took over) → back off
244
- // owner LIVE (foreign pid alive, or our own current token) → back off
200
+ // owner LIVE back off regardless of age
245
201
  function _tryReclaimStaleLock(lockPath, staleMs) {
246
- let st;
247
- try { st = statSync(lockPath); } catch { return false; }
202
+ try { statSync(lockPath); } catch { return false; }
248
203
  const owner = _readLockOwner(lockPath);
249
- const stale = Date.now() - st.mtimeMs > staleMs;
250
204
  const dead = owner.pid !== null && !_ownerIsLive(owner);
251
- const pidless = owner.pid === null;
252
- const selfForeign = _ownerIsSelfForeign(owner);
253
- // A dead foreign owner reclaims immediately. A pidless corpse, or a
254
- // same-pid foreign-token owner (which may still be a live sibling
255
- // worker_thread), reclaims only once mtime is past staleMs; a live
256
- // foreign holder is never stolen even after mtime goes stale.
257
- if (!dead && !((pidless || selfForeign) && stale)) return false;
205
+ if (!dead) return false;
258
206
  const reclaim = _tryAcquireReclaimGuard(lockPath, staleMs);
259
207
  if (reclaim === null) return false;
260
208
  try {
@@ -266,11 +214,8 @@ function _tryReclaimStaleLock(lockPath, staleMs) {
266
214
  // the guard window has a different token and is not mistaken for the
267
215
  // corpse — so we never yank a freshly-acquired live lock.
268
216
  if (curOwner.pid !== owner.pid || curOwner.token !== owner.token) return false;
269
- const curStale = Date.now() - cur.mtimeMs > staleMs;
270
217
  const curDead = curOwner.pid !== null && !_ownerIsLive(curOwner);
271
- const curPidless = curOwner.pid === null;
272
- const curSelfForeign = _ownerIsSelfForeign(curOwner);
273
- if (curDead || ((curPidless || curSelfForeign) && curStale)) {
218
+ if (curDead) {
274
219
  try { unlinkSync(lockPath); return true; } catch { return false; }
275
220
  }
276
221
  return false;
@@ -279,89 +224,23 @@ function _tryReclaimStaleLock(lockPath, staleMs) {
279
224
  }
280
225
  }
281
226
 
282
- function _reclaimGuardIsUnreadableAndStale(guardPath, staleMs) {
283
- if (_readLockOwnerPid(guardPath) !== null) return false;
284
- try {
285
- const st = statSync(guardPath);
286
- return Date.now() - st.mtimeMs > staleMs;
287
- } catch {
288
- return false;
289
- }
290
- }
291
-
292
- function _reclaimGuardMatchesToken(guardPath, token) {
293
- try {
294
- return readFileSync(guardPath, 'utf8') === token;
295
- } catch {
296
- return false;
297
- }
298
- }
299
-
300
- function _readGuardContent(guardPath) {
301
- try { return readFileSync(guardPath, 'utf8'); } catch { return null; }
302
- }
303
-
304
- function _parseStampPid(raw) {
305
- if (raw === null) return null;
306
- const tok = String(raw).trim().split(/\s+/)[0];
307
- const pid = Number.parseInt(tok, 10);
308
- return Number.isFinite(pid) && pid > 0 ? pid : null;
309
- }
310
-
311
- // Unlink the guard only if its FULL content (pid ts token) is still the
312
- // exact content the stale/dead decision was made against. A regenerated
313
- // guard — even one whose pid was recycled to the same value (same-pid
314
- // ABA) — carries a fresh ts+token and therefore never matches, so a
315
- // fresh guard is never removed by a stale decision.
316
- function _unlinkReclaimGuardIfContentMatches(guardPath, content) {
317
- if (_readGuardContent(guardPath) !== content) return false;
318
- try { unlinkSync(guardPath); return true; } catch { return false; }
319
- }
320
-
321
- function _unlinkUnreadableStaleReclaimGuard(guardPath, staleMs) {
322
- if (!_reclaimGuardIsUnreadableAndStale(guardPath, staleMs)) return false;
323
- try { unlinkSync(guardPath); return true; } catch { return false; }
324
- }
325
-
326
- function _tryClearStaleReclaimGuard(guardPath, staleMs) {
327
- const guardContent = _readGuardContent(guardPath);
328
- const guardPid = _parseStampPid(guardContent);
329
- if (guardPid !== null) {
330
- // Re-read the guard's full content immediately before unlinking so a
331
- // regenerated guard (different owner OR same-pid ABA) is not removed
332
- // by a stale decision.
333
- // This is still not a serialized primitive: correctness comes from
334
- // the lockPath owner-token reverify before unlinking lockPath, not
335
- // from making guard cleanup itself authoritative.
336
- // A guard whose pid was recycled onto a LIVE process would otherwise
337
- // never clear, starving reclaim permanently; so also clear a guard
338
- // past staleMs regardless of apparent pid-liveness. Safe because the
339
- // guard is best-effort — the lockPath owner-token reverify still gates
340
- // the actual lock unlink.
341
- let guardStale = false;
342
- try { guardStale = Date.now() - statSync(guardPath).mtimeMs > staleMs; } catch {}
343
- if (_pidIsDead(guardPid) || guardStale) {
344
- _unlinkReclaimGuardIfContentMatches(guardPath, guardContent);
345
- }
346
- return;
347
- }
348
- // A crash between openSync('wx') and the pid write can leave an empty
349
- // guard. There is no pid to prove dead, so clear only once the guard
350
- // file itself is stale; any contender that proceeds must still prove
351
- // the lockPath token dead before unlinking the lock.
352
- _unlinkUnreadableStaleReclaimGuard(guardPath, staleMs);
353
- }
354
-
355
227
  function _tryAcquireReclaimGuard(lockPath, staleMs) {
356
228
  const guardPath = `${lockPath}.reclaim`;
229
+ const token = `${process.pid} ${Date.now()} ${randomBytes(8).toString('hex')}\n`;
230
+ const stagedPath = `${guardPath}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
357
231
  try {
358
- const fd = openSync(guardPath, 'wx', 0o600);
359
- const token = `${process.pid} ${Date.now()} ${randomBytes(8).toString('hex')}\n`;
360
- try { writeFileSync(fd, token, 'utf8'); } catch {}
361
- return { fd, guardPath, token };
232
+ // Publish a fully formed guard with no empty-file window. linkSync is
233
+ // atomic and fails rather than replacing an existing pathname.
234
+ writeFileSync(stagedPath, token, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
235
+ linkSync(stagedPath, guardPath);
236
+ unlinkSync(stagedPath);
237
+ return { guardPath };
362
238
  } catch (err) {
239
+ try { unlinkSync(stagedPath); } catch {}
363
240
  if (LOCK_WAIT_CODES.has(err?.code)) {
364
- _tryClearStaleReclaimGuard(guardPath, staleMs);
241
+ // There is no portable ownership-conditional unlink. Never revoke a
242
+ // published guard: age and contents are diagnostic only, and deleting
243
+ // after a pathname re-read admits an ABA that can steal a live guard.
365
244
  return null;
366
245
  }
367
246
  throw err;
@@ -369,10 +248,9 @@ function _tryAcquireReclaimGuard(lockPath, staleMs) {
369
248
  }
370
249
 
371
250
  function _releaseReclaimGuard(reclaim) {
372
- try { closeSync(reclaim.fd); } catch {}
373
- if (_reclaimGuardMatchesToken(reclaim.guardPath, reclaim.token)) {
374
- try { unlinkSync(reclaim.guardPath); } catch {}
375
- }
251
+ // Protocol participants cannot replace a published guard: contenders only
252
+ // wait, so the exact owner may remove its non-revocable pathname directly.
253
+ try { unlinkSync(reclaim.guardPath); } catch {}
376
254
  }
377
255
 
378
256
  // ── Async lock variant (non-blocking wait) ──────────────────────────
@@ -0,0 +1,363 @@
1
+ 'use strict';
2
+
3
+ import {
4
+ closeSync,
5
+ existsSync,
6
+ fsyncSync,
7
+ mkdirSync,
8
+ openSync,
9
+ readFileSync,
10
+ readdirSync,
11
+ renameSync,
12
+ statSync,
13
+ unlinkSync,
14
+ writeFileSync,
15
+ writeSync,
16
+ } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { execFileSync } from 'node:child_process';
19
+ import { withFileLockSync } from './atomic-file.mjs';
20
+ import { resolvePluginData } from './plugin-paths.mjs';
21
+
22
+ export const LIFECYCLE_LEDGER_MAX_BYTES = 64 * 1024;
23
+ export const LIFECYCLE_LEDGER_KEEP_FILES = 2;
24
+ const LEDGER_NAME = 'process-lifecycle.jsonl';
25
+ const MARKER_DIR_NAME = 'process-lifecycle.active';
26
+ const LEGACY_MARKER_NAME = 'process-lifecycle.active.json';
27
+ const REPORT_NAME = 'mixdog-node-report.json';
28
+ const LEDGER_LOCK_NAME = 'process-lifecycle.lock';
29
+ const LEDGER_LOCK_TIMEOUT_MS = 2000;
30
+ const LEDGER_LOCK_STALE_MS = 10000;
31
+ const STATE_KEY = Symbol.for('mixdog.processLifecycle.v1');
32
+ const VALID_REASONS = new Set([
33
+ 'process-start',
34
+ 'prior-process-vanished',
35
+ 'clean-shutdown',
36
+ 'catchable-fatal-error',
37
+ 'forced-cleanup',
38
+ ]);
39
+
40
+ function sharedState() {
41
+ if (!globalThis[STATE_KEY]) globalThis[STATE_KEY] = { active: null };
42
+ return globalThis[STATE_KEY];
43
+ }
44
+
45
+ function paths(directory) {
46
+ const dir = directory || join(resolvePluginData(), 'diagnostics');
47
+ return {
48
+ dir,
49
+ ledger: join(dir, LEDGER_NAME),
50
+ previousLedger: join(dir, `${LEDGER_NAME}.1`),
51
+ markerDir: join(dir, MARKER_DIR_NAME),
52
+ legacyMarker: join(dir, LEGACY_MARKER_NAME),
53
+ lock: join(dir, LEDGER_LOCK_NAME),
54
+ report: join(dir, REPORT_NAME),
55
+ };
56
+ }
57
+
58
+ function memoryCounters() {
59
+ try {
60
+ const value = process.memoryUsage();
61
+ return {
62
+ rss: value.rss,
63
+ heapTotal: value.heapTotal,
64
+ heapUsed: value.heapUsed,
65
+ external: value.external,
66
+ arrayBuffers: value.arrayBuffers,
67
+ };
68
+ } catch {
69
+ return {};
70
+ }
71
+ }
72
+
73
+ function rotateLedger(active) {
74
+ const size = Number(statSync(active.ledger).size) || 0;
75
+ try { unlinkSync(active.previousLedger); } catch {}
76
+ if (size <= LIFECYCLE_LEDGER_MAX_BYTES) {
77
+ renameSync(active.ledger, active.previousLedger);
78
+ return;
79
+ }
80
+
81
+ // An oversized file cannot have been produced by this writer. Do not carry
82
+ // an untrusted/partial record into the retained generation.
83
+ writeFileSync(active.previousLedger, '', { mode: 0o600 });
84
+ unlinkSync(active.ledger);
85
+ }
86
+
87
+ function boundPreviousLedger(active) {
88
+ if (existsSync(active.previousLedger)
89
+ && statSync(active.previousLedger).size > LIFECYCLE_LEDGER_MAX_BYTES) {
90
+ writeFileSync(active.previousLedger, '', { mode: 0o600 });
91
+ }
92
+ }
93
+
94
+ function appendEntry(active, entry) {
95
+ const line = `${JSON.stringify(entry)}\n`;
96
+ if (Buffer.byteLength(line) > LIFECYCLE_LEDGER_MAX_BYTES) return false;
97
+ try {
98
+ return withFileLockSync(active.lock, () => {
99
+ boundPreviousLedger(active);
100
+ if (existsSync(active.ledger)
101
+ && statSync(active.ledger).size + Buffer.byteLength(line) > LIFECYCLE_LEDGER_MAX_BYTES) {
102
+ rotateLedger(active);
103
+ }
104
+ const fd = openSync(active.ledger, 'a', 0o600);
105
+ try {
106
+ writeSync(fd, line, null, 'utf8');
107
+ fsyncSync(fd);
108
+ } finally {
109
+ closeSync(fd);
110
+ }
111
+ return statSync(active.ledger).size <= LIFECYCLE_LEDGER_MAX_BYTES;
112
+ }, {
113
+ timeoutMs: LEDGER_LOCK_TIMEOUT_MS,
114
+ staleMs: LEDGER_LOCK_STALE_MS,
115
+ });
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ function currentProcessIdentity() {
122
+ return processIdentityForPid(process.pid);
123
+ }
124
+
125
+ function processIdentityForPid(pid) {
126
+ if (process.platform === 'linux') {
127
+ try {
128
+ const raw = readFileSync(`/proc/${pid}/stat`, 'utf8');
129
+ const fields = raw.slice(raw.lastIndexOf(') ') + 2).trim().split(/\s+/);
130
+ return fields[19] && /^\d+$/.test(fields[19])
131
+ ? { kind: 'linux-start-ticks', value: fields[19] }
132
+ : null;
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+ try {
138
+ if (process.platform === 'win32') {
139
+ const out = execFileSync('powershell.exe', [
140
+ '-NoProfile', '-NonInteractive', '-Command',
141
+ `([DateTimeOffset](Get-Process -Id ${pid} -ErrorAction Stop).StartTime).ToUnixTimeSeconds()`,
142
+ ], { encoding: 'utf8', timeout: 2000, windowsHide: true });
143
+ const text = String(out).trim();
144
+ const value = /^\d+$/.test(text) ? Number(text) : NaN;
145
+ return Number.isSafeInteger(value) ? { kind: 'start-seconds', value } : null;
146
+ }
147
+ const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
148
+ encoding: 'utf8',
149
+ timeout: 2000,
150
+ windowsHide: true,
151
+ });
152
+ const value = Math.floor(Date.parse(String(out).trim()) / 1000);
153
+ return Number.isInteger(value) ? { kind: 'start-seconds', value } : null;
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+
159
+ function sameProcessIdentity(expected, observed) {
160
+ if (!expected || !observed || expected.kind !== observed.kind) return null;
161
+ if (expected.kind === 'linux-start-ticks') {
162
+ if (!/^\d+$/.test(String(expected.value)) || !/^\d+$/.test(String(observed.value))) return null;
163
+ if (BigInt(expected.value) < 1n || BigInt(observed.value) < 1n) return null;
164
+ return String(expected.value) === String(observed.value);
165
+ }
166
+ if (expected.kind === 'start-seconds') {
167
+ if (!Number.isSafeInteger(expected.value) || expected.value < 1
168
+ || !Number.isSafeInteger(observed.value) || observed.value < 1) return null;
169
+ return expected.value === observed.value;
170
+ }
171
+ return null;
172
+ }
173
+
174
+ function recordCurrent(reason, exitCode = null) {
175
+ const active = sharedState().active;
176
+ if (!active || !VALID_REASONS.has(reason)) return false;
177
+ return appendEntry(active, {
178
+ version: 1,
179
+ timestamp: new Date().toISOString(),
180
+ pid: process.pid,
181
+ ppid: process.ppid,
182
+ reason,
183
+ exitCode: Number.isInteger(exitCode) ? exitCode : null,
184
+ memory: memoryCounters(),
185
+ cwd: active.cwd,
186
+ });
187
+ }
188
+
189
+ function recordPriorVanished(active, previous) {
190
+ return appendEntry(active, {
191
+ version: 1,
192
+ timestamp: new Date().toISOString(),
193
+ pid: previous.pid,
194
+ ppid: Number.isInteger(previous.ppid) ? previous.ppid : null,
195
+ reason: 'prior-process-vanished',
196
+ exitCode: null,
197
+ });
198
+ }
199
+
200
+ function pidLiveness(pid) {
201
+ if (!Number.isInteger(pid) || pid < 1 || pid > 2147483647) return 'unknown';
202
+ try {
203
+ process.kill(pid, 0);
204
+ return 'occupied';
205
+ } catch (error) {
206
+ if (error?.code === 'ESRCH') return 'dead';
207
+ if (error?.code === 'EPERM') return 'occupied';
208
+ return 'unknown';
209
+ }
210
+ }
211
+
212
+ function markerOwned(active) {
213
+ try {
214
+ const marker = JSON.parse(readFileSync(active.markerPath, 'utf8'));
215
+ return marker?.pid === process.pid && marker?.token === active.token;
216
+ } catch {
217
+ return false;
218
+ }
219
+ }
220
+
221
+ function writeMarker(active) {
222
+ try {
223
+ mkdirSync(active.markerDir, { recursive: true, mode: 0o700 });
224
+ writeFileSync(active.markerPath, `${JSON.stringify({
225
+ version: 1,
226
+ timestamp: new Date().toISOString(),
227
+ pid: process.pid,
228
+ ppid: process.ppid,
229
+ token: active.token,
230
+ processIdentity: active.processIdentity,
231
+ })}\n`, { encoding: 'utf8', mode: 0o600 });
232
+ const fd = openSync(active.markerPath, 'r');
233
+ try { fsyncSync(fd); } finally { closeSync(fd); }
234
+ return true;
235
+ } catch {
236
+ return false;
237
+ }
238
+ }
239
+
240
+ function reapVanishedMarkers(active) {
241
+ const candidates = [];
242
+ try {
243
+ for (const entry of readdirSync(active.markerDir, { withFileTypes: true })) {
244
+ if (entry.isFile() && entry.name.endsWith('.json')) {
245
+ candidates.push(join(active.markerDir, entry.name));
246
+ }
247
+ }
248
+ } catch {}
249
+ if (existsSync(active.legacyMarker)) candidates.push(active.legacyMarker);
250
+ for (const markerPath of candidates) {
251
+ try {
252
+ const previous = JSON.parse(readFileSync(markerPath, 'utf8'));
253
+ const liveness = pidLiveness(previous?.pid);
254
+ let vanished = liveness === 'dead';
255
+ if (liveness === 'occupied') {
256
+ const identityMatch = sameProcessIdentity(
257
+ previous.processIdentity,
258
+ processIdentityForPid(previous.pid),
259
+ );
260
+ vanished = identityMatch === false;
261
+ }
262
+ if (!vanished) continue;
263
+ if (recordPriorVanished(active, previous)) unlinkSync(markerPath);
264
+ } catch {}
265
+ }
266
+ }
267
+
268
+ function configureNodeReports(reportPath, safeCommandLine) {
269
+ const report = process.report;
270
+ if (!report || !safeCommandLine || !('excludeEnv' in report)) return false;
271
+ try {
272
+ report.directory = '';
273
+ report.filename = reportPath;
274
+ report.compact = true;
275
+ report.excludeEnv = true;
276
+ if ('excludeNetwork' in report) report.excludeNetwork = true;
277
+ report.reportOnFatalError = true;
278
+ report.reportOnUncaughtException = true;
279
+ return true;
280
+ } catch {
281
+ return false;
282
+ }
283
+ }
284
+
285
+ export function beginProcessLifecycle({
286
+ directory,
287
+ configureReports = true,
288
+ safeCommandLine = process.argv.length <= 2,
289
+ } = {}) {
290
+ const state = sharedState();
291
+ if (state.active) return state.active.api;
292
+ const resolved = paths(directory);
293
+ try {
294
+ mkdirSync(resolved.dir, { recursive: true, mode: 0o700 });
295
+ mkdirSync(resolved.markerDir, { recursive: true, mode: 0o700 });
296
+ } catch {}
297
+ const token = `${Date.now().toString(36)}-${process.hrtime.bigint().toString(36)}`;
298
+ const active = {
299
+ ...resolved,
300
+ token,
301
+ processIdentity: currentProcessIdentity(),
302
+ markerPath: join(resolved.markerDir, `${process.pid}-${token}.json`),
303
+ cwd: (() => { try { return process.cwd(); } catch { return null; } })(),
304
+ };
305
+ state.active = active;
306
+
307
+ reapVanishedMarkers(active);
308
+ writeMarker(active);
309
+ recordCurrent('process-start');
310
+ if (configureReports && safeCommandLine) {
311
+ try { unlinkSync(`${active.report}.1`); } catch {}
312
+ try { renameSync(active.report, `${active.report}.1`); } catch {}
313
+ }
314
+ const reportsEnabled = configureReports && configureNodeReports(active.report, safeCommandLine);
315
+ active.api = {
316
+ directory: active.dir,
317
+ ledgerPath: active.ledger,
318
+ markerDir: active.markerDir,
319
+ markerPath: active.markerPath,
320
+ reportPath: reportsEnabled ? active.report : null,
321
+ };
322
+ return active.api;
323
+ }
324
+
325
+ export function recordCatchableFatal(exitCode = 1) {
326
+ const active = sharedState().active;
327
+ if (active) {
328
+ const next = strongerReason(active.finalReason, 'catchable-fatal-error');
329
+ if (next !== active.finalReason || !Number.isInteger(active.finalExitCode)) {
330
+ active.finalExitCode = exitCode;
331
+ }
332
+ active.finalReason = next;
333
+ }
334
+ return recordCurrent('catchable-fatal-error', exitCode);
335
+ }
336
+
337
+ function strongerReason(current, candidate) {
338
+ const rank = { 'clean-shutdown': 0, 'catchable-fatal-error': 1, 'forced-cleanup': 2 };
339
+ return (rank[candidate] ?? 0) > (rank[current] ?? -1) ? candidate : current;
340
+ }
341
+
342
+ export function finishProcessLifecycle(reason = 'clean-shutdown', exitCode = 0) {
343
+ const state = sharedState();
344
+ const active = state.active;
345
+ if (!active) return false;
346
+ const finalReason = VALID_REASONS.has(reason) ? reason : 'clean-shutdown';
347
+ const nextReason = strongerReason(active.finalReason, finalReason);
348
+ if (nextReason !== active.finalReason || !Number.isInteger(active.finalExitCode)) {
349
+ active.finalExitCode = exitCode;
350
+ }
351
+ active.finalReason = nextReason;
352
+ const written = recordCurrent(active.finalReason, active.finalExitCode);
353
+ if (!written) return false;
354
+ if (markerOwned(active)) {
355
+ try { unlinkSync(active.markerPath); } catch { return false; }
356
+ }
357
+ state.active = null;
358
+ return true;
359
+ }
360
+
361
+ export function lifecyclePathsForTest(directory) {
362
+ return paths(directory);
363
+ }
@@ -1,5 +1,10 @@
1
1
  'use strict';
2
2
 
3
+ import {
4
+ finishProcessLifecycle,
5
+ recordCatchableFatal,
6
+ } from './process-lifecycle.mjs';
7
+
3
8
  const SIGNAL_EXIT_CODES = {
4
9
  SIGINT: 130,
5
10
  SIGTERM: 143,
@@ -27,7 +32,6 @@ export function waitWithTimeout(promise, timeoutMs, label = 'cleanup') {
27
32
  settled = true;
28
33
  reject(new Error(`${label} timed out after ${ms}ms`));
29
34
  }, ms);
30
- if (typeof timer.unref === 'function') timer.unref();
31
35
  Promise.resolve(promise)
32
36
  .then((value) => {
33
37
  if (settled) return;
@@ -67,6 +71,7 @@ export function installProcessSignalCleanup({
67
71
  };
68
72
 
69
73
  const hardExit = (code) => {
74
+ finishProcessLifecycle('forced-cleanup', code);
70
75
  try { process.exit(code); } catch {}
71
76
  };
72
77
 
@@ -81,6 +86,9 @@ export function installProcessSignalCleanup({
81
86
  }
82
87
  running = true;
83
88
  removeHandlers();
89
+ let cleanupFailed = false;
90
+
91
+ if (error) recordCatchableFatal(code);
84
92
 
85
93
  if (shouldExit) {
86
94
  hardExitTimer = setTimeout(() => hardExit(code), Math.max(1000, Number(timeoutMs) + 1000));
@@ -91,21 +99,36 @@ export function installProcessSignalCleanup({
91
99
  log(`[${name}] ${reason}: ${errorText(error)}`);
92
100
  }
93
101
 
94
- try { beforeCleanup?.(reason, { code, error }); } catch {}
102
+ try { globalThis.__mixdogShutdownProviderAdmission?.(reason); } catch {}
103
+ try { globalThis.__mixdogDrainProviderConnections?.(reason); } catch {}
104
+ try { beforeCleanup?.(reason, { code, error }); } catch (cleanupError) {
105
+ cleanupFailed = true;
106
+ if (typeof log === 'function') log(`[${name}] cleanup failed: ${errorText(cleanupError)}`);
107
+ }
95
108
  try {
96
109
  if (typeof cleanup === 'function') {
97
110
  await waitWithTimeout(cleanup(reason, { code, error }), timeoutMs, `${name} shutdown`);
98
111
  }
99
112
  } catch (cleanupError) {
113
+ cleanupFailed = true;
114
+ if (typeof log === 'function') log(`[${name}] cleanup failed: ${errorText(cleanupError)}`);
115
+ }
116
+ try { afterCleanup?.(reason, { code, error }); } catch (cleanupError) {
117
+ cleanupFailed = true;
100
118
  if (typeof log === 'function') log(`[${name}] cleanup failed: ${errorText(cleanupError)}`);
101
119
  }
102
- try { afterCleanup?.(reason, { code, error }); } catch {}
103
120
  if (hardExitTimer) {
104
121
  clearTimeout(hardExitTimer);
105
122
  hardExitTimer = null;
106
123
  }
107
124
  running = false;
108
- if (shouldExit) hardExit(code);
125
+ if (shouldExit) {
126
+ finishProcessLifecycle(
127
+ cleanupFailed ? 'forced-cleanup' : error ? 'catchable-fatal-error' : 'clean-shutdown',
128
+ code,
129
+ );
130
+ try { process.exit(code); } catch {}
131
+ }
109
132
  return true;
110
133
  };
111
134