mixdog 1.0.12 → 1.1.1

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 (356) hide show
  1. package/README.md +83 -182
  2. package/package.json +4 -2
  3. package/scripts/build-shipped-history.mjs +131 -0
  4. package/scripts/latest-guard.mjs +63 -0
  5. package/scripts/lib/run-node-tests.mjs +330 -32
  6. package/scripts/lib/test-failure-records.mjs +5 -0
  7. package/scripts/lib/test-failure-summary.mjs +14 -3
  8. package/scripts/lib/test-spawn-ledger.mjs +19 -0
  9. package/scripts/merge-mac-update-feed.mjs +107 -0
  10. package/scripts/mobile-release.mjs +210 -0
  11. package/scripts/post-release-check.mjs +353 -0
  12. package/scripts/prune-embedding-runtime.mjs +9 -5
  13. package/scripts/publish-asset-release.mjs +43 -0
  14. package/scripts/release-guard.mjs +63 -0
  15. package/scripts/test-direct.mjs +16 -1
  16. package/scripts/test.mjs +29 -7
  17. package/src/agents/advisor/AGENT.md +38 -0
  18. package/src/agents/advisor/agent.json +6 -0
  19. package/src/agents/reviewer/AGENT.md +5 -2
  20. package/src/agents/reviewer/agent.json +1 -1
  21. package/src/defaults/shipped-history.json +383 -0
  22. package/src/defaults/skills/computer-use/SKILL.md +1 -1
  23. package/src/defaults/skills/goal-management/SKILL.md +11 -5
  24. package/src/defaults/skills/setup/SKILL.md +5 -8
  25. package/src/defaults/skills/setup/references/actions.md +62 -31
  26. package/src/defaults/skills/setup/references/surfaces.md +12 -10
  27. package/src/lib/keychain-cjs.cjs +582 -17
  28. package/src/lib/rules-builder.cjs +1 -1
  29. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +3 -1
  30. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +4 -0
  31. package/src/runtime/agent/orchestrator/config-presets.d.mts +1 -0
  32. package/src/runtime/agent/orchestrator/config-presets.mjs +44 -0
  33. package/src/runtime/agent/orchestrator/config-providers-merge.mjs +9 -1
  34. package/src/runtime/agent/orchestrator/config-storage.mjs +32 -4
  35. package/src/runtime/agent/orchestrator/config.mjs +120 -21
  36. package/src/runtime/agent/orchestrator/context/instruction-files.mjs +130 -0
  37. package/src/runtime/agent/orchestrator/context/role-instructions.mjs +4 -0
  38. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +77 -26
  39. package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +44 -14
  40. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +2 -1
  41. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +11 -2
  42. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-login.mjs +2 -0
  43. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +3 -3
  44. package/src/runtime/agent/orchestrator/providers/custom-provider-config.mjs +388 -0
  45. package/src/runtime/agent/orchestrator/providers/custom-provider.mjs +92 -109
  46. package/src/runtime/agent/orchestrator/providers/custom-reasoning.mjs +107 -0
  47. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +51 -9
  48. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +2 -1
  49. package/src/runtime/agent/orchestrator/providers/lib/oauth-token-utils.mjs +11 -0
  50. package/src/runtime/agent/orchestrator/providers/lib/open-login-url.mjs +9 -0
  51. package/src/runtime/agent/orchestrator/providers/mimo-oauth-login.mjs +254 -0
  52. package/src/runtime/agent/orchestrator/providers/mimo-oauth-profile.mjs +218 -0
  53. package/src/runtime/agent/orchestrator/providers/mimo-oauth-tokens.mjs +239 -0
  54. package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +233 -0
  55. package/src/runtime/agent/orchestrator/providers/muse-code-oauth-login.mjs +247 -0
  56. package/src/runtime/agent/orchestrator/providers/muse-code-oauth-tokens.mjs +225 -0
  57. package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +542 -0
  58. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +25 -0
  59. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +18 -10
  60. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +13 -0
  61. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +51 -11
  62. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +6 -1
  63. package/src/runtime/agent/orchestrator/providers/openai-compat-models.mjs +7 -5
  64. package/src/runtime/agent/orchestrator/providers/openai-compat-options.mjs +12 -0
  65. package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +37 -9
  66. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +12 -2
  67. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +6 -1
  68. package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog-store.mjs +99 -0
  69. package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog.mjs +4 -8
  70. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +3 -7
  71. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +8 -0
  72. package/src/runtime/agent/orchestrator/providers/quota-reading.mjs +70 -0
  73. package/src/runtime/agent/orchestrator/providers/registry.mjs +42 -3
  74. package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +19 -35
  75. package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +7 -0
  76. package/src/runtime/agent/orchestrator/runtime-core/goal-mutations.mjs +0 -17
  77. package/src/runtime/agent/orchestrator/runtime-core/goal-state.mjs +0 -3
  78. package/src/runtime/agent/orchestrator/runtime-core/goal-store.mjs +0 -1
  79. package/src/runtime/agent/orchestrator/runtime-core/goal-text.mjs +1 -1
  80. package/src/runtime/agent/orchestrator/runtime-core/goal-tool-defs.mjs +16 -2
  81. package/src/runtime/agent/orchestrator/runtime-core/goal-tool-exec.mjs +1 -2
  82. package/src/runtime/agent/orchestrator/runtime-core/goal-turns.mjs +18 -4
  83. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +7 -1
  84. package/src/runtime/agent/orchestrator/runtime-core/session-route-policy.mjs +16 -0
  85. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +2 -0
  86. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +2 -2
  87. package/src/runtime/agent/orchestrator/runtime-core/workflow/context.mjs +7 -7
  88. package/src/runtime/agent/orchestrator/runtime-core/workflow.mjs +6 -0
  89. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +11 -1
  90. package/src/runtime/agent/orchestrator/session/loop/step-auto-effort.mjs +28 -3
  91. package/src/runtime/agent/orchestrator/session/loop/tool-call-ids.mjs +90 -0
  92. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +19 -11
  93. package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +1 -0
  94. package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +24 -11
  95. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +2 -6
  96. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +28 -0
  97. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +63 -3
  98. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +8 -0
  99. package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +3 -0
  100. package/src/runtime/agent/orchestrator/session/manager/session-tool-surface.mjs +4 -4
  101. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +3 -0
  102. package/src/runtime/agent/orchestrator/session/tool-batch/finalize.mjs +2 -0
  103. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +3 -0
  104. package/src/runtime/agent/orchestrator/session/transcript-restore/tool-result-status.mjs +23 -0
  105. package/src/runtime/agent/orchestrator/session/turn-effort.mjs +18 -0
  106. package/src/runtime/agent/orchestrator/stall-policy.mjs +0 -1
  107. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +11 -8
  108. package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +27 -12
  109. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/bashism-rewrite.mjs +245 -0
  110. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/command-preflight.mjs +13 -1
  111. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/completed-result.mjs +3 -1
  112. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/invocation-plan.mjs +2 -2
  113. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/result-format.mjs +49 -0
  114. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +3 -3
  115. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -7
  116. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +19 -20
  117. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +67 -19
  118. package/src/runtime/agent/orchestrator/tools/builtin/file-version-test-helpers.mjs +11 -0
  119. package/src/runtime/agent/orchestrator/tools/builtin/file-version.mjs +13 -0
  120. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +28 -3
  121. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-single-file-rescue.mjs +8 -5
  122. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-task-output.mjs +25 -12
  123. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +73 -1
  124. package/src/runtime/agent/orchestrator/tools/builtin/raw-content-publication.mjs +17 -0
  125. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +136 -76
  126. package/src/runtime/agent/orchestrator/tools/builtin/read-single-guards.mjs +2 -2
  127. package/src/runtime/agent/orchestrator/tools/builtin/read-single-render.mjs +15 -17
  128. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -2
  129. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +11 -10
  130. package/src/runtime/agent/orchestrator/tools/builtin/read-source-windows.mjs +5 -3
  131. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +30 -16
  132. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +3 -1
  133. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +17 -6
  134. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +23 -5
  135. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +93 -43
  136. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-helpers.mjs +4 -11
  137. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +7 -3
  138. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-validation.mjs +16 -19
  139. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +32 -6
  140. package/src/runtime/agent/orchestrator/tools/builtin.mjs +9 -2
  141. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +3 -1
  142. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +13 -13
  143. package/src/runtime/agent/orchestrator/tools/lib/shell-run-background/promote-job.mjs +12 -15
  144. package/src/runtime/agent/orchestrator/tools/lib/shell-run-background.mjs +6 -3
  145. package/src/runtime/agent/orchestrator/tools/lib/shell-run-settle.mjs +14 -4
  146. package/src/runtime/agent/orchestrator/tools/lib/shell-run-state.mjs +4 -2
  147. package/src/runtime/agent/orchestrator/tools/patch/apply-patch/codex-batch.mjs +58 -8
  148. package/src/runtime/agent/orchestrator/tools/patch/content-guard.mjs +47 -0
  149. package/src/runtime/agent/orchestrator/tools/patch/delete-quarantine.mjs +155 -0
  150. package/src/runtime/agent/orchestrator/tools/patch/dispatch/native-proofs.mjs +6 -2
  151. package/src/runtime/agent/orchestrator/tools/patch/dispatch/native-snapshots.mjs +6 -2
  152. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +19 -16
  153. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +13 -44
  154. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  155. package/src/runtime/agent/orchestrator/tools/patch/post-patch-excerpts.mjs +5 -2
  156. package/src/runtime/agent/orchestrator/tools/patch/replay-capture.mjs +47 -7
  157. package/src/runtime/agent/orchestrator/tools/patch/rollback-state.mjs +72 -8
  158. package/src/runtime/agent/orchestrator/tools/patch/section-coalesce.mjs +3 -2
  159. package/src/runtime/agent/orchestrator/tools/patch/sequence/run.mjs +9 -1
  160. package/src/runtime/agent/orchestrator/tools/patch/sequence/units.mjs +9 -1
  161. package/src/runtime/agent/orchestrator/tools/patch/sequence.mjs +6 -6
  162. package/src/runtime/agent/orchestrator/tools/patch/ui-diff.mjs +16 -4
  163. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -14
  164. package/src/runtime/agent/orchestrator/tools/patch/wave.mjs +42 -4
  165. package/src/runtime/agent/orchestrator/tools/shell-codepage.mjs +296 -0
  166. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -81
  167. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +75 -24
  168. package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +19 -4
  169. package/src/runtime/channels/lib/config.mjs +3 -8
  170. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +7 -3
  171. package/src/{tui → runtime/channels}/lib/voice-setup.mjs +38 -22
  172. package/src/runtime/channels/lib/webhook/request-handler.mjs +42 -8
  173. package/src/runtime/computer-bridge/actions.mjs +2 -0
  174. package/src/runtime/computer-bridge/client.mjs +25 -1
  175. package/src/runtime/computer-bridge/linux-action-capabilities.mjs +84 -0
  176. package/src/runtime/computer-bridge/linux-readiness.d.mts +52 -0
  177. package/src/runtime/computer-bridge/linux-readiness.mjs +411 -0
  178. package/src/runtime/computer-bridge/test-linux-display.mjs +5 -0
  179. package/src/runtime/effort-judge/compact-tokenizer.mjs +13 -8
  180. package/src/runtime/effort-judge/judge-client.mjs +12 -3
  181. package/src/runtime/effort-judge/judge-input.mjs +5 -2
  182. package/src/runtime/effort-judge/model-install.mjs +9 -3
  183. package/src/runtime/effort-judge/model-manifest.json +8 -8
  184. package/src/runtime/effort-judge/previous-human-request.mjs +162 -0
  185. package/src/runtime/effort-judge/verified-context-input.mjs +10 -0
  186. package/src/runtime/memory/lib/embedding-provider.mjs +12 -0
  187. package/src/runtime/memory/lib/embedding-worker.mjs +9 -3
  188. package/src/runtime/memory/lib/http-router/session-routes.mjs +2 -1
  189. package/src/runtime/memory/lib/memory-config-flags.mjs +3 -2
  190. package/src/runtime/memory/lib/memory-embed.mjs +5 -0
  191. package/src/runtime/memory/lib/pg/supervisor.mjs +3 -0
  192. package/src/runtime/shared/agent-route-config.mjs +50 -2
  193. package/src/runtime/shared/bounded-file-read.mjs +166 -0
  194. package/src/runtime/shared/builtin-grandfathering.mjs +37 -0
  195. package/src/runtime/shared/config.mjs +74 -6
  196. package/src/runtime/shared/defaults-separation.mjs +263 -0
  197. package/src/runtime/shared/developer-options.mjs +7 -0
  198. package/src/runtime/shared/instruction-files-mode.mjs +9 -0
  199. package/src/runtime/shared/llm/anthropic-effort.mjs +4 -0
  200. package/src/runtime/shared/llm/model-catalog.mjs +25 -1
  201. package/src/runtime/shared/llm/openai-daybreak.mjs +79 -0
  202. package/src/runtime/shared/llm/usage-ledger-rollup.mjs +105 -1
  203. package/src/runtime/shared/llm/usage-ledger-worker.mjs +2 -1
  204. package/src/runtime/shared/llm/usage-ledger.mjs +72 -5
  205. package/src/runtime/shared/onnx-runtime-support.mjs +6 -0
  206. package/src/runtime/shared/orchestration.mjs +14 -6
  207. package/src/runtime/shared/pristine-execution-contract.json +9 -0
  208. package/src/runtime/shared/pristine-execution.mjs +1 -1
  209. package/src/runtime/shared/projects.mjs +65 -48
  210. package/src/runtime/shared/provider-accounts.mjs +49 -6
  211. package/src/runtime/shared/quota-resume-store.mjs +255 -0
  212. package/src/runtime/shared/resource-admission.mjs +10 -3
  213. package/src/runtime/shared/schedule-model-ref.mjs +11 -0
  214. package/src/runtime/shared/session-approval-wait.mjs +35 -0
  215. package/src/runtime/shared/shell-display.mjs +4 -2
  216. package/src/runtime/shared/shipped-definitions.mjs +275 -0
  217. package/src/runtime/shared/tool-card-model.golden.json +3 -3
  218. package/src/runtime/shared/tool-result-summary.mjs +86 -15
  219. package/src/runtime/shared/transcript-metadata.mjs +2 -0
  220. package/src/runtime/shared/turn-review-reply.mjs +2 -0
  221. package/src/runtime/shared/turn-snapshot.mjs +65 -6
  222. package/src/runtime/shared/turn-worktree-snapshot.mjs +208 -27
  223. package/src/session-runtime/boot/apis.mjs +24 -0
  224. package/src/session-runtime/boot/config.mjs +4 -0
  225. package/src/session-runtime/boot/facade.mjs +4 -1
  226. package/src/session-runtime/boot/infrastructure.mjs +21 -2
  227. package/src/session-runtime/boot/session.mjs +3 -0
  228. package/src/session-runtime/channel-config-api.mjs +5 -0
  229. package/src/session-runtime/config-lifecycle/config-writers.mjs +3 -0
  230. package/src/session-runtime/config-lifecycle.mjs +31 -6
  231. package/src/session-runtime/goal-facade-api.mjs +4 -0
  232. package/src/session-runtime/lifecycle/inheritance.mjs +28 -6
  233. package/src/session-runtime/lifecycle/session-switch/context-switch.mjs +3 -0
  234. package/src/session-runtime/lifecycle/session-switch/resume.mjs +7 -1
  235. package/src/session-runtime/lifecycle/session-switch/session-delete.mjs +6 -0
  236. package/src/session-runtime/lifecycle/teardown/session-close.mjs +14 -2
  237. package/src/session-runtime/media-api.mjs +32 -22
  238. package/src/session-runtime/model-recency.mjs +17 -0
  239. package/src/session-runtime/model-route/route-tuning.mjs +3 -5
  240. package/src/session-runtime/model-route/set-route.mjs +49 -21
  241. package/src/session-runtime/model-route-api.mjs +2 -1
  242. package/src/session-runtime/notification-bus.mjs +31 -6
  243. package/src/session-runtime/provider-auth/login.mjs +16 -10
  244. package/src/session-runtime/provider-auth/setup.mjs +22 -2
  245. package/src/session-runtime/provider-models/catalog-load.mjs +3 -1
  246. package/src/session-runtime/provider-models/row-hydration.mjs +26 -17
  247. package/src/session-runtime/provider-readiness.mjs +4 -0
  248. package/src/session-runtime/quick-model-rows/model-meta.mjs +26 -0
  249. package/src/session-runtime/quick-model-rows/provider-rows.mjs +40 -14
  250. package/src/session-runtime/resource-api.mjs +8 -1
  251. package/src/session-runtime/runtime-core.mjs +4 -0
  252. package/src/session-runtime/runtime-facade.mjs +25 -1
  253. package/src/session-runtime/runtime-review-api.mjs +12 -1
  254. package/src/session-runtime/schedule-session-run.mjs +3 -1
  255. package/src/session-runtime/services/agent-tool/helpers.mjs +4 -0
  256. package/src/session-runtime/services/agent-tool/index-write-queue.mjs +63 -27
  257. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +15 -3
  258. package/src/session-runtime/services/agent-tool/job-views/session-progress.mjs +2 -0
  259. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-prep.mjs +11 -3
  260. package/src/session-runtime/services/agent-tool/spawn-preset.mjs +2 -0
  261. package/src/session-runtime/services/agent-tool/worker-index/approval-wait.mjs +97 -0
  262. package/src/session-runtime/services/agent-tool/worker-index/mutation-batch.mjs +4 -2
  263. package/src/session-runtime/services/agent-tool/worker-index/row-liveness.mjs +8 -2
  264. package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +3 -0
  265. package/src/session-runtime/services/agent-tool/worker-index/row-store.mjs +58 -2
  266. package/src/session-runtime/services/agent-tool/worker-index.mjs +69 -15
  267. package/src/session-runtime/services/agent-tool/worker-rows.mjs +2 -0
  268. package/src/session-runtime/services/channel-admin.mjs +91 -15
  269. package/src/session-runtime/services/provider-admin.mjs +336 -80
  270. package/src/session-runtime/services/seeds.mjs +80 -152
  271. package/src/session-runtime/services/usage-dashboard-model.mjs +35 -0
  272. package/src/session-runtime/services/usage-dashboard.mjs +7 -0
  273. package/src/session-runtime/session-address.mjs +125 -0
  274. package/src/session-runtime/session-create.mjs +57 -2
  275. package/src/session-runtime/settings-builtin-tools-api.mjs +23 -0
  276. package/src/session-runtime/settings-compaction-api.mjs +13 -0
  277. package/src/session-runtime/setup-tool/executor.mjs +97 -40
  278. package/src/session-runtime/setup-tool/extended-actions.mjs +64 -5
  279. package/src/session-runtime/setup-tool/settings-contract.mjs +44 -1
  280. package/src/session-runtime/setup-tool/tool-defs.mjs +20 -7
  281. package/src/session-runtime/tool-policy-refresh.mjs +3 -0
  282. package/src/session-runtime/tool-surface.mjs +16 -9
  283. package/src/session-runtime/turn/session-ops.mjs +3 -0
  284. package/src/session-runtime/turn/share-ops.mjs +4 -1
  285. package/src/session-runtime/turn/turn-run/turn-prepare.mjs +6 -0
  286. package/src/session-runtime/usage-stats-api.mjs +15 -0
  287. package/src/session-runtime/webhook-session-run.mjs +3 -1
  288. package/src/session-runtime/workflow-agents-api/agent-editor.mjs +31 -5
  289. package/src/session-runtime/workflow-agents-api/agent-route.mjs +4 -0
  290. package/src/session-runtime/workflow-agents-api/workflow-packs.mjs +27 -7
  291. package/src/standalone/daemon-boot-coordinator.mjs +10 -0
  292. package/src/standalone/daemon-drain-policy.mjs +9 -0
  293. package/src/standalone/daemon-stored-session-views.mjs +7 -8
  294. package/src/standalone/daemon.mjs +21 -5
  295. package/src/standalone/local-session-runtime.mjs +11 -0
  296. package/src/standalone/session-client.mjs +52 -7
  297. package/src/standalone/session-protocol.mjs +9 -0
  298. package/src/standalone/session-service/agent-tree/agent-child.mjs +2 -0
  299. package/src/standalone/session-service/entries/lifecycle.mjs +2 -0
  300. package/src/standalone/session-service/entries/session-loads.mjs +9 -0
  301. package/src/standalone/session-service/projection/revisions.mjs +28 -4
  302. package/src/standalone/session-service/projection.mjs +1 -0
  303. package/src/standalone/session-service/quota-resume.mjs +140 -0
  304. package/src/standalone/session-service/session-calls/session-action.mjs +2 -2
  305. package/src/standalone/session-service/session-calls.mjs +10 -2
  306. package/src/standalone/session-service/stored-reader.mjs +26 -1
  307. package/src/standalone/session-service.mjs +21 -0
  308. package/src/standalone/session-transport/routes.mjs +2 -0
  309. package/src/tui/app/doctor.mjs +7 -11
  310. package/src/tui/app/maintenance-pickers/auto-clear-picker.mjs +3 -5
  311. package/src/tui/app/model-options.mjs +13 -1
  312. package/src/tui/app/model-picker/route-selection/fast-parameters.mjs +10 -4
  313. package/src/tui/app/provider-setup/oauth-actions.mjs +5 -0
  314. package/src/tui/app/settings-picker/settings-rows.mjs +4 -6
  315. package/src/tui/app/settings-picker/settings-toggles/store-toggles.mjs +1 -1
  316. package/src/tui/app/slash-dispatch/route-commands.mjs +3 -2
  317. package/src/tui/components/ContextPanel.jsx +5 -1
  318. package/src/tui/components/Spinner.jsx +34 -319
  319. package/src/tui/components/SpinnerByline.jsx +256 -0
  320. package/src/tui/components/use-spinner-progress.mjs +113 -0
  321. package/src/tui/dist/index.mjs +15421 -14283
  322. package/src/tui/session/agent-job-feed/execution-delivery.mjs +13 -1
  323. package/src/tui/session/agent-job-feed/notification-router.mjs +3 -0
  324. package/src/tui/session/agent-job-feed/pending-resume.mjs +14 -1
  325. package/src/tui/session/agent-job-feed.mjs +4 -0
  326. package/src/tui/session/context-state.mjs +15 -1
  327. package/src/tui/session/initial-state.mjs +6 -0
  328. package/src/tui/session/live-share/owner.mjs +17 -0
  329. package/src/tui/session/oauth-flows.mjs +2 -0
  330. package/src/tui/session/quota-resume.mjs +263 -0
  331. package/src/tui/session/session-api/intake/abort.mjs +39 -35
  332. package/src/tui/session/session-api/intake/submission.mjs +13 -21
  333. package/src/tui/session/session-api/intake.mjs +3 -3
  334. package/src/tui/session/session-api/integrations.mjs +13 -9
  335. package/src/tui/session/session-api/lifecycle/dispose.mjs +1 -0
  336. package/src/tui/session/session-api/lifecycle/inheritance.mjs +2 -2
  337. package/src/tui/session/session-api/lifecycle/session-reset.mjs +4 -0
  338. package/src/tui/session/session-api/lifecycle.mjs +71 -2
  339. package/src/tui/session/session-api/media.mjs +2 -2
  340. package/src/tui/session/session-api/quota-resume.mjs +21 -0
  341. package/src/tui/session/session-api/routes.mjs +4 -2
  342. package/src/tui/session/session-api/settings.mjs +2 -0
  343. package/src/tui/session/session-api.mjs +2 -0
  344. package/src/tui/session/session-flow/auto-clear.mjs +14 -6
  345. package/src/tui/session/session-flow/drain.mjs +52 -5
  346. package/src/tui/session/session-flow/queue/take-entries.mjs +2 -3
  347. package/src/tui/session/session-flow/queue.mjs +3 -0
  348. package/src/tui/session/session-flow/steering.mjs +11 -0
  349. package/src/tui/session/session-flow.mjs +1 -0
  350. package/src/tui/session/tool-approval.mjs +7 -2
  351. package/src/tui/session/tool-card-results/grouped-result.mjs +11 -2
  352. package/src/tui/session/transcript-intake.mjs +1 -0
  353. package/src/tui/session/transcript-spill/spill-dir.mjs +22 -6
  354. package/src/tui/session/turn.mjs +40 -3
  355. package/src/tui/spinner-meta.mjs +26 -10
  356. package/src/vendor/statusline/bin/statusline-route.mjs +3 -2
