@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.
- package/agentic/code/providers/model-capabilities.v1.json +78 -14
- package/dist/src/cli/handlers/sessions.js +198 -15
- package/dist/src/cli/handlers/use.js +12 -4
- package/dist/src/config/worker-context.js +337 -0
- package/dist/src/mcp/tools/worker-invoke.mjs +108 -10
- package/dist/src/models/model-capabilities.v1.json +78 -14
- package/dist/src/models/provider-policy.js +7 -0
- package/dist/src/policy/authorization.js +29 -1
- package/dist/src/process/aiwg-probe-command.d.mts +15 -0
- package/dist/src/process/aiwg-probe-command.mjs +37 -0
- package/dist/src/sessions/adapters/grokbot.js +7 -0
- package/dist/src/sessions/batch-import.js +34 -6
- package/dist/src/sessions/contracts.js +19 -0
- package/dist/src/sessions/coverage-preflight.js +7 -1
- package/dist/src/sessions/fortemi-export-mapping.js +33 -3
- package/dist/src/sessions/host-catalog.js +157 -0
- package/dist/src/sessions/image-blobs.js +141 -0
- package/dist/src/sessions/importer.js +23 -3
- package/dist/src/sessions/index.js +2 -0
- package/dist/src/sessions/refresh.js +25 -2
- package/dist/src/sessions/repository.js +164 -22
- package/dist/src/sessions/workspace-discovery.js +5 -2
- package/package.json +7 -1
- package/tools/ralph-external/lib/muse-adapter.mjs +10 -0
- package/tools/ralph-external/lib/worker-context.mjs +237 -0
- package/tools/ralph-external/lib/worker-invocation-ancestry.mjs +132 -0
- package/tools/ralph-external/lib/worker-invocation-processes.mjs +123 -0
- package/tools/ralph-external/lib/worker-isolation-status.mjs +48 -0
- package/tools/ralph-external/lib/worker-isolation.mjs +17 -29
- package/tools/ralph-external/lib/worker-launch-cli.mjs +3 -1
- package/tools/ralph-external/lib/worker-overflow.mjs +48 -13
- package/tools/ralph-external/lib/worker-retry-evidence.mjs +103 -0
- package/tools/ralph-external/lib/worker-termination.mjs +82 -0
- 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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1119
|
-
|
|
1120
|
-
|
|
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 ? '
|
|
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
|
|
1177
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1789
|
-
:
|
|
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 ? '
|
|
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
|
|
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
|