@ngockhoale/ukit 3.0.8 → 3.0.9

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 (105) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/manifests/documentation.yaml +11 -0
  3. package/package.json +1 -1
  4. package/scripts/audit/decision-coverage.mjs +29 -2
  5. package/scripts/bench/data-foundation.mjs +52 -3
  6. package/scripts/bench/decision-runtime-baseline.mjs +427 -0
  7. package/scripts/bench/decision-runtime-metrics.mjs +67 -0
  8. package/scripts/bench/decision-runtime-variant.mjs +626 -0
  9. package/scripts/bench/memory-ablation.mjs +495 -0
  10. package/scripts/bench/memory-baseline.mjs +596 -0
  11. package/scripts/bench/memory-bench.mjs +661 -0
  12. package/scripts/bench/memory-canary.mjs +321 -0
  13. package/scripts/bench/memory-corpus.mjs +354 -0
  14. package/scripts/bench/memory-gate.mjs +389 -0
  15. package/scripts/bench/memory-metrics.mjs +179 -0
  16. package/scripts/bench/parallel-agents.mjs +33 -11
  17. package/scripts/bench/recorder-overhead.mjs +204 -0
  18. package/scripts/bench/sqlite-spike.mjs +451 -0
  19. package/scripts/measure-decision-gateway.mjs +306 -0
  20. package/scripts/perf/audit-perf.mjs +35 -17
  21. package/src/bug/triageBug.js +4 -3
  22. package/src/cli/commands/memory.js +357 -63
  23. package/src/context/detectProjectContext.js +11 -1
  24. package/src/core/agentRuntime/adapters.js +254 -0
  25. package/src/core/agentRuntime/artifacts.js +192 -0
  26. package/src/core/agentRuntime/completionGate.js +176 -0
  27. package/src/core/agentRuntime/context.js +149 -0
  28. package/src/core/agentRuntime/contract.js +247 -0
  29. package/src/core/agentRuntime/diagnostics.js +244 -0
  30. package/src/core/agentRuntime/evaluation.js +163 -0
  31. package/src/core/agentRuntime/eventStore.js +404 -0
  32. package/src/core/agentRuntime/liveness.js +60 -0
  33. package/src/core/agentRuntime/planCompiler.js +322 -0
  34. package/src/core/agentRuntime/promotion.js +53 -0
  35. package/src/core/agentRuntime/qualityComparison.js +112 -0
  36. package/src/core/agentRuntime/recovery.js +266 -0
  37. package/src/core/agentRuntime/resourcePolicy.js +78 -0
  38. package/src/core/agentRuntime/runtimeSupport.js +237 -0
  39. package/src/core/agentRuntime/supervisor.js +565 -0
  40. package/src/core/agentRuntime/vmEngine.js +621 -0
  41. package/src/core/codeintel/analogy.js +3 -2
  42. package/src/core/experiments/dynamicWorkflow.js +17 -2
  43. package/src/core/fileOps.js +21 -3
  44. package/src/core/memory/deltaOverlays.js +75 -30
  45. package/src/core/memory/learningCandidates.js +93 -48
  46. package/src/core/memory/memoryFlags.js +83 -0
  47. package/src/core/memory/memoryFreshness.js +190 -0
  48. package/src/core/memory/memoryHit.js +144 -0
  49. package/src/core/memory/migrate.js +69 -189
  50. package/src/core/memory/migrateMapping.js +232 -0
  51. package/src/core/memory/mutateMemory.js +323 -0
  52. package/src/core/memory/policy.js +96 -0
  53. package/src/core/memory/projectIdentity.js +266 -0
  54. package/src/core/memory/recordIndex.js +178 -0
  55. package/src/core/memory/recordStore.js +133 -20
  56. package/src/core/memory/records.js +144 -6
  57. package/src/core/memory/retrieval.js +259 -125
  58. package/src/core/memory/store.js +16 -5
  59. package/src/core/memory/storeBackup.js +226 -0
  60. package/src/core/memory/storeV2.js +63 -26
  61. package/src/core/memory/storeV2Loader.js +30 -12
  62. package/src/core/memory/userMemory.js +38 -20
  63. package/src/core/memory/writeClassification.js +161 -0
  64. package/src/core/memory/writeGuard.js +129 -0
  65. package/src/core/observability/adapters/hookTelemetryAdapter.js +90 -0
  66. package/src/core/observability/analytics/cohorts.js +148 -0
  67. package/src/core/observability/analytics/storeDigest.js +163 -0
  68. package/src/core/observability/evaluation/experimentPlan.js +95 -0
  69. package/src/core/observability/evaluation/findings.js +99 -0
  70. package/src/core/observability/evaluation/optimizationKnowledge.js +10 -1
  71. package/src/core/observability/evaluation/perturbation.js +273 -0
  72. package/src/core/observability/evaluation/replay.js +7 -1
  73. package/src/core/observability/evaluation/scorecard.js +23 -3
  74. package/src/core/observability/rollout.js +11 -7
  75. package/src/core/observability/schema/compatibility.js +135 -0
  76. package/src/core/observability/schema/registry.js +99 -0
  77. package/src/core/observability/schema/validate.js +7 -0
  78. package/src/core/observability/support/import.js +53 -9
  79. package/src/core/observability/support/paths.js +13 -3
  80. package/src/core/observability/support/projector.js +148 -12
  81. package/src/core/output/index.js +12 -2
  82. package/src/core/runtimeConfig.js +83 -0
  83. package/src/core/runtimePaths.js +3 -0
  84. package/src/core/sensitiveValueScanner.js +40 -0
  85. package/src/core/token/index.js +40 -3
  86. package/src/decision/client.js +37 -13
  87. package/src/decision/protocol.js +1 -1
  88. package/src/decision/registry.js +5 -3
  89. package/src/decision/runtimeDecide.js +242 -0
  90. package/src/decision/runtimeFilter.js +150 -0
  91. package/src/decision/runtimeScheduler.js +239 -0
  92. package/src/index/buildIndex.js +13 -12
  93. package/src/index/queryIndex.js +35 -14
  94. package/src/index/relatedTests.js +50 -8
  95. package/src/index/resolveContext.js +9 -4
  96. package/src/manifest/selectItems.js +7 -3
  97. package/src/render/instructionRenderer.js +17 -5
  98. package/template_project/.claude/ukit/index/lib/index-core.mjs +94 -39
  99. package/template_project/.claude/ukit/index/route-task.mjs +121 -19
  100. package/template_project/.claude/ukit/index/unic-decision.mjs +28 -13
  101. package/template_project/.claude/ukit/runtime/memory-flags.mjs +51 -0
  102. package/template_project/.claude/ukit/runtime/memory-freshness.mjs +155 -0
  103. package/template_project/.claude/ukit/runtime/memory-policy.mjs +286 -0
  104. package/template_project/.claude/ukit/runtime/output-compression.mjs +3 -0
  105. package/template_project/.claude/ukit/runtime/reinject-context.mjs +145 -14
