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
@@ -1,8 +1,16 @@
1
1
  import { availableParallelism } from 'node:os';
2
2
  import { Worker } from 'node:worker_threads';
3
3
  import { currentProviderAdmissionOwner } from './admission-scheduler.mjs';
4
+ import { frameAndParseSse } from './lib/sse-framing.mjs';
4
5
 
5
6
  const DEFAULT_MIN_BATCH_BYTES = 32 * 1024;
7
+ // Soft cap on IDLE owner-affinity metadata. Owners with in-flight work are
8
+ // never evicted, so the live-stream count (not this number) bounds the map.
9
+ const MAX_IDLE_OWNER_AFFINITIES = 4096;
10
+ // After this many consecutive worker-construction failures the pool stops
11
+ // trying to spawn and stays inline. This is a failure latch, not a throttle:
12
+ // it never limits concurrent streams, it only stops re-throwing constructors.
13
+ const MAX_SPAWN_FAILURES = 3;
6
14
 
7
15
  function positiveInt(value, fallback) {
8
16
  const parsed = Math.floor(Number(value));
@@ -16,6 +24,10 @@ function configuredWorkerCount(env = process.env) {
16
24
  return Math.max(1, Math.min(4, availableParallelism() - 1));
17
25
  }
18
26
 
27
+ function normalizeOwner(ownerKey) {
28
+ return String(ownerKey || '').trim().slice(0, 240);
29
+ }
30
+
19
31
  function abortError(signal) {
20
32
  return signal?.reason instanceof Error
21
33
  ? signal.reason
@@ -29,12 +41,18 @@ function syntaxError(details) {
29
41
  }
30
42
 
31
43
  /**
32
- * Reusable worker pool for the CPU part of provider SSE handling.
44
+ * Reusable worker pool for the CPU part of provider stream handling.
45
+ *
46
+ * Two units of work share it:
47
+ * - `parseBatch(payloads)` — a batch of JSON payloads (Gemini/OpenAI).
48
+ * - `frameSse(chunk)` — a whole SSE network chunk: line framing plus
49
+ * per-record JSON parsing, so a chunk never costs one task (or one
50
+ * microtask) per event on the shared event loop.
33
51
  *
34
- * Small deltas stay inline because a Worker round-trip costs more than parsing
35
- * them. Large events and multi-event network chunks are parsed off the daemon
36
- * event loop. Calls are never admission-capped: every request is posted
37
- * immediately and workers consume their independent message queues.
52
+ * Small work stays inline because a Worker round-trip costs more than doing
53
+ * it; large chunks are framed/parsed off the daemon event loop. Calls are
54
+ * never admission-capped: every request is posted immediately and workers
55
+ * consume their independent message queues.
38
56
  */
39
57
  export function createStreamJsonPool({
40
58
  maxWorkers = configuredWorkerCount(),
@@ -42,22 +60,45 @@ export function createStreamJsonPool({
42
60
  || DEFAULT_MIN_BATCH_BYTES,
43
61
  maxPendingBytes = (Number(process.env.MIXDOG_PROVIDER_STREAM_PENDING_MB) || 32)
44
62
  * 1024 * 1024,
63
+ maxIdleOwnerAffinities = MAX_IDLE_OWNER_AFFINITIES,
45
64
  WorkerImpl = Worker,
46
65
  } = {}) {
47
66
  const workerMax = Math.max(0, Math.floor(Number(maxWorkers) || 0));
48
67
  const inlineBelowBytes = Math.max(0, Math.floor(Number(minBatchBytes) || 0));
68
+ const idleAffinityMax = positiveInt(maxIdleOwnerAffinities, MAX_IDLE_OWNER_AFFINITIES);
49
69
  const slots = [];
70
+ // owner -> { slot, active }. `active` counts the in-flight tasks holding
71
+ // this affinity; an owner with work in flight is NEVER evicted, because
72
+ // moving a live stream to another worker is exactly what reorders it.
50
73
  const ownerAffinities = new Map();
74
+ // Per-stream FIFO tail: streamKey -> { tail, pending }. One stream can mix
75
+ // routes (inline below the threshold, offloaded above it, inline again
76
+ // after backpressure or a lost worker); chaining every submission on its
77
+ // stream tail keeps chunk N+1 settling after chunk N no matter which route
78
+ // each took. Entries are refcounted and self-delete at pending === 0, so
79
+ // only idle metadata is ever dropped: an ACTIVE stream can never lose its
80
+ // ordering state to a capacity bound (the live-stream count bounds it).
81
+ const streamTails = new Map();
82
+ // Explicit transport-lifetime holds. A stream can be active while waiting
83
+ // for its next network chunk, when no pool task exists to carry the normal
84
+ // per-submission hold. The transport retains once and releases in finally.
85
+ const retainedStreamAffinities = new Map();
51
86
  const waiting = [];
52
87
  const pendingByteMax = Math.max(1024 * 1024, Math.floor(Number(maxPendingBytes) || 0));
53
88
  let pendingBytes = 0;
54
89
  let waitingBytes = 0;
55
90
  let sequence = 0;
91
+ let spawnFailures = 0;
56
92
  let closed = false;
57
93
  const stats = {
58
94
  inlineBatches: 0,
59
95
  offloadedBatches: 0,
60
96
  fallbackBatches: 0,
97
+ inlineChunks: 0,
98
+ offloadedChunks: 0,
99
+ framedEvents: 0,
100
+ peakOrderedStreams: 0,
101
+ spawnFailures: 0,
61
102
  parsedBytes: 0,
62
103
  };
63
104
 
@@ -65,11 +106,146 @@ export function createStreamJsonPool({
65
106
  return payloads.map((payload) => JSON.parse(payload));
66
107
  }
67
108
 
109
+ /**
110
+ * Re-run a task's work on the owner thread. Every task kind carries an
111
+ * `inline()` that is the exact equivalent of the worker computation, so a
112
+ * dead worker, a failed postMessage or a closing pool degrades to local
113
+ * CPU instead of failing a live stream.
114
+ */
115
+ function settleInline(task) {
116
+ stats.fallbackBatches += 1;
117
+ try { task.resolve(task.inline()); }
118
+ catch (error) { task.reject(error); }
119
+ }
120
+
121
+ /**
122
+ * Serialize one stream's submissions. Returns the raw (possibly
123
+ * synchronous) result when the stream has nothing in flight, so the common
124
+ * inline path stays free of promise/microtask overhead.
125
+ */
126
+ function withStreamOrder(streamKey, run) {
127
+ if (!streamKey) return run();
128
+ let entry = streamTails.get(streamKey) || null;
129
+ const started = entry ? entry.tail.then(run, run) : run();
130
+ if (!started || typeof started.then !== 'function') {
131
+ // Nothing was in flight for this stream and the work completed
132
+ // synchronously: there is no ordering state to retain.
133
+ return started;
134
+ }
135
+ const settled = started.then(() => {}, () => {});
136
+ if (entry) {
137
+ entry.tail = settled;
138
+ entry.pending += 1;
139
+ } else {
140
+ entry = { tail: settled, pending: 1 };
141
+ streamTails.set(streamKey, entry);
142
+ }
143
+ if (streamTails.size > stats.peakOrderedStreams) {
144
+ stats.peakOrderedStreams = streamTails.size;
145
+ }
146
+ const owned = entry;
147
+ settled.then(() => {
148
+ if (streamTails.get(streamKey) !== owned) return;
149
+ owned.pending = Math.max(0, owned.pending - 1);
150
+ // A stream releases ONLY its own slot, and only once nothing of
151
+ // that stream is in flight. There is no cross-stream eviction, so
152
+ // an active stream can never lose the tail that orders its chunks.
153
+ if (owned.pending === 0) streamTails.delete(streamKey);
154
+ });
155
+ return started;
156
+ }
157
+
158
+ /**
159
+ * Affinity refcount.
160
+ *
161
+ * A hold is taken when a submission ENTERS the pool — before it waits
162
+ * behind its stream's FIFO tail, before it is queued for backpressure and
163
+ * before any worker owns it — and released when that submission settles.
164
+ * An owner with queued OR in-flight work therefore has `active > 0` for
165
+ * the whole window, so neither pruning nor a drainWaiting() burst can move
166
+ * a live stream to a different worker between its chunks.
167
+ */
168
+ function acquireAffinity(owner) {
169
+ if (!owner) return null;
170
+ let entry = ownerAffinities.get(owner);
171
+ if (!entry) {
172
+ pruneOwnerAffinities();
173
+ entry = { slot: null, active: 0 };
174
+ ownerAffinities.set(owner, entry);
175
+ }
176
+ entry.active += 1;
177
+ return entry;
178
+ }
179
+
180
+ function releaseAffinity(entry) {
181
+ if (!entry) return;
182
+ entry.active = Math.max(0, entry.active - 1);
183
+ // Settlement is itself a pruning opportunity: metadata that just went
184
+ // idle is reclaimed here, so a finished fan-out burst does not leave
185
+ // the map above its cap until some unrelated owner happens to arrive.
186
+ if (entry.active === 0) pruneOwnerAffinities();
187
+ }
188
+
189
+ function releaseAffinityWhenSettled(entry, result) {
190
+ if (!entry) return result;
191
+ if (!result || typeof result.then !== 'function') {
192
+ releaseAffinity(entry);
193
+ return result;
194
+ }
195
+ result.then(() => releaseAffinity(entry), () => releaseAffinity(entry));
196
+ return result;
197
+ }
198
+
199
+ /** Run one submission under an affinity hold spanning its whole lifetime. */
200
+ function underAffinityHold(owner, needsHold, produce) {
201
+ const entry = needsHold ? acquireAffinity(owner) : null;
202
+ if (!entry) return produce();
203
+ let result;
204
+ try { result = produce(); }
205
+ catch (error) { releaseAffinity(entry); throw error; }
206
+ return releaseAffinityWhenSettled(entry, result);
207
+ }
208
+
209
+ /** Drop only IDLE affinity entries when the soft cap is reached. */
210
+ function pruneOwnerAffinities() {
211
+ if (ownerAffinities.size < idleAffinityMax) return;
212
+ for (const [owner, entry] of ownerAffinities) {
213
+ if (entry.active > 0) continue;
214
+ ownerAffinities.delete(owner);
215
+ if (ownerAffinities.size < idleAffinityMax) return;
216
+ }
217
+ }
218
+
219
+ function retainStream(streamKey, ownerKey = currentProviderAdmissionOwner()) {
220
+ if (!streamKey) return false;
221
+ const key = String(streamKey).slice(0, 240);
222
+ if (!key || retainedStreamAffinities.has(key)) return !!key;
223
+ const entry = acquireAffinity(normalizeOwner(ownerKey) || key);
224
+ if (!entry) return false;
225
+ retainedStreamAffinities.set(key, entry);
226
+ return true;
227
+ }
228
+
229
+ /**
230
+ * A worker keeps the event loop alive only while it owes an answer: idle
231
+ * workers stay unref'd (no process is held open by the pool), busy workers
232
+ * are ref'd (an in-flight chunk can never be lost to an early exit).
233
+ */
234
+ function syncSlotRef(slot) {
235
+ try {
236
+ if (slot.tasks.size > 0) slot.worker.ref?.();
237
+ else slot.worker.unref?.();
238
+ } catch { /* ref/unref is best-effort */ }
239
+ }
240
+
68
241
  function removeSlot(slot) {
69
242
  const index = slots.indexOf(slot);
70
243
  if (index >= 0) slots.splice(index, 1);
71
- for (const [owner, assigned] of ownerAffinities) {
72
- if (assigned === slot) ownerAffinities.delete(owner);
244
+ for (const entry of ownerAffinities.values()) {
245
+ // Keep the entry — its refcount tracks live queued/in-flight work.
246
+ // Only the dead slot pointer is dropped, so the next chunk re-picks
247
+ // a worker while the owner's hold stays intact.
248
+ if (entry.slot === slot) entry.slot = null;
73
249
  }
74
250
  }
75
251
 
@@ -81,29 +257,63 @@ export function createStreamJsonPool({
81
257
  pendingBytes = Math.max(0, pendingBytes - task.bytes);
82
258
  task.detach();
83
259
  if (task.aborted) continue;
84
- stats.fallbackBatches += 1;
85
- try { task.resolve(parseInline(task.payloads)); }
86
- catch (error) { task.reject(error); }
260
+ settleInline(task);
87
261
  }
88
262
  slot.tasks.clear();
89
263
  try { slot.worker.terminate(); } catch {}
90
264
  drainWaiting();
91
265
  }
92
266
 
267
+ /**
268
+ * Spawn a worker slot.
269
+ *
270
+ * Construction and listener wiring can throw (missing worker file, thread
271
+ * limit, restricted runtime). That must never reach a live stream, so a
272
+ * failure is latched and reported as `null` — "no worker available" — and
273
+ * the caller runs the same work inline instead.
274
+ */
93
275
  function createSlot() {
94
- const worker = new WorkerImpl(new URL('./stream-json-worker.mjs', import.meta.url), {
95
- execArgv: [],
96
- });
97
- worker.unref?.();
276
+ let worker;
277
+ try {
278
+ worker = new WorkerImpl(new URL('./stream-json-worker.mjs', import.meta.url), {
279
+ execArgv: [],
280
+ });
281
+ } catch {
282
+ spawnFailures += 1;
283
+ stats.spawnFailures += 1;
284
+ return null;
285
+ }
98
286
  const slot = { worker, tasks: new Map(), failed: false };
287
+ try {
288
+ worker.unref?.();
289
+ wireSlot(slot);
290
+ } catch {
291
+ spawnFailures += 1;
292
+ stats.spawnFailures += 1;
293
+ try { worker.terminate?.(); } catch {}
294
+ return null;
295
+ }
296
+ spawnFailures = 0;
297
+ slots.push(slot);
298
+ return slot;
299
+ }
300
+
301
+ function spawnAllowed() {
302
+ return !closed && workerMax > 0 && spawnFailures < MAX_SPAWN_FAILURES;
303
+ }
304
+
305
+ function wireSlot(slot) {
306
+ const worker = slot.worker;
99
307
  worker.on('message', (message) => {
100
308
  const task = slot.tasks.get(Number(message?.id));
101
309
  if (!task) return;
102
310
  slot.tasks.delete(Number(message.id));
311
+ syncSlotRef(slot);
103
312
  pendingBytes = Math.max(0, pendingBytes - task.bytes);
104
313
  task.detach();
105
314
  if (task.aborted) return;
106
- if (message?.ok === true) task.resolve(message.values);
315
+ if (message?.ok === true) task.resolve(task.decode(message));
316
+ else if (task.kind === 'sse') settleInline(task);
107
317
  else task.reject(syntaxError(message?.error));
108
318
  drainWaiting();
109
319
  });
@@ -112,25 +322,32 @@ export function createStreamJsonPool({
112
322
  if (!closed) failSlot(slot);
113
323
  else removeSlot(slot);
114
324
  });
115
- slots.push(slot);
116
- return slot;
325
+ // Attaching the message listener starts (and refs) the public port, so
326
+ // the pre-listener unref() above is not enough: an idle worker must not
327
+ // hold a short-lived process open until the pool is closed.
328
+ syncSlotRef(slot);
117
329
  }
118
330
 
119
- function pickSlot(ownerKey) {
331
+ /** Pick a worker slot for `owner`, or null when none can be provided. */
332
+ function pickSlot(owner) {
333
+ const entry = owner ? ownerAffinities.get(owner) : null;
334
+ if (entry?.slot && !entry.slot.failed) return entry.slot;
120
335
  const ready = slots.filter((slot) => !slot.failed);
121
- const owner = String(ownerKey || '').trim().slice(0, 240);
122
- const assigned = owner ? ownerAffinities.get(owner) : null;
123
- if (assigned && !assigned.failed) return assigned;
124
336
  const least = ready.sort((left, right) => left.tasks.size - right.tasks.size)[0] || null;
125
- const slot = ready.length < workerMax && (!least || least.tasks.size > 0)
126
- ? createSlot()
127
- : least || createSlot();
128
- if (owner) {
129
- ownerAffinities.set(owner, slot);
130
- while (ownerAffinities.size > 4096) {
131
- ownerAffinities.delete(ownerAffinities.keys().next().value);
132
- }
337
+ const wantsNew = ready.length < workerMax && (!least || least.tasks.size > 0);
338
+ let slot;
339
+ if (wantsNew) {
340
+ // This task asked for its OWN worker. When the spawn fails (or the
341
+ // failure latch is set) it settles INLINE instead of being queued
342
+ // behind a worker it deliberately avoided: a broken spawn must
343
+ // never serialize unrelated offload work onto one thread.
344
+ slot = spawnAllowed() ? createSlot() : null;
345
+ if (!slot) return null;
346
+ } else {
347
+ slot = least;
348
+ if (!slot) return null;
133
349
  }
350
+ if (entry) entry.slot = slot;
134
351
  return slot;
135
352
  }
136
353
 
@@ -143,19 +360,36 @@ export function createStreamJsonPool({
143
360
  }
144
361
 
145
362
  function postTask(task) {
146
- const slot = pickSlot(task.ownerKey);
363
+ const owner = normalizeOwner(task.ownerKey);
364
+ let slot = null;
365
+ try {
366
+ slot = pickSlot(owner);
367
+ } catch {
368
+ // A WorkerImpl whose constructor (or wiring) throws must not fail
369
+ // the caller: treat it as "no worker available".
370
+ spawnFailures += 1;
371
+ stats.spawnFailures += 1;
372
+ slot = null;
373
+ }
374
+ if (!slot) {
375
+ // Deterministic inline fallback: same computation, same result,
376
+ // no rejection, and abort/retry semantics are untouched.
377
+ task.detach();
378
+ if (!task.aborted) settleInline(task);
379
+ return;
380
+ }
147
381
  task.slot = slot;
148
382
  slot.tasks.set(task.id, task);
149
383
  pendingBytes += task.bytes;
384
+ syncSlotRef(slot);
150
385
  try {
151
- slot.worker.postMessage({ id: task.id, payloads: task.payloads });
386
+ slot.worker.postMessage(task.message);
152
387
  } catch {
153
388
  slot.tasks.delete(task.id);
389
+ syncSlotRef(slot);
154
390
  pendingBytes = Math.max(0, pendingBytes - task.bytes);
155
391
  task.detach();
156
- stats.fallbackBatches += 1;
157
- try { task.resolve(parseInline(task.payloads)); }
158
- catch (error) { task.reject(error); }
392
+ if (!task.aborted) settleInline(task);
159
393
  drainWaiting();
160
394
  }
161
395
  }
@@ -195,12 +429,17 @@ export function createStreamJsonPool({
195
429
 
196
430
  const id = ++sequence;
197
431
  stats.offloadedBatches += 1;
198
- return new Promise((resolve, reject) => {
432
+ // The hold spans the backpressure queue too, so a batch waiting for
433
+ // pending-byte headroom keeps its owner's worker affinity.
434
+ return underAffinityHold(normalizeOwner(ownerKey), true, () => new Promise((resolve, reject) => {
199
435
  const task = {
200
436
  id,
437
+ kind: 'batch',
201
438
  ownerKey,
202
439
  bytes,
203
- payloads,
440
+ message: { id, payloads },
441
+ inline: () => parseInline(payloads),
442
+ decode: (result) => result.values,
204
443
  resolve,
205
444
  reject,
206
445
  aborted: false,
@@ -239,7 +478,108 @@ export function createStreamJsonPool({
239
478
  return;
240
479
  }
241
480
  postTask(task);
242
- });
481
+ }));
482
+ }
483
+
484
+ /**
485
+ * Frame + parse ONE SSE network chunk as a single unit of work.
486
+ *
487
+ * The caller hands over the complete-record region of its decode buffer;
488
+ * line framing, per-record JSON parsing and per-record error isolation all
489
+ * happen in one place — inside a worker once the chunk is worth the
490
+ * round-trip, otherwise inline. The inline route returns a plain object
491
+ * synchronously (no promise, no microtask), which is what removes the
492
+ * former one-await-per-SSE-event amplification from the shared event loop.
493
+ *
494
+ * Ordering: submissions carrying the same `streamKey` settle in submission
495
+ * order. `currentEvent` is the caller's carry at submission time, so a
496
+ * caller that pipelines chunks must keep feeding the carry it already
497
+ * holds (the Anthropic reader submits one chunk at a time and threads the
498
+ * returned carry forward).
499
+ */
500
+ function frameSse(text, {
501
+ currentEvent = '',
502
+ ownerKey = currentProviderAdmissionOwner(),
503
+ streamKey = null,
504
+ } = {}) {
505
+ const region = typeof text === 'string' ? text : String(text ?? '');
506
+ const carry = typeof currentEvent === 'string' ? currentEvent : String(currentEvent ?? '');
507
+ const key = streamKey ? String(streamKey).slice(0, 240) : '';
508
+ const owner = normalizeOwner(ownerKey);
509
+ const affinityOwner = owner || key;
510
+ // A chunk that must wait behind its stream's tail is already "queued
511
+ // work" for this owner, so it takes an affinity hold even when it is
512
+ // framed inline — that is the window in which a drainWaiting() burst
513
+ // used to prune the owner and migrate the stream to another worker.
514
+ const chained = key !== '' && streamTails.has(key);
515
+ if (!region) {
516
+ return underAffinityHold(affinityOwner, chained, () =>
517
+ withStreamOrder(key, () => ({ events: [], currentEvent: carry })));
518
+ }
519
+ const bytes = Buffer.byteLength(region);
520
+ stats.parsedBytes += bytes;
521
+ // Bounded and failure-safe by construction: an oversized chunk or a
522
+ // full in-flight budget runs inline instead of queueing or failing, so
523
+ // pending worker bytes stay capped and no live stream is ever dropped
524
+ // for resource pressure.
525
+ const offloadable = workerMax > 0
526
+ && !closed
527
+ && bytes >= inlineBelowBytes
528
+ && bytes <= pendingByteMax
529
+ && (pendingBytes === 0 || pendingBytes + bytes <= pendingByteMax);
530
+ if (!offloadable) {
531
+ stats.inlineChunks += 1;
532
+ return underAffinityHold(affinityOwner, chained, () => withStreamOrder(key, () => {
533
+ const framed = frameAndParseSse(region, carry);
534
+ stats.framedEvents += framed.events.length;
535
+ return framed;
536
+ }));
537
+ }
538
+ stats.offloadedChunks += 1;
539
+ return underAffinityHold(affinityOwner, true, () => withStreamOrder(key, () => new Promise((resolve, reject) => {
540
+ const id = ++sequence;
541
+ const task = {
542
+ id,
543
+ kind: 'sse',
544
+ // Owner affinity first (one agent's streams share a worker and
545
+ // its parser caches); the stream key only stands in when the
546
+ // call runs outside a provider admission scope.
547
+ ownerKey: affinityOwner,
548
+ bytes,
549
+ message: { id, kind: 'sse', text: region, event: carry },
550
+ inline: () => frameAndParseSse(region, carry),
551
+ decode: (result) => ({
552
+ events: Array.isArray(result?.events) ? result.events : [],
553
+ currentEvent: String(result?.event || ''),
554
+ }),
555
+ resolve: (value) => {
556
+ stats.framedEvents += Array.isArray(value?.events) ? value.events.length : 0;
557
+ resolve(value);
558
+ },
559
+ reject,
560
+ aborted: false,
561
+ onAbort: null,
562
+ detach() {},
563
+ };
564
+ postTask(task);
565
+ })));
566
+ }
567
+
568
+ /**
569
+ * Drop a finished stream's ordering slot. An entry that still has work in
570
+ * flight is left alone — it self-deletes once its last chunk settles — so
571
+ * an early/late release can never unorder a stream that is still running.
572
+ */
573
+ function releaseStream(streamKey) {
574
+ if (!streamKey) return;
575
+ const key = String(streamKey).slice(0, 240);
576
+ const retainedAffinity = retainedStreamAffinities.get(key);
577
+ if (retainedAffinity) {
578
+ retainedStreamAffinities.delete(key);
579
+ releaseAffinity(retainedAffinity);
580
+ }
581
+ const entry = streamTails.get(key);
582
+ if (entry && entry.pending === 0) streamTails.delete(key);
243
583
  }
244
584
 
245
585
  async function close(reason = 'provider stream JSON pool closed') {
@@ -255,12 +595,18 @@ export function createStreamJsonPool({
255
595
  for (const slot of slots.splice(0)) {
256
596
  for (const task of slot.tasks.values()) {
257
597
  task.detach();
258
- if (!task.aborted) task.reject(error);
598
+ if (task.aborted) continue;
599
+ // A live provider stream must not fail because the pool is
600
+ // shutting down: finish its chunk inline instead.
601
+ if (task.kind === 'sse') settleInline(task);
602
+ else task.reject(error);
259
603
  }
260
604
  slot.tasks.clear();
261
605
  workers.push(Promise.resolve(slot.worker.terminate()).catch(() => {}));
262
606
  }
607
+ retainedStreamAffinities.clear();
263
608
  ownerAffinities.clear();
609
+ streamTails.clear();
264
610
  await Promise.all(workers);
265
611
  }
266
612
 
@@ -274,18 +620,32 @@ export function createStreamJsonPool({
274
620
  waitingBytes,
275
621
  maxPendingBytes: pendingByteMax,
276
622
  ownerAffinities: ownerAffinities.size,
623
+ retainedStreams: retainedStreamAffinities.size,
624
+ orderedStreams: streamTails.size,
625
+ peakOrderedStreams: stats.peakOrderedStreams,
626
+ spawnFailures: stats.spawnFailures,
627
+ workerSpawnDisabled: !spawnAllowed(),
277
628
  inlineBatches: stats.inlineBatches,
278
629
  offloadedBatches: stats.offloadedBatches,
279
630
  fallbackBatches: stats.fallbackBatches,
631
+ inlineChunks: stats.inlineChunks,
632
+ offloadedChunks: stats.offloadedChunks,
633
+ framedEvents: stats.framedEvents,
280
634
  parsedBytes: stats.parsedBytes,
281
635
  };
282
636
  }
283
637
 
284
- return { parseBatch, close, snapshot };
638
+ return { parseBatch, frameSse, retainStream, releaseStream, close, snapshot };
285
639
  }
286
640
 
287
641
  export const providerStreamJsonPool = createStreamJsonPool();
288
642
  export const parseProviderJsonBatch = (payloads, options) =>
289
643
  providerStreamJsonPool.parseBatch(payloads, options);
644
+ export const frameProviderSseChunk = (text, options) =>
645
+ providerStreamJsonPool.frameSse(text, options);
646
+ export const retainProviderSseStream = (streamKey, ownerKey) =>
647
+ providerStreamJsonPool.retainStream(streamKey, ownerKey);
648
+ export const releaseProviderSseStream = (streamKey) =>
649
+ providerStreamJsonPool.releaseStream(streamKey);
290
650
  export const providerStreamJsonSnapshot = () => providerStreamJsonPool.snapshot();
291
651
  export const closeProviderStreamJsonPool = (reason) => providerStreamJsonPool.close(reason);
@@ -1,9 +1,35 @@
1
1
  import { parentPort } from 'node:worker_threads';
2
+ import { frameAndParseSse } from './lib/sse-framing.mjs';
2
3
 
3
4
  if (!parentPort) throw new Error('provider stream JSON worker requires parentPort');
4
5
 
5
6
  parentPort.on('message', (message) => {
6
7
  const id = Number(message?.id);
8
+ if (message?.kind === 'sse') {
9
+ // Whole-chunk SSE work: line framing AND per-record JSON parsing run
10
+ // here, so the owner event loop only receives the ordered event list.
11
+ try {
12
+ const framed = frameAndParseSse(String(message.text || ''), String(message.event || ''));
13
+ parentPort.postMessage({
14
+ id,
15
+ ok: true,
16
+ kind: 'sse',
17
+ events: framed.events,
18
+ event: framed.currentEvent,
19
+ });
20
+ } catch (error) {
21
+ parentPort.postMessage({
22
+ id,
23
+ ok: false,
24
+ kind: 'sse',
25
+ error: {
26
+ name: String(error?.name || 'Error'),
27
+ message: String(error?.message || error || 'sse framing failed'),
28
+ },
29
+ });
30
+ }
31
+ return;
32
+ }
7
33
  const payloads = Array.isArray(message?.payloads) ? message.payloads : [];
8
34
  try {
9
35
  const values = payloads.map((payload) => JSON.parse(String(payload)));