mixdog 0.9.95 → 0.9.97

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 (282) hide show
  1. package/LICENSES/Apache-2.0.txt +201 -0
  2. package/LICENSES/MIT.txt +56 -0
  3. package/LICENSES/codex-NOTICE.txt +6 -0
  4. package/NOTICE.md +46 -9
  5. package/README.md +0 -17
  6. package/package.json +16 -12
  7. package/scripts/.tmp-coverage-report.mjs +96 -0
  8. package/scripts/build-tui.mjs +17 -1
  9. package/scripts/{channel-daemon-stub.mjs → daemon-stub.mjs} +18 -12
  10. package/scripts/fixtures/patch-replay-corpus.json +98 -0
  11. package/src/defaults/mixdog-config.template.json +5 -8
  12. package/src/defaults/skills/setup/SKILL.md +1 -1
  13. package/src/lib/keychain-cjs.cjs +1 -1
  14. package/src/lib/mixdog-debug.cjs +2 -3
  15. package/src/rules/agent/30-explorer.md +7 -4
  16. package/src/rules/lead/01-general.md +1 -2
  17. package/src/rules/lead/lead-tool.md +0 -3
  18. package/src/rules/shared/01-tool.md +21 -29
  19. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
  20. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
  22. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -1
  23. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -13
  24. package/src/runtime/agent/orchestrator/config.mjs +246 -67
  25. package/src/runtime/agent/orchestrator/mcp/client.mjs +104 -41
  26. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
  27. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
  28. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
  29. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
  30. package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
  31. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
  32. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
  33. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
  34. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -8
  35. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
  36. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
  37. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +3 -4
  38. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +3 -6
  39. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -2
  40. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +67 -7
  41. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
  42. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
  43. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +36 -10
  44. package/src/runtime/agent/orchestrator/session/compact.mjs +1 -1
  45. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +22 -33
  46. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
  47. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
  48. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
  49. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +21 -29
  50. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +7 -10
  51. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +45 -25
  52. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -3
  53. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
  54. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +12 -6
  55. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
  56. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
  57. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +38 -2
  58. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +29 -9
  59. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
  60. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  61. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +86 -8
  62. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +10 -3
  63. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +4 -0
  64. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +71 -42
  65. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +4 -33
  66. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -5
  67. package/src/runtime/agent/orchestrator/stall-policy.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +35 -1
  70. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +116 -26
  71. package/src/runtime/agent/orchestrator/tools/builtin/binary-file.mjs +92 -19
  72. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
  73. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
  74. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
  75. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +2 -2
  76. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +6 -15
  77. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +12 -26
  78. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +5 -5
  79. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +83 -33
  80. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +42 -45
  81. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +27 -9
  82. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
  83. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +17 -39
  84. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +73 -0
  85. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  86. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +0 -2
  87. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +3 -8
  88. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
  89. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -5
  90. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +142 -26
  91. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +12 -1
  92. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +47 -19
  93. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -2
  94. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +1 -2
  95. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
  96. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +39 -1
  97. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +1 -1
  98. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +94 -20
  99. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +16 -0
  100. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +108 -12
  101. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +12 -11
  102. package/src/runtime/agent/orchestrator/tools/patch.mjs +1 -1
  103. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +63 -9
  104. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  105. package/src/runtime/channels/lib/config.mjs +34 -34
  106. package/src/runtime/channels/lib/inbound-handler.mjs +8 -8
  107. package/src/runtime/channels/lib/interaction-handlers.mjs +6 -6
  108. package/src/runtime/channels/lib/memory-client.mjs +31 -157
  109. package/src/runtime/channels/lib/output-forwarder.mjs +16 -16
  110. package/src/runtime/channels/lib/owned-runtime.mjs +60 -60
  111. package/src/runtime/channels/lib/owner-heartbeat.mjs +1 -1
  112. package/src/runtime/channels/lib/{backend-dispatch.mjs → provider-dispatch.mjs} +6 -6
  113. package/src/runtime/channels/lib/runtime-paths.mjs +4 -30
  114. package/src/runtime/channels/lib/scheduler.mjs +2 -2
  115. package/src/runtime/channels/lib/status-snapshot.mjs +3 -3
  116. package/src/runtime/channels/lib/tool-dispatch.mjs +4 -4
  117. package/src/runtime/channels/lib/worker-bootstrap.mjs +1 -1
  118. package/src/runtime/channels/lib/worker-ipc.mjs +7 -7
  119. package/src/runtime/channels/lib/worker-main.mjs +38 -38
  120. package/src/runtime/channels/{backends → providers}/discord-access.mjs +1 -1
  121. package/src/runtime/channels/{backends → providers}/discord-gateway.mjs +1 -1
  122. package/src/runtime/channels/{backends → providers}/discord.mjs +3 -3
  123. package/src/runtime/channels/{backends → providers}/telegram.mjs +12 -12
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/memory/index.mjs +76 -248
  126. package/src/runtime/memory/lib/agent-ipc.mjs +146 -81
  127. package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
  128. package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
  129. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +13 -34
  130. package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
  131. package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
  132. package/src/runtime/memory/lib/http-router.mjs +9 -6
  133. package/src/runtime/memory/lib/ko-morph.mjs +3 -20
  134. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
  135. package/src/runtime/memory/lib/memory-config-flags.mjs +1 -1
  136. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
  137. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
  138. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
  139. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +4 -1
  140. package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
  141. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +5 -0
  142. package/src/runtime/memory/lib/memory-log.mjs +1 -1
  143. package/src/runtime/memory/lib/memory-process-lock.mjs +5 -60
  144. package/src/runtime/memory/lib/memory.mjs +1 -1
  145. package/src/runtime/memory/lib/pg/process.mjs +32 -9
  146. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  147. package/src/runtime/memory/tool-defs.mjs +1 -5
  148. package/src/runtime/search/index.mjs +2 -2
  149. package/src/runtime/search/tool-defs.mjs +2 -2
  150. package/src/runtime/shared/abort-race.mjs +70 -0
  151. package/src/runtime/shared/agent-route-config.mjs +119 -0
  152. package/src/runtime/shared/atomic-file.mjs +80 -4
  153. package/src/runtime/shared/channel-notification-routing.mjs +2 -2
  154. package/src/runtime/shared/child-guardian.mjs +204 -108
  155. package/src/runtime/shared/child-spawn-gate.mjs +139 -72
  156. package/src/runtime/shared/config.mjs +120 -47
  157. package/src/runtime/shared/llm/index.mjs +1 -1
  158. package/src/runtime/shared/owner-fair-gate.mjs +134 -0
  159. package/src/runtime/shared/resource-admission.mjs +150 -100
  160. package/src/runtime/shared/stream-progress.mjs +6 -0
  161. package/src/runtime/shared/tool-execution-owner.mjs +12 -0
  162. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  163. package/src/runtime/shared/tool-surface.mjs +91 -27
  164. package/src/runtime/shared/tool-workload-gates.mjs +29 -0
  165. package/src/runtime/shared/turn-snapshot.mjs +47 -6
  166. package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
  167. package/src/session-runtime/channel-config-api.mjs +5 -5
  168. package/src/session-runtime/config-helpers.mjs +39 -37
  169. package/src/session-runtime/config-lifecycle.mjs +27 -27
  170. package/src/session-runtime/lifecycle-api.mjs +22 -3
  171. package/src/session-runtime/memory-runtime-prewarm.mjs +15 -0
  172. package/src/session-runtime/model-route-api.mjs +7 -5
  173. package/src/session-runtime/prewarm.mjs +1 -1
  174. package/src/session-runtime/provider-auth-api.mjs +0 -7
  175. package/src/session-runtime/provider-models.mjs +1 -1
  176. package/src/session-runtime/remote-control.mjs +6 -7
  177. package/src/session-runtime/runtime-core.mjs +63 -39
  178. package/src/session-runtime/runtime-tunables.mjs +4 -4
  179. package/src/session-runtime/self-update.mjs +1 -1
  180. package/src/session-runtime/session-lifecycle.mjs +45 -17
  181. package/src/session-runtime/session-title.mjs +1 -1
  182. package/src/session-runtime/session-turn-api.mjs +96 -24
  183. package/src/session-runtime/settings-api.mjs +12 -24
  184. package/src/session-runtime/tool-defs.mjs +3 -3
  185. package/src/session-runtime/workflow-agents-api.mjs +55 -81
  186. package/src/session-runtime/workflow.mjs +14 -24
  187. package/src/standalone/agent-dispatch-broker.mjs +198 -0
  188. package/src/standalone/agent-tool/helpers.mjs +2 -2
  189. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  190. package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
  191. package/src/standalone/agent-tool/tool-def.mjs +0 -14
  192. package/src/standalone/channel-admin.mjs +53 -64
  193. package/src/standalone/{channel-daemon-client.mjs → channel-client.mjs} +33 -19
  194. package/src/standalone/{channel-daemon-transport.mjs → channel-transport.mjs} +127 -46
  195. package/src/standalone/channel-worker.mjs +124 -647
  196. package/src/standalone/{backend-daemon.mjs → daemon.mjs} +270 -154
  197. package/src/standalone/explore-tool.mjs +187 -70
  198. package/src/standalone/fair-call-scheduler.mjs +264 -0
  199. package/src/standalone/memory-runtime-proxy.mjs +58 -1
  200. package/src/standalone/session-client.mjs +1134 -0
  201. package/src/standalone/session-protocol.mjs +192 -0
  202. package/src/standalone/session-runtime-pool.mjs +393 -0
  203. package/src/standalone/session-runtime-record.mjs +20 -0
  204. package/src/standalone/session-runtime-worker.mjs +203 -0
  205. package/src/standalone/{engine-daemon-service.mjs → session-service.mjs} +481 -224
  206. package/src/standalone/{engine-daemon-transport.mjs → session-transport.mjs} +390 -121
  207. package/src/standalone/session-wire.mjs +55 -0
  208. package/src/tui/App.jsx +34 -21
  209. package/src/tui/app/app-format.mjs +1 -1
  210. package/src/tui/app/app-view.jsx +5 -2
  211. package/src/tui/app/channel-pickers.mjs +41 -41
  212. package/src/tui/app/core-memory-picker.mjs +19 -19
  213. package/src/tui/app/doctor.mjs +5 -11
  214. package/src/tui/app/input-parsers.mjs +2 -2
  215. package/src/tui/app/message-selector.mjs +103 -0
  216. package/src/tui/app/onboarding-steps.mjs +3 -4
  217. package/src/tui/app/project-picker.mjs +80 -56
  218. package/src/tui/app/prompt-submit.mjs +48 -43
  219. package/src/tui/app/resume-picker.mjs +2 -2
  220. package/src/tui/app/route-pickers.mjs +3 -1
  221. package/src/tui/app/settings-picker.mjs +31 -33
  222. package/src/tui/app/transcript-window.mjs +38 -0
  223. package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
  224. package/src/tui/app/use-prompt-handlers.mjs +86 -21
  225. package/src/tui/app/use-prompt-hint.mjs +3 -2
  226. package/src/tui/app/use-prompt-queue-history.mjs +10 -3
  227. package/src/tui/app/use-transcript-scroll.mjs +10 -3
  228. package/src/tui/app/use-transcript-window.mjs +50 -17
  229. package/src/tui/components/PromptInput.jsx +71 -38
  230. package/src/tui/components/Spinner.jsx +1 -1
  231. package/src/tui/components/ToolExecution.jsx +1 -1
  232. package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
  233. package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
  234. package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
  235. package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
  236. package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
  237. package/src/tui/components/tool-execution/surface-detail.mjs +1 -1
  238. package/src/tui/dist/index.mjs +1031 -1676
  239. package/src/tui/hooks/useSession.mjs +16 -0
  240. package/src/tui/index.jsx +12 -13
  241. package/src/tui/{engine → session}/agent-envelope.mjs +3 -3
  242. package/src/tui/{engine → session}/agent-job-feed.mjs +4 -4
  243. package/src/tui/{engine → session}/boot-profile.mjs +2 -2
  244. package/src/tui/{engine → session}/context-state.mjs +3 -3
  245. package/src/tui/{engine → session}/labels.mjs +2 -2
  246. package/src/tui/{engine → session}/live-share.mjs +38 -4
  247. package/src/tui/{engine → session}/notice-text.mjs +2 -2
  248. package/src/tui/{engine → session}/notification-plan.mjs +3 -3
  249. package/src/tui/session/oauth-flows.mjs +140 -0
  250. package/src/tui/{engine → session}/prompt-history.mjs +3 -3
  251. package/src/tui/{engine → session}/queue-helpers.mjs +2 -2
  252. package/src/tui/{engine → session}/render-timing.mjs +2 -2
  253. package/src/tui/{engine → session}/session-api-ext.mjs +30 -20
  254. package/src/tui/{engine → session}/session-api.mjs +110 -93
  255. package/src/tui/{engine → session}/session-flow.mjs +8 -2
  256. package/src/tui/{engine → session}/session-stats.mjs +3 -3
  257. package/src/tui/{engine → session}/tool-approval.mjs +3 -3
  258. package/src/tui/{engine → session}/tool-call-fields.mjs +2 -2
  259. package/src/tui/{engine → session}/tool-card-results.mjs +8 -6
  260. package/src/tui/{engine → session}/tool-result-status.mjs +5 -5
  261. package/src/tui/{engine → session}/tool-result-text.mjs +13 -3
  262. package/src/tui/{engine → session}/transcript-spill.mjs +34 -34
  263. package/src/tui/{engine → session}/turn.mjs +38 -176
  264. package/src/tui/{engine-local-session.mjs → session-local.mjs} +103 -103
  265. package/src/tui/session.mjs +22 -0
  266. package/src/tui/spinner-meta.mjs +4 -4
  267. package/src/tui/spinner-verbs.mjs +51 -3
  268. package/src/ui/statusline-segments.mjs +1 -1
  269. package/src/ui/statusline.mjs +1 -1
  270. package/vendor/ink/build/components/App.js +10 -1
  271. package/src/session-runtime/memory-daemon-probe.mjs +0 -61
  272. package/src/standalone/engine-daemon-client.mjs +0 -894
  273. package/src/standalone/engine-daemon-local-bridge.mjs +0 -20
  274. package/src/standalone/engine-daemon-protocol.mjs +0 -33
  275. package/src/tui/engine/turn-watchdog.mjs +0 -92
  276. package/src/tui/engine.mjs +0 -22
  277. package/src/tui/hooks/useEngine.mjs +0 -16
  278. /package/src/runtime/agent/orchestrator/session/compact/{engine.mjs → runner.mjs} +0 -0
  279. /package/src/runtime/channels/{backends → providers}/discord-attachments.mjs +0 -0
  280. /package/src/tui/{engine → session}/agent-response-tail.mjs +0 -0
  281. /package/src/tui/{engine → session}/frame-batched-store.mjs +0 -0
  282. /package/src/tui/{engine → session}/tui-steering-persist.mjs +0 -0
