@aiwg/cli 2026.10.10 → 2026.10.11

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 (34) hide show
  1. package/agentic/code/providers/model-capabilities.v1.json +78 -14
  2. package/dist/src/cli/handlers/sessions.js +198 -15
  3. package/dist/src/cli/handlers/use.js +12 -4
  4. package/dist/src/config/worker-context.js +337 -0
  5. package/dist/src/mcp/tools/worker-invoke.mjs +108 -10
  6. package/dist/src/models/model-capabilities.v1.json +78 -14
  7. package/dist/src/models/provider-policy.js +7 -0
  8. package/dist/src/policy/authorization.js +29 -1
  9. package/dist/src/process/aiwg-probe-command.d.mts +15 -0
  10. package/dist/src/process/aiwg-probe-command.mjs +37 -0
  11. package/dist/src/sessions/adapters/grokbot.js +7 -0
  12. package/dist/src/sessions/batch-import.js +34 -6
  13. package/dist/src/sessions/contracts.js +19 -0
  14. package/dist/src/sessions/coverage-preflight.js +7 -1
  15. package/dist/src/sessions/fortemi-export-mapping.js +33 -3
  16. package/dist/src/sessions/host-catalog.js +157 -0
  17. package/dist/src/sessions/image-blobs.js +141 -0
  18. package/dist/src/sessions/importer.js +23 -3
  19. package/dist/src/sessions/index.js +2 -0
  20. package/dist/src/sessions/refresh.js +25 -2
  21. package/dist/src/sessions/repository.js +164 -22
  22. package/dist/src/sessions/workspace-discovery.js +5 -2
  23. package/package.json +7 -1
  24. package/tools/ralph-external/lib/muse-adapter.mjs +10 -0
  25. package/tools/ralph-external/lib/worker-context.mjs +237 -0
  26. package/tools/ralph-external/lib/worker-invocation-ancestry.mjs +132 -0
  27. package/tools/ralph-external/lib/worker-invocation-processes.mjs +123 -0
  28. package/tools/ralph-external/lib/worker-isolation-status.mjs +48 -0
  29. package/tools/ralph-external/lib/worker-isolation.mjs +17 -29
  30. package/tools/ralph-external/lib/worker-launch-cli.mjs +3 -1
  31. package/tools/ralph-external/lib/worker-overflow.mjs +48 -13
  32. package/tools/ralph-external/lib/worker-retry-evidence.mjs +103 -0
  33. package/tools/ralph-external/lib/worker-termination.mjs +82 -0
  34. package/tools/ralph-external/session-launcher.mjs +51 -96