@@ -0,0 +1,565 @@
1
+ /**
2
+ * agentRuntime/supervisor.js — decision-first-runtime G2 (DR-03), SPEC §4.
3
+ *
4
+ * Owned-process supervisor: detached process-group spawn, serialized
5
+ * per-operation transition chain (journal seq stays gap-free under
6
+ * exit/cancel races), bounded event appends only on transitions and
7
+ * observation tiers (never per tick), SIGTERM→grace→SIGKILL group cancel
8
+ * through an injectable killImpl (only PIDs this supervisor spawned are
9
+ * ever signalled), the disableLaunches()/enableLaunches() kill switch
10
+ * (typed refuse, zero fs writes, evidence preserved), and a thin
11
+ * reconcile() delegate to recovery.js.
12
+ *
13
+ * Kill switch: `decisionRuntime.supervisor.stage` — absent/malformed →
14
+ * 'off'; `config.enabled === true` also enables the launch surface for
15
+ * embedders that do not carry runtimeConfig stage flags.
16
+ */
17
+
18
+ import { spawn } from 'node:child_process';
19
+ import path from 'node:path';
20
+
21
+ import { resolveConfigStage } from '../runtimeConfig.js';
22
+ import { selectWaitPolicy } from './promotion.js';
23
+ import {
24
+ CONTRACT_VERSION,
25
+ SIDE_EFFECT_CLASSES,
26
+ isTerminal,
27
+ validateTransition,
28
+ } from './contract.js';
29
+ import {
30
+ appendEvent,
31
+ writeOperationState,
32
+ readJournal,
33
+ } from './eventStore.js';
34
+ import { classifyObservation } from './liveness.js';
35
+ import { createArtifactWriter } from './artifacts.js';
36
+ import { reconcileOwnedOperations } from './recovery.js';
37
+
38
+ const SUPERVISOR_FLAG = 'decisionRuntime.supervisor.stage';
39
+ // G6 (DR-09): `decisionRuntime.promotion.stage` — absent/malformed → 'off';
40
+ // 'shadow' consults selectWaitPolicy at the caller-wait decision and records a
41
+ // bounded receipt WITHOUT changing served wait semantics (prior path always
42
+ // served; a throw/malformed decision falls back deterministically).
43
+ const PROMOTION_FLAG = 'decisionRuntime.promotion.stage';
44
+ const PRODUCER_VERSION = '3.0.8';
45
+ const PRIVACY_CLASS = 'internal';
46
+ const SIGTERM = 'SIGTERM';
47
+ const SIGKILL = 'SIGKILL';
48
+
49
+ const DEFAULT_LIMITS = Object.freeze({
50
+ pollIntervalMs: 250,
51
+ killGraceMs: 2_000,
52
+ closeWaitMs: 5_000,
53
+ });
54
+
55
+ const unsupported = (code) => ({ unsupported: true, code });
56
+
57
+ // Real FileHandle exposes .sync(), not .fsync(); tolerate both.
58
+ const defaultFsyncImpl = async (handle) => {
59
+ if (handle && typeof handle.sync === 'function') return handle.sync();
60
+ if (handle && typeof handle.fsync === 'function') return handle.fsync();
61
+ return undefined;
62
+ };
63
+
64
+ const defaultSpawnImpl = (argv, opts) => spawn(argv[0], argv.slice(1), opts);
65
+ const defaultKillImpl = (signal, target) => process.kill(target, signal);
66
+
67
+ function specIsValid(spec) {
68
+ if (spec === null || typeof spec !== 'object') return false;
69
+ if (typeof spec.operationId !== 'string' || spec.operationId === '') return false;
70
+ if (!Number.isInteger(spec.attempt) || spec.attempt < 1) return false;
71
+ const hasCommand = typeof spec.command === 'string' && spec.command !== '';
72
+ const hasArgv = Array.isArray(spec.argv) && spec.argv.length > 0
73
+ && spec.argv.every((a) => typeof a === 'string' && a !== '');
74
+ if (!hasCommand && !hasArgv) return false;
75
+ return SIDE_EFFECT_CLASSES.includes(spec.sideEffectClass);
76
+ }
77
+
78
+ /**
79
+ * @param {object} opts
80
+ * @param {string} opts.runtimeDir eventStore root (events/, state/, artifacts/)
81
+ * @param {string} [opts.artifactDir] bounded output artifact dir
82
+ * @param {object} [opts.config] runtimeConfig / `{enabled:true}` gate
83
+ * @param {object} [opts.clock] injectable `{now:number,setTimeout,clearTimeout}`
84
+ * @param {object} [opts.processTable] injected process snapshot for reconcile
85
+ * @param {number} [opts.fencingEpoch] this owner's monotonic epoch
86
+ * @param {number} [opts.ownerEpoch] epoch floor for transition validation
87
+ * @param {object} [opts.limits] {pollIntervalMs,killGraceMs,closeWaitMs,artifact caps}
88
+ * @param {Function} [opts.spawnImpl] (argv, spawnOpts) → child-like proc
89
+ * @param {Function} [opts.killImpl] (signal, target) → void (target = -pgid)
90
+ * @param {Function} [opts.fsyncImpl] artifact fsync
91
+ * @param {Function} [opts.appendEventImpl] journal append (test injection)
92
+ * @param {object} [opts.artifactDeps] artifact writer dep overrides
93
+ * @returns {object} Supervisor — SPEC §4 surface.
94
+ */
95
+ export function createSupervisor(opts = {}) {
96
+ const runtimeDir = opts.runtimeDir;
97
+ const artifactDir = opts.artifactDir
98
+ ?? (typeof runtimeDir === 'string' ? path.join(runtimeDir, 'artifacts') : undefined);
99
+ const promotionStage = () => resolveConfigStage(opts.config ?? null, PROMOTION_FLAG);
100
+ const waitPolicyFn = typeof opts.waitPolicyFn === 'function' ? opts.waitPolicyFn : selectWaitPolicy;
101
+ const durationProfile = opts.durationProfile ?? null;
102
+ const resourcesFn = typeof opts.resourcesFn === 'function' ? opts.resourcesFn : () => ({});
103
+ // Bounded receipt log — shadow-stage evidence only, never fs.
104
+ const WAIT_RECEIPT_CAP = 128;
105
+ const waitReceipts = [];
106
+
107
+ function recordWaitReceipt(entry) {
108
+ if (waitReceipts.length >= WAIT_RECEIPT_CAP) waitReceipts.shift();
109
+ waitReceipts.push(Object.freeze(entry));
110
+ }
111
+
112
+ /**
113
+ * Consult selectWaitPolicy at the caller-wait decision point. Only invoked
114
+ * when `decisionRuntime.promotion` resolves non-'off'; a policy throw or
115
+ * malformed decision is still recorded and the prior path is always served.
116
+ */
117
+ function consultWaitPolicy(spec, policy) {
118
+ let decision;
119
+ let error;
120
+ try {
121
+ decision = waitPolicyFn(
122
+ durationProfile,
123
+ { class: spec?.sideEffectClass, spec },
124
+ resourcesFn() ?? {},
125
+ policy,
126
+ );
127
+ } catch (err) {
128
+ error = String(err?.message ?? err);
129
+ }
130
+ const valid = decision !== null && typeof decision === 'object'
131
+ && (decision.mode === 'foreground' || decision.mode === 'handle');
132
+ recordWaitReceipt({
133
+ operationId: spec?.operationId ?? null,
134
+ stage: promotionStage(),
135
+ policy: valid ? { mode: decision.mode, reason: decision.reason } : null,
136
+ error: error ?? null,
137
+ });
138
+ }
139
+
140
+ const limits = { ...DEFAULT_LIMITS, ...(opts.limits ?? {}) };
141
+ const stage = () => resolveConfigStage(opts.config ?? null, SUPERVISOR_FLAG);
142
+ const enabledByConfig = () => stage() !== 'off' || opts.config?.enabled === true;
143
+
144
+ let launchesDisabled = !enabledByConfig();
145
+ const enabledAtCreate = enabledByConfig();
146
+ let closed = false;
147
+
148
+ const spawnImpl = opts.spawnImpl ?? defaultSpawnImpl;
149
+ const killImpl = opts.killImpl ?? defaultKillImpl;
150
+ const append = opts.appendEventImpl ?? appendEvent;
151
+ const clock = opts.clock && typeof opts.clock.setTimeout === 'function'
152
+ ? opts.clock
153
+ : null;
154
+ const nowMs = clock ? () => clock.now : () => Date.now();
155
+ const setT = clock ? (cb, ms) => clock.setTimeout(cb, ms) : (cb, ms) => setTimeout(cb, ms);
156
+ const clearT = clock ? (t) => clock.clearTimeout(t) : (t) => clearTimeout(t);
157
+ const isoNow = () => new Date(nowMs()).toISOString();
158
+
159
+ const fencingEpoch = Number.isInteger(opts.fencingEpoch) ? opts.fencingEpoch : 1;
160
+ const ownerEpoch = Number.isInteger(opts.ownerEpoch) ? opts.ownerEpoch : undefined;
161
+
162
+ const artifactDeps = {
163
+ fsyncImpl: opts.fsyncImpl ?? defaultFsyncImpl,
164
+ ...(opts.artifactDeps ?? {}),
165
+ };
166
+ const artifactWriter = artifactDir
167
+ ? createArtifactWriter(artifactDir, limits, artifactDeps)
168
+ : null;
169
+
170
+ /** @type {Map<string, object>} live operation records by operationId */
171
+ const operations = new Map();
172
+
173
+ function makeEvent(op, eventType, safePayload, artifactRefs = []) {
174
+ return {
175
+ eventId: `supervisor-${op.id}-${op.seq}`,
176
+ operationId: op.id,
177
+ seq: op.seq,
178
+ eventType,
179
+ observedAt: isoNow(),
180
+ producerVersion: PRODUCER_VERSION,
181
+ contractVersion: CONTRACT_VERSION,
182
+ privacyClass: PRIVACY_CLASS,
183
+ artifactRefs,
184
+ safePayload,
185
+ };
186
+ }
187
+
188
+ /** Serialized per-op journal/state write; seq allocated inside the chain. */
189
+ function enqueue(op, fn) {
190
+ op.chain = op.chain.then(fn).catch((err) => {
191
+ op.chainError = err;
192
+ });
193
+ return op.chain;
194
+ }
195
+
196
+ function emitEvent(op, eventType, safePayload, artifactRefs) {
197
+ return enqueue(op, async () => {
198
+ op.seq += 1;
199
+ const event = makeEvent(op, eventType, safePayload, artifactRefs);
200
+ await append(runtimeDir, event);
201
+ return event;
202
+ });
203
+ }
204
+
205
+ function transition(op, to, extra = {}, artifactRefs) {
206
+ const from = op.state;
207
+ const verdict = validateTransition(from, to, { fencingEpoch, ownerEpoch });
208
+ if (!verdict.ok) return Promise.resolve(verdict);
209
+ op.state = to;
210
+ return enqueue(op, async () => {
211
+ op.seq += 1;
212
+ const event = makeEvent(op, 'operation.transition', { from, to, ...extra }, artifactRefs);
213
+ await append(runtimeDir, event);
214
+ await writeOperationState(runtimeDir, op.id, {
215
+ state: to,
216
+ fencingEpoch,
217
+ ownerEpoch,
218
+ });
219
+ return event;
220
+ });
221
+ }
222
+
223
+ function emitObservation(op, cls) {
224
+ if (op.observedClasses.has(cls)) return;
225
+ op.observedClasses.add(cls);
226
+ emitEvent(op, 'operation.observation', { class: cls });
227
+ }
228
+
229
+ function killGroup(op, signal) {
230
+ try {
231
+ killImpl(signal, -op.pgid);
232
+ } catch {
233
+ // ESRCH: group already gone — nothing owned left to reap.
234
+ }
235
+ }
236
+
237
+ /** SIGTERM → grace → SIGKILL on the owned process group only. */
238
+ function performCancel(op, reason) {
239
+ if (op.cancelRequested) return;
240
+ op.cancelRequested = true;
241
+ op.cancelReason = reason;
242
+ transition(op, 'cancel_pending', { reason });
243
+ killGroup(op, SIGTERM);
244
+ op.timers.add(setT(() => {
245
+ if (!op.exited) killGroup(op, SIGKILL);
246
+ // Backstop: a child that never reports exit still resolves terminal.
247
+ const from = op.state;
248
+ if (validateTransition(from, 'cancelled', { fencingEpoch, ownerEpoch }).ok) {
249
+ op.state = 'cancelled';
250
+ enqueue(op, async () => {
251
+ op.seq += 1;
252
+ const event = makeEvent(op, 'operation.transition', {
253
+ from,
254
+ to: 'cancelled',
255
+ reason: op.cancelReason ?? 'killed',
256
+ }, op.artifactRefs);
257
+ await append(runtimeDir, event);
258
+ await writeOperationState(runtimeDir, op.id, {
259
+ state: 'cancelled',
260
+ fencingEpoch,
261
+ ownerEpoch,
262
+ });
263
+ });
264
+ }
265
+ }, limits.killGraceMs));
266
+ }
267
+
268
+ function scheduleTick(op) {
269
+ op.timers.add(setT(() => tick(op), limits.pollIntervalMs));
270
+ }
271
+
272
+ function tick(op) {
273
+ if (op.exited || isTerminal(op.state)) return;
274
+ const now = nowMs();
275
+ const sample = {
276
+ processAlive: !op.exited,
277
+ bytesWritten: op.bytesWritten,
278
+ lastOutputAt: op.lastOutputAt,
279
+ startedAt: op.startedAt,
280
+ now,
281
+ };
282
+ const cls = classifyObservation(sample, op.prevSample, {
283
+ silentAfterMs: op.policy.silentAfterMs,
284
+ stallTimeoutMs: op.spec.stallTimeoutMs ?? op.policy.stallTimeoutMs,
285
+ wallTimeoutMs: op.spec.wallTimeoutMs ?? op.policy.wallTimeoutMs,
286
+ stallAction: op.policy.stallAction,
287
+ });
288
+ op.prevSample = sample;
289
+ switch (cls) {
290
+ case 'timeout':
291
+ performCancel(op, 'wall_timeout');
292
+ break;
293
+ case 'stalled':
294
+ emitObservation(op, 'stalled');
295
+ if (op.policy.stallAction === 'cancel') {
296
+ performCancel(op, 'stalled');
297
+ }
298
+ break;
299
+ case 'silent_live':
300
+ emitObservation(op, 'silent_live');
301
+ break;
302
+ default:
303
+ // live_progressing / exited: no persistent write (never per-tick).
304
+ break;
305
+ }
306
+ if (!op.exited && !isTerminal(op.state) && !op.cancelRequested) {
307
+ scheduleTick(op);
308
+ }
309
+ }
310
+
311
+ function artifactErrorCode(refs) {
312
+ for (const ref of refs) {
313
+ if (ref?.error?.code) return ref.error.code;
314
+ }
315
+ return null;
316
+ }
317
+
318
+ /** Runs INSIDE op.chain (via onExit's enqueue) — appends inline, never re-enqueues. */
319
+ async function finalizeExit(op) {
320
+ if (op.finalized || isTerminal(op.state)) return;
321
+ op.finalized = true;
322
+
323
+ let refs = [];
324
+ if (op.artifacts) {
325
+ try {
326
+ refs = await op.artifacts.close();
327
+ } catch {
328
+ refs = [];
329
+ }
330
+ }
331
+ op.artifactRefs = refs.filter((r) => r && r.bytes >= 0);
332
+ const artErr = artifactErrorCode(refs);
333
+
334
+ let to;
335
+ const extra = {};
336
+ if (op.cancelRequested || op.state === 'cancel_pending') {
337
+ to = 'cancelled';
338
+ extra.reason = op.cancelReason ?? 'killed';
339
+ } else {
340
+ const failed = artErr != null || (op.exitInfo?.code ?? 0) !== 0;
341
+ to = failed ? 'failed' : 'completed';
342
+ extra.exitCode = op.exitInfo?.code ?? null;
343
+ extra.signal = op.exitInfo?.signal ?? null;
344
+ if (artErr != null) {
345
+ extra.error = { code: artErr };
346
+ } else if (failed) {
347
+ extra.error = {
348
+ code: 'exit_nonzero',
349
+ exitCode: op.exitInfo?.code ?? null,
350
+ signal: op.exitInfo?.signal ?? null,
351
+ };
352
+ }
353
+ }
354
+
355
+ const from = op.state;
356
+ const verdict = validateTransition(from, to, { fencingEpoch, ownerEpoch });
357
+ if (!verdict.ok) return;
358
+ op.state = to;
359
+ op.seq += 1;
360
+ const event = makeEvent(op, 'operation.transition', { from, to, ...extra }, op.artifactRefs);
361
+ await append(runtimeDir, event);
362
+ await writeOperationState(runtimeDir, op.id, { state: to, fencingEpoch, ownerEpoch });
363
+ }
364
+
365
+ function onExit(op, code, signal) {
366
+ if (op.exited) return;
367
+ op.exited = true;
368
+ op.exitInfo = { code, signal };
369
+ for (const t of op.timers) clearT(t);
370
+ op.timers.clear();
371
+ // Drain the transition chain before deciding the terminal path so a
372
+ // racing cancel_pending write lands first (seq stays linear).
373
+ enqueue(op, () => finalizeExit(op));
374
+ }
375
+
376
+ const supervisor = {
377
+ /**
378
+ * Launch an owned operation. Kill switch (flag off or
379
+ * disableLaunches()) → `{ unsupported:true, code:'launch_disabled' }`
380
+ * with zero fs writes; malformed spec → `invalid_spec`; the spawned
381
+ * child runs detached in its own process group so cancel can reap the
382
+ * whole tree via `-pgid`.
383
+ */
384
+ async start(spec, policy = {}) {
385
+ if (closed || launchesDisabled || !enabledByConfig()) {
386
+ return unsupported('launch_disabled');
387
+ }
388
+ if (!specIsValid(spec)) {
389
+ return unsupported('invalid_spec');
390
+ }
391
+ // G6 promotion consult — shadow evaluates+records a receipt; served
392
+ // wait semantics below are always the prior path.
393
+ if (promotionStage() !== 'off') {
394
+ consultWaitPolicy(spec, policy);
395
+ }
396
+
397
+ const op = {
398
+ id: spec.operationId,
399
+ spec,
400
+ policy: policy && typeof policy === 'object' ? policy : {},
401
+ state: 'queued',
402
+ seq: 0,
403
+ chain: Promise.resolve(),
404
+ chainError: null,
405
+ proc: null,
406
+ pgid: null,
407
+ exited: false,
408
+ finalized: false,
409
+ cancelRequested: false,
410
+ cancelReason: null,
411
+ exitInfo: null,
412
+ bytesWritten: 0,
413
+ lastOutputAt: null,
414
+ startedAt: nowMs(),
415
+ prevSample: null,
416
+ observedClasses: new Set(),
417
+ artifactRefs: [],
418
+ artifacts: null,
419
+ timers: new Set(),
420
+ };
421
+ operations.set(op.id, op);
422
+
423
+ await transition(op, 'starting', {
424
+ attempt: spec.attempt,
425
+ sideEffectClass: spec.sideEffectClass,
426
+ });
427
+
428
+ let proc;
429
+ try {
430
+ const argv = Array.isArray(spec.argv) && spec.argv.length > 0
431
+ ? spec.argv
432
+ : ['/bin/sh', '-c', spec.command];
433
+ proc = spawnImpl(argv, {
434
+ detached: true,
435
+ stdio: ['ignore', 'pipe', 'pipe'],
436
+ env: spec.env,
437
+ cwd: spec.cwd,
438
+ });
439
+ } catch (err) {
440
+ await transition(op, 'failed', {
441
+ error: { code: 'spawn_failed', message: String(err?.message ?? err) },
442
+ });
443
+ return { ok: false, code: 'spawn_failed' };
444
+ }
445
+
446
+ op.proc = proc;
447
+ op.pgid = proc.pid;
448
+ op.lastOutputAt = nowMs();
449
+
450
+ if (artifactWriter) {
451
+ try {
452
+ op.artifacts = artifactWriter.open(op.id);
453
+ } catch {
454
+ op.artifacts = null;
455
+ }
456
+ }
457
+
458
+ const onData = (stream) => (chunk) => {
459
+ op.bytesWritten += chunk?.length ?? 0;
460
+ op.lastOutputAt = nowMs();
461
+ try {
462
+ op.artifacts?.[stream]?.write(chunk);
463
+ } catch {
464
+ // Artifact stream errors surface via refs at close.
465
+ }
466
+ };
467
+ proc.stdout?.on('data', onData('stdout'));
468
+ proc.stderr?.on('data', onData('stderr'));
469
+ proc.on?.('error', () => onExit(op, proc.exitCode ?? 1, null));
470
+ proc.on?.('exit', (code, signal) => onExit(op, code, signal));
471
+
472
+ await transition(op, 'running', { pid: proc.pid ?? null });
473
+ if (!op.exited) scheduleTick(op);
474
+
475
+ return {
476
+ operationId: op.id,
477
+ pid: proc.pid,
478
+ state: op.state,
479
+ get currentState() {
480
+ return op.state;
481
+ },
482
+ };
483
+ },
484
+
485
+ /** Async replay of the operation journal (contract v1 events). */
486
+ async* observe(operationId) {
487
+ if (typeof runtimeDir !== 'string' || runtimeDir === '') return;
488
+ try {
489
+ for await (const e of readJournal(runtimeDir, operationId)) yield e;
490
+ } catch {
491
+ return;
492
+ }
493
+ },
494
+
495
+ /** Descendant-aware cancel: SIGTERM→grace→SIGKILL on the owned group. */
496
+ async cancel(operationId, reason = 'cancel_requested') {
497
+ const op = operations.get(operationId);
498
+ if (!op) return { ok: false, code: 'not_found' };
499
+ if (isTerminal(op.state)) return { ok: false, code: 'already_terminal' };
500
+ performCancel(op, reason);
501
+ return { ok: true };
502
+ },
503
+
504
+ /** One-shot restart reconciliation — thin delegate to recovery.js. */
505
+ async reconcile() {
506
+ if (typeof runtimeDir !== 'string' || runtimeDir === '') {
507
+ return { results: [] };
508
+ }
509
+ try {
510
+ // Dynamic import keeps the delegate resilient when recovery.js is
511
+ // absent from a partial tree (avoids a static-resolution failure).
512
+ const mod = await import('./recovery.js').catch(() => null);
513
+ const fn = mod?.reconcileOwnedOperations ?? reconcileOwnedOperations;
514
+ if (typeof fn !== 'function') return { results: [] };
515
+ return await fn({
516
+ runtimeDir,
517
+ processTable: opts.processTable ?? {},
518
+ fencingEpoch,
519
+ ownerEpoch: opts.ownerEpoch,
520
+ launchDisabled: launchesDisabled,
521
+ now: opts.now,
522
+ });
523
+ } catch {
524
+ return { results: [] };
525
+ }
526
+ },
527
+
528
+ disableLaunches() {
529
+ launchesDisabled = true;
530
+ },
531
+
532
+ enableLaunches() {
533
+ if (enabledAtCreate) launchesDisabled = false;
534
+ },
535
+
536
+ /** Bounded frozen receipts of shadow-stage wait-policy consults (G6). */
537
+ waitPolicyReceipts() {
538
+ return [...waitReceipts];
539
+ },
540
+
541
+ /**
542
+ * Reap in-flight owned children and drain journals with a bounded wait
543
+ * on the REAL clock — the injected clock may never advance on its own.
544
+ */
545
+ async close() {
546
+ closed = true;
547
+ for (const op of operations.values()) {
548
+ for (const t of op.timers) clearT(t);
549
+ op.timers.clear();
550
+ if (!op.exited && !isTerminal(op.state)) {
551
+ op.cancelRequested = true;
552
+ killGroup(op, SIGKILL);
553
+ }
554
+ }
555
+ const deadline = new Promise((r) => setTimeout(r, limits.closeWaitMs));
556
+ await Promise.race([
557
+ Promise.allSettled([...operations.values()].map((op) => op.chain)),
558
+ deadline,
559
+ ]);
560
+ operations.clear();
561
+ },
562
+ };
563
+
564
+ return supervisor;
565
+ }