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
@@ -12,11 +12,21 @@ import {
12
12
  countTokensNative,
13
13
  prewarmNativeTokenCounter,
14
14
  } from '../runtime/agent/orchestrator/session/token-native.mjs';
15
+ import { createRuntimeLagTracker } from '../runtime/shared/session-runtime-health.mjs';
15
16
  import {
16
17
  SESSION_CONFIGURE_ACTIONS,
17
18
  SESSION_READ_ACTIONS,
18
19
  } from './session-protocol.mjs';
20
+ import {
21
+ createShardOwnership,
22
+ mergeProviderCooldown,
23
+ normalizeShardCount,
24
+ resolveShardCount,
25
+ runtimeRoutingKey,
26
+ selectShardIndex,
27
+ } from './session-runtime-shard-router.mjs';
19
28
  import { applySessionStatePatch } from './session-state-patch.mjs';
29
+ import { renderResult as renderAgentResult } from './agent-tool/render.mjs';
20
30
 
21
31
  const RUNTIME_METHODS = new Set([
22
32
  ...SESSION_READ_ACTIONS,
@@ -37,12 +47,24 @@ export function applyRuntimeStateFrame(previous, frame) {
37
47
  return applySessionStatePatch(previous, patch);
38
48
  }
39
49
 
40
- class SessionRuntimeWorker {
41
- constructor({ workerEntry, cwd, env, log }) {
50
+ /**
51
+ * One runtime child process = one shard = one event-loop failure domain.
52
+ *
53
+ * Everything below (identity, revisions, recovery, recycling, spawn leases)
54
+ * is scoped to THIS child, so a saturated or crashed shard can never freeze
55
+ * control/abort for sessions owned by a sibling shard.
56
+ */
57
+ class SessionRuntimeShard {
58
+ constructor({ index = 0, pool = null, workerEntry, cwd, env, log, onAgentSessionState }) {
59
+ this.index = Math.max(0, Math.floor(Number(index) || 0));
60
+ this.pool = pool;
42
61
  this.workerEntry = workerEntry;
43
62
  this.cwd = cwd;
44
63
  this.env = env;
45
- this.log = log;
64
+ const emit = typeof log === 'function' ? log : () => {};
65
+ this.log = (line) => emit(`session runtime shard ${this.index} ${line}`);
66
+ this.lag = createRuntimeLagTracker();
67
+ this.onAgentSessionState = onAgentSessionState;
46
68
  this.child = null;
47
69
  this.pending = new Map();
48
70
  this.proxies = new Map();
@@ -50,6 +72,8 @@ class SessionRuntimeWorker {
50
72
  this.closed = false;
51
73
  this.recovery = null;
52
74
  this.recycling = null;
75
+ this.prewarmChildRef = null;
76
+ this.prewarmPromise = null;
53
77
  this.failedChildren = new WeakSet();
54
78
  // Machine-wide spawn leases held on behalf of the runtime child process.
55
79
  this.spawnLeases = new Map(); // leaseId -> { release, controller, settled }
@@ -66,9 +90,11 @@ class SessionRuntimeWorker {
66
90
  ...hiddenSpawnOpts,
67
91
  });
68
92
  this.child = child;
93
+ // Lag history belongs to the process that produced it.
94
+ this.lag.reset();
69
95
  child.stderr?.on('data', (chunk) => {
70
96
  const text = String(chunk || '').trimEnd();
71
- if (text) this.log(`session runtime worker: ${text}`);
97
+ if (text) this.log(`stderr: ${text}`);
72
98
  });
73
99
  child.on('message', (message) => this.onMessage(message, child));
74
100
  child.on('error', (error) => this.handleChildFailure(child, error));
@@ -79,6 +105,9 @@ class SessionRuntimeWorker {
79
105
  new Error(`session runtime worker exited (${signal || code || 'unknown'})`),
80
106
  );
81
107
  });
108
+ // Account-wide provider cooldown and prewarm state are pool facts, not
109
+ // child facts: a freshly forked shard inherits both immediately.
110
+ this.pool?.primeChild(this);
82
111
  return child;
83
112
  }
84
113
 
@@ -104,10 +133,38 @@ class SessionRuntimeWorker {
104
133
  this.settleSpawnLease(String(message.leaseId || ''));
105
134
  return;
106
135
  }
136
+ if (message.type === 'event-loop-lag') {
137
+ this.recordLag(message.sample);
138
+ return;
139
+ }
140
+ if (message.type === 'provider-cooldown') {
141
+ this.pool?.recordProviderCooldown(this, message);
142
+ return;
143
+ }
144
+ if (message.type === 'agent-control') {
145
+ void this.pool?.handleAgentControl(this, child, message);
146
+ return;
147
+ }
148
+ if (message.type === 'agent-control-cancel') {
149
+ this.pool?.cancelAgentControl(message);
150
+ return;
151
+ }
152
+ if (message.type === 'agent-control-notification') {
153
+ this.pool?.routeAgentControlNotification(message);
154
+ return;
155
+ }
107
156
  if (message.type === 'unhealthy') {
108
157
  this.recycleUnhealthy(child, message.detail);
109
158
  return;
110
159
  }
160
+ if (message.type === 'agent-session-state') {
161
+ const sessionId = String(message.sessionId || '');
162
+ const snapshot = message.snapshot;
163
+ if (sessionId && snapshot && typeof snapshot === 'object') {
164
+ this.onAgentSessionState?.({ sessionId, snapshot });
165
+ }
166
+ return;
167
+ }
111
168
  if (message.type === 'state') {
112
169
  this.proxies.get(String(message.runtimeId || ''))?.applyFrame(message);
113
170
  return;
@@ -169,7 +226,7 @@ class SessionRuntimeWorker {
169
226
  // Same identity rule for failures: a retired child's late exit/error must
170
227
  // not reject the replacement's in-flight calls or start a second recovery.
171
228
  if (this.child && this.child !== child) {
172
- this.log(`session runtime worker superseded child failed (ignored): ${error?.message || error}`);
229
+ this.log(`superseded child failed (ignored): ${error?.message || error}`);
173
230
  return;
174
231
  }
175
232
  if (this.child === child) this.child = null;
@@ -190,7 +247,7 @@ class SessionRuntimeWorker {
190
247
  try {
191
248
  this.ensureChild();
192
249
  for (const proxy of this.proxies.values()) await proxy.recreate();
193
- this.log(`session runtime worker recovered ${this.proxies.size} runtime(s)`);
250
+ this.log(`recovered ${this.proxies.size} runtime(s)`);
194
251
  return true;
195
252
  } catch (error) {
196
253
  lastError = error;
@@ -209,11 +266,11 @@ class SessionRuntimeWorker {
209
266
 
210
267
  recycleChild(child, reason) {
211
268
  if (this.closed || child !== this.child || this.recycling) return;
212
- this.log(`session runtime worker recycling pid=${child.pid || 'unknown'}: ${reason}`);
269
+ this.log(`recycling pid=${child.pid || 'unknown'}: ${reason}`);
213
270
  this.recycling = this.stopChild(reason)
214
271
  .then(() => true)
215
272
  .catch((error) => {
216
- this.log(`session runtime worker recycle failed: ${error?.message || error}`);
273
+ this.log(`recycle failed: ${error?.message || error}`);
217
274
  try { child.kill?.(); } catch {}
218
275
  return false;
219
276
  })
@@ -259,19 +316,106 @@ class SessionRuntimeWorker {
259
316
  return this.requestRaw(type, payload, timeoutMs);
260
317
  }
261
318
 
262
- async create(options) {
263
- const runtimeId = randomUUID();
264
- const proxy = new SessionRuntimeProxy(runtimeId, this, options);
319
+ async create(runtimeId, options, routingKey = '') {
320
+ const proxy = new SessionRuntimeProxy(runtimeId, this, options, routingKey);
265
321
  this.proxies.set(runtimeId, proxy);
266
322
  try {
267
323
  await this.request('create', { runtimeId, options }, 120_000);
268
324
  return proxy;
269
325
  } catch (error) {
326
+ // Drop the proxy only. The ownership claim belongs to whoever CLAIMED it
327
+ // (the pool's create path) and is released there exactly once: releasing
328
+ // it here too would decrement a claim still held by another live view of
329
+ // the same session and split that session across two shards after a
330
+ // later quarantine.
270
331
  this.proxies.delete(runtimeId);
271
332
  throw error;
272
333
  }
273
334
  }
274
335
 
336
+ /** Drop a runtime AND its shard ownership claim in one place. */
337
+ releaseProxy(runtimeId) {
338
+ const id = String(runtimeId || '');
339
+ const proxy = this.proxies.get(id);
340
+ if (!proxy) return;
341
+ this.proxies.delete(id);
342
+ if (proxy.ownershipReleased) return;
343
+ proxy.ownershipReleased = true;
344
+ this.pool?.releaseOwnership(proxy.routingKey);
345
+ }
346
+
347
+ /** Event-loop lag is a routing signal, never a kill signal: a saturated
348
+ * shard still owns accepted input and in-flight turns. */
349
+ recordLag(sample) {
350
+ const row = sample && typeof sample === 'object' ? sample : null;
351
+ const result = this.lag.record(row);
352
+ const detail = `p95=${row?.p95Ms ?? -1}ms p99=${row?.p99Ms ?? -1}ms max=${row?.maxMs ?? -1}ms`
353
+ + ` runtimes=${this.proxies.size}`;
354
+ if (result.changed) {
355
+ this.log(result.degraded
356
+ ? `event-loop saturated ${detail} — quarantined from new placement`
357
+ : `event-loop lag recovered ${detail} — placement resumed`);
358
+ } else if (Number(row?.p99Ms) >= Number(this.lag.config.warnP99Ms)) {
359
+ this.log(`event-loop lag ${detail}`);
360
+ }
361
+ return result;
362
+ }
363
+
364
+ /** Prewarm is per CHILD and issued exactly once for it: the spawn path and
365
+ * an explicit host prewarm share the same in-flight promise instead of
366
+ * racing two module-preload passes into one fresh process. */
367
+ prewarmChild() {
368
+ const child = this.ensureChild();
369
+ if (this.prewarmChildRef === child && this.prewarmPromise) return this.prewarmPromise;
370
+ this.prewarmChildRef = child;
371
+ this.prewarmPromise = this.requestRaw('prewarm', {}, 120_000);
372
+ return this.prewarmPromise;
373
+ }
374
+
375
+ sendProviderCooldown(cooldown, admission = null) {
376
+ const child = this.child;
377
+ if (!child || child.killed || this.closed) return false;
378
+ return safeIpcSend(child, {
379
+ type: 'provider-cooldown-sync',
380
+ cooldown,
381
+ ...(admission ? { admission } : {}),
382
+ }, { onError: () => {} });
383
+ }
384
+
385
+ sendAgentControlNotification(message) {
386
+ const child = this.child;
387
+ if (!child || child.killed || this.closed) return false;
388
+ return safeIpcSend(child, {
389
+ type: 'agent-control-notification',
390
+ ownerSessionId: String(message?.ownerSessionId || ''),
391
+ text: String(message?.text || ''),
392
+ meta: message?.meta && typeof message.meta === 'object' ? message.meta : {},
393
+ }, { onError: () => {} });
394
+ }
395
+
396
+ async workloadSnapshot() {
397
+ const base = {
398
+ shard: this.index,
399
+ pid: this.child?.pid || null,
400
+ degraded: this.lag.degraded,
401
+ eventLoopLag: this.lag.sample,
402
+ };
403
+ try {
404
+ const value = await this.request('workload', {}, 2_000);
405
+ return { ...base, pid: this.child?.pid || base.pid, ...value };
406
+ } catch (error) {
407
+ return { ...base, error: String(error?.message || error) };
408
+ }
409
+ }
410
+
411
+ get isLive() {
412
+ return Boolean(this.child) && !this.child.killed;
413
+ }
414
+
415
+ get degraded() {
416
+ return this.lag.degraded;
417
+ }
418
+
275
419
  /** Grant one machine-wide spawn lease from the daemon-side gate (the single
276
420
  * budget authority; daemon-hosted work uses the same instance locally). */
277
421
  async grantSpawnLease(message) {
@@ -366,14 +510,96 @@ class SessionRuntimeWorker {
366
510
 
367
511
  get status() {
368
512
  return {
513
+ index: this.index,
369
514
  pid: this.child?.pid || null,
370
515
  runtimes: this.proxies.size,
371
516
  pending: this.pending.size,
372
517
  recycling: Boolean(this.recycling),
518
+ degraded: this.lag.degraded,
519
+ lag: this.lag.sample,
373
520
  };
374
521
  }
375
522
  }
376
523
 
524
+ // Fields whose machine-wide value is the WORST shard rather than the sum:
525
+ // configured caps do not add up across processes, and wait/age gauges are
526
+ // worst-case signals. Everything else (inflight, queued, counters, bytes) is
527
+ // real per-process load and sums.
528
+ const WORKLOAD_MAX_FIELDS = new Set([
529
+ 'limit', 'maxInflight', 'queueMax', 'activeMax', 'waitTimeoutMs', 'concurrency',
530
+ 'maxAgents', 'maxShells', 'maxHighLoad', 'maxQueue', 'minFreeMemoryMb', 'maxRssMb',
531
+ 'oldestWaitMs', 'oldestQueuedMs', 'maxWaitMs', 'averageWaitMs', 'ageMs',
532
+ 'freeMemoryBytes', 'totalMemoryBytes',
533
+ 'p50Ms', 'p95Ms', 'p99Ms', 'maxMs', 'meanMs', 'intervalMs', 'at',
534
+ ]);
535
+ const WORKLOAD_LIST_CAP = 64;
536
+
537
+ /** Structural merge of two shard workload rows (exported for tests). */
538
+ export function mergeShardWorkloadValues(left, right, key = '') {
539
+ if (right === undefined || right === null) return left;
540
+ if (left === undefined || left === null) return right;
541
+ if (typeof left === 'number' && typeof right === 'number') {
542
+ return WORKLOAD_MAX_FIELDS.has(key) ? Math.max(left, right) : left + right;
543
+ }
544
+ if (typeof left === 'boolean' || typeof right === 'boolean') {
545
+ return Boolean(left) || Boolean(right);
546
+ }
547
+ if (typeof left === 'string' || typeof right === 'string') {
548
+ return left === right ? left : 'mixed';
549
+ }
550
+ if (Array.isArray(left) && Array.isArray(right)) {
551
+ const entries = [...left, ...right];
552
+ const named = entries.length > 0
553
+ && entries.every((entry) => entry && typeof entry === 'object' && typeof entry.name === 'string');
554
+ if (!named) return entries.slice(0, WORKLOAD_LIST_CAP);
555
+ // Lane/gate rows are per-process views of ONE machine-wide lane: merge by
556
+ // name so `childSpawns.lanes[].inflight` reports the machine total.
557
+ const byName = new Map();
558
+ for (const entry of entries) {
559
+ const existing = byName.get(entry.name);
560
+ byName.set(entry.name, existing ? mergeShardWorkloadValues(existing, entry) : entry);
561
+ }
562
+ return [...byName.values()];
563
+ }
564
+ if (typeof left === 'object' && typeof right === 'object') {
565
+ const out = {};
566
+ for (const field of new Set([...Object.keys(left), ...Object.keys(right)])) {
567
+ out[field] = mergeShardWorkloadValues(left[field], right[field], field);
568
+ }
569
+ return out;
570
+ }
571
+ return left;
572
+ }
573
+
574
+ /** Aggregate EVERY live shard into the single-worker shape older status
575
+ * consumers still read (resources/toolIo/childSpawns included), keeping the
576
+ * per-shard rows alongside it. */
577
+ export function aggregateShardWorkload(rows) {
578
+ const list = Array.isArray(rows) ? rows.filter(Boolean) : [];
579
+ if (list.length === 0) return null;
580
+ let merged = {};
581
+ for (const row of list) merged = mergeShardWorkloadValues(merged, row);
582
+ const errors = list
583
+ .filter((row) => row?.error)
584
+ .map((row) => `shard ${row.shard ?? '?'}: ${row.error}`);
585
+ const worstLag = list.reduce((worst, row) => {
586
+ const sample = row?.eventLoopLag;
587
+ if (!sample) return worst;
588
+ return !worst || (Number(sample.p99Ms) || 0) > (Number(worst.p99Ms) || 0) ? sample : worst;
589
+ }, null);
590
+ return {
591
+ ...merged,
592
+ // Identity/telemetry fields describe shards individually — never summed.
593
+ shard: list[0]?.shard ?? 0,
594
+ pid: list.find((row) => row?.pid)?.pid ?? null,
595
+ pids: list.map((row) => row?.pid ?? null),
596
+ shards: list.length,
597
+ degraded: list.some((row) => row?.degraded === true),
598
+ eventLoopLag: worstLag,
599
+ ...(errors.length > 0 ? { error: errors[0], errors } : {}),
600
+ };
601
+ }
602
+
377
603
  // Wire contract for runtime calls: the fork IPC link serializes frames
378
604
  // as JSON, which cannot represent `undefined` and would fabricate `null` in
379
605
  // its place — turning an omitted optional argument (e.g. `resume(id)`) into a
@@ -389,9 +615,14 @@ function wireCallArgs(args) {
389
615
  }
390
616
 
391
617
  class SessionRuntimeProxy {
392
- constructor(id, host, options) {
618
+ constructor(id, shard, options, routingKey = '') {
393
619
  this.id = id;
394
- this.host = host;
620
+ this.shard = shard;
621
+ // Stable ownership key (sessionId when known): recovery, resume and every
622
+ // later view of this session resolve to the same shard. The claim is
623
+ // released exactly once, by whichever settle path runs first.
624
+ this.routingKey = String(routingKey || id);
625
+ this.ownershipReleased = false;
395
626
  this.options = { ...(options || {}) };
396
627
  this.state = {};
397
628
  this.revision = 0;
@@ -417,7 +648,7 @@ class SessionRuntimeProxy {
417
648
  const revision = Number(frame.revision) || 0;
418
649
  if (revision <= this.revision) return;
419
650
  if (frame.patch && revision !== this.revision + 1) {
420
- void this.host.request('snapshot', { runtimeId: this.id }, 5_000).catch(() => {});
651
+ void this.shard.request('snapshot', { runtimeId: this.id }, 5_000).catch(() => {});
421
652
  return;
422
653
  }
423
654
  this.state = applyRuntimeStateFrame(this.state, frame);
@@ -445,45 +676,414 @@ class SessionRuntimeProxy {
445
676
  async recreate() {
446
677
  const previousSessionId = String(this.state?.sessionId || this.options.sessionId || '');
447
678
  this.revision = 0;
448
- await this.host.requestRaw('create', {
679
+ await this.shard.requestRaw('create', {
449
680
  runtimeId: this.id,
450
681
  options: this.options,
451
682
  }, 120_000);
452
683
  if (previousSessionId) {
453
- const resumed = await this.host.requestRaw('call', {
684
+ const resumed = await this.shard.requestRaw('call', {
454
685
  runtimeId: this.id,
455
686
  method: 'resume',
456
687
  args: [previousSessionId],
457
688
  }, 120_000);
458
689
  if (resumed !== true) {
459
- await this.host.requestRaw('call', {
690
+ await this.shard.requestRaw('call', {
460
691
  runtimeId: this.id,
461
692
  method: 'reserveSession',
462
693
  args: [previousSessionId],
463
694
  }, 30_000);
464
695
  }
465
696
  }
466
- await this.host.requestRaw('snapshot', { runtimeId: this.id }, 5_000);
697
+ await this.shard.requestRaw('snapshot', { runtimeId: this.id }, 5_000);
467
698
  this.failure = null;
468
699
  this.recovering = false;
469
700
  }
470
701
 
471
702
  async call(method, args) {
472
- if (this.recovering && this.host.recovery) await this.host.recovery;
703
+ if (this.recovering && this.shard.recovery) await this.shard.recovery;
473
704
  if (this.failure) throw this.failure;
474
705
  try {
475
- const value = await this.host.request('call', {
706
+ const value = await this.shard.request('call', {
476
707
  runtimeId: this.id,
477
708
  method,
478
709
  args: wireCallArgs(args),
479
710
  }, method === 'abort' ? 15_000 : 10 * 60_000);
480
- if (method === 'dispose') this.host.proxies.delete(this.id);
711
+ if (method === 'dispose') this.shard.releaseProxy(this.id);
481
712
  return value;
482
713
  } catch (error) {
483
- if (method === 'dispose') this.host.proxies.delete(this.id);
714
+ if (method === 'dispose') this.shard.releaseProxy(this.id);
715
+ throw error;
716
+ }
717
+ }
718
+ }
719
+
720
+ /**
721
+ * Bounded multi-child shard pool.
722
+ *
723
+ * Fan-out is unchanged — every shard runs the same full agent/session graph —
724
+ * but independent work is distributed so that no single event loop carries all
725
+ * sessions, all provider parsing and all tool results. Machine-wide
726
+ * authorities are deliberately NOT duplicated: child-spawn leases still flow
727
+ * through each shard's IPC channel to the one daemon-side gate, and provider
728
+ * capacity cooldowns discovered by one shard are replayed into the others.
729
+ */
730
+ class SessionRuntimeShardPool {
731
+ constructor({ workerEntry, cwd, env, log, shardCount, onAgentSessionState }) {
732
+ this.log = typeof log === 'function' ? log : () => {};
733
+ this.closed = false;
734
+ this.ownership = createShardOwnership();
735
+ this.dispatchOwners = new Map(); // dispatchId -> shard index
736
+ this.agentControlOwners = new Map(); // controlId -> shard index
737
+ this.agentTaskOwners = new Map(); // taskId -> shard index
738
+ this.agentTagOwners = new Map(); // ownerSessionId\0tag -> shard index
739
+ this.agentSessionOwners = new Map(); // agent sessionId -> shard index
740
+ this.agentOwnerOrigins = new Map(); // owner sessionId -> source shard index
741
+ this.providerCooldown = { untilMs: 0, disabledReason: null, updatedAt: 0 };
742
+ this.prewarmRequested = false;
743
+ this.workloadCache = { refreshedAt: 0, shards: [] };
744
+ this.workloadRefresh = null;
745
+ // Hard bound, always: an explicit shardCount is operator/test input and
746
+ // must never fork an unbounded number of runtime children.
747
+ const count = normalizeShardCount(shardCount);
748
+ this.shards = Array.from({ length: count }, (_, index) => new SessionRuntimeShard({
749
+ index,
750
+ pool: this,
751
+ workerEntry,
752
+ cwd,
753
+ env: {
754
+ ...env,
755
+ MIXDOG_SESSION_RUNTIME_SHARD: String(index),
756
+ MIXDOG_SESSION_RUNTIME_SHARD_COUNT: String(count),
757
+ },
758
+ log: this.log,
759
+ onAgentSessionState,
760
+ }));
761
+ }
762
+
763
+ get shardCount() {
764
+ return this.shards.length;
765
+ }
766
+
767
+ shardAt(index) {
768
+ const count = this.shards.length;
769
+ const safe = ((Math.floor(Number(index) || 0) % count) + count) % count;
770
+ return this.shards[safe];
771
+ }
772
+
773
+ liveShards() {
774
+ return this.shards.filter((shard) => shard.isLive);
775
+ }
776
+
777
+ /** NEW work avoids a quarantined shard; work already owned never migrates
778
+ * (migration would strand accepted input in the abandoned child). */
779
+ isPlaceable(index) {
780
+ const shard = this.shards[index];
781
+ return Boolean(shard) && !shard.degraded && !shard.closed;
782
+ }
783
+
784
+ placeKey(key) {
785
+ return this.ownership.claim(
786
+ key,
787
+ () => selectShardIndex(key, this.shards.length, (index) => this.isPlaceable(index)),
788
+ );
789
+ }
790
+
791
+ releaseOwnership(key) {
792
+ if (!key) return;
793
+ this.ownership.release(key);
794
+ }
795
+
796
+ async create(options = {}) {
797
+ const runtimeId = randomUUID();
798
+ const key = runtimeRoutingKey(options, runtimeId);
799
+ const shard = this.shardAt(this.placeKey(key));
800
+ try {
801
+ return await shard.create(runtimeId, options, key);
802
+ } catch (error) {
803
+ this.releaseOwnership(key);
484
804
  throw error;
485
805
  }
486
806
  }
807
+
808
+ /** Agent dispatches are independent units of work: hash placement spreads
809
+ * a batch fan-out across shards, and the recorded owner keeps cancel/abort
810
+ * addressed to the shard actually running it. */
811
+ dispatchShard(dispatchId, { create = false } = {}) {
812
+ const id = String(dispatchId || '');
813
+ if (this.dispatchOwners.has(id)) return this.shardAt(this.dispatchOwners.get(id));
814
+ if (!create) return null;
815
+ const index = selectShardIndex(
816
+ `dispatch:${id}`,
817
+ this.shards.length,
818
+ (candidate) => this.isPlaceable(candidate),
819
+ );
820
+ this.dispatchOwners.set(id, index);
821
+ return this.shardAt(index);
822
+ }
823
+
824
+ releaseDispatch(dispatchId) {
825
+ this.dispatchOwners.delete(String(dispatchId || ''));
826
+ }
827
+
828
+ agentTagKey(ownerSessionId, tag) {
829
+ return `${String(ownerSessionId || '')}\0${String(tag || '')}`;
830
+ }
831
+
832
+ rememberAgentControlResult(index, args, context, value) {
833
+ const text = String(value || '');
834
+ const ownerSessionId = String(context?.callerSessionId || '');
835
+ const taskId = String(
836
+ args?.task_id
837
+ || args?.taskId
838
+ || (/^agent task:\s*(\S+)/m.exec(text)?.[1] || ''),
839
+ );
840
+ const target = /^target:\s*(\S+)(?:\s+(\S+))?/m.exec(text);
841
+ const tag = String(args?.tag || (target?.[1] && target[1] !== '-' ? target[1] : ''));
842
+ const sessionId = String(
843
+ args?.sessionId
844
+ || args?.session_id
845
+ || (target?.[2] && /^sess_/.test(target[2]) ? target[2] : ''),
846
+ );
847
+ if (taskId) this.agentTaskOwners.set(taskId, index);
848
+ if (tag) this.agentTagOwners.set(this.agentTagKey(ownerSessionId, tag), index);
849
+ if (sessionId) this.agentSessionOwners.set(sessionId, index);
850
+ }
851
+
852
+ agentControlShard(originShard, args, context, controlId) {
853
+ const ownerSessionId = String(context?.callerSessionId || '');
854
+ const taskId = String(args?.task_id || args?.taskId || '');
855
+ const sessionId = String(args?.sessionId || args?.session_id || '');
856
+ const tag = String(args?.tag || '');
857
+ let index = taskId ? this.agentTaskOwners.get(taskId) : null;
858
+ if (index == null && sessionId) index = this.agentSessionOwners.get(sessionId);
859
+ if (index == null && tag) index = this.agentTagOwners.get(this.agentTagKey(ownerSessionId, tag));
860
+ if (index == null) {
861
+ const key = `agent:${ownerSessionId}:${tag || sessionId || controlId}`;
862
+ index = selectShardIndex(
863
+ key,
864
+ this.shards.length,
865
+ (candidate) => this.isPlaceable(candidate),
866
+ );
867
+ }
868
+ return this.shardAt(index ?? originShard?.index ?? 0);
869
+ }
870
+
871
+ async aggregateAgentStatus(context, originShard) {
872
+ const targets = this.liveShards();
873
+ if (targets.length === 0 && originShard) targets.push(originShard);
874
+ const settled = await Promise.allSettled(targets.map((shard) =>
875
+ shard.request('agent-control-status', { context }, 30_000)));
876
+ const workers = new Map();
877
+ const jobs = new Map();
878
+ for (const result of settled) {
879
+ if (result.status !== 'fulfilled') continue;
880
+ for (const worker of Array.isArray(result.value?.workers) ? result.value.workers : []) {
881
+ const key = String(worker?.sessionId || worker?.tag || '');
882
+ if (key) workers.set(key, worker);
883
+ }
884
+ for (const job of Array.isArray(result.value?.jobs) ? result.value.jobs : []) {
885
+ const key = String(job?.task_id || job?.taskId || '');
886
+ if (key) jobs.set(key, job);
887
+ }
888
+ }
889
+ return renderAgentResult({ workers: [...workers.values()], jobs: [...jobs.values()] });
890
+ }
891
+
892
+ async executeAgentControl(originShard, args = {}, context = {}, controlId = randomUUID()) {
893
+ const type = String(args?.type || 'spawn').trim().toLowerCase();
894
+ const ownerSessionId = String(context?.callerSessionId || '');
895
+ if (ownerSessionId && originShard) {
896
+ this.agentOwnerOrigins.set(ownerSessionId, originShard.index);
897
+ }
898
+ if (type === 'list') return this.aggregateAgentStatus(context, originShard);
899
+ if (type === 'cleanup' || type === '__close_all') {
900
+ const targets = this.liveShards();
901
+ const settled = await Promise.allSettled(targets.map((shard) =>
902
+ shard.request('agent-control-local', {
903
+ controlId: `${controlId}:${shard.index}`,
904
+ args,
905
+ context,
906
+ }, 180_000)));
907
+ return settled
908
+ .filter((result) => result.status === 'fulfilled' && result.value)
909
+ .map((result) => String(result.value))
910
+ .join('\n');
911
+ }
912
+ const target = this.agentControlShard(originShard, args, context, controlId);
913
+ this.agentControlOwners.set(controlId, target.index);
914
+ try {
915
+ let value = await target.request('agent-control-local', {
916
+ controlId,
917
+ args,
918
+ context,
919
+ }, 180_000);
920
+ const lookup = type === 'status' || type === 'read' || type === 'cancel' || type === 'close';
921
+ if (lookup && /^Error:/i.test(String(value || ''))) {
922
+ for (const alternate of this.liveShards()) {
923
+ if (alternate === target) continue;
924
+ const candidate = await alternate.request('agent-control-local', {
925
+ controlId: `${controlId}:${alternate.index}`,
926
+ args,
927
+ context,
928
+ }, 180_000);
929
+ if (/^Error:/i.test(String(candidate || ''))) continue;
930
+ value = candidate;
931
+ this.rememberAgentControlResult(alternate.index, args, context, value);
932
+ return value;
933
+ }
934
+ }
935
+ this.rememberAgentControlResult(target.index, args, context, value);
936
+ return value;
937
+ } finally {
938
+ this.agentControlOwners.delete(controlId);
939
+ }
940
+ }
941
+
942
+ handleAgentControl(originShard, originChild, message) {
943
+ const controlId = String(message?.controlId || '');
944
+ if (!controlId) return;
945
+ void this.executeAgentControl(
946
+ originShard,
947
+ message.args || {},
948
+ message.context || {},
949
+ controlId,
950
+ ).then((value) => {
951
+ if (originShard.child !== originChild || originChild?.killed) return;
952
+ safeIpcSend(originChild, {
953
+ type: 'agent-control-result',
954
+ controlId,
955
+ ok: true,
956
+ value,
957
+ }, { onError: () => {} });
958
+ }).catch((error) => {
959
+ if (originShard.child !== originChild || originChild?.killed) return;
960
+ safeIpcSend(originChild, {
961
+ type: 'agent-control-result',
962
+ controlId,
963
+ ok: false,
964
+ error: {
965
+ name: String(error?.name || 'Error'),
966
+ message: String(error?.message || error || 'agent control failed'),
967
+ stack: typeof error?.stack === 'string' ? error.stack : null,
968
+ code: error?.code || null,
969
+ },
970
+ }, { onError: () => {} });
971
+ });
972
+ }
973
+
974
+ cancelAgentControl(message) {
975
+ const controlId = String(message?.controlId || '');
976
+ const index = this.agentControlOwners.get(controlId);
977
+ if (index == null) return false;
978
+ const shard = this.shardAt(index);
979
+ void shard.request('agent-control-local-cancel', {
980
+ controlId,
981
+ reason: String(message?.reason || 'agent control canceled'),
982
+ }, 5_000).catch(() => {});
983
+ return true;
984
+ }
985
+
986
+ routeAgentControlNotification(message) {
987
+ const ownerSessionId = String(message?.ownerSessionId || '');
988
+ if (!ownerSessionId) return false;
989
+ const index = this.agentOwnerOrigins.get(ownerSessionId)
990
+ ?? this.ownership.peek(ownerSessionId);
991
+ if (index == null) return false;
992
+ return this.shardAt(index).sendAgentControlNotification(message);
993
+ }
994
+
995
+ async prewarm() {
996
+ this.prewarmRequested = true;
997
+ // Shard 0 always exists: its readiness is the daemon's prewarm contract.
998
+ // Shards that have not forked yet stay cold (no idle worker per core) and
999
+ // prewarm themselves the moment they spawn — see primeChild.
1000
+ const targets = new Set([this.shardAt(0), ...this.liveShards()]);
1001
+ const results = await Promise.allSettled(
1002
+ [...targets].map((shard) => shard.prewarmChild()),
1003
+ );
1004
+ const ready = results.find((result) => result.status === 'fulfilled');
1005
+ if (ready) return ready.value;
1006
+ const failed = results.find((result) => result.status === 'rejected');
1007
+ if (failed) throw failed.reason;
1008
+ return { ready: true };
1009
+ }
1010
+
1011
+ primeChild(shard) {
1012
+ const cooldown = this.providerCooldown;
1013
+ if (cooldown.disabledReason || cooldown.untilMs > Date.now()) {
1014
+ shard.sendProviderCooldown(cooldown);
1015
+ }
1016
+ if (this.prewarmRequested && !this.closed) {
1017
+ // A shard that forks after the daemon's prewarm warms itself, so a
1018
+ // lazily spawned shard is never the cold one in a fan-out burst.
1019
+ void shard.prewarmChild().catch(() => {});
1020
+ }
1021
+ }
1022
+
1023
+ /** Provider capacity cooldown is account-wide but observed per process:
1024
+ * merge monotonically and replay to every sibling shard so a drained fast
1025
+ * pool is never re-probed by the shards that did not see the rejection. */
1026
+ recordProviderCooldown(originShard, payload) {
1027
+ const admission = payload?.admission && typeof payload.admission === 'object'
1028
+ ? payload.admission
1029
+ : null;
1030
+ const merged = mergeProviderCooldown(this.providerCooldown, payload?.cooldown);
1031
+ if (merged.changed) this.providerCooldown = merged.cooldown;
1032
+ // An admission cooldown/reset event is forwarded the moment it happens —
1033
+ // siblings must stop probing a drained account immediately, not after the
1034
+ // next telemetry tick.
1035
+ if (!merged.changed && !admission) return false;
1036
+ const remainingS = Math.max(0, Math.round((this.providerCooldown.untilMs - Date.now()) / 1000));
1037
+ this.log(
1038
+ `session runtime provider cooldown from shard ${originShard?.index ?? '-'}`
1039
+ + ` → ${this.shards.length - 1} sibling shard(s)`
1040
+ + (admission ? ` [${admission.type}${admission.key ? ` ${admission.key}` : ''}]` : '')
1041
+ + ` (${this.providerCooldown.disabledReason ? `disabled: ${this.providerCooldown.disabledReason}` : `${remainingS}s`})`,
1042
+ );
1043
+ for (const shard of this.shards) {
1044
+ if (shard === originShard) continue;
1045
+ shard.sendProviderCooldown(this.providerCooldown, admission);
1046
+ }
1047
+ return true;
1048
+ }
1049
+
1050
+ refreshWorkload() {
1051
+ if (this.workloadRefresh) return this.workloadRefresh;
1052
+ const live = this.liveShards();
1053
+ if (live.length === 0) {
1054
+ this.workloadCache = { refreshedAt: Date.now(), shards: [] };
1055
+ return Promise.resolve();
1056
+ }
1057
+ this.workloadRefresh = Promise.all(live.map((shard) => shard.workloadSnapshot()))
1058
+ .then((rows) => { this.workloadCache = { refreshedAt: Date.now(), shards: rows }; })
1059
+ .catch(() => { this.workloadCache = { refreshedAt: Date.now(), shards: [] }; })
1060
+ .finally(() => { this.workloadRefresh = null; });
1061
+ return this.workloadRefresh;
1062
+ }
1063
+
1064
+ async close(reason) {
1065
+ this.closed = true;
1066
+ await Promise.allSettled(this.shards.map((shard) => shard.close(reason)));
1067
+ }
1068
+
1069
+ get status() {
1070
+ const shards = this.shards.map((shard) => shard.status);
1071
+ const live = shards.filter((row) => row.pid);
1072
+ return {
1073
+ shardCount: this.shards.length,
1074
+ shards,
1075
+ // Back-compatible single-worker view for existing status consumers.
1076
+ worker: {
1077
+ pid: live[0]?.pid || null,
1078
+ pids: live.map((row) => row.pid),
1079
+ shards: this.shards.length,
1080
+ runtimes: shards.reduce((total, row) => total + row.runtimes, 0),
1081
+ pending: shards.reduce((total, row) => total + row.pending, 0),
1082
+ recycling: shards.some((row) => row.recycling),
1083
+ degraded: shards.filter((row) => row.degraded).length,
1084
+ },
1085
+ };
1086
+ }
487
1087
  }
488
1088
 
489
1089
  export function createSessionRuntimeHost({
@@ -491,12 +1091,33 @@ export function createSessionRuntimeHost({
491
1091
  cwd = process.cwd(),
492
1092
  env = process.env,
493
1093
  log = () => {},
1094
+ shardCount = null,
494
1095
  } = {}) {
495
- const worker = new SessionRuntimeWorker({
1096
+ const agentSessionListeners = new Set();
1097
+ const agentSessionSnapshots = new Map();
1098
+ const publishAgentSessionState = (update) => {
1099
+ const sessionId = String(update?.sessionId || '');
1100
+ if (!sessionId || !update?.snapshot) return;
1101
+ agentSessionSnapshots.delete(sessionId);
1102
+ agentSessionSnapshots.set(sessionId, update.snapshot);
1103
+ while (agentSessionSnapshots.size > 256) {
1104
+ const oldest = agentSessionSnapshots.keys().next().value;
1105
+ if (oldest === undefined) break;
1106
+ agentSessionSnapshots.delete(oldest);
1107
+ }
1108
+ for (const listener of [...agentSessionListeners]) {
1109
+ try { listener({ sessionId, snapshot: update.snapshot }); } catch {}
1110
+ }
1111
+ };
1112
+ const pool = new SessionRuntimeShardPool({
496
1113
  workerEntry,
497
1114
  cwd,
498
1115
  env,
499
1116
  log,
1117
+ shardCount: Number(shardCount) > 0
1118
+ ? normalizeShardCount(shardCount)
1119
+ : resolveShardCount(),
1120
+ onAgentSessionState: publishAgentSessionState,
500
1121
  });
501
1122
  // One machine-global native counter stays warm in the daemon. Session
502
1123
  // runtimes relay requests over the worker IPC channel and never spawn
@@ -504,40 +1125,20 @@ export function createSessionRuntimeHost({
504
1125
  try { prewarmNativeTokenCounter(); } catch { /* JS/WASM fallback remains */ }
505
1126
  let closed = false;
506
1127
 
507
- // Runtime workload telemetry is refreshed lazily with a short TTL.
1128
+ // Runtime workload telemetry is refreshed lazily with a short TTL, across
1129
+ // every live shard.
508
1130
  const WORKLOAD_TTL_MS = 2_000;
509
- let workloadCache = { refreshedAt: 0, worker: null };
510
- let workloadRefresh = null;
511
1131
  function refreshRuntimeWorkload() {
512
- if (workloadRefresh) return workloadRefresh;
513
- if (!worker.child || worker.child.killed) {
514
- workloadCache = { refreshedAt: Date.now(), worker: null };
515
- return Promise.resolve();
516
- }
517
- workloadRefresh = worker.request('workload', {}, 2_000)
518
- .then((value) => {
519
- workloadCache = {
520
- refreshedAt: Date.now(),
521
- worker: { pid: worker.child?.pid || null, ...value },
522
- };
523
- })
524
- .catch((error) => {
525
- workloadCache = {
526
- refreshedAt: Date.now(),
527
- worker: { pid: worker.child?.pid || null, error: String(error?.message || error) },
528
- };
529
- })
530
- .finally(() => { workloadRefresh = null; });
531
- return workloadRefresh;
1132
+ return pool.refreshWorkload();
532
1133
  }
533
1134
 
534
1135
  return {
535
1136
  async create(options = {}) {
536
1137
  if (closed) throw new Error('session runtime host is closed');
537
- return worker.create(options);
1138
+ return pool.create(options);
538
1139
  },
539
1140
  prewarm() {
540
- return worker.request('prewarm', {}, 120_000);
1141
+ return pool.prewarm();
541
1142
  },
542
1143
  // Agent execution rides the runtime worker instead of accumulating
543
1144
  // provider/orchestrator churn in the daemon.
@@ -545,17 +1146,23 @@ export function createSessionRuntimeHost({
545
1146
  if (closed) throw new Error('session runtime host is closed');
546
1147
  const dispatchId = String(payload?.dispatchId || '');
547
1148
  if (!dispatchId) throw new Error('agent dispatch id is required');
1149
+ // Placement is deterministic and recorded, so cancel/abort always reach
1150
+ // the shard that is actually running this dispatch.
1151
+ const shard = pool.dispatchShard(dispatchId, { create: true });
548
1152
  const cancel = (reason) => {
549
- void worker.request('agent-dispatch-cancel', {
1153
+ void shard.request('agent-dispatch-cancel', {
550
1154
  dispatchId,
551
1155
  reason: String(reason || 'agent dispatch canceled'),
552
1156
  }, 10_000).catch(() => {});
553
1157
  };
554
- if (signal?.aborted) throw new Error('agent dispatch canceled before start');
1158
+ if (signal?.aborted) {
1159
+ pool.releaseDispatch(dispatchId);
1160
+ throw new Error('agent dispatch canceled before start');
1161
+ }
555
1162
  const onAbort = () => cancel(signal?.reason?.message || signal?.reason);
556
1163
  signal?.addEventListener?.('abort', onAbort, { once: true });
557
1164
  try {
558
- const result = await worker.request('agent-dispatch', payload, timeoutMs);
1165
+ const result = await shard.request('agent-dispatch', payload, timeoutMs);
559
1166
  return result?.value;
560
1167
  } catch (error) {
561
1168
  // The worker keeps running after a host-side timeout; tell it to stop.
@@ -563,29 +1170,60 @@ export function createSessionRuntimeHost({
563
1170
  throw error;
564
1171
  } finally {
565
1172
  signal?.removeEventListener?.('abort', onAbort);
1173
+ pool.releaseDispatch(dispatchId);
566
1174
  }
567
1175
  },
1176
+ async agentControl(args = {}, context = {}) {
1177
+ if (closed) throw new Error('session runtime host is closed');
1178
+ const ownerSessionId = String(context?.callerSessionId || '');
1179
+ const ownerIndex = pool.ownership.peek(ownerSessionId);
1180
+ const originShard = pool.shardAt(ownerIndex ?? selectShardIndex(
1181
+ `owner:${ownerSessionId || randomUUID()}`,
1182
+ pool.shardCount,
1183
+ (candidate) => pool.isPlaceable(candidate),
1184
+ ));
1185
+ return pool.executeAgentControl(originShard, args, context);
1186
+ },
568
1187
  refreshRuntimeWorkload,
1188
+ subscribeAgentSessionStates(listener) {
1189
+ if (typeof listener !== 'function') return () => {};
1190
+ agentSessionListeners.add(listener);
1191
+ for (const [sessionId, snapshot] of agentSessionSnapshots) {
1192
+ try { listener({ sessionId, snapshot }); } catch {}
1193
+ }
1194
+ return () => agentSessionListeners.delete(listener);
1195
+ },
569
1196
  get workloads() {
570
- if (!closed && Date.now() - workloadCache.refreshedAt > WORKLOAD_TTL_MS) {
1197
+ if (!closed && Date.now() - pool.workloadCache.refreshedAt > WORKLOAD_TTL_MS) {
571
1198
  void refreshRuntimeWorkload();
572
1199
  }
1200
+ const shards = pool.workloadCache.shards;
573
1201
  return {
574
- refreshedAt: workloadCache.refreshedAt,
1202
+ mode: pool.shardCount === 1 ? 'single-runtime' : 'multi-runtime-test',
1203
+ refreshedAt: pool.workloadCache.refreshedAt,
575
1204
  machineSpawnBudget: machineSpawnSnapshot(),
576
- worker: workloadCache.worker,
1205
+ shardCount: pool.shardCount,
1206
+ shards,
1207
+ // Aggregate row keeps the historical single-worker shape.
1208
+ worker: aggregateShardWorkload(shards),
577
1209
  };
578
1210
  },
579
1211
  async close(reason = 'session runtime host closed') {
580
1212
  if (closed) return;
581
1213
  closed = true;
582
- await worker.close(reason);
1214
+ agentSessionListeners.clear();
1215
+ agentSessionSnapshots.clear();
1216
+ await pool.close(reason);
583
1217
  },
584
1218
  get status() {
585
- const status = worker.status;
1219
+ const status = pool.status;
586
1220
  return {
587
- active: Boolean(status.pid),
588
- worker: status,
1221
+ mode: status.shardCount === 1 ? 'single-runtime' : 'multi-runtime-test',
1222
+ active: Boolean(status.worker.pid),
1223
+ worker: status.worker,
1224
+ shards: status.shards,
1225
+ shardCount: status.shardCount,
1226
+ providerCooldown: pool.providerCooldown,
589
1227
  };
590
1228
  },
591
1229
  };