@@ -0,0 +1,82 @@
1
+ // Confirm owned containment is empty independently of semantic observer coverage.
2
+ import { sampleWorkerIdentity, isSafeWorkerGroupTarget } from '../process-monitor.mjs';
3
+ import { getWorkerIsolationStatus } from './worker-isolation-status.mjs';
4
+ import { scanWorkerInvocationProcesses } from './worker-invocation-processes.mjs';
5
+
6
+ export function observeWorkerTermination({ mode, native, workerExited, identity,
7
+ supervisor = sampleWorkerIdentity(process.pid), kill = process.kill, invocationId,
8
+ scan = scanWorkerInvocationProcesses, deadline } = {}) {
9
+ if (mode === 'systemd-unit') {
10
+ const exited = workerExited === true || [1, 2, 3].includes(native?.execMainCode);
11
+ return { confirmed: exited && native?.active === false && native?.cgroupEmpty === true,
12
+ missing: !exited ? 'launcher-observed exit or unit ExecMainCode exit evidence' : native?.active !== false
13
+ ? 'unit inactive evidence' : native?.cgroupEmpty !== true ? 'empty unit cgroup (TasksCurrent=0)' : null };
14
+ }
15
+ if (!workerExited) return { confirmed: false, missing: 'launcher-observed worker exit' };
16
+ if (!identity || identity.pid !== identity.pgid || identity.uid !== process.getuid?.()
17
+ || !supervisor || identity.host !== supervisor.host || identity.bootId !== supervisor.bootId
18
+ || !isSafeWorkerGroupTarget(identity.pgid, supervisor)) {
19
+ return { confirmed: false, missing: 'launcher-owned process group identity on this host and boot' };
20
+ }
21
+ try { kill(-identity.pgid, 0); }
22
+ catch (error) {
23
+ // Pass the recorded leader so the marker scan can tell a parallel
24
+ // hardened (non-dumpable) worker's tree apart from a genuine escapee via
25
+ // readable stat/cgroup links instead of failing closed on every
26
+ // unreadable post-start process.
27
+ if (error.code === 'ESRCH') {
28
+ return scan({ invocationId, startTicks: identity.startTicks, uid: identity.uid, deadline,
29
+ workerPid: identity.pid, workerPgid: identity.pgid, workerSession: identity.sessionId });
30
+ }
31
+ return { confirmed: false, missing: `empty owned process group (signal-0 returned ${error.code ?? 'unknown'})` };
32
+ }
33
+ return { confirmed: false, missing: 'empty owned process group (signal-0 still finds members)' };
34
+ }
35
+
36
+ export function terminationRemedy(result, runDir) {
37
+ const missing = result?.isolation === 'systemd-unit'
38
+ ? 'unit exit, inactive state and empty cgroup (TasksCurrent=0)'
39
+ : 'launcher-observed worker exit, empty owned process group (kill(-pgid, 0) = ESRCH) and escaped-descendant evidence (bounded same-uid invocation-marker scan in Linux /proc)';
40
+ return `Missing evidence: ${missing}. Repeat the original --launch-worker${runDir ? ` --retry-of-run-dir ${JSON.stringify(runDir)}` : ' --retry-of-run-dir DIR'} or --verify-worker${runDir ? ` --run-dir ${JSON.stringify(runDir)}` : ' --run-dir DIR'} command with --check-descendants for a bounded descendant check (at most 2 seconds); live or unknown descendants still refuse linkage. Missing launcher-owned group identity requires the original launch owner to establish containment; worker-reported identity cannot authorize this check.`;
41
+ }
42
+
43
+ export async function recheckWorkerTermination({ processRecord, exitRecord,
44
+ status = getWorkerIsolationStatus, observe = observeWorkerTermination,
45
+ now = () => performance.now(), wait = ms => new Promise(resolve => setTimeout(resolve, ms)) }) {
46
+ if (processRecord?.invocationId !== exitRecord.invocationId || processRecord.origin !== 'launcher') {
47
+ return { confirmed: false, missing: 'matching launcher process.json' };
48
+ }
49
+ const local = sampleWorkerIdentity(process.pid);
50
+ if (!local || processRecord.supervisor?.host !== local.host || processRecord.supervisor?.bootId !== local.bootId
51
+ || processRecord.supervisor?.uid !== local.uid || processRecord.isolation !== exitRecord.isolation
52
+ || !['systemd-unit', 'native-sandbox', 'degraded'].includes(processRecord.isolation)) {
53
+ return { confirmed: false, missing: 'launcher containment identity on this host and boot' };
54
+ }
55
+ const identity = processRecord.identityObservation === 'stable-owned-group'
56
+ && processRecord.ownership === 'owned-group' ? processRecord.worker : null;
57
+ if (processRecord.isolation !== 'systemd-unit' && processRecord.invocationMarker !== processRecord.invocationId) {
58
+ return { confirmed: false, missing: 'escaped-descendant evidence: launcher-bound invocation marker in process.json' };
59
+ }
60
+ const deadline = now() + 2000;
61
+ // Both elapsed time and sample count bound the check, including injected clocks.
62
+ let evidence;
63
+ for (let attempt = 0; attempt < 21; attempt++) {
64
+ if (evidence && now() >= deadline) return evidence;
65
+ let native = null;
66
+ // Use the unit's retained exit state whenever a unit name is recorded,
67
+ // not only when the recorded mode already says systemd-unit: launch-mode
68
+ // selection is unchanged, but unit (cgroup) evidence is preferred
69
+ // wherever it is available. Non-unit modes still ignore it below.
70
+ if (typeof processRecord.unit === 'string' && processRecord.unit !== '') {
71
+ try { native = status(processRecord.unit, { timeoutMs: Math.max(1, Math.min(100, deadline - now())) }); }
72
+ catch { /* Unavailable is never empty. */ }
73
+ }
74
+ evidence = observe({ mode: processRecord.isolation, native, identity, invocationId: processRecord.invocationId,
75
+ deadline: Math.min(deadline, now() + 100),
76
+ workerExited: Number.isInteger(exitRecord.exitCode) && exitRecord.exitCode >= 0 && exitRecord.exitCode <= 255
77
+ || exitRecord.captureCoverage === 'complete' && exitRecord.workerTermination === 'confirmed' && /^SIG[A-Z0-9]+$/.test(exitRecord.signal ?? '') });
78
+ if (evidence.confirmed || evidence.markerPresent || now() >= deadline || attempt === 20) return evidence;
79
+ await wait(Math.min(100, Math.max(0, deadline - now())));
80
+ }
81
+ return evidence;
82
+ }
@@ -51,6 +51,9 @@ export { assertManagedWorkerLaunch } from './lib/managed-worker-attempt.mjs';
51
51
  import { buildSessionEnvironment } from './lib/session-environment.mjs';
