@aiwg/cli 2026.10.5 → 2026.10.7

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 (263) hide show
  1. package/agentic/code/addons/composition-engine/lib/runtime.mjs +1401 -0
  2. package/agentic/code/addons/composition-engine/lib/validator.mjs +513 -0
  3. package/agentic/code/addons/composition-engine/schemas/flow-graph.schema.json +372 -0
  4. package/agentic/code/providers/model-capabilities.v1.json +74 -50
  5. package/bin/aiwg.mjs +7 -3
  6. package/dist/src/a2a/agent-card.js +11 -0
  7. package/dist/src/api/index.d.ts +1 -0
  8. package/dist/src/api/index.js +1 -0
  9. package/dist/src/artifacts/browser-export.js +1 -0
  10. package/dist/src/artifacts/citation-parser.js +8 -1
  11. package/dist/src/artifacts/cli.js +57 -6
  12. package/dist/src/artifacts/fortemi-core-query-adapter.js +2 -0
  13. package/dist/src/artifacts/fortemi-core-sync.js +26 -8
  14. package/dist/src/artifacts/fortemi-shard-contract/2.2.0/contract.json +103 -0
  15. package/dist/src/artifacts/fortemi-shard-contract/2.2.0/full-v1-embedding-config.schema.json +212 -0
  16. package/dist/src/artifacts/fortemi-shard-contract/2.2.0/full-v1-embedding-set.schema.json +240 -0
  17. package/dist/src/artifacts/fortemi-shard-contract/2.2.0/full-v1-manifest.schema.json +451 -0
  18. package/dist/src/artifacts/fortemi-shard-export.js +284 -0
  19. package/dist/src/artifacts/graph-query.js +18 -8
  20. package/dist/src/artifacts/index-adapters.js +339 -0
  21. package/dist/src/artifacts/index-builder.js +143 -14
  22. package/dist/src/artifacts/index-files.js +15 -4
  23. package/dist/src/artifacts/index-preparation.js +2 -0
  24. package/dist/src/artifacts/index-reader.js +44 -0
  25. package/dist/src/artifacts/index-refresh.js +114 -55
  26. package/dist/src/artifacts/index-status.js +20 -5
  27. package/dist/src/artifacts/memory-maintenance.js +503 -0
  28. package/dist/src/artifacts/prebuilt-relations.js +3 -1
  29. package/dist/src/artifacts/query-engine.js +263 -108
  30. package/dist/src/artifacts/relations-builder.js +2 -2
  31. package/dist/src/artifacts/source-graph.js +2 -2
  32. package/dist/src/artifacts/types.js +9 -1
  33. package/dist/src/auth/client.js +5 -1
  34. package/dist/src/auth/credential-store.js +5 -1
  35. package/dist/src/auth/sensitive-input.js +151 -0
  36. package/dist/src/channel/manager.mjs +3 -0
  37. package/dist/src/cli/handlers/agentcard.js +47 -25
  38. package/dist/src/cli/handlers/auth.js +22 -2
  39. package/dist/src/cli/handlers/decision.js +22 -3
  40. package/dist/src/cli/handlers/effect.js +4 -84
  41. package/dist/src/cli/handlers/guided-setup.js +470 -0
  42. package/dist/src/cli/handlers/help.js +2 -2
  43. package/dist/src/cli/handlers/housekeeper.js +1110 -0
  44. package/dist/src/cli/handlers/index.js +4 -2
  45. package/dist/src/cli/handlers/init.js +41 -6
  46. package/dist/src/cli/handlers/install.js +6 -0
  47. package/dist/src/cli/handlers/local-executor.js +5 -3
  48. package/dist/src/cli/handlers/marketplace.js +15 -4
  49. package/dist/src/cli/handlers/mc.js +509 -53
  50. package/dist/src/cli/handlers/models.js +32 -2
  51. package/dist/src/cli/handlers/ralph-launcher.js +76 -4
  52. package/dist/src/cli/handlers/rules.js +1 -1
  53. package/dist/src/cli/handlers/script-runner.js +73 -2
  54. package/dist/src/cli/handlers/serve.js +5 -3
  55. package/dist/src/cli/handlers/sessions.js +1 -0
  56. package/dist/src/cli/handlers/setup.js +432 -1
  57. package/dist/src/cli/handlers/skill-lint.js +1 -1
  58. package/dist/src/cli/handlers/subcommands.js +118 -8
  59. package/dist/src/cli/handlers/use.js +335 -107
  60. package/dist/src/cli/handlers/workspace.js +3 -1
  61. package/dist/src/cli/prompt-utils.js +81 -0
  62. package/dist/src/cli/router.js +16 -1
  63. package/dist/src/config/aiwg-config.js +42 -0
  64. package/dist/src/config/cli.js +7 -3
  65. package/dist/src/config/decision-config.js +14 -1
  66. package/dist/src/config/gitignore.js +4 -0
  67. package/dist/src/config/native-sandbox-warnings.js +25 -0
  68. package/dist/src/config/workspace.js +2 -1
  69. package/dist/src/contributors/validation.js +6 -5
  70. package/dist/src/decision/adapters/jev.js +20 -31
  71. package/dist/src/decision/admission.js +7 -3
  72. package/dist/src/decision/ask.js +6 -1
  73. package/dist/src/decision/benchmark.js +482 -0
  74. package/dist/src/decision/ledger.js +22 -3
  75. package/dist/src/decision/pricing.js +147 -0
  76. package/dist/src/decision/qualification/gate-evidence.js +23 -18
  77. package/dist/src/decision/qualification/load-staged.js +576 -0
  78. package/dist/src/decision/qualification/load.js +109 -33
  79. package/dist/src/decision/qualification/runner.js +12 -5
  80. package/dist/src/decision/review/service.js +6 -4
  81. package/dist/src/decision/review/validate.js +23 -2
  82. package/dist/src/decision/savings.js +8 -3
  83. package/dist/src/discovery/index.js +492 -0
  84. package/dist/src/discovery/interview.js +238 -0
  85. package/dist/src/discovery/plan-apply.js +567 -0
  86. package/dist/src/discovery/plan-store.js +171 -0
  87. package/dist/src/discovery/plan-types.js +2 -0
  88. package/dist/src/discovery/plan-validation.js +139 -0
  89. package/dist/src/discovery/types.js +2 -0
  90. package/dist/src/effects/host.js +91 -0
  91. package/dist/src/extensions/commands/definitions.js +49 -9
  92. package/dist/src/extensions/manifest.js +71 -44
  93. package/dist/src/extensions/project-local-discovery.js +1 -1
  94. package/dist/src/extensions/project-local-gitignore.js +2 -0
  95. package/dist/src/extensions/project-quickref.js +18 -17
  96. package/dist/src/extensions/validation.js +32 -29
  97. package/dist/src/housekeeper/daemon-config.js +148 -0
  98. package/dist/src/housekeeper/ipc.js +84 -0
  99. package/dist/src/housekeeper/lifecycle.js +148 -0
  100. package/dist/src/housekeeper/platform.js +35 -0
  101. package/dist/src/housekeeper/status.js +70 -0
  102. package/dist/src/housekeeper/units.js +114 -0
  103. package/dist/src/issues/cli.js +16 -5
  104. package/dist/src/issues/live.js +44 -12
  105. package/dist/src/jobs/flow.js +22 -21
  106. package/dist/src/lint/rule-activation.js +20 -5
  107. package/dist/src/lint/runner.js +1 -1
  108. package/dist/src/marketplace/exchange.js +18 -6
  109. package/dist/src/marketplace/provenance.js +3 -2
  110. package/dist/src/mcp/adapters/codex-runtime.js +46 -76
  111. package/dist/src/mcp/cli.mjs +22 -96
  112. package/dist/src/mcp/config-file.mjs +6 -2
  113. package/dist/src/mcp/config-layers.mjs +23 -7
  114. package/dist/src/mcp/grok-build-config.mjs +2 -1
  115. package/dist/src/mcp/muse-native-config.mjs +2 -1
  116. package/dist/src/mcp/profiles.js +18 -31
  117. package/dist/src/mcp/profiles.mjs +18 -32
  118. package/dist/src/mcp/registry.js +7 -27
  119. package/dist/src/mcp/registry.mjs +7 -28
  120. package/dist/src/mcp/server.mjs +34 -1
  121. package/dist/src/mcp/tools/agentic-sandbox.mjs +8 -7
  122. package/dist/src/mcp/tools/command-run.mjs +3 -2
  123. package/dist/src/mcp/tools/decision.mjs +49 -48
  124. package/dist/src/mcp/tools/discovery.mjs +46 -21
  125. package/dist/src/mcp/tools/orchestration.mjs +22 -22
  126. package/dist/src/mcp/tools/subsystems.mjs +9 -8
  127. package/dist/src/mcp/tools/worker-invoke.mjs +30 -6
  128. package/dist/src/mcp/tools/writer-profiles.mjs +1 -1
  129. package/dist/src/memory/audit.js +719 -0
  130. package/dist/src/memory/canonical-context.js +392 -13
  131. package/dist/src/memory/canonical-maintenance.js +95 -0
  132. package/dist/src/memory/cli-result.js +22 -0
  133. package/dist/src/memory/cli.js +176 -0
  134. package/dist/src/memory/context-pack.js +57 -6
  135. package/dist/src/memory/cost.js +512 -0
  136. package/dist/src/memory/firewall-classification.d.mts +7 -0
  137. package/dist/src/memory/firewall-classification.mjs +32 -0
  138. package/dist/src/memory/index.js +8 -0
  139. package/dist/src/memory/line-maintenance.js +68 -0
  140. package/dist/src/memory/maintenance-cli.js +286 -0
  141. package/dist/src/memory/maintenance-contract.js +136 -0
  142. package/dist/src/memory/maintenance-index.js +18 -0
  143. package/dist/src/memory/maintenance-owner.js +2 -0
  144. package/dist/src/memory/maintenance-record.js +138 -0
  145. package/dist/src/memory/maintenance.js +254 -0
  146. package/dist/src/memory/project-registry.js +2 -1
  147. package/dist/src/memory/provider-memory-adapter.js +154 -0
  148. package/dist/src/memory/recommend-cli.js +83 -0
  149. package/dist/src/memory/recommend.js +269 -0
  150. package/dist/src/memory/scheduled.js +81 -0
  151. package/dist/src/memory/taxonomy.js +44 -0
  152. package/dist/src/mission-protocol/codecs.js +19 -9
  153. package/dist/src/mission-protocol/worker-status.js +91 -1
  154. package/dist/src/models/config-loader.js +11 -1
  155. package/dist/src/models/model-capabilities.v1.json +74 -50
  156. package/dist/src/models/provider-policy.js +125 -62
  157. package/dist/src/models/resolver.js +45 -8
  158. package/dist/src/models/worker-installation.d.mts +8 -1
  159. package/dist/src/models/worker-installation.mjs +231 -38
  160. package/dist/src/models/wrapper-route.js +1 -1
  161. package/dist/src/output-modes/registry.js +8 -7
  162. package/dist/src/packages/adapters/git.js +99 -23
  163. package/dist/src/packages/adapters/gitea.js +8 -27
  164. package/dist/src/packages/adapters/github.js +7 -25
  165. package/dist/src/packages/registry.js +4 -4
  166. package/dist/src/plugin/metadata-validator.js +1 -1
  167. package/dist/src/process/cli-command.d.mts +5 -0
  168. package/dist/src/process/cli-command.mjs +42 -0
  169. package/dist/src/providers/deploy-version.d.mts +1 -0
  170. package/dist/src/providers/deploy-version.mjs +17 -0
  171. package/dist/src/providers/provider-definitions.js +19 -16
  172. package/dist/src/providers/skill-ownership.d.mts +11 -2
  173. package/dist/src/providers/skill-ownership.mjs +35 -17
  174. package/dist/src/rules/activation.d.mts +11 -2
  175. package/dist/src/rules/activation.mjs +82 -6
  176. package/dist/src/rules/inventory.js +1 -1
  177. package/dist/src/rules/rule-frontmatter.schema.json +2 -0
  178. package/dist/src/schema/zod-validation.d.mts +6 -0
  179. package/dist/src/schema/zod-validation.mjs +50 -0
  180. package/dist/src/serve/executor-registry.js +297 -20
  181. package/dist/src/serve/executor-token.d.mts +1 -0
  182. package/dist/src/serve/executor-token.mjs +8 -0
  183. package/dist/src/serve/shared-host-scheduler.js +1102 -31
  184. package/dist/src/serve/worker-invocation.js +124 -15
  185. package/dist/src/sessions/adapters/claude.js +19 -18
  186. package/dist/src/sessions/adapters/codex.js +10 -9
  187. package/dist/src/sessions/adapters/copilot.js +14 -13
  188. package/dist/src/sessions/adapters/cursor.js +19 -18
  189. package/dist/src/sessions/adapters/deepseek-harness.js +9 -8
  190. package/dist/src/sessions/adapters/factory.js +11 -10
  191. package/dist/src/sessions/adapters/generic.js +32 -23
  192. package/dist/src/sessions/adapters/grokbot.js +35 -35
  193. package/dist/src/sessions/adapters/hermes.js +20 -19
  194. package/dist/src/sessions/adapters/muse.js +50 -49
  195. package/dist/src/sessions/adapters/omp.js +5 -4
  196. package/dist/src/sessions/adapters/openclaw.js +30 -29
  197. package/dist/src/sessions/adapters/opencode.js +26 -26
  198. package/dist/src/sessions/adapters/openhuman.js +24 -23
  199. package/dist/src/sessions/adapters/pi.js +5 -4
  200. package/dist/src/sessions/adapters/windsurf.js +8 -8
  201. package/dist/src/sessions/analytics.js +10 -9
  202. package/dist/src/sessions/candidates.js +7 -6
  203. package/dist/src/sessions/contracts.js +63 -53
  204. package/dist/src/sessions/output-registration.js +11 -10
  205. package/dist/src/sessions/workspace-discovery.js +3 -4
  206. package/dist/src/smiths/context-pipeline/aiwg-md.js +18 -1
  207. package/dist/src/smiths/context-pipeline/claude-hook.js +46 -10
  208. package/dist/src/smiths/context-pipeline/context-file-safety.js +330 -9
  209. package/dist/src/smiths/context-pipeline/finalization.js +3 -2
  210. package/dist/src/smiths/context-pipeline/generator.js +7 -19
  211. package/dist/src/smiths/context-pipeline/legacy-inject.js +3 -10
  212. package/dist/src/smiths/context-pipeline/managed-hook.js +5 -5
  213. package/dist/src/smiths/context-pipeline/parallelism-section.js +72 -5
  214. package/dist/src/smiths/context-pipeline/workspace-context.js +110 -22
  215. package/dist/src/tracker/project-inputs.js +2 -0
  216. package/dist/src/update/checker.d.mts +4 -0
  217. package/dist/src/update/checker.mjs +14 -0
  218. package/dist/src/update/notifier.mjs +2 -4
  219. package/dist/src/writing/exemplar-selection.js +6 -6
  220. package/dist/src/writing/voice-evaluation.js +24 -23
  221. package/dist/src/writing/writer-migration.js +14 -13
  222. package/dist/src/writing/writer-profile.js +19 -18
  223. package/dist/src/writing/writing-brief.js +18 -17
  224. package/dist/src/writing/writing-receipt.js +47 -46
  225. package/package.json +5 -2
  226. package/schemas/decision/DecisionReview.schema.json +78 -1
  227. package/schemas/mission-v1.schema.json +3 -0
  228. package/tools/agents/deploy-agents.mjs +67 -39
  229. package/tools/agents/providers/antigravity.mjs +6 -1
  230. package/tools/agents/providers/base.mjs +24 -14
  231. package/tools/agents/providers/claude.mjs +6 -3
  232. package/tools/agents/providers/codex.mjs +54 -57
  233. package/tools/agents/providers/context-write.mjs +28 -0
  234. package/tools/agents/providers/copilot.mjs +13 -4
  235. package/tools/agents/providers/cursor.mjs +1 -1
  236. package/tools/agents/providers/deepseek-harness.mjs +1 -1
  237. package/tools/agents/providers/factory.mjs +7 -1
  238. package/tools/agents/providers/grok-build.mjs +18 -2
  239. package/tools/agents/providers/grokbot.mjs +1 -1
  240. package/tools/agents/providers/hermes.mjs +12 -15
  241. package/tools/agents/providers/muse-hooks.mjs +21 -14
  242. package/tools/agents/providers/muse.mjs +7 -2
  243. package/tools/agents/providers/omp.mjs +31 -5
  244. package/tools/agents/providers/opencode.mjs +14 -5
  245. package/tools/agents/providers/openhuman.mjs +1 -1
  246. package/tools/agents/providers/pi.mjs +1 -1
  247. package/tools/agents/providers/provider-prepare-coordinator.mjs +29 -9
  248. package/tools/agents/providers/rule-delivery.mjs +289 -80
  249. package/tools/agents/providers/warp.mjs +4 -1
  250. package/tools/agents/providers/windsurf.mjs +13 -3
  251. package/tools/cli/workspace-status.mjs +5 -0
  252. package/tools/commands/deploy-prompts-codex.mjs +11 -8
  253. package/tools/ralph-external/lib/codex-adapter.mjs +6 -2
  254. package/tools/ralph-external/lib/consecutive-failure-circuit-breaker.mjs +26 -0
  255. package/tools/ralph-external/lib/launcher-recovery-evidence.mjs +7 -1
  256. package/tools/ralph-external/lib/muse-adapter.mjs +12 -2
  257. package/tools/ralph-external/lib/recovery-probe.mjs +14 -4
  258. package/tools/ralph-external/lib/worker-health.mjs +1 -1
  259. package/tools/ralph-external/lib/worker-isolation.mjs +65 -9
  260. package/tools/ralph-external/process-monitor.mjs +49 -8
  261. package/tools/ralph-external/session-launcher.mjs +405 -35
  262. package/tools/skills/deploy-skills-codex.mjs +11 -7
  263. package/vscode-extension/schemas/aiwg.config.v1.json +11 -1
