@juzser/blacksmith 0.1.1 → 0.2.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.
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { unreadTaskBudgetFields } from './budgets.js';
4
+ import { claimCoversPath } from './claims.js';
4
5
  import { SmithError } from './errors.js';
5
6
  import { topoSort } from './graph.js';
6
7
  import { SPECS_ACTIVE_DIR } from './paths.js';
@@ -170,6 +171,62 @@ function describeAllowed(err) {
170
171
  * validateRequiredDimensions directly rather than acquiring a second register
171
172
  * of what an edge must carry, which could then drift from taxonomy.yml's.
172
173
  */
174
+ const GLOB_CHARS = /[*?[{]/;
175
+ /**
176
+ * A `keeps_exports` promise is a file the task swears to keep the exports of,
177
+ * and the post-run verifier reads it back against the task's diff. Three
178
+ * things make a promise unverifiable, and each is caught here, at plan time:
179
+ * a pattern (the verifier diffs one file, not a glob), a file outside the
180
+ * task's claims (the task cannot edit it, so its diff never shows it), and a
181
+ * file listed twice (which entry did the verifier report?). Shape is the
182
+ * schema's business; when the schema already rejected the field, this reads
183
+ * nothing and reports nothing, so the operator sees one error, not two.
184
+ */
185
+ function unkeptPromises(t, schemaIssues) {
186
+ const promises = t.keeps_exports;
187
+ if (promises === undefined)
188
+ return [];
189
+ if (schemaIssues.some((issue) => issue.path.startsWith('/keeps_exports')))
190
+ return [];
191
+ if (!Array.isArray(promises) || !promises.every((p) => typeof p === 'string')) {
192
+ return [{ path: '/keeps_exports', message: 'keeps_exports is not a list of file paths.' }];
193
+ }
194
+ const claims = Array.isArray(t.claims)
195
+ ? t.claims.filter((c) => typeof c === 'string')
196
+ : [];
197
+ const issues = [];
198
+ const seen = new Set();
199
+ promises.forEach((promise, index) => {
200
+ const at = `/keeps_exports/${index}`;
201
+ if (promise === '') {
202
+ issues.push({
203
+ path: at,
204
+ message: `keeps_exports entry ${index} is an empty string, not a file path.`,
205
+ });
206
+ return;
207
+ }
208
+ if (GLOB_CHARS.test(promise)) {
209
+ issues.push({
210
+ path: at,
211
+ message: `keeps_exports entry "${promise}" is a pattern: a promise names one file, not a pattern.`,
212
+ });
213
+ return;
214
+ }
215
+ if (!claims.some((claim) => claimCoversPath(claim, promise))) {
216
+ issues.push({
217
+ path: at,
218
+ message: `keeps_exports entry "${promise}" lies outside the task's claims: a task can only keep the exports of a file it may edit.`,
219
+ });
220
+ return;
221
+ }
222
+ if (seen.has(promise)) {
223
+ issues.push({ path: at, message: `keeps_exports entry "${promise}" is listed twice.` });
224
+ return;
225
+ }
226
+ seen.add(promise);
227
+ });
228
+ return issues;
229
+ }
173
230
  export function validatePlan(plan, opts = {}) {
174
231
  const { taxonomy, schemas } = resolveTaxonomyAndSchemas(opts);
175
232
  const errors = [];
@@ -192,6 +249,9 @@ export function validatePlan(plan, opts = {}) {
192
249
  errors.push({ path: `/tasks/${t.task_id}${issue.path}`, message: issue.message });
193
250
  }
194
251
  }
252
+ for (const issue of unkeptPromises(t, result.valid ? [] : result.errors)) {
253
+ errors.push({ path: `/tasks/${t.task_id}${issue.path}`, message: issue.message });
254
+ }
195
255
  if (t.plan_version !== plan.version) {
196
256
  errors.push({
197
257
  path: `/tasks/${t.task_id}/plan_version`,
@@ -3,7 +3,7 @@
3
3
  // providers.md). Prompt delivery: STDIN, not a temp file — no filesystem
4
4
  // cleanup/race to manage, and a CLI judge that reads a prompt off stdin
5
5
  // (same as an interactive paste) needs no extra flag wiring per provider.
6
- import { spawn } from 'node:child_process';
6
+ import { spawnCapped } from '../spawn.js';
7
7
  import { extractAndValidate } from './schema-validate.js';
8
8
  import { ProviderError } from './types.js';
9
9
  const NUDGE = '\n\nReturn only valid JSON per schema.';
@@ -40,80 +40,14 @@ function diagnosticFor(outcome) {
40
40
  return combined === '' ? errOnly : `${errOnly} | ${combined}`;
41
41
  }
42
42
  /**
43
- * One CLI judge invocation: detached process group (POSIX `setsid` via
44
- * `detached: true`) + group-kill on timeout or size-cap breach — mirrors
45
- * testgate.ts's runOne() exactly (a judge CLI can spawn its own child
46
- * processes; a plain per-child kill would orphan them the same way
47
- * testgate's own regression note describes).
43
+ * One CLI judge invocation via the shared capped spawn (src/spawn.ts). No
44
+ * `cwd` in the call: a CLI judge transport is handed a prompt and nothing
45
+ * else — architecture §18 rule 5, "judges never gain write access" — and a
46
+ * process started with no working directory of its own has no worktree to
47
+ * write into even if its command line wanted one.
48
48
  */
49
49
  function spawnOnce(config, prompt, budget) {
50
- return new Promise((resolve) => {
51
- const child = spawn(config.command, config.args, {
52
- detached: true,
53
- stdio: ['pipe', 'pipe', 'pipe'],
54
- });
55
- let combined = '';
56
- let stderr = '';
57
- let totalBytes = 0;
58
- let timedOut = false;
59
- let sizeExceeded = false;
60
- let settled = false;
61
- let spawnError;
62
- const killGroup = () => {
63
- if (child.pid !== undefined) {
64
- try {
65
- process.kill(-child.pid, 'SIGKILL');
66
- }
67
- catch {
68
- // Group already gone — fine.
69
- }
70
- }
71
- };
72
- const timer = setTimeout(() => {
73
- timedOut = true;
74
- killGroup();
75
- }, budget.timeout_ms);
76
- const onChunk = (chunk) => {
77
- totalBytes += chunk.length;
78
- if (totalBytes > budget.max_output_bytes) {
79
- if (!sizeExceeded) {
80
- sizeExceeded = true;
81
- killGroup();
82
- }
83
- return;
84
- }
85
- combined += chunk.toString('utf8');
86
- };
87
- child.stdout?.on('data', onChunk);
88
- child.stderr?.on('data', (chunk) => {
89
- // Still folded into `combined` for extraction — a judge that writes its
90
- // verdict to stderr has always worked and must keep working. The second
91
- // copy exists only so a refusal reason can be reported on its own.
92
- stderr += chunk.toString('utf8');
93
- onChunk(chunk);
94
- });
95
- child.stdin?.on('error', () => {
96
- // Judge exited before consuming stdin (e.g. a fixture that errors
97
- // immediately) — writing to a closed pipe would otherwise raise an
98
- // unhandled EPIPE; the close handler below still resolves correctly.
99
- });
100
- child.stdin?.write(prompt);
101
- child.stdin?.end();
102
- const finish = (exitCode, signal) => {
103
- if (settled)
104
- return;
105
- settled = true;
106
- clearTimeout(timer);
107
- resolve({ combined, stderr, exitCode, signal, spawnError, timedOut, sizeExceeded });
108
- };
109
- child.on('error', (err) => {
110
- // The child never ran (ENOENT, EACCES, …). Distinct from a child that
111
- // ran and failed: 'close' carries no code in this case.
112
- spawnError = err;
113
- finish(null, null);
114
- });
115
- child.on('close', finish);
116
- });
50
+ return spawnCapped({ command: config.command, args: config.args }, prompt, budget);
117
51
  }
118
52
  /**
119
53
  * The buffer minus the prompt this process just sent, for the extractor to
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ // smith-run: a separate executable, not a `smith` verb. `smith harness plan`
3
+ // renders a WorkerInvocation and stops there — architecture §18 rule 3,
4
+ // "nothing that observes may dispatch". This program is the only thing in
5
+ // this repo that actually starts one, and it does nothing else: no event
6
+ // log, no state/, no DB (see runner.ts's own header and its source-level
7
+ // test in test/runner.test.ts).
8
+ import { readFileSync, writeFileSync } from 'node:fs';
9
+ import { parseArgs } from './args.js';
10
+ import { runInvocation } from './runner.js';
11
+ const HELP = `Usage: smith-run <invocation.json | -> [--prompt-file <path>] [--timeout-ms <n>] [--out <file>]
12
+
13
+ Spawns ONE already-rendered worker invocation (the JSON \`smith harness plan\`
14
+ prints) and reports what happened as JSON. This is not \`smith harness run\`:
15
+ \`smith\` only ever renders an invocation and never starts it (§18 rule 3,
16
+ "nothing that observes may dispatch"), so starting the process an invocation
17
+ describes lives in its own binary instead.
18
+
19
+ Arguments:
20
+ <invocation.json> Path to a rendered WorkerInvocation, or "-" for stdin.
21
+
22
+ Options:
23
+ --prompt-file <path> Read the prompt from this file instead of invocation.promptFile.
24
+ --timeout-ms <n> Override invocation.budget.timeout_ms for this run.
25
+ --out <file> Write the JSON result here instead of stdout.
26
+ --help Show this message.
27
+
28
+ Exit codes:
29
+ 0 completed (exit 0), answer validated against its schema (or no schema set)
30
+ 1 the harness process failed to start, or exited non-zero, or the harness
31
+ reported an error in its output
32
+ 2 completed (exit 0), but schema validation failed
33
+ 3 timed out, or exceeded the output size cap
34
+ `;
35
+ const FLAG_SPEC = new Map([
36
+ ['prompt-file', true],
37
+ ['timeout-ms', true],
38
+ ['out', true],
39
+ ['help', false],
40
+ ]);
41
+ function readInvocation(source) {
42
+ const text = source === '-' ? readFileSync(0, 'utf8') : readFileSync(source, 'utf8');
43
+ return JSON.parse(text);
44
+ }
45
+ /** See HELP's own exit-code table — kept in one place so the two cannot drift. */
46
+ function exitCodeFor(outcome) {
47
+ if (outcome.spawnError !== null)
48
+ return 1;
49
+ if (outcome.timedOut || outcome.sizeExceeded)
50
+ return 3;
51
+ if (outcome.exitCode !== 0)
52
+ return 1;
53
+ // The process exited 0, but its own output reported a failure (a codex
54
+ // top-level `error` event, or claude's `is_error: true`) — only reachable
55
+ // for a clean exit, since a nonzero exitCode above already returned 1.
56
+ if (outcome.harness_error !== null)
57
+ return 1;
58
+ if (outcome.validation !== null && !outcome.validation.ok)
59
+ return 2;
60
+ return 0;
61
+ }
62
+ async function main() {
63
+ const argv = process.argv.slice(2);
64
+ const parsed = parseArgs(argv, FLAG_SPEC);
65
+ if (parsed.flags.help === 'true') {
66
+ process.stdout.write(HELP);
67
+ return 0;
68
+ }
69
+ const source = parsed.positional[0];
70
+ if (source === undefined) {
71
+ process.stderr.write('smith-run: missing <invocation.json | -> argument.\n\n');
72
+ process.stderr.write(HELP);
73
+ return 1;
74
+ }
75
+ let invocation;
76
+ try {
77
+ invocation = readInvocation(source);
78
+ }
79
+ catch (err) {
80
+ const message = err instanceof Error ? err.message : String(err);
81
+ process.stderr.write(`smith-run: could not read/parse invocation: ${message}\n`);
82
+ return 1;
83
+ }
84
+ const timeoutOverride = parsed.flags['timeout-ms'];
85
+ if (timeoutOverride !== undefined && invocation.kind === 'cli') {
86
+ const timeout_ms = Number.parseInt(timeoutOverride, 10);
87
+ if (Number.isNaN(timeout_ms)) {
88
+ process.stderr.write(`smith-run: --timeout-ms must be a number, got "${timeoutOverride}".\n`);
89
+ return 1;
90
+ }
91
+ invocation = { ...invocation, budget: { ...invocation.budget, timeout_ms } };
92
+ }
93
+ const opts = {};
94
+ const promptFile = parsed.flags['prompt-file'];
95
+ if (promptFile !== undefined) {
96
+ try {
97
+ opts.promptText = readFileSync(promptFile, 'utf8');
98
+ }
99
+ catch (err) {
100
+ const message = err instanceof Error ? err.message : String(err);
101
+ process.stderr.write(`smith-run: could not read --prompt-file: ${message}\n`);
102
+ return 1;
103
+ }
104
+ }
105
+ let outcome;
106
+ try {
107
+ outcome = await runInvocation(invocation, opts);
108
+ }
109
+ catch (err) {
110
+ const message = err instanceof Error ? err.message : String(err);
111
+ process.stderr.write(`smith-run: ${message}\n`);
112
+ return 1;
113
+ }
114
+ const json = `${JSON.stringify(outcome, null, 2)}\n`;
115
+ const outFile = parsed.flags.out;
116
+ if (outFile !== undefined) {
117
+ writeFileSync(outFile, json);
118
+ }
119
+ else {
120
+ process.stdout.write(json);
121
+ }
122
+ return exitCodeFor(outcome);
123
+ }
124
+ main().then((code) => {
125
+ process.exitCode = code;
126
+ }, (err) => {
127
+ const message = err instanceof Error ? (err.stack ?? err.message) : String(err);
128
+ process.stderr.write(`smith-run: unexpected error: ${message}\n`);
129
+ process.exitCode = 1;
130
+ });
@@ -0,0 +1,264 @@
1
+ // smith-run's library half (architecture §18 rule 3: "nothing that observes
2
+ // may dispatch"). harness.ts's planWorkerTurn() renders a WorkerInvocation
3
+ // but never spawns it — this is the one function that actually starts a
4
+ // harness program. Imports nothing under src/db/, src/events.ts or
5
+ // src/projector.ts on purpose (see test/runner.test.ts's own source-level
6
+ // check): a function that can start a process has no business also being
7
+ // able to see or extend the event log.
8
+ import { readFileSync } from 'node:fs';
9
+ import path from 'node:path';
10
+ import { SmithError } from './errors.js';
11
+ import { REPO_ROOT } from './paths.js';
12
+ import { extractAndValidate, extractBalancedJson } from './providers/schema-validate.js';
13
+ import { spawnCapped } from './spawn.js';
14
+ export class RunnerError extends SmithError {
15
+ }
16
+ /**
17
+ * harness.yml's own two shipped CLI harnesses both set an explicit
18
+ * `timeout_ms`; this only covers a hand-built WorkerInvocation (or a future
19
+ * policy entry) that leaves it null. 30 minutes, same order of magnitude as
20
+ * those two examples.
21
+ */
22
+ export const RUNNER_DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
23
+ const FRONTMATTER = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/;
24
+ /** Strips a role template's leading `---`-delimited YAML frontmatter, keeping only the markdown body. */
25
+ function stripFrontmatter(templateText) {
26
+ return templateText.replace(FRONTMATTER, '');
27
+ }
28
+ /** Builds the child's environment from the invocation's allowlist only — never the full parent env. */
29
+ function buildEnv(envAllowlist, parentEnv) {
30
+ const env = {};
31
+ for (const name of envAllowlist) {
32
+ const value = parentEnv[name];
33
+ if (value !== undefined)
34
+ env[name] = value;
35
+ }
36
+ return env;
37
+ }
38
+ const DIAGNOSTIC_TAIL_BYTES = 400;
39
+ /** Last few hundred bytes of stderr, collapsed to one line — same convention as cli-transport.ts's tail(). */
40
+ function tail(text) {
41
+ const trimmed = text.trim();
42
+ const cut = trimmed.length > DIAGNOSTIC_TAIL_BYTES ? trimmed.slice(-DIAGNOSTIC_TAIL_BYTES) : trimmed;
43
+ return cut.replace(/\s+/g, ' ');
44
+ }
45
+ /** codex's own `input_tokens` already includes its `cached_input_tokens` — pass through as-is. */
46
+ function normalizeCodexUsage(raw) {
47
+ const input = raw.input_tokens ?? 0;
48
+ const output = raw.output_tokens ?? 0;
49
+ const cached = raw.cached_input_tokens;
50
+ return {
51
+ input_tokens: input,
52
+ output_tokens: output,
53
+ ...(cached !== undefined ? { cached_input_tokens: cached } : {}),
54
+ total_tokens: input + output,
55
+ };
56
+ }
57
+ /**
58
+ * Parses a `codex exec --json` JSONL stream: last `agent_message` item wins,
59
+ * `turn.completed` carries usage, and a top-level `{"type":"error", ...}`
60
+ * line — distinct from a non-fatal `item.completed` item whose own
61
+ * `item.type` is `"error"`, which stays ignored — sets `harness_error` (last
62
+ * one wins, same convention as `agent_message`). The process can still exit
63
+ * 0 and carry both an answer and a harness_error: codex reports partial
64
+ * progress before a failure, and neither field should hide the other.
65
+ */
66
+ function parseCodexJson(combined) {
67
+ let answer = '';
68
+ let usage = null;
69
+ let harness_error = null;
70
+ for (const line of combined.split('\n')) {
71
+ const trimmed = line.trim();
72
+ if (trimmed === '')
73
+ continue;
74
+ let event;
75
+ try {
76
+ event = JSON.parse(trimmed);
77
+ }
78
+ catch {
79
+ continue;
80
+ }
81
+ if (typeof event !== 'object' || event === null)
82
+ continue;
83
+ const record = event;
84
+ if (record.type === 'item.completed') {
85
+ const item = record.item;
86
+ if (item !== undefined && item.type === 'agent_message' && typeof item.text === 'string') {
87
+ answer = item.text;
88
+ }
89
+ }
90
+ else if (record.type === 'turn.completed') {
91
+ const rawUsage = record.usage;
92
+ if (rawUsage !== undefined)
93
+ usage = normalizeCodexUsage(rawUsage);
94
+ }
95
+ else if (record.type === 'error' && typeof record.message === 'string') {
96
+ harness_error = record.message;
97
+ }
98
+ }
99
+ return { answer, usage, harness_error };
100
+ }
101
+ /** Anthropic's raw `input_tokens` excludes cache tokens — fold cache-creation + cache-read into it. */
102
+ function normalizeClaudeUsage(raw) {
103
+ const cached = (raw.cache_creation_input_tokens ?? 0) + (raw.cache_read_input_tokens ?? 0);
104
+ const input = (raw.input_tokens ?? 0) + cached;
105
+ const output = raw.output_tokens ?? 0;
106
+ return {
107
+ input_tokens: input,
108
+ output_tokens: output,
109
+ cached_input_tokens: cached,
110
+ total_tokens: input + output,
111
+ };
112
+ }
113
+ /**
114
+ * Parses a `claude -p --output-format json` single JSON object.
115
+ * `is_error: true` is `claude -p`'s own way of reporting a failed turn while
116
+ * still exiting 0 — `harness_error` prefers `subtype` (e.g.
117
+ * `error_during_execution`), falls back to `result` when that is the only
118
+ * string the payload carries, and otherwise names the shape generically.
119
+ * `answer` still comes from `result` either way, unchanged from today.
120
+ */
121
+ function parseClaudeJson(combined) {
122
+ const value = extractBalancedJson(combined);
123
+ if (typeof value !== 'object' || value === null) {
124
+ return { answer: combined.trim(), usage: null, harness_error: null };
125
+ }
126
+ const record = value;
127
+ const answer = typeof record.result === 'string' ? record.result : combined.trim();
128
+ const rawUsage = record.usage;
129
+ const usage = rawUsage !== undefined ? normalizeClaudeUsage(rawUsage) : null;
130
+ const harness_error = record.is_error === true
131
+ ? typeof record.subtype === 'string' && record.subtype !== ''
132
+ ? record.subtype
133
+ : typeof record.result === 'string' && record.result !== ''
134
+ ? record.result
135
+ : 'claude reported is_error'
136
+ : null;
137
+ return {
138
+ answer,
139
+ usage,
140
+ harness_error,
141
+ ...(typeof record.session_id === 'string' ? { session_id: record.session_id } : {}),
142
+ ...(typeof record.total_cost_usd === 'number' ? { cost_usd: record.total_cost_usd } : {}),
143
+ };
144
+ }
145
+ /** Dispatches on the invocation's declared output mode — never sniffs the payload to guess it. */
146
+ function parseOutput(mode, combined) {
147
+ if (mode === 'codex-json')
148
+ return parseCodexJson(combined);
149
+ if (mode === 'claude-json')
150
+ return parseClaudeJson(combined);
151
+ return { answer: combined.trim(), usage: null, harness_error: null };
152
+ }
153
+ /** Validates the answer against a schema when one is set; reports failure, never throws. */
154
+ function validate(answer, schema) {
155
+ if (schema === null)
156
+ return { validation: null, parsed: null };
157
+ const result = extractAndValidate(answer, schema);
158
+ if (result.valid)
159
+ return { validation: { ok: true }, parsed: result.value };
160
+ const errors = 'errors' in result
161
+ ? result.errors.map((issue) => `${issue.path}: ${issue.message}`)
162
+ : [result.reason];
163
+ return { validation: { ok: false, errors }, parsed: null };
164
+ }
165
+ /**
166
+ * Actually spawns a rendered `WorkerInvocation` — the one thing harness.ts
167
+ * itself is forbidden from doing. Refuses `in-process` invocations: those are
168
+ * Agent-tool subagent turns, not programs smith-run knows how to start.
169
+ */
170
+ export async function runInvocation(invocation, opts = {}) {
171
+ if (invocation.kind === 'in-process') {
172
+ throw new RunnerError('runner.in-process', `Invocation for role "${invocation.role}" is in-process (harness "${invocation.harness}") — ` +
173
+ 'smith-run starts programs, not Agent-tool subagents. In-process harnesses run inside the ' +
174
+ 'orchestrator session itself and have no separate binary for smith-run to spawn.', { harness: invocation.harness, role: invocation.role });
175
+ }
176
+ const now = opts.now ?? Date.now;
177
+ const start = now();
178
+ const prompt = opts.promptText ?? readFileSync(invocation.promptFile, 'utf8');
179
+ const templatePath = path.join(REPO_ROOT, invocation.template);
180
+ const templateBody = stripFrontmatter(readFileSync(templatePath, 'utf8'));
181
+ const stdin = `${templateBody}\n\n${prompt}`;
182
+ const env = buildEnv(invocation.envAllowlist, opts.env ?? process.env);
183
+ const timeout_ms = invocation.budget.timeout_ms ?? RUNNER_DEFAULT_TIMEOUT_MS;
184
+ let outcome;
185
+ try {
186
+ outcome = await spawnCapped({
187
+ command: invocation.command,
188
+ args: [...invocation.args],
189
+ cwd: invocation.cwd ?? undefined,
190
+ env,
191
+ }, stdin, { timeout_ms, max_output_bytes: invocation.budget.max_output_bytes });
192
+ }
193
+ catch (err) {
194
+ // spawnCapped resolves rather than rejects on a spawn failure; this catch
195
+ // is defensive only, kept so a future change there cannot turn into an
196
+ // uncaught rejection here.
197
+ const message = err instanceof Error ? err.message : String(err);
198
+ return {
199
+ harness: invocation.harness,
200
+ role: invocation.role,
201
+ taskId: invocation.taskId,
202
+ exitCode: null,
203
+ signal: null,
204
+ timedOut: false,
205
+ sizeExceeded: false,
206
+ spawnError: message,
207
+ latency_ms: now() - start,
208
+ answer: '',
209
+ parsed: null,
210
+ validation: null,
211
+ usage: null,
212
+ over_budget: null,
213
+ stderr_tail: '',
214
+ harness_error: null,
215
+ };
216
+ }
217
+ const latency_ms = now() - start;
218
+ if (outcome.spawnError !== undefined) {
219
+ const reason = outcome.spawnError.code ?? outcome.spawnError.message;
220
+ return {
221
+ harness: invocation.harness,
222
+ role: invocation.role,
223
+ taskId: invocation.taskId,
224
+ exitCode: null,
225
+ signal: null,
226
+ timedOut: outcome.timedOut,
227
+ sizeExceeded: outcome.sizeExceeded,
228
+ spawnError: reason,
229
+ latency_ms,
230
+ answer: '',
231
+ parsed: null,
232
+ validation: null,
233
+ usage: null,
234
+ over_budget: null,
235
+ stderr_tail: tail(outcome.stderr),
236
+ harness_error: null,
237
+ };
238
+ }
239
+ const { answer, usage, harness_error } = parseOutput(invocation.output, outcome.combined);
240
+ const { validation, parsed } = validate(answer, invocation.schema);
241
+ // Unknown (null) unless the harness both sets a cap and the output mode
242
+ // reported usage to compare it against — never guess a verdict either way.
243
+ const over_budget = invocation.budget.cap_tokens === null || usage === null
244
+ ? null
245
+ : usage.total_tokens > invocation.budget.cap_tokens;
246
+ return {
247
+ harness: invocation.harness,
248
+ role: invocation.role,
249
+ taskId: invocation.taskId,
250
+ exitCode: outcome.exitCode,
251
+ signal: outcome.signal,
252
+ timedOut: outcome.timedOut,
253
+ sizeExceeded: outcome.sizeExceeded,
254
+ spawnError: null,
255
+ latency_ms,
256
+ answer,
257
+ parsed,
258
+ validation,
259
+ usage,
260
+ over_budget,
261
+ stderr_tail: tail(outcome.stderr),
262
+ harness_error,
263
+ };
264
+ }
@@ -0,0 +1,84 @@
1
+ // A capped, detached-group child process — the mechanics both cli-transport.ts
2
+ // (an external judge, no worktree, no cwd) and runner.ts (smith-run, a
3
+ // harness that may hold a worktree) spawn a process the same way. Extracted
4
+ // so the two do not carry two copies of a timeout/size-cap/group-kill
5
+ // implementation that drift the way SpawnOnce's original inline copy would
6
+ // have, the day a second caller needed it.
7
+ import { spawn } from 'node:child_process';
8
+ /**
9
+ * One spawn: detached process group (POSIX `setsid` via `detached: true`) +
10
+ * group-kill on timeout or size-cap breach — mirrors testgate.ts's runOne()
11
+ * exactly (a spawned CLI can start its own children; a plain per-child kill
12
+ * would orphan them the same way testgate's own regression note describes).
13
+ */
14
+ export function spawnCapped(config, stdin, budget) {
15
+ return new Promise((resolve) => {
16
+ const child = spawn(config.command, config.args, {
17
+ cwd: config.cwd,
18
+ env: config.env,
19
+ detached: true,
20
+ stdio: ['pipe', 'pipe', 'pipe'],
21
+ });
22
+ let combined = '';
23
+ let stderr = '';
24
+ let totalBytes = 0;
25
+ let timedOut = false;
26
+ let sizeExceeded = false;
27
+ let settled = false;
28
+ let spawnError;
29
+ const killGroup = () => {
30
+ if (child.pid !== undefined) {
31
+ try {
32
+ process.kill(-child.pid, 'SIGKILL');
33
+ }
34
+ catch {
35
+ // Group already gone — fine.
36
+ }
37
+ }
38
+ };
39
+ const timer = setTimeout(() => {
40
+ timedOut = true;
41
+ killGroup();
42
+ }, budget.timeout_ms);
43
+ const onChunk = (chunk) => {
44
+ totalBytes += chunk.length;
45
+ if (totalBytes > budget.max_output_bytes) {
46
+ if (!sizeExceeded) {
47
+ sizeExceeded = true;
48
+ killGroup();
49
+ }
50
+ return;
51
+ }
52
+ combined += chunk.toString('utf8');
53
+ };
54
+ child.stdout?.on('data', onChunk);
55
+ child.stderr?.on('data', (chunk) => {
56
+ // Still folded into `combined` for extraction — a judge that writes its
57
+ // verdict to stderr has always worked and must keep working. The second
58
+ // copy exists only so a refusal reason can be reported on its own.
59
+ stderr += chunk.toString('utf8');
60
+ onChunk(chunk);
61
+ });
62
+ child.stdin?.on('error', () => {
63
+ // Child exited before consuming stdin (e.g. a fixture that errors
64
+ // immediately) — writing to a closed pipe would otherwise raise an
65
+ // unhandled EPIPE; the close handler below still resolves correctly.
66
+ });
67
+ child.stdin?.write(stdin);
68
+ child.stdin?.end();
69
+ const finish = (exitCode, signal) => {
70
+ if (settled)
71
+ return;
72
+ settled = true;
73
+ clearTimeout(timer);
74
+ resolve({ combined, stderr, exitCode, signal, spawnError, timedOut, sizeExceeded });
75
+ };
76
+ child.on('error', (err) => {
77
+ // The child never ran (ENOENT, EACCES, …). Distinct from a child that
78
+ // ran and failed: 'close' carries no code in this case.
79
+ spawnError = err;
80
+ finish(null, null);
81
+ });
82
+ child.on('close', finish);
83
+ });
84
+ }