mixdog 0.9.151 → 0.9.152

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 (175) hide show
  1. package/README.md +110 -68
  2. package/package.json +3 -2
  3. package/scripts/tool-stress.mjs +0 -2
  4. package/src/defaults/skills/setup/SKILL.md +18 -27
  5. package/src/headless-exec.mjs +3 -10
  6. package/src/headless-exec.test.mjs +2 -7
  7. package/src/runtime/agent/orchestrator/config.mjs +6 -66
  8. package/src/runtime/agent/orchestrator/context/collect-skills.test.mjs +153 -0
  9. package/src/runtime/agent/orchestrator/context/collect.mjs +49 -45
  10. package/src/runtime/agent/orchestrator/mcp/client.mjs +242 -15
  11. package/src/runtime/agent/orchestrator/mcp/features.test.mjs +66 -0
  12. package/src/runtime/agent/orchestrator/mcp/security.test.mjs +36 -20
  13. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +54 -33
  14. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -0
  15. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
  16. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +7 -0
  17. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +3 -8
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +7 -0
  19. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +10 -2
  20. package/src/runtime/agent/orchestrator/providers/lib/provider-replay.mjs +50 -0
  21. package/src/runtime/agent/orchestrator/providers/lib/sse-framing.mjs +93 -0
  22. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +3 -3
  23. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +8 -0
  24. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +7 -0
  25. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +25 -2
  26. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
  27. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -0
  28. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -2
  29. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +13 -0
  30. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +4 -7
  31. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +9 -0
  32. package/src/runtime/agent/orchestrator/providers/provider-replay.test.mjs +235 -0
  33. package/src/runtime/agent/orchestrator/providers/sse-framing.test.mjs +719 -0
  34. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +399 -39
  35. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +26 -0
  36. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +14 -4
  37. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +13 -0
  38. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +24 -1
  39. package/src/runtime/agent/orchestrator/session/compaction-read-reset.test.mjs +161 -0
  40. package/src/runtime/agent/orchestrator/session/context-utils.mjs +14 -4
  41. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -2
  42. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -1
  43. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +31 -18
  44. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -0
  45. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +3 -0
  46. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +6 -1
  47. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +1 -3
  48. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.mjs +556 -0
  49. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.test.mjs +457 -0
  50. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +105 -186
  51. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +115 -0
  52. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +2 -0
  53. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -0
  54. package/src/runtime/agent/orchestrator/session/store.mjs +23 -1
  55. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool-cwd-env.test.mjs +14 -0
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +19 -10
  57. package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +30 -14
  58. package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +15 -4
  59. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +73 -15
  60. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +76 -2
  61. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +24 -683
  62. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +64 -72
  63. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +2 -5
  64. package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.mjs +75 -0
  65. package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.test.mjs +24 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +12 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +4 -1
  68. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +24 -5
  69. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.test.mjs +44 -0
  70. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +12 -2
  71. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +35 -21
  72. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +28 -13
  73. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.test.mjs +54 -0
  74. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  75. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  76. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +8 -0
  77. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  78. package/src/runtime/browser-bridge/client.mjs +103 -0
  79. package/src/runtime/browser-bridge/tool-defs.mjs +42 -0
  80. package/src/runtime/computer-bridge/client.mjs +101 -0
  81. package/src/runtime/computer-bridge/tool-defs.mjs +53 -0
  82. package/src/runtime/media/store.mjs +18 -8
  83. package/src/runtime/media/store.test.mjs +14 -0
  84. package/src/runtime/memory/index.mjs +1 -1
  85. package/src/runtime/memory/lib/transcript-ingest.mjs +8 -6
  86. package/src/runtime/memory/lib/transcript-ingest.test.mjs +52 -0
  87. package/src/runtime/shared/child-spawn-gate.mjs +17 -0
  88. package/src/runtime/shared/child-spawn-remote.mjs +69 -7
  89. package/src/runtime/shared/child-spawn-remote.test.mjs +124 -0
  90. package/src/runtime/shared/pristine-execution-contract.json +2 -1
  91. package/src/runtime/shared/provider-api-key.mjs +1 -0
  92. package/src/runtime/shared/session-runtime-health.mjs +91 -0
  93. package/src/runtime/shared/session-runtime-health.test.mjs +53 -0
  94. package/src/runtime/shared/skill-document.mjs +86 -0
  95. package/src/runtime/shared/skill-document.test.mjs +81 -0
  96. package/src/runtime/shared/tool-surface.mjs +18 -0
  97. package/src/runtime/shared/turn-snapshot-store.mjs +1 -0
  98. package/src/runtime/shared/turn-snapshot.mjs +32 -9
  99. package/src/runtime/shared/user-cwd.mjs +33 -14
  100. package/src/runtime/shared/user-cwd.test.mjs +43 -0
  101. package/src/session-runtime/cwd-plugins.mjs +26 -26
  102. package/src/session-runtime/cwd-plugins.test.mjs +75 -0
  103. package/src/session-runtime/cwd-tool-routing.test.mjs +5 -0
  104. package/src/session-runtime/global-extensions.mjs +26 -0
  105. package/src/session-runtime/global-extensions.test.mjs +28 -0
  106. package/src/session-runtime/goal-runtime.mjs +721 -0
  107. package/src/session-runtime/goal-runtime.test.mjs +108 -0
  108. package/src/session-runtime/lifecycle-api.mjs +18 -4
  109. package/src/session-runtime/lifecycle-api.test.mjs +21 -0
  110. package/src/session-runtime/mcp-glue.mjs +49 -47
  111. package/src/session-runtime/plugin-mcp-standard.test.mjs +177 -0
  112. package/src/session-runtime/plugin-mcp.mjs +96 -22
  113. package/src/session-runtime/prewarm.mjs +1 -10
  114. package/src/session-runtime/resource-api-global.test.mjs +69 -0
  115. package/src/session-runtime/resource-api.mjs +173 -58
  116. package/src/session-runtime/runtime-core.mjs +148 -20
  117. package/src/session-runtime/runtime-tunables.mjs +6 -8
  118. package/src/session-runtime/session-turn-api.mjs +22 -11
  119. package/src/session-runtime/skills-api.mjs +79 -25
  120. package/src/session-runtime/skills-api.test.mjs +99 -0
  121. package/src/session-runtime/tool-catalog-schema.mjs +0 -5
  122. package/src/session-runtime/tool-catalog.mjs +4 -13
  123. package/src/session-runtime/tool-policy-surface.test.mjs +33 -11
  124. package/src/session-runtime/tool-surface.mjs +12 -0
  125. package/src/standalone/agent-dispatch-broker.mjs +5 -9
  126. package/src/standalone/agent-tool/notify.mjs +6 -8
  127. package/src/standalone/agent-tool/notify.test.mjs +37 -0
  128. package/src/standalone/daemon.mjs +31 -15
  129. package/src/standalone/hook-bus/config.mjs +1 -0
  130. package/src/standalone/plugin-admin.mjs +17 -0
  131. package/src/standalone/plugin-admin.test.mjs +7 -0
  132. package/src/standalone/provider-admin.mjs +1 -0
  133. package/src/standalone/session-protocol.mjs +5 -0
  134. package/src/standalone/session-runtime-agent-control-client.mjs +133 -0
  135. package/src/standalone/session-runtime-agent-control-client.test.mjs +71 -0
  136. package/src/standalone/session-runtime-dispatch-cancel.test.mjs +139 -0
  137. package/src/standalone/session-runtime-host-factory.mjs +17 -0
  138. package/src/standalone/session-runtime-host-factory.test.mjs +31 -0
  139. package/src/standalone/session-runtime-host-health.test.mjs +5 -0
  140. package/src/standalone/session-runtime-host.mjs +696 -58
  141. package/src/standalone/session-runtime-inline-host.mjs +351 -0
  142. package/src/standalone/session-runtime-inline-host.test.mjs +83 -0
  143. package/src/standalone/session-runtime-provider-cooldown.mjs +78 -0
  144. package/src/standalone/session-runtime-provider-cooldown.test.mjs +80 -0
  145. package/src/standalone/session-runtime-shard-host.test.mjs +569 -0
  146. package/src/standalone/session-runtime-shard-router.mjs +149 -0
  147. package/src/standalone/session-runtime-shard-router.test.mjs +101 -0
  148. package/src/standalone/session-runtime-worker.mjs +476 -18
  149. package/src/standalone/session-service.mjs +81 -6
  150. package/src/tui/app/app-view.jsx +1 -0
  151. package/src/tui/app/core-memory-picker.mjs +2 -0
  152. package/src/tui/app/extension-pickers.mjs +1 -1
  153. package/src/tui/app/maintenance-pickers.mjs +3 -3
  154. package/src/tui/app/model-options.mjs +2 -0
  155. package/src/tui/app/model-picker.mjs +14 -4
  156. package/src/tui/app/panel-handoff.test.mjs +311 -0
  157. package/src/tui/app/project-picker.mjs +1 -0
  158. package/src/tui/app/route-pickers.mjs +21 -6
  159. package/src/tui/app/settings-picker.mjs +26 -3
  160. package/src/tui/app/slash-commands.mjs +1 -0
  161. package/src/tui/app/slash-dispatch.mjs +73 -22
  162. package/src/tui/app/theme-effort-pickers.mjs +7 -2
  163. package/src/tui/components/Picker.jsx +10 -1
  164. package/src/tui/dist/index.mjs +163 -44
  165. package/src/tui/session/context-state.mjs +1 -0
  166. package/src/tui/session/goal-continuation.mjs +110 -0
  167. package/src/tui/session/goal-continuation.test.mjs +84 -0
  168. package/src/tui/session/queue-helpers.mjs +5 -2
  169. package/src/tui/session/session-action-surface.test.mjs +108 -0
  170. package/src/tui/session/session-api-ext.mjs +39 -8
  171. package/src/tui/session/session-api.mjs +58 -0
  172. package/src/tui/session/session-flow.mjs +6 -0
  173. package/src/tui/session/turn.mjs +1 -0
  174. package/src/tui/session-local.mjs +15 -1
  175. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +0 -301
