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.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- 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
|
+
}
|