faberun 0.3.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.
Files changed (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/faberun.mjs +25 -0
  4. package/integrations/claude-code/statusline-bench.sh +42 -0
  5. package/integrations/claude-code/statusline.sh +80 -0
  6. package/package.json +33 -0
  7. package/skills/faberun/SKILL.md +24 -0
  8. package/skills/faberun/references/contract.md +380 -0
  9. package/skills/faberun/references/engineering.md +29 -0
  10. package/skills/faberun/references/handoffs.md +26 -0
  11. package/skills/faberun/references/operations.md +184 -0
  12. package/skills/faberun/references/rules.md +35 -0
  13. package/skills/faberun/references/workflow.md +23 -0
  14. package/skills/init-agentkit/SKILL.md +108 -0
  15. package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
  16. package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
  17. package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
  18. package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
  19. package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
  20. package/skills/init-agentkit/templates/AGENTS.md +110 -0
  21. package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
  22. package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
  23. package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
  24. package/skills/init-agentkit/templates/docs/VISION.md +33 -0
  25. package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
  26. package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
  27. package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
  28. package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
  29. package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
  30. package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
  31. package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
  32. package/src/campaign/brief.mjs +394 -0
  33. package/src/campaign/chain.mjs +555 -0
  34. package/src/campaign/handoff.mjs +516 -0
  35. package/src/campaign/index.mjs +300 -0
  36. package/src/campaign/journal.mjs +347 -0
  37. package/src/campaign/layout.mjs +51 -0
  38. package/src/campaign/metrics-evals.mjs +25 -0
  39. package/src/campaign/metrics.mjs +517 -0
  40. package/src/campaign/projection.mjs +250 -0
  41. package/src/campaign/record.mjs +102 -0
  42. package/src/campaign/unpark.mjs +56 -0
  43. package/src/cli/brand.mjs +205 -0
  44. package/src/cli/campaign.mjs +730 -0
  45. package/src/cli/contract.mjs +67 -0
  46. package/src/cli/init.mjs +170 -0
  47. package/src/cli/launch.mjs +239 -0
  48. package/src/cli/seat.mjs +139 -0
  49. package/src/cli/setup.mjs +294 -0
  50. package/src/cli/skills.mjs +105 -0
  51. package/src/cli/update.mjs +216 -0
  52. package/src/cli.mjs +525 -0
  53. package/src/contract/articles.mjs +12 -0
  54. package/src/contract/assert.mjs +162 -0
  55. package/src/contract/definition-of-done.mjs +97 -0
  56. package/src/contract/final-verification.mjs +96 -0
  57. package/src/contract/index.mjs +641 -0
  58. package/src/contract/judge-envelope.mjs +25 -0
  59. package/src/contract/review-modes.mjs +151 -0
  60. package/src/contract/runtime.mjs +204 -0
  61. package/src/contract/schema-version.mjs +25 -0
  62. package/src/contract/scope-findings.mjs +77 -0
  63. package/src/contract/snapshot.mjs +639 -0
  64. package/src/contract/task-packet.mjs +495 -0
  65. package/src/contract/untrusted.mjs +75 -0
  66. package/src/contract/verification.mjs +185 -0
  67. package/src/contract/worker-result.mjs +138 -0
  68. package/src/engine/assignment.mjs +63 -0
  69. package/src/engine/backoff.mjs +492 -0
  70. package/src/engine/bulk-read.mjs +361 -0
  71. package/src/engine/cancel.mjs +177 -0
  72. package/src/engine/detach.mjs +101 -0
  73. package/src/engine/dispatch.mjs +752 -0
  74. package/src/engine/failover.mjs +192 -0
  75. package/src/engine/gate.mjs +183 -0
  76. package/src/engine/judge-gate.mjs +517 -0
  77. package/src/engine/lifecycle.mjs +772 -0
  78. package/src/engine/live-preflight.mjs +299 -0
  79. package/src/engine/mutation.mjs +146 -0
  80. package/src/engine/notify-queue.mjs +327 -0
  81. package/src/engine/process-identity.mjs +72 -0
  82. package/src/engine/process.mjs +774 -0
  83. package/src/engine/prompts.mjs +289 -0
  84. package/src/engine/recover.mjs +300 -0
  85. package/src/engine/result-file.mjs +222 -0
  86. package/src/engine/resume.mjs +635 -0
  87. package/src/engine/retry.mjs +334 -0
  88. package/src/engine/review.mjs +228 -0
  89. package/src/engine/run-command.mjs +287 -0
  90. package/src/engine/run-identity.mjs +411 -0
  91. package/src/engine/runtime-discovery.mjs +235 -0
  92. package/src/engine/scheduler.mjs +526 -0
  93. package/src/engine/scope.mjs +378 -0
  94. package/src/engine/settle.mjs +207 -0
  95. package/src/engine/state.mjs +148 -0
  96. package/src/engine/supervise.mjs +713 -0
  97. package/src/engine/verify.mjs +167 -0
  98. package/src/harnesses/agy/index.mjs +62 -0
  99. package/src/harnesses/catalogue.mjs +509 -0
  100. package/src/harnesses/claude/index.mjs +90 -0
  101. package/src/harnesses/codex/index.mjs +87 -0
  102. package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
  103. package/src/harnesses/dsh/index.mjs +210 -0
  104. package/src/harnesses/dsh/runner.mjs +259 -0
  105. package/src/harnesses/exec-jsonl/index.mjs +788 -0
  106. package/src/harnesses/index.mjs +508 -0
  107. package/src/harnesses/protocol.mjs +531 -0
  108. package/src/harnesses/replay/bin.mjs +386 -0
  109. package/src/harnesses/replay/index.mjs +238 -0
  110. package/src/harnesses/zcode/index.mjs +276 -0
  111. package/src/host/config.mjs +87 -0
  112. package/src/host/home.mjs +149 -0
  113. package/src/host/package.mjs +23 -0
  114. package/src/host/preflight.mjs +520 -0
  115. package/src/host/tool-policy-decisions.mjs +341 -0
  116. package/src/host/tool-policy-hook.mjs +270 -0
  117. package/src/notify/index.mjs +359 -0
  118. package/src/notify/os-macos.mjs +81 -0
  119. package/src/repo/declared-paths.mjs +220 -0
  120. package/src/repo/integrate.mjs +546 -0
  121. package/src/repo/scope-closure.mjs +665 -0
  122. package/src/repo/signal-block.mjs +16 -0
  123. package/src/repo/signal.mjs +222 -0
  124. package/src/repo/source-identity.mjs +295 -0
  125. package/src/repo/workspace.mjs +557 -0
  126. package/src/repo/worktree.mjs +352 -0
  127. package/src/report/final.mjs +200 -0
  128. package/src/report/metrics-report.mjs +99 -0
  129. package/src/report/next.mjs +383 -0
  130. package/src/report/render.mjs +716 -0
  131. package/src/run/disk-gc.mjs +251 -0
  132. package/src/run/lock.mjs +329 -0
  133. package/src/run/node-store.mjs +62 -0
  134. package/src/run/operations.mjs +286 -0
  135. package/src/run/store.mjs +187 -0
  136. package/src/run/usage.mjs +337 -0
  137. package/src/seat/harnesses.mjs +83 -0
  138. package/src/seat/index.mjs +239 -0
  139. package/src/seat/tmux.mjs +208 -0
  140. package/src/util.mjs +0 -0
  141. package/src/web/api.mjs +371 -0
  142. package/src/web/boundary.mjs +88 -0
  143. package/src/web/index.html +299 -0
  144. package/src/web/server.mjs +552 -0
@@ -0,0 +1,774 @@
1
+ /**
2
+ * One provider invocation as an operating-system fact: spawn it behind the gate,
3
+ * watch its transcript grow, decide it has stalled, and take it down.
4
+ *
5
+ * Everything here is about the process and its files -- pids, process groups,
6
+ * start tokens, log tails, stall clocks. Nothing here knows what a node is, what
7
+ * a judge decides, or when a run is done. That separation is the point: a stuck
8
+ * provider is killed by the same code whatever it was asked to do.
9
+ */
10
+ import { SessionMetricsParser } from "../harnesses/exec-jsonl/index.mjs";
11
+ import { closeSync, existsSync, fsyncSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { errorCode, errorMessage } from "../util.mjs";
14
+ import { fileURLToPath } from "node:url";
15
+ import { harnessCapabilities, normalizeProviderResult, providerCommand } from "../harnesses/index.mjs";
16
+ import { latestTimeoutSec } from "./backoff.mjs";
17
+ import { attemptWorkspace, sealAttempt } from "../repo/worktree.mjs";
18
+
19
+ import { invocationOwned, processGroupAlive, processStartTokenMatches } from "./process-identity.mjs";
20
+ import { processStartToken } from "../run/lock.mjs";
21
+ import { randomUUID } from "node:crypto";
22
+ import { spawn } from "node:child_process";
23
+ import { appendJsonl, writeJsonAtomic } from "../run/store.mjs";
24
+ import { writeNodeSnapshot } from "../run/node-store.mjs";
25
+
26
+ /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
27
+ /** @typedef {import("../contract/index.mjs").ValidatedNode} ValidatedNode */
28
+ /** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
29
+ /** @typedef {import("../harnesses/index.mjs").HarnessRuntime} HarnessRuntime */
30
+ /** @typedef {import("../contract/index.mjs").Usage} Usage */
31
+ /** @typedef {import("../harnesses/index.mjs").ProviderEnvelope} ProviderEnvelope */
32
+ /** @typedef {ProviderEnvelope & {costProvenance?: "priced"}} PricedEnvelope */
33
+ /** @typedef {import("node:child_process").ChildProcess} ChildProcess */
34
+ /** @typedef {{prompt: string|null, stdout: string, stderr: string}} PathSet */
35
+ /** @typedef {{id: string, pid: number, processGroupId: number|null, processStartToken: string|null, harness: string, runtimeId: string|null, runtimeFingerprint?: string, revision?: number, phase: string, promptPath: string|null, stdoutPath: string, stderrPath: string, startedAt: string, deadlineAt: string|null, updatedAt: string, closedAt: string|null, exitCode: number|null, signal: string|null, status: "active"|"closed"|"terminated", executable: string, snapshotPath?: string, usage?: Usage, usageEstimated?: boolean, costUsd?: number|null, costProvenance?: "priced", runId?: string, campaignId?: string, nodeId?: string, attempt?: number, workspace?: string, worktreeBranch?: string|null, worktreeBaseSha?: string|null, planPhase?: string, role?: "worker"|"judge", model?: string, reasoning?: string|null, sandbox?: string|null, continuationId?: string|null, continuationMode?: "fresh"|"reuse"|"rotate"}} Invocation */
36
+ /** @typedef {{pid: number|null, processGroupId?: number|null, processStartToken?: string|null}} InvocationProbe */
37
+ /** @typedef {{child: ChildProcess, contract: ValidatedContract, node: ValidatedNode, state: NodeSnapshot, runtime: HarnessRuntime & {id: string|null}, cwd: string, paths: PathSet, phase: string, invocation: Invocation, startedAt: string, startedTicks: bigint, progressTicks: bigint, lastOutputAt: number, closed: boolean, exitCode: number|null, signal: string|null, spawnError: Error|null, terminating: Promise<void>|null, gateConfigPath: string, gateReleasePath: string, scopeBaseline?: unknown, scopeChecked?: boolean, scopeViolation?: boolean, resultMaterialization?: boolean, recoveryBaseline?: unknown, observeTimer?: ReturnType<typeof setInterval>, monitorOffset?: number, monitorParser?: import("../harnesses/exec-jsonl/index.mjs").SessionMetricsParser, lastEventCount?: number, observedOnce?: boolean, onClose?: (invocation: Invocation) => void, onInvocationUpdate?: (invocation: Invocation) => void, onProgress?: (state: NodeSnapshot) => void}} Job */
38
+ /** @typedef {{graceMs?: number, killGraceMs?: number, escalate?: boolean, runDir?: string, kill?: (pid: number, signal: string|number) => unknown, child?: ChildProcess|null}} TerminateOptions */
39
+
40
+ const HERE = dirname(fileURLToPath(import.meta.url));
41
+ const DEFAULT_GRACE_MS = 2_000;
42
+ const GATE_PATH = join(HERE, "gate.mjs");
43
+ const MAX_PROVIDER_LOG_BYTES = 512 * 1024;
44
+ /** Fixed-size read for incremental transcript observation. */
45
+ const MONITOR_CHUNK_BYTES = 64 * 1024;
46
+ /** Per-observation read budget: one tick never blocks on a huge backlog. */
47
+ const MONITOR_CALL_BUDGET_BYTES = 1024 * 1024;
48
+ /**
49
+ * @param {{contract: ValidatedContract, node: ValidatedNode, state: NodeSnapshot, runtime: HarnessRuntime & {id: string|null}, prompt: string, paths: PathSet, phase: string, workspace?: string, commandOptions?: import("../harnesses/index.mjs").CommandOptions, onInvocation: (invocation: Invocation, job: Job) => void, onInvocationUpdate?: (invocation: Invocation) => void, onProgress?: (state: NodeSnapshot) => void}} args
50
+ * @returns {Job}
51
+ */
52
+ export function startProcess({ contract, node, state, runtime, prompt, paths, phase, workspace = contract.cwd, commandOptions = {}, onInvocation, onInvocationUpdate, onProgress }) {
53
+ const command = providerCommand(runtime, prompt, commandOptions);
54
+ if (paths.prompt) writeFileSync(paths.prompt, prompt, { flag: "wx", mode: 0o600 });
55
+ const gateConfigPath = `${paths.prompt}.gate.json`;
56
+ const gateReleasePath = `${paths.prompt}.gate.release`;
57
+ writeJsonAtomic(gateConfigPath, {
58
+ cwd: workspace,
59
+ executable: command.executable,
60
+ args: command.args,
61
+ promptTransport: command.promptTransport,
62
+ harness: runtime.harness,
63
+ env: command.env ?? null,
64
+ stdoutPath: paths.stdout,
65
+ stderrPath: paths.stderr,
66
+ });
67
+ let child;
68
+ try {
69
+ child = spawn(process.execPath, [GATE_PATH], {
70
+ cwd: workspace,
71
+ env: {
72
+ ...process.env,
73
+ FABERUN_GATE_CONFIG: gateConfigPath,
74
+ FABERUN_GATE_RELEASE: gateReleasePath,
75
+ FABERUN_GATE_PARENT_PID: String(process.pid),
76
+ FABERUN_GATE_PARENT_TOKEN: processStartToken(process.pid) ?? "",
77
+ },
78
+ detached: process.platform !== "win32",
79
+ stdio: ["pipe", "ignore", "ignore"],
80
+ });
81
+ } catch (error) {
82
+ cleanupGate({ gateConfigPath, gateReleasePath });
83
+ throw error;
84
+ }
85
+ const startedAt = new Date().toISOString();
86
+ const timeoutSec = latestTimeoutSec(state, node.timeoutSec ?? contract.timeoutSec);
87
+ /** @type {Invocation} */
88
+ const invocation = {
89
+ id: randomUUID(),
90
+ pid: /** @type {number} */ (child.pid),
91
+ processGroupId: process.platform === "win32" ? null : /** @type {number} */ (child.pid),
92
+ processStartToken: processStartToken(/** @type {number} */ (child.pid)),
93
+ harness: runtime.harness,
94
+ runtimeId: runtime.id ?? null,
95
+ revision: state.revisions ?? 0,
96
+ phase,
97
+ promptPath: paths.prompt ?? null,
98
+ stdoutPath: paths.stdout,
99
+ stderrPath: paths.stderr,
100
+ startedAt,
101
+ deadlineAt: Number.isFinite(timeoutSec) ? new Date(Date.parse(startedAt) + timeoutSec * 1_000).toISOString() : null,
102
+ updatedAt: startedAt,
103
+ closedAt: null,
104
+ exitCode: null,
105
+ signal: null,
106
+ status: "active",
107
+ executable: command.executable,
108
+ };
109
+ /** @type {Job} */
110
+ const job = {
111
+ child,
112
+ contract,
113
+ node,
114
+ state,
115
+ runtime,
116
+ cwd: workspace,
117
+ paths,
118
+ phase,
119
+ invocation,
120
+ startedAt,
121
+ startedTicks: process.hrtime.bigint(),
122
+ progressTicks: process.hrtime.bigint(),
123
+ lastOutputAt: 0,
124
+ closed: false,
125
+ exitCode: null,
126
+ signal: null,
127
+ spawnError: null,
128
+ terminating: null,
129
+ gateConfigPath,
130
+ gateReleasePath,
131
+ onInvocationUpdate,
132
+ onProgress,
133
+ };
134
+ child.once("error", (error) => {
135
+ job.spawnError = error;
136
+ job.closed = true;
137
+ closeInvocation(job);
138
+ });
139
+ child.once("close", (exitCode, signal) => {
140
+ job.exitCode = exitCode;
141
+ job.signal = signal;
142
+ job.closed = true;
143
+ closeInvocation(job);
144
+ });
145
+ try {
146
+ if (typeof onInvocation !== "function") throw new Error("durable invocation persistence callback is required");
147
+ onInvocation(invocation, job);
148
+ signalGate(job.gateReleasePath);
149
+ if (command.promptTransport === "stdin") {
150
+ child.stdin.on("error", () => {});
151
+ child.stdin.end(command.input);
152
+ }
153
+ job.observeTimer = setInterval(() => observeInvocation(job), 25);
154
+ job.observeTimer.unref?.();
155
+ } catch (error) {
156
+ void terminateInvocation(invocation, { graceMs: 100, killGraceMs: 500 }).catch(() => {});
157
+ cleanupGate(job);
158
+ throw error;
159
+ }
160
+ process.stdout.write(`[node] ${node.id} running · ${phase} · ${runtime.id}\n`);
161
+ return job;
162
+ }
163
+ /**
164
+ * @param {Job} job
165
+ */
166
+ function closeInvocation(job) {
167
+ if (job.observeTimer) clearInterval(job.observeTimer);
168
+ job.observeTimer = undefined;
169
+ job.invocation = /** @type {Invocation} */ ({
170
+ ...job.invocation,
171
+ updatedAt: new Date().toISOString(),
172
+ closedAt: new Date().toISOString(),
173
+ exitCode: job.exitCode,
174
+ signal: job.signal,
175
+ status: "closed",
176
+ });
177
+ job.onClose?.(job.invocation);
178
+ cleanupGate(job);
179
+ }
180
+ /**
181
+ * Observe a bounded prefix while the provider is live. Harness normalizers know
182
+ * how to recognize a continuation-start event without runner-specific parsing.
183
+ *
184
+ * @param {Job} job
185
+ */
186
+ function observeInvocation(job) {
187
+ if (job.closed || job.invocation.continuationId) return;
188
+ try {
189
+ const monitored = monitorInvocation(job);
190
+ if (!monitored.continuationId) return;
191
+ job.invocation = {
192
+ ...job.invocation,
193
+ continuationId: monitored.continuationId,
194
+ updatedAt: new Date().toISOString(),
195
+ };
196
+ job.onInvocationUpdate?.(job.invocation);
197
+ } catch {
198
+ // monitorInvocation already swallows its own IO, so the only thing left that
199
+ // can throw here is the caller's onInvocationUpdate: observing a continuation
200
+ // id must not be able to kill the job that is being observed.
201
+ }
202
+ }
203
+ /**
204
+ * Observe the transcript incrementally: read only the bytes appended since
205
+ * the last observation, in fixed-size chunks folded into a parser whose
206
+ * retained state never scales with the unread length — so the metrics
207
+ * survive both a transcript that outgrows any fixed window and an
208
+ * already-large transcript on the first call after a controller restart.
209
+ * The gate caps the log only at close, so byte offsets stay valid while the
210
+ * provider is live. Only newline-terminated records are evidence; a
211
+ * trailing partial record stays unconsumed for the next observation. The
212
+ * generic metrics are zero for a provider that does not expose them.
213
+ *
214
+ * @param {Job} job
215
+ * @returns {{continuationId: string|null, turns: number, cacheReadInputTokens: number, toolCalls: number, completed: boolean}}
216
+ */
217
+ export function monitorInvocation(job) {
218
+ try {
219
+ const parser = job.monitorParser ?? (job.monitorParser = new SessionMetricsParser(job.runtime.harness));
220
+ const size = statSync(job.paths.stdout).size;
221
+ let offset = job.monitorOffset ?? 0;
222
+ let budget = MONITOR_CALL_BUDGET_BYTES;
223
+ if (size > offset) {
224
+ const fd = openSync(job.paths.stdout, "r");
225
+ try {
226
+ const chunk = Buffer.alloc(MONITOR_CHUNK_BYTES);
227
+ while (offset < size && budget > 0) {
228
+ const read = readSync(fd, chunk, 0, Math.min(chunk.length, size - offset, budget), offset);
229
+ if (read <= 0) break;
230
+ parser.push(chunk.subarray(0, read));
231
+ offset += read;
232
+ budget -= read;
233
+ }
234
+ } finally {
235
+ closeSync(fd);
236
+ }
237
+ job.monitorOffset = offset;
238
+ }
239
+ return { continuationId: parser.continuationId, ...parser.metrics() };
240
+ } catch {
241
+ return { continuationId: null, turns: 0, cacheReadInputTokens: 0, toolCalls: 0, completed: false };
242
+ }
243
+ }
244
+ /**
245
+ * @param {string} path
246
+ */
247
+ function signalGate(path) {
248
+ const fd = openSync(path, "wx", 0o600);
249
+ try {
250
+ writeSync(fd, `${Date.now()}\n`, 0, "utf8");
251
+ fsyncSync(fd);
252
+ } finally {
253
+ closeSync(fd);
254
+ }
255
+ }
256
+ /**
257
+ * @param {Job|{gateConfigPath: string, gateReleasePath: string}} job
258
+ */
259
+ function cleanupGate(job) {
260
+ for (const path of [job.gateConfigPath, job.gateReleasePath]) {
261
+ try { unlinkSync(path); } catch (error) {
262
+ if (errorCode(error) !== "ENOENT") throw error;
263
+ }
264
+ }
265
+ }
266
+ /**
267
+ * @param {Job|undefined} job
268
+ * @param {TerminateOptions} options
269
+ * @returns {Promise<void>}
270
+ */
271
+ export async function terminateProcess(job, options = {}) {
272
+ if (!job) return;
273
+ if (job.terminating) return job.terminating;
274
+ const graceMs = options.graceMs ?? DEFAULT_GRACE_MS;
275
+ job.terminating = (async () => {
276
+ const invocation = job.invocation;
277
+ if (!invocationOwned(invocation, { child: job.child })) {
278
+ noteUnverifiableIdentity(job, invocation);
279
+ return;
280
+ }
281
+ if (!signalInvocation(invocation, "SIGTERM", { child: job.child, kill: options.kill })) return;
282
+ if (await waitForJobTermination(job, invocation, graceMs)) return;
283
+ if (options.escalate !== false && process.platform !== "win32") {
284
+ if (!signalInvocation(invocation, "SIGKILL", { child: job.child, kill: options.kill })) return;
285
+ }
286
+ if (await waitForJobTermination(job, invocation, options.killGraceMs ?? graceMs)) return;
287
+ throw new Error(`provider invocation ${invocation.id} did not terminate`);
288
+ })();
289
+ try {
290
+ await job.terminating;
291
+ } finally {
292
+ job.terminating = null;
293
+ }
294
+ }
295
+ /**
296
+ * @param {InvocationProbe & {id?: string}|undefined} invocation
297
+ * @param {TerminateOptions} options
298
+ * @returns {Promise<void>}
299
+ */
300
+ export async function terminateInvocation(invocation, options = {}) {
301
+ if (!invocation) return;
302
+ if (!invocationOwned(invocation, options)) {
303
+ if (options.runDir && invocationAlive(invocation)) recordIdentityUnverifiable(options.runDir, invocation);
304
+ return;
305
+ }
306
+ const graceMs = options.graceMs ?? DEFAULT_GRACE_MS;
307
+ if (!signalInvocation(invocation, "SIGTERM", options)) return;
308
+ if (await waitForInvocationDeath(invocation, graceMs)) return;
309
+ if (options.escalate !== false && process.platform !== "win32") {
310
+ if (!signalInvocation(invocation, "SIGKILL", options)) return;
311
+ }
312
+ if (!await waitForInvocationDeath(invocation, options.killGraceMs ?? graceMs)) {
313
+ throw new Error(`provider invocation ${invocation.id} did not terminate`);
314
+ }
315
+ }
316
+ /**
317
+ * The timeout codes whose attempt workspace is sealed before the provider is
318
+ * killed. Every other retry path already cuts the next attempt from the
319
+ * previous attempt's seal (`dispatch.mjs`'s `sealPreviousAttempt`); without
320
+ * sealing here, a timeout parks with an empty seal and the recorded work is
321
+ * abandoned in a worktree the next attempt never reads.
322
+ */
323
+ const SEAL_BEFORE_KILL_CODES = new Set(["wall_clock_timeout", "stall_timeout"]);
324
+
325
+ /**
326
+ * How long a `SIGSTOP`ped process group is given to actually stop before the
327
+ * seal begins. The stop is asynchronous; this bounded settle keeps the seal
328
+ * from racing a provider that has not yet been suspended. It is deliberately
329
+ * short: the seal's own git timeout is the outer bound.
330
+ */
331
+ const QUIESCE_SETTLE_MS = 50;
332
+
333
+ /**
334
+ * The stall threshold one invocation is judged by: the runtime's own declared
335
+ * `stallTimeoutSec`, else the contract value. Validation guarantees a present
336
+ * runtime value is a positive finite number (`contract/runtime.mjs`), and the
337
+ * contract value is positive by construction, so this always returns a usable
338
+ * number.
339
+ *
340
+ * @param {unknown} runtime
341
+ * @param {ValidatedContract} contract
342
+ * @returns {number}
343
+ */
344
+ export function stallTimeoutSecFor(runtime, contract) {
345
+ const declared = /** @type {{stallTimeoutSec?: unknown}} */ (runtime ?? {}).stallTimeoutSec;
346
+ return typeof declared === "number" && Number.isFinite(declared) && declared > 0
347
+ ? declared
348
+ : contract.stallTimeoutSec;
349
+ }
350
+
351
+ /**
352
+ * Freeze the provider process group so it cannot write while the attempt is
353
+ * sealed. Returns true when a `SIGSTOP` was sent. On win32 there is no
354
+ * process-group stop, so the seal races a live writer and the bounded git
355
+ * timeout is what keeps it from hanging.
356
+ *
357
+ * @param {InvocationProbe & {id?: string}} invocation
358
+ * @param {{child?: ChildProcess|null}} [options]
359
+ * @returns {boolean}
360
+ */
361
+ function quiesceInvocation(invocation, options = {}) {
362
+ if (process.platform === "win32" || !invocationOwned(invocation, options)) return false;
363
+ const target = invocation.processGroupId ?? invocation.pid;
364
+ if (target === null || target === undefined) return false;
365
+ try {
366
+ process.kill(-target, "SIGSTOP");
367
+ return true;
368
+ } catch (error) {
369
+ if (errorCode(error) !== "ESRCH" && errorCode(error) !== "EPERM") throw error;
370
+ return false;
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Release a process group frozen by `quiesceInvocation` so the subsequent
376
+ * `terminateProcess` signal can be delivered. A stopped process holds `SIGTERM`
377
+ * pending until it is continued, so this must run before the kill.
378
+ *
379
+ * @param {InvocationProbe & {id?: string}} invocation
380
+ */
381
+ function resumeInvocation(invocation) {
382
+ if (process.platform === "win32") return;
383
+ const target = invocation.processGroupId ?? invocation.pid;
384
+ if (target === null || target === undefined) return;
385
+ try {
386
+ process.kill(-target, "SIGCONT");
387
+ } catch (error) {
388
+ // ESRCH: the group is already gone. EPERM: it is not one this user owns, so
389
+ // there is nothing to resume.
390
+ if (errorCode(error) !== "ESRCH" && errorCode(error) !== "EPERM") throw error;
391
+ }
392
+ }
393
+
394
+ /**
395
+ * The pre-termination seam, filled: on `wall_clock_timeout` and `stall_timeout`
396
+ * quiesce the provider, seal the attempt worktree, and only then let the caller
397
+ * terminate it, so the next attempt is cut from the seal. The seal is bounded
398
+ * by the same git timeout every synchronous git call uses
399
+ * (`GIT_SYNC_TIMEOUT_MS`, overridable with `FABERUN_GIT_TIMEOUT_MS`);
400
+ * when the provider holds `index.lock` or the seal otherwise fails, the
401
+ * declared outcome is to skip the seal, record `worktree.sealError`, and let
402
+ * the termination proceed — never to hang. An attempt with nothing to seal is
403
+ * left with no `sealedSha`, so phase 2's automatic retry parks it as before.
404
+ *
405
+ * @param {Job} job
406
+ * @param {{code: string, message: string}} timeout
407
+ * @returns {Promise<void>}
408
+ */
409
+ export async function sealBeforeTerminate(job, timeout) {
410
+ if (!SEAL_BEFORE_KILL_CODES.has(timeout.code)) return;
411
+ const path = attemptWorkspace(job.state);
412
+ if (!path || !job.state.worktree?.branch || !job.state.worktree.baseSha) return;
413
+ const runDir = dirname(dirname(job.paths.stdout));
414
+ const quiesced = quiesceInvocation(job.invocation, { child: job.child });
415
+ try {
416
+ if (quiesced) await new Promise((resolve) => setTimeout(resolve, QUIESCE_SETTLE_MS));
417
+ const sealed = sealAttempt({
418
+ repo: job.contract.cwd,
419
+ path,
420
+ baseSha: job.state.worktree.baseSha,
421
+ runId: job.contract.id,
422
+ nodeId: job.node.id,
423
+ attempt: job.state.attempt,
424
+ });
425
+ job.state.worktree = {
426
+ ...job.state.worktree,
427
+ status: "ready",
428
+ commit: sealed.sha,
429
+ // An empty seal is not work: leaving `sealedSha` unset keeps the
430
+ // timeout codes on the parking path phase 2 requires.
431
+ ...(sealed.empty ? {} : { sealedSha: sealed.sha }),
432
+ sealError: null,
433
+ };
434
+ writeNodeSnapshot(runDir, job.state);
435
+ } catch (error) {
436
+ job.state.worktree = {
437
+ ...job.state.worktree,
438
+ sealError: errorMessage(error),
439
+ };
440
+ writeNodeSnapshot(runDir, job.state);
441
+ } finally {
442
+ if (quiesced) resumeInvocation(job.invocation);
443
+ }
444
+ }
445
+
446
+ /**
447
+ * @param {ValidatedContract} contract
448
+ * @param {Map<string, Job>} running
449
+ * @param {(job: Job, outcome: "exhausted"|"stalled", error: {code: string, message: string}) => Promise<void>} onTimeout
450
+ * @param {(job: Job) => Promise<void>|void} [onProgress]
451
+ * @param {(job: Job, timeout: {code: string, message: string}) => Promise<void>|void} [onBeforeTerminate] invoked before the kill; defaults to the phase 5b seal
452
+ */
453
+ export async function detectStalls(contract, running, onTimeout, onProgress, onBeforeTerminate = sealBeforeTerminate) {
454
+ const now = process.hrtime.bigint();
455
+ for (const [nodeId, job] of running) {
456
+ const budgetSec = latestTimeoutSec(job.state, job.node.timeoutSec ?? contract.timeoutSec);
457
+ if (elapsedSeconds(job.startedTicks, now) >= budgetSec) {
458
+ const timeout = {
459
+ code: "wall_clock_timeout",
460
+ message: `${job.phase} ran longer than ${budgetSec}s`,
461
+ };
462
+ await onBeforeTerminate(job, timeout);
463
+ await terminateProcess(job);
464
+ running.delete(nodeId);
465
+ await onTimeout(job, "exhausted", timeout);
466
+ continue;
467
+ }
468
+ // Progress is a provider event, not an mtime: a streamed turn that keeps
469
+ // calling tools is alive even when it writes no workspace file, and a
470
+ // buffered harness (zcode's `--json`) writes its whole transcript only at
471
+ // exit, so its mtime proves nothing. A harness that never streams is
472
+ // stall-tracked only when its runtime declares its own threshold; otherwise
473
+ // the wall clock above is the only budget it is held to.
474
+ const streaming = harnessCapabilities(job.runtime).streamsOutput;
475
+ const declaredStall = typeof (/** @type {{stallTimeoutSec?: unknown}} */ (job.runtime)?.stallTimeoutSec) === "number";
476
+ if (!streaming && !declaredStall) continue;
477
+ const stallTimeoutSec = stallTimeoutSecFor(job.runtime, contract);
478
+ if (streaming) {
479
+ const monitored = monitorInvocation(job);
480
+ const events = monitored.turns + monitored.toolCalls;
481
+ if (events !== job.lastEventCount || job.observedOnce !== true) {
482
+ job.lastEventCount = events;
483
+ job.progressTicks = now;
484
+ // `lastOutputAt` is the supervised controller's provider-progress
485
+ // signal (scheduler.mjs): keep it advancing for an event that counts
486
+ // as liveness, not only for an mtime that no longer does.
487
+ job.lastOutputAt = Date.now();
488
+ }
489
+ } else if (job.observedOnce !== true) {
490
+ job.progressTicks = now;
491
+ job.lastOutputAt = Date.now();
492
+ }
493
+ job.observedOnce = true;
494
+ if (elapsedSeconds(job.progressTicks, now) < stallTimeoutSec) continue;
495
+ const timeout = {
496
+ code: "stall_timeout",
497
+ message: `no provider progress for ${stallTimeoutSec}s`,
498
+ };
499
+ await onBeforeTerminate(job, timeout);
500
+ await terminateProcess(job);
501
+ running.delete(nodeId);
502
+ await onTimeout(job, "stalled", timeout);
503
+ }
504
+ }
505
+ /**
506
+ * @param {InvocationProbe|undefined} invocation
507
+ * @returns {boolean}
508
+ */
509
+ export function invocationAlive(invocation) {
510
+ if (!invocation?.pid || !Number.isInteger(invocation.pid)) return false;
511
+ let leaderAlive = false;
512
+ try {
513
+ process.kill(invocation.pid, 0);
514
+ leaderAlive = true;
515
+ } catch (error) {
516
+ leaderAlive = errorCode(error) === "EPERM";
517
+ }
518
+ if (leaderAlive) return processStartTokenMatches(invocation);
519
+ if (!processGroupAlive(invocation.processGroupId ?? null)) return false;
520
+ return processStartTokenMatches(invocation);
521
+ }
522
+ /**
523
+ * @param {{stdoutPath: string}} invocation
524
+ * @param {HarnessRuntime} runtime
525
+ * @param {import("../harnesses/index.mjs").NormalizeOptions} options
526
+ * @returns {PricedEnvelope|null}
527
+ */
528
+ export function invocationResult(invocation, runtime, options = {}) {
529
+ try {
530
+ const stdout = boundedRegion(invocation.stdoutPath);
531
+ const envelope = normalizeProviderResult(runtime, stdout, options.exitCode ?? 0, options.signal ?? null, options);
532
+ // Price before returning: recovery threads this envelope through its own
533
+ // RecoveryOutcome objects, so the priced fields must be final here rather
534
+ // than recomputed by any caller.
535
+ const priced = priceUsage(runtime, envelope.usage, envelope.costUsd);
536
+ return { ...envelope, costUsd: priced.costUsd, costProvenance: priced.costProvenance };
537
+ } catch {
538
+ return null;
539
+ }
540
+ }
541
+ /** @typedef {{inputPerMTok?: number, cachedInputPerMTok?: number, outputPerMTok?: number}} RuntimePricing */
542
+ /**
543
+ * Price one invocation's canonical counters against the runtime's declared
544
+ * rates. Pure: it reads no clock, disk, or process, and a harness-reported
545
+ * cost -- including a reported zero -- is returned untouched, never re-derived,
546
+ * because provider evidence always wins.
547
+ *
548
+ * A cost is `priced` only when every one of the three counters is a number and
549
+ * every one of those counters has a declared rate. A missing counter is a
550
+ * missing measurement, not a zero contribution, so it keeps the whole record
551
+ * `unknown` (`costUsd: null`) rather than understating it.
552
+ *
553
+ * It lives beside `invocationResult`, the second source point, rather than in
554
+ * `run/usage.mjs`, which re-exports it: that module already imports this one,
555
+ * so defining it here is what keeps the two source points acyclic.
556
+ *
557
+ * @param {unknown} runtime
558
+ * @param {Usage|undefined} usage
559
+ * @param {number|null|undefined} reportedCostUsd
560
+ * @returns {{costUsd: number|null, costProvenance: "priced"|undefined}}
561
+ */
562
+ export function priceUsage(runtime, usage, reportedCostUsd) {
563
+ if (typeof reportedCostUsd === "number") return { costUsd: reportedCostUsd, costProvenance: undefined };
564
+ const pricing = /** @type {{pricing?: RuntimePricing}|null|undefined} */ (runtime)?.pricing;
565
+ if (!pricing) return { costUsd: null, costProvenance: undefined };
566
+ /** @type {[number|null|undefined, number|undefined][]} */
567
+ const terms = [
568
+ [usage?.inputTokens, pricing.inputPerMTok],
569
+ [usage?.cacheReadInputTokens, pricing.cachedInputPerMTok],
570
+ [usage?.outputTokens, pricing.outputPerMTok],
571
+ ];
572
+ let total = 0;
573
+ for (const [counter, rate] of terms) {
574
+ if (typeof counter !== "number" || typeof rate !== "number") return { costUsd: null, costProvenance: undefined };
575
+ total += counter * rate;
576
+ }
577
+ return { costUsd: total / 1_000_000, costProvenance: "priced" };
578
+ }
579
+ /**
580
+ * @param {string} path
581
+ * @param {number} maxBytes
582
+ * @returns {string}
583
+ */
584
+ function boundedRegion(path, maxBytes = MAX_PROVIDER_LOG_BYTES) {
585
+ try {
586
+ return dropPartialLogLine(readFileSync(`${path}.tail`, "utf8"));
587
+ } catch (error) {
588
+ if (errorCode(error) !== "ENOENT") throw error;
589
+ }
590
+ const size = statSync(path).size;
591
+ if (size <= maxBytes) return readFileSync(path, "utf8");
592
+ const fd = openSync(path, "r");
593
+ try {
594
+ const bytes = Buffer.alloc(maxBytes);
595
+ readSync(fd, bytes, 0, maxBytes, size - maxBytes);
596
+ return dropPartialLogLine(bytes.toString("utf8"));
597
+ } finally {
598
+ closeSync(fd);
599
+ }
600
+ }
601
+ /**
602
+ * Signal the invocation's process group only when ownership is proven. Never
603
+ * throws: ESRCH is gone, EPERM is a group this user cannot signal and therefore
604
+ * never spawned, and both mean the caller must treat the invocation as already
605
+ * gone rather than crash the drive loop.
606
+ *
607
+ * @param {InvocationProbe & {id?: string}} invocation
608
+ * @param {string} signal
609
+ * @param {{child?: ChildProcess|null, kill?: (pid: number, signal: string|number) => unknown}} [options]
610
+ * @returns {boolean} whether a signal was delivered
611
+ */
612
+ function signalInvocation(invocation, signal, options = {}) {
613
+ if (!invocationOwned(invocation, options)) return false;
614
+ const pid = invocation.pid;
615
+ if (pid === null || pid === undefined) return false;
616
+ const target = process.platform === "win32" ? pid : -(invocation.processGroupId ?? pid);
617
+ const kill = options.kill ?? process.kill;
618
+ try {
619
+ kill(target, signal);
620
+ return true;
621
+ } catch {
622
+ // ESRCH: the process or group is already gone. EPERM: a group this user
623
+ // cannot signal is not one this controller spawned. Neither is a controller
624
+ // failure, so neither may escape as an exception.
625
+ return false;
626
+ }
627
+ }
628
+ /**
629
+ * Record that a signal was withheld because ownership could not be proven, but
630
+ * only while the raw probe still sees a leader or group: a genuinely gone
631
+ * process needs no line.
632
+ *
633
+ * @param {Job} job
634
+ * @param {InvocationProbe} invocation
635
+ */
636
+ function noteUnverifiableIdentity(job, invocation) {
637
+ if (!invocationAlive(invocation)) return;
638
+ recordIdentityUnverifiable(runDirForJob(job), invocation);
639
+ }
640
+ /**
641
+ * A job's log directory is `<runDir>/logs`, so two dirnames recover the run.
642
+ *
643
+ * @param {Job} job
644
+ * @returns {string|null}
645
+ */
646
+ function runDirForJob(job) {
647
+ const stdout = job.paths?.stdout;
648
+ return typeof stdout === "string" ? dirname(dirname(stdout)) : null;
649
+ }
650
+ /**
651
+ * @param {string|null} runDir
652
+ * @param {InvocationProbe} invocation
653
+ */
654
+ function recordIdentityUnverifiable(runDir, invocation) {
655
+ if (!runDir) return;
656
+ try {
657
+ appendJsonl(join(runDir, "events.jsonl"), {
658
+ type: "invocation_identity_unverifiable",
659
+ at: new Date().toISOString(),
660
+ invocationId: /** @type {{id?: string}} */ (invocation).id ?? null,
661
+ pid: invocation.pid,
662
+ processGroupId: invocation.processGroupId ?? null,
663
+ });
664
+ } catch {
665
+ // The line is diagnostic; a failed append must never turn "we declined to
666
+ // signal an unverified group" into a controller crash.
667
+ }
668
+ }
669
+ /**
670
+ * @param {Job} job
671
+ * @param {number} timeoutMs
672
+ * @returns {Promise<boolean>}
673
+ */
674
+ function waitForJobClose(job, timeoutMs) {
675
+ if (job.closed) return Promise.resolve(true);
676
+ return new Promise((resolve) => {
677
+ const timer = setTimeout(() => resolve(false), timeoutMs);
678
+ const previous = job.onClose;
679
+ job.onClose = (invocation) => {
680
+ previous?.(invocation);
681
+ clearTimeout(timer);
682
+ resolve(true);
683
+ };
684
+ });
685
+ }
686
+ /**
687
+ * @param {Job} job
688
+ * @param {Invocation} invocation
689
+ * @param {number} timeoutMs
690
+ * @returns {Promise<boolean>}
691
+ */
692
+ async function waitForJobTermination(job, invocation, timeoutMs) {
693
+ const [closed, dead] = await Promise.all([
694
+ waitForJobClose(job, timeoutMs),
695
+ waitForInvocationDeath(invocation, timeoutMs),
696
+ ]);
697
+ return closed && dead;
698
+ }
699
+ /**
700
+ * @param {InvocationProbe} invocation
701
+ * @param {number} timeoutMs
702
+ * @returns {Promise<boolean>}
703
+ */
704
+ async function waitForInvocationDeath(invocation, timeoutMs) {
705
+ const deadline = Date.now() + timeoutMs;
706
+ while (Date.now() < deadline) {
707
+ if (!invocationAlive(invocation)) return true;
708
+ await new Promise((resolve) => setTimeout(resolve, 50));
709
+ }
710
+ return !invocationAlive(invocation);
711
+ }
712
+ /**
713
+ * @param {bigint} fromTicks
714
+ * @param {bigint} toTicks
715
+ * @returns {number}
716
+ */
717
+ function elapsedSeconds(fromTicks, toTicks) {
718
+ return Number(toTicks - fromTicks) / 1e9;
719
+ }
720
+ /**
721
+ * @param {string} runDir
722
+ * @param {string} nodeId
723
+ * @param {string} phase
724
+ * @param {number} attempt
725
+ * @returns {PathSet}
726
+ */
727
+ export function logPaths(runDir, nodeId, phase, attempt) {
728
+ const base = `${nodeId}.${attempt}.${phase}`;
729
+ let stem = base;
730
+ /**
731
+ * @param {string} candidate
732
+ * @returns {boolean}
733
+ */
734
+ const occupied = (candidate) => ["prompt", "jsonl", "err"].some((suffix) => existsSync(join(runDir, "logs", `${candidate}.${suffix}`)));
735
+ for (let generation = 2; occupied(stem); generation += 1) stem = `${base}.r${generation}`;
736
+ return {
737
+ prompt: join(runDir, "logs", `${stem}.prompt`),
738
+ stdout: join(runDir, "logs", `${stem}.jsonl`),
739
+ stderr: join(runDir, "logs", `${stem}.err`),
740
+ };
741
+ }
742
+ /**
743
+ * @param {string} path
744
+ * @param {number} [maxBytes]
745
+ * @returns {string}
746
+ */
747
+ export function readBoundedTail(path, maxBytes = 512 * 1024) {
748
+ try {
749
+ try { return dropPartialLogLine(readFileSync(`${path}.tail`, "utf8")); } catch (tailError) {
750
+ if (errorCode(tailError) !== "ENOENT") throw tailError;
751
+ }
752
+ const size = statSync(path).size;
753
+ if (size <= maxBytes) return readFileSync(path, "utf8");
754
+ const fd = openSync(path, "r");
755
+ try {
756
+ const bytes = Buffer.alloc(maxBytes);
757
+ readSync(fd, bytes, 0, maxBytes, size - maxBytes);
758
+ return dropPartialLogLine(bytes.toString("utf8"));
759
+ } finally {
760
+ closeSync(fd);
761
+ }
762
+ } catch (error) {
763
+ if (errorCode(error) === "ENOENT") return "";
764
+ throw error;
765
+ }
766
+ }
767
+ /**
768
+ * @param {unknown} value
769
+ * @returns {string}
770
+ */
771
+ function dropPartialLogLine(value) {
772
+ const newline = String(value).indexOf("\n");
773
+ return newline < 0 ? "" : String(value).slice(newline + 1);
774
+ }