@@ -34,9 +34,9 @@
34
34
  import { StringDecoder } from 'node:string_decoder';
35
35
  import { spawn } from 'child_process';
36
36
  import { createWriteStream, mkdirSync, existsSync, readFileSync, copyFileSync, constants } from 'fs';
37
- import { open, mkdir, rename, unlink, lstat, readdir, realpath, link } from 'node:fs/promises';
37
+ import { open, mkdir, rename, unlink, lstat, readdir, realpath, link, access } from 'node:fs/promises';
38
38
  import { randomUUID, createHash } from 'node:crypto';
39
- import { fileURLToPath } from 'node:url';
39
+ import { fileURLToPath, pathToFileURL } from 'node:url';
40
40
  import { basename, dirname, join, relative, isAbsolute } from 'path';
41
41
  import { EventEmitter } from 'events';
42
42
  import { homedir, constants as osConstants } from 'os';
@@ -49,7 +49,7 @@ import { ScopedFailureBreaker, attributeFailureScope } from './lib/consecutive-f
49
49
  import { buildSessionEnvironment } from './lib/session-environment.mjs';
50
50
  import { launcherRecoveryEvidence, observeLauncherRecoveryOutcome, recordLauncherRecoveryHint } from './lib/launcher-recovery-evidence.mjs';