@@ -1,45 +1,108 @@
1
1
  'use strict';
2
2
 
3
+ import { availableParallelism } from 'node:os';
4
+ import { createOwnerFairGate } from './owner-fair-gate.mjs';
5
+ import { currentToolExecutionOwner } from './tool-execution-owner.mjs';
6
+
3
7
  // ── Module-global child-spawn semaphore ──────────────────────────────────
4
8
  //
5
9
  // Single-daemon premise: tool execution is in-process, so a module-level
6
10
  // singleton semaphore CAN bound the number of concurrent child processes (rg,
7
11
  // mixdog-graph, …) across ALL agents/workers in this daemon.
8
12
  //
9
- // Default: 1 on win32, unbounded elsewhere. Measured on Windows (explore-bench
10
- // 13-way fan-out, 2026-08-05): concurrent rg spawns thrash the AV child-scan
11
- // path — wall 31-61s unbounded vs 20-23s at cap 1, monotonic 1<2<6≈∞, tool
12
- // p50 14.6s→11.0s, quality unchanged. Spawns are short (rg/graph only; shell
13
- // is deliberately NOT gated), so serializing them overlaps LLM waits instead
14
- // of starving anything. MIXDOG_CHILD_SPAWN_MAX_INFLIGHT still overrides.
13
+ // Default: Windows search scales conservatively from 1..4 with CPU count;
14
+ // code-graph remains 1. Other platforms are unbounded. Windows measurements
15
+ // showed both extremes are poor: cap 1 creates multi-session queue latency,
16
+ // while unbounded multithreaded rg amplifies Defender/process-scan and disk
17
+ // contention. Separate lanes isolate short search from long graph builds.
18
+ // MIXDOG_CHILD_SPAWN_MAX_INFLIGHT overrides globally.
15
19
  //