52
52
  import { withWorkerPath, boundedPrivateRead } from './lib/worker-inputs.mjs';
53
53
  import { collectWorkerFinal } from './lib/worker-final.mjs';
54
+ import { verifyRetryPredecessor } from './lib/worker-retry-evidence.mjs';
55
+ import { observeWorkerTermination, terminationRemedy } from './lib/worker-termination.mjs';
56
+ import { buildWorkerInvocationEnvironment, WORKER_INVOCATION_MARKER } from './lib/worker-invocation-processes.mjs';
54
57
  import { resolveRetryReference } from './lib/worker-launch-cli.mjs';
55
58
  import { launcherRecoveryEvidence, observeLauncherRecoveryOutcome, recordLauncherRecoveryHint } from './lib/launcher-recovery-evidence.mjs';
56
59
  export { observeLauncherRecoveryOutcome } from './lib/launcher-recovery-evidence.mjs';
@@ -375,6 +378,23 @@ async function launchOneWorker(owner, options) {
375
378
  const { assertConductorWorkerPlan } = await importImpl(import.meta.url, 'serve/worker-invocation-paths.js');
376
379
  await assertConductorWorkerPlan(saved, options.invocationPath, inputs.drivers);
377
380
  const invocation = await validate(saved, inputs.snapshot, inputs.drivers, now, options.invocationPath);
381
+ // #3685 round 2: the worker CLI's AIWG bootstrap must be present in the
382
+ // worker workdir before any spawn — a context-free worker would run with
383
+ // no project rules/skills/hooks. Opt-in per entry point (the
384
+ // `--launch-worker` CLI enables it via `options.ensureWorkerContext`);
385
+ // absent means "no ensure", never a default deploy, so pinned baselines
386
+ // and synthetic launches are unaffected. A failed ensure throws the
387
+ // typed `OG_WORKER_CONTEXT_UNAVAILABLE` diagnostic (with remedy) and the
388
+ // worker never spawns.
389
+ if (options.ensureWorkerContext) {
390
+ const { ensureWorkerContextForLaunch } = await import('./lib/worker-context.mjs');
391
+ await ensureWorkerContextForLaunch({
392
+ workdir: invocation.cwd,
393
+ cli: invocation.selected?.cli,
394
+ config: inputs.snapshot?.config ?? null,
395
+ ...(typeof options.ensureWorkerContext === 'object' ? options.ensureWorkerContext : {}),
396
+ });
397
+ }
378
398
  if (invocation.mode === 'managed' || options.managedAdmission) {
379
399
  const admitted = await assertManagedWorkerLaunch(options, invocation, inputs, options.projectRoot ?? process.cwd());
380
400
  options = { ...options, scopedBreaker: null, managedInvocationId: admitted.metadata.invocationId,
@@ -409,7 +429,7 @@ async function launchOneWorker(owner, options) {
409
429
  let verifiedPreviousAttemptId = null;
410
430
  if (options?.retryOf !== undefined) {
411
431
  const exitRecord = await verifyRetryPredecessor(await resolveRetryReference(options.retryOf, { inspect }),
412
- { newInvocationPath: options?.invocationPath, inspect, workerWritableRoots: writableRoots });
432
+ { newInvocationPath: options?.invocationPath, inspect, workerWritableRoots: writableRoots, checkDescendants: options.checkDescendants });
413
433
  verifiedPreviousAttemptId = exitRecord.invocationId;
414
434
  }
415
435
  const paths = (await importImpl(import.meta.url, 'serve/worker-invocation-paths.js')).workerOutputPaths(invocation);
@@ -675,13 +695,13 @@ async function launchOneWorker(owner, options) {
675
695
  executedArgvDigest: qualificationDigest(artifacts.argv),
676
696
  artifacts: (invocation.artifacts ?? []).map(artifact => ({ name: artifact.name,
677
697
  byteLength: Buffer.byteLength(artifact.content), sha256: bytesDigest(artifact.content) })),
678
- envNames: invocation.envNames, stdin: invocation.stdin,
698
+ envNames: [...new Set([...invocation.envNames, WORKER_INVOCATION_MARKER])], stdin: invocation.stdin,
679
699
  prompt: { path: invocation.briefPath, digest: invocation.briefDigest }, cwd: invocation.cwd,
680
700
  worktree: { path: invocation.cwd, baseRevision: null }, paths, captures, outputFraming: profile.health?.outputFraming ?? 'opaque',
681
701
  health: invocation.health ?? null, transport: invocation.transport,
682
702
  isolation: { mode: isolation.mode, memoryMaxGb: isolation.mode === 'native-sandbox' ? null : options.memoryMaxGb ?? 4 },
683
703
  limits: invocation.limits, maxOutputBytes: Math.min(invocation.maxOutputBytes ?? 10485760, 10485760) });
684
- await publish('process.json', { invocationId, origin: 'launcher', supervisor, worker: null,
704
+ await publish('process.json', { invocationId, invocationMarker: invocationId, origin: 'launcher', supervisor, worker: null,
685
705
  launchTime, monotonicStart, ownership: 'unknown', isolation: isolation.mode });
686
706
  // All captures (including the reserved final) open successfully BEFORE spawn.
687
707
  for (const filename of [...Object.values(paths), ...Object.values(captures)]) {
@@ -695,8 +715,7 @@ async function launchOneWorker(owner, options) {
695
715
  paths: await Promise.all([invocation.cwd, runDir].map(workerDispatchPath)) });
696
716
  if (!gate.ok) throw workerDispatchRefusal(gate);
697
717
  if (bytesDigest(await boundedPrivateRead(invocation.briefPath, 262144, inspect)) !== invocation.briefDigest) throw new Error('Worker prompt changed before spawn');
698
- const environment = Object.fromEntries(invocation.envNames.filter(name => process.env[name] !== undefined)
699
- .map(name => [name, process.env[name]]));
718
+ const environment = buildWorkerInvocationEnvironment(invocation.envNames, invocationId);
700
719
  const baselineContent = await sampleOwnedWorktreeContent(invocation.cwd, { files: options.healthArtifacts });
701
720
  // #2915 round 8 (D2, replaces the former "debit immediately before this
702
721
  // call" comment): the ledger debit now happens INSIDE the dispatch-
@@ -757,7 +776,7 @@ async function launchOneWorker(owner, options) {
757
776
  command: artifacts.argv[0], args: artifacts.argv.slice(1), cwd: invocation.cwd,
758
777
  memoryMaxGb: options.memoryMaxGb ?? 4, gate, config: inputs.snapshot.config, isolation, workerGroup: invocation.selected.group,
759
778
  allowDegraded: !!options.allowDegraded, attached: true, ownedGroup: true,
760
- env: environment, envNames: invocation.envNames, stdin: invocation.stdin, runDir,
779
+ ...environment, stdin: invocation.stdin, runDir,
761
780
  ...(onDispatchCommit ? { onDispatchCommit } : {}) });
762
781
  outcome = typeof launchResult?.then === 'function' ? await launchResult : launchResult;
763
782
  if (!outcome?.child) throw workerLaunchRefusal(outcome);
@@ -1088,7 +1107,7 @@ async function captureOneWorker(owner, ctx) {
1088
1107
  for (const stream of streams) stream.on('error', () => { errorCode = 'CAPTURE_WRITE'; stop(); });
1089
1108
  child.stdin?.on('error', error => { if (error.code !== 'EPIPE') { errorCode = 'STDIN_WRITE'; stop(); } });
1090
1109
  child.stdin?.end(ctx.stdinBytes ?? undefined);
1091
- const recordProcess = () => publish('process.json', { invocationId, origin: 'launcher', supervisor,
1110
+ const recordProcess = () => publish('process.json', { invocationId, invocationMarker: invocationId, origin: 'launcher', supervisor,
1092
1111
  transportProcess: hasTransport ? sampleWorkerIdentity(child.pid) : null,
1093
1112
  nativeSupervisor,
1094
1113
  worker: identity ?? reportedWorker, identityObservation, launchTime, monotonicStart, ownership: identity ? 'owned-group' : 'unknown',
@@ -1113,12 +1132,14 @@ async function captureOneWorker(owner, ctx) {
1113
1132
  await drainChildStdio(child, graceMs);
1114
1133
  child.stdout.destroy(); child.stderr.destroy(); child.stdin?.destroy(); child.unref();
1115
1134
  for (const stream of streams) await new Promise(resolve => { if (stream.destroyed) resolve(); else stream.end(resolve); });
1116
- const native = outcome.mode === 'systemd-unit' ? readNative() : null;
1135
+ // Prefer the unit's retained state whenever a unit name is recorded
1136
+ // (#3674 round 4): launch-mode selection is unchanged, but unit (cgroup)
1137
+ // evidence is used wherever it is available.
1138
+ const native = outcome.unit ? readNative() : null;
1117
1139
  const members = identity ? sampleWorkerGroup(identity.pgid) : null;
1118
- // An unconfined group can escape via setsid. Empty PGID alone never proves
1119
- // descendant termination; keep the explicit degraded stop uncertain.
1120
- const confirmed = outcome.mode === 'systemd-unit' ? native?.active === false && [1, 2, 3].includes(native.execMainCode)
1121
- : child.pid === undefined;
1140
+ // Require positive kernel evidence of owned containment being empty.
1141
+ const confirmed = (outcome.mode !== 'systemd-unit' && child.pid === undefined) || observeWorkerTermination({ mode: outcome.mode, native,
1142
+ workerExited: sawExit, identity, supervisor, invocationId }).confirmed;
1122
1143
  // `worker-exit.json`/`worker-identity.json` are written into a directory
1123
1144
  // the worker itself can reach (same UID, no filesystem confinement), so a
1124
1145
  // worker can plant either file before the trusted transport ever writes
@@ -1154,7 +1175,7 @@ async function captureOneWorker(owner, ctx) {
1154
1175
  : outcome.mode === 'systemd-unit' && native?.execMainCode === 1 ? native.execMainStatus : waitCode;
1155
1176
  const result = { invocationId, exitCode, signal: resultSignal,
1156
1177
  endedAt: new Date().toISOString(), termination: confirmed ? 'confirmed' : 'uncertain',
1157
- descendants: confirmed ? 'stopped' : 'unknown', timedOut: resultTimedOut,
1178
+ descendants: confirmed ? 'none' : 'unknown', timedOut: resultTimedOut,
1158
1179
  cancelled: stopReason === 'CANCELLED', stopReason, errorCode: errorCode ?? null, capturedBytes: Math.min(total, output.length),
1159
1180
  // Unauthenticated: reported by the worker-writable transport record, not
1160
1181
  // kernel-observed. Never used above for exitCode/signal/termination.
@@ -1173,9 +1194,8 @@ async function captureOneWorker(owner, ctx) {
1173
1194
  // ExecMainCode/Status when applicable, launcher-detected errors,
1174
1195
  // timeout and explicit cancellation) — never worker stderr/stdout text
1175
1196
  // or worker-writable records. A degraded-isolation attempt can reach
1176
- // 'succeeded' here even though its `termination` stays 'uncertain'
1177
- // forever (no descendant-stop confirmation is possible without a
1178
- // systemd unit) — this is exactly the #2915 final-review fix: "give
1197
+ // 'succeeded' here even when descendant-stop evidence is unavailable.
1198
+ // This preserves the #2915 final-review fix: "give
1179
1199
  // degraded successful execution a valid recovery-success path without
1180
1200
  // falsely declaring descendant termination confirmed."
1181
1201
  const directChildCleanExit = sawExit && waitCode === 0 && waitSignal == null;
@@ -1555,13 +1575,13 @@ async function launchOneWorkerBaseline(owner, options, ctx) {
1555
1575
  executedArgvDigest: qualificationDigest(artifacts.argv),
1556
1576
  artifacts: (invocation.artifacts ?? []).map(artifact => ({ name: artifact.name,
1557
1577
  byteLength: Buffer.byteLength(artifact.content), sha256: bytesDigest(artifact.content) })),
1558
- envNames: invocation.envNames, stdin: invocation.stdin,
1578
+ envNames: [...new Set([...invocation.envNames, WORKER_INVOCATION_MARKER])], stdin: invocation.stdin,
1559
1579
  prompt: { path: invocation.briefPath, digest: invocation.briefDigest }, cwd: invocation.cwd,
1560
1580
  worktree: { path: invocation.cwd, baseRevision: null }, paths, captures, outputFraming: profile.health?.outputFraming ?? 'opaque',
1561
1581
  health: invocation.health ?? null, transport: invocation.transport,
1562
1582
  isolation: { mode: isolation.mode, memoryMaxGb: isolation.mode === 'native-sandbox' ? null : options.memoryMaxGb ?? 4 },
1563
1583
  limits: invocation.limits, maxOutputBytes: Math.min(invocation.maxOutputBytes ?? 10485760, 10485760) });
1564
- await publish('process.json', { invocationId, origin: 'launcher', supervisor, worker: null,
1584
+ await publish('process.json', { invocationId, invocationMarker: invocationId, origin: 'launcher', supervisor, worker: null,
1565
1585
  launchTime, monotonicStart, ownership: 'unknown', isolation: isolation.mode });
1566
1586
  // All captures (including the reserved final) open successfully BEFORE spawn.
1567
1587
  for (const filename of [...Object.values(paths), ...Object.values(captures)]) {
@@ -1575,8 +1595,7 @@ async function launchOneWorkerBaseline(owner, options, ctx) {
1575
1595
  paths: await Promise.all([invocation.cwd, runDir].map(workerDispatchPath)) });
1576
1596
  if (!localGate.ok) throw workerDispatchRefusal(localGate);
1577
1597
  if (bytesDigest(await boundedPrivateRead(invocation.briefPath, 262144, inspect)) !== invocation.briefDigest) throw new Error('Worker prompt changed before spawn');
1578
- const environment = Object.fromEntries(invocation.envNames.filter(name => process.env[name] !== undefined)
1579
- .map(name => [name, process.env[name]]));
1598
+ const environment = buildWorkerInvocationEnvironment(invocation.envNames, invocationId);
1580
1599
  const baselineContent = await sampleOwnedWorktreeContent(invocation.cwd, { files: options.healthArtifacts });
1581
1600
  // The real `launchIsolatedWorker`, called with no `onDispatchCommit`
1582
1601
  // (never passed anywhere in this frozen function), is synchronous —
@@ -1598,7 +1617,7 @@ async function launchOneWorkerBaseline(owner, options, ctx) {
1598
1617
  options.managedDeadlineAtMs = Math.min(options.managedDeadlineAtMs, admitted.deadlineAtMs);
1599
1618
  } } : {}),
1600
1619
  ...(options.managedAdmission ? { onSpawnAttempt: () => { managedSpawnAttempted = true; } } : {}),
1601
- env: environment, envNames: invocation.envNames, stdin: invocation.stdin, runDir });
1620
+ ...environment, stdin: invocation.stdin, runDir });
1602
1621
  outcome = typeof launchResult?.then === 'function' ? await launchResult : launchResult;
1603
1622
  if (!outcome?.child) throw workerLaunchRefusal(outcome);
1604
1623
  return await captureOneWorkerBaseline(owner, { child: outcome.child, outcome, invocation, invocationId, supervisor,
@@ -1757,7 +1776,7 @@ async function captureOneWorkerBaseline(owner, ctx) {
1757
1776
  for (const stream of streams) stream.on('error', () => { errorCode = 'CAPTURE_WRITE'; stop(); });
1758
1777
  child.stdin?.on('error', error => { if (error.code !== 'EPIPE') { errorCode = 'STDIN_WRITE'; stop(); } });
1759
1778
  child.stdin?.end(ctx.stdinBytes ?? undefined);
1760
- const recordProcess = () => publish('process.json', { invocationId, origin: 'launcher', supervisor,
1779
+ const recordProcess = () => publish('process.json', { invocationId, invocationMarker: invocationId, origin: 'launcher', supervisor,
1761
1780
  transportProcess: hasTransport ? sampleWorkerIdentity(child.pid) : null,
1762
1781
  nativeSupervisor,
1763
1782
  worker: identity ?? reportedWorker, identityObservation, launchTime, monotonicStart, ownership: identity ? 'owned-group' : 'unknown',
@@ -1783,10 +1802,13 @@ async function captureOneWorkerBaseline(owner, ctx) {
1783
1802
  await drainChildStdio(child, graceMs);
1784
1803
  child.stdout.destroy(); child.stderr.destroy(); child.stdin?.destroy(); child.unref();
1785
1804
  for (const stream of streams) await new Promise(resolve => { if (stream.destroyed) resolve(); else stream.end(resolve); });
1786
- const native = outcome.mode === 'systemd-unit' ? readNative() : null;
1805
+ // Prefer the unit's retained state whenever a unit name is recorded
1806
+ // (#3674 round 4): launch-mode selection is unchanged, but unit (cgroup)
1807
+ // evidence is used wherever it is available.
1808
+ const native = outcome.unit ? readNative() : null;
1787
1809
  const members = identity ? sampleWorkerGroup(identity.pgid) : null;
1788
- const confirmed = outcome.mode === 'systemd-unit' ? native?.active === false && [1, 2, 3].includes(native.execMainCode)
1789
- : child.pid === undefined;
1810
+ const confirmed = (outcome.mode !== 'systemd-unit' && child.pid === undefined) || observeWorkerTermination({ mode: outcome.mode, native,
1811
+ workerExited: sawExit, identity, supervisor, invocationId }).confirmed;
1790
1812
  const workerExit = hasTransport ? await transportRecord('worker-exit.json') : null;
1791
1813
  if (!identity && hasTransport && nativeSupervisor) {
1792
1814
  const workerStart = await transportRecord('worker-identity.json');
@@ -1805,7 +1827,7 @@ async function captureOneWorkerBaseline(owner, ctx) {
1805
1827
  const result = { invocationId, exitCode, signal: resultSignal,
1806
1828
  ...(options.managedAdmission ? { managedAdmission: options.managedAdmission } : {}),
1807
1829
  endedAt: new Date().toISOString(), termination: confirmed ? 'confirmed' : 'uncertain',
1808
- descendants: confirmed ? 'stopped' : 'unknown', timedOut: resultTimedOut,
1830
+ descendants: confirmed ? 'none' : 'unknown', timedOut: resultTimedOut,
1809
1831
  cancelled: stopReason === 'CANCELLED', stopReason, errorCode: errorCode ?? null, capturedBytes: Math.min(total, output.length),
1810
1832
  workerTermination: hasTransport ? workerExit && !workerExit.errorCode ? 'confirmed' : 'uncertain' : sawExit ? 'confirmed' : 'uncertain',
1811
1833
  workerReportedExit: workerExit ? { exitCode: workerExit.exitCode, signal: workerExit.signal, errorCode: workerExit.errorCode ?? null } : null,
@@ -2015,13 +2037,13 @@ export const UNBOUND_RETRY_EVIDENCE_CODE = 'UNBOUND_RETRY_EVIDENCE';
2015
2037
  * retry and is surfaced with a code the conductor can act on — it must never
2016
2038
  * silently spawn a duplicate writer.
2017
2039
  */
2018
- export function stopBeforeRetryGate(previousResult, { expectedInvocationId = null } = {}) {
2040
+ export function stopBeforeRetryGate(previousResult, { expectedInvocationId = null, runDir, missing } = {}) {
2019
2041
  if (previousResult?.termination !== 'confirmed') {
2020
2042
  return {
2021
2043
  ok: false,
2022
2044
  code: STOP_BEFORE_RETRY_CODE,
2023
2045
  reason: `Previous attempt termination is '${previousResult?.termination ?? 'unknown'}', not confirmed: `
2024
- + 'retry is blocked until owned stop is proven (no duplicate writer while the old tree may continue).',
2046
+ + 'retry is blocked until owned stop is proven (no duplicate writer while the old tree may continue). ' + (missing ? `Missing: ${missing}. ` : '') + terminationRemedy(previousResult, runDir),
2025
2047
  previousResult: previousResult === undefined ? undefined : (previousResult ?? null),
2026
2048
  };
2027
2049
  }
@@ -3138,74 +3160,7 @@ export async function releaseLedgerPolicy({ captureRoot, taskChainDigest } = {})
3138
3160
  * Returns the predecessor's verified `exit.json` record on success; throws
3139
3161
  * on any refusal.
3140
3162
  */
3141
- export async function verifyRetryPredecessor(retryOf, { newInvocationPath, inspect, workerWritableRoots } = {}) {
3142
- const fail = (reason) => {
3143
- const error = new Error(`Retry predecessor evidence refused: ${reason}`);
3144
- error.code = UNBOUND_RETRY_EVIDENCE_CODE;
3145
- throw error;
3146
- };
3147
- if (!retryOf || typeof retryOf.invocationId !== 'string' || !retryOf.invocationId
3148
- || typeof retryOf.runDir !== 'string' || !retryOf.runDir) {
3149
- fail('retryOf requires both a non-empty invocationId and runDir');
3150
- }
3151
- const { invocationId: prevId, runDir: prevRunDir } = retryOf;
3152
- // #2915 review 4 (finding 4): the minimal directory-authority repair —
3153
- // reject a predecessor location inside any worker-writable root, exactly
3154
- // like `launchOneWorker` already refuses for a NEW run directory. This
3155
- // closes the easiest forgery (planting a complete, well-formed fake
3156
- // predecessor inside the worktree the worker itself can write to) but is
3157
- // explicitly NOT a defense against an unconfined malicious same-UID
3158
- // worker in general: such a worker can still write anywhere this
3159
- // conductor process can (e.g. directly under a configured capture root,
3160
- // if that root is not itself outside every worker-writable path, or by
3161
- // racing a legitimate predecessor's own directory before this check
3162
- // runs). A real guarantee needs an enforced filesystem/process boundary
3163
- // protecting conductor-owned metadata, or a different execution
3164
- // identity — same-UID alone never provides it.
3165
- if (Array.isArray(workerWritableRoots)) {
3166
- for (const root of workerWritableRoots) {
3167
- if (await realBeneath(root, prevRunDir)) fail('predecessor location is inside a worker-writable root');
3168
- }
3169
- }
3170
- if (!await inspect(prevRunDir, 'directory')) fail('predecessor run directory is missing');
3171
- const dirStat = await lstat(prevRunDir);
3172
- if (dirStat.uid !== process.getuid() || (dirStat.mode & 0o077)) fail('predecessor run directory is not privately owned');
3173
- let ownerBytes, invocationBytes, exitBytes;
3174
- try {
3175
- // #2915 review 4 (finding 4): `rejectInsecureMode: true` — an insecure
3176
- // mode on predecessor metadata REFUSES the retry instead of silently
3177
- // chmod-repairing it and proceeding (the former behaviour the review
3178
- // found: "the probe's 0666 files were accepted and changed to 0600").
3179
- ownerBytes = await boundedPrivateRead(join(prevRunDir, '.launch-owner'), 256, inspect, true, true);
3180
- invocationBytes = await boundedPrivateRead(join(prevRunDir, 'invocation.json'), 1048576, inspect, true, true);
3181
- exitBytes = await boundedPrivateRead(join(prevRunDir, 'exit.json'), 1048576, inspect, true, true);
3182
- } catch (error) { fail(`predecessor metadata is missing, insecurely-moded, or not a bounded, privately-owned, unaliased regular file (${error.message})`); }
3183
- if (ownerBytes.toString('utf8') !== prevId) fail('.launch-owner does not match retryOf.invocationId');
3184
- let invocationRecord, exitRecord;
3185
- try {
3186
- invocationRecord = JSON.parse(invocationBytes.toString('utf8'));
3187
- exitRecord = JSON.parse(exitBytes.toString('utf8'));
3188
- } catch { fail('predecessor metadata is not valid JSON'); }
3189
- if (invocationRecord?.invocationId !== prevId || invocationRecord?.origin !== 'launcher') {
3190
- fail('invocation.json identity does not match retryOf (or is not launcher-written)');
3191
- }
3192
- if (exitRecord?.invocationId !== prevId) fail('exit.json identity does not match retryOf');
3193
- if (newInvocationPath !== undefined) {
3194
- // #2915 review 4 (finding 4): the SAME stable task-chain identity
3195
- // `launchOneWorker` publishes — role/task/group plus the brief CONTENT
3196
- // digest, never `final_output`/`brief`/`workdir` PATHS, which legitimately
3197
- // differ between a confirmed predecessor and this fresh attempt.
3198
- let newTaskDigest;
3199
- try {
3200
- const newPlanBytes = await boundedPrivateRead(newInvocationPath, 1048576, inspect);
3201
- const newPlan = JSON.parse(newPlanBytes.toString('utf8'));
3202
- newTaskDigest = await stableTaskChainDigest(newPlan.request, newPlan.invocation?.briefDigest ?? null);
3203
- } catch { fail('new attempt plan is missing or invalid; cannot bind the retry to a task'); }
3204
- if (invocationRecord.taskDigest !== newTaskDigest) fail('predecessor task does not match the new attempt (task-chain binding)');
3205
- }
3206
- assertStopBeforeRetry(exitRecord, { expectedInvocationId: prevId });
3207
- return exitRecord;
3208
- }
3163
+ export { verifyRetryPredecessor } from './lib/worker-retry-evidence.mjs';
3209
3164
 
3210
3165
  /**
3211
3166
  * One corrected startup retry (#2915, 03 §11.5 step 1). A failed start may