@karowanorg/orc-core 0.1.0

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.
@@ -0,0 +1,1072 @@
1
+ /**
2
+ * The run supervisor: owns one run end-to-end — program VM, leaf dispatch with
3
+ * maxParallel, the WAL journal (fsync before delivery), the trace sidecar,
4
+ * approvals via control.jsonl, cancellation, replay, and fail-forward resume
5
+ * (write leaves re-dispatch as re-orienting attempts, never blind re-runs).
6
+ */
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import { randomUUID } from "node:crypto";
10
+ import { DEFAULT_POLICY, DivergenceError, PolicyError, } from "./contracts.js";
11
+ import { boundString, canonicalJson, digestJson, sha256Hex } from "./canonical.js";
12
+ import { validateAgainstSchema } from "./jsonschema.js";
13
+ import { compileProgram } from "./compile.js";
14
+ import { ProgramVM } from "./engine.js";
15
+ import { JsonlAppender, acquireLock, createRunDir, newRunId, readControl, readJournal, readManifest, readResult, readTraces, runPaths, writeResult, } from "./rundir.js";
16
+ import { openApprovals, statusForRun } from "./status.js";
17
+ const READ_ONLY_SHUTDOWN_GRACE_MS = 1_000;
18
+ export const ORC_VERSION = "0.1.0";
19
+ /** Static pre-scan: does the program source declare any write leaf? */
20
+ export function sourceRequestsWrite(source) {
21
+ return /readOnly\s*:\s*false/.test(source);
22
+ }
23
+ export async function prepareRun(opts, registry) {
24
+ const { bundle, sha256 } = await compileProgram(opts.programPath);
25
+ // For local runs, canonicalize + validate the cwd against the local FS. For
26
+ // remote (host) runs the cwd lives on the remote host — keep it as an
27
+ // absolute path and let `orc doctor --host` / the leaf validate existence.
28
+ let cwd;
29
+ if (opts.host) {
30
+ cwd = opts.cwd ?? ".";
31
+ }
32
+ else {
33
+ cwd = fs.realpathSync(path.resolve(opts.cwd ?? process.cwd()));
34
+ if (!fs.statSync(cwd).isDirectory())
35
+ throw new Error(`cwd is not a directory: ${cwd}`);
36
+ }
37
+ const brief = opts.brief?.trim();
38
+ if (!brief)
39
+ throw new Error("brief is required");
40
+ if (opts.budgetUsd !== undefined && !(opts.budgetUsd > 0)) {
41
+ throw new Error("budget must be a positive USD amount");
42
+ }
43
+ const manifest = {
44
+ runId: newRunId(opts.name ?? path.basename(opts.programPath).replace(/\.[^.]+$/, "")),
45
+ name: opts.name,
46
+ programPath: path.resolve(opts.programPath),
47
+ programSha256: sha256,
48
+ cwd,
49
+ host: opts.host,
50
+ brief,
51
+ allowWrites: opts.allowWrites ?? false,
52
+ approvalMode: opts.approvalMode ?? "auto",
53
+ sandbox: opts.sandbox ?? false,
54
+ sandboxDirs: opts.sandboxDirs ?? [],
55
+ maxParallel: Math.min(opts.maxParallel ?? DEFAULT_POLICY.maxParallel, 64),
56
+ idleTimeoutMs: opts.idleTimeout ?? 15 * 60_000,
57
+ budgetUsd: opts.budgetUsd,
58
+ defaultHarness: opts.defaultHarness ?? registry.defaultHarness,
59
+ createdAtMs: Date.now(),
60
+ orcVersion: ORC_VERSION,
61
+ };
62
+ createRunDir(manifest, bundle);
63
+ return manifest;
64
+ }
65
+ export async function superviseRun(runId, registry, hooks = {}, policy = DEFAULT_POLICY) {
66
+ const manifest = readManifest(runId);
67
+ const paths = runPaths(runId);
68
+ const lock = await acquireLock(paths);
69
+ let journalOut;
70
+ let traceOut;
71
+ let controlWatcher;
72
+ try {
73
+ journalOut = new JsonlAppender(paths.journal);
74
+ traceOut = new JsonlAppender(paths.traces);
75
+ const sup = new Supervisor(manifest, paths, registry, hooks, policy, journalOut, traceOut);
76
+ // Watch control.jsonl so approval responses / cancels are picked up within
77
+ // milliseconds instead of on the next poll tick — responsive UI approvals.
78
+ fs.closeSync(fs.openSync(paths.control, "a")); // touch: create if absent, never modify
79
+ try {
80
+ controlWatcher = fs.watch(paths.control, () => sup.wakeForControl());
81
+ }
82
+ catch {
83
+ /* fs.watch unsupported here; the 1s poll still covers it */
84
+ }
85
+ let ownershipError;
86
+ void lock.lost.catch((err) => {
87
+ ownershipError = err instanceof Error ? err : new Error(String(err));
88
+ sup.loseOwnership(ownershipError);
89
+ });
90
+ const status = await sup.run();
91
+ if (ownershipError)
92
+ throw ownershipError;
93
+ return status;
94
+ }
95
+ finally {
96
+ controlWatcher?.close();
97
+ journalOut?.close();
98
+ traceOut?.close();
99
+ await lock.release();
100
+ hooks.onUpdate?.(runId);
101
+ }
102
+ }
103
+ class Supervisor {
104
+ manifest;
105
+ paths;
106
+ registry;
107
+ hooks;
108
+ policy;
109
+ journalOut;
110
+ traceOut;
111
+ vm;
112
+ newCalls = [];
113
+ callRecords = []; // full history incl. journal-loaded
114
+ specBySeq = new Map();
115
+ dispatchQueue = [];
116
+ inflight = new Map();
117
+ completed = [];
118
+ completionSignal = null;
119
+ attemptBySeq = new Map();
120
+ pendingApprovals = new Map();
121
+ controlConsumed = 0;
122
+ cancelled = false;
123
+ terminalError = null;
124
+ terminalErrorSeq;
125
+ /** Latest cumulative cost for each durable leaf attempt. */
126
+ costByAttempt = new Map();
127
+ leafTasks = new Set();
128
+ writeLeafTasks = new Set();
129
+ stopping = false;
130
+ replayCallCursor = 0; // verified against callRecords during replay
131
+ phaseNames = [];
132
+ constructor(manifest, paths, registry, hooks, policy, journalOut, traceOut) {
133
+ this.manifest = manifest;
134
+ this.paths = paths;
135
+ this.registry = registry;
136
+ this.hooks = hooks;
137
+ this.policy = policy;
138
+ this.journalOut = journalOut;
139
+ this.traceOut = traceOut;
140
+ }
141
+ async run() {
142
+ const journal = readJournal(this.manifest.runId);
143
+ const alreadyFinished = lastFinish(journal);
144
+ let latestFinish;
145
+ for (const record of journal) {
146
+ if (record.t === "finish")
147
+ latestFinish = record;
148
+ }
149
+ const controls = readControl(this.manifest.runId);
150
+ // A finish durably records the old control epoch. This also survives a
151
+ // resume that wrote its retry marker and then crashed.
152
+ this.controlConsumed =
153
+ latestFinish?.controlOffset === undefined
154
+ ? alreadyFinished
155
+ ? controls.length // legacy finish record
156
+ : 0
157
+ : Math.min(latestFinish.controlOffset, controls.length);
158
+ const bundle = fs.readFileSync(this.paths.program, "utf8");
159
+ if (sha256Hex(bundle) !== this.manifest.programSha256) {
160
+ throw new DivergenceError("program bundle does not match manifest hash", {});
161
+ }
162
+ // Attempt starts and spend are durable so crashes cannot reset retry
163
+ // allowance or make prior attempts free. Completion records preserve
164
+ // compatibility with runs created before attempt-start records existed.
165
+ for (const rec of journal) {
166
+ if (rec.t === "attempt" || rec.t === "done") {
167
+ this.attemptBySeq.set(rec.seq, Math.max(this.attemptBySeq.get(rec.seq) ?? 0, rec.attempt));
168
+ }
169
+ else if (rec.t === "cost") {
170
+ this.costByAttempt.set(attemptKey(rec.seq, rec.attempt), rec.costUsd);
171
+ }
172
+ }
173
+ // Old runs recorded cost only in traces. Recover the latest revision for
174
+ // each attempt, but never override a durable cost record.
175
+ if (this.manifest.budgetUsd !== undefined) {
176
+ const legacyCosts = new Map();
177
+ for (const tr of readTraces(this.manifest.runId)) {
178
+ if (tr.t !== "leaf" || tr.costUsd === undefined)
179
+ continue;
180
+ const key = attemptKey(tr.seq, tr.attempt);
181
+ const current = legacyCosts.get(key);
182
+ if (!current || tr.rev >= current.rev)
183
+ legacyCosts.set(key, { rev: tr.rev, cost: tr.costUsd });
184
+ }
185
+ for (const [key, value] of legacyCosts) {
186
+ if (!this.costByAttempt.has(key))
187
+ this.costByAttempt.set(key, value.cost);
188
+ }
189
+ this.checkBudget();
190
+ }
191
+ // ---- fail-forward resume bookkeeping -----------------------------------
192
+ const priorCalls = journal.filter((r) => r.t === "call");
193
+ this.callRecords = [...priorCalls];
194
+ const effective = effectiveCompletions(journal);
195
+ let retrySeqs = [];
196
+ let persistRetry = false;
197
+ if (alreadyFinished && alreadyFinished.status !== "completed") {
198
+ if (alreadyFinished.status === "failed") {
199
+ // A failure followed by another call was observed and handled by the
200
+ // program; rewriting it would rewrite history. The only safe automatic
201
+ // retry is the exact leaf recorded as the terminal cause. Call sequence
202
+ // is dispatch order, so a concurrent causal failure need not be the
203
+ // highest sequence number.
204
+ if (alreadyFinished.errorSeq !== undefined &&
205
+ effective.get(alreadyFinished.errorSeq)?.record.status === "error") {
206
+ retrySeqs = [alreadyFinished.errorSeq];
207
+ }
208
+ else if (alreadyFinished.errorSeq === undefined) {
209
+ // Legacy finishes lack causal metadata. Keep their conservative
210
+ // final-call string check rather than guessing across concurrency.
211
+ const finalSeq = priorCalls.at(-1)?.seq;
212
+ const finalCompletion = finalSeq === undefined ? undefined : effective.get(finalSeq)?.record;
213
+ if (finalSeq !== undefined &&
214
+ finalCompletion?.status === "error" &&
215
+ finalCompletion.error !== undefined &&
216
+ (alreadyFinished.error === finalCompletion.error ||
217
+ alreadyFinished.error?.startsWith(`${finalCompletion.error}\n`))) {
218
+ retrySeqs = [finalSeq];
219
+ }
220
+ }
221
+ if (retrySeqs.length === 0) {
222
+ throw new Error(`run ${this.manifest.runId} has no unambiguous terminal leaf failure to retry; launch a new run`);
223
+ }
224
+ }
225
+ for (const seq of retrySeqs)
226
+ effective.delete(seq);
227
+ persistRetry = true;
228
+ }
229
+ else if (alreadyFinished?.status === "completed") {
230
+ throw new Error(`run ${this.manifest.runId} already completed`);
231
+ }
232
+ // A hard-killed owner cannot resolve approvals in its finally path. Close
233
+ // those historical requests before a resumed attempt can ask again.
234
+ for (const approval of openApprovals(readTraces(this.manifest.runId))) {
235
+ this.traceEvent({
236
+ kind: "approval-resolved",
237
+ approvalId: approval.id,
238
+ decision: { behavior: "deny", message: "approval expired when the supervisor restarted" },
239
+ by: "supervisor",
240
+ });
241
+ }
242
+ const replaying = priorCalls.length > 0;
243
+ try {
244
+ // ---- boot the VM (the initial drain can already trip the step budget)
245
+ this.vm = await ProgramVM.create(bundle, this.policy, {
246
+ onCall: (seq, spec) => this.onCall(seq, spec, replaying),
247
+ onLog: (m) => this.traceEvent({ kind: "log", message: m }),
248
+ onPhase: (name) => {
249
+ if (!this.phaseNames.includes(name))
250
+ this.phaseNames.push(name);
251
+ this.traceEvent({ kind: "phase", name });
252
+ },
253
+ });
254
+ return await this.execute(effective, retrySeqs, persistRetry);
255
+ }
256
+ catch (err) {
257
+ // Policy violations (write gate, caps, step budget) are TERMINAL RUN
258
+ // OUTCOMES, not supervisor crashes. Divergence stays a loud throw.
259
+ if (err instanceof PolicyError) {
260
+ this.finishFailed(err.message);
261
+ return statusForRun(this.manifest.runId);
262
+ }
263
+ throw err;
264
+ }
265
+ finally {
266
+ await this.stopAll("run ended");
267
+ this.vm?.dispose();
268
+ }
269
+ }
270
+ loseOwnership(error) {
271
+ this.terminalError = `supervisor ownership lost: ${error.message}`;
272
+ this.abortAll(this.terminalError);
273
+ this.signal();
274
+ }
275
+ async execute(effective, retrySeqs, persistRetry) {
276
+ {
277
+ // ---- replay: deliver effective completions in journal order ----------
278
+ const deliveryOrder = [...effective.values()].sort((a, b) => a.index - b.index);
279
+ for (const { record } of deliveryOrder) {
280
+ this.checkNewCallsAgainstJournal();
281
+ if (!this.vm.pendingSeqs().includes(record.seq)) {
282
+ throw new DivergenceError(`journal has a completion for seq ${record.seq} the program never requested (dangling completion)`, { seq: record.seq });
283
+ }
284
+ this.deliver(record.seq, this.materializeOutcome(record));
285
+ }
286
+ this.checkNewCallsAgainstJournal();
287
+ // Unconsumed suffix check: every prior call must have been re-made.
288
+ if (this.replayCallCursor < this.callRecords.length) {
289
+ const pendingReplayable = this.callRecords.slice(this.replayCallCursor).map((c) => c.seq);
290
+ const vmPending = new Set(this.vm.pendingSeqs());
291
+ const nevermade = pendingReplayable.filter((s) => !vmPending.has(s) && !this.specBySeq.has(s));
292
+ if (nevermade.length > 0 && this.vm.state().state !== "pending") {
293
+ throw new DivergenceError(`program completed replay with unconsumed journal calls: seq ${nevermade.join(",")}`, {});
294
+ }
295
+ }
296
+ // Persist only after the candidate history replayed cleanly. A failed
297
+ // resume must not mutate the journal into a permanently re-armed state.
298
+ if (persistRetry) {
299
+ this.journalOut.append({ t: "retry", seqs: retrySeqs, atMs: Date.now() });
300
+ }
301
+ // ---- re-dispatch undelivered calls (re-orient for writes) ------------
302
+ for (const seq of this.vm.pendingSeqs()) {
303
+ if (!this.dispatchQueue.includes(seq) && !this.inflight.has(seq)) {
304
+ const spec = this.specBySeq.get(seq);
305
+ if (spec) {
306
+ const reorient = !spec.readOnly || retrySeqs.includes(seq);
307
+ this.dispatchQueue.push(seq);
308
+ if (reorient)
309
+ this.markReorient(seq);
310
+ }
311
+ }
312
+ }
313
+ // ---- live loop -------------------------------------------------------
314
+ await this.liveLoop();
315
+ }
316
+ return statusForRun(this.manifest.runId);
317
+ }
318
+ // -------------------------------------------------------------------------
319
+ reorientSeqs = new Set();
320
+ markReorient(seq) {
321
+ this.reorientSeqs.add(seq);
322
+ }
323
+ onCall(seq, spec, verifyAgainstJournal) {
324
+ // ext leaves: readOnly comes from the registration, not the program.
325
+ if (spec.kind.startsWith("ext:")) {
326
+ const ext = this.registry.extensions.get(spec.kind.slice(4));
327
+ if (ext)
328
+ spec.readOnly = ext.readOnly;
329
+ }
330
+ this.specBySeq.set(seq, spec);
331
+ this.newCalls.push({ seq, spec });
332
+ void verifyAgainstJournal; // verification happens in checkNewCallsAgainstJournal
333
+ }
334
+ /** Journal (or verify) calls the VM made during the last drain. */
335
+ checkNewCallsAgainstJournal() {
336
+ for (const { seq, spec } of this.newCalls) {
337
+ const digest = digestJson(spec);
338
+ if (this.replayCallCursor < this.callRecords.length) {
339
+ const expected = this.callRecords[this.replayCallCursor];
340
+ if (expected.seq !== seq || expected.specDigest !== digest) {
341
+ throw new DivergenceError(`call ${seq} does not match the journal (expected seq ${expected.seq})`, { seq, expected: expected.specDigest, got: digest });
342
+ }
343
+ this.replayCallCursor++;
344
+ }
345
+ else {
346
+ if (seq >= this.policy.maxCommands) {
347
+ throw new PolicyError(`program exceeded maxCommands (${this.policy.maxCommands})`);
348
+ }
349
+ if (spec.prompt && Buffer.byteLength(spec.prompt) > this.policy.maxPromptBytes) {
350
+ throw new PolicyError(`prompt for call ${seq} exceeds ${this.policy.maxPromptBytes} bytes`);
351
+ }
352
+ // Extension payloads are subject to the same byte cap as prompts, and
353
+ // are validated against the extension's declared inputSchema.
354
+ if (spec.kind.startsWith("ext:")) {
355
+ const name = spec.kind.slice(4);
356
+ const ext = this.registry.extensions.get(name);
357
+ if (!ext)
358
+ throw new PolicyError(`call ${seq} uses unregistered extension ext.${name}`);
359
+ const payloadBytes = Buffer.byteLength(canonicalJson(spec.payload ?? null));
360
+ if (payloadBytes > this.policy.maxPromptBytes) {
361
+ throw new PolicyError(`ext.${name} payload for call ${seq} exceeds ${this.policy.maxPromptBytes} bytes`);
362
+ }
363
+ if (ext.inputSchema !== undefined) {
364
+ const problem = validateAgainstSchema(spec.payload ?? null, ext.inputSchema);
365
+ if (problem)
366
+ throw new PolicyError(`ext.${name} payload for call ${seq} fails inputSchema: ${problem}`);
367
+ }
368
+ }
369
+ if (!spec.readOnly && !this.manifest.allowWrites) {
370
+ throw new PolicyError(`write-declared leaf (seq ${seq}) but the run was launched without allow_writes — fail-closed`);
371
+ }
372
+ const rec = {
373
+ t: "call",
374
+ seq,
375
+ kind: spec.kind,
376
+ id: spec.id,
377
+ phase: spec.phase,
378
+ readOnly: spec.readOnly,
379
+ specDigest: digest,
380
+ };
381
+ this.journalOut.append(rec); // WAL: call journaled before dispatch
382
+ this.callRecords.push(rec);
383
+ this.replayCallCursor++;
384
+ this.dispatchQueue.push(seq);
385
+ }
386
+ }
387
+ this.newCalls = [];
388
+ }
389
+ materializeOutcome(rec) {
390
+ if (rec.status === "ok" && rec.resultSha) {
391
+ return { status: "ok", value: readResult(this.paths, rec.resultSha) };
392
+ }
393
+ return { status: "error", error: rec.error ?? "unknown leaf error" };
394
+ }
395
+ // -------------------------------------------------------------------------
396
+ async liveLoop() {
397
+ for (;;) {
398
+ this.pollControl();
399
+ this.checkNewCallsAgainstJournal();
400
+ if (this.terminalError) {
401
+ this.finishFailed(this.terminalError);
402
+ return;
403
+ }
404
+ if (this.cancelled) {
405
+ this.abortAll("cancelled");
406
+ this.journalOut.append({
407
+ t: "finish",
408
+ status: "cancelled",
409
+ error: "cancelled by operator",
410
+ controlOffset: this.controlConsumed,
411
+ });
412
+ this.hooks.onUpdate?.(this.manifest.runId);
413
+ return;
414
+ }
415
+ const state = this.vm.state();
416
+ if (state.state !== "pending" &&
417
+ this.inflight.size === 0 &&
418
+ this.dispatchQueue.length === 0 &&
419
+ this.completed.length === 0) {
420
+ this.finish(state);
421
+ return;
422
+ }
423
+ this.pumpDispatch();
424
+ if (state.state === "pending" &&
425
+ this.inflight.size === 0 &&
426
+ this.dispatchQueue.length === 0 &&
427
+ this.completed.length === 0 &&
428
+ this.newCalls.length === 0) {
429
+ this.finishFailed("program is awaiting something orc will never resolve (deadlock)");
430
+ return;
431
+ }
432
+ if (this.completed.length > 0) {
433
+ // Deliver exactly one completion per quiescent drain (frozen policy).
434
+ const next = this.completed.shift();
435
+ this.deliverLive(next);
436
+ continue;
437
+ }
438
+ await this.waitForSignal(1_000);
439
+ this.checkIdleTimeouts();
440
+ }
441
+ }
442
+ pumpDispatch() {
443
+ while (this.inflight.size < this.manifest.maxParallel && this.dispatchQueue.length > 0) {
444
+ const seq = this.dispatchQueue.shift();
445
+ const spec = this.specBySeq.get(seq);
446
+ if (!spec)
447
+ continue;
448
+ const attempt = (this.attemptBySeq.get(seq) ?? 0) + 1;
449
+ this.attemptBySeq.set(seq, attempt);
450
+ this.journalOut.append({ t: "attempt", seq, attempt, atMs: Date.now() });
451
+ const leaf = {
452
+ seq,
453
+ attempt,
454
+ spec,
455
+ abort: new AbortController(),
456
+ lastEventAtMs: Date.now(),
457
+ idleTimeoutMs: spec.idleTimeoutMs ?? this.manifest.idleTimeoutMs,
458
+ groupId: spec.groupId,
459
+ };
460
+ this.inflight.set(seq, leaf);
461
+ let task;
462
+ task = this.executeLeaf(leaf)
463
+ .then((outcome) => this.onLeafDone(leaf, outcome))
464
+ .catch((err) => this.onLeafDone(leaf, {
465
+ status: "error",
466
+ error: boundString(String(err instanceof Error ? err.stack ?? err.message : err), this.policy.maxErrorBytes),
467
+ }))
468
+ .finally(() => {
469
+ this.leafTasks.delete(task);
470
+ this.writeLeafTasks.delete(task);
471
+ this.signal();
472
+ });
473
+ this.leafTasks.add(task);
474
+ if (!spec.readOnly)
475
+ this.writeLeafTasks.add(task);
476
+ }
477
+ }
478
+ onLeafDone(leaf, outcome) {
479
+ if (this.stopping || !this.inflight.has(leaf.seq))
480
+ return; // already aborted+settled
481
+ this.inflight.delete(leaf.seq);
482
+ // Supervisor retry table: a failed READ-ONLY leaf gets a bounded number of
483
+ // fresh attempts before it (and its parallel group) is failed. Write leaves
484
+ // are never auto-retried (a retry would double-apply mutations); a cancelled
485
+ // leaf is not retried either.
486
+ if (outcome.status === "error" &&
487
+ leaf.spec.readOnly &&
488
+ !leaf.abort.signal.aborted &&
489
+ isRetryable(outcome.error) &&
490
+ leaf.attempt <= this.policy.readOnlyRetries) {
491
+ this.traceEvent({
492
+ kind: "log",
493
+ message: `leaf ${leaf.seq} failed (read-only) → retry ${leaf.attempt}/${this.policy.readOnlyRetries}`,
494
+ });
495
+ this.dispatchQueue.push(leaf.seq); // re-dispatch with a fresh attempt
496
+ this.signal();
497
+ return;
498
+ }
499
+ // A failed leaf no longer cancels its parallel() siblings: every lane runs
500
+ // to completion independently and parallel() returns a per-lane outcome, so
501
+ // one malformed lane never wastes the rest of a fan-out. (Fail-fast is still
502
+ // available: a raw Promise.all over agent() rejects on the first failure.)
503
+ this.completed.push({ seq: leaf.seq, attempt: leaf.attempt, outcome });
504
+ this.signal();
505
+ }
506
+ deliverLive(done) {
507
+ let outcome = done.outcome;
508
+ if (outcome.status === "ok") {
509
+ const size = Buffer.byteLength(canonicalJson(outcome.value));
510
+ if (size > this.policy.maxResultBytes) {
511
+ outcome = { status: "error", error: `result exceeds cap (${size} > ${this.policy.maxResultBytes} bytes)` };
512
+ }
513
+ }
514
+ let rec;
515
+ if (outcome.status === "ok") {
516
+ const { sha, sizeBytes } = writeResult(this.paths, outcome.value);
517
+ rec = { t: "done", seq: done.seq, status: "ok", resultSha: sha, sizeBytes, attempt: done.attempt };
518
+ }
519
+ else {
520
+ rec = {
521
+ t: "done",
522
+ seq: done.seq,
523
+ status: "error",
524
+ // LeafOutcome errors are bounded exactly once at their source. Reusing
525
+ // the same string here keeps live and replay identity identical.
526
+ error: outcome.error,
527
+ attempt: done.attempt,
528
+ };
529
+ }
530
+ // WAL invariant: fsync the completion BEFORE delivering it into the sandbox.
531
+ this.journalOut.append(rec, { fsync: true });
532
+ this.deliver(done.seq, outcome);
533
+ this.hooks.onUpdate?.(this.manifest.runId);
534
+ }
535
+ /** Deliver identically during replay and live execution, including causality. */
536
+ deliver(seq, outcome) {
537
+ const before = this.vm.state();
538
+ this.vm.deliver(seq, outcome);
539
+ if (before.state === "pending" && outcome.status === "error") {
540
+ const after = this.vm.state();
541
+ if (after.state === "error" &&
542
+ (after.error === outcome.error || after.error.startsWith(`${outcome.error}\n`))) {
543
+ this.terminalErrorSeq = seq;
544
+ }
545
+ }
546
+ }
547
+ finish(state) {
548
+ if (state.state === "ok") {
549
+ let result;
550
+ try {
551
+ result = this.validateResult(state.result ?? null);
552
+ }
553
+ catch (err) {
554
+ throw new PolicyError(String(err instanceof Error ? err.message : err));
555
+ }
556
+ const { sha } = writeResult(this.paths, result);
557
+ this.journalOut.append({
558
+ t: "finish",
559
+ status: "completed",
560
+ resultSha: sha,
561
+ controlOffset: this.controlConsumed,
562
+ });
563
+ }
564
+ else if (state.state === "error") {
565
+ this.journalOut.append({
566
+ t: "finish",
567
+ status: "failed",
568
+ error: boundString(state.error ?? "program failed", this.policy.maxErrorBytes),
569
+ errorSeq: this.terminalErrorSeq,
570
+ controlOffset: this.controlConsumed,
571
+ });
572
+ }
573
+ this.hooks.onUpdate?.(this.manifest.runId);
574
+ }
575
+ finishFailed(error) {
576
+ this.abortAll(error);
577
+ this.journalOut.append({
578
+ t: "finish",
579
+ status: "failed",
580
+ error: boundString(error, this.policy.maxErrorBytes),
581
+ controlOffset: this.controlConsumed,
582
+ });
583
+ this.hooks.onUpdate?.(this.manifest.runId);
584
+ }
585
+ abortAll(reason) {
586
+ for (const leaf of this.inflight.values())
587
+ leaf.abort.abort(new Error(reason));
588
+ }
589
+ async stopAll(reason) {
590
+ this.stopping = true;
591
+ this.abortAll(reason);
592
+ const tasks = [...this.leafTasks];
593
+ if (tasks.length === 0)
594
+ return;
595
+ const writeTasks = new Set(this.writeLeafTasks);
596
+ const readTasks = tasks.filter((task) => !writeTasks.has(task));
597
+ // Write tasks retain exclusive ownership until each one actually stops;
598
+ // a stuck read-only sibling must not extend that requirement.
599
+ await Promise.allSettled(writeTasks);
600
+ if (readTasks.length === 0)
601
+ return;
602
+ let timer;
603
+ try {
604
+ await Promise.race([
605
+ Promise.allSettled(readTasks),
606
+ new Promise((resolve) => {
607
+ timer = setTimeout(resolve, READ_ONLY_SHUTDOWN_GRACE_MS);
608
+ }),
609
+ ]);
610
+ }
611
+ finally {
612
+ if (timer)
613
+ clearTimeout(timer);
614
+ }
615
+ }
616
+ // -------------------------------------------------------------------------
617
+ async executeLeaf(leaf) {
618
+ const { spec, seq, attempt } = leaf;
619
+ const host = spec.host ?? this.manifest.host;
620
+ const cwd = spec.cwd ?? this.manifest.cwd;
621
+ const executor = this.registry.executorFor(host);
622
+ let rev = 0;
623
+ const startMs = Date.now();
624
+ const toolCalls = new Map();
625
+ let tokensIn;
626
+ let tokensOut;
627
+ let costUsd;
628
+ let costEstimated;
629
+ let sessionId;
630
+ let resolvedModel = spec.model;
631
+ let resolvedEffort = spec.reasoningEffort;
632
+ const base = {
633
+ t: "leaf",
634
+ seq,
635
+ attempt,
636
+ id: spec.id,
637
+ phase: spec.phase,
638
+ kind: spec.kind,
639
+ harness: spec.kind === "agent" ? (spec.harness ?? this.manifest.defaultHarness) : undefined,
640
+ host,
641
+ cwd,
642
+ readOnly: spec.readOnly,
643
+ startMs,
644
+ prompt: spec.prompt ? boundString(spec.prompt, 16 * 1024) : undefined,
645
+ brief: boundString(this.manifest.brief, 4 * 1024),
646
+ reoriented: this.reorientSeqs.has(seq) || undefined,
647
+ };
648
+ const emitTrace = (status, extra = {}, fsync = false) => {
649
+ if (this.stopping)
650
+ return;
651
+ this.traceOut.append({
652
+ ...base,
653
+ rev: rev++,
654
+ status,
655
+ toolCalls: [...toolCalls.values()],
656
+ model: resolvedModel,
657
+ reasoningEffort: resolvedEffort,
658
+ tokensIn,
659
+ tokensOut,
660
+ costUsd,
661
+ costEstimated,
662
+ sessionId,
663
+ ...extra,
664
+ }, { fsync });
665
+ this.hooks.onUpdate?.(this.manifest.runId);
666
+ };
667
+ emitTrace("running");
668
+ const ctx = {
669
+ executor,
670
+ signal: leaf.abort.signal,
671
+ // Harness stderr/tracing is per-leaf detail, not run narrative: record it
672
+ // as an hlog trace (drawer's collapsed "Harness log"), never as a feed event.
673
+ log: (m) => this.harnessLog(seq, m),
674
+ requestApproval: (req) => this.bridgeApproval(req, leaf),
675
+ };
676
+ try {
677
+ if (spec.kind.startsWith("ext:")) {
678
+ const name = spec.kind.slice(4);
679
+ const ext = this.registry.extensions.get(name);
680
+ if (!ext)
681
+ throw new Error(`unknown extension leaf: ext.${name} (register it in orc.config)`);
682
+ const value = this.validateResult((await ext.execute(spec.payload ?? null, ctx)) ?? null);
683
+ emitTrace("ok", { endMs: Date.now(), output: value }, true);
684
+ return { status: "ok", value };
685
+ }
686
+ const harnessName = spec.harness ?? this.manifest.defaultHarness;
687
+ const harness = this.registry.harnesses.get(harnessName);
688
+ if (!harness) {
689
+ throw new Error(`unknown harness "${harnessName}" (available: ${[...this.registry.harnesses.keys()].join(", ") || "none"})`);
690
+ }
691
+ let prompt = spec.prompt ?? "";
692
+ if (this.reorientSeqs.has(seq)) {
693
+ prompt = (await this.reorientPreamble(executor, cwd)) + prompt;
694
+ }
695
+ const req = {
696
+ runId: this.manifest.runId,
697
+ seq,
698
+ id: spec.id,
699
+ prompt,
700
+ system: leafSystemPrompt(spec.readOnly, cwd, this.manifest.brief),
701
+ brief: this.manifest.brief,
702
+ schema: spec.schema,
703
+ model: spec.model,
704
+ reasoningEffort: spec.reasoningEffort,
705
+ readOnly: spec.readOnly,
706
+ cwd,
707
+ host,
708
+ approvalMode: this.manifest.approvalMode,
709
+ idleTimeoutMs: leaf.idleTimeoutMs,
710
+ sandbox: this.manifest.sandbox,
711
+ sandboxDirs: this.manifest.sandboxDirs,
712
+ };
713
+ let result;
714
+ let errorMsg;
715
+ for await (const ev of harness.invoke(req, ctx)) {
716
+ if (this.stopping)
717
+ throw new Error(`aborted: ${String(leaf.abort.signal.reason ?? "run ended")}`);
718
+ assertHarnessEvent(ev);
719
+ leaf.lastEventAtMs = Date.now();
720
+ this.applyEvent(ev, toolCalls, (u) => {
721
+ tokensIn = u.tokensIn ?? tokensIn;
722
+ tokensOut = u.tokensOut ?? tokensOut;
723
+ if (u.costUsd !== undefined) {
724
+ if (!Number.isFinite(u.costUsd) || u.costUsd < 0) {
725
+ throw new Error(`harness reported invalid costUsd: ${String(u.costUsd)}`);
726
+ }
727
+ const changed = u.costUsd !== costUsd;
728
+ costUsd = u.costUsd;
729
+ costEstimated = u.costEstimated ?? false;
730
+ this.costByAttempt.set(attemptKey(seq, attempt), u.costUsd);
731
+ if (changed) {
732
+ this.journalOut.append({
733
+ t: "cost",
734
+ seq,
735
+ attempt,
736
+ costUsd: u.costUsd,
737
+ costEstimated,
738
+ atMs: Date.now(),
739
+ });
740
+ }
741
+ this.checkBudget();
742
+ }
743
+ });
744
+ if (ev.kind === "session")
745
+ sessionId = ev.sessionId;
746
+ if (ev.kind === "model") {
747
+ if (ev.model)
748
+ resolvedModel = ev.model;
749
+ if (ev.reasoningEffort)
750
+ resolvedEffort = ev.reasoningEffort;
751
+ }
752
+ if (ev.kind === "result")
753
+ result = ev.output;
754
+ if (ev.kind === "error")
755
+ errorMsg = ev.message;
756
+ if (ev.kind === "tool-call-open" || ev.kind === "tool-call-close")
757
+ emitTrace("running");
758
+ if (ev.kind === "denied") {
759
+ this.traceEvent({ kind: "denied", seq, toolName: ev.toolName, reason: ev.reason });
760
+ }
761
+ }
762
+ if (leaf.abort.signal.aborted) {
763
+ throw new Error(`aborted: ${String(leaf.abort.signal.reason ?? "cancelled")}`);
764
+ }
765
+ if (errorMsg !== undefined && result === undefined)
766
+ throw new Error(errorMsg);
767
+ if (result === undefined)
768
+ throw new Error("harness produced no result event");
769
+ result = this.validateResult(result, spec.schema);
770
+ emitTrace("ok", { endMs: Date.now(), output: result }, true);
771
+ return { status: "ok", value: result };
772
+ }
773
+ catch (err) {
774
+ const msg = boundString(String(err instanceof Error ? (err.message ?? err) : err), this.policy.maxErrorBytes);
775
+ emitTrace("error", { endMs: Date.now(), error: msg }, true);
776
+ return { status: "error", error: msg };
777
+ }
778
+ }
779
+ validateResult(value, schema) {
780
+ let canonical;
781
+ try {
782
+ canonical = canonicalJson(value);
783
+ }
784
+ catch (err) {
785
+ throw new Error(`result is not valid JSON: ${String(err instanceof Error ? err.message : err)}`);
786
+ }
787
+ const size = Buffer.byteLength(canonical);
788
+ if (size > this.policy.maxResultBytes) {
789
+ throw new Error(`result exceeds cap (${size} > ${this.policy.maxResultBytes} bytes)`);
790
+ }
791
+ const snapshot = JSON.parse(canonical);
792
+ if (schema !== undefined) {
793
+ const problem = validateAgainstSchema(snapshot, schema);
794
+ if (problem)
795
+ throw new Error(`result fails output schema: ${problem}`);
796
+ }
797
+ return snapshot;
798
+ }
799
+ applyEvent(ev, toolCalls, usage) {
800
+ if (ev.kind === "tool-call-open") {
801
+ toolCalls.set(ev.id, { id: ev.id, name: ev.name, input: ev.input, startMs: ev.atMs, status: "running" });
802
+ }
803
+ else if (ev.kind === "tool-call-close") {
804
+ const tc = toolCalls.get(ev.id);
805
+ if (tc) {
806
+ tc.status = ev.status;
807
+ tc.endMs = ev.atMs;
808
+ if (ev.result !== undefined)
809
+ tc.result = ev.result;
810
+ }
811
+ }
812
+ else if (ev.kind === "usage") {
813
+ usage(ev);
814
+ }
815
+ }
816
+ async reorientPreamble(executor, cwd) {
817
+ let snapshot = "";
818
+ try {
819
+ const status = await executor.run(["git", "status", "--porcelain"], { cwd, timeoutMs: 5_000 });
820
+ const diff = await executor.run(["git", "diff", "--stat"], { cwd, timeoutMs: 5_000 });
821
+ if (status.code === 0) {
822
+ snapshot = `Observed working-tree state:\n${status.stdout.trim() || "(clean)"}\n${diff.stdout.trim()}`;
823
+ }
824
+ }
825
+ catch {
826
+ snapshot = "(state snapshot unavailable)";
827
+ }
828
+ return (`RE-ORIENT NOTE: A previous attempt at this task may have partially completed before being interrupted. ` +
829
+ `${snapshot}\nInspect the current state before acting, do not blindly redo work that is already done, ` +
830
+ `and complete the task idempotently.\n\n---\n\n`);
831
+ }
832
+ // -------------------------------------------------------------------------
833
+ bridgeApproval(req, leaf) {
834
+ if (this.stopping || leaf.abort.signal.aborted) {
835
+ return Promise.resolve({ behavior: "deny", message: "leaf is no longer running" });
836
+ }
837
+ const id = `a_${leaf.seq}_${randomUUID()}`;
838
+ const approval = { ...req, id, requestedAtMs: Date.now() };
839
+ this.traceEvent({ kind: "approval-requested", approval });
840
+ return new Promise((resolve) => {
841
+ const settle = (d) => {
842
+ this.pendingApprovals.delete(id);
843
+ resolve(d);
844
+ };
845
+ this.pendingApprovals.set(id, settle);
846
+ leaf.abort.signal.addEventListener("abort", () => {
847
+ if (this.pendingApprovals.get(id) !== settle)
848
+ return;
849
+ const decision = { behavior: "deny", message: "leaf aborted while approval was pending" };
850
+ this.traceEvent({ kind: "approval-resolved", approvalId: id, decision, by: "supervisor" });
851
+ settle(decision);
852
+ });
853
+ });
854
+ }
855
+ /** Called by the control-file watcher: drain control immediately and wake the loop. */
856
+ wakeForControl() {
857
+ try {
858
+ this.pollControl();
859
+ }
860
+ catch {
861
+ /* the loop will re-poll */
862
+ }
863
+ this.signal();
864
+ }
865
+ pollControl() {
866
+ const messages = readControl(this.manifest.runId);
867
+ for (let i = this.controlConsumed; i < messages.length; i++) {
868
+ const msg = messages[i];
869
+ if (msg.t === "cancel")
870
+ this.cancelled = true;
871
+ if (msg.t === "approval") {
872
+ const settle = this.pendingApprovals.get(msg.approvalId);
873
+ if (settle) {
874
+ this.traceEvent({ kind: "approval-resolved", approvalId: msg.approvalId, decision: msg.decision, by: msg.by });
875
+ settle(msg.decision);
876
+ }
877
+ }
878
+ }
879
+ this.controlConsumed = messages.length;
880
+ }
881
+ checkIdleTimeouts() {
882
+ const now = Date.now();
883
+ for (const leaf of this.inflight.values()) {
884
+ if (typeof leaf.idleTimeoutMs === "number" && now - leaf.lastEventAtMs > leaf.idleTimeoutMs) {
885
+ const error = `idle timeout: no harness events for ${leaf.idleTimeoutMs}ms`;
886
+ leaf.abort.abort(new Error(error));
887
+ // AbortSignal is cooperative. Settle the deterministic leaf outcome
888
+ // now; a late task result is ignored because the inflight entry is gone.
889
+ this.onLeafDone(leaf, { status: "error", error });
890
+ }
891
+ }
892
+ }
893
+ traceEvent(event) {
894
+ this.traceOut.append({ t: "event", atMs: Date.now(), event }, { fsync: false });
895
+ this.hooks.onUpdate?.(this.manifest.runId);
896
+ }
897
+ harnessLog(seq, message) {
898
+ if (this.stopping)
899
+ return;
900
+ this.traceOut.append({ t: "hlog", seq, atMs: Date.now(), message }, { fsync: false });
901
+ this.hooks.onUpdate?.(this.manifest.runId);
902
+ }
903
+ /**
904
+ * Budget cap: once the run's summed estimated cost passes manifest.budgetUsd,
905
+ * fail the run terminally (all inflight leaves are aborted by finishFailed).
906
+ * Checked on every cost update and once at start (a resume can already be over).
907
+ */
908
+ checkBudget() {
909
+ const budget = this.manifest.budgetUsd;
910
+ if (budget === undefined || this.terminalError)
911
+ return;
912
+ let total = 0;
913
+ for (const v of this.costByAttempt.values())
914
+ total += v;
915
+ if (total > budget) {
916
+ const msg = `budget exceeded: estimated cost ~$${total.toFixed(2)} passed the $${budget.toFixed(2)} budget`;
917
+ this.traceEvent({ kind: "log", message: `${msg} — failing run` });
918
+ this.terminalError = msg;
919
+ this.signal();
920
+ }
921
+ }
922
+ signal() {
923
+ this.completionSignal?.();
924
+ this.completionSignal = null;
925
+ }
926
+ waitForSignal(maxMs) {
927
+ return new Promise((resolve) => {
928
+ const timer = setTimeout(() => {
929
+ this.completionSignal = null;
930
+ resolve();
931
+ }, maxMs);
932
+ this.completionSignal = () => {
933
+ clearTimeout(timer);
934
+ resolve();
935
+ };
936
+ });
937
+ }
938
+ }
939
+ // ---------------------------------------------------------------------------
940
+ function lastFinish(journal) {
941
+ let out;
942
+ let outIdx = -1;
943
+ journal.forEach((r, i) => {
944
+ if (r.t === "finish") {
945
+ out = r;
946
+ outIdx = i;
947
+ }
948
+ });
949
+ // A retry record after the finish re-arms the run: treat as not finished.
950
+ const retried = journal.some((r, i) => r.t === "retry" && i > outIdx);
951
+ return retried ? undefined : out;
952
+ }
953
+ /**
954
+ * Effective completion per seq: completion records are void if a later retry
955
+ * record names their seq. Returns map seq -> {record, index-in-journal}.
956
+ */
957
+ function effectiveCompletions(journal) {
958
+ const voidedBefore = new Map(); // seq -> journal index of last retry naming it
959
+ journal.forEach((r, i) => {
960
+ if (r.t === "retry")
961
+ for (const seq of r.seqs)
962
+ voidedBefore.set(seq, i);
963
+ });
964
+ const out = new Map();
965
+ journal.forEach((r, i) => {
966
+ if (r.t !== "done")
967
+ return;
968
+ const voidIdx = voidedBefore.get(r.seq);
969
+ if (voidIdx !== undefined && i < voidIdx)
970
+ return; // voided by a later retry
971
+ out.set(r.seq, { record: r, index: i });
972
+ });
973
+ return out;
974
+ }
975
+ /** Deterministic setup errors are not worth retrying. */
976
+ function isRetryable(error) {
977
+ // Deterministic errors won't change on a re-run, so retrying only wastes a
978
+ // model call and delays the real failure. Config/routing errors plus the
979
+ // structured-output schema rejections (OpenAI/codex strict mode) are all
980
+ // author-fixable, not transient.
981
+ return !/unknown harness|unknown extension|unregistered extension|fails inputSchema|not supported for remote|allow_writes|invalid_json_schema|invalid schema|unsupported schema|additionalProperties|output[\s_-]?schema|result exceeds cap|result is not valid JSON/i.test(error);
982
+ }
983
+ function attemptKey(seq, attempt) {
984
+ return `${seq}:${attempt}`;
985
+ }
986
+ function assertHarnessEvent(value) {
987
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
988
+ throw new Error("harness emitted a non-object event");
989
+ }
990
+ const event = value;
991
+ const kind = event.kind;
992
+ const stringField = (name) => {
993
+ if (typeof event[name] !== "string")
994
+ throw new Error(`harness ${String(kind)} event has invalid ${name}`);
995
+ };
996
+ const optionalString = (name) => {
997
+ if (event[name] !== undefined && typeof event[name] !== "string") {
998
+ throw new Error(`harness ${String(kind)} event has invalid ${name}`);
999
+ }
1000
+ };
1001
+ const finiteNumber = (name, optional = false) => {
1002
+ const v = event[name];
1003
+ if (optional && v === undefined)
1004
+ return;
1005
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0) {
1006
+ throw new Error(`harness ${String(kind)} event has invalid ${name}`);
1007
+ }
1008
+ };
1009
+ const jsonField = (name) => {
1010
+ if (event[name] !== undefined)
1011
+ canonicalJson(event[name]);
1012
+ };
1013
+ switch (kind) {
1014
+ case "tool-call-open":
1015
+ stringField("id");
1016
+ stringField("name");
1017
+ finiteNumber("atMs");
1018
+ jsonField("input");
1019
+ break;
1020
+ case "tool-call-close":
1021
+ stringField("id");
1022
+ if (event.status !== "ok" && event.status !== "error") {
1023
+ throw new Error("harness tool-call-close event has invalid status");
1024
+ }
1025
+ finiteNumber("atMs");
1026
+ jsonField("result");
1027
+ break;
1028
+ case "text":
1029
+ stringField("delta");
1030
+ finiteNumber("atMs");
1031
+ break;
1032
+ case "usage":
1033
+ finiteNumber("tokensIn", true);
1034
+ finiteNumber("tokensOut", true);
1035
+ finiteNumber("costUsd", true);
1036
+ if (event.costEstimated !== undefined && typeof event.costEstimated !== "boolean") {
1037
+ throw new Error("harness usage event has invalid costEstimated");
1038
+ }
1039
+ break;
1040
+ case "model":
1041
+ optionalString("model");
1042
+ optionalString("reasoningEffort");
1043
+ break;
1044
+ case "session":
1045
+ stringField("sessionId");
1046
+ break;
1047
+ case "denied":
1048
+ stringField("toolName");
1049
+ stringField("reason");
1050
+ finiteNumber("atMs");
1051
+ break;
1052
+ case "result":
1053
+ if (!Object.prototype.hasOwnProperty.call(event, "output")) {
1054
+ throw new Error("harness result event has no output");
1055
+ }
1056
+ break;
1057
+ case "error":
1058
+ stringField("message");
1059
+ break;
1060
+ default:
1061
+ throw new Error(`harness emitted unknown event kind: ${String(kind)}`);
1062
+ }
1063
+ }
1064
+ export function leafSystemPrompt(readOnly, cwd, brief) {
1065
+ const mutation = readOnly
1066
+ ? "This task is READ-ONLY: inspect freely but do not modify files, run mutating commands, or change system state."
1067
+ : "This task may modify files under the working directory. Make only the changes the task requires.";
1068
+ return (`You are an orc leaf agent. You start fresh with no memory of other leaves; everything you need is in this prompt.\n` +
1069
+ `Working directory: ${cwd}\n${mutation}\n` +
1070
+ `SHARED CONTEXT (brief):\n${brief}`);
1071
+ }
1072
+ export { newRunId };