16
- // IMPORTANT: this is a resource-control knob and is deliberately NOT exposed
17
- // on any tool JSON schema / tool parameter surface. The only tuning surface
18
- // is the MIXDOG_CHILD_SPAWN_MAX_INFLIGHT env override.
20
+ // IMPORTANT: these are resource-control knobs and are deliberately NOT exposed
21
+ // on any tool JSON schema / tool parameter surface. Operators may set the
22
+ // shared MIXDOG_CHILD_SPAWN_MAX_INFLIGHT fallback or a lane-specific override.
19
23
 
20
- function _defaultMaxInflight() {
21
- const override = Number(process.env.MIXDOG_CHILD_SPAWN_MAX_INFLIGHT);
24
+ export function resolveDefaultChildSpawnMaxInflight(env = process.env, platform = process.platform) {
25
+ const override = Number(env.MIXDOG_CHILD_SPAWN_MAX_INFLIGHT);
22
26
  if (Number.isFinite(override) && override >= 1) return Math.floor(override);
23
- return process.platform === 'win32' ? 1 : Infinity;
27
+ return platform === 'win32' ? 1 : Infinity;
28
+ }
29
+
30
+ export function resolveDefaultChildSpawnLaneMaxInflight(
31
+ laneName,
32
+ env = process.env,
33
+ platform = process.platform,
34
+ parallelism = availableParallelism(),
35
+ ) {
36
+ const lane = _laneName(laneName);
37
+ const key = lane.toUpperCase().replace(/[^A-Z0-9]+/g, '_');
38
+ const laneOverride = Number(env[`MIXDOG_CHILD_SPAWN_${key}_MAX_INFLIGHT`]);
39
+ if (Number.isFinite(laneOverride) && laneOverride >= 1) return Math.floor(laneOverride);
40
+ const sharedOverride = Number(env.MIXDOG_CHILD_SPAWN_MAX_INFLIGHT);
41
+ if (Number.isFinite(sharedOverride) && sharedOverride >= 1) return Math.floor(sharedOverride);
42
+ if (platform !== 'win32') return Infinity;
43
+ if (lane !== 'search') return 1;
44
+ // Multiple rg processes beat one under multi-session load, but each process
45
+ // is itself threaded and Defender amplifies excess fan-out. Scale slowly and
46
+ // cap at four: 4 cores→1, 8→2, 12→3, 16+→4.
47
+ const cpus = Math.max(1, Math.floor(Number(parallelism) || 1));
48
+ return Math.max(1, Math.min(4, Math.floor(cpus / 4)));
24
49
  }
25
50
 
26
- const MAX_INFLIGHT = _defaultMaxInflight();
51
+ const DEFAULT_MAX_INFLIGHT = resolveDefaultChildSpawnMaxInflight();
52
+ const LANE_ALIASES = new Map([
53
+ ['default', 'search'],
54
+ ['build', 'code-graph'],
55
+ ]);
56
+
57
+ function _laneName(name) {
58
+ const clean = String(name || 'search').trim().toLowerCase() || 'search';
59
+ return LANE_ALIASES.get(clean) || clean;
60
+ }
61
+
62
+ function _laneLimit(name) {
63
+ return resolveDefaultChildSpawnLaneMaxInflight(name);
64
+ }
65
+
66
+ function _laneSetting(name, suffix, fallback) {
67
+ const key = _laneName(name).toUpperCase().replace(/[^A-Z0-9]+/g, '_');
68
+ const laneValue = Number(process.env[`MIXDOG_CHILD_SPAWN_${key}_${suffix}`]);
69
+ if (Number.isFinite(laneValue) && laneValue >= 1) return Math.floor(laneValue);
70
+ const sharedValue = Number(process.env[`MIXDOG_CHILD_SPAWN_${suffix}`]);
71
+ return Number.isFinite(sharedValue) && sharedValue >= 1
72
+ ? Math.floor(sharedValue)
73
+ : fallback;
74
+ }
27
75
 
28
76
  // ── Lanes ────────────────────────────────────────────────────────────────
29
- // One shared cap number, applied PER LANE with independent counters/queues:
30
- // 'default' — short-lived spawns (rg). Holding time is one search run.
31
- // 'build' — long-lived builds (code-graph worker / graph binary). A cold
32
- // graph build can hold its slot for seconds-to-minutes; on the
33
- // win32 cap=1 default a single shared lane would let one build
77
+ // Independent optional limits and queues:
78
+ // 'search' — short-lived rg processes.
79
+ // 'code-graph' — long-lived graph workers / native graph binaries. A cold
80
+ // graph build can hold its slot for seconds-to-minutes; a
81
+ // single shared lane would let one build
34
82
  // stall every rg spawn behind it. Separate lanes remove that
35
83
  // starvation class while keeping each spawn family bounded.
36
- function _makeLane() {
37
- return { inflight: 0, queue: [] };
84
+ function _makeLane(name) {
85
+ const limit = _laneLimit(name);
86
+ const queueMax = _laneSetting(name, 'MAX_QUEUE', 1024);
87
+ const waitTimeoutMs = _laneSetting(name, 'WAIT_TIMEOUT_MS', 30_000);
88
+ return {
89
+ limit,
90
+ queueMax,
91
+ waitTimeoutMs,
92
+ gate: createOwnerFairGate({
93
+ name: `${name} child spawn`,
94
+ activeMax: limit,
95
+ queueMax,
96
+ minOwnerQueue: Math.max(8, Math.floor(queueMax / 32)),
97
+ waitTimeoutMs,
98
+ }),
99
+ };
38
100
  }
39
101
  const _lanes = new Map();
40
102
  function _lane(name) {
41
- let lane = _lanes.get(name);
42
- if (!lane) { lane = _makeLane(); _lanes.set(name, lane); }
103
+ const normalized = _laneName(name);
104
+ let lane = _lanes.get(normalized);
105
+ if (!lane) { lane = _makeLane(normalized); _lanes.set(normalized, lane); }
43
106
  return lane;
44
107
  }
45
108
 
@@ -61,24 +124,12 @@ function _maybeWarnSlow(waitedMs, laneName, lane) {
61
124
  _lastSlowWarnAt = now;
62
125
  try {
63
126
  process.stderr.write(
64
- `[child-spawn-gate] lane=${laneName} queue wait ${waitedMs}ms (inflight cap=${MAX_INFLIGHT}, queued=${lane.queue.length}); `
65
- + 'raise MIXDOG_CHILD_SPAWN_MAX_INFLIGHT if this persists\n',
127
+ `[child-spawn-gate] lane=${laneName} queue wait ${waitedMs}ms (inflight cap=${lane.limit}, queued=${lane.queue.length}); `
128
+ + 'raise the lane-specific MIXDOG_CHILD_SPAWN_*_MAX_INFLIGHT if this persists\n',
66
129
  );
67
130
  } catch { /* ignore */ }
68
131
  }
69
132
 
70
- function _drain(laneName, lane) {
71
- while (lane.inflight < MAX_INFLIGHT && lane.queue.length > 0) {
72
- const waiter = lane.queue.shift();
73
- if (waiter.onAbort && waiter.signal) {
74
- try { waiter.signal.removeEventListener('abort', waiter.onAbort); } catch { /* ignore */ }
75
- }
76
- lane.inflight++;
77
- _maybeWarnSlow(Date.now() - waiter.enqueuedAt, laneName, lane);
78
- waiter.resolve();
79
- }
80
- }
81
-
82
133
  /**
83
134
  * Acquire one child-spawn slot. Resolves immediately when below the cap,
84
135
  * otherwise queues until a slot frees. The returned function releases the
@@ -89,45 +140,37 @@ function _drain(laneName, lane) {
89
140
  * call release().
90
141
  *
91
142
  * @param {AbortSignal | null} [signal]
92
- * @param {string} [laneName] — 'default' (short spawns) or 'build'.
143
+ * @param {string} [laneName] — 'search' (rg) or 'code-graph'.
93
144
  * @returns {Promise<() => void>}
94
145
  */
95
- export function acquire(signal = null, laneName = 'default') {
146
+ export function acquire(signal = null, laneName = 'search', options = {}) {
96
147
  if (signal && signal.aborted) {
97
148
  return Promise.reject(signal.reason ?? _abortError());
98
149
  }
99
- const lane = _lane(laneName);
100
- return new Promise((resolve, reject) => {
101
- let released = false;
102
- const release = () => {
103
- if (released) return;
104
- released = true;
105
- lane.inflight = Math.max(0, lane.inflight - 1);
106
- _drain(laneName, lane);
107
- };
108
- if (lane.inflight < MAX_INFLIGHT && lane.queue.length === 0) {
109
- lane.inflight++;
110
- resolve(release);
111
- return;
112
- }
113
- const waiter = {
114
- enqueuedAt: Date.now(),
115
- signal: signal || null,
116
- onAbort: null,
117
- resolve: () => resolve(release),
118
- reject,
119
- };
120
- if (signal) {
121
- waiter.onAbort = () => {
122
- const idx = lane.queue.indexOf(waiter);
123
- if (idx !== -1) lane.queue.splice(idx, 1);
124
- try { signal.removeEventListener('abort', waiter.onAbort); } catch { /* ignore */ }
125
- reject(signal.reason ?? _abortError());
126
- };
127
- try { signal.addEventListener('abort', waiter.onAbort, { once: true }); } catch { /* ignore */ }
128
- }
129
- lane.queue.push(waiter);
150
+ const normalizedLaneName = _laneName(laneName);
151
+ const lane = _lane(normalizedLaneName);
152
+ const admitted = Promise.withResolvers();
153
+ const held = Promise.withResolvers();
154
+ let released = false;
155
+ const release = () => {
156
+ if (released) return;
157
+ released = true;
158
+ held.resolve();
159
+ };
160
+ const ownerKey = options?.ownerKey || currentToolExecutionOwner();
161
+ const running = lane.gate.run(ownerKey, async () => {
162
+ admitted.resolve(release);
163
+ await held.promise;
164
+ }, {
165
+ signal,
166
+ waitTimeoutMs: options?.waitTimeoutMs ?? lane.waitTimeoutMs,
167
+ onAdmit: (waitedMs) => _maybeWarnSlow(waitedMs, normalizedLaneName, {
168
+ limit: lane.limit,
169
+ queue: { length: lane.gate.queued },
170
+ }),
130
171
  });
172
+ running.catch((error) => admitted.reject(error));
173
+ return admitted.promise;
131
174
  }
132
175
 
133
176
  function _abortError() {
@@ -147,9 +190,33 @@ function _abortError() {
147
190
  * best-effort work. Real queries must use acquire()/withGate() and never gate
148
191
  * themselves on this.
149
192
  */
150
- export function hasSpareCapacity(laneName = 'default') {
193
+ export function hasSpareCapacity(laneName = 'search') {
151
194
  const lane = _lane(laneName);
152
- return lane.inflight < MAX_INFLIGHT && lane.queue.length === 0;
195
+ return lane.gate.active < lane.limit && lane.gate.queued === 0;
196
+ }
197
+
198
+ export function snapshot() {
199
+ return {
200
+ maxInflight: Number.isFinite(DEFAULT_MAX_INFLIGHT) ? DEFAULT_MAX_INFLIGHT : null,
201
+ lanes: [..._lanes.entries()].map(([name, lane]) => {
202
+ const state = lane.gate.snapshot();
203
+ return {
204
+ name,
205
+ inflight: state.active,
206
+ queued: state.queued,
207
+ limit: Number.isFinite(lane.limit) ? lane.limit : null,
208
+ queueMax: lane.queueMax,
209
+ waitTimeoutMs: lane.waitTimeoutMs,
210
+ owners: state.owners,
211
+ oldestWaitMs: state.oldestWaitMs,
212
+ admitted: state.admitted,
213
+ rejected: state.rejected,
214
+ timedOut: state.timedOut,
215
+ averageWaitMs: state.averageWaitMs,
216
+ maxWaitMs: state.maxWaitMs,
217
+ };
218
+ }),
219
+ };
153
220
  }
154
221
 
155
222
  /**
@@ -162,7 +229,7 @@ export function hasSpareCapacity(laneName = 'default') {
162
229
  * @param {string} [laneName]
163
230
  * @returns {Promise<T>}
164
231
  */
165
- async function withGate(fn, signal = null, laneName = 'default') {
232
+ async function withGate(fn, signal = null, laneName = 'search') {
166
233
  const release = await acquire(signal, laneName);
167
234
  try {
168
235
  return await fn({ signal: signal || null });
@@ -117,6 +117,107 @@ function isPlainObject(value) {
117
117
  return !!value && typeof value === 'object' && !Array.isArray(value)
118
118
  }
119
119
 
120
+ function hasOwn(value, key) {
121
+ return Object.prototype.hasOwnProperty.call(value || {}, key)
122
+ }
123
+
124
+ // Canonical on-disk channel shape. Provider-specific ids are durable. Schedule
125
+ // arrays moved to the scheduler DB and prompt injection moved to runtime hooks,
126
+ // so neither belongs in config anymore.
127
+ export function canonicalizeStoredChannelsConfig(value = {}) {
128
+ const next = isPlainObject(value) ? { ...value } : {}
129
+ delete next.promptInjection
130
+ delete next.schedules
131
+ delete next.nonInteractive
132
+ delete next.interactive
133
+
134
+ const channel = isPlainObject(next.channel) ? { ...next.channel } : {}
135
+ next.provider = next.provider === 'telegram' || next.backend === 'telegram'
136
+ ? 'telegram'
137
+ : 'discord'
138
+ delete next.backend
139
+ delete channel.channelId
140
+ next.channel = channel
141
+ return next
142
+ }
143
+
144
+ // Normalize cross-section legacy keys without touching independent domains
145
+ // (ui/theme, desktop, voice, channels access, memory schedules/embedding, …).
146
+ // Reads use this shape in-memory; every subsequent locked write persists it.
147
+ export function canonicalizeUnifiedConfig(value = {}) {
148
+ const next = isPlainObject(value) ? { ...value } : {}
149
+ const hadAgent = isPlainObject(next.agent)
150
+ const agent = hadAgent ? { ...next.agent } : {}
151
+
152
+ if (!String(next.outputStyle || '').trim() && String(agent.outputStyle || '').trim()) {
153
+ next.outputStyle = agent.outputStyle
154
+ }
155
+ delete agent.outputStyle
156
+
157
+ for (const key of ['autoClear', 'compaction', 'shell']) {
158
+ if (!isPlainObject(agent[key]) && isPlainObject(next[key])) agent[key] = next[key]
159
+ delete next[key]
160
+ }
161
+ if (isPlainObject(next.mcpServers)) {
162
+ agent.mcpServers = {
163
+ ...next.mcpServers,
164
+ ...(isPlainObject(agent.mcpServers) ? agent.mcpServers : {}),
165
+ }
166
+ delete next.mcpServers
167
+ }
168
+ if (!isPlainObject(agent.searchRoute) && isPlainObject(agent.search)) {
169
+ agent.searchRoute = agent.search
170
+ }
171
+ delete agent.search
172
+ delete agent.capabilities
173
+
174
+ const modules = isPlainObject(agent.modules) ? { ...agent.modules } : {}
175
+ if (hasOwn(modules, 'memory')) {
176
+ const legacyMemory = modules.memory
177
+ const explicitRecap = isPlainObject(agent.recap) && hasOwn(agent.recap, 'enabled')
178
+ if (!explicitRecap) {
179
+ const enabled = isPlainObject(legacyMemory)
180
+ ? legacyMemory.enabled !== false
181
+ : legacyMemory !== false
182
+ agent.recap = { ...(isPlainObject(agent.recap) ? agent.recap : {}), enabled }
183
+ }
184
+ delete modules.memory
185
+ agent.modules = modules
186
+ }
187
+
188
+ if (isPlainObject(next.memory)) {
189
+ const memory = { ...next.memory }
190
+ const user = isPlainObject(memory.user) ? { ...memory.user } : {}
191
+ const legacyTitle = String(user.title || '').trim()
192
+ if (legacyTitle && !String(agent.profile?.title || '').trim()) {
193
+ agent.profile = { ...(isPlainObject(agent.profile) ? agent.profile : {}), title: legacyTitle }
194
+ }
195
+ delete user.title
196
+ if (Object.keys(user).length) memory.user = user
197
+ else delete memory.user
198
+ if (hasOwn(memory, 'enabled')) {
199
+ const explicitRecap = isPlainObject(agent.recap) && hasOwn(agent.recap, 'enabled')
200
+ if (!explicitRecap) {
201
+ agent.recap = { ...(isPlainObject(agent.recap) ? agent.recap : {}), enabled: memory.enabled !== false }
202
+ }
203
+ delete memory.enabled
204
+ }
205
+ if (Object.keys(memory).length) next.memory = memory
206
+ else delete next.memory
207
+ }
208
+
209
+ if (isPlainObject(next.channels)) {
210
+ next.channels = canonicalizeStoredChannelsConfig(next.channels)
211
+ }
212
+ // Both sections are retired. `search` had only a fixed timeout that the
213
+ // runtime now owns, while `capabilities.homeAccess` never had a live path
214
+ // consumer or settings writer. Do not keep inert user-facing config.
215
+ delete next.search
216
+ delete next.capabilities
217
+ if (hadAgent || Object.keys(agent).length) next.agent = agent
218
+ return next
219
+ }
220
+
120
221
  export function stripGeneratedMarker(data) {
121
222
  if (!isPlainObject(data) || !Object.prototype.hasOwnProperty.call(data, GENERATED_KEY)) return data
122
223
  const { [GENERATED_KEY]: _generated, ...rest } = data
@@ -188,7 +289,7 @@ function writeJsonFile(path, data) {
188
289
 
189
290
  function readAll() {
190
291
  const parsed = readJsonFile(CONFIG_PATH)
191
- if (parsed != null) return parsed
292
+ if (parsed != null) return canonicalizeUnifiedConfig(parsed)
192
293
  // Symmetric with readAllForRmW(): a missing/unreadable config on a data
193
294
  // dir that was previously initialized means the file was LOST (deleted,
194
295
  // failed write), not a fresh install. Self-heal from the newest
@@ -202,7 +303,7 @@ function readAll() {
202
303
  const restored = loadLatestMixdogConfigFromBackup(DATA_DIR)
203
304
  if (restored && isPlainObject(restored)) {
204
305
  process.stderr.write('[config] read: restored mixdog-config.json from latest user-data backup (missing after init)\n')
205
- return restored
306
+ return canonicalizeUnifiedConfig(restored)
206
307
  }
207
308
  }
208
309
  return {}
@@ -259,7 +360,7 @@ function readAllForRmW() {
259
360
  }
260
361
 
261
362
  function writeAll(data) {
262
- writeJsonFile(CONFIG_PATH, data)
363
+ writeJsonFile(CONFIG_PATH, canonicalizeUnifiedConfig(data))
263
364
  }
264
365
 
265
366
  // Serialize a read-modify-write under the same file lock. Concurrent
@@ -268,10 +369,13 @@ function writeAll(data) {
268
369
  // silently clobber the earlier section update. Holding the lock across
269
370
  // read+modify+write keeps RMW linearizable.
270
371
  function withConfigLock(fn) {
271
- // secret:true clamps the lock file (which sits beside the API-key
272
- // config in a possibly shared home dir) to an owner-only ACL on win32,
273
- // fail-closed. POSIX is unaffected.
274
- return withFileLockSync(`${CONFIG_PATH}.lock`, fn, { secret: true })
372
+ // The lock file itself carries no secret — only `<pid> <ts> <token>`, an
373
+ // integrity marker. Clamping IT owner-only meant two synchronous icacls
374
+ // spawns (plus whoami) INSIDE the critical section on every config write:
375
+ // hundreds of ms with the daemon's event loop parked and every other writer
376
+ // queued behind it. The CONFIG file keeps secret:true, which is where the
377
+ // API keys actually live.
378
+ return withFileLockSync(`${CONFIG_PATH}.lock`, fn)
275
379
  }
276
380
 
277
381
  export function readSection(section) {
@@ -288,7 +392,7 @@ export function updateConfig(updater) {
288
392
  const current = stripGeneratedMarker(readAllForRmW()) || {}
289
393
  const next = typeof updater === 'function' ? updater({ ...current }) : updater
290
394
  if (!isPlainObject(next)) throw new Error('[config] updateConfig updater must return an object')
291
- saved = stripGeneratedMarker(next) || {}
395
+ saved = canonicalizeUnifiedConfig(stripGeneratedMarker(next) || {})
292
396
  writeAll(saved)
293
397
  })
294
398
  return saved
@@ -335,13 +439,13 @@ async function writeJsonFileAsync(path, data) {
335
439
  }
336
440
 
337
441
  async function writeAllAsync(data) {
338
- await writeJsonFileAsync(CONFIG_PATH, data)
442
+ await writeJsonFileAsync(CONFIG_PATH, canonicalizeUnifiedConfig(data))
339
443
  }
340
444
 
341
- // secret:true clamps the shared-home lock file owner-only on win32 via the
342
- // ASYNC icacls variant inside withFileLock (fail-closed, non-blocking).
343
445
  function withConfigLockAsync(fn) {
344
- return withFileLock(`${CONFIG_PATH}.lock`, fn, { secret: true })
446
+ // Same reasoning as withConfigLock: the ACL belongs on the config file, not
447
+ // on its lock marker, and icacls has no business inside the lock.
448
+ return withFileLock(`${CONFIG_PATH}.lock`, fn)
345
449
  }
346
450
 
347
451
  // Process-wide registry at the lock-owning layer: every public async RMW path
@@ -376,7 +480,7 @@ export function updateConfigAsync(updater) {
376
480
  const current = stripGeneratedMarker(readAllForRmW()) || {}
377
481
  const next = typeof updater === 'function' ? updater({ ...current }) : updater
378
482
  if (!isPlainObject(next)) throw new Error('[config] updateConfigAsync updater must return an object')
379
- saved = stripGeneratedMarker(next) || {}
483
+ saved = canonicalizeUnifiedConfig(stripGeneratedMarker(next) || {})
380
484
  await writeAllAsync(saved)
381
485
  })
382
486
  return saved
@@ -400,37 +504,6 @@ export function updateSectionAsync(section, updater) {
400
504
  }))
401
505
  }
402
506
 
403
- // ── Capabilities (B2 central path policy) ───────────────────────────
404
- // Top-level `capabilities` section in mixdog-config.json. Safe defaults
405
- // win on missing/malformed input — every cap is OFF unless explicitly
406
- // enabled. Settings round-trip through the setup UI; the in-process
407
- // path gate reads them via `getCapabilities()`.
408
- //
409
- // homeAccess: when true, file tools may write anywhere under $HOME. When
410
- // false (default), file tools are cwd-scoped — matches the setup UI's
411
- // out-of-the-box "OFF" toggle so a fresh install is restrictive until the
412
- // user explicitly opts in. This ONLY controls the main-agent path gate —
413
- // agent role Edit/Write to HOME paths always go through Discord approval
414
- // regardless (enforced in hooks/pre-tool-subagent.cjs).
415
- const CAPABILITY_DEFAULTS = Object.freeze({ homeAccess: false })
416
-
417
- function readCapabilities() {
418
- const raw = readAll().capabilities
419
- const out = { ...CAPABILITY_DEFAULTS }
420
- if (raw && typeof raw === 'object') {
421
- if (raw.homeAccess === true) out.homeAccess = true
422
- else if (raw.homeAccess === false) out.homeAccess = false
423
- }
424
- return out
425
- }
426
-
427
- // Convenience alias requested by B2 call-site plumbing. Returns the
428
- // same object shape as readCapabilities(); callers that only need a
429
- // boolean can read `.homeAccess` directly.
430
- function getCapabilities() {
431
- return readCapabilities()
432
- }
433
-
434
507
  // ── Secret account names ─────────────────────────────────────────────────────
435
508
  // Canonical account strings stored in the OS keychain. Must stay in sync with
436
509
  // the migration logic in seed.mjs.
@@ -519,9 +592,9 @@ export function saveSecret(account, value) {
519
592
  try {
520
593
  _setSecret(account, value)
521
594
  } catch (err) {
522
- // On WSL/headless Linux (and any host missing a usable keychain backend,
595
+ // On WSL/headless Linux (and any host missing a usable keychain provider,
523
596
  // e.g. keytar/libsecret not installed or no running Secret Service) the OS
524
- // write throws a cryptic backend error. Surface an actionable message that
597
+ // write throws a cryptic provider error. Surface an actionable message that
525
598
  // points at the env-var read path the getters already honor, instead of
526
599
  // silently writing the plaintext secret to mixdog-config.json.
527
600
  const envKey = _envKey(account)
@@ -530,7 +603,7 @@ export function saveSecret(account, value) {
530
603
  const envHint = stdEnv ? `${stdEnv} (or ${envKey})` : envKey
531
604
  const e = new Error(
532
605
  `[config] could not save secret to the OS keychain: ${err && err.message ? err.message : err}\n` +
533
- ` No usable keychain backend on this host (common on WSL / headless Linux without libsecret).\n` +
606
+ ` No usable keychain provider on this host (common on WSL / headless Linux without libsecret).\n` +
534
607
  ` Set the ${envHint} environment variable instead — the runtime reads it directly.`
535
608
  )
536
609
  e.cause = err
@@ -6,7 +6,7 @@
6
6
  * (`makeAgentDispatch({ taskType })`) and, for memory maintenance specifically,
7
7
  * through `maintenance-llm.mjs`'s thin wrapper.
8
8
  *
9
- * Only preset resolution remains here: memory-cycle and future backend
9
+ * Only preset resolution remains here: memory-cycle and future provider
10
10
  * callers still need a consistent way to map `(task, agent-config)` to a
11
11
  * preset id.
12
12
  */
@@ -0,0 +1,134 @@
1
+ import { createFairCallScheduler } from '../../standalone/fair-call-scheduler.mjs';
2
+ import { currentToolExecutionOwner } from './tool-execution-owner.mjs';
3
+
4
+ function positiveInt(value, fallback) {
5
+ const parsed = Math.floor(Number(value));
6
+ return Number.isFinite(parsed) && parsed >= 1 ? parsed : fallback;
7
+ }
8
+
9
+ function waitTimeoutError(name, waitTimeoutMs) {
10
+ const error = new Error(`${name} admission wait exceeded ${waitTimeoutMs}ms; retry after running work completes`);
11
+ error.code = 'EADMISSIONWAITTIMEOUT';
12
+ error.statusCode = 503;
13
+ error.waitTimeoutMs = waitTimeoutMs;
14
+ return error;
15
+ }
16
+
17
+ /**
18
+ * Owner-fair bounded work gate. The underlying scheduler lets a lone owner
19
+ * borrow all capacity, then rotates contending session owners and rebalances a
20
+ * full borrowed queue so a late interactive session is not trapped behind one
21
+ * session's entire fan-out.
22
+ */
23
+ export function createOwnerFairGate({
24
+ name = 'work',
25
+ activeMax = 8,
26
+ queueMax = 1024,
27
+ minOwnerQueue = 8,
28
+ waitTimeoutMs = 30_000,
29
+ schedule = setImmediate,
30
+ now = Date.now,
31
+ } = {}) {
32
+ const defaultWaitTimeoutMs = Math.max(0, Math.floor(Number(waitTimeoutMs) || 0));
33
+ const scheduler = createFairCallScheduler({
34
+ name,
35
+ activeMax,
36
+ queueMax,
37
+ minOwnerQueue,
38
+ dispatchBurst: Math.max(1, Math.min(8, positiveInt(activeMax, 8))),
39
+ schedule,
40
+ now,
41
+ });
42
+ const stats = {
43
+ admitted: 0,
44
+ rejected: 0,
45
+ timedOut: 0,
46
+ totalWaitMs: 0,
47
+ maxWaitMs: 0,
48
+ };
49
+
50
+ function run(ownerKey, task, {
51
+ signal = null,
52
+ weight = 1,
53
+ waitTimeoutMs: callWaitTimeoutMs = defaultWaitTimeoutMs,
54
+ onAdmit = null,
55
+ } = {}) {
56
+ if (typeof task !== 'function') return Promise.reject(new TypeError(`${name} task must be a function`));
57
+ const owner = String(ownerKey || currentToolExecutionOwner() || 'anonymous').trim().slice(0, 240) || 'anonymous';
58
+ const queuedAt = now();
59
+ const timeoutMs = Math.max(0, Math.floor(Number(callWaitTimeoutMs) || 0));
60
+ const controller = (signal || timeoutMs > 0) ? new AbortController() : null;
61
+ let timer = null;
62
+ let started = false;
63
+ let timeoutError = null;
64
+ let onAbort = null;
65
+
66
+ const cleanupQueueWait = () => {
67
+ if (timer) {
68
+ clearTimeout(timer);
69
+ timer = null;
70
+ }
71
+ if (onAbort && signal) {
72
+ try { signal.removeEventListener('abort', onAbort); } catch {}
73
+ onAbort = null;
74
+ }
75
+ };
76
+
77
+ if (controller && signal) {
78
+ onAbort = () => {
79
+ if (!controller.signal.aborted) controller.abort(signal.reason);
80
+ };
81
+ if (signal.aborted) onAbort();
82
+ else signal.addEventListener('abort', onAbort, { once: true });
83
+ }
84
+ if (controller && timeoutMs > 0 && !controller.signal.aborted) {
85
+ timer = setTimeout(() => {
86
+ if (started || controller.signal.aborted) return;
87
+ timeoutError = waitTimeoutError(name, timeoutMs);
88
+ controller.abort(timeoutError);
89
+ }, timeoutMs);
90
+ }
91
+
92
+ const promise = scheduler.enqueue(owner, async () => {
93
+ started = true;
94
+ cleanupQueueWait();
95
+ const waitedMs = Math.max(0, now() - queuedAt);
96
+ stats.admitted += 1;
97
+ stats.totalWaitMs += waitedMs;
98
+ stats.maxWaitMs = Math.max(stats.maxWaitMs, waitedMs);
99
+ try { onAdmit?.(waitedMs); } catch {}
100
+ return task();
101
+ }, {
102
+ weight,
103
+ signal: controller?.signal || signal,
104
+ });
105
+
106
+ return promise.catch((error) => {
107
+ if (!started) {
108
+ stats.rejected += 1;
109
+ if (timeoutError && error === timeoutError) stats.timedOut += 1;
110
+ }
111
+ throw error;
112
+ }).finally(cleanupQueueWait);
113
+ }
114
+
115
+ function snapshot() {
116
+ const base = scheduler.snapshot();
117
+ return {
118
+ ...base,
119
+ admitted: stats.admitted,
120
+ rejected: stats.rejected,
121
+ timedOut: stats.timedOut,
122
+ averageWaitMs: stats.admitted > 0 ? Math.round(stats.totalWaitMs / stats.admitted) : 0,
123
+ maxWaitMs: stats.maxWaitMs,
124
+ };
125
+ }
126
+
127
+ return {
128
+ run,
129
+ close: (reason) => scheduler.close(reason),
130
+ snapshot,
131
+ get active() { return scheduler.active; },
132
+ get queued() { return scheduler.queued; },
133
+ };
134
+ }