@@ -2,10 +2,12 @@ import { spawn } from 'node:child_process';
2
2
  import { appendFile, mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises';
3
3
  import { availableParallelism, tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { FAILURE_RECORDS_ENV } from './test-failure-records.mjs';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+ import { FAILURE_RECORDS_ENV, spawnLedgerPath } from './test-failure-records.mjs';
6
7
  import { classifyRerunOutcomes, formatFailureSummary } from './test-failure-summary.mjs';
7
8
 
8
9
  const SUMMARY_REPORTER = new URL('./test-summary-reporter.mjs', import.meta.url).href;
10
+ const SPAWN_LEDGER = new URL('./test-spawn-ledger.mjs', import.meta.url).href;
9
11
 
10
12
  // A whole-suite run passes ~700 paths to one node process. Windows caps a
11
13
  // command line at 32767 characters (CreateProcess) and POSIX caps argv+env at
@@ -68,30 +70,282 @@ const positiveInt = (value) => {
68
70
  // `node --test` defaults to availableParallelism()-1 file processes. When the
69
71
  // run is one of several concurrent shell commands (the Mixdog shell tool
70
72
  // exports the admission cap as MIXDOG_SHELL_CONCURRENCY_CAP), that multiplies
71
- // across peers, so each run takes its share of the cores. Precedence: an
73
+ // across peers, so a capped run leaves `cap` cores to them: cores - cap, never
74
+ // below 4. Dividing the cores by the cap left a 20-core machine with 2 file
75
+ // processes and tripled the wall time (desktop 289 s vs 63.5 s). Precedence: an
72
76
  // explicit --test-concurrency flag, then MIXDOG_TEST_CONCURRENCY, then the
73
- // share; with none of them Node's default stands and standalone runs are
74
- // unchanged. Returns the flag to add, or null.
77
+ // capped share; with none of them Node's default (cores-1) stands.
78
+ // Returns the flag to add, or null.
75
79
  export function testConcurrencyArg(nodeArgs, env = process.env, parallelism = availableParallelism()) {
76
- if (nodeArgs.some((arg) => arg === '--test-concurrency' || arg.startsWith('--test-concurrency='))) return null;
80
+ if (nodeArgs.some(concurrencyFlag)) return null;
77
81
  const explicit = positiveInt(env.MIXDOG_TEST_CONCURRENCY);
78
82
  if (explicit) return `--test-concurrency=${explicit}`;
79
83
  const cap = positiveInt(env.MIXDOG_SHELL_CONCURRENCY_CAP);
80
84
  if (!cap) return null;
81
- return `--test-concurrency=${Math.max(2, Math.floor(parallelism / cap))}`;
85
+ return `--test-concurrency=${Math.max(4, parallelism - cap)}`;
82
86
  }
83
87
 
84
- function spawnBatch(args, env) {
88
+ // Every test file process is bounded: node:test fails a file (and records it
89
+ // like any other failure) that is still running after --test-timeout, so a hang
90
+ // costs minutes instead of waiting for the batch watchdog. A --test-timeout
91
+ // flag wins, then MIXDOG_TEST_TIMEOUT_MS (0 turns the bound off), then the
92
+ // caller's default. Returns the flag to add, or null.
93
+ export function testTimeoutArg(nodeArgs, env = process.env, defaultMs = DEFAULT_TEST_TIMEOUT_MS) {
94
+ if (nodeArgs.some((arg) => arg === '--test-timeout' || arg.startsWith('--test-timeout='))) return null;
95
+ const raw = env.MIXDOG_TEST_TIMEOUT_MS;
96
+ if (raw !== undefined && String(raw).trim() === '0') return null;
97
+ const ms = positiveInt(raw) ?? defaultMs;
98
+ return ms ? `--test-timeout=${ms}` : null;
99
+ }
100
+
101
+ const concurrencyFlag = (arg) => arg === '--test-concurrency' || arg.startsWith('--test-concurrency=');
102
+
103
+ // The file-process count a run was given (flag or env), else Node's default.
104
+ function concurrencyOf(runArgs, fileArgs) {
105
+ const args = [...runArgs, ...fileArgs];
106
+ const index = args.findIndex(concurrencyFlag);
107
+ const value = index < 0 ? null : positiveInt(args[index].includes('=') ? args[index].split('=')[1] : args[index + 1]);
108
+ return value ?? Math.max(1, availableParallelism() - 1);
109
+ }
110
+
111
+ function withoutConcurrency(args) {
112
+ return args.filter((arg, i) => !concurrencyFlag(arg) && !(i > 0 && args[i - 1] === '--test-concurrency'));
113
+ }
114
+
115
+ // What keeps a test process off the signed-in user's machine state.
116
+ export function testIsolationEnv(scratchDir) {
117
+ return {
118
+ TEMP: scratchDir,
119
+ TMP: scratchDir,
120
+ TMPDIR: scratchDir,
121
+ // A fixture must never write into the signed-in app's data (~/.mixdog):
122
+ // schedule/webhook logs, design-library bindings and the like resolve the
123
+ // default data dir. The desktop preload already does this per process.
124
+ MIXDOG_DATA_DIR: join(scratchDir, 'data'),
125
+ // Any session a test composes would otherwise read the real home's
126
+ // AGENTS.md / CLAUDE.md whenever the stored setting is on; tests that
127
+ // exercise the feature clear this and pass their own home.
128
+ MIXDOG_BOOT_INSTRUCTION_FILES: '0',
129
+ };
130
+ }
131
+
132
+ export const DEFAULT_TEST_TIMEOUT_MS = 120_000;
133
+ export const LONG_TEST_TIMEOUT_MS = 600_000;
134
+
135
+ // A batch that has not exited by then is killed with its process tree and
136
+ // fails the run; MIXDOG_TEST_BATCH_TIMEOUT_MS overrides the default.
137
+ const DEFAULT_BATCH_TIMEOUT_MS = 60 * 60 * 1000;
138
+ const minutes = (ms) => Number((ms / 60_000).toFixed(2));
139
+
140
+ // Runs a helper command to completion and returns its stdout.
141
+ function capture(command, args) {
85
142
  return new Promise((resolve) => {
86
- const child = spawn(process.execPath, args, { stdio: 'inherit', env });
143
+ const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
144
+ let out = '';
145
+ child.stdout.setEncoding('utf8');
146
+ child.stdout.on('data', (chunk) => {
147
+ out += chunk;
148
+ });
149
+ child.on('error', (error) => {
150
+ console.error(`${command} failed: ${error.message}`);
151
+ resolve('');
152
+ });
153
+ child.on('close', () => resolve(out));
154
+ });
155
+ }
156
+
157
+ // Kills a process and every descendant still linked to it. On POSIX a batch
158
+ // leads its own process group (spawned detached), so the group goes with it.
159
+ async function killTree(pid) {
160
+ if (process.platform === 'win32') {
161
+ await capture('taskkill', ['/T', '/F', '/PID', String(pid)]);
162
+ return;
163
+ }
164
+ for (const target of [-pid, pid]) {
165
+ try {
166
+ process.kill(target, 'SIGKILL');
167
+ } catch {
168
+ /* already gone */
169
+ }
170
+ }
171
+ }
172
+
173
+ // Every live process: { pid, ppid, created (epoch ms), exe ('' when unknown) }.
174
+ // POSIX ps only reports elapsed seconds, so its creation times are ±1 s.
175
+ async function processSnapshot() {
176
+ if (process.platform === 'win32') {
177
+ const out = await capture('powershell', [
178
+ '-NoProfile',
179
+ '-NonInteractive',
180
+ '-Command',
181
+ "Get-CimInstance Win32_Process | Where-Object CreationDate | ForEach-Object { '{0} {1} {2} {3}' -f $_.ProcessId, $_.ParentProcessId, ([DateTimeOffset]$_.CreationDate).ToUnixTimeMilliseconds(), $_.ExecutablePath }",
182
+ ]);
183
+ return out
184
+ .split(/\r?\n/)
185
+ .filter(Boolean)
186
+ .map((line) => {
187
+ const [pid, ppid, created, ...exe] = line.split(' ');
188
+ return { pid: Number(pid), ppid: Number(ppid), created: Number(created), exe: exe.join(' ') };
189
+ });
190
+ }
191
+ const now = Date.now();
192
+ const out = await capture('ps', ['-A', '-o', 'pid=,ppid=,etime=']);
193
+ return out
194
+ .split('\n')
195
+ .map((line) => line.trim().split(/\s+/))
196
+ .filter((fields) => fields.length === 3)
197
+ .map(([pid, ppid, etime]) => {
198
+ // [[dd-]hh:]mm:ss
199
+ const [days, clock] = etime.includes('-') ? etime.split('-') : ['0', etime];
200
+ const seconds = clock.split(':').reduce((total, part) => total * 60 + Number(part), Number(days) * 86_400);
201
+ return { pid: Number(pid), ppid: Number(ppid), created: now - seconds * 1000, exe: '' };
202
+ });
203
+ }
204
+
205
+ const CLOCK_SLACK_MS = 2000;
206
+
207
+ // The processes this run owns, out of a snapshot: an anchor (the batch, or a
208
+ // child a test process recorded in its spawn ledger) still alive under its
209
+ // pid, the orphans an exited anchor left (they still name it as their parent;
210
+ // a later process that reuses its pid only has younger children), every
211
+ // descendant of those, and anything executing from the run's scratch root
212
+ // (e.g. the postgres the memory runtime downloads into MIXDOG_DATA_DIR).
213
+ // Command-line text never makes a process the run's: an editor or terminal
214
+ // opened on the scratch root is not.
215
+ export function ownedProcesses(snapshot, anchors, { batchStart, scratchDir, platform = process.platform }) {
216
+ const byPid = new Map(snapshot.map((entry) => [entry.pid, entry]));
217
+ const childrenOf = new Map();
218
+ for (const entry of snapshot) childrenOf.set(entry.ppid, [...(childrenOf.get(entry.ppid) ?? []), entry]);
219
+ const owned = new Map();
220
+ const queue = [];
221
+ const claim = (entry) => {
222
+ if (entry.pid === process.pid || owned.has(entry.pid)) return;
223
+ owned.set(entry.pid, entry);
224
+ queue.push(entry);
225
+ };
226
+ for (const { pid, since } of anchors) {
227
+ const holder = byPid.get(pid);
228
+ if (holder && holder.created >= batchStart - CLOCK_SLACK_MS && holder.created <= since + CLOCK_SLACK_MS) {
229
+ claim(holder);
230
+ continue;
231
+ }
232
+ for (const orphan of childrenOf.get(pid) ?? []) {
233
+ if (orphan.created >= since - CLOCK_SLACK_MS && (!holder || orphan.created < holder.created)) claim(orphan);
234
+ }
235
+ }
236
+ const fold = (path) => (platform === 'win32' ? path.toLowerCase() : path);
237
+ // An empty scratchDir skips the sweep: batches running side by side share
238
+ // one scratch root, so one batch's exit must not claim its peers' processes.
239
+ const scratchPrefix = scratchDir ? fold(join(scratchDir, '/')) : null;
240
+ if (scratchPrefix)
241
+ for (const entry of snapshot) if (entry.exe && fold(entry.exe).startsWith(scratchPrefix)) claim(entry);
242
+ while (queue.length > 0) {
243
+ const parent = queue.pop();
244
+ for (const entry of childrenOf.get(parent.pid) ?? []) {
245
+ if (entry.created >= parent.created - CLOCK_SLACK_MS) claim(entry);
246
+ }
247
+ }
248
+ return [...owned.values()];
249
+ }
250
+
251
+ async function readSpawnLedger(path) {
252
+ const text = await readFile(path, 'utf8').catch(() => '');
253
+ return text
254
+ .split('\n')
255
+ .filter(Boolean)
256
+ .map((line) => {
257
+ const [pid, since] = line.split(' ').map(Number);
258
+ return { pid, since };
259
+ });
260
+ }
261
+
262
+ // A process a test leaves running must not outlive its batch: it can hold a
263
+ // port, a database, or files in the scratch root the run removes at the end.
264
+ async function reapLeftovers(child, { batchStart, ledger, scratchDir }) {
265
+ // The batch itself has exited: only its process group can remain.
266
+ if (process.platform !== 'win32' && child.pid) {
267
+ try {
268
+ process.kill(-child.pid, 'SIGKILL');
269
+ } catch {
270
+ /* the group is empty */
271
+ }
272
+ }
273
+ const anchors = [...(child.pid ? [{ pid: child.pid, since: batchStart }] : []), ...(await readSpawnLedger(ledger))];
274
+ await killOwned(anchors, { batchStart, scratchDir });
275
+ }
276
+
277
+ async function killOwned(anchors, { batchStart, scratchDir }) {
278
+ const leftovers = ownedProcesses(await processSnapshot(), anchors, { batchStart, scratchDir });
279
+ if (leftovers.length === 0) return;
280
+ const pids = leftovers.map((entry) => entry.pid);
281
+ console.error(`Killing ${pids.length} process(es) a test left running: ${pids.join(' ')}`);
282
+ if (process.platform === 'win32') {
283
+ await capture('taskkill', ['/T', '/F', ...pids.flatMap((pid) => ['/PID', String(pid)])]);
284
+ return;
285
+ }
286
+ for (const pid of pids) {
287
+ try {
288
+ process.kill(pid, 'SIGKILL');
289
+ } catch {
290
+ /* already gone */
291
+ }
292
+ }
293
+ }
294
+
295
+ // Resolves { code, hung } once the batch exited and its leftovers are gone.
296
+ // `scratchDir` '' = leave the scratch-root sweep to the caller (side-by-side batches).
297
+ function spawnBatch(args, env, { files, ledger, scratchDir, timeoutMs }) {
298
+ return new Promise((resolve) => {
299
+ const batchStart = Date.now();
300
+ const child = spawn(process.execPath, args, { stdio: 'inherit', env, detached: process.platform !== 'win32' });
301
+ let hung = '';
302
+ const watchdog = setTimeout(() => {
303
+ hung = `hung after ${minutes(timeoutMs)} min: ${files.join(' ') || '(discovered files)'}`;
304
+ console.error(`Test batch ${hung}; killing its process tree`);
305
+ void killTree(child.pid);
306
+ }, timeoutMs);
307
+ // A detached POSIX batch no longer gets the terminal's signals itself.
308
+ const forward = (signal) => {
309
+ try {
310
+ process.kill(-child.pid, signal);
311
+ } catch {
312
+ /* already gone */
313
+ }
314
+ };
315
+ const signals = process.platform === 'win32' ? [] : ['SIGINT', 'SIGTERM'];
316
+ for (const signal of signals) process.on(signal, forward);
317
+ const settle = async (code) => {
318
+ clearTimeout(watchdog);
319
+ for (const signal of signals) process.off(signal, forward);
320
+ await reapLeftovers(child, { batchStart, ledger, scratchDir });
321
+ resolve({ code: hung ? 1 : code, hung });
322
+ };
87
323
  child.on('error', (error) => {
88
324
  console.error(error);
89
- resolve(1);
325
+ void settle(1);
90
326
  });
91
- child.on('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)));
327
+ child.on('exit', (code, signal) => void settle(code ?? (signal ? 1 : 0)));
92
328
  });
93
329
  }
94
330
 
331
+ // rm's own maxRetries re-walks the tree on every nested retry, so one held
332
+ // file a few directories down multiplied its delay into hours. Each pass here
333
+ // is a single bounded walk.
334
+ async function removeScratch(dir) {
335
+ for (let attempt = 1; ; attempt += 1) {
336
+ try {
337
+ await rm(dir, { recursive: true, force: true });
338
+ return;
339
+ } catch (error) {
340
+ if (attempt === 5) {
341
+ console.error(`Test scratch left behind (${error.code}): ${dir}`);
342
+ return;
343
+ }
344
+ await delay(200 * attempt);
345
+ }
346
+ }
347
+ }
348
+
95
349
  // The failure reporter writes one JSON line per failed test; a spawn that
96
350
  // died before Node opened its destination leaves no file at all.
97
351
  async function readFailureRecords(path) {
@@ -116,10 +370,12 @@ async function readFailureRecords(path) {
116
370
  // flag was passed, does not fail the run; a test that fails every re-run
117
371
  // still exits 1. Every run ends with the failure summary block, so a filtered
118
372
  // log can still name what failed.
373
+ //
374
+ // `testTimeoutMs` is the per-file --test-timeout default (see testTimeoutArg).
119
375
  export async function runNodeTests(
120
376
  nodeArgs,
121
377
  fileArgs,
122
- { argBudget = ARG_BUDGET, coverage = false, rerunFailed = 0 } = {}
378
+ { argBudget = ARG_BUDGET, coverage = false, rerunFailed = 0, testTimeoutMs = DEFAULT_TEST_TIMEOUT_MS } = {}
123
379
  ) {
124
380
  await pruneStaleTestLogs(tmpdir());
125
381
  const logPath = join(await mkdtemp(join(tmpdir(), TEST_LOG_PREFIX)), 'full.log');
@@ -133,9 +389,13 @@ export async function runNodeTests(
133
389
  const scratchDir = await mkdtemp(join(tmpdir(), 'mixdog-test-scratch-'));
134
390
  // The explicit entry also accepts Node options alongside its file paths.
135
391
  const concurrencyArg = testConcurrencyArg([...nodeArgs, ...fileArgs]);
392
+ const timeoutArg = testTimeoutArg([...nodeArgs, ...fileArgs], process.env, testTimeoutMs);
136
393
  const runArgs = [
137
394
  ...nodeArgs,
138
395
  ...(concurrencyArg ? [concurrencyArg] : []),
396
+ ...(timeoutArg ? [timeoutArg] : []),
397
+ // Node's test runner passes --import on to every test file process.
398
+ `--import=${SPAWN_LEDGER}`,
139
399
  `--test-reporter=${SUMMARY_REPORTER}`,
140
400
  '--test-reporter-destination=stdout',
141
401
  '--test-reporter=spec',
@@ -144,8 +404,19 @@ export async function runNodeTests(
144
404
  argCost(process.execPath) +
145
405
  runArgs.reduce((total, arg) => total + argCost(arg), 0) +
146
406
  argCost(`--test-reporter-destination=${logPath}`) +
147
- argCost('.999'); // the per-batch log suffix
407
+ argCost('.999') + // the per-batch log suffix
408
+ argCost('--test-concurrency=999'); // the per-batch share when batches overlap
148
409
  const batches = chunkFileArgs(fileArgs, Math.max(argBudget - fixedCost, 1));
410
+ // Batches run side by side (a sequential second batch idled the machine
411
+ // behind the first one's slowest file), so the run's concurrency is split
412
+ // across them instead of multiplied.
413
+ const overlapped = batches.length > 1;
414
+ const totalConcurrency = overlapped ? concurrencyOf(runArgs, fileArgs) : 0;
415
+ const batchRunArgs = () => {
416
+ if (!overlapped) return runArgs;
417
+ const share = Math.max(1, Math.ceil(totalConcurrency / batches.length));
418
+ return [...withoutConcurrency(runArgs), `--test-concurrency=${share}`];
419
+ };
149
420
  const coverageDir = coverage ? await mkdtemp(join(tmpdir(), 'mixdog-v8-coverage-')) : '';
150
421
  // Node 24's test runner rebuilds each test process's execArgv from its option
151
422
  // table and drops V8 flags on the way, so a heap cap passed to the runner no
@@ -157,13 +428,7 @@ export async function runNodeTests(
157
428
  // already budgeted, and a third reporter would warn about listeners.
158
429
  const childEnv = (recordsPath) => ({
159
430
  ...process.env,
160
- TEMP: scratchDir,
161
- TMP: scratchDir,
162
- TMPDIR: scratchDir,
163
- // A fixture must never write into the signed-in app's data (~/.mixdog):
164
- // schedule/webhook logs, design-library bindings and the like resolve the
165
- // default data dir. The desktop preload already does this per process.
166
- MIXDOG_DATA_DIR: join(scratchDir, 'data'),
431
+ ...testIsolationEnv(scratchDir),
167
432
  // Only the immutable validator binary is shared; fixture data stays private.
168
433
  // A gate supplies one directory across lanes; a standalone run owns its own.
169
434
  MIXDOG_TEST_OOXML_CACHE_DIR: process.env.MIXDOG_TEST_OOXML_CACHE_DIR || join(scratchDir, 'ooxml-cache'),
@@ -171,15 +436,35 @@ export async function runNodeTests(
171
436
  ...(coverageDir ? { NODE_V8_COVERAGE: coverageDir } : {}),
172
437
  ...(nodeOptions ? { NODE_OPTIONS: nodeOptions } : {}),
173
438
  });
439
+ const batchOptions = (files, recordsPath, sweepScratch = true) => ({
440
+ files,
441
+ ledger: spawnLedgerPath(recordsPath),
442
+ scratchDir: sweepScratch ? scratchDir : '',
443
+ timeoutMs: positiveInt(process.env.MIXDOG_TEST_BATCH_TIMEOUT_MS) ?? DEFAULT_BATCH_TIMEOUT_MS,
444
+ });
174
445
  let status = 0;
175
446
  const failures = [];
176
- for (const [index, batch] of batches.entries()) {
177
- const batchLog = batches.length === 1 ? logPath : `${logPath}.${index + 1}`;
178
- const batchFailures = join(failureDir, `batch-${index + 1}.jsonl`);
179
- const code = await spawnBatch(
180
- [...runArgs, `--test-reporter-destination=${batchLog}`, ...batch],
181
- childEnv(batchFailures)
182
- );
447
+ const hung = [];
448
+ const batchLogOf = (index) => (batches.length === 1 ? logPath : `${logPath}.${index + 1}`);
449
+ const batchFailuresOf = (index) => join(failureDir, `batch-${index + 1}.jsonl`);
450
+ const outcomes = await Promise.all(
451
+ batches.map((batch, index) =>
452
+ spawnBatch(
453
+ [...batchRunArgs(), `--test-reporter-destination=${batchLogOf(index)}`, ...batch],
454
+ childEnv(batchFailuresOf(index)),
455
+ batchOptions(batch, batchFailuresOf(index), !overlapped)
456
+ )
457
+ )
458
+ );
459
+ // Peers were still running while each batch exited, so the scratch root's
460
+ // own processes (e.g. a postgres a test started) are swept once, here.
461
+ if (overlapped) await killOwned([], { batchStart: Date.now(), scratchDir });
462
+ // Logs and records merge in batch order, whatever order the batches finished.
463
+ for (const [index, outcome] of outcomes.entries()) {
464
+ const batchLog = batchLogOf(index);
465
+ const batchFailures = batchFailuresOf(index);
466
+ const { code } = outcome;
467
+ if (outcome.hung) hung.push(outcome.hung);
183
468
  if (batchLog !== logPath) {
184
469
  await appendFile(logPath, await readFile(batchLog));
185
470
  await rm(batchLog, { force: true });
@@ -196,10 +481,13 @@ export async function runNodeTests(
196
481
  console.error(`Re-running ${files.length} failed file(s): attempt ${attempt}/${rerunFailed}`);
197
482
  const attemptLog = `${logPath}.rerun-${attempt}`;
198
483
  const attemptFailures = join(failureDir, `rerun-${attempt}.jsonl`);
199
- lastAttemptStatus = await spawnBatch(
484
+ const outcome = await spawnBatch(
200
485
  [...runArgs, `--test-reporter-destination=${attemptLog}`, ...files],
201
- childEnv(attemptFailures)
486
+ childEnv(attemptFailures),
487
+ batchOptions(files, attemptFailures)
202
488
  );
489
+ lastAttemptStatus = outcome.code;
490
+ if (outcome.hung) hung.push(outcome.hung);
203
491
  await appendFile(logPath, await readFile(attemptLog));
204
492
  await rm(attemptLog, { force: true });
205
493
  attempts.push({ files, failures: await readFailureRecords(attemptFailures) });
@@ -209,17 +497,27 @@ export async function runNodeTests(
209
497
  // Flaky tolerance applies only to an ordinary test-failure exit that the
210
498
  // re-runs fully explained: nothing failed twice and the last re-run itself
211
499
  // came back clean.
212
- if (rerunFailed > 0 && status === 1 && failed.length === 0 && flaky.length > 0 && lastAttemptStatus === 0) status = 0;
500
+ // A hung batch is never explained by re-runs.
501
+ if (
502
+ rerunFailed > 0 &&
503
+ status === 1 &&
504
+ hung.length === 0 &&
505
+ failed.length === 0 &&
506
+ flaky.length > 0 &&
507
+ lastAttemptStatus === 0
508
+ )
509
+ status = 0;
213
510
  process.exitCode = status;
214
511
 
215
512
  await rm(failureDir, { recursive: true, force: true });
216
- // A process a test left running may still hold a file; what it holds stays.
217
- await rm(scratchDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }).catch(() => {});
513
+ // Leftover processes are already killed; what a process outside the run
514
+ // still holds stays behind.
515
+ await removeScratch(scratchDir);
218
516
  if (coverageDir) {
219
517
  const { reportCoverage } = await import('./coverage.mjs');
220
518
  await reportCoverage(coverageDir);
221
519
  await rm(coverageDir, { recursive: true, force: true });
222
520
  }
223
521
  // Last line of the run, after every reporter and the coverage report.
224
- process.stdout.write(`\n${formatFailureSummary({ failed, flaky, rerunFailed })}`);
522
+ process.stdout.write(`\n${formatFailureSummary({ failed, flaky, rerunFailed, hung, exitCode: status })}`);
225
523
  }
@@ -13,6 +13,11 @@ import { relative } from 'node:path';
13
13
 
14
14
  export const FAILURE_RECORDS_ENV = 'MIXDOG_TEST_FAILURE_RECORDS';
15
15
 
16
+ // Every child process a test process starts is logged beside its spawn's
17
+ // failure records (scripts/lib/test-spawn-ledger.mjs), so the runner can tell
18
+ // a process the run started from one that merely names its directories.
19
+ export const spawnLedgerPath = (recordsPath) => (recordsPath ? `${recordsPath}.spawns` : '');
20
+
16
21
  // Ancestor names are tracked per file: node:test runs files concurrently, so
17
22
  // one flat stack would mix their nestings.
18
23
  export function failureRecord(event, stacks) {
@@ -43,8 +43,19 @@ export function policyLine(rerunFailed) {
43
43
  : 'policy: --rerun-failed is off, so every failure fails the run (pass --rerun-failed <n> to classify flaky)';
44
44
  }
45
45
 
46
- export function formatFailureSummary({ failed = [], flaky = [], rerunFailed = 0 }) {
47
- const lines = [`${SUMMARY_TAG} ${failed.length} failed, ${flaky.length} flaky (every failed test is named below)`];
46
+ // `hung` holds one "hung after N min: <files>" message per batch the runner's
47
+ // watchdog killed; such a batch fails the run whatever its tests reported.
48
+ // A non-zero `exitCode` with no failed test, flaky test or hung batch behind it
49
+ // (a bad node flag, a crashed runner) is its own failure, never "0 failed".
50
+ export function formatFailureSummary({ failed = [], flaky = [], rerunFailed = 0, hung = [], exitCode = 0 }) {
51
+ const hungCount = hung.length > 0 ? `, ${hung.length} hung batch(es)` : '';
52
+ const unexplained = exitCode !== 0 && failed.length === 0 && hung.length === 0;
53
+ const lines = [
54
+ unexplained
55
+ ? `${SUMMARY_TAG} RUN FAILED: exit code ${exitCode} with no failed test recorded (bad argument or runner error; see output above)`
56
+ : `${SUMMARY_TAG} ${failed.length} failed, ${flaky.length} flaky${hungCount} (every failed test is named below)`,
57
+ ];
58
+ for (const message of hung) lines.push(`${SUMMARY_TAG} HUNG ${message}`);
48
59
  for (const record of failed) {
49
60
  const origin = record.firstSeenOnRerun ? ` (first seen on re-run ${record.firstSeenOnRerun})` : '';
50
61
  lines.push(`${SUMMARY_TAG} FAILED ${record.file} > ${record.name}${origin}`);
@@ -54,6 +65,6 @@ export function formatFailureSummary({ failed = [], flaky = [], rerunFailed = 0
54
65
  `${SUMMARY_TAG} FLAKY ${record.file} > ${record.name} (failed, then passed on re-run ${record.passedOnAttempt}/${rerunFailed})`
55
66
  );
56
67
  }
57
- if (failed.length > 0 || flaky.length > 0) lines.push(`${SUMMARY_TAG} ${policyLine(rerunFailed)}`);
68
+ if (failed.length > 0 || flaky.length > 0 || hung.length > 0) lines.push(`${SUMMARY_TAG} ${policyLine(rerunFailed)}`);
58
69
  return `${lines.join('\n')}\n`;
59
70
  }
@@ -0,0 +1,19 @@
1
+ // Preloaded (--import) into every test process the runner starts: records
2
+ // "<pid> <spawn time ms>" for each child process, so a process a test leaves
3
+ // running can be traced back to the run after its parent exited
4
+ // (scripts/lib/run-node-tests.mjs). Without the runner's environment it does
5
+ // nothing.
6
+ import { ChildProcess } from 'node:child_process';
7
+ import { appendFileSync } from 'node:fs';
8
+ import { FAILURE_RECORDS_ENV, spawnLedgerPath } from './test-failure-records.mjs';
9
+
10
+ const ledger = spawnLedgerPath(process.env[FAILURE_RECORDS_ENV]);
11
+ if (ledger) {
12
+ // spawn, exec, execFile and fork all start their process here.
13
+ const spawn = ChildProcess.prototype.spawn;
14
+ ChildProcess.prototype.spawn = function recordedSpawn(...args) {
15
+ const result = spawn.apply(this, args);
16
+ if (this.pid) appendFileSync(ledger, `${this.pid} ${Date.now()}\n`);
17
+ return result;
18
+ };
19
+ }
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ // electron-builder writes one latest-mac.yml per macOS build, and each runner
3
+ // builds a single architecture, so each feed lists only its own zip and dmg.
4
+ // electron-updater on macOS always reads the single latest-mac.yml of the
5
+ // latest release and picks the zip whose name matches the machine's
6
+ // architecture, so the release needs one feed that lists both builds.
7
+ //
8
+ // The publish job runs without installing dependencies, so this reads the
9
+ // fixed shape electron-builder emits (top-level scalars plus a `files:` list
10
+ // of `- url:` items) directly and fails on anything else.
11
+
12
+ import { readFileSync, writeFileSync } from 'node:fs';
13
+ import { pathToFileURL } from 'node:url';
14
+
15
+ export const MAC_ARCHES = Object.freeze(['arm64', 'x64']);
16
+ // Legacy top-level path/sha512 (read only by electron-updater 1.x) point at
17
+ // the x64 zip, which also runs on Apple silicon through Rosetta.
18
+ const PRIMARY_ARCH = 'x64';
19
+ const TOP_LEVEL_KEYS = ['version', 'path', 'sha512', 'releaseDate'];
20
+
21
+ function zipArch(url) {
22
+ const match = /^mixdog-desktop-mac-([a-z0-9]+)\.zip$/.exec(url);
23
+ return match ? match[1] : null;
24
+ }
25
+
26
+ /** Parse one electron-builder latest-mac.yml. File items keep their raw lines. */
27
+ export function parseMacFeed(text, label) {
28
+ const top = {};
29
+ const files = [];
30
+ let inFiles = false;
31
+ for (const line of text.split(/\r?\n/)) {
32
+ if (!line.trim()) continue;
33
+ if (/^\S/.test(line)) {
34
+ const match = /^([A-Za-z][A-Za-z0-9]*):(?:\s+(.*))?$/.exec(line);
35
+ if (!match) throw new Error(`${label}: unsupported line: ${line}`);
36
+ inFiles = match[1] === 'files';
37
+ if (!inFiles) top[match[1]] = line;
38
+ } else if (inFiles && /^ {2}- url: \S+$/.test(line)) {
39
+ files.push({ url: line.replace(/^ {2}- url:\s+/, ''), lines: [line] });
40
+ } else if (inFiles && files.length > 0 && /^ {4}[A-Za-z][A-Za-z0-9]*: \S.*$/.test(line)) {
41
+ files[files.length - 1].lines.push(line);
42
+ } else {
43
+ throw new Error(`${label}: unsupported line: ${line}`);
44
+ }
45
+ }
46
+ for (const key of TOP_LEVEL_KEYS) {
47
+ if (!top[key]) throw new Error(`${label}: missing ${key}`);
48
+ }
49
+ const value = (key) => top[key].slice(key.length + 1).trim();
50
+ return { version: value('version'), top, files };
51
+ }
52
+
53
+ /** Merge the per-architecture feeds into one feed listing both zips. */
54
+ export function mergeMacFeeds(inputs, expectedVersion) {
55
+ const feeds = inputs.map(({ label, text }) => {
56
+ const feed = parseMacFeed(text, label);
57
+ if (expectedVersion && feed.version !== expectedVersion) {
58
+ throw new Error(`${label}: version ${feed.version} is not ${expectedVersion}`);
59
+ }
60
+ const zips = feed.files.filter((file) => zipArch(file.url));
61
+ if (zips.length !== 1) throw new Error(`${label}: expected exactly one mac zip, found ${zips.length}`);
62
+ const [zip] = zips;
63
+ for (const field of ['sha512', 'size']) {
64
+ if (!zip.lines.some((line) => line.startsWith(` ${field}: `))) {
65
+ throw new Error(`${label}: ${zip.url} has no ${field}`);
66
+ }
67
+ }
68
+ return { ...feed, arch: zipArch(zip.url) };
69
+ });
70
+ const arches = feeds.map((feed) => feed.arch).sort();
71
+ if (JSON.stringify(arches) !== JSON.stringify(MAC_ARCHES)) {
72
+ throw new Error(`Expected one feed per ${MAC_ARCHES.join(' + ')}, found: ${arches.join(', ')}`);
73
+ }
74
+ if (feeds[0].version !== feeds[1].version) {
75
+ throw new Error(`Feed versions differ: ${feeds[0].version} and ${feeds[1].version}`);
76
+ }
77
+ feeds.sort((a, b) => MAC_ARCHES.indexOf(a.arch) - MAC_ARCHES.indexOf(b.arch));
78
+ const entries = feeds.flatMap((feed) => feed.files);
79
+ const urls = entries.map((file) => file.url);
80
+ if (new Set(urls).size !== urls.length) throw new Error(`Duplicate feed files: ${urls.join(', ')}`);
81
+ const primary = feeds.find((feed) => feed.arch === PRIMARY_ARCH);
82
+ return [
83
+ primary.top.version,
84
+ 'files:',
85
+ ...entries.flatMap((file) => file.lines),
86
+ primary.top.path,
87
+ primary.top.sha512,
88
+ primary.top.releaseDate,
89
+ '',
90
+ ].join('\n');
91
+ }
92
+
93
+ if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
94
+ const args = process.argv.slice(2);
95
+ const option = (name) => args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
96
+ const out = option('out');
97
+ const inputs = args.filter((arg) => !arg.startsWith('--'));
98
+ if (!out || inputs.length === 0) {
99
+ throw new Error('usage: merge-mac-update-feed.mjs --out=latest-mac.yml [--version=X.Y.Z] <feed.yml>...');
100
+ }
101
+ const merged = mergeMacFeeds(
102
+ inputs.map((path) => ({ label: path, text: readFileSync(path, 'utf8') })),
103
+ option('version')
104
+ );
105
+ writeFileSync(out, merged);
106
+ console.log(`Wrote ${out} from ${inputs.length} feeds.`);
107
+ }