@@ -264,7 +264,7 @@ export function createTranscriptIngest({
264
264
  _transcriptOffsets = new Map()
265
265
  }
266
266
 
267
- function initTranscriptWatcher() {
267
+ async function initTranscriptWatcher() {
268
268
  const root = projectsRoot()
269
269
  const SAFETY_POLL_MS = 5 * 60_000
270
270
  const DEBOUNCE_MS = 500
@@ -273,7 +273,6 @@ export function createTranscriptIngest({
273
273
  const watchers = []
274
274
  const intervals = []
275
275
  const polledFiles = new Set()
276
- let safetySweepTimeout = null
277
276
 
278
277
  function isWatchable(relOrBase) {
279
278
  const base = path.basename(relOrBase)
@@ -345,14 +344,12 @@ export function createTranscriptIngest({
345
344
  async function safetySweep() {
346
345
  try {
347
346
  const active = await discoverActiveTranscripts()
348
- for (const { path: fp } of active) ingestOne(fp)
347
+ await Promise.all(active.map(({ path: fp }) => ingestOne(fp)))
349
348
  } catch (e) {
350
349
  log(`[transcript-watch] safety sweep error: ${e.message}\n`)
351
350
  }
352
351
  }
353
352
 
354
- safetySweepTimeout = setTimeout(safetySweep, 3_000)
355
-
356
353
  // fs.watch({recursive}) is only reliable on win32.
357
354
  // darwin: recursive option unreliable — use flat watch per-entry (glob dirs at start).
358
355
  // linux/WSL: recursive not supported — use fs.watchFile polling per file found via
@@ -429,9 +426,14 @@ export function createTranscriptIngest({
429
426
  intervals.push(setInterval(_patchedSweep, SAFETY_POLL_MS))
430
427
  }
431
428
 
429
+ // Runtime readiness includes the first active-transcript sweep. Without
430
+ // this barrier a lazy-started memory process can answer recall before the
431
+ // current session's persisted rows exist, causing automatic compaction to
432
+ // fail during the former three-second startup window.
433
+ await safetySweep()
434
+
432
435
  return {
433
436
  stop() {
434
- if (safetySweepTimeout) { clearTimeout(safetySweepTimeout); safetySweepTimeout = null }
435
437
  for (const t of pendingByFile.values()) { try { clearTimeout(t) } catch {} }
436
438
  pendingByFile.clear()
437
439
  for (const i of intervals) { try { clearInterval(i) } catch {} }
@@ -0,0 +1,52 @@
1
+ import assert from 'node:assert/strict'
2
+ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import path from 'node:path'
5
+ import test from 'node:test'
6
+
7
+ import { createTranscriptIngest } from './transcript-ingest.mjs'
8
+
9
+ test('transcript watcher initialization waits for the first active-session sweep', async () => {
10
+ const root = await mkdtemp(path.join(tmpdir(), 'mixdog-transcript-watch-'))
11
+ const projectDir = path.join(root, 'project')
12
+ const sessionId = 'sess_readiness_regression'
13
+ const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`)
14
+ const inserts = []
15
+ let offsets = '{}'
16
+ let watcher = null
17
+
18
+ try {
19
+ await mkdir(projectDir, { recursive: true })
20
+ await writeFile(transcriptPath, `${JSON.stringify({
21
+ type: 'user',
22
+ timestamp: new Date().toISOString(),
23
+ cwd: projectDir,
24
+ message: { content: 'persisted before memory readiness' },
25
+ })}\n`)
26
+
27
+ const ingest = createTranscriptIngest({
28
+ getDb: () => ({
29
+ async query(_sql, values) {
30
+ inserts.push(values)
31
+ return { rowCount: 1 }
32
+ },
33
+ }),
34
+ loadMeta: async () => offsets,
35
+ persistMeta: async (value) => { offsets = value },
36
+ projectsRoot: () => root,
37
+ resolveProjectId: () => 'project-readiness',
38
+ firstTextContent: (value) => String(value ?? ''),
39
+ cleanMemoryText: (value) => String(value ?? '').trim(),
40
+ })
41
+
42
+ await ingest.loadTranscriptOffsets()
43
+ watcher = await ingest.initTranscriptWatcher()
44
+
45
+ assert.equal(inserts.length, 1)
46
+ assert.equal(inserts[0][2], 'persisted before memory readiness')
47
+ assert.equal(inserts[0][4], sessionId)
48
+ } finally {
49
+ watcher?.stop()
50
+ await rm(root, { recursive: true, force: true })
51
+ }
52
+ })
@@ -128,6 +128,19 @@ const SLOW_WAIT_MS = Math.max(
128
128
  const SLOW_WARN_THROTTLE_MS = 30000;
129
129
 
130
130
  let _lastSlowWarnAt = 0;
131
+ let _lastFallbackWarnAt = 0;
132
+
133
+ function _warnLeaseFallback(error, laneName) {
134
+ const now = Date.now();
135
+ if (now - _lastFallbackWarnAt < SLOW_WARN_THROTTLE_MS) return;
136
+ _lastFallbackWarnAt = now;
137
+ try {
138
+ process.stderr.write(
139
+ `[child-spawn-gate] lane=${laneName} machine spawn budget unavailable`
140
+ + ` (${error?.message || error}); using this process's bounded local lane\n`,
141
+ );
142
+ } catch { /* ignore */ }
143
+ }
131
144
 
132
145
  function _maybeWarnSlow(waitedMs, laneName, lane) {
133
146
  if (waitedMs < SLOW_WAIT_MS) return;
@@ -175,6 +188,10 @@ export function acquire(signal = null, laneName = 'search', options = {}) {
175
188
  waitTimeoutMs,
176
189
  }).catch((error) => {
177
190
  if (error?.code !== 'ELEASEFALLBACK') throw error;
191
+ // Only a genuinely dead pool channel reaches here (the client re-arms
192
+ // while the link is alive). Say so loudly: from this point the cap is
193
+ // per-process, so N shards can hold N× the machine-wide budget.
194
+ _warnLeaseFallback(error, normalizedLaneName);
178
195
  return _acquireLocal(signal, normalizedLaneName, lane, ownerKey, waitTimeoutMs);
179
196
  });
180
197
  }
@@ -16,7 +16,32 @@ import { safeIpcSend } from './safe-ipc-send.mjs';
16
16
  const _pending = new Map(); // leaseId -> { grant, fail }
17
17
  let _sequence = 0;
18
18
  let _listening = false;
19
- let _broken = false;
19
+ // "Lost" means the parent IPC link is actually gone — never merely slow. A
20
+ // single timed-out or failed lease must NOT latch this process onto the
21
+ // process-local lane: with several runtime shards that silently multiplies the
22
+ // machine-wide child-spawn cap, which is exactly the Windows spawn storm this
23
+ // gate exists to prevent.
24
+ let _channelLost = false;
25
+ // Bounded patience for a busy/recovering daemon. Each expiry re-arms while the
26
+ // link is alive, so IPC latency keeps shards on the machine-wide budget.
27
+ const LEASE_WAIT_EXTENSIONS = Math.max(
28
+ 0,
29
+ Math.floor(Number(process.env.MIXDOG_SPAWN_LEASE_WAIT_EXTENSIONS) || 3),
30
+ );
31
+ const LEASE_WARN_THROTTLE_MS = 30_000;
32
+ let _lastLeaseWarnAt = 0;
33
+
34
+ function _warnSlowLease(budgetMs, extensionsLeft) {
35
+ const now = Date.now();
36
+ if (now - _lastLeaseWarnAt < LEASE_WARN_THROTTLE_MS) return;
37
+ _lastLeaseWarnAt = now;
38
+ try {
39
+ process.stderr.write(
40
+ `[child-spawn-remote] machine spawn lease pending >${budgetMs}ms; staying on the daemon budget`
41
+ + ` (${extensionsLeft} extension(s) left)\n`,
42
+ );
43
+ } catch { /* diagnostics only */ }
44
+ }
20
45
 
21
46
  function _fallbackError(reason) {
22
47
  const error = new Error(`machine spawn budget unavailable (${reason}); using the local lane`);
@@ -24,6 +49,12 @@ function _fallbackError(reason) {
24
49
  return error;
25
50
  }
26
51
 
52
+ function _leaseTimeoutError(reason) {
53
+ const error = new Error(`machine spawn budget did not answer (${reason}); spawn was not started`);
54
+ error.code = 'ESPAWNLEASETIMEOUT';
55
+ return error;
56
+ }
57
+
27
58
  function _ensureListener() {
28
59
  if (_listening) return;
29
60
  _listening = true;
@@ -42,7 +73,7 @@ function _ensureListener() {
42
73
  pending.fail(error);
43
74
  });
44
75
  process.on('disconnect', () => {
45
- _broken = true;
76
+ _channelLost = true;
46
77
  for (const [leaseId, pending] of [..._pending]) {
47
78
  _pending.delete(leaseId);
48
79
  pending.fail(_fallbackError('pool channel disconnected'));
@@ -50,9 +81,20 @@ function _ensureListener() {
50
81
  });
51
82
  }
52
83
 
84
+ /**
85
+ * PID-accurate shard detection. MIXDOG_SESSION_RUNTIME_WORKER is inherited by
86
+ * every grandchild (tools, helpers, spawned nodes), so the plain flag cannot
87
+ * decide who owns a runtime IPC channel: only the process whose OWN pid matches
88
+ * the pid-scoped marker is a runtime shard. A grandchild therefore uses its own
89
+ * bounded local lane, and a real shard is never misread into it.
90
+ */
91
+ export function isSessionRuntimeWorkerProcess(env = process.env, pid = process.pid) {
92
+ return String(env.MIXDOG_SESSION_RUNTIME_WORKER_PID || '') === String(pid);
93
+ }
94
+
53
95
  export function remoteSpawnLeasesEnabled(env = process.env) {
54
- return !_broken
55
- && env.MIXDOG_SESSION_RUNTIME_WORKER === '1'
96
+ return !_channelLost
97
+ && isSessionRuntimeWorkerProcess(env)
56
98
  && env.MIXDOG_DISABLE_MACHINE_SPAWN_BUDGET !== '1'
57
99
  && typeof process.send === 'function'
58
100
  && process.connected === true;
@@ -91,7 +133,6 @@ export function acquireRemoteSpawnLease({ lane, ownerKey, signal, waitTimeoutMs
91
133
  if (settled) return;
92
134
  settled = true;
93
135
  cleanup();
94
- if (error?.code === 'ELEASEFALLBACK') _broken = true;
95
136
  reject(error);
96
137
  };
97
138
  const grant = () => {
@@ -124,8 +165,29 @@ export function acquireRemoteSpawnLease({ lane, ownerKey, signal, waitTimeoutMs
124
165
  // The pool enforces the real waitTimeoutMs; this guard only covers a
125
166
  // parent that never answers (bug/kill) so a spawn can never hang forever.
126
167
  const budget = Math.max(1_000, Math.floor(Number(waitTimeoutMs) || 0)) + 5_000;
127
- safety = setTimeout(() => fail(_fallbackError(`no lease response in ${budget}ms`)), budget);
128
- safety.unref?.();
168
+ let extensionsLeft = LEASE_WAIT_EXTENSIONS;
169
+ const armSafety = () => {
170
+ safety = setTimeout(() => {
171
+ // A busy or recovering daemon is latency, not a broken budget
172
+ // authority: while the link is alive, keep waiting instead of dropping
173
+ // to a per-process lane that multiplies the machine-wide cap.
174
+ if (extensionsLeft > 0 && !_channelLost && process.connected === true) {
175
+ extensionsLeft -= 1;
176
+ _warnSlowLease(budget, extensionsLeft);
177
+ armSafety();
178
+ return;
179
+ }
180
+ // A still-connected authority may be wedged, but bypassing it would
181
+ // multiply the machine cap by the shard count. Fail this spawn instead;
182
+ // only an actually lost IPC channel is allowed to use the bounded
183
+ // process-local fallback lane.
184
+ fail(process.connected === true && !_channelLost
185
+ ? _leaseTimeoutError(`no lease response in ${budget}ms`)
186
+ : _fallbackError(`no lease response in ${budget}ms`));
187
+ }, budget);
188
+ safety.unref?.();
189
+ };
190
+ armSafety();
129
191
  const sent = safeIpcSend(process, {
130
192
  type: 'spawn-lease',
131
193
  leaseId,
@@ -0,0 +1,124 @@
1
+ // Machine-wide child-spawn budget: every runtime shard must stay on the
2
+ // daemon-owned gate. Two ways that silently breaks (and multiplies the cap by
3
+ // the shard count) are covered here: an inherited env flag activating the
4
+ // process-local lane in a grandchild, and one failed lease latching a shard
5
+ // off the remote budget for the rest of its life.
6
+ import assert from 'node:assert/strict';
7
+ import { fork } from 'node:child_process';
8
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import test from 'node:test';
13
+
14
+ import { isSessionRuntimeWorkerProcess } from './child-spawn-remote.mjs';
15
+
16
+ const REMOTE_URL = new URL('./child-spawn-remote.mjs', import.meta.url).href;
17
+ const GATE_URL = new URL('./child-spawn-gate.mjs', import.meta.url).href;
18
+
19
+ const CHILD_SOURCE = `
20
+ const inherited = process.env.STUB_PID_MODE === 'inherited';
21
+ process.env.MIXDOG_SESSION_RUNTIME_WORKER = '1';
22
+ // A grandchild inherits BOTH variables; only its own pid differs.
23
+ process.env.MIXDOG_SESSION_RUNTIME_WORKER_PID = inherited
24
+ ? String(process.pid + 1)
25
+ : String(process.pid);
26
+ const remote = await import(process.env.REMOTE_URL);
27
+ const gate = await import(process.env.GATE_URL);
28
+ const report = { enabled: remote.remoteSpawnLeasesEnabled(), mode: gate.snapshot().mode };
29
+ if (!inherited) {
30
+ try {
31
+ await remote.acquireRemoteSpawnLease({ lane: 'search', ownerKey: 'test', waitTimeoutMs: 30000 });
32
+ report.firstLease = 'granted';
33
+ } catch (error) {
34
+ report.firstLease = String(error?.code || error?.message || error);
35
+ }
36
+ report.enabledAfterFailure = remote.remoteSpawnLeasesEnabled();
37
+ report.modeAfterFailure = gate.snapshot().mode;
38
+ const release = await remote.acquireRemoteSpawnLease({ lane: 'search', ownerKey: 'test', waitTimeoutMs: 30000 });
39
+ report.secondLease = typeof release === 'function' ? 'granted' : 'unexpected';
40
+ release();
41
+ }
42
+ process.send({ type: 'report', report });
43
+ setTimeout(() => process.exit(0), 50).unref?.();
44
+ `;
45
+
46
+ async function runChild(mode, { onLease = () => {} } = {}) {
47
+ const dir = await mkdtemp(join(tmpdir(), 'mixdog-spawn-remote-'));
48
+ const entry = join(dir, 'child.mjs');
49
+ await writeFile(entry, CHILD_SOURCE, 'utf8');
50
+ try {
51
+ return await new Promise((resolve, reject) => {
52
+ const child = fork(entry, [], {
53
+ stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
54
+ env: {
55
+ ...process.env,
56
+ STUB_PID_MODE: mode,
57
+ REMOTE_URL,
58
+ GATE_URL,
59
+ },
60
+ });
61
+ const timer = setTimeout(() => {
62
+ try { child.kill(); } catch {}
63
+ reject(new Error('child spawn-lease probe timed out'));
64
+ }, 20_000);
65
+ let stderr = '';
66
+ child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
67
+ child.on('message', (message) => {
68
+ if (message?.type === 'spawn-lease') {
69
+ onLease(child, message);
70
+ return;
71
+ }
72
+ if (message?.type !== 'report') return;
73
+ clearTimeout(timer);
74
+ try { child.kill(); } catch {}
75
+ resolve({ ...message.report, stderr });
76
+ });
77
+ child.on('error', reject);
78
+ });
79
+ } finally {
80
+ await rm(dir, { recursive: true, force: true });
81
+ }
82
+ }
83
+
84
+ test('only the pid-scoped runtime worker uses the machine-wide spawn budget', () => {
85
+ assert.equal(isSessionRuntimeWorkerProcess({ MIXDOG_SESSION_RUNTIME_WORKER_PID: '4242' }, 4242), true);
86
+ assert.equal(isSessionRuntimeWorkerProcess({ MIXDOG_SESSION_RUNTIME_WORKER_PID: '4242' }, 99), false);
87
+ // The inheritable flag alone can never claim the runtime IPC channel.
88
+ assert.equal(isSessionRuntimeWorkerProcess({ MIXDOG_SESSION_RUNTIME_WORKER: '1' }, 99), false);
89
+ assert.equal(isSessionRuntimeWorkerProcess({}, 99), false);
90
+ });
91
+
92
+ test('a grandchild that inherited the worker flag stays on its own local lane', async () => {
93
+ const report = await runChild('inherited');
94
+ assert.equal(report.enabled, false);
95
+ assert.equal(report.mode, 'local');
96
+ });
97
+
98
+ test('a failed lease never latches a shard off the machine-wide budget', async () => {
99
+ let leases = 0;
100
+ const report = await runChild('shard', {
101
+ onLease: (child, message) => {
102
+ leases += 1;
103
+ // First lease fails the way a momentarily unreachable pool does.
104
+ child.send(leases === 1
105
+ ? {
106
+ type: 'spawn-lease-result',
107
+ leaseId: message.leaseId,
108
+ ok: false,
109
+ error: 'pool channel unavailable',
110
+ code: 'ELEASEFALLBACK',
111
+ }
112
+ : { type: 'spawn-lease-result', leaseId: message.leaseId, ok: true });
113
+ },
114
+ });
115
+ assert.equal(report.enabled, true);
116
+ assert.equal(report.mode, 'remote-lease');
117
+ assert.equal(report.firstLease, 'ELEASEFALLBACK');
118
+ // Recovery: the shard is still on the daemon budget and the next lease is
119
+ // granted remotely instead of silently using a per-process lane.
120
+ assert.equal(report.enabledAfterFailure, true);
121
+ assert.equal(report.modeAfterFailure, 'remote-lease');
122
+ assert.equal(report.secondLease, 'granted');
123
+ assert.equal(leases, 2);
124
+ });
@@ -67,7 +67,8 @@
67
67
  "gemini": "GEMINI_API_KEY",
68
68
  "deepseek": "DEEPSEEK_API_KEY",
69
69
  "xai": "XAI_API_KEY",
70
- "opencode-go": "OPENCODE_API_KEY"
70
+ "opencode-go": "OPENCODE_API_KEY",
71
+ "openrouter": "OPENROUTER_API_KEY"
71
72
  },
72
73
  "personalStateCounters": [
73
74
  "behavioralStateFilesCopied",
@@ -13,6 +13,7 @@ export const AGENT_PROVIDER_ENV = Object.freeze({
13
13
  deepseek: 'DEEPSEEK_API_KEY',
14
14
  xai: 'XAI_API_KEY',
15
15
  'opencode-go': 'OPENCODE_API_KEY',
16
+ openrouter: 'OPENROUTER_API_KEY',
16
17
  });
17
18
 
18
19
  export const AGENT_PROVIDER_ENV_ALIASES = Object.freeze({
@@ -1,4 +1,5 @@
1
1
  import { normalize } from 'node:path';
2
+ import { monitorEventLoopDelay } from 'node:perf_hooks';
2
3
 
3
4
  export const SESSION_RUNTIME_WORKER_UNHEALTHY_EVENT = 'mixdog:session-runtime-worker-unhealthy';
4
5
 
@@ -67,6 +68,96 @@ export function reportRuntimeDirectoryReadFailure(path, error, now = Date.now())
67
68
  });
68
69
  }
69
70
 
71
+ // ── Per-shard event-loop lag ───────────────────────────────────────────────
72
+ // Each runtime shard runs provider parsing, transcript projection and tool
73
+ // results on its OWN event loop, so lag is the per-shard saturation signal the
74
+ // daemon cannot see (it only measures itself). Lag is DIAGNOSTIC + ROUTING
75
+ // input, never a kill signal: a saturated shard still owns accepted input and
76
+ // in-flight turns, so the host quarantines it from NEW placement instead of
77
+ // recycling it out from under that work.
78
+ export const RUNTIME_LAG_DEFAULTS = Object.freeze({
79
+ intervalMs: 5_000,
80
+ warnP99Ms: 250,
81
+ degradedP99Ms: 2_000,
82
+ degradedSamples: 3,
83
+ recoverP99Ms: 750,
84
+ });
85
+
86
+ function lagMs(value) {
87
+ return Number.isFinite(value) ? Math.round(value / 1e6) : 0;
88
+ }
89
+
90
+ /**
91
+ * Sample this process's event-loop delay on a fixed interval.
92
+ * Returns a stop function; the timer is unref'd so it never holds the process.
93
+ */
94
+ export function startRuntimeEventLoopLagMonitor({
95
+ intervalMs = RUNTIME_LAG_DEFAULTS.intervalMs,
96
+ resolution = 20,
97
+ onSample = () => {},
98
+ } = {}) {
99
+ const histogram = monitorEventLoopDelay({ resolution });
100
+ histogram.enable();
101
+ const timer = setInterval(() => {
102
+ const sample = {
103
+ p50Ms: lagMs(histogram.percentile(50)),
104
+ p95Ms: lagMs(histogram.percentile(95)),
105
+ p99Ms: lagMs(histogram.percentile(99)),
106
+ maxMs: lagMs(histogram.max),
107
+ meanMs: lagMs(histogram.mean),
108
+ intervalMs,
109
+ at: Date.now(),
110
+ };
111
+ histogram.reset();
112
+ try { onSample(sample); } catch { /* telemetry must never throw upward */ }
113
+ }, intervalMs);
114
+ timer.unref?.();
115
+ return () => {
116
+ clearInterval(timer);
117
+ try { histogram.disable(); } catch {}
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Hysteresis state machine over lag samples: a shard becomes `degraded` only
123
+ * after repeated sustained lag and recovers once it drops well below the
124
+ * trigger, so a single GC pause never quarantines a healthy shard.
125
+ */
126
+ export function createRuntimeLagTracker(overrides = {}) {
127
+ const config = { ...RUNTIME_LAG_DEFAULTS, ...(overrides || {}) };
128
+ let consecutive = 0;
129
+ let degraded = false;
130
+ let sample = null;
131
+ return {
132
+ get config() { return config; },
133
+ get degraded() { return degraded; },
134
+ get sample() { return sample; },
135
+ record(next) {
136
+ sample = next && typeof next === 'object' ? next : null;
137
+ const p99 = Number(sample?.p99Ms) || 0;
138
+ if (p99 >= config.degradedP99Ms) {
139
+ consecutive += 1;
140
+ if (!degraded && consecutive >= config.degradedSamples) {
141
+ degraded = true;
142
+ return { degraded, changed: true, sample, consecutive };
143
+ }
144
+ return { degraded, changed: false, sample, consecutive };
145
+ }
146
+ consecutive = 0;
147
+ if (degraded && p99 <= config.recoverP99Ms) {
148
+ degraded = false;
149
+ return { degraded, changed: true, sample, consecutive };
150
+ }
151
+ return { degraded, changed: false, sample, consecutive };
152
+ },
153
+ reset() {
154
+ consecutive = 0;
155
+ degraded = false;
156
+ sample = null;
157
+ },
158
+ };
159
+ }
160
+
70
161
  export function reportRuntimeAbortListenerPressure(warning, now = Date.now(), retainedListeners = 0) {
71
162
  if (!isCurrentSessionRuntimeWorker()) return false;
72
163
  if (!Number.isFinite(Number(retainedListeners)) || Number(retainedListeners) <= 50) {
@@ -2,10 +2,63 @@ import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
 
4
4
  import {
5
+ createRuntimeLagTracker,
5
6
  recordRuntimeDirectoryReadSuccess,
6
7
  reportRuntimeAbortListenerPressure,
8
+ RUNTIME_LAG_DEFAULTS,
9
+ startRuntimeEventLoopLagMonitor,
7
10
  } from './session-runtime-health.mjs';
8
11
 
12
+ test('sustained event-loop lag degrades a shard, a single spike does not', () => {
13
+ const tracker = createRuntimeLagTracker();
14
+ const spike = { p99Ms: RUNTIME_LAG_DEFAULTS.degradedP99Ms + 500 };
15
+ assert.equal(tracker.record(spike).degraded, false);
16
+ assert.equal(tracker.record(spike).degraded, false);
17
+ const degraded = tracker.record(spike);
18
+ assert.equal(degraded.degraded, true);
19
+ assert.equal(degraded.changed, true);
20
+ // Already degraded: no repeated transition.
21
+ assert.equal(tracker.record(spike).changed, false);
22
+ assert.equal(tracker.degraded, true);
23
+
24
+ // Hysteresis: still-elevated lag keeps the quarantine, a clean sample lifts it.
25
+ assert.equal(tracker.record({ p99Ms: RUNTIME_LAG_DEFAULTS.degradedP99Ms - 1 }).degraded, true);
26
+ const recovered = tracker.record({ p99Ms: 10 });
27
+ assert.equal(recovered.degraded, false);
28
+ assert.equal(recovered.changed, true);
29
+
30
+ // A fresh process starts from a clean slate.
31
+ tracker.record(spike);
32
+ tracker.reset();
33
+ assert.equal(tracker.degraded, false);
34
+ assert.equal(tracker.sample, null);
35
+ });
36
+
37
+ test('the event-loop lag monitor samples this process and stops cleanly', async () => {
38
+ const samples = [];
39
+ const stop = startRuntimeEventLoopLagMonitor({
40
+ intervalMs: 20,
41
+ onSample: (sample) => samples.push(sample),
42
+ });
43
+ try {
44
+ const deadline = Date.now() + 2_000;
45
+ while (samples.length === 0 && Date.now() < deadline) {
46
+ await new Promise((resolve) => setTimeout(resolve, 20));
47
+ }
48
+ } finally {
49
+ stop();
50
+ }
51
+ assert.ok(samples.length > 0, 'monitor produced at least one sample');
52
+ const [sample] = samples;
53
+ for (const key of ['p50Ms', 'p95Ms', 'p99Ms', 'maxMs']) {
54
+ assert.equal(Number.isFinite(sample[key]), true, `${key} is numeric`);
55
+ assert.ok(sample[key] >= 0);
56
+ }
57
+ const seen = samples.length;
58
+ await new Promise((resolve) => setTimeout(resolve, 80));
59
+ assert.equal(samples.length, seen, 'no samples after stop');
60
+ });
61
+
9
62
  test('repeated AbortSignal listener pressure marks only the session runtime worker unhealthy', () => {
10
63
  const originalRuntimeWorkerPid = process.env.MIXDOG_SESSION_RUNTIME_WORKER_PID;
11
64
  let detail = null;
@@ -0,0 +1,86 @@
1
+ import { parseDocument } from 'yaml';
2
+
3
+ const SKILL_FRONTMATTER_RE =
4
+ /^(?:\uFEFF)?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
5
+
6
+ const scalarText = (value, field) => {
7
+ if (typeof value !== 'string') {
8
+ throw new Error(`Skill ${field} must be a string.`);
9
+ }
10
+ return value.trim();
11
+ };
12
+
13
+ export function validateSkillName(value) {
14
+ const name = String(value || '').trim();
15
+ if (!name) throw new Error('Skill name is required.');
16
+ if (name.length > 64) throw new Error('Skill name must be 64 characters or fewer.');
17
+ if (!/^[a-z0-9-]+$/.test(name)) {
18
+ throw new Error('Skill name may contain only lowercase letters, numbers, and hyphens.');
19
+ }
20
+ if (name.startsWith('-') || name.endsWith('-') || name.includes('--')) {
21
+ throw new Error('Skill name cannot start or end with a hyphen or contain consecutive hyphens.');
22
+ }
23
+ return name;
24
+ }
25
+
26
+ export function validateSkillDescription(value) {
27
+ const description = String(value || '').trim();
28
+ if (!description) throw new Error('Skill trigger is required.');
29
+ if (description.length > 1024) {
30
+ throw new Error('Skill trigger must be 1024 characters or fewer.');
31
+ }
32
+ return description;
33
+ }
34
+
35
+ function parseFrontmatterDocument(markdown) {
36
+ const source = String(markdown || '');
37
+ const match = source.match(SKILL_FRONTMATTER_RE);
38
+ if (!match) throw new Error('SKILL.md must start with YAML frontmatter.');
39
+ const document = parseDocument(match[1], {
40
+ prettyErrors: false,
41
+ strict: true,
42
+ uniqueKeys: true,
43
+ });
44
+ if (document.errors.length) {
45
+ throw new Error(`Invalid SKILL.md frontmatter: ${document.errors[0].message}`);
46
+ }
47
+ const frontmatter = document.toJS({ maxAliasCount: 100 });
48
+ if (!frontmatter || typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
49
+ throw new Error('SKILL.md frontmatter must be a YAML mapping.');
50
+ }
51
+ return { source, match, document, frontmatter };
52
+ }
53
+
54
+ export function parseSkillDocument(markdown) {
55
+ const parsed = parseFrontmatterDocument(markdown);
56
+ const name = validateSkillName(scalarText(parsed.frontmatter.name, 'name'));
57
+ const description = validateSkillDescription(
58
+ scalarText(parsed.frontmatter.description, 'description'),
59
+ );
60
+ return {
61
+ name,
62
+ description,
63
+ body: parsed.source.slice(parsed.match[0].length).replace(/^\r?\n/, ''),
64
+ frontmatter: parsed.frontmatter,
65
+ };
66
+ }
67
+
68
+ function renderSkillDocument(document, body) {
69
+ const instructions = String(body || '').replace(/\r\n/g, '\n').trim();
70
+ if (!instructions) throw new Error('Skill instructions are required.');
71
+ return `---\n${String(document).trimEnd()}\n---\n\n${instructions}\n`;
72
+ }
73
+
74
+ export function createSkillDocument({ name, description, body }) {
75
+ const document = parseDocument('');
76
+ document.set('name', validateSkillName(name));
77
+ document.set('description', validateSkillDescription(description));
78
+ return renderSkillDocument(document, body);
79
+ }
80
+
81
+ export function updateSkillDocument(markdown, { name, description, body }) {
82
+ const parsed = parseFrontmatterDocument(markdown);
83
+ parsed.document.set('name', validateSkillName(name));
84
+ parsed.document.set('description', validateSkillDescription(description));
85
+ return renderSkillDocument(parsed.document, body);
86
+ }