51
51
  export { observeLauncherRecoveryOutcome } from './lib/launcher-recovery-evidence.mjs';
52
- import { checkWorkerDispatch, detectWorkerIsolation, launchIsolatedWorker, getWorkerIsolationStatus, stopWorkerUnit, readUnitControlGroup } from './lib/worker-isolation.mjs';
52
+ import { workerNeedsNativeNamespaces, probeWorkerNativeSandbox, nativeSandboxFailure, nativeSandboxProbeMessage, NATIVE_SANDBOX_UNAVAILABLE, checkWorkerDispatch, detectWorkerIsolation, launchIsolatedWorker, getWorkerIsolationStatus, stopWorkerUnit, readUnitControlGroup } from './lib/worker-isolation.mjs';
53
53
  const { signalChild } = await importImpl(import.meta.url, 'process/signal-child.mjs');
54
54
 
55
55
  // Resolve the same installation owner as CLI/MCP inputs in source and packaged layouts.
@@ -413,6 +413,84 @@ function subscribeWorkerRecovery(owner, breaker) {
413
413
  _workerRecoverySubscriptions.set(owner, () => reporter.owners.delete(owner));
414
414
  }
415
415
 
416
+ /** Reuse the MC authority and its registered one-shot identity at every spawn boundary. */
417
+ export async function assertManagedWorkerLaunch(options, invocation, inputs, projectRoot = process.cwd()) {
418
+ if (!options.managedAdmission || invocation.mode !== 'managed'
419
+ || inputs.snapshot.config.parallelism?.worker_mode !== 'managed') throw new Error('Managed worker admission is required');
420
+ const { createMcAdmissionScheduler } = await importImpl(import.meta.url, 'cli/handlers/mc.js');
421
+ const { loadLauncherRegistry } = await importImpl(import.meta.url, 'cli/handlers/ralph-launcher.js');
422
+ const { workerModelKey } = await importImpl(import.meta.url, 'models/provider-policy.js');
423
+ const scheduler = await createMcAdmissionScheduler(projectRoot, inputs.snapshot.config);
424
+ const { requestId, generation } = options.managedAdmission;
425
+ const snapshot = scheduler.snapshot();
426
+ const record = snapshot.records[requestId];
427
+ const deadlineAtMs = Math.min(...[record?.accounting?.runKey, record?.accounting?.taskKey]
428
+ .map(key => snapshot.allowances?.[key]?.deadlineAt).filter(Boolean).map(Date.parse));
429
+ const registered = loadLauncherRegistry(projectRoot).loops[process.env.RALPH_LOOP_ID];
430
+ const worker = record?.managed;
431
+ const reservation = record?.accounting?.reservation;
432
+ if (!record || record.state !== 'admitted' || record.leaseGeneration !== generation
433
+ || !record.launchClaimedAt || !record.metadata?.invocationId || Date.parse(record.leaseExpiresAt ?? '') <= Date.now()
434
+ || deadlineAtMs <= Date.now()
435
+ || worker?.kind !== 'external' || worker.group !== invocation.selected.group
436
+ || worker.member !== invocation.selected.member || worker.cli !== invocation.selected.cli
437
+ || worker.model !== workerModelKey(invocation.selected.inferenceProvider, invocation.selected.model)
438
+ || reservation?.tokens != null && !(invocation.limits.tokens <= reservation.tokens)
439
+ || reservation?.costUsd != null && !(invocation.limits.costUsd !== null && invocation.limits.costUsd <= reservation.costUsd)
440
+ || registered?.managedAdmission?.requestId !== requestId || registered.managedAdmission.generation !== generation
441
+ || registered.managedWorker?.invocationPath !== options.invocationPath
442
+ || registered.managedWorker?.runDir !== options.runDir) {
443
+ throw new Error('Managed worker launch differs from the current claimed admission');
444
+ }
445
+ if (record.execution) {
446
+ const identity = sampleWorkerIdentity(process.pid);
447
+ if (record.execution.pid !== process.pid || !identity?.bootId || !identity.startTicks
448
+ || record.execution.startedAt !== `${identity.bootId}:${identity.startTicks}:${identity.uid}`) {
449
+ throw new Error('Managed worker supervisor identity differs');
450
+ }
451
+ }
452
+ return { ...record, deadlineAtMs };
453
+ }
454
+
455
+ async function workerDispatchPath(filename) {
456
+ let candidate = filename;
457
+ while (dirname(candidate) !== candidate) {
458
+ try {
459
+ if (!(await lstat(candidate)).isDirectory()) return filename;
460
+ await access(candidate, constants.R_OK | constants.X_OK);
461
+ return candidate;
462
+ } catch (error) {
463
+ if (error.code !== 'ENOENT') return filename;
464
+ candidate = dirname(candidate);
465
+ }
466
+ }
467
+ // Keep an unusable path for fail-closed sampling rather than falling back to /.
468
+ return filename;
469
+ }
470
+
471
+ function workerDispatchRefusal(gate) {
472
+ const bounded = value => String(value ?? '').replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 256);
473
+ const reasons = (gate.failures ?? []).slice(0, 8)
474
+ .map(failure => `${bounded(failure.signal)}: ${bounded(failure.reason)}`).join('; ').slice(0, 1024);
475
+ return new Error(`Worker dispatch resource gate refused launch${reasons ? `: ${reasons}` : ''}`);
476
+ }
477
+
478
+ function workerLaunchRefusal(outcome) {
479
+ const bounded = value => String(value ?? '').replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 256);
480
+ const reasons = (outcome?.failures ?? []).slice(0, 8)
481
+ .map(failure => `${bounded(failure.signal)}: ${bounded(failure.reason)}`);
482
+ for (const detail of [outcome?.diagnostic, outcome?.error]) {
483
+ if (detail) reasons.push(bounded(detail));
484
+ }
485
+ return new Error(reasons.length
486
+ ? `Worker launch refused: ${reasons.join('; ').slice(0, 1024)}`
487
+ : 'Worker launch did not return an attached child');
488
+ }
489
+
490
+ // Only the admitted direct owner can supply inputs for an automatic correction.
491
+ // Neither worker output nor a caller-supplied result can populate this context.
492
+ const directStartupCorrectionContext = new WeakMap();
493
+
416
494
  async function launchOneWorker(owner, options) {
417
495
  const { inspectWorkerPath: inspect, revalidateWorkerInvocationPlan: validate } = await importImpl(import.meta.url, 'serve/worker-invocation.js');
418
496
  const { qualificationDigest } = await importImpl(import.meta.url, 'models/qualification-digest.mjs');
@@ -425,6 +503,11 @@ async function launchOneWorker(owner, options) {
425
503
  const clock = () => typeof options.now === 'function' ? options.now() : options.now ?? new Date().toISOString();
426
504
  const now = clock();
427
505
  const invocation = await validate(saved, inputs.snapshot, inputs.drivers, now);
506
+ if (invocation.mode === 'managed' || options.managedAdmission) {
507
+ const admitted = await assertManagedWorkerLaunch(options, invocation, inputs);
508
+ options = { ...options, scopedBreaker: null, managedInvocationId: admitted.metadata.invocationId,
509
+ managedDeadlineAtMs: admitted.deadlineAtMs };
510
+ }
428
511
  if (invocation.transport !== 'shell') throw new Error('One-shot capture requires shell transport; use the qualified MCP descriptor directly');
429
512
  if (process.platform !== 'linux') throw new Error('One-shot worker capture requires qualified Linux process identity');
430
513
  const runDir = options.runDir;
@@ -475,9 +558,24 @@ async function launchOneWorker(owner, options) {
475
558
  // document on the stdin pipe, never brief text in argv (#2911).
476
559
  const { workerStdinBytes } = await importImpl(import.meta.url, 'serve/worker-stdin-frame.mjs');
477
560
  const stdinBytes = workerStdinBytes(invocation, brief, inputs.drivers[invocation.selected.cli]);
478
- let gate = (options.checkDispatch ?? checkWorkerDispatch)({ config: inputs.snapshot.config });
479
- if (!gate.ok) throw new Error('Worker dispatch resource gate refused launch');
480
- const isolation = options.isolation ?? detectWorkerIsolation();
561
+ let gate = (options.checkDispatch ?? checkWorkerDispatch)({ config: inputs.snapshot.config,
562
+ paths: await Promise.all([invocation.cwd, runDir].map(workerDispatchPath)) });
563
+ if (!gate.ok) throw workerDispatchRefusal(gate);
564
+ const { WorkerPolicyError, assertWorkerLaunchMode } = await importImpl(import.meta.url, 'models/provider-policy.js');
565
+ const permissions = inputs.snapshot.config.parallelism?.groups?.[invocation.selected.group]?.execution ?? {};
566
+ const adapter = inputs.drivers[invocation.selected.cli].qualification.adapter;
567
+ assertWorkerLaunchMode(adapter, permissions);
568
+ if (options.isolation?.mode === 'native-sandbox' && invocation.isolation !== 'native-sandbox') {
569
+ throw new WorkerPolicyError('OG_POLICY_RELAXATION', 'native-sandbox requires the pinned project execution policy');
570
+ }
571
+ const isolation = invocation.isolation === 'native-sandbox' ? { mode: 'native-sandbox' }
572
+ : options.isolation ?? detectWorkerIsolation();
573
+ if (isolation.mode === 'systemd-unit' && workerNeedsNativeNamespaces(adapter, permissions)) {
574
+ const probe = await (options.probeNativeSandbox ?? probeWorkerNativeSandbox)({ cwd: invocation.cwd,
575
+ envNames: invocation.envNames, env: Object.fromEntries(invocation.envNames.filter(name => process.env[name] !== undefined)
576
+ .map(name => [name, process.env[name]])) });
577
+ if (!probe.ok) throw new WorkerPolicyError(NATIVE_SANDBOX_UNAVAILABLE, nativeSandboxProbeMessage(probe));
578
+ }
481
579
  if (isolation.mode === 'degraded' && !options.allowDegraded) {
482
580
  throw new Error('Worker isolation unavailable; explicitly select --allow-degraded-worker or use manual observation');
483
581
  }
@@ -570,6 +668,7 @@ async function launchOneWorker(owner, options) {
570
668
  // worker-writable captureRoot fell back to the in-process ledger) is
571
669
  // folded in up front so every exit path reports it.
572
670
  const ledgerDiagnostics = admission.ledgerDiagnostic ? [admission.ledgerDiagnostic] : [];
671
+ const startupQualification = structuredClone(inputs.drivers[invocation.selected.cli].qualification);
573
672
  const settleLedger = async (outcome) => {
574
673
  const diagnostic = await settleDirectWorker(admission, outcome);
575
674
  if (diagnostic) ledgerDiagnostics.push(diagnostic);
@@ -653,11 +752,22 @@ async function launchOneWorker(owner, options) {
653
752
  // writes; `.catch(() => {})` keeps a prior failure from poisoning it, and
654
753
  // the per-call promise returned to the caller still reflects this write's
655
754
  // own outcome.
755
+ let latestProcessRecord = null;
656
756
  const publish = (name, value) => {
757
+ if (name === 'process.json') {
758
+ value = { ...value, recovery: { ...admission.breaker.recoveryStatus(), observedAt: new Date().toISOString() } };
759
+ if (options.startupCorrectionRecord) value.startupCorrection = options.startupCorrectionRecord;
760
+ latestProcessRecord = value;
761
+ }
657
762
  const write = publishing.catch(() => {}).then(() => writeRecord(runDir, name, value, inspect));
658
763
  publishing = write.catch(error => { publishFailures.push({ name, error: error?.message ?? String(error) }); });
659
764
  return write;
660
765
  };
766
+ const publishRecovery = () => {
767
+ if (latestProcessRecord) return publish('process.json', latestProcessRecord);
768
+ };
769
+ const onScopeTransition = () => { publishRecovery()?.catch(() => {}); };
770
+ admission.breaker.on('transition', onScopeTransition);
661
771
  const handles = [];
662
772
  let outcome;
663
773
  let artifacts;
@@ -692,7 +802,7 @@ async function launchOneWorker(owner, options) {
692
802
  prompt: { path: invocation.briefPath, digest: invocation.briefDigest }, cwd: invocation.cwd,
693
803
  worktree: { path: invocation.cwd, baseRevision: null }, paths, captures, outputFraming: profile.health?.outputFraming ?? 'opaque',
694
804
  health: invocation.health ?? null, transport: invocation.transport,
695
- isolation: { mode: isolation.mode, memoryMaxGb: options.memoryMaxGb ?? 4 },
805
+ isolation: { mode: isolation.mode, memoryMaxGb: isolation.mode === 'native-sandbox' ? null : options.memoryMaxGb ?? 4 },
696
806
  limits: invocation.limits, maxOutputBytes: Math.min(invocation.maxOutputBytes ?? 10485760, 10485760) });
697
807
  await publish('process.json', { invocationId, origin: 'launcher', supervisor, worker: null,
698
808
  launchTime, monotonicStart, ownership: 'unknown', isolation: isolation.mode });
@@ -704,8 +814,9 @@ async function launchOneWorker(owner, options) {
704
814
  // Recheck policy, prompt, installation and exact bindings after file preparation.
705
815
  inputs = await loadInputs();
706
816
  await validate(saved, inputs.snapshot, inputs.drivers, clock());
707
- gate = (options.checkDispatch ?? checkWorkerDispatch)({ config: inputs.snapshot.config });
708
- if (!gate.ok) throw new Error('Worker dispatch resource gate refused launch');
817
+ gate = (options.checkDispatch ?? checkWorkerDispatch)({ config: inputs.snapshot.config,
818
+ paths: await Promise.all([invocation.cwd, runDir].map(workerDispatchPath)) });
819
+ if (!gate.ok) throw workerDispatchRefusal(gate);
709
820
  if (bytesDigest(await boundedPrivateRead(invocation.briefPath, 262144, inspect)) !== invocation.briefDigest) throw new Error('Worker prompt changed before spawn');
710
821
  const environment = Object.fromEntries(invocation.envNames.filter(name => process.env[name] !== undefined)
711
822
  .map(name => [name, process.env[name]]));
@@ -766,12 +877,12 @@ async function launchOneWorker(owner, options) {
766
877
  // would in production).
767
878
  const launchResult = (options.launchIsolated ?? launchIsolatedWorker)({ name: invocationId,
768
879
  command: artifacts.argv[0], args: artifacts.argv.slice(1), cwd: invocation.cwd,
769
- memoryMaxGb: options.memoryMaxGb ?? 4, gate, config: inputs.snapshot.config, isolation,
880
+ memoryMaxGb: options.memoryMaxGb ?? 4, gate, config: inputs.snapshot.config, isolation, workerGroup: invocation.selected.group,
770
881
  allowDegraded: !!options.allowDegraded, attached: true, ownedGroup: true,
771
882
  env: environment, envNames: invocation.envNames, stdin: invocation.stdin, runDir,
772
883
  ...(onDispatchCommit ? { onDispatchCommit } : {}) });
773
884
  outcome = typeof launchResult?.then === 'function' ? await launchResult : launchResult;
774
- if (!outcome.child) throw new Error('Worker launch did not return an attached child');
885
+ if (!outcome?.child) throw workerLaunchRefusal(outcome);
775
886
  // #2915 review 4 (finding 3): when THIS attempt is the admitted canary
776
887
  // for one or more covering scopes, execution itself must be clipped to
777
888
  // the canary's own deadline (min(task deadline, admission+300s)) — not
@@ -819,6 +930,8 @@ async function launchOneWorker(owner, options) {
819
930
  // accumulated across this attempt's lifecycle on the returned result,
820
931
  // mirroring `publishFailures` — never silently discarded.
821
932
  if (ledgerDiagnostics.length && result) result.ledgerDiagnostics = ledgerDiagnostics.slice();
933
+ directStartupCorrectionContext.set(owner, { saved, invocation, loadInputs, clock, dispatchGuard,
934
+ ledger: admission.ledger, inspect, qualification: startupQualification });
822
935
  return result;
823
936
  } catch (error) {
824
937
  if (!outcome?.child) {
@@ -831,7 +944,7 @@ async function launchOneWorker(owner, options) {
831
944
  // canary deadline.
832
945
  await settleLedger({ kind: 'uncertain', confirmedStop: true });
833
946
  await publish('exit.json', { invocationId, exitCode: null, signal: null,
834
- endedAt: new Date().toISOString(), termination: 'confirmed', descendants: 'not-started', errorCode: error.code ?? 'LAUNCH_REFUSED' });
947
+ endedAt: new Date().toISOString(), isolation: isolation.mode, termination: 'confirmed', descendants: 'not-started', errorCode: error.code ?? 'LAUNCH_REFUSED' });
835
948
  } else if (admission.gated) {
836
949
  // #2915 review 5 (finding 2a, "leaked capacity"), hardened by #2915
837
950
  // review 6 (finding 4, "capture-setup exception after spawn"): a
@@ -890,6 +1003,8 @@ async function launchOneWorker(owner, options) {
890
1003
  if (ledgerDiagnostics.length) error.ledgerDiagnostics = ledgerDiagnostics;
891
1004
  throw error;
892
1005
  } finally {
1006
+ admission.breaker.removeListener('transition', onScopeTransition);
1007
+ await publishing;
893
1008
  try { await artifacts?.cleanup(); } finally {
894
1009
  // Never chmod a worker-replaced pathname; only the inodes we opened.
895
1010
  for (const handle of handles) { await handle.chmod(0o600).catch(() => {}); await handle.close().catch(() => {}); }
@@ -900,6 +1015,38 @@ async function launchOneWorker(owner, options) {
900
1015
  }
901
1016
  }
902
1017
 
1018
+ // #3489: process exit/close notification can be observed before the final
1019
+ // pipe bytes are delivered as stream 'data' events (the same class as #3488,
1020
+ // which waited for IPC disconnect as well as exit). The child 'data'
1021
+ // handlers are the ONLY path that feeds the health observer and accumulates
1022
+ // the `output` buffer adapter extraction consumes, so settling health on
1023
+ // the process-level event alone classifies a completed exit-0 run as
1024
+ // `failed` (missing terminal event, FINAL_INVALID) whenever it wins the
1025
+ // race under load. Drain any already-buffered stdout/stderr bytes — bounded
1026
+ // by the termination grace — before destroying the source streams and
1027
+ // snapshotting output/observer state. Streams with no buffered bytes
1028
+ // resolve immediately, so never-ending stdio (timer-faked stand-ins, held
1029
+ // pipes) never delays settle; the bound only covers genuinely in-flight
1030
+ // data. The timer stays referenced so a bounded drain always completes,
1031
+ // mirroring the already-referenced exit-grace `later()` timer.
1032
+ function drainChildStdio(child, timeoutMs) {
1033
+ const pending = [];
1034
+ for (const stream of [child?.stdout, child?.stderr]) {
1035
+ if (!stream || typeof stream.once !== 'function' || stream.readableEnded || stream.destroyed) continue;
1036
+ if (!(stream.readableLength > 0)) continue;
1037
+ try { stream.resume?.(); } catch { continue; }
1038
+ pending.push(new Promise(resolve => {
1039
+ const done = () => { clearTimeout(timer); resolve(); };
1040
+ const timer = setTimeout(done, Math.max(0, timeoutMs ?? 0));
1041
+ stream.once('end', done);
1042
+ stream.once('close', done);
1043
+ stream.once('error', done);
1044
+ }));
1045
+ }
1046
+ if (!pending.length) return Promise.resolve();
1047
+ return Promise.all(pending);
1048
+ }
1049
+
903
1050
  async function captureOneWorker(owner, ctx) {
904
1051
  const { child, outcome, invocation, invocationId, supervisor, launchTime, monotonicStart, publish, paths, handles, options,
905
1052
  canaryDeadlineMs = null, canaryNowMs = () => Date.now(), recoveryGated = false } = ctx;
@@ -968,7 +1115,16 @@ async function captureOneWorker(owner, ctx) {
968
1115
  };
969
1116
  const graceMs = (invocation.health?.terminateGraceSeconds ?? 5) * 1000;
970
1117
  const signal = value => {
971
- if (settled || !signalOwnedWorker(identity, value, { supervisor })) return false;
1118
+ if (settled) return false;
1119
+ let delivered = signalOwnedWorker(identity, value, { supervisor });
1120
+ // Startup can consume the whole deadline before the transport spawns
1121
+ // its worker or publishes an owned group. We still own this attached
1122
+ // ChildProcess: stop it before it can launch work after finalization.
1123
+ // This proves no descendant termination; degraded mode stays uncertain.
1124
+ if (!delivered && !identity && hasTransport && outcome.mode !== 'systemd-unit' && !sawExit) {
1125
+ try { delivered = signalChild(child, value); } catch { /* already exited or no child handle */ }
1126
+ }
1127
+ if (!delivered) return false;
972
1128
  deliveredSignal = value;
973
1129
  return true;
974
1130
  };
@@ -1007,8 +1163,19 @@ async function captureOneWorker(owner, ctx) {
1007
1163
  }
1008
1164
  signal('SIGTERM');
1009
1165
  const remainingGrace = Math.min(graceMs, hardRemaining());
1010
- if (remainingGrace === 0) { signal('SIGKILL'); later(() => finish(), 100); }
1011
- else later(() => { signal('SIGKILL'); later(() => finish(), 100); }, remainingGrace);
1166
+ const escalate = () => {
1167
+ signal('SIGKILL');
1168
+ // Startup may have no worker identity yet. Wait for our transport's
1169
+ // exit status before settling: TERM can have killed it even if KILL
1170
+ // was also delivered before Node observes exit (#3532). Use the unused
1171
+ // grace plus the existing 100ms settle tick, keeping the total
1172
+ // termination budget at graceMs + 100 on the tracked launcher timers.
1173
+ const exitWaitMs = !identity && hasTransport && outcome.mode !== 'systemd-unit'
1174
+ ? graceMs - remainingGrace + 100 : 100;
1175
+ later(() => finish(), exitWaitMs);
1176
+ };
1177
+ if (remainingGrace === 0) escalate();
1178
+ else later(escalate, remainingGrace);
1012
1179
  };
1013
1180
  owner.currentProcess = child;
1014
1181
  owner.workerStop = stop;
@@ -1023,9 +1190,15 @@ async function captureOneWorker(owner, ctx) {
1023
1190
  owner.emit('stdout', chunk);
1024
1191
  });
1025
1192
  let stderrBytes = 0;
1193
+ let sandboxStderrTail = '';
1026
1194
  let recoveryReason = null;
1027
1195
  child.stderr.on('data', chunk => {
1028
1196
  observer.feed(chunk, { kind: 'stderr', id: 'stderr', generation: 0, offset: stderrOffset }); stderrOffset += chunk.length;
1197
+ if (workerNeedsNativeNamespaces(driver.qualification.adapter, ctx.inputs.snapshot.config.parallelism?.groups?.[invocation.selected.group]?.execution)) {
1198
+ const observedStderr = sandboxStderrTail + chunk.toString('utf8');
1199
+ if (nativeSandboxFailure(observedStderr)) errorCode = NATIVE_SANDBOX_UNAVAILABLE;
1200
+ sandboxStderrTail = observedStderr.slice(-1024);
1201
+ }
1029
1202
  const hint = classifyWorkerHealthFailure(chunk.toString('utf8'), { source: { kind: 'stderr' } })?.reasonCode;
1030
1203
  if (['RATE_LIMIT', 'NETWORK'].includes(hint)) recoveryReason = hint;
1031
1204
  const remaining = Math.max(0, 10485760 - stderrBytes);
@@ -1055,6 +1228,10 @@ async function captureOneWorker(owner, ctx) {
1055
1228
  timers.clear();
1056
1229
  process.off('SIGTERM', onSignal); process.off('SIGINT', onSignal);
1057
1230
  owner.currentProcess = null; owner.workerStop = null;
1231
+ // #3489: drain in-flight stdio before destroying the source streams —
1232
+ // destroying first discards bytes never delivered as 'data', settling
1233
+ // health on a partial stream (exit observed before drain).
1234
+ await drainChildStdio(child, graceMs);
1058
1235
  child.stdout.destroy(); child.stderr.destroy(); child.stdin?.destroy(); child.unref();
1059
1236
  for (const stream of streams) await new Promise(resolve => { if (stream.destroyed) resolve(); else stream.end(resolve); });
1060
1237
  const native = outcome.mode === 'systemd-unit' ? readNative() : null;
@@ -1415,12 +1592,15 @@ async function captureOneWorker(owner, ctx) {
1415
1592
 
1416
1593
  // ===========================================================================
1417
1594
  // BASED ON a4085b86a (#2915 round 8, D6).
1418
- // Exception: finish() snapshots timeout classification before cleanup and
1419
- // respects the winning stop cause, matching captureOneWorker's race fix.
1595
+ // Shared lifecycle fixes: finish() snapshots timeout classification before
1596
+ // cleanup and respects the winning stop cause; stop() can signal the owned
1597
+ // transport before worker identity is available, and then uses the unused
1598
+ // termination grace to observe its exit before falling back to the last
1599
+ // delivered signal (#3532).
1420
1600
  // The test/fixtures frozen reference remains unchanged.
1421
1601
  //
1422
1602
  // `launchOneWorkerBaseline` and `captureOneWorkerBaseline` reproduce
1423
- // `launchOneWorker`/`captureOneWorker`, apart from the timeout race fix above,
1603
+ // `launchOneWorker`/`captureOneWorker`, apart from the lifecycle fixes above,
1424
1604
  // as they existed at base commit `a4085b86a` (`git show
1425
1605
  // a4085b86a:tools/ralph-external/session-launcher.mjs`) — before any
1426
1606
  // #2915 direct-recovery code existed in this file. `launchOneWorker`
@@ -1472,7 +1652,7 @@ async function launchOneWorkerBaseline(owner, options, ctx) {
1472
1652
  if (runStat.uid !== process.getuid()) throw new Error('Worker run directory must be conductor-owned');
1473
1653
  await withWorkerPath(runDir, async target => { const handle = await open(target, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
1474
1654
  try { await handle.chmod(0o700); } finally { await handle.close(); } });
1475
- const invocationId = randomUUID();
1655
+ const invocationId = options.managedAdmission ? options.managedInvocationId : randomUUID();
1476
1656
  const reservation = await withWorkerPath(join(runDir, '.launch-owner'), target => open(target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600));
1477
1657
  try { await reservation.writeFile(invocationId); await reservation.sync(); } finally { await reservation.close(); }
1478
1658
  const supervisor = sampleWorkerIdentity(process.pid);
@@ -1492,11 +1672,12 @@ async function launchOneWorkerBaseline(owner, options, ctx) {
1492
1672
  const handles = [];
1493
1673
  let outcome;
1494
1674
  let artifacts;
1675
+ let managedSpawnAttempted = false;
1495
1676
  try {
1496
1677
  artifacts = await materializeWorkerArtifacts(invocation, runDir);
1497
1678
  const displayArgv = artifacts.argv.map((token, i) => i === 0 ? token : `<argument:${bytesDigest(token)}>`);
1498
1679
  await publish('invocation.json', { version: 1, origin: 'launcher', invocationId, attemptId: invocationId,
1499
- previousAttemptId: verifiedPreviousAttemptId ?? null, requestId: invocationId, taskDigest: qualificationDigest(saved.request), role: saved.request.role,
1680
+ previousAttemptId: verifiedPreviousAttemptId ?? null, requestId: options.managedAdmission?.requestId ?? invocationId, taskDigest: qualificationDigest(saved.request), role: saved.request.role,
1500
1681
  selected: invocation.selected, planDigest: qualificationDigest(saved), invocationDigest: qualificationDigest(invocation),
1501
1682
  policyDigest: invocation.policyDigest, catalogDigest: invocation.catalogDigest, profileDigest: invocation.profileDigest,
1502
1683
  expiresAt: invocation.expiresAt, argv: displayArgv, commandDisplay: displayArgv.map(token => JSON.stringify(token)).join(' '),
@@ -1509,7 +1690,7 @@ async function launchOneWorkerBaseline(owner, options, ctx) {
1509
1690
  prompt: { path: invocation.briefPath, digest: invocation.briefDigest }, cwd: invocation.cwd,
1510
1691
  worktree: { path: invocation.cwd, baseRevision: null }, paths, captures, outputFraming: profile.health?.outputFraming ?? 'opaque',
1511
1692
  health: invocation.health ?? null, transport: invocation.transport,
1512
- isolation: { mode: isolation.mode, memoryMaxGb: options.memoryMaxGb ?? 4 },
1693
+ isolation: { mode: isolation.mode, memoryMaxGb: isolation.mode === 'native-sandbox' ? null : options.memoryMaxGb ?? 4 },
1513
1694
  limits: invocation.limits, maxOutputBytes: Math.min(invocation.maxOutputBytes ?? 10485760, 10485760) });
1514
1695
  await publish('process.json', { invocationId, origin: 'launcher', supervisor, worker: null,
1515
1696
  launchTime, monotonicStart, ownership: 'unknown', isolation: isolation.mode });
@@ -1521,8 +1702,9 @@ async function launchOneWorkerBaseline(owner, options, ctx) {
1521
1702
  // Recheck policy, prompt, installation and exact bindings after file preparation.
1522
1703
  inputs = await loadInputs();
1523
1704
  await validate(saved, inputs.snapshot, inputs.drivers, clock());
1524
- localGate = (options.checkDispatch ?? checkWorkerDispatch)({ config: inputs.snapshot.config });
1525
- if (!localGate.ok) throw new Error('Worker dispatch resource gate refused launch');
1705
+ localGate = (options.checkDispatch ?? checkWorkerDispatch)({ config: inputs.snapshot.config,
1706
+ paths: await Promise.all([invocation.cwd, runDir].map(workerDispatchPath)) });
1707
+ if (!localGate.ok) throw workerDispatchRefusal(localGate);
1526
1708
  if (bytesDigest(await boundedPrivateRead(invocation.briefPath, 262144, inspect)) !== invocation.briefDigest) throw new Error('Worker prompt changed before spawn');
1527
1709
  const environment = Object.fromEntries(invocation.envNames.filter(name => process.env[name] !== undefined)
1528
1710
  .map(name => [name, process.env[name]]));
@@ -1539,16 +1721,24 @@ async function launchOneWorkerBaseline(owner, options, ctx) {
1539
1721
  const launchResult = (options.launchIsolated ?? launchIsolatedWorker)({ name: invocationId,
1540
1722
  command: artifacts.argv[0], args: artifacts.argv.slice(1), cwd: invocation.cwd,
1541
1723
  memoryMaxGb: options.memoryMaxGb ?? 4, gate: localGate, config: inputs.snapshot.config, isolation,
1724
+ workerGroup: invocation.selected.group,
1542
1725
  allowDegraded: !!options.allowDegraded, attached: true, ownedGroup: true,
1726
+ ...(options.managedAdmission ? { onDispatchCommit: async () => {
1727
+ const admitted = await assertManagedWorkerLaunch(options, invocation, inputs);
1728
+ options.managedDeadlineAtMs = Math.min(options.managedDeadlineAtMs, admitted.deadlineAtMs);
1729
+ } } : {}),
1730
+ ...(options.managedAdmission ? { onSpawnAttempt: () => { managedSpawnAttempted = true; } } : {}),
1543
1731
  env: environment, envNames: invocation.envNames, stdin: invocation.stdin, runDir });
1544
1732
  outcome = typeof launchResult?.then === 'function' ? await launchResult : launchResult;
1545
- if (!outcome.child) throw new Error('Worker launch did not return an attached child');
1733
+ if (!outcome?.child) throw workerLaunchRefusal(outcome);
1546
1734
  return await captureOneWorkerBaseline(owner, { child: outcome.child, outcome, invocation, invocationId, supervisor,
1547
1735
  launchTime, monotonicStart, publish, publishFailures, paths, handles, inspect, inputs, profile, baselineContent, options,
1548
1736
  brief: ctx.brief, stdinBytes: ctx.stdinBytes, runDir });
1549
1737
  } catch (error) {
1550
1738
  if (!outcome?.child) await publish('exit.json', { invocationId, exitCode: null, signal: null,
1551
- endedAt: new Date().toISOString(), termination: 'confirmed', descendants: 'not-started', errorCode: error.code ?? 'LAUNCH_REFUSED' });
1739
+ ...(options.managedAdmission ? { managedAdmission: options.managedAdmission } : {}),
1740
+ endedAt: new Date().toISOString(), isolation: isolation.mode, termination: managedSpawnAttempted ? 'uncertain' : 'confirmed',
1741
+ descendants: managedSpawnAttempted ? 'unknown' : 'not-started', errorCode: error.code ?? 'LAUNCH_REFUSED' });
1552
1742
  throw error;
1553
1743
  } finally {
1554
1744
  try { await artifacts?.cleanup(); } finally {
@@ -1588,9 +1778,13 @@ async function captureOneWorkerBaseline(owner, ctx) {
1588
1778
  let sawClose = false;
1589
1779
  const driver = ctx.inputs.drivers[invocation.selected.cli];
1590
1780
  const observationOptions = { provider: driver.qualification.adapter.provider, profile: ctx.profile,
1591
- effectiveHealth: invocation.health, invocationId, startedAt: Number(BigInt(monotonicStart)) / 1e6,
1781
+ effectiveHealth: options.managedAdmission ? { ...invocation.health,
1782
+ hardTimeoutSeconds: Math.min(invocation.health?.hardTimeoutSeconds ?? invocation.limits.wallSeconds,
1783
+ (options.managedDeadlineAtMs - Date.parse(launchTime)) / 1000) } : invocation.health,
1784
+ invocationId, startedAt: Number(BigInt(monotonicStart)) / 1e6,
1592
1785
  launchTime, contentDigest: ctx.baselineContent, sessionId: options.healthSessionLog?.sessionId ?? null,
1593
- runId: options.healthSessionLog?.runId ?? null, maxOutputBytes: invocation.maxOutputBytes };
1786
+ runId: options.healthSessionLog?.runId ?? null, maxOutputBytes: invocation.maxOutputBytes,
1787
+ ...(options.managedAdmission ? { observerGeneration: options.managedAdmission.generation, requestId: options.managedAdmission.requestId } : {}) };
1594
1788
  const observer = driver.createHealthObserver?.(observationOptions) ?? createWorkerHealthObserver(observationOptions);
1595
1789
  const tickRate = qualifyWorkerCpuTickRate();
1596
1790
  let previousCpu = null;
@@ -1611,7 +1805,16 @@ async function captureOneWorkerBaseline(owner, ctx) {
1611
1805
  };
1612
1806
  const graceMs = (invocation.health?.terminateGraceSeconds ?? 5) * 1000;
1613
1807
  const signal = value => {
1614
- if (settled || !signalOwnedWorker(identity, value, { supervisor })) return false;
1808
+ if (settled) return false;
1809
+ let delivered = signalOwnedWorker(identity, value, { supervisor });
1810
+ // Startup can consume the whole deadline before the transport spawns
1811
+ // its worker or publishes an owned group. We still own this attached
1812
+ // ChildProcess: stop it before it can launch work after finalization.
1813
+ // This proves no descendant termination; degraded mode stays uncertain.
1814
+ if (!delivered && !identity && hasTransport && outcome.mode !== 'systemd-unit' && !sawExit) {
1815
+ try { delivered = signalChild(child, value); } catch { /* already exited or no child handle */ }
1816
+ }
1817
+ if (!delivered) return false;
1615
1818
  deliveredSignal = value;
1616
1819
  return true;
1617
1820
  };
@@ -1640,8 +1843,19 @@ async function captureOneWorkerBaseline(owner, ctx) {
1640
1843
  }
1641
1844
  signal('SIGTERM');
1642
1845
  const remainingGrace = Math.min(graceMs, hardRemaining());
1643
- if (remainingGrace === 0) { signal('SIGKILL'); later(() => finish(), 100); }
1644
- else later(() => { signal('SIGKILL'); later(() => finish(), 100); }, remainingGrace);
1846
+ const escalate = () => {
1847
+ signal('SIGKILL');
1848
+ // Startup may have no worker identity yet. Wait for our transport's
1849
+ // exit status before settling: TERM can have killed it even if KILL
1850
+ // was also delivered before Node observes exit (#3532). Use the unused
1851
+ // grace plus the existing 100ms settle tick, keeping the total
1852
+ // termination budget at graceMs + 100 on the tracked launcher timers.
1853
+ const exitWaitMs = !identity && hasTransport && outcome.mode !== 'systemd-unit'
1854
+ ? graceMs - remainingGrace + 100 : 100;
1855
+ later(() => finish(), exitWaitMs);
1856
+ };
1857
+ if (remainingGrace === 0) escalate();
1858
+ else later(escalate, remainingGrace);
1645
1859
  };
1646
1860
  owner.currentProcess = child;
1647
1861
  owner.workerStop = stop;
@@ -1656,8 +1870,14 @@ async function captureOneWorkerBaseline(owner, ctx) {
1656
1870
  owner.emit('stdout', chunk);
1657
1871
  });
1658
1872
  let stderrBytes = 0;
1873
+ let sandboxStderrTail = '';
1659
1874
  child.stderr.on('data', chunk => {
1660
1875
  observer.feed(chunk, { kind: 'stderr', id: 'stderr', generation: 0, offset: stderrOffset }); stderrOffset += chunk.length;
1876
+ if (workerNeedsNativeNamespaces(driver.qualification.adapter, ctx.inputs.snapshot.config.parallelism?.groups?.[invocation.selected.group]?.execution)) {
1877
+ const observedStderr = sandboxStderrTail + chunk.toString('utf8');
1878
+ if (nativeSandboxFailure(observedStderr)) errorCode = NATIVE_SANDBOX_UNAVAILABLE;
1879
+ sandboxStderrTail = observedStderr.slice(-1024);
1880
+ }
1661
1881
  const remaining = Math.max(0, 10485760 - stderrBytes);
1662
1882
  streams[1].write(chunk.subarray(0, remaining)); streams[3].write(chunk.subarray(0, remaining)); stderrBytes += chunk.length;
1663
1883
  if (stderrBytes > 10485760) { errorCode = 'OUTPUT_LIMIT'; stop(); }
@@ -1685,6 +1905,11 @@ async function captureOneWorkerBaseline(owner, ctx) {
1685
1905
  timers.clear();
1686
1906
  process.off('SIGTERM', onSignal); process.off('SIGINT', onSignal);
1687
1907
  owner.currentProcess = null; owner.workerStop = null;
1908
+ // #3489: drain in-flight stdio before destroying the source streams —
1909
+ // destroying first discards bytes never delivered as 'data', settling
1910
+ // health on a partial stream (exit observed before drain). Ordering-only;
1911
+ // recovery semantics in this frozen baseline are unchanged.
1912
+ await drainChildStdio(child, graceMs);
1688
1913
  child.stdout.destroy(); child.stderr.destroy(); child.stdin?.destroy(); child.unref();
1689
1914
  for (const stream of streams) await new Promise(resolve => { if (stream.destroyed) resolve(); else stream.end(resolve); });
1690
1915
  const native = outcome.mode === 'systemd-unit' ? readNative() : null;
@@ -1707,6 +1932,7 @@ async function captureOneWorkerBaseline(owner, ctx) {
1707
1932
  const exitCode = resultSignal ? null
1708
1933
  : outcome.mode === 'systemd-unit' && native?.execMainCode === 1 ? native.execMainStatus : waitCode;
1709
1934
  const result = { invocationId, exitCode, signal: resultSignal,
1935
+ ...(options.managedAdmission ? { managedAdmission: options.managedAdmission } : {}),
1710
1936
  endedAt: new Date().toISOString(), termination: confirmed ? 'confirmed' : 'uncertain',
1711
1937
  descendants: confirmed ? 'stopped' : 'unknown', timedOut: resultTimedOut,
1712
1938
  cancelled: stopReason === 'CANCELLED', stopReason, errorCode: errorCode ?? null, capturedBytes: Math.min(total, output.length),
@@ -1772,8 +1998,11 @@ async function captureOneWorkerBaseline(owner, ctx) {
1772
1998
  sawExit = true; waitCode = code; waitSignal = sig; later(() => finish(), graceMs);
1773
1999
  });
1774
2000
  child.once('close', () => { sawClose = true; finish(); });
1775
- const hardRemaining = () => Math.max(0, Math.min(invocation.health?.hardTimeoutSeconds ?? invocation.limits.wallSeconds,
1776
- invocation.limits.wallSeconds) * 1000 - (Number(process.hrtime.bigint() - BigInt(monotonicStart)) / 1e6));
2001
+ const hardRemaining = () => Math.max(0, Math.min(
2002
+ Math.min(invocation.health?.hardTimeoutSeconds ?? invocation.limits.wallSeconds, invocation.limits.wallSeconds,
2003
+ options.managedAdmission ? (options.managedDeadlineAtMs - Date.parse(launchTime)) / 1000 : Infinity) * 1000
2004
+ - (Number(process.hrtime.bigint() - BigInt(monotonicStart)) / 1e6),
2005
+ options.managedAdmission ? options.managedDeadlineAtMs - Date.now() : Infinity));
1777
2006
  later(() => {
1778
2007
  observer.check();
1779
2008
  reportHealth(); stop('HARD_DEADLINE');
@@ -3707,6 +3936,102 @@ export function classifyAttemptForSettlement(invocation, result, { quotaAccount
3707
3936
  inferenceProvider: selected.inferenceProvider ?? null, quotaAccount, installationQualifier };
3708
3937
  }
3709
3938
 
3939
+ /** Refresh only a demonstrated stale executable binding. Installation, login,
3940
+ * permissions, models, working directories and policy are never repaired here.
3941
+ * One retry shares the original task clock and the existing atomic ledgers. */
3942
+ async function correctDirectWorkerStartup(owner, options, previous) {
3943
+ const context = directStartupCorrectionContext.get(owner);
3944
+ if (!context || options.correctStartup !== undefined || previous?.errorCode !== 'ENOENT'
3945
+ || previous.termination !== 'confirmed' || previous.exitCode != null || previous.signal != null
3946
+ || previous.cancelled || previous.timedOut || previous.publishFailures?.length
3947
+ || classifyLaunchOutcomeForRecovery(previous).reasonCode !== 'NOT_FOUND') return previous;
3948
+ const { saved, invocation, loadInputs, clock, dispatchGuard, ledger, inspect, qualification } = context;
3949
+ const record = { action: 'refresh-executable-binding', reasonCode: 'NOT_FOUND', state: 'stopped',
3950
+ previousInvocationId: previous.invocationId };
3951
+ const recordAt = async (runDir, invocationId) => {
3952
+ const processRecord = JSON.parse((await boundedPrivateRead(join(runDir, 'process.json'), 65536, inspect)).toString('utf8'));
3953
+ if (processRecord.origin !== 'launcher' || processRecord.invocationId !== invocationId) throw new Error('Correction record identity changed');
3954
+ await (options.publishRecord ?? publishWorkerRecord)(runDir, 'process.json', { ...processRecord, startupCorrection: { ...record } }, inspect);
3955
+ };
3956
+ const stop = async code => {
3957
+ record.stopCode = code;
3958
+ previous.startupCorrection = { ...record };
3959
+ await recordAt(options.runDir, previous.invocationId);
3960
+ return previous;
3961
+ };
3962
+ const guardStop = () => dispatchGuard?.isCancelled?.() === true ? 'CANCELLED'
3963
+ : dispatchGuard?.deadlineAtMs != null && (dispatchGuard.nowMs?.() ?? Date.now()) >= dispatchGuard.deadlineAtMs ? 'DEADLINE' : null;
3964
+ if (ledger?.n >= ledger?.maxAttempts) return stop('ATTEMPT_LIMIT');
3965
+ if (guardStop()) return stop(guardStop());
3966
+ let inputs;
3967
+ let plan;
3968
+ const retryId = randomUUID();
3969
+ const finalPath = `${invocation.finalPath}.recovery-${retryId}`;
3970
+ try {
3971
+ inputs = await loadInputs();
3972
+ const { resolveWorkerInvocation } = await importImpl(import.meta.url, 'serve/worker-invocation.js');
3973
+ plan = await resolveWorkerInvocation(inputs.snapshot, { ...saved.request, final_output: finalPath }, inputs.drivers, clock());
3974
+ } catch { return stop('QUALIFICATION_REFUSED'); }
3975
+ if (guardStop()) return stop(guardStop());
3976
+ if (plan?.status !== 'eligible' || plan.mode !== 'direct' || plan.invocation?.readiness !== 'launchable') return stop('QUALIFICATION_REFUSED');
3977
+ if (plan.invocation.argv[0] === invocation.argv[0]) return stop('UNCHANGED_EXECUTABLE');
3978
+ // A freshly requalified installation may have new binary/proof digests.
3979
+ // Preserve every effective adapter declaration and installation boundary.
3980
+ const effectiveQualification = value => {
3981
+ const copy = structuredClone(value);
3982
+ const stripEvidence = evidence => {
3983
+ if (evidence) for (const key of ['scopeDigest', 'fixtureDigest', 'verifiedAt', 'evidenceRef']) delete evidence[key];
3984
+ };
3985
+ stripEvidence(copy.adapter?.qualification);
3986
+ for (const profile of Object.values(copy.adapter?.invocationProfiles ?? {})) stripEvidence(profile.installationQualification);
3987
+ if (copy.installation) {
3988
+ delete copy.installation.executable;
3989
+ delete copy.installation.evidence;
3990
+ }
3991
+ if (copy.stdinFramerBinding) delete copy.stdinFramerBinding.storeDigest;
3992
+ return JSON.stringify(copy);
3993
+ };
3994
+ if (effectiveQualification(qualification) !== effectiveQualification(inputs.drivers[invocation.selected.cli]?.qualification)) return stop('BINDING_CHANGED');
3995
+ // The resolver is authoritative for the replacement executable. Everything
3996
+ // else must remain identical, including qualified selection, policy, limits,
3997
+ // framing, environment, controls, prompt digest and workspace. Only fresh
3998
+ // output names and expiration are expected to differ between attempts.
3999
+ const binding = value => {
4000
+ const copy = structuredClone(value);
4001
+ const replacements = ['stderrPath', 'stdoutPath', 'finalPath'].map(key => [copy[key], `<${key}>`]);
4002
+ const normalize = value => replacements.find(([path]) => path === value)?.[1] ?? value;
4003
+ copy.argv = copy.argv.map((value, index) => index === 0 ? '<executable>' : normalize(value));
4004
+ delete copy.selected.qualificationDigest;
4005
+ for (const key of ['stdoutPath', 'stderrPath', 'finalPath', 'expiresAt', 'shellCommand', 'profileDigest']) delete copy[key];
4006
+ return JSON.stringify(copy);
4007
+ };
4008
+ if (binding(plan.invocation) !== binding(invocation)) return stop('BINDING_CHANGED');
4009
+ const invocationPath = join(options.runDir, `recovery-plan-${retryId}.json`);
4010
+ const runDir = `${options.runDir}.recovery-${retryId}`;
4011
+ if (guardStop()) return stop(guardStop());
4012
+ let retry;
4013
+ try {
4014
+ const planHandle = await withWorkerPath(invocationPath, target => open(target,
4015
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600));
4016
+ try { await planHandle.writeFile(JSON.stringify(plan)); await planHandle.sync(); } finally { await planHandle.close(); }
4017
+ // Publication happens before any retry launch. A record failure stops work.
4018
+ record.state = 'applied';
4019
+ await recordAt(options.runDir, previous.invocationId);
4020
+ retry = await launchOneWorker(owner, { ...options, invocationPath, runDir, dispatchGuard,
4021
+ inputs: undefined, loadInputs, retryOf: { invocationId: previous.invocationId, runDir: options.runDir },
4022
+ correctStartup: { reasonCode: 'NOT_FOUND' }, startupCorrectionRecord: { ...record } });
4023
+ } catch {
4024
+ record.state = 'stopped';
4025
+ return stop(guardStop() ?? 'RETRY_REFUSED');
4026
+ }
4027
+ record.retryInvocationId = retry.invocationId;
4028
+ if (retry.attemptOutcome !== 'succeeded') { record.state = 'stopped'; record.stopCode = 'RETRY_FAILED'; }
4029
+ retry.startupCorrection = { ...record };
4030
+ await recordAt(options.runDir, previous.invocationId);
4031
+ await recordAt(runDir, retry.invocationId);
4032
+ return retry;
4033
+ }
4034
+
3710
4035
  // #2915 round 8 (D6, "lazy public fields still differ after mixed use"):
3711
4036
  // `lastAttemptResult`/`lastAttemptInvocationId` are informational
3712
4037
  // bookkeeping only (#2915 step 4 — the retry gate no longer reads them;
@@ -3778,8 +4103,9 @@ export class SessionLauncher extends EventEmitter {
3778
4103
  * `options.correctStartup: { reasonCode, correctionsUsed }` enforces the
3779
4104
  * one-qualified-startup-correction budget on a conductor-declared
3780
4105
  * correction retry (startupCorrectionGate): unqualified causes and second
3781
- * corrections are refused, within the task's existing allowance. There is
3782
- * no automatic correction loop.
4106
+ * corrections are refused, within the task's existing allowance. The only
4107
+ * automatic correction refreshes a changed qualified executable binding
4108
+ * after a launcher-observed ENOENT with confirmed stop, once per task chain.
3783
4109
  *
3784
4110
  * Both checks are gated recovery behavior, not a general launch
3785
4111
  * precondition: enforcement happens entirely INSIDE `launchOneWorker`,
@@ -3806,8 +4132,13 @@ export class SessionLauncher extends EventEmitter {
3806
4132
  // `options.retryOf`-driven and disk-verified every time) is set inside
3807
4133
  // `launchOneWorker` itself, gated on whether recovery actually admitted
3808
4134
  // this attempt (#2915 review 6, finding 6) — never here unconditionally.
3809
- try { return await launchOneWorker(this, options); }
4135
+ directStartupCorrectionContext.delete(this);
4136
+ try {
4137
+ const result = await launchOneWorker(this, options);
4138
+ return await correctDirectWorkerStartup(this, options, result);
4139
+ }
3810
4140
  finally {
4141
+ directStartupCorrectionContext.delete(this);
3811
4142
  _workerRecoverySubscriptions.get(this)?.();
3812
4143
  _workerRecoverySubscriptions.delete(this);
3813
4144
  this.workerLaunchPending = false;
@@ -5055,3 +5386,42 @@ export function planDirectWorkerLaunch({
5055
5386
  }
5056
5387
 
5057
5388
  export default SessionLauncher;
5389
+
5390
+ /** The packaged session owner accepts only the parent-bound managed one-shot contract. */
5391
+ export function parseManagedWorkerLaunchArgs(args) {
5392
+ const result = {};
5393
+ const seen = new Set();
5394
+ for (let index = 0; index < args.length; index++) {
5395
+ const flag = args[index];
5396
+ if (seen.has(flag)) throw new Error(`Duplicate managed worker option: ${flag}`);
5397
+ seen.add(flag);
5398
+ if (flag === '--launch-worker') continue;
5399
+ const key = { '--invocation': 'invocationPath', '--run-dir': 'runDir',
5400
+ '--managed-request': 'requestId', '--managed-generation': 'generation' }[flag];
5401
+ if (!key) throw new Error(`Unsupported managed worker option: ${flag}`);
5402
+ const value = args[++index];
5403
+ if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value`);
5404
+ result[key] = value;
5405
+ }
5406
+ if (!seen.has('--launch-worker') || !result.invocationPath || !result.runDir || !result.requestId || !result.generation) {
5407
+ throw new Error('Managed worker launch requires invocation, run directory and fenced admission');
5408
+ }
5409
+ const generation = Number(result.generation);
5410
+ if (!Number.isSafeInteger(generation) || generation < 1) throw new Error('Invalid managed admission generation');
5411
+ return { invocationPath: result.invocationPath, runDir: result.runDir,
5412
+ managedAdmission: { requestId: result.requestId, generation } };
5413
+ }
5414
+
5415
+ async function runManagedWorkerEntry() {
5416
+ const result = await new SessionLauncher().launchWorker(parseManagedWorkerLaunchArgs(process.argv.slice(2)));
5417
+ console.log(JSON.stringify(result));
5418
+ process.exitCode = result.termination !== 'confirmed' ? 2
5419
+ : result.errorCode || result.timedOut || result.exitCode !== 0 ? 1 : 0;
5420
+ }
5421
+
5422
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
5423
+ runManagedWorkerEntry().catch(error => {
5424
+ console.error(error.message);
5425
+ process.exitCode = 1;
5426
+ });
5427
+ }