mixdog 0.9.147 → 0.9.149

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 (265) 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 +5 -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-codex-identity.test.mjs +32 -9
  48. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +78 -33
  49. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +21 -0
  50. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +36 -36
  51. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +16 -6
  52. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +50 -213
  53. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +135 -126
  54. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +20 -11
  55. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +112 -36
  56. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +220 -50
  57. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +112 -9
  58. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +11 -0
  59. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +130 -60
  60. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -2
  61. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +28 -10
  62. package/src/runtime/agent/orchestrator/providers/registry.mjs +122 -95
  63. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +69 -3
  64. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +4 -22
  65. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +7 -14
  66. package/src/runtime/agent/orchestrator/session/compact-policy.test.mjs +128 -0
  67. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +5 -0
  68. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +27 -48
  69. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +19 -14
  70. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -43
  71. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +5 -1
  72. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +43 -5
  73. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -2
  74. package/src/runtime/agent/orchestrator/session/manager/pending-messages-persistence.test.mjs +325 -0
  75. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +599 -195
  76. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +22 -0
  77. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +9 -2
  78. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +2 -0
  79. package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
  80. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +26 -16
  81. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +23 -11
  82. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +118 -3
  83. package/src/runtime/agent/orchestrator/session/store-summary-reader.test.mjs +332 -0
  84. package/src/runtime/agent/orchestrator/session/store.mjs +38 -1
  85. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +27 -6
  86. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -20
  87. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +291 -128
  88. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +46 -9
  89. package/src/runtime/agent/orchestrator/tools/builtin/edit-encoding-eol.test.mjs +224 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/edit-sequential-occupation.test.mjs +53 -3
  91. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +255 -25
  92. package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +87 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +1 -1
  94. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +132 -9
  95. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +10 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +29 -3
  97. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +1 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/list-tool-integrity.test.mjs +14 -0
  99. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +180 -31
  100. package/src/runtime/agent/orchestrator/tools/builtin/local-search-telemetry.mjs +4 -1
  101. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +32 -5
  102. package/src/runtime/agent/orchestrator/tools/builtin/native-search-health.test.mjs +5 -2
  103. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +10 -1
  104. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +38 -10
  105. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +12 -0
  106. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +31 -1
  107. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +11 -1
  108. package/src/runtime/agent/orchestrator/tools/builtin/read-windows.mjs +22 -4
  109. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +6 -8
  110. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +25 -24
  111. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +152 -133
  112. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +114 -42
  113. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +129 -15
  114. package/src/runtime/agent/orchestrator/tools/code-graph/loose-file-target.test.mjs +72 -0
  115. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +7 -8
  116. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +4 -4
  117. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +201 -19
  118. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +26 -3
  119. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +10 -1
  120. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-search-scope.test.mjs +55 -0
  121. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +115 -25
  122. package/src/runtime/agent/orchestrator/tools/code-graph/text-span-mask.test.mjs +268 -0
  123. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -3
  124. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +296 -46
  125. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.test.mjs +104 -0
  126. package/src/runtime/agent/orchestrator/tools/lib/shell-descendants.mjs +136 -0
  127. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +127 -38
  128. package/src/runtime/agent/orchestrator/tools/patch/engine-contract-gate.test.mjs +661 -0
  129. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +350 -9
  130. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +418 -44
  131. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +27 -3
  132. package/src/runtime/agent/orchestrator/tools/patch/patch-codec-eol.test.mjs +552 -0
  133. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +65 -15
  134. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  135. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +284 -109
  136. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +51 -21
  137. package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +44 -27
  138. package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +6 -1
  139. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  140. package/src/runtime/attachments/store.mjs +11 -3
  141. package/src/runtime/channels/data/voice-runtime-manifest.json +46 -70
  142. package/src/runtime/channels/lib/event-queue.mjs +51 -41
  143. package/src/runtime/channels/lib/scheduler.mjs +23 -28
  144. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -17
  145. package/src/runtime/channels/lib/voice-runtime-fetcher.test.mjs +17 -0
  146. package/src/runtime/channels/lib/webhook.mjs +39 -20
  147. package/src/runtime/channels/lib/whisper-server.mjs +46 -1
  148. package/src/runtime/channels/lib/worker-main.mjs +3 -1
  149. package/src/runtime/media/adapters/gemini-video.mjs +20 -4
  150. package/src/runtime/media/jobs.mjs +13 -0
  151. package/src/runtime/media/renditions.mjs +7 -1
  152. package/src/runtime/media/store.mjs +42 -22
  153. package/src/runtime/memory/index.mjs +4 -9
  154. package/src/runtime/memory/lib/agent-ipc.mjs +1 -6
  155. package/src/runtime/memory/lib/archive-security.test.mjs +21 -1
  156. package/src/runtime/memory/lib/core-memory-store.mjs +28 -50
  157. package/src/runtime/memory/lib/embedding-model-config.mjs +37 -1
  158. package/src/runtime/memory/lib/embedding-model-config.test.mjs +33 -0
  159. package/src/runtime/memory/lib/embedding-provider.mjs +34 -12
  160. package/src/runtime/memory/lib/embedding-worker.mjs +37 -5
  161. package/src/runtime/memory/lib/ko-morph.mjs +46 -15
  162. package/src/runtime/memory/lib/memory-action-handlers.mjs +18 -1
  163. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -18
  164. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +1 -18
  165. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +98 -45
  166. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +71 -0
  167. package/src/runtime/memory/lib/memory-cycle2-shared.test.mjs +78 -0
  168. package/src/runtime/memory/lib/memory-cycle2.mjs +37 -3
  169. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  170. package/src/runtime/memory/lib/memory-embedding-migration.test.mjs +50 -0
  171. package/src/runtime/memory/lib/memory-recall-store.mjs +9 -24
  172. package/src/runtime/memory/lib/memory.mjs +50 -12
  173. package/src/runtime/memory/lib/pg/process.mjs +1 -10
  174. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -9
  175. package/src/runtime/memory/lib/query-handlers.mjs +2 -4
  176. package/src/runtime/memory/lib/session-ingest-runtime.mjs +2 -2
  177. package/src/runtime/shared/background-tasks.mjs +15 -0
  178. package/src/runtime/shared/background-tasks.test.mjs +78 -0
  179. package/src/runtime/shared/child-guardian.mjs +2 -10
  180. package/src/runtime/shared/pid-liveness.mjs +27 -0
  181. package/src/runtime/shared/pristine-execution-contract.json +0 -1
  182. package/src/runtime/shared/runtime-root.mjs +1 -11
  183. package/src/runtime/shared/service-discovery.mjs +1 -17
  184. package/src/runtime/shared/singleton-owner.mjs +1 -11
  185. package/src/runtime/shared/staged-update.mjs +2 -12
  186. package/src/runtime/shared/tool-status.mjs +5 -1
  187. package/src/runtime/shared/tool-surface.mjs +15 -7
  188. package/src/runtime/shared/turn-snapshot-store.mjs +168 -0
  189. package/src/runtime/shared/turn-snapshot.mjs +137 -13
  190. package/src/runtime/shared/turn-worktree-snapshot.mjs +110 -18
  191. package/src/runtime/shared/webhook-session-run.mjs +16 -2
  192. package/src/runtime/web-search/lib/http-fetch.mjs +34 -4
  193. package/src/runtime/web-search/lib/state.mjs +15 -15
  194. package/src/runtime/web-search/lib/web-tools.mjs +46 -5
  195. package/src/session-runtime/context-status.mjs +4 -12
  196. package/src/session-runtime/runtime-core.mjs +20 -1
  197. package/src/session-runtime/session-lifecycle.mjs +9 -0
  198. package/src/standalone/agent-dispatch-broker.mjs +33 -2
  199. package/src/standalone/agent-tool/job-task-reconcile.mjs +78 -0
  200. package/src/standalone/agent-tool/notify.mjs +3 -1
  201. package/src/standalone/agent-tool/spawn-flow.mjs +52 -70
  202. package/src/standalone/agent-tool/tag-registry.mjs +116 -33
  203. package/src/standalone/agent-tool/tool-def.mjs +2 -2
  204. package/src/standalone/agent-tool/worker-index.mjs +8 -11
  205. package/src/standalone/agent-tool/worker-rows.mjs +4 -1
  206. package/src/standalone/agent-tool.mjs +20 -55
  207. package/src/standalone/agent-watchdog-registry.mjs +28 -4
  208. package/src/standalone/channel-client.mjs +47 -15
  209. package/src/standalone/channel-transport.mjs +44 -25
  210. package/src/standalone/daemon.mjs +31 -2
  211. package/src/standalone/hook-bus/config.mjs +25 -9
  212. package/src/standalone/hook-bus/handlers.mjs +16 -52
  213. package/src/standalone/hook-bus.mjs +29 -23
  214. package/src/standalone/memory-runtime-proxy.mjs +1 -16
  215. package/src/standalone/session-client.mjs +86 -23
  216. package/src/standalone/session-protocol.mjs +0 -2
  217. package/src/standalone/session-runtime-host-health.test.mjs +5 -4
  218. package/src/standalone/session-runtime-host.mjs +101 -6
  219. package/src/standalone/session-runtime-record.mjs +1 -5
  220. package/src/standalone/session-runtime-worker.mjs +52 -57
  221. package/src/standalone/session-service.mjs +4 -1
  222. package/src/standalone/session-transport.mjs +56 -25
  223. package/src/tui/App.jsx +40 -49
  224. package/src/tui/app/app-view.jsx +14 -21
  225. package/src/tui/app/core-memory-picker.mjs +29 -16
  226. package/src/tui/app/create-app-pickers.mjs +15 -36
  227. package/src/tui/app/extension-pickers.mjs +60 -49
  228. package/src/tui/app/maintenance-pickers.mjs +100 -57
  229. package/src/tui/app/message-selector.mjs +6 -5
  230. package/src/tui/app/model-picker.mjs +30 -13
  231. package/src/tui/app/onboarding-steps.mjs +21 -12
  232. package/src/tui/app/panel-epoch.mjs +56 -0
  233. package/src/tui/app/panel-epoch.test.mjs +1009 -0
  234. package/src/tui/app/panel-surface.mjs +98 -0
  235. package/src/tui/app/project-picker.mjs +30 -29
  236. package/src/tui/app/prompt-submit.mjs +192 -97
  237. package/src/tui/app/prompt-submit.test.mjs +206 -0
  238. package/src/tui/app/provider-setup-picker.mjs +74 -43
  239. package/src/tui/app/resume-picker.mjs +11 -8
  240. package/src/tui/app/route-pickers.mjs +45 -28
  241. package/src/tui/app/settings-picker.mjs +69 -18
  242. package/src/tui/app/shell-layout.mjs +1 -3
  243. package/src/tui/app/slash-commands.mjs +0 -2
  244. package/src/tui/app/slash-dispatch.mjs +4 -2
  245. package/src/tui/app/text-entry-policy.mjs +25 -0
  246. package/src/tui/app/theme-effort-pickers.mjs +11 -16
  247. package/src/tui/app/usage-context-panels.mjs +21 -25
  248. package/src/tui/app/use-global-key-input.mjs +3 -2
  249. package/src/tui/app/use-prompt-draft-flow.mjs +6 -18
  250. package/src/tui/app/use-prompt-handlers.mjs +3 -2
  251. package/src/tui/app/use-welcome-prompt-hint.mjs +17 -3
  252. package/src/tui/components/Picker.jsx +1 -1
  253. package/src/tui/components/TextEntryPanel.jsx +7 -2
  254. package/src/tui/dist/index.mjs +652 -561
  255. package/src/tui/lib/voice-setup.mjs +1 -2
  256. package/src/tui/session/agent-job-feed.mjs +73 -11
  257. package/src/tui/session/session-api-ext.mjs +5 -6
  258. package/src/tui/session/session-api.mjs +1 -0
  259. package/src/tui/session/tool-approval.mjs +15 -5
  260. package/src/tui/session/tui-steering-persist.mjs +33 -19
  261. package/src/tui/session/tui-steering-persist.test.mjs +104 -0
  262. package/src/tui/session-local.mjs +0 -22
  263. package/scripts/.tmp-coverage-report.mjs +0 -96
  264. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.mjs +0 -57
  265. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.test.mjs +0 -57
@@ -11,14 +11,125 @@ import { ensureSpawnBinary, findCachedSpawnBinary } from '../spawn-binary-fetche
11
11
 
12
12
  const RESTART_BACKOFF_MS = 30_000;
13
13
  const SERVER_READY_TIMEOUT_MS = 5_000;
14
+ const REQUIRED_LIFECYCLE_CAPS = Object.freeze([
15
+ 'trackedForeground',
16
+ 'promoteTask',
17
+ 'cancelOwner',
18
+ ]);
14
19
 
15
20
  let _server = null;
16
21
  let _binaryPath = undefined;
17
22
  let _lastFailureAt = 0;
18
23
  const _tasks = new Map();
19
- const _pidToRequest = new Map();
20
24
  const _taskEvents = new EventEmitter();
21
25
 
26
+ // Retention of SETTLED tasks. A completed task stays queryable so a late
27
+ // `task read` still answers, but the spawn server's `task_released` event is
28
+ // the only thing that used to drop it — a server that never sends one (old
29
+ // binary, crash, cancelled owner) grew _tasks without bound for the daemon's
30
+ // whole life. Terminal entries now carry their own TTL plus a hard cap, and
31
+ // releaseNativeTask() gives owners an explicit release.
32
+ const RETAINED_TASK_TTL_MS = 10 * 60_000;
33
+ const RETAINED_TASK_LIMIT = 256;
34
+ const RETAINED_TASK_SWEEP_MS = 60_000;
35
+ const _terminalAtMs = new Map();
36
+ let _retentionSweepTimer = null;
37
+
38
+ // The spawn server keeps its OWN retained-jobs map, so a client-side drop must
39
+ // tell it to drop the same entry — otherwise the leak just moves one process
40
+ // over. Older servers without the capability simply keep their cap-based
41
+ // pruning.
42
+ function _requestServerTaskRelease(jobId) {
43
+ const server = _server;
44
+ if (!server?.ready || server.caps?.releaseTask !== true) return false;
45
+ try {
46
+ server.child.stdin.write(`${JSON.stringify({ id: ++server.sequence, releaseTask: jobId })}\n`);
47
+ return true;
48
+ } catch { return false; }
49
+ }
50
+
51
+ function _stopRetentionSweep() {
52
+ if (!_retentionSweepTimer) return;
53
+ clearInterval(_retentionSweepTimer);
54
+ _retentionSweepTimer = null;
55
+ }
56
+
57
+ // Retention must not depend on new task events arriving: a daemon that goes
58
+ // quiet right after its last command would otherwise hold that entry (and the
59
+ // server's) forever. The sweep runs on its own unref'd cadence and stops
60
+ // itself as soon as nothing is retained.
61
+ function _startRetentionSweep() {
62
+ if (_retentionSweepTimer || _terminalAtMs.size === 0) return;
63
+ _retentionSweepTimer = setInterval(() => {
64
+ _sweepRetainedTasks();
65
+ if (_terminalAtMs.size === 0) _stopRetentionSweep();
66
+ }, RETAINED_TASK_SWEEP_MS);
67
+ _retentionSweepTimer.unref?.();
68
+ }
69
+
70
+ function _dropTaskEntry(jobId, { notifyServer = true } = {}) {
71
+ _tasks.delete(jobId);
72
+ _terminalAtMs.delete(jobId);
73
+ _startedAtOverrides.delete(jobId);
74
+ if (notifyServer) _requestServerTaskRelease(jobId);
75
+ if (_terminalAtMs.size === 0) _stopRetentionSweep();
76
+ }
77
+
78
+ function _sweepRetainedTasks(now = Date.now()) {
79
+ for (const [jobId, at] of _terminalAtMs) {
80
+ if (now - at >= RETAINED_TASK_TTL_MS) _dropTaskEntry(jobId);
81
+ }
82
+ // Insertion-ordered: the oldest terminal entries go first.
83
+ let overflow = _terminalAtMs.size - RETAINED_TASK_LIMIT;
84
+ if (overflow <= 0) return;
85
+ for (const jobId of [..._terminalAtMs.keys()]) {
86
+ if (overflow-- <= 0) break;
87
+ _dropTaskEntry(jobId);
88
+ }
89
+ }
90
+
91
+ function _markTerminalTask(jobId, status) {
92
+ if (status === 'running') {
93
+ _terminalAtMs.delete(jobId);
94
+ return;
95
+ }
96
+ if (!_terminalAtMs.has(jobId)) _terminalAtMs.set(jobId, Date.now());
97
+ _sweepRetainedTasks();
98
+ _startRetentionSweep();
99
+ }
100
+
101
+ /** Drop a settled task's retained state. Running tasks are never released
102
+ * (cancel them first); returns true when an entry was actually dropped. */
103
+ export function releaseNativeTask(jobId) {
104
+ const key = String(jobId || '').trim();
105
+ const task = key ? _tasks.get(key) : null;
106
+ if (!task || task.status === 'running') return false;
107
+ _dropTaskEntry(key);
108
+ return true;
109
+ }
110
+
111
+ function nativeSpawnCompatibilityError(server) {
112
+ if (!server?.ready) return null;
113
+ const missingCaps = REQUIRED_LIFECYCLE_CAPS.filter((cap) => server.caps?.[cap] !== true);
114
+ if (missingCaps.length === 0) return null;
115
+ const error = new Error(
116
+ `native spawn server is incompatible; missing required lifecycle capabilities: ${missingCaps.join(', ')}`,
117
+ );
118
+ error.code = 'NATIVE_SPAWN_INCOMPATIBLE';
119
+ error.missingCaps = missingCaps;
120
+ return error;
121
+ }
122
+
123
+ function assertNativeSpawnCompatibility(server) {
124
+ const error = nativeSpawnCompatibilityError(server);
125
+ if (error) throw error;
126
+ }
127
+
128
+ export function _assertNativeSpawnCapabilitiesForTest(caps = {}) {
129
+ assertNativeSpawnCompatibility({ ready: true, caps });
130
+ return true;
131
+ }
132
+
22
133
  // Warm-standby correction. The spawn server stamps a task with the moment its
23
134
  // PROCESS was created, which for a pre-spawned standby shell is up to the
24
135
  // standby TTL (2 min) before the command was ever fed to it — every consumer
@@ -80,6 +191,7 @@ function acceptTask(message) {
80
191
  const task = normalizeTask(message?.task);
81
192
  if (!task) return null;
82
193
  _tasks.set(task.jobId, task);
194
+ _markTerminalTask(task.jobId, task.status);
83
195
  _taskEvents.emit(task.jobId, task);
84
196
  return task;
85
197
  }
@@ -223,7 +335,7 @@ export function setNativeSpawnRequestIdle(child, idle = true) {
223
335
  return true;
224
336
  }
225
337
 
226
- function _teardown(error) {
338
+ function _teardown(error, { kill = true } = {}) {
227
339
  const server = _server;
228
340
  _server = null;
229
341
  _lastFailureAt = Date.now();
@@ -242,9 +354,12 @@ function _teardown(error) {
242
354
  finishedAt: new Date().toISOString(),
243
355
  };
244
356
  _tasks.set(jobId, failed);
357
+ _markTerminalTask(jobId, failed.status);
245
358
  _taskEvents.emit(jobId, { ...failed });
246
359
  }
247
- try { server.child.kill(); } catch {}
360
+ if (kill) {
361
+ try { server.child.kill(); } catch {}
362
+ }
248
363
  }
249
364
 
250
365
  export async function shutdownNativeSpawnServer(reason = 'process-exit', timeoutMs = 1_000) {
@@ -255,8 +370,13 @@ export async function shutdownNativeSpawnServer(reason = 'process-exit', timeout
255
370
  child.once('exit', () => resolve(true));
256
371
  child.once('error', () => resolve(true));
257
372
  });
373
+ // Graceful stop first: detach the module handle and fail pending requests,
374
+ // but do NOT kill the server here. Closing stdin is its shutdown signal, and
375
+ // it needs that window to terminate the process GROUPS it owns (POSIX) —
376
+ // killing the server first preempted that cleanup and orphaned children.
377
+ // SIGKILL below is the fallback for a server that misses the window.
378
+ _teardown(new Error(`native spawn server shutdown (${reason})`), { kill: false });
258
379
  try { child.stdin?.end?.(); } catch {}
259
- _teardown(new Error(`native spawn server shutdown (${reason})`));
260
380
  let timer;
261
381
  const stopped = await Promise.race([
262
382
  exited,
@@ -305,7 +425,13 @@ function _ensureServer() {
305
425
  settleReady(true);
306
426
  return;
307
427
  }
308
- if (String(message?.event || '').startsWith('task_')) acceptTask(message);
428
+ const event = String(message?.event || '');
429
+ if (event === 'task_released') {
430
+ const jobId = String(message?.jobId || '');
431
+ if (jobId) _dropTaskEntry(jobId, { notifyServer: false });
432
+ } else if (event.startsWith('task_')) {
433
+ acceptTask(message);
434
+ }
309
435
  const pending = server.pending.get(Number(message?.id));
310
436
  if (!pending) return;
311
437
  pending.onMessage(message);
@@ -333,7 +459,10 @@ export async function warmNativeSpawnServer() {
333
459
  }
334
460
 
335
461
  export async function ensureNativeSpawnServer() {
336
- if (_server?.ready) return true;
462
+ if (_server?.ready) {
463
+ assertNativeSpawnCompatibility(_server);
464
+ return true;
465
+ }
337
466
  let binary = _resolveBinary();
338
467
  if (!binary) {
339
468
  binary = await ensureSpawnBinary(getPluginData());
@@ -367,12 +496,14 @@ export async function ensureNativeSpawnServer() {
367
496
  });
368
497
  }
369
498
  }
499
+ assertNativeSpawnCompatibility(server);
370
500
  return true;
371
501
  }
372
502
 
373
503
  export function tryNativeSpawn({ shell, argv, spawnOptions = {}, cwd } = {}) {
374
504
  const server = _ensureServer();
375
505
  if (!server) return null;
506
+ assertNativeSpawnCompatibility(server);
376
507
  const id = ++server.sequence;
377
508
  const fake = new NativeSpawnChild(id, (cancelId) => {
378
509
  try { server.child.stdin.write(`${JSON.stringify({ cancel: cancelId })}\n`); } catch {}
@@ -382,6 +513,12 @@ export function tryNativeSpawn({ shell, argv, spawnOptions = {}, cwd } = {}) {
382
513
  return true;
383
514
  } catch { return false; }
384
515
  });
516
+ // File capture engages only when the RUNNING server advertises it and the
517
+ // caller supplied both paths. An older server would ignore the unknown
518
+ // fields and keep piping, so the capability gate decides rather than hope.
519
+ const _fileCapture = server.caps?.fileCapture === true
520
+ && Boolean(spawnOptions.stdoutPath)
521
+ && Boolean(spawnOptions.stderrPath);
385
522
  const request = {
386
523
  id,
387
524
  program: String(shell || ''),
@@ -394,6 +531,10 @@ export function tryNativeSpawn({ shell, argv, spawnOptions = {}, cwd } = {}) {
394
531
  outputLimit: Math.max(0, Number(spawnOptions.outputLimit) || 0),
395
532
  mergeStderr: spawnOptions.mergeStderr === true,
396
533
  rawOutput: spawnOptions.rawOutput === true,
534
+ ...(_fileCapture ? {
535
+ stdoutPath: String(spawnOptions.stdoutPath),
536
+ stderrPath: String(spawnOptions.stderrPath),
537
+ } : {}),
397
538
  ...(spawnOptions.stdinPipe === true ? { stdinPipe: true } : {}),
398
539
  command: spawnOptions.command || undefined,
399
540
  shellType: spawnOptions.shellType || undefined,
@@ -417,7 +558,6 @@ export function tryNativeSpawn({ shell, argv, spawnOptions = {}, cwd } = {}) {
417
558
  const event = String(message?.event || '');
418
559
  if (event === 'spawned') {
419
560
  fake.pid = Number(message.pid) || undefined;
420
- if (fake.pid) _pidToRequest.set(fake.pid, id);
421
561
  fake.emit('spawn');
422
562
  return;
423
563
  }
@@ -440,7 +580,6 @@ export function tryNativeSpawn({ shell, argv, spawnOptions = {}, cwd } = {}) {
440
580
  }
441
581
  else if (event === 'exit') {
442
582
  server.pending.delete(id);
443
- if (fake.pid) _pidToRequest.delete(fake.pid);
444
583
  fake.exitCode = message.code == null ? null : Number(message.code);
445
584
  fake.signalCode = message.signal || null;
446
585
  fake._closed = true;
@@ -463,7 +602,8 @@ export function tryNativeSpawn({ shell, argv, spawnOptions = {}, cwd } = {}) {
463
602
  }
464
603
  return {
465
604
  child: fake,
466
- adoptErrorHandler(handler) {
605
+ fileCapture: _fileCapture,
606
+ attachErrorHandler(handler) {
467
607
  fake.on('error', handler);
468
608
  },
469
609
  };
@@ -474,6 +614,20 @@ export function getNativeTask(jobId) {
474
614
  return task ? { ...task } : null;
475
615
  }
476
616
 
617
+ /** Whether the live server can hand a child its own capture-file fds.
618
+ * Callers ask BEFORE opening those files so a server without the capability
619
+ * never leaves an empty pair behind.
620
+ *
621
+ * win32 EXCEPTION: there is no process group to interrogate there, so a
622
+ * command's surviving descendants are observed by the stdout/stderr pipes
623
+ * they INHERITED still being held after the shell itself exited (see
624
+ * lib/shell-descendants.mjs). File capture removes exactly that evidence.
625
+ * POSIX loses nothing — its survivor check reads the process group. */
626
+ export function nativeSpawnFileCaptureReady() {
627
+ if (process.platform === 'win32') return false;
628
+ return _server?.ready === true && _server?.caps?.fileCapture === true;
629
+ }
630
+
477
631
  export function listNativeTasks() {
478
632
  return [..._tasks.values()].map((task) => ({ ...task }));
479
633
  }
@@ -494,23 +648,81 @@ export function cancelNativeTask(jobId) {
494
648
  return task;
495
649
  }
496
650
 
497
- export function waitNativeTask(jobId, timeoutMs = 30_000) {
651
+ /** Cancel every running task owned by one session.
652
+ *
653
+ * Reports what the kill could actually prove:
654
+ * cancelled tasks that were running when the cancel was issued
655
+ * unconfirmed jobIds whose cancel reached NOTHING — no live spawn-server
656
+ * link, so the process stays observable but unaddressable from
657
+ * this host (the Windows survivor case)
658
+ * confirmed false while any unconfirmed entry exists
659
+ * Delivery to the owning spawn server counts as confirmed (it holds the
660
+ * handle and completes the kill); an undelivered signal never does. */
661
+ export function cancelNativeTasks({ ownerSessionId = null } = {}) {
662
+ const owner = String(ownerSessionId || '').trim();
663
+ if (!owner) return { cancelled: 0, unconfirmed: [], confirmed: true };
664
+ const running = [..._tasks.values()].filter((task) =>
665
+ task.status === 'running' && String(task.ownerSessionId || '') === owner);
666
+ const server = _server;
667
+ let ownerCancelDelivered = false;
668
+ if (server?.caps?.cancelOwner === true) {
669
+ try {
670
+ server.child.stdin.write(`${JSON.stringify({
671
+ id: ++server.sequence,
672
+ cancelOwnerSession: owner,
673
+ })}\n`);
674
+ ownerCancelDelivered = true;
675
+ } catch { ownerCancelDelivered = false; }
676
+ }
677
+ // Also issue direct per-task cancellation for entries already visible in
678
+ // this client so teardown does not wait on the owner-wide command round trip.
679
+ // A teardown never BOOTS a spawn server just to cancel: with the link gone
680
+ // there is nothing left for the signal to travel through, and pretending
681
+ // otherwise is what turned an unkillable survivor into a false `cancelled`.
682
+ const unconfirmed = [];
683
+ for (const task of running) {
684
+ let delivered = ownerCancelDelivered;
685
+ if (server) {
686
+ try { delivered = Boolean(cancelNativeTask(task.jobId)) || delivered; } catch { /* undelivered */ }
687
+ }
688
+ if (!delivered) unconfirmed.push(task.jobId);
689
+ }
690
+ return { cancelled: running.length, unconfirmed, confirmed: unconfirmed.length === 0 };
691
+ }
692
+
693
+ // Completion-event wait. Resolves the moment the task leaves `running`, so the
694
+ // timeout is a ceiling and never a fixed delay. An abort signal resolves with
695
+ // the CURRENT state instead of leaving the caller blocked: a cancelled turn
696
+ // must return immediately, not serve out the remaining ceiling.
697
+ export function waitNativeTask(jobId, timeoutMs = 30_000, signal = null) {
498
698
  const key = String(jobId || '');
499
699
  const current = getNativeTask(key);
500
700
  if (!current || current.status !== 'running') return Promise.resolve(current);
701
+ if (signal?.aborted) return Promise.resolve(current);
501
702
  return new Promise((resolve) => {
502
703
  let timer = null;
503
- const done = (task) => {
504
- if (task?.status === 'running') return;
704
+ let onAbort = null;
705
+ let settled = false;
706
+ const finish = (task) => {
707
+ if (settled) return;
708
+ settled = true;
505
709
  if (timer) clearTimeout(timer);
710
+ if (onAbort && signal) {
711
+ try { signal.removeEventListener('abort', onAbort); } catch {}
712
+ }
506
713
  unsubscribe();
507
714
  resolve(task ? { ...task } : null);
508
715
  };
716
+ const done = (task) => {
717
+ if (task?.status === 'running') return;
718
+ finish(task);
719
+ };
509
720
  const unsubscribe = subscribeNativeTask(key, done);
510
- timer = setTimeout(() => {
511
- unsubscribe();
512
- resolve(getNativeTask(key));
513
- }, Math.max(1, Number(timeoutMs) || 30_000));
721
+ if (signal) {
722
+ onAbort = () => finish(getNativeTask(key));
723
+ try { signal.addEventListener('abort', onAbort, { once: true }); } catch {}
724
+ }
725
+ timer = setTimeout(() => finish(getNativeTask(key)), Math.max(1, Number(timeoutMs) || 30_000));
514
726
  timer.unref?.();
515
727
  });
516
728
  }
@@ -572,19 +784,10 @@ export async function startNativeTask({
572
784
  });
573
785
  }
574
786
 
575
- export function adoptNativeTaskByPid({
576
- pid,
577
- command = '',
578
- cwd = '',
579
- timeoutMs = 0,
580
- shellType = null,
581
- ownerSessionId = null,
582
- clientHostPid = null,
583
- jobId = `job_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
584
- } = {}) {
585
- const requestId = _pidToRequest.get(Number(pid));
586
- const server = _ensureServer();
587
- if (!server || !requestId) return null;
787
+ function requestNativeTaskState(jobId, payload, errorLabel) {
788
+ const key = String(jobId || '').trim();
789
+ const server = _server;
790
+ if (!server || !key) return null;
588
791
  const id = ++server.sequence;
589
792
  return new Promise((resolve, reject) => {
590
793
  let timer = null;
@@ -597,14 +800,14 @@ export function adoptNativeTaskByPid({
597
800
  };
598
801
  server.pending.set(id, {
599
802
  fail(error) {
600
- finish(null, error instanceof Error ? error : new Error(String(error || 'native adoption failed')));
803
+ finish(null, error instanceof Error ? error : new Error(String(error || `${errorLabel} failed`)));
601
804
  },
602
805
  onMessage(message) {
603
806
  const event = String(message?.event || '');
604
- if (event === 'task_started') {
605
- finish(getNativeTask(jobId));
807
+ if (event === 'task_started' || event === 'task_status') {
808
+ finish(getNativeTask(key));
606
809
  } else if (event === 'error') {
607
- const error = new Error(String(message.message || 'native adoption failed'));
810
+ const error = new Error(String(message.message || `${errorLabel} failed`));
608
811
  if (message.code) error.code = String(message.code);
609
812
  finish(null, error);
610
813
  }
@@ -612,28 +815,64 @@ export function adoptNativeTaskByPid({
612
815
  });
613
816
  _setServerReferenced(server, true);
614
817
  try {
615
- server.child.stdin.write(`${JSON.stringify({
616
- id,
617
- adopt: requestId,
618
- jobId,
619
- timeoutMs,
620
- command,
621
- cwd,
622
- shellType,
623
- ownerSessionId,
624
- clientHostPid,
625
- })}\n`);
818
+ server.child.stdin.write(`${JSON.stringify({ id, ...payload })}\n`);
626
819
  } catch (error) {
627
820
  finish(null, error);
628
821
  return;
629
822
  }
630
823
  timer = setTimeout(() => {
631
- finish(null, Object.assign(new Error('native task adoption timeout'), { code: 'ETIMEDOUT' }));
824
+ finish(null, Object.assign(new Error(`${errorLabel} timeout`), { code: 'ETIMEDOUT' }));
632
825
  }, 5_000);
633
826
  timer.unref?.();
634
827
  });
635
828
  }
636
829
 
830
+ export function trackNativeForegroundTask({
831
+ child,
832
+ jobId,
833
+ command = '',
834
+ cwd = '',
835
+ shellType = null,
836
+ ownerSessionId = null,
837
+ clientHostPid = null,
838
+ } = {}) {
839
+ const requestId = Number(child?._id) || 0;
840
+ const server = _server;
841
+ if (!server || server.caps?.trackedForeground !== true || !requestId) return null;
842
+ return requestNativeTaskState(jobId, {
843
+ track: requestId,
844
+ jobId,
845
+ command,
846
+ cwd,
847
+ shellType,
848
+ ownerSessionId,
849
+ clientHostPid,
850
+ }, 'native task tracking');
851
+ }
852
+
853
+ export function promoteNativeTask({
854
+ jobId,
855
+ command = '',
856
+ cwd = '',
857
+ timeoutMs = 0,
858
+ shellType = null,
859
+ ownerSessionId = null,
860
+ clientHostPid = null,
861
+ } = {}) {
862
+ const key = String(jobId || '').trim();
863
+ const server = _server;
864
+ if (server?.caps?.promoteTask !== true || !getNativeTask(key)) return null;
865
+ return requestNativeTaskState(key, {
866
+ promoteTask: key,
867
+ timeoutMs,
868
+ command,
869
+ cwd,
870
+ shellType,
871
+ ownerSessionId,
872
+ clientHostPid,
873
+ }, 'native task promotion');
874
+ }
875
+
637
876
  // Capability handshake: true only after the connected spawn server announced
638
877
  // stdinPipe support in its ready line. An older binary silently ignores the
639
878
  // unknown stdinPipe field and gives the child a null stdin — a standby taken
@@ -648,10 +887,21 @@ export function _resetNativeSpawnClientForTest() {
648
887
  _binaryPath = undefined;
649
888
  _lastFailureAt = 0;
650
889
  _tasks.clear();
651
- _pidToRequest.clear();
890
+ _terminalAtMs.clear();
891
+ _stopRetentionSweep();
892
+ _startedAtOverrides.clear();
652
893
  _taskEvents.removeAllListeners();
653
894
  }
654
895
 
896
+ /** Test-only: seed a task record exactly as a spawn-server report would, so
897
+ * cancellation/teardown paths can be exercised without a live server. */
898
+ export function _seedNativeTaskForTest(task) {
899
+ const normalized = normalizeTask(task);
900
+ if (!normalized) return null;
901
+ _tasks.set(normalized.jobId, normalized);
902
+ return normalized;
903
+ }
904
+
655
905
  export function _setNativeSpawnBinaryForTest(binaryPath) {
656
906
  const path = String(binaryPath || '');
657
907
  if (!path || !existsSync(path)) throw new Error(`native spawn test binary not found: ${path}`);
@@ -0,0 +1,104 @@
1
+ import assert from 'node:assert/strict';
2
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+ import test from 'node:test';
6
+ import { setTimeout as delay } from 'node:timers/promises';
7
+
8
+ import { execShellCommand } from '../shell-command.mjs';
9
+ import { retireShellJobRecord } from '../builtin/lib/shell-job-records.mjs';
10
+ import {
11
+ _assertNativeSpawnCapabilitiesForTest,
12
+ _resetNativeSpawnClientForTest,
13
+ _setNativeSpawnBinaryForTest,
14
+ cancelNativeTasks,
15
+ listNativeTasks,
16
+ shutdownNativeSpawnServer,
17
+ waitNativeTask,
18
+ } from './native-spawn-client.mjs';
19
+
20
+ test('native spawn rejects binaries without the required lifecycle protocol', () => {
21
+ assert.throws(
22
+ () => _assertNativeSpawnCapabilitiesForTest({
23
+ trackedForeground: true,
24
+ promoteTask: true,
25
+ }),
26
+ (error) => error?.code === 'NATIVE_SPAWN_INCOMPATIBLE'
27
+ && error.missingCaps?.includes('cancelOwner'),
28
+ );
29
+ assert.equal(_assertNativeSpawnCapabilitiesForTest({
30
+ trackedForeground: true,
31
+ promoteTask: true,
32
+ cancelOwner: true,
33
+ }), true);
34
+ });
35
+
36
+ test('foreground shell has stable owner identity before promotion', { timeout: 15_000 }, async (t) => {
37
+ const binary = resolve(
38
+ 'native/mixdog-spawn/target/debug',
39
+ process.platform === 'win32' ? 'mixdog-spawn.exe' : 'mixdog-spawn',
40
+ );
41
+ if (!existsSync(binary)) {
42
+ t.skip(`native spawn test binary not found: ${binary}`);
43
+ return;
44
+ }
45
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-native-owner-'));
46
+ const previousDataDir = process.env.MIXDOG_DATA_DIR;
47
+ process.env.MIXDOG_DATA_DIR = root;
48
+ const ownerSessionId = `sess_native_owner_${process.pid}`;
49
+ let jobId = null;
50
+ const admission = {
51
+ async acquire() {
52
+ return {
53
+ signal: null,
54
+ async detachDependency() {},
55
+ async release() {},
56
+ };
57
+ },
58
+ };
59
+ try {
60
+ _setNativeSpawnBinaryForTest(binary);
61
+ const running = execShellCommand({
62
+ shell: process.execPath,
63
+ shellArg: '',
64
+ command: 'native stable foreground ownership test',
65
+ directArgv: ['-e', 'setTimeout(() => {}, 10000)'],
66
+ env: process.env,
67
+ cwd: process.cwd(),
68
+ timeoutMs: 5_000,
69
+ autoBackgroundMs: 250,
70
+ backgroundOnTimeout: true,
71
+ promotedTimeoutMs: 5_000,
72
+ backgroundDeadlineMs: 5_000,
73
+ ownerSessionId,
74
+ clientHostPid: process.pid,
75
+ admission,
76
+ });
77
+
78
+ let foreground = null;
79
+ for (let attempt = 0; attempt < 100; attempt += 1) {
80
+ foreground = listNativeTasks().find((task) =>
81
+ task.status === 'running' && task.ownerSessionId === ownerSessionId);
82
+ if (foreground) break;
83
+ await delay(10);
84
+ }
85
+ assert.ok(foreground, 'foreground process was not owner-tracked at spawn');
86
+ jobId = foreground.jobId;
87
+
88
+ const result = await running;
89
+ assert.equal(result.backgrounded, true);
90
+ assert.equal(result.jobId, jobId);
91
+
92
+ assert.ok(cancelNativeTasks({ ownerSessionId }).cancelled >= 1);
93
+ const terminal = await waitNativeTask(jobId, 3_000);
94
+ assert.notEqual(terminal?.status, 'running');
95
+ assert.equal(terminal?.status, 'cancelled');
96
+ } finally {
97
+ if (jobId) retireShellJobRecord(jobId);
98
+ await shutdownNativeSpawnServer('test-finish').catch(() => {});
99
+ _resetNativeSpawnClientForTest();
100
+ if (previousDataDir === undefined) delete process.env.MIXDOG_DATA_DIR;
101
+ else process.env.MIXDOG_DATA_DIR = previousDataDir;
102
+ rmSync(root, { recursive: true, force: true });
103
+ }
104
+ });