mixdog 0.9.51 → 0.9.52

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 (110) hide show
  1. package/package.json +5 -3
  2. package/scripts/abort-recovery-test.mjs +17 -1
  3. package/scripts/agent-model-liveness-test.mjs +79 -2
  4. package/scripts/anthropic-admission-retry-integration-test.mjs +119 -0
  5. package/scripts/anthropic-transport-policy-test.mjs +466 -0
  6. package/scripts/atomic-lock-tryonce-test.mjs +60 -1
  7. package/scripts/build-tui.mjs +13 -1
  8. package/scripts/channel-daemon-smoke.mjs +630 -10
  9. package/scripts/code-graph-aggregate-cwd-test.mjs +41 -37
  10. package/scripts/code-graph-disk-hit-test.mjs +11 -0
  11. package/scripts/code-graph-root-federation-test.mjs +273 -0
  12. package/scripts/compact-pressure-test.mjs +55 -1
  13. package/scripts/compact-smoke.mjs +80 -0
  14. package/scripts/context-mcp-metering-test.mjs +1350 -19
  15. package/scripts/deferred-tool-loading-test.mjs +17 -0
  16. package/scripts/gemini-provider-test.mjs +1053 -0
  17. package/scripts/interrupted-turn-history-test.mjs +371 -0
  18. package/scripts/lifecycle-api-test.mjs +76 -0
  19. package/scripts/max-output-recovery-test.mjs +55 -0
  20. package/scripts/mcp-grace-deferred-test.mjs +89 -13
  21. package/scripts/memory-pg-recovery-test.mjs +59 -0
  22. package/scripts/openai-oauth-ws-1006-retry-test.mjs +324 -3
  23. package/scripts/process-lifecycle-test.mjs +389 -0
  24. package/scripts/provider-admission-scheduler-test.mjs +582 -0
  25. package/scripts/provider-contract-test.mjs +268 -0
  26. package/scripts/provider-toolcall-test.mjs +320 -1
  27. package/scripts/reactive-compact-persist-smoke.mjs +59 -0
  28. package/scripts/resource-admission-test.mjs +789 -0
  29. package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
  30. package/scripts/steering-drain-buckets-test.mjs +18 -0
  31. package/scripts/toolcall-args-test.mjs +14 -6
  32. package/scripts/tui-transcript-perf-test.mjs +5 -7
  33. package/src/cli.mjs +15 -2
  34. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +26 -0
  35. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
  36. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +331 -0
  37. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +118 -26
  38. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +29 -17
  39. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -38
  40. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +24 -4
  41. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
  42. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
  43. package/src/runtime/agent/orchestrator/providers/gemini.mjs +95 -46
  44. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -4
  45. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
  46. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
  47. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -3
  48. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
  49. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +89 -17
  50. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +93 -26
  51. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +130 -47
  52. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
  53. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +36 -3
  54. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -17
  55. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
  56. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
  57. package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
  58. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +224 -104
  59. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +122 -63
  60. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
  61. package/src/runtime/agent/orchestrator/session/context-utils.mjs +17 -1
  62. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +69 -37
  63. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
  64. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
  65. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +6 -7
  66. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
  67. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
  68. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
  69. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
  70. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +20 -4
  71. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
  72. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
  73. package/src/runtime/agent/orchestrator/session/store.mjs +89 -22
  74. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
  75. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
  76. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
  77. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
  78. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +380 -37
  79. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
  80. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
  81. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
  82. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
  83. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
  84. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -14
  85. package/src/runtime/memory/index.mjs +22 -4
  86. package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
  87. package/src/runtime/memory/lib/pg/process.mjs +91 -47
  88. package/src/runtime/memory/lib/pg/supervisor.mjs +50 -13
  89. package/src/runtime/shared/atomic-file.mjs +28 -150
  90. package/src/runtime/shared/process-lifecycle.mjs +363 -0
  91. package/src/runtime/shared/process-shutdown.mjs +27 -4
  92. package/src/runtime/shared/resource-admission.mjs +359 -0
  93. package/src/runtime/shared/staged-child-result.mjs +19 -0
  94. package/src/session-runtime/context-status.mjs +38 -27
  95. package/src/session-runtime/lifecycle-api.mjs +26 -6
  96. package/src/session-runtime/provider-request-tools.mjs +72 -0
  97. package/src/session-runtime/runtime-core.mjs +6 -3
  98. package/src/session-runtime/session-turn-api.mjs +5 -4
  99. package/src/session-runtime/tool-catalog.mjs +375 -15
  100. package/src/standalone/agent-tool.mjs +17 -38
  101. package/src/standalone/channel-daemon-client.mjs +200 -38
  102. package/src/standalone/channel-daemon-transport.mjs +136 -9
  103. package/src/standalone/channel-worker.mjs +79 -12
  104. package/src/tui/dist/index.mjs +73 -278
  105. package/src/tui/engine/session-api-ext.mjs +13 -2
  106. package/src/tui/engine/session-api.mjs +1 -1
  107. package/src/tui/engine/turn.mjs +10 -6
  108. package/src/tui/engine.mjs +13 -4
  109. package/src/tui/index.jsx +4 -1
  110. package/src/tui/lib/voice-setup.mjs +16 -0
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'child_process';
2
- import { closeSync, existsSync, openSync, readFileSync, readSync, statSync, watch as fsWatch, writeFileSync } from 'fs';
2
+ import { closeSync, existsSync, openSync, readFileSync, readSync, statSync, unlinkSync, watch as fsWatch, writeFileSync } from 'fs';
3
3
  import { basename } from 'path';
4
4
  import { stripAnsi } from '../shell-command.mjs';
5
5
  import { scrubLoaderVars, scrubProviderSecrets } from '../env-scrub.mjs';
@@ -34,6 +34,7 @@ import {
34
34
  } from './shell-job-paths.mjs';
35
35
  import {
36
36
  isPidAlive,
37
+ trackProcessTreeQuiescence,
37
38
  killProcessTree,
38
39
  _unregisterLiveJobPid,
39
40
  _installShellJobsExitHook,
@@ -53,6 +54,88 @@ function sleep(ms) {
53
54
  return new Promise((resolve) => setTimeout(resolve, ms));
54
55
  }
55
56
 
57
+ const SPAWN_ERROR_GUARD = Symbol('mixdog.spawnErrorGuard');
58
+ function awaitSpawnReady(child, label) {
59
+ return new Promise((resolve, reject) => {
60
+ if (!child || typeof child.once !== 'function') {
61
+ reject(new Error(`${label} spawn returned no child process`));
62
+ return;
63
+ }
64
+ let spawned = false;
65
+ let bufferedError = null;
66
+ const cleanup = () => child.removeListener('spawn', onSpawn);
67
+ const onError = (error) => {
68
+ if (spawned) {
69
+ bufferedError = bufferedError || error;
70
+ return;
71
+ }
72
+ cleanup();
73
+ child.removeListener('error', onError);
74
+ reject(error);
75
+ };
76
+ const onSpawn = () => {
77
+ cleanup();
78
+ spawned = true;
79
+ const pid = Number(child.pid);
80
+ if (!Number.isFinite(pid) || pid <= 0) {
81
+ child.removeListener('error', onError);
82
+ reject(new Error(`${label} spawn returned no pid`));
83
+ return;
84
+ }
85
+ child[SPAWN_ERROR_GUARD] = {
86
+ adopt(handler) {
87
+ child.on('error', handler);
88
+ child.removeListener('error', onError);
89
+ if (bufferedError) handler(bufferedError);
90
+ delete child[SPAWN_ERROR_GUARD];
91
+ },
92
+ discard() {
93
+ child.removeListener('error', onError);
94
+ delete child[SPAWN_ERROR_GUARD];
95
+ },
96
+ };
97
+ resolve(child);
98
+ };
99
+ child.once('error', onError);
100
+ child.once('spawn', onSpawn);
101
+ });
102
+ }
103
+
104
+ function adoptSpawnErrorHandler(child, handler) {
105
+ const guard = child?.[SPAWN_ERROR_GUARD];
106
+ if (guard) guard.adopt(handler);
107
+ else child?.on?.('error', handler);
108
+ }
109
+
110
+ async function rollbackSpawnedChild(child, { timeoutMs = 5000 } = {}) {
111
+ if (!child || child.exitCode != null || child.signalCode != null) {
112
+ return { confirmed: true, errors: [] };
113
+ }
114
+ const errors = [];
115
+ let timer = null;
116
+ let settled = false;
117
+ const outcome = await new Promise((resolve) => {
118
+ const finish = (confirmed) => {
119
+ if (settled) return;
120
+ settled = true;
121
+ if (timer) clearTimeout(timer);
122
+ child.removeListener('exit', onExit);
123
+ child.removeListener('close', onExit);
124
+ child.removeListener('error', onError);
125
+ resolve({ confirmed, errors });
126
+ };
127
+ const onExit = () => finish(true);
128
+ const onError = (error) => { errors.push(error); };
129
+ child.on('error', onError);
130
+ child.once('exit', onExit);
131
+ child.once('close', onExit);
132
+ timer = setTimeout(() => finish(false), Math.max(1, Number(timeoutMs) || 5000));
133
+ try { killProcessTree(child.pid, 'SIGKILL'); } catch (error) { errors.push(error); }
134
+ try { child.kill?.('SIGKILL'); } catch (error) { errors.push(error); }
135
+ });
136
+ return outcome;
137
+ }
138
+
56
139
  const JOB_STATUS_PREVIEW_MAX_BYTES = 4096;
57
140
  const JOB_STATUS_PREVIEW_MAX_LINES = 20;
58
141
  const JOB_STATUS_PREVIEW_MAX_CHARS = 1200;
@@ -300,6 +383,11 @@ export function killShellJob(jobId) {
300
383
  const detail = readShellJobDetail(jobId);
301
384
  if (!detail) return null;
302
385
  if (detail.status !== 'running') {
386
+ if (detail.terminationPending === true) {
387
+ killProcessTree(detail.pid, 'SIGKILL');
388
+ return { ...detail, killed: true, note: 'termination still awaiting confirmed process exit' };
389
+ }
390
+ releaseShellJobOwnershipWhenQuiescent(jobId, detail.pid);
303
391
  return { ...detail, killed: false, note: `task already ${detail.status}` };
304
392
  }
305
393
  killProcessTree(detail.pid, 'SIGTERM');
@@ -308,8 +396,14 @@ export function killShellJob(jobId) {
308
396
  detail.error = 'killed by user (KillShell)';
309
397
  detail.finishedAt = new Date().toISOString();
310
398
  trimShellJobSpill(detail);
399
+ detail.terminationPending = true;
311
400
  writeShellJobDetail(detail);
312
- _unregisterLiveJobPid(detail.pid);
401
+ releaseShellJobOwnershipWhenQuiescent(jobId, detail.pid, {
402
+ onConfirmed: () => {
403
+ detail.terminationPending = false;
404
+ try { writeShellJobDetail(detail); } catch {}
405
+ },
406
+ });
313
407
  return { ...attachJobInsights(detail), killed: true };
314
408
  }
315
409
 
@@ -343,7 +437,7 @@ function refreshShellJob(jobId) {
343
437
  // producer can't leave a 100MB stdout/stderr log behind.
344
438
  trimShellJobSpill(detail);
345
439
  writeShellJobDetail(detail);
346
- _unregisterLiveJobPid(detail.pid);
440
+ releaseShellJobOwnershipWhenQuiescent(jobId, detail.pid);
347
441
  return detail;
348
442
  }
349
443
  const timeoutMs = Number(detail.timeoutMs || 0);
@@ -355,8 +449,14 @@ function refreshShellJob(jobId) {
355
449
  detail.finishedAt = new Date().toISOString();
356
450
  detail.error = `timed out after ${timeoutMs} ms`;
357
451
  trimShellJobSpill(detail);
452
+ detail.terminationPending = true;
358
453
  writeShellJobDetail(detail);
359
- _unregisterLiveJobPid(detail.pid);
454
+ releaseShellJobOwnershipWhenQuiescent(jobId, detail.pid, {
455
+ onConfirmed: () => {
456
+ detail.terminationPending = false;
457
+ try { writeShellJobDetail(detail); } catch {}
458
+ },
459
+ });
360
460
  return detail;
361
461
  }
362
462
  // Output size watchdog: cap the job's spilled stdout/stderr at the same
@@ -369,8 +469,14 @@ function refreshShellJob(jobId) {
369
469
  detail.finishedAt = new Date().toISOString();
370
470
  detail.error = `output exceeded ${SHELL_JOB_OUTPUT_DISK_CAP} byte cap`;
371
471
  trimShellJobSpill(detail);
472
+ detail.terminationPending = true;
372
473
  writeShellJobDetail(detail);
373
- _unregisterLiveJobPid(detail.pid);
474
+ releaseShellJobOwnershipWhenQuiescent(jobId, detail.pid, {
475
+ onConfirmed: () => {
476
+ detail.terminationPending = false;
477
+ try { writeShellJobDetail(detail); } catch {}
478
+ },
479
+ });
374
480
  return detail;
375
481
  }
376
482
  if (detail.pid && !isPidAlive(detail.pid)) {
@@ -378,13 +484,13 @@ function refreshShellJob(jobId) {
378
484
  detail.finishedAt = new Date().toISOString();
379
485
  detail.error = 'process exited without completion marker';
380
486
  writeShellJobDetail(detail);
381
- _unregisterLiveJobPid(detail.pid);
487
+ releaseShellJobOwnershipWhenQuiescent(jobId, detail.pid);
382
488
  }
383
489
  return detail;
384
490
  }
385
491
 
386
- export function startBackgroundShellJob({ command, timeoutMs, workDir, mergeStderr, spawnEnv, shell, shellArg, shellArgs, shellType, clientHostPid }) {
387
- return _startBackgroundShellJobImpl({ command, timeoutMs, workDir, mergeStderr, spawnEnv, shell, shellArg, shellArgs, shellType, clientHostPid });
492
+ export async function startBackgroundShellJob(options) {
493
+ return _startBackgroundShellJobImpl(options || {});
388
494
  }
389
495
 
390
496
  // In-process completion watcher. After a background shell task is spawned the
@@ -417,6 +523,86 @@ const backgroundShellJobWatchers = new Map();
417
523
  // cannot be armed because the direct notifyFn is unavailable, this poller still
418
524
  // reconciles the shell job into the shared background-task row.
419
525
  const shellTaskSafetyPollers = new Map();
526
+ const shellJobResourceLeases = new Map();
527
+ const unpersistedTerminalJobs = new Set();
528
+ const shellJobQuiescenceTrackers = new Map();
529
+ function releaseShellJobResourceLease(jobId) {
530
+ const lease = shellJobResourceLeases.get(jobId);
531
+ if (!lease) return false;
532
+ shellJobResourceLeases.delete(jobId);
533
+ try { Promise.resolve(lease.release()).catch(() => {}); } catch { /* admission cleanup must not mask job state */ }
534
+ return true;
535
+ }
536
+ export function attachShellJobResourceLease(jobId, lease, { allowUnpersisted = false } = {}) {
537
+ if (!jobId || !lease || typeof lease.release !== 'function') return false;
538
+ if (allowUnpersisted && unpersistedTerminalJobs.delete(jobId)) {
539
+ try { Promise.resolve(lease.release()).catch(() => {}); } catch {}
540
+ return false;
541
+ }
542
+ const detail = readShellJobDetail(jobId);
543
+ if ((!detail && !allowUnpersisted) || (detail && detail.status !== 'running')) {
544
+ try { Promise.resolve(lease.release()).catch(() => {}); } catch {}
545
+ return false;
546
+ }
547
+ releaseShellJobResourceLease(jobId);
548
+ shellJobResourceLeases.set(jobId, lease);
549
+ return true;
550
+ }
551
+ function releaseShellJobOwnershipWhenQuiescent(jobId, pid, {
552
+ onConfirmed = null,
553
+ allowLateLease = false,
554
+ deferUntilRootExit = false,
555
+ } = {}) {
556
+ const numericPid = Number(pid);
557
+ if (!jobId || !Number.isFinite(numericPid) || numericPid <= 0) return false;
558
+ const existing = shellJobQuiescenceTrackers.get(jobId);
559
+ if (existing) {
560
+ if (onConfirmed) existing.callbacks.add(onConfirmed);
561
+ if (allowLateLease) existing.allowLateLease = true;
562
+ if (!deferUntilRootExit) existing.tracker?.rootExited?.();
563
+ return true;
564
+ }
565
+ const entry = {
566
+ callbacks: new Set(onConfirmed ? [onConfirmed] : []),
567
+ allowLateLease,
568
+ tracker: null,
569
+ };
570
+ shellJobQuiescenceTrackers.set(jobId, entry);
571
+ entry.tracker = trackProcessTreeQuiescence(numericPid, () => {
572
+ shellJobQuiescenceTrackers.delete(jobId);
573
+ _unregisterLiveJobPid(numericPid);
574
+ for (const callback of entry.callbacks) {
575
+ try { callback(); } catch {}
576
+ }
577
+ if (!releaseShellJobResourceLease(jobId) && entry.allowLateLease) {
578
+ unpersistedTerminalJobs.add(jobId);
579
+ }
580
+ }, { waitForRootExit: deferUntilRootExit });
581
+ return true;
582
+ }
583
+ function trackChildUntilConfirmedExit(child, jobId, onConfirmed = null) {
584
+ const pid = Number(child?.pid);
585
+ if (!Number.isFinite(pid) || pid <= 0) return false;
586
+ _installShellJobsExitHook();
587
+ _registerLiveJobPid(pid, jobId);
588
+ let settled = false;
589
+ const onError = () => {};
590
+ const finish = () => {
591
+ if (settled) return;
592
+ settled = true;
593
+ child.removeListener('exit', finish);
594
+ child.removeListener('close', finish);
595
+ child.removeListener('error', onError);
596
+ releaseShellJobOwnershipWhenQuiescent(jobId, pid, {
597
+ onConfirmed,
598
+ allowLateLease: true,
599
+ });
600
+ };
601
+ child.on('error', onError);
602
+ child.once('exit', finish);
603
+ child.once('close', finish);
604
+ return true;
605
+ }
420
606
  // Persistent notify ctx per jobId, set at FIRST arm and surviving cancel — so a
421
607
  // re-arm after a task wait timeout can reconstruct the notify wiring without the
422
608
  // caller threading the ctx back in. Deleted only in the watcher's cleanup() on
@@ -524,8 +710,14 @@ function markShellJobCancelledByShutdown(jobId, reason = 'shutdown') {
524
710
  detail.finishedAt = new Date().toISOString();
525
711
  try { writeFileSync(detail.exitPath || shellJobExitPath(jobId), '137'); } catch { /* ignore */ }
526
712
  try { writeFileSync(detail.donePath || shellJobDonePath(jobId), ''); } catch { /* ignore */ }
713
+ detail.terminationPending = true;
527
714
  writeShellJobDetail(detail);
528
- _unregisterLiveJobPid(detail.pid);
715
+ releaseShellJobOwnershipWhenQuiescent(jobId, detail.pid, {
716
+ onConfirmed: () => {
717
+ detail.terminationPending = false;
718
+ try { writeShellJobDetail(detail); } catch {}
719
+ },
720
+ });
529
721
  return true;
530
722
  }
531
723
  // Register a synchronous task waiter. Paired with endShellJobWait in a
@@ -555,12 +747,11 @@ export function shutdownShellJobs(reason = 'runtime-close', { sync = false } = {
555
747
  try { cancelBackgroundShellJobWatch(jobId); } catch { /* ignore */ }
556
748
  }
557
749
  for (const pid of livePids) _killLiveJobPid(pid, { sync });
558
- _liveJobPids.clear();
559
- _liveJobIdsByPid.clear();
560
750
  let marked = 0;
561
751
  for (const jobId of jobIds) {
562
752
  try { if (markShellJobCancelledByShutdown(jobId, reason)) marked += 1; } catch { /* ignore */ }
563
753
  }
754
+ unpersistedTerminalJobs.clear();
564
755
  backgroundShellJobWatchers.clear();
565
756
  for (const jobId of [...shellTaskSafetyPollers.keys()]) clearShellTaskSafetyNet(jobId);
566
757
  jobNotifyCtxByJobId.clear();
@@ -818,13 +1009,20 @@ function _armAdoptedJobCapPoll(jobId) {
818
1009
  return tick;
819
1010
  }
820
1011
 
821
- function _startBackgroundShellJobImpl({ command, timeoutMs, workDir, mergeStderr, spawnEnv, shell, shellArg, shellArgs, shellType, clientHostPid }) {
1012
+ async function _startBackgroundShellJobImpl({
1013
+ command, timeoutMs, workDir, mergeStderr, spawnEnv, shell, shellArg, shellArgs,
1014
+ shellType, clientHostPid, spawnFn = spawn, writeDetailFn = writeShellJobDetail,
1015
+ rollbackTimeoutMs = 5000,
1016
+ }) {
822
1017
  // Route ANY PowerShell shell to the PS wrapper, regardless of platform.
823
1018
  // Gating on win32 sent shell:'powershell' on macOS/Linux down the POSIX
824
1019
  // path below, which spawns `pwsh <wrapper>.sh` — pwsh then tries to run a
825
1020
  // bash script. isPowerShellShell already matches pwsh/powershell by stem.
826
1021
  if (isPowerShellShell(shell, shellType)) {
827
- return startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr, spawnEnv, shell, clientHostPid });
1022
+ return startBackgroundPowerShellJob({
1023
+ command, timeoutMs, workDir, mergeStderr, spawnEnv, shell, clientHostPid,
1024
+ spawnFn, writeDetailFn,
1025
+ });
828
1026
  }
829
1027
 
830
1028
  // POSIX-shell wrapper path. On Windows this runs for shell:'bash' (Git
@@ -903,19 +1101,19 @@ function _startBackgroundShellJobImpl({ command, timeoutMs, workDir, mergeStderr
903
1101
  // R11: scrub loader/execution vars even though bash-tool.mjs already
904
1102
  // scrubs upstream — defense-in-depth at the spawn site catches future
905
1103
  // callers that build their own spawnEnv.
906
- const child = spawn(shell, [wrappedTempPath], {
907
- cwd: workDir,
908
- env: scrubLoaderVars(scrubProviderSecrets({ ...spawnEnv })),
909
- stdio: 'ignore',
910
- ...detachedSpawnOpts,
911
- });
912
- startChildGuardian({
913
- childPid: child.pid,
914
- childGroupPid: child.pid,
915
- label: 'shell-job',
916
- });
917
- _installShellJobsExitHook();
918
- _registerLiveJobPid(child.pid, jobId);
1104
+ let child;
1105
+ try {
1106
+ child = spawnFn(shell, [wrappedTempPath], {
1107
+ cwd: workDir,
1108
+ env: scrubLoaderVars(scrubProviderSecrets({ ...spawnEnv })),
1109
+ stdio: 'ignore',
1110
+ ...detachedSpawnOpts,
1111
+ });
1112
+ await awaitSpawnReady(child, 'POSIX background task');
1113
+ } catch (e) {
1114
+ try { unlinkSync(wrappedTempPath); } catch {}
1115
+ return { jobId, kind: 'bash', status: 'failed', error: `failed to spawn shell background task: ${e?.message || e}` };
1116
+ }
919
1117
  const detail = {
920
1118
  jobId,
921
1119
  kind: 'bash',
@@ -934,7 +1132,79 @@ function _startBackgroundShellJobImpl({ command, timeoutMs, workDir, mergeStderr
934
1132
  ownerHostPid: resolveJobOwnerHostPid(clientHostPid),
935
1133
  startedAt: new Date().toISOString(),
936
1134
  };
937
- writeShellJobDetail(detail);
1135
+ let terminal = false;
1136
+ let runtimeOwned = false;
1137
+ const onRuntimeError = (error) => {
1138
+ if (terminal) return;
1139
+ terminal = true;
1140
+ detail.status = 'failed';
1141
+ detail.terminationPending = true;
1142
+ detail.error = `shell background task process error: ${error?.message || error}`;
1143
+ detail.finishedAt = new Date().toISOString();
1144
+ try { writeDetailFn(detail); } catch {}
1145
+ if (runtimeOwned) void (async () => {
1146
+ const rollback = await rollbackSpawnedChild(child, { timeoutMs: rollbackTimeoutMs });
1147
+ if (rollback.confirmed) {
1148
+ releaseShellJobOwnershipWhenQuiescent(jobId, child.pid, {
1149
+ onConfirmed: () => {
1150
+ detail.terminationPending = false;
1151
+ try { writeDetailFn(detail); } catch {}
1152
+ },
1153
+ });
1154
+ } else {
1155
+ detail.error += `; termination unconfirmed after ${rollbackTimeoutMs}ms (process remains tracked)`;
1156
+ try { writeDetailFn(detail); } catch {}
1157
+ trackChildUntilConfirmedExit(child, jobId, () => {
1158
+ detail.terminationPending = false;
1159
+ try { writeDetailFn(detail); } catch {}
1160
+ });
1161
+ }
1162
+ })();
1163
+ };
1164
+ adoptSpawnErrorHandler(child, onRuntimeError);
1165
+ if (terminal) {
1166
+ const rollback = await rollbackSpawnedChild(child, { timeoutMs: rollbackTimeoutMs });
1167
+ detail.terminationPending = !rollback.confirmed;
1168
+ try { writeDetailFn(detail); } catch {}
1169
+ try { unlinkSync(wrappedTempPath); } catch {}
1170
+ return rollback.confirmed
1171
+ ? detail
1172
+ : {
1173
+ ...detail,
1174
+ rollbackPending: trackChildUntilConfirmedExit(child, jobId, () => {
1175
+ detail.terminationPending = false;
1176
+ try { writeDetailFn(detail); } catch {}
1177
+ }),
1178
+ error: `${detail.error}; termination unconfirmed after ${rollbackTimeoutMs}ms (process remains tracked)`,
1179
+ };
1180
+ }
1181
+ try {
1182
+ writeDetailFn(detail);
1183
+ } catch (e) {
1184
+ terminal = true;
1185
+ const rollback = await rollbackSpawnedChild(child, { timeoutMs: rollbackTimeoutMs });
1186
+ child.removeListener('error', onRuntimeError);
1187
+ child[SPAWN_ERROR_GUARD]?.discard?.();
1188
+ try { unlinkSync(wrappedTempPath); } catch {}
1189
+ const failure = { jobId, kind: 'bash', status: 'failed', error: `failed to persist shell background task: ${e?.message || e}` };
1190
+ if (!rollback.confirmed) {
1191
+ failure.rollbackPending = trackChildUntilConfirmedExit(child, jobId);
1192
+ failure.error += `; termination unconfirmed after ${rollbackTimeoutMs}ms (process remains tracked)`;
1193
+ }
1194
+ return failure;
1195
+ }
1196
+ startChildGuardian({
1197
+ childPid: child.pid,
1198
+ childGroupPid: child.pid,
1199
+ label: 'shell-job',
1200
+ });
1201
+ _installShellJobsExitHook();
1202
+ _registerLiveJobPid(child.pid, jobId);
1203
+ runtimeOwned = true;
1204
+ releaseShellJobOwnershipWhenQuiescent(jobId, child.pid, {
1205
+ allowLateLease: true,
1206
+ deferUntilRootExit: true,
1207
+ });
938
1208
  // Deadline cleanup poke only when a timeout is enforced; an unlimited
939
1209
  // (timeoutMs<=0) job has no deadline — completion is observed via the
940
1210
  // fs.watch/poll watcher and refreshShellJob on task queries.
@@ -945,7 +1215,10 @@ function _startBackgroundShellJobImpl({ command, timeoutMs, workDir, mergeStderr
945
1215
  return detail;
946
1216
  }
947
1217
 
948
- function startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr, spawnEnv, shell, clientHostPid }) {
1218
+ async function startBackgroundPowerShellJob({
1219
+ command, timeoutMs, workDir, mergeStderr, spawnEnv, shell, clientHostPid,
1220
+ spawnFn = spawn, writeDetailFn = writeShellJobDetail, rollbackTimeoutMs = 5000,
1221
+ }) {
949
1222
  const jobId = `job_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
950
1223
  const stdoutPath = shellJobStdoutPath(jobId);
951
1224
  const rawStderrPath = shellJobStderrPath(jobId);
@@ -1059,27 +1332,23 @@ function startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr
1059
1332
  // process; runtime.close()/exit hooks reap it instead of daemonizing it.
1060
1333
  let child;
1061
1334
  try {
1062
- child = spawn(shell, wrapperArgs, {
1335
+ child = spawnFn(shell, wrapperArgs, {
1063
1336
  cwd: workDir,
1064
1337
  env: scrubLoaderVars(scrubProviderSecrets({ ...spawnEnv })),
1065
1338
  detached: false,
1066
1339
  stdio: 'ignore',
1067
1340
  windowsHide: true,
1068
1341
  });
1069
- startChildGuardian({
1070
- childPid: child.pid,
1071
- childGroupPid: child.pid,
1072
- label: 'shell-job-powershell',
1073
- });
1342
+ await awaitSpawnReady(child, 'PowerShell background task');
1074
1343
  } catch (e) {
1344
+ try { unlinkSync(wrappedTempPath); } catch {}
1345
+ try { unlinkSync(innerTempPath); } catch {}
1075
1346
  return { jobId, kind: 'bash', status: 'failed', error: `failed to spawn PowerShell background task: ${e?.message || e}` };
1076
1347
  }
1077
1348
  const childPid = child.pid;
1078
1349
  if (!Number.isFinite(childPid) || childPid <= 0) {
1079
1350
  return { jobId, kind: 'bash', status: 'failed', error: 'PowerShell background task spawn returned no pid' };
1080
1351
  }
1081
- _installShellJobsExitHook();
1082
- _registerLiveJobPid(childPid, jobId);
1083
1352
  const detail = {
1084
1353
  jobId,
1085
1354
  kind: 'bash',
@@ -1103,7 +1372,81 @@ function startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr
1103
1372
  ownerHostPid: resolveJobOwnerHostPid(clientHostPid),
1104
1373
  startedAt: new Date().toISOString(),
1105
1374
  };
1106
- writeShellJobDetail(detail);
1375
+ let terminal = false;
1376
+ let runtimeOwned = false;
1377
+ const onRuntimeError = (error) => {
1378
+ if (terminal) return;
1379
+ terminal = true;
1380
+ detail.status = 'failed';
1381
+ detail.terminationPending = true;
1382
+ detail.error = `PowerShell background task process error: ${error?.message || error}`;
1383
+ detail.finishedAt = new Date().toISOString();
1384
+ try { writeDetailFn(detail); } catch {}
1385
+ if (runtimeOwned) void (async () => {
1386
+ const rollback = await rollbackSpawnedChild(child, { timeoutMs: rollbackTimeoutMs });
1387
+ if (rollback.confirmed) {
1388
+ releaseShellJobOwnershipWhenQuiescent(jobId, childPid, {
1389
+ onConfirmed: () => {
1390
+ detail.terminationPending = false;
1391
+ try { writeDetailFn(detail); } catch {}
1392
+ },
1393
+ });
1394
+ } else {
1395
+ detail.error += `; termination unconfirmed after ${rollbackTimeoutMs}ms (process remains tracked)`;
1396
+ try { writeDetailFn(detail); } catch {}
1397
+ trackChildUntilConfirmedExit(child, jobId, () => {
1398
+ detail.terminationPending = false;
1399
+ try { writeDetailFn(detail); } catch {}
1400
+ });
1401
+ }
1402
+ })();
1403
+ };
1404
+ adoptSpawnErrorHandler(child, onRuntimeError);
1405
+ if (terminal) {
1406
+ const rollback = await rollbackSpawnedChild(child, { timeoutMs: rollbackTimeoutMs });
1407
+ detail.terminationPending = !rollback.confirmed;
1408
+ try { writeDetailFn(detail); } catch {}
1409
+ try { unlinkSync(wrappedTempPath); } catch {}
1410
+ try { unlinkSync(innerTempPath); } catch {}
1411
+ return rollback.confirmed
1412
+ ? detail
1413
+ : {
1414
+ ...detail,
1415
+ rollbackPending: trackChildUntilConfirmedExit(child, jobId, () => {
1416
+ detail.terminationPending = false;
1417
+ try { writeDetailFn(detail); } catch {}
1418
+ }),
1419
+ error: `${detail.error}; termination unconfirmed after ${rollbackTimeoutMs}ms (process remains tracked)`,
1420
+ };
1421
+ }
1422
+ try {
1423
+ writeDetailFn(detail);
1424
+ } catch (e) {
1425
+ terminal = true;
1426
+ const rollback = await rollbackSpawnedChild(child, { timeoutMs: rollbackTimeoutMs });
1427
+ child.removeListener('error', onRuntimeError);
1428
+ child[SPAWN_ERROR_GUARD]?.discard?.();
1429
+ try { unlinkSync(wrappedTempPath); } catch {}
1430
+ try { unlinkSync(innerTempPath); } catch {}
1431
+ const failure = { jobId, kind: 'bash', status: 'failed', error: `failed to persist PowerShell background task: ${e?.message || e}` };
1432
+ if (!rollback.confirmed) {
1433
+ failure.rollbackPending = trackChildUntilConfirmedExit(child, jobId);
1434
+ failure.error += `; termination unconfirmed after ${rollbackTimeoutMs}ms (process remains tracked)`;
1435
+ }
1436
+ return failure;
1437
+ }
1438
+ startChildGuardian({
1439
+ childPid,
1440
+ childGroupPid: childPid,
1441
+ label: 'shell-job-powershell',
1442
+ });
1443
+ _installShellJobsExitHook();
1444
+ _registerLiveJobPid(childPid, jobId);
1445
+ runtimeOwned = true;
1446
+ releaseShellJobOwnershipWhenQuiescent(jobId, childPid, {
1447
+ allowLateLease: true,
1448
+ deferUntilRootExit: true,
1449
+ });
1107
1450
  if (Number(timeoutMs) > 0) {
1108
1451
  const timer = setTimeout(() => { refreshShellJob(jobId); }, Math.min(TIMER_MAX_MS, timeoutMs + 25));
1109
1452
  if (typeof timer.unref === 'function') timer.unref();