mixdog 0.9.147 → 0.9.148

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 (255) hide show
  1. package/package.json +23 -3
  2. package/scripts/build-voice-runtime-unix.sh +101 -0
  3. package/scripts/embedding-quality-probe.mjs +96 -0
  4. package/scripts/generate-voice-runtime-manifest.mjs +300 -0
  5. package/scripts/generate-voice-runtime-manifest.test.mjs +135 -0
  6. package/scripts/native-binary-arch.mjs +30 -13
  7. package/scripts/native-binary-arch.test.mjs +40 -4
  8. package/scripts/plan-voice-runtime-release.mjs +73 -0
  9. package/scripts/prepare-native-assets.mjs +50 -9
  10. package/scripts/prune-embedding-runtime.mjs +3 -3
  11. package/scripts/sync-voice-runtime-manifest.mjs +46 -0
  12. package/scripts/verify-release-assets.mjs +106 -6
  13. package/scripts/verify-voice-runtime-release.mjs +89 -0
  14. package/scripts/voice-runtime-config.json +52 -0
  15. package/src/app.mjs +42 -5
  16. package/src/headless-exec.mjs +58 -32
  17. package/src/headless-exec.test.mjs +74 -13
  18. package/src/rules/lead/01-general.md +2 -0
  19. package/src/rules/shared/10-tool-workflow.md +7 -4
  20. package/src/rules/shared/30-exploration.md +2 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +28 -9
  22. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +6 -1
  23. package/src/runtime/agent/orchestrator/config.mjs +53 -18
  24. package/src/runtime/agent/orchestrator/mcp/cancellation.test.mjs +139 -0
  25. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +34 -5
  26. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -10
  27. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +5 -0
  28. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +98 -0
  29. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.test.mjs +90 -0
  30. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +67 -16
  31. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +4 -82
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +0 -5
  33. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -121
  34. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +48 -0
  35. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.test.mjs +215 -0
  36. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +44 -48
  37. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +54 -31
  38. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +56 -2
  39. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +34 -2
  40. package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +61 -1
  41. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +6 -1
  42. package/src/runtime/agent/orchestrator/providers/gemini.mjs +61 -15
  43. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +120 -1
  44. package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +9 -0
  45. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +0 -3
  46. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +187 -84
  47. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +36 -36
  48. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +16 -6
  49. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +50 -213
  50. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +135 -126
  51. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +17 -11
  52. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +20 -7
  53. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +11 -0
  54. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +132 -42
  55. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -2
  56. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +28 -10
  57. package/src/runtime/agent/orchestrator/providers/registry.mjs +122 -95
  58. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +58 -3
  59. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +4 -22
  60. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +7 -14
  61. package/src/runtime/agent/orchestrator/session/compact-policy.test.mjs +128 -0
  62. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +5 -0
  63. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +27 -48
  64. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +19 -14
  65. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -43
  66. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +5 -1
  67. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -2
  68. package/src/runtime/agent/orchestrator/session/manager/pending-messages-persistence.test.mjs +325 -0
  69. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +599 -195
  70. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +22 -0
  71. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +9 -2
  72. package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
  73. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +26 -16
  74. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +23 -11
  75. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +118 -3
  76. package/src/runtime/agent/orchestrator/session/store-summary-reader.test.mjs +332 -0
  77. package/src/runtime/agent/orchestrator/session/store.mjs +38 -1
  78. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +27 -6
  79. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -20
  80. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +291 -128
  81. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +45 -8
  82. package/src/runtime/agent/orchestrator/tools/builtin/edit-encoding-eol.test.mjs +224 -0
  83. package/src/runtime/agent/orchestrator/tools/builtin/edit-sequential-occupation.test.mjs +53 -3
  84. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +255 -25
  85. package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +87 -0
  86. package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +1 -1
  87. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +132 -9
  88. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +10 -0
  89. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +29 -3
  90. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +1 -1
  91. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +178 -29
  92. package/src/runtime/agent/orchestrator/tools/builtin/local-search-telemetry.mjs +4 -1
  93. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +32 -5
  94. package/src/runtime/agent/orchestrator/tools/builtin/native-search-health.test.mjs +5 -2
  95. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +10 -1
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +38 -10
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +12 -0
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +31 -1
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +11 -1
  100. package/src/runtime/agent/orchestrator/tools/builtin/read-windows.mjs +22 -4
  101. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +6 -8
  102. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +25 -24
  103. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +152 -133
  104. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +114 -42
  105. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +129 -15
  106. package/src/runtime/agent/orchestrator/tools/code-graph/loose-file-target.test.mjs +72 -0
  107. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +7 -8
  108. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +4 -4
  109. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +201 -19
  110. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +26 -3
  111. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +10 -1
  112. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-search-scope.test.mjs +55 -0
  113. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +115 -25
  114. package/src/runtime/agent/orchestrator/tools/code-graph/text-span-mask.test.mjs +268 -0
  115. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -3
  116. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +296 -46
  117. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.test.mjs +104 -0
  118. package/src/runtime/agent/orchestrator/tools/lib/shell-descendants.mjs +136 -0
  119. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +127 -38
  120. package/src/runtime/agent/orchestrator/tools/patch/engine-contract-gate.test.mjs +661 -0
  121. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +350 -9
  122. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +418 -44
  123. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +27 -3
  124. package/src/runtime/agent/orchestrator/tools/patch/patch-codec-eol.test.mjs +552 -0
  125. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +65 -15
  126. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  127. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +284 -109
  128. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +51 -21
  129. package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +44 -27
  130. package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +6 -1
  131. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  132. package/src/runtime/attachments/store.mjs +11 -3
  133. package/src/runtime/channels/data/voice-runtime-manifest.json +46 -70
  134. package/src/runtime/channels/lib/event-queue.mjs +51 -41
  135. package/src/runtime/channels/lib/scheduler.mjs +23 -28
  136. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -17
  137. package/src/runtime/channels/lib/voice-runtime-fetcher.test.mjs +17 -0
  138. package/src/runtime/channels/lib/webhook.mjs +39 -20
  139. package/src/runtime/channels/lib/whisper-server.mjs +46 -1
  140. package/src/runtime/channels/lib/worker-main.mjs +3 -1
  141. package/src/runtime/media/adapters/gemini-video.mjs +20 -4
  142. package/src/runtime/media/jobs.mjs +13 -0
  143. package/src/runtime/media/renditions.mjs +7 -1
  144. package/src/runtime/media/store.mjs +42 -22
  145. package/src/runtime/memory/index.mjs +4 -9
  146. package/src/runtime/memory/lib/agent-ipc.mjs +1 -6
  147. package/src/runtime/memory/lib/archive-security.test.mjs +21 -1
  148. package/src/runtime/memory/lib/core-memory-store.mjs +28 -50
  149. package/src/runtime/memory/lib/embedding-model-config.mjs +37 -1
  150. package/src/runtime/memory/lib/embedding-model-config.test.mjs +33 -0
  151. package/src/runtime/memory/lib/embedding-provider.mjs +34 -12
  152. package/src/runtime/memory/lib/embedding-worker.mjs +37 -5
  153. package/src/runtime/memory/lib/ko-morph.mjs +46 -15
  154. package/src/runtime/memory/lib/memory-action-handlers.mjs +18 -1
  155. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -18
  156. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +1 -18
  157. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +98 -45
  158. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +71 -0
  159. package/src/runtime/memory/lib/memory-cycle2-shared.test.mjs +78 -0
  160. package/src/runtime/memory/lib/memory-cycle2.mjs +37 -3
  161. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  162. package/src/runtime/memory/lib/memory-embedding-migration.test.mjs +50 -0
  163. package/src/runtime/memory/lib/memory-recall-store.mjs +9 -24
  164. package/src/runtime/memory/lib/memory.mjs +50 -12
  165. package/src/runtime/memory/lib/pg/process.mjs +1 -10
  166. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -9
  167. package/src/runtime/memory/lib/query-handlers.mjs +2 -4
  168. package/src/runtime/memory/lib/session-ingest-runtime.mjs +2 -2
  169. package/src/runtime/shared/background-tasks.mjs +15 -0
  170. package/src/runtime/shared/background-tasks.test.mjs +78 -0
  171. package/src/runtime/shared/child-guardian.mjs +2 -10
  172. package/src/runtime/shared/pid-liveness.mjs +27 -0
  173. package/src/runtime/shared/runtime-root.mjs +1 -11
  174. package/src/runtime/shared/service-discovery.mjs +1 -17
  175. package/src/runtime/shared/singleton-owner.mjs +1 -11
  176. package/src/runtime/shared/staged-update.mjs +2 -12
  177. package/src/runtime/shared/tool-status.mjs +5 -1
  178. package/src/runtime/shared/tool-surface.mjs +15 -7
  179. package/src/runtime/shared/turn-snapshot-store.mjs +168 -0
  180. package/src/runtime/shared/turn-snapshot.mjs +137 -13
  181. package/src/runtime/shared/turn-worktree-snapshot.mjs +110 -18
  182. package/src/runtime/shared/webhook-session-run.mjs +16 -2
  183. package/src/runtime/web-search/lib/http-fetch.mjs +34 -4
  184. package/src/runtime/web-search/lib/state.mjs +15 -15
  185. package/src/runtime/web-search/lib/web-tools.mjs +46 -5
  186. package/src/session-runtime/context-status.mjs +4 -12
  187. package/src/session-runtime/runtime-core.mjs +20 -1
  188. package/src/standalone/agent-dispatch-broker.mjs +33 -2
  189. package/src/standalone/agent-tool/job-task-reconcile.mjs +78 -0
  190. package/src/standalone/agent-tool/notify.mjs +3 -1
  191. package/src/standalone/agent-tool/spawn-flow.mjs +48 -66
  192. package/src/standalone/agent-tool/tag-registry.mjs +116 -33
  193. package/src/standalone/agent-tool/tool-def.mjs +2 -2
  194. package/src/standalone/agent-tool/worker-index.mjs +8 -11
  195. package/src/standalone/agent-tool/worker-rows.mjs +4 -1
  196. package/src/standalone/agent-tool.mjs +20 -55
  197. package/src/standalone/agent-watchdog-registry.mjs +28 -4
  198. package/src/standalone/channel-client.mjs +47 -15
  199. package/src/standalone/channel-transport.mjs +44 -25
  200. package/src/standalone/daemon.mjs +31 -2
  201. package/src/standalone/hook-bus/config.mjs +25 -9
  202. package/src/standalone/hook-bus/handlers.mjs +16 -52
  203. package/src/standalone/hook-bus.mjs +29 -23
  204. package/src/standalone/memory-runtime-proxy.mjs +1 -16
  205. package/src/standalone/session-client.mjs +86 -23
  206. package/src/standalone/session-protocol.mjs +0 -2
  207. package/src/standalone/session-runtime-host-health.test.mjs +5 -4
  208. package/src/standalone/session-runtime-host.mjs +101 -6
  209. package/src/standalone/session-runtime-record.mjs +1 -5
  210. package/src/standalone/session-runtime-worker.mjs +52 -57
  211. package/src/standalone/session-service.mjs +4 -1
  212. package/src/standalone/session-transport.mjs +56 -25
  213. package/src/tui/App.jsx +40 -49
  214. package/src/tui/app/app-view.jsx +14 -21
  215. package/src/tui/app/core-memory-picker.mjs +29 -16
  216. package/src/tui/app/create-app-pickers.mjs +15 -36
  217. package/src/tui/app/extension-pickers.mjs +60 -49
  218. package/src/tui/app/maintenance-pickers.mjs +100 -57
  219. package/src/tui/app/message-selector.mjs +6 -5
  220. package/src/tui/app/model-picker.mjs +30 -13
  221. package/src/tui/app/onboarding-steps.mjs +21 -12
  222. package/src/tui/app/panel-epoch.mjs +56 -0
  223. package/src/tui/app/panel-epoch.test.mjs +1009 -0
  224. package/src/tui/app/panel-surface.mjs +98 -0
  225. package/src/tui/app/project-picker.mjs +30 -29
  226. package/src/tui/app/prompt-submit.mjs +192 -97
  227. package/src/tui/app/prompt-submit.test.mjs +206 -0
  228. package/src/tui/app/provider-setup-picker.mjs +74 -43
  229. package/src/tui/app/resume-picker.mjs +11 -8
  230. package/src/tui/app/route-pickers.mjs +45 -28
  231. package/src/tui/app/settings-picker.mjs +69 -18
  232. package/src/tui/app/shell-layout.mjs +1 -3
  233. package/src/tui/app/slash-commands.mjs +0 -2
  234. package/src/tui/app/slash-dispatch.mjs +4 -2
  235. package/src/tui/app/text-entry-policy.mjs +25 -0
  236. package/src/tui/app/theme-effort-pickers.mjs +11 -16
  237. package/src/tui/app/usage-context-panels.mjs +21 -25
  238. package/src/tui/app/use-global-key-input.mjs +3 -2
  239. package/src/tui/app/use-prompt-draft-flow.mjs +6 -18
  240. package/src/tui/app/use-prompt-handlers.mjs +3 -2
  241. package/src/tui/app/use-welcome-prompt-hint.mjs +17 -3
  242. package/src/tui/components/Picker.jsx +1 -1
  243. package/src/tui/components/TextEntryPanel.jsx +7 -2
  244. package/src/tui/dist/index.mjs +652 -561
  245. package/src/tui/lib/voice-setup.mjs +1 -2
  246. package/src/tui/session/agent-job-feed.mjs +73 -11
  247. package/src/tui/session/session-api-ext.mjs +5 -6
  248. package/src/tui/session/session-api.mjs +1 -0
  249. package/src/tui/session/tool-approval.mjs +15 -5
  250. package/src/tui/session/tui-steering-persist.mjs +33 -19
  251. package/src/tui/session/tui-steering-persist.test.mjs +104 -0
  252. package/src/tui/session-local.mjs +0 -22
  253. package/scripts/.tmp-coverage-report.mjs +0 -96
  254. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.mjs +0 -57
  255. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.test.mjs +0 -57
@@ -36,6 +36,29 @@ function finiteInt(value, { min, max, def }) {
36
36
  if (i > max) return max;
37
37
  return i;
38
38
  }
39
+ // Belt-and-suspenders ownership guard shared by both tick paths: if this
40
+ // process is not the active owner, do nothing. The runtime should only have
41
+ // started this queue on the owner path, but an ownership hand-off can briefly
42
+ // leave two processes both ticking — this short-circuits that window. This is
43
+ // multi-process active-owner gating, not HTTP authentication.
44
+ // Fail closed: a probe exception must NOT be treated as ownership.
45
+ // Duplicate-owner queue ticks are exactly what this guard prevents.
46
+ // Module-scope on purpose: both tick paths run this exact guard, never a
47
+ // dispatched override.
48
+ function ownerTickAllowed(queue, what) {
49
+ if (!queue.ownerGetter) return true;
50
+ let isOwner = false;
51
+ try { isOwner = !!queue.ownerGetter(); } catch { isOwner = false; }
52
+ if (!isOwner) {
53
+ if (!queue.ownerSkipLogged) {
54
+ logEvent(`queue: skipping ${what} — not owner`);
55
+ queue.ownerSkipLogged = true;
56
+ }
57
+ return false;
58
+ }
59
+ queue.ownerSkipLogged = false;
60
+ return true;
61
+ }
39
62
  class EventQueue {
40
63
  config;
41
64
  channelId;
@@ -127,25 +150,7 @@ class EventQueue {
127
150
  if (this._processQueueRunning) return;
128
151
  this._processQueueRunning = true;
129
152
  try {
130
- // Belt-and-suspenders ownership guard: if this process is not the
131
- // active owner, do nothing. The runtime should only have started this
132
- // queue on the owner path, but an ownership hand-off can briefly leave
133
- // two processes both ticking — this short-circuits that window.
134
- // This is multi-process active-owner gating, not HTTP authentication.
135
- if (this.ownerGetter) {
136
- // Fail closed: a probe exception must NOT be treated as ownership.
137
- // Duplicate-owner queue ticks are exactly what this guard prevents.
138
- let isOwner = false;
139
- try { isOwner = !!this.ownerGetter(); } catch { isOwner = false; }
140
- if (!isOwner) {
141
- if (!this.ownerSkipLogged) {
142
- logEvent("queue: skipping tick — not owner");
143
- this.ownerSkipLogged = true;
144
- }
145
- return;
146
- }
147
- this.ownerSkipLogged = false;
148
- }
153
+ if (!ownerTickAllowed(this, "tick")) return;
149
154
  const files = this.readQueueFiles();
150
155
  if (files.length === 0) return;
151
156
  for (const file of files) {
@@ -167,24 +172,7 @@ class EventQueue {
167
172
  }
168
173
  }
169
174
  processBatch() {
170
- // Belt-and-suspenders ownership guard: if this process is not the
171
- // active owner, do nothing. The runtime should only have started this
172
- // queue on the owner path, but an ownership hand-off can briefly leave
173
- // two processes both ticking — this short-circuits that window.
174
- // This is multi-process active-owner gating, not HTTP authentication.
175
- if (this.ownerGetter) {
176
- // Fail closed: a probe exception must NOT be treated as ownership.
177
- let isOwner = false;
178
- try { isOwner = !!this.ownerGetter(); } catch { isOwner = false; }
179
- if (!isOwner) {
180
- if (!this.ownerSkipLogged) {
181
- logEvent("queue: skipping batch tick — not owner");
182
- this.ownerSkipLogged = true;
183
- }
184
- return;
185
- }
186
- this.ownerSkipLogged = false;
187
- }
175
+ if (!ownerTickAllowed(this, "batch tick")) return;
188
176
  const files = this.readQueueFiles();
189
177
  const lowFiles = files.filter((f) => {
190
178
  const item = this.readItem(f);
@@ -312,14 +300,36 @@ ${p.item.prompt}`).join("\n\n")}`;
312
300
  }
313
301
  }
314
302
  readItem(file) {
303
+ let raw;
315
304
  try {
316
- return JSON.parse(readFileSync(join(QUEUE_DIR, file), "utf8"));
305
+ raw = readFileSync(join(QUEUE_DIR, file), "utf8");
317
306
  } catch (err) {
318
- if (err.code !== "ENOENT") {
319
- logEvent(`queue: corrupt file ${file}`);
320
- }
307
+ if (err?.code === "ENOENT") return null;
308
+ // An IO fault (EACCES/EBUSY/EMFILE/EIO…) says nothing about the record
309
+ // itself — the bytes may be perfectly valid. Leave it queued for the next
310
+ // tick instead of dead-lettering a live event.
311
+ logEvent(`queue: read failed for ${file}: ${err?.message ?? err}`);
312
+ return null;
313
+ }
314
+ let item;
315
+ try {
316
+ item = JSON.parse(raw);
317
+ } catch (err) {
318
+ // Corrupt bytes can never execute, and leaving them in queue/ made every
319
+ // tick re-read the same poison file forever. Quarantine under processed/
320
+ // so the payload stays inspectable and the queue moves on.
321
+ logEvent(`queue: corrupt file ${file} — quarantined: ${err?.message ?? err}`);
322
+ this.moveToProcessed(file, "corrupt");
323
+ return null;
324
+ }
325
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
326
+ // Parses, but is not an event record (null / scalar / array): the same
327
+ // dead end as corrupt bytes, so it is quarantined rather than retried.
328
+ logEvent(`queue: invalid record ${file} — quarantined`);
329
+ this.moveToProcessed(file, "corrupt");
321
330
  return null;
322
331
  }
332
+ return item;
323
333
  }
324
334
  // Atomically rename from queue/ to in-progress/. Returns the new
325
335
  // filename on success, or null if another worker already claimed it /
@@ -87,6 +87,27 @@ function validateCronExpression(time) {
87
87
  }
88
88
  if (!valid) throw new Error(`invalid cron expression "${time}": failed node-cron validation. Legacy formats (HH:MM, everyNm, hourly, daily) are no longer supported — use a cron expression instead.`);
89
89
  }
90
+ // Scheduler teardown: stop ticking, destroy the cron jobs and release the
91
+ // scheduler lock so a subsequent start() in the same process can re-acquire it.
92
+ // Without the release, the wx-create in start() hits its own live lock
93
+ // (matching INSTANCE_UUID + recent mtime) and refuses to register cron jobs,
94
+ // leaving the scheduler silently idle after a reload/restart cycle.
95
+ // Read-verify-then-unlink so we don't delete another live owner's lock file
96
+ // (mirrors memory releaseLock and the exit handler). Module-scope on purpose:
97
+ // stop() and restart() both run this exact teardown, never a dispatched
98
+ // override.
99
+ function releaseSchedulerRuntime(scheduler) {
100
+ if (scheduler.tickTimer) {
101
+ clearInterval(scheduler.tickTimer);
102
+ scheduler.tickTimer = null;
103
+ }
104
+ scheduler.destroyCronJobs();
105
+ try {
106
+ const content = readFileSync(Scheduler.SCHEDULER_LOCK, "utf8");
107
+ const lockedPid = parseInt(content.split("\n")[0]);
108
+ if (lockedPid === process.pid) unlinkSync(Scheduler.SCHEDULER_LOCK);
109
+ } catch {}
110
+ }
90
111
  class Scheduler {
91
112
  nonInteractive;
92
113
  interactive;
@@ -417,22 +438,7 @@ ${Scheduler.INSTANCE_UUID}`;
417
438
  }
418
439
  }
419
440
  stop() {
420
- if (this.tickTimer) {
421
- clearInterval(this.tickTimer);
422
- this.tickTimer = null;
423
- }
424
- this.destroyCronJobs();
425
- // Release the scheduler lock so a subsequent start() in the same
426
- // process can re-acquire it. Without this, the wx-create in start()
427
- // hits its own live lock (matching INSTANCE_UUID + recent mtime) and
428
- // refuses to register cron jobs, leaving the scheduler silently idle
429
- // after a reload/restart cycle. Read-verify-then-unlink so we don't
430
- // delete another live owner's lock file (mirrors memory releaseLock).
431
- try {
432
- const content = readFileSync(Scheduler.SCHEDULER_LOCK, "utf8");
433
- const lockedPid = parseInt(content.split("\n")[0]);
434
- if (lockedPid === process.pid) unlinkSync(Scheduler.SCHEDULER_LOCK);
435
- } catch {}
441
+ releaseSchedulerRuntime(this);
436
442
  }
437
443
  destroyCronJobs() {
438
444
  for (const [, task] of this.cronJobs) {
@@ -542,18 +548,7 @@ ${Scheduler.INSTANCE_UUID}`;
542
548
  }
543
549
  }
544
550
  restart() {
545
- if (this.tickTimer) {
546
- clearInterval(this.tickTimer);
547
- this.tickTimer = null;
548
- }
549
- this.destroyCronJobs();
550
- // Read-verify-then-unlink so a non-owner reload can't delete the
551
- // live owner's lock file (mirrors stop() and the exit handler).
552
- try {
553
- const content = readFileSync(Scheduler.SCHEDULER_LOCK, "utf8");
554
- const lockedPid = parseInt(content.split("\n")[0]);
555
- if (lockedPid === process.pid) unlinkSync(Scheduler.SCHEDULER_LOCK);
556
- } catch {}
551
+ releaseSchedulerRuntime(this);
557
552
  this.start();
558
553
  }
559
554
  reloadConfig(nonInteractive, interactive, channelId, options = {}) {
@@ -40,22 +40,10 @@ import { createGunzip } from 'zlib'
40
40
  import { renameWithRetrySync, writeFileAtomicSync } from '../../shared/atomic-file.mjs'
41
41
  import { streamResponseToFile } from '../../shared/bounded-download.mjs'
42
42
  import { windowsProgramRoots, windowsSystemRoot } from '../../agent/orchestrator/tools/builtin/windows-roots.mjs'
43
- import { detectDeviceLanguage, normalizeWhisperLanguage } from './whisper-language.mjs'
44
-
45
- // Model catalog: `models.standard` / `models.korean` in the manifest, with the
46
- // legacy single `model` section as the standard fallback for older manifests.
47
- // Selection: explicit voice.model config wins; 'auto' picks the Korean
48
- // fine-tune only when the device language resolves to Korean.
49
- export function selectVoiceModelId(voiceConfig = null) {
50
- const pref = String(voiceConfig?.model || 'auto').trim().toLowerCase()
51
- if (pref === 'standard' || pref === 'korean') return pref
52
- // voice.language is the strongest auto signal: Node's ICU default locale
53
- // often resolves to en-US even on a Korean Windows install, so an explicit
54
- // transcription language wins over device-locale detection.
55
- const configured = normalizeWhisperLanguage(voiceConfig?.language)
56
- if (configured === 'ko') return 'korean'
57
- if (configured) return 'standard'
58
- return detectDeviceLanguage() === 'ko' ? 'korean' : 'standard'
43
+ // The standard multilingual model is the only managed model. Legacy
44
+ // voice.model values are accepted by callers but converge here.
45
+ export function selectVoiceModelId() {
46
+ return 'standard'
59
47
  }
60
48
 
61
49
  function manifestModelEntry(manifest, modelId = 'standard') {
@@ -624,7 +612,8 @@ function resolveManagedWhisperModelForId(manifest, dataDir, modelId) {
624
612
  }
625
613
 
626
614
  // Clean-install policy: the models dir holds ONLY filenames declared by the
627
- // current manifest (standard/korean). Anything else — the pre-Q8 F16 weight,
615
+ // current manifest. Anything else — the pre-Q8 F16 weight or retired
616
+ // fine-tunes,
628
617
  // interrupted renames, hand-copied experiments — is deleted so a model swap
629
618
  // never leaves a 1.6GB orphan behind and resolution never silently falls back
630
619
  // to a stale weight.
@@ -0,0 +1,17 @@
1
+ import assert from 'node:assert/strict'
2
+ import { readFile } from 'node:fs/promises'
3
+ import test from 'node:test'
4
+
5
+ import { selectVoiceModelId } from './voice-runtime-fetcher.mjs'
6
+
7
+ test('managed voice always uses the standard multilingual model', () => {
8
+ assert.equal(selectVoiceModelId(), 'standard')
9
+ assert.equal(selectVoiceModelId({ model: 'korean', language: 'ko' }), 'standard')
10
+ })
11
+
12
+ test('voice manifest contains no language-specific model', async () => {
13
+ const manifestPath = new URL('../data/voice-runtime-manifest.json', import.meta.url)
14
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
15
+ assert.deepEqual(Object.keys(manifest.models), ['standard'])
16
+ assert.equal(manifest.models.standard.id, 'large-v3-turbo-q8_0')
17
+ })
@@ -9,6 +9,7 @@ import {
9
9
  } from "../../shared/webhooks-db.mjs";
10
10
  import {
11
11
  contentDeliveryId,
12
+ extractDeliveryId,
12
13
  buildHeadersSummary,
13
14
  } from "./webhook/deliveries.mjs";
14
15
  import { resolveHookRelayUrl, startHookTunnel } from "./webhook/relay-tunnel.mjs";
@@ -170,10 +171,17 @@ class WebhookServer {
170
171
  // Signature has accepted the raw bytes; decode to a UTF-8 string for
171
172
  // content-type / JSON / preview handling below.
172
173
  const body = rawBody.length === 0 ? "" : rawBody.toString("utf8");
173
- // Signature schemes authenticate the body, not delivery-id headers.
174
- // Claim by body digest so a captured valid body/signature cannot bypass
175
- // dedup merely by changing x-request-id or x-github-delivery.
176
- deliveryId = contentDeliveryId(rawBody);
174
+ // Claim identity = the sender's own delivery id BOUND to the digest of
175
+ // the authenticated body. Keying on the digest alone made dedup permanent
176
+ // per content: two legitimately distinct deliveries with an identical
177
+ // payload (a repeated ping, a re-run of the same job) were suppressed
178
+ // forever. Binding the id to the body still makes a replay of a captured
179
+ // body under its original delivery id a duplicate.
180
+ const bodyDigestId = contentDeliveryId(rawBody);
181
+ const sourceDeliveryId = extractDeliveryId(headers);
182
+ deliveryId = sourceDeliveryId
183
+ ? `${String(sourceDeliveryId).slice(0, 120)}:${bodyDigestId}`
184
+ : bodyDigestId;
177
185
  // Atomic claim + dedup in one step: INSERT ... ON CONFLICT DO NOTHING.
178
186
  // A concurrent duplicate POST of the same id loses the race
179
187
  // (claimed:false) and is rejected flat, so the first run is never
@@ -418,10 +426,16 @@ ${payload}
418
426
  // for the lifetime of the process and dedup keeps re-running
419
427
  // forever. 10 minutes covers the slowest handler run we ship.
420
428
  const DISPATCH_TIMEOUT_MS = 10 * 60 * 1000;
421
- let timeoutHandle = null;
429
+ // Racing a bare timer against the dispatch promise reported failure while
430
+ // the run itself kept going: the delivery row was already `failed` when the
431
+ // late result arrived and flipped it again. The timeout now aborts the
432
+ // dispatch through a signal the dispatcher receives, and the first outcome
433
+ // to land is the only one that writes a terminal status.
434
+ const controller = new AbortController();
422
435
  const dispatchP = Promise.resolve(this.bridgeDispatch({
423
436
  model: model || null,
424
437
  prompt: fullPrompt,
438
+ signal: controller.signal,
425
439
  // Endpoint-scoped project/workflow (New-task parity): the webhook row's
426
440
  // cwd/workflow define the created session, not the worker's global cwd.
427
441
  cwd: extra.cwd || this.config?.cwd || null,
@@ -435,21 +449,26 @@ ${payload}
435
449
  event: headers["x-github-event"] || null,
436
450
  },
437
451
  }));
438
- const timeoutP = new Promise((_, reject) => {
439
- timeoutHandle = setTimeout(
440
- () => reject(new Error(`bridge dispatch timed out after ${DISPATCH_TIMEOUT_MS}ms`)),
441
- DISPATCH_TIMEOUT_MS,
442
- );
443
- });
444
- Promise.race([dispatchP, timeoutP]).then(() => {
445
- if (timeoutHandle) clearTimeout(timeoutHandle);
446
- void updateDeliveryStatus(name, deliveryId, "done").catch((e) => logWebhook(`${name}: delivery status update failed: ${e?.message || e}`));
447
- logWebhook(`${name}: webhook session run dispatched (id=${deliveryId})`);
448
- }).catch((err) => {
449
- if (timeoutHandle) clearTimeout(timeoutHandle);
450
- void updateDeliveryStatus(name, deliveryId, "failed", { error: String(err?.message || err) }).catch(() => {});
451
- logWebhook(`${name}: webhook session run failed: ${err?.message || err}`);
452
- });
452
+ let settled = false;
453
+ const timeoutHandle = setTimeout(
454
+ () => controller.abort(new Error(`bridge dispatch timed out after ${DISPATCH_TIMEOUT_MS}ms`)),
455
+ DISPATCH_TIMEOUT_MS,
456
+ );
457
+ const finish = (status, fields, message) => {
458
+ if (settled) return;
459
+ settled = true;
460
+ clearTimeout(timeoutHandle);
461
+ void updateDeliveryStatus(name, deliveryId, status, fields).catch((e) => logWebhook(`${name}: delivery status update failed: ${e?.message || e}`));
462
+ logWebhook(message);
463
+ };
464
+ controller.signal.addEventListener("abort", () => {
465
+ const reason = controller.signal.reason;
466
+ finish("failed", { error: String(reason?.message || reason) }, `${name}: webhook session run aborted: ${reason?.message || reason}`);
467
+ }, { once: true });
468
+ dispatchP.then(
469
+ () => finish("done", {}, `${name}: webhook session run dispatched (id=${deliveryId})`),
470
+ (err) => finish("failed", { error: String(err?.message || err) }, `${name}: webhook session run failed: ${err?.message || err}`),
471
+ );
453
472
  res.writeHead(202, { "Content-Type": "application/json" });
454
473
  res.end(JSON.stringify({ status: "accepted", handler: "session", id: deliveryId }));
455
474
  }
@@ -53,6 +53,16 @@ const READY_POLL_MS = 250; // TCP probe cadence
53
53
  const PROBE_CONNECT_MS = 1_000; // per TCP-connect attempt
54
54
  const STOP_GRACE_MS = 5_000; // SIGTERM → SIGKILL escalation window
55
55
  const INFERENCE_PATH = '/inference'; // whisper-server default --inference-path
56
+ // Idle release. Without it ONE transcription pins the loaded model for the whole
57
+ // host-process lifetime (measured: ~325 MB working set, ~2.1 GB commit with the
58
+ // CUDA build) even when voice is never used again. Reclaim after an idle window;
59
+ // the next ensureReady() rebuilds the SAME contract, so the only cost is one
60
+ // model load. Mirrors the embedding worker's 5-minute window.
61
+ // MIXDOG_WHISPER_IDLE_TIMEOUT_MS overrides; 0 disables the release entirely.
62
+ const IDLE_TIMEOUT_MS = (() => {
63
+ const raw = Number(process.env.MIXDOG_WHISPER_IDLE_TIMEOUT_MS);
64
+ return Number.isFinite(raw) && raw >= 0 ? raw : 300_000;
65
+ })();
56
66
 
57
67
  const STATE = Object.freeze({
58
68
  STOPPED: 'STOPPED',
@@ -73,6 +83,7 @@ const mgr = {
73
83
  startPromise: null, // in-flight ensureReady() start
74
84
  inflight: new Set(), // active transcribe AbortControllers
75
85
  logTail: '', // recent stdout/stderr for diagnostics
86
+ idleTimer: null, // armed only while READY with nothing in flight
76
87
  };
77
88
 
78
89
  function runtimeKeyOf({ serverCmd, modelPath, threadCount, host }) {
@@ -244,9 +255,35 @@ function detachChildHandlers() {
244
255
  try { mgr.child.stderr?.removeAllListeners(); } catch {}
245
256
  }
246
257
 
258
+ function clearIdleTimer() {
259
+ if (!mgr.idleTimer) return;
260
+ clearTimeout(mgr.idleTimer);
261
+ mgr.idleTimer = null;
262
+ }
263
+
264
+ // Arm ONLY from a resting READY state with nothing in flight, and re-check that
265
+ // same condition when the timer fires: a transcription accepted in between must
266
+ // never have the model pulled out from under it. The timer is unref'd — an idle
267
+ // whisper-server can never keep its host process alive.
268
+ function armIdleTimer() {
269
+ clearIdleTimer();
270
+ if (!IDLE_TIMEOUT_MS) return;
271
+ if (mgr.state !== STATE.READY || !mgr.child || mgr.inflight.size > 0) return;
272
+ mgr.idleTimer = setTimeout(() => {
273
+ mgr.idleTimer = null;
274
+ if (mgr.state !== STATE.READY || !mgr.child || mgr.inflight.size > 0) return;
275
+ mgr.state = STATE.STOPPING;
276
+ void teardown('idle-timeout')
277
+ .catch(() => {})
278
+ .finally(() => { mgr.state = STATE.STOPPED; });
279
+ }, IDLE_TIMEOUT_MS);
280
+ mgr.idleTimer.unref?.();
281
+ }
282
+
247
283
  // Deterministic teardown of the current child. Used both for STOPPING and for a
248
284
  // contract change. Escalates SIGTERM → SIGKILL after a grace window.
249
285
  async function teardown(reason) {
286
+ clearIdleTimer();
250
287
  const child = mgr.child;
251
288
  const pid = child?.pid;
252
289
  // Abort all in-flight transcribe requests first.
@@ -386,9 +423,11 @@ export async function ensureReady({ serverCmd, modelPath, threadCount, host = '1
386
423
  }
387
424
  const contract = { serverCmd, modelPath, threadCount, host };
388
425
  const key = runtimeKeyOf(contract);
426
+ clearIdleTimer();
389
427
 
390
428
  // Already READY on the same contract with a live child → done.
391
429
  if (mgr.state === STATE.READY && mgr.runtimeKey === key && mgr.child) {
430
+ armIdleTimer();
392
431
  return;
393
432
  }
394
433
  // A start is already in flight for the SAME contract → await it.
@@ -402,7 +441,11 @@ export async function ensureReady({ serverCmd, modelPath, threadCount, host = '1
402
441
  mgr.state = STATE.STOPPED;
403
442
  }
404
443
 
405
- mgr.startPromise = startServer(contract).finally(() => { mgr.startPromise = null; });
444
+ mgr.startPromise = startServer(contract).finally(() => {
445
+ mgr.startPromise = null;
446
+ // A start that no transcription ever follows must not pin the model either.
447
+ armIdleTimer();
448
+ });
406
449
  return mgr.startPromise;
407
450
  }
408
451
 
@@ -418,6 +461,7 @@ export async function transcribe(wavPath, { language } = {}) {
418
461
  const host = mgr.host;
419
462
  const port = mgr.port;
420
463
  const ctrl = new AbortController();
464
+ clearIdleTimer();
421
465
  mgr.inflight.add(ctrl);
422
466
  try {
423
467
  const data = await fs.promises.readFile(wavPath);
@@ -463,6 +507,7 @@ export async function transcribe(wavPath, { language } = {}) {
463
507
  throw err;
464
508
  } finally {
465
509
  mgr.inflight.delete(ctrl);
510
+ armIdleTimer();
466
511
  }
467
512
  }
468
513
 
@@ -264,7 +264,7 @@ function wireWebhookHandlers() {
264
264
  webhookServer.setEventPipeline(eventPipeline);
265
265
  // Webhook fires run as sessions (schedules parity); the Automations
266
266
  // session row is the only surface (channel relay retired).
267
- webhookServer.setBridgeDispatch(async ({ prompt, model, cwd, workflow, attachments, delivery, context }) => {
267
+ webhookServer.setBridgeDispatch(async ({ prompt, model, cwd, workflow, attachments, delivery, context, signal }) => {
268
268
  const { runWebhookSession } = await import("../../shared/webhook-session-run.mjs");
269
269
  const run = await runWebhookSession({
270
270
  name: context?.endpoint || "webhook",
@@ -273,6 +273,8 @@ function wireWebhookHandlers() {
273
273
  workflow: workflow || null,
274
274
  attachments: attachments || null,
275
275
  delivery: delivery || null,
276
+ // Dispatch-timeout cancellation only works if the signal reaches the run.
277
+ signal: signal || null,
276
278
  prompt,
277
279
  });
278
280
  return run;
@@ -16,6 +16,14 @@ const BASE_URL = 'https://generativelanguage.googleapis.com/v1beta';
16
16
  const OMNI_TIMEOUT_MS = 600_000;
17
17
  const POLL_INTERVAL_MS = 8_000;
18
18
  const TOTAL_TIMEOUT_MS = 900_000;
19
+ // Submission and each poll need their own ceiling: an unbounded request keeps
20
+ // the 15-minute budget from ever starting (or ever ending).
21
+ const REQUEST_TIMEOUT_MS = 60_000;
22
+
23
+ function boundedSignal(signal, deadline, capMs = REQUEST_TIMEOUT_MS) {
24
+ const remaining = Math.max(1, deadline - Date.now());
25
+ return AbortSignal.any([signal, AbortSignal.timeout(Math.min(capMs, remaining))].filter(Boolean));
26
+ }
19
27
 
20
28
  function isOmniModel(model) {
21
29
  return /omni/i.test(String(model || ''));
@@ -75,22 +83,27 @@ async function generateViaVeo({ model, prompt, options, references = [], signal,
75
83
  const duration = Math.trunc(Number(options?.duration) || 0);
76
84
  if ([4, 6, 8].includes(duration)) parameters.durationSeconds = duration;
77
85
 
86
+ // The budget starts at submission, not after it: a hung predictLongRunning
87
+ // POST used to run without any ceiling of its own.
88
+ const deadline = Date.now() + TOTAL_TIMEOUT_MS;
78
89
  const started = await fetch(`${BASE_URL}/models/${encodeURIComponent(model)}:predictLongRunning`, {
79
90
  method: 'POST',
80
91
  headers,
81
92
  body: JSON.stringify({ instances: [instance], parameters }),
82
- signal,
93
+ signal: boundedSignal(signal, deadline),
83
94
  });
84
95
  if (!started.ok) throw upstreamError('Veo video', started.status, await started.text().catch(() => ''));
85
96
  const operation = await started.json();
86
97
  if (!operation?.name) throw mediaError('Veo returned no operation name', 'MEDIA_UPSTREAM_FAILED', 502);
87
98
 
88
- const deadline = Date.now() + TOTAL_TIMEOUT_MS;
89
99
  for (;;) {
90
100
  if (signal?.aborted) throw mediaError('canceled', 'MEDIA_CANCELED', 499);
91
101
  if (Date.now() > deadline) throw mediaError('Veo poll budget exceeded', 'MEDIA_TIMEOUT', 504);
92
102
  await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
93
- const poll = await fetch(`${BASE_URL}/${operation.name}`, { headers, signal });
103
+ const poll = await fetch(`${BASE_URL}/${operation.name}`, {
104
+ headers,
105
+ signal: boundedSignal(signal, deadline),
106
+ });
94
107
  if (!poll.ok) throw upstreamError('Veo poll', poll.status, await poll.text().catch(() => ''));
95
108
  const data = await poll.json();
96
109
  if (!data?.done) {
@@ -107,7 +120,10 @@ async function generateViaVeo({ model, prompt, options, references = [], signal,
107
120
  const uri = sample?.video?.uri || sample?.video?.fileUri;
108
121
  if (!uri) throw mediaError('Veo finished without a video URI', 'MEDIA_EMPTY_RESULT', 502);
109
122
  return {
110
- bytes: await downloadGeminiMedia(uri, { key, signal }),
123
+ // The download is part of the generation budget: unbounded, it held an
124
+ // active job slot (and the upstream connection) open indefinitely after
125
+ // the poll loop finished.
126
+ bytes: await downloadGeminiMedia(uri, { key, signal: boundedSignal(signal, deadline, TOTAL_TIMEOUT_MS) }),
111
127
  mime: 'video/mp4',
112
128
  };
113
129
  }
@@ -16,6 +16,10 @@ const JOBS = new Map();
16
16
  // terminal state, then drop out to bound memory.
17
17
  const TERMINAL_TTL_MS = 10 * 60_000;
18
18
  const MAX_PROMPT_CHARS = 8_000;
19
+ // Video lanes hold an upstream slot for minutes each. Without a ceiling a
20
+ // caller can start unbounded concurrent generations, each pinning provider
21
+ // quota and a poll loop for its whole budget.
22
+ const MAX_ACTIVE_JOBS = Math.max(1, Number(process.env.MIXDOG_MEDIA_MAX_ACTIVE_JOBS) || 4);
19
23
 
20
24
  function snapshot(job) {
21
25
  return {
@@ -84,6 +88,15 @@ export function startMediaJob({ lane: laneId, kind, model, prompt, options = {},
84
88
  .slice(0, maxRefs)
85
89
  .map((ref) => ({ base64: ref.base64, mime: String(ref.mime || 'image/png') }));
86
90
  sweep();
91
+ let active = 0;
92
+ for (const entry of JOBS.values()) if (entry.status === 'running') active += 1;
93
+ if (active >= MAX_ACTIVE_JOBS) {
94
+ throw mediaError(
95
+ `too many media generations are already running (${active}/${MAX_ACTIVE_JOBS})`,
96
+ 'MEDIA_TOO_MANY_JOBS',
97
+ 429,
98
+ );
99
+ }
87
100
 
88
101
  const controller = new AbortController();
89
102
  const job = {
@@ -90,7 +90,13 @@ function renditionPath(cacheDir, variant, id, extension) {
90
90
  function cachedRendition(cacheDir, variant, id) {
91
91
  for (const [extension, mime] of Object.entries(EXTENSION_MIME)) {
92
92
  const path = renditionPath(cacheDir, variant, id, extension);
93
- if (existsSync(path)) return { path, mime, bytes: statSync(path).size };
93
+ // stat IS the existence check: a separate existsSync leaves a window in
94
+ // which concurrent pruning unlinks the file between the two calls, turning
95
+ // a plain cache miss into a thrown media request.
96
+ try {
97
+ const info = statSync(path);
98
+ if (info.isFile()) return { path, mime, bytes: info.size };
99
+ } catch { /* absent or evicted mid-lookup */ }
94
100
  }
95
101
  return null;
96
102
  }