@cat-factory/executor-harness 1.50.4 → 1.50.8
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/dist/agent-runner.js +135 -69
- package/dist/claude-stream.js +48 -0
- package/dist/onboarding-preseed.js +67 -0
- package/dist/runner.js +31 -0
- package/dist/subagents.js +206 -0
- package/package.json +3 -3
- package/src/agent-runner.ts +151 -82
- package/src/claude-stream.ts +58 -0
- package/src/onboarding-preseed.ts +78 -0
- package/src/runner.ts +54 -0
- package/src/subagents.ts +276 -0
package/dist/agent-runner.js
CHANGED
|
@@ -2,16 +2,12 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { homedir, tmpdir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
|
+
import { claudeAssistantContent, claudeCallUsage, isObject, numberOf, redactBody, } from './claude-stream.js';
|
|
5
6
|
import { killChildProcess, spawnDetached } from './process.js';
|
|
6
7
|
import { redact, secretsToRedact } from './redact.js';
|
|
8
|
+
import { createSliceTracker, startSubagentWatcher } from './subagents.js';
|
|
9
|
+
import { assertOnboardingKeysCurrent, writeOnboardingPreseed } from './onboarding-preseed.js';
|
|
7
10
|
import { retainSessionTranscripts } from './transcript-retention.js';
|
|
8
|
-
function isObject(value) {
|
|
9
|
-
return typeof value === 'object' && value !== null;
|
|
10
|
-
}
|
|
11
|
-
/** Scrub any leased-credential occurrences from a telemetry body (no-op when none). */
|
|
12
|
-
function redactBody(text, secrets) {
|
|
13
|
-
return secrets.length ? redact(text, secrets) : text;
|
|
14
|
-
}
|
|
15
11
|
/**
|
|
16
12
|
* Fallback token attribution: if a CLI reported a cumulative total but no per-turn
|
|
17
13
|
* usage (so every captured call has zero tokens), pin the whole total onto the LAST
|
|
@@ -34,10 +30,11 @@ function attributeCumulativeUsage(calls, usage) {
|
|
|
34
30
|
* never argv), `onActivity` on every chunk, abort kills the child, and the close
|
|
35
31
|
* handler resolves/rejects. The caller's `onEvent` accumulates the outcome.
|
|
36
32
|
*
|
|
37
|
-
* `prompt` is fed over stdin: for Claude Code that is just the task prompt (the
|
|
38
|
-
* system prompt rides `--append-system-prompt`)
|
|
39
|
-
*
|
|
40
|
-
*
|
|
33
|
+
* `prompt` is fed over stdin: for Claude Code that is normally just the task prompt (the
|
|
34
|
+
* system prompt rides `--append-system-prompt`), unless the system prompt is too large for
|
|
35
|
+
* argv, in which case it is folded into `prompt` (see `carryClaudeSystemPrompt`); for Codex
|
|
36
|
+
* — which has no system-prompt flag — the caller always prepends the composed system prompt
|
|
37
|
+
* so the role + best-practice context is not lost.
|
|
41
38
|
*/
|
|
42
39
|
function streamCli(cli, prompt, opts, env, secrets, onEvent) {
|
|
43
40
|
const { command, args } = cli;
|
|
@@ -122,6 +119,40 @@ function streamCli(cli, prompt, opts, env, secrets, onEvent) {
|
|
|
122
119
|
});
|
|
123
120
|
});
|
|
124
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Fold a composed system prompt into the task prompt so the role + best-practice context
|
|
124
|
+
* rides stdin as a single user turn. Used by the Codex runner (no system-prompt flag) and
|
|
125
|
+
* by the Claude runner's argv-overflow fallback. Empty system prompt ⇒ the task prompt is
|
|
126
|
+
* returned unchanged.
|
|
127
|
+
*/
|
|
128
|
+
function foldSystemPrompt(systemPrompt, userPrompt) {
|
|
129
|
+
return systemPrompt ? `${systemPrompt}\n\n---\n\n${userPrompt}` : userPrompt;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Linux caps a SINGLE argv string at MAX_ARG_STRLEN (32 pages = 128 KiB) — a per-string limit,
|
|
133
|
+
* distinct from (and reached long before) the far larger total ARG_MAX for argv + env combined. A
|
|
134
|
+
* system prompt with best-practice fragments folded in can exceed that per-string cap, and `execve`
|
|
135
|
+
* then fails the whole spawn with `E2BIG` before the agent runs at all — the failure mode seen on
|
|
136
|
+
* the `pr-reviewer` step (a ~150 KiB composed prompt). The binding constraint is that per-string
|
|
137
|
+
* cap; 96 KiB stays comfortably under 128 KiB so the system-prompt argv can never approach it.
|
|
138
|
+
*/
|
|
139
|
+
const MAX_ARGV_STRING_BYTES = 96 * 1024;
|
|
140
|
+
/**
|
|
141
|
+
* Decide how the Claude Code runner carries the composed system prompt. Small prompts ride
|
|
142
|
+
* `--append-system-prompt` (a real system turn, cacheable) as before; a prompt too large for a
|
|
143
|
+
* single argv string is instead folded into the stdin task prompt (like the Codex runner), which
|
|
144
|
+
* has no size ceiling. Pure so the branch is unit-testable without spawning the CLI.
|
|
145
|
+
*/
|
|
146
|
+
export function carryClaudeSystemPrompt(systemPrompt, userPrompt) {
|
|
147
|
+
if (Buffer.byteLength(systemPrompt, 'utf8') <= MAX_ARGV_STRING_BYTES) {
|
|
148
|
+
return {
|
|
149
|
+
appendArgs: ['--append-system-prompt', systemPrompt],
|
|
150
|
+
prompt: userPrompt,
|
|
151
|
+
folded: false,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return { appendArgs: [], prompt: foldSystemPrompt(systemPrompt, userPrompt), folded: true };
|
|
155
|
+
}
|
|
125
156
|
// ---------------------------------------------------------------------------
|
|
126
157
|
// Claude Code
|
|
127
158
|
// ---------------------------------------------------------------------------
|
|
@@ -161,19 +192,47 @@ export async function runClaudeCode(opts) {
|
|
|
161
192
|
const stats = { toolCalls: 0, assistantChars: 0 };
|
|
162
193
|
let summary = '';
|
|
163
194
|
let usage;
|
|
195
|
+
// Decide how the composed system prompt is carried up front, so the telemetry seed below
|
|
196
|
+
// reflects what actually reaches the model: a small prompt rides `--append-system-prompt`
|
|
197
|
+
// (a real system turn), while an argv-overflowing prompt is folded into the first user turn
|
|
198
|
+
// — in which case NO system turn of ours is sent (the `E2BIG` fallback).
|
|
199
|
+
const { appendArgs, prompt, folded } = carryClaudeSystemPrompt(opts.systemPrompt, opts.userPrompt);
|
|
200
|
+
if (folded) {
|
|
201
|
+
opts.log?.warn('system prompt exceeds argv limit; folding into the task prompt', {
|
|
202
|
+
bytes: Buffer.byteLength(opts.systemPrompt, 'utf8'),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
164
205
|
// Reconstruct the full per-call request/response bodies for telemetry from the
|
|
165
206
|
// stream. `--output-format stream-json --verbose` emits each turn as a near-verbatim
|
|
166
207
|
// Anthropic Messages envelope, so `assistant` events carry the complete response
|
|
167
208
|
// (text + tool_use blocks + usage), and `user` events carry the tool_result blocks
|
|
168
|
-
// fed back — together the growing prompt transcript. We seed it with the
|
|
169
|
-
//
|
|
170
|
-
//
|
|
209
|
+
// fed back — together the growing prompt transcript. We seed it with the inputs the
|
|
210
|
+
// harness supplies (they never appear in the stream): the system + first user message
|
|
211
|
+
// when the prompt rides argv, or a single folded user turn when it doesn't — so the
|
|
212
|
+
// reconstruction never shows a system turn that was never sent. Bodies are
|
|
213
|
+
// credential-scrubbed (they can echo the leased token).
|
|
171
214
|
const secrets = opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [];
|
|
172
|
-
const messages =
|
|
173
|
-
{ role: '
|
|
174
|
-
|
|
175
|
-
|
|
215
|
+
const messages = folded
|
|
216
|
+
? [{ role: 'user', content: prompt }]
|
|
217
|
+
: [
|
|
218
|
+
{ role: 'system', content: opts.systemPrompt },
|
|
219
|
+
{ role: 'user', content: opts.userPrompt },
|
|
220
|
+
];
|
|
176
221
|
const calls = [];
|
|
222
|
+
// ADR 0026 D2.1: derive slice progress from the parent stream's `Task` dispatches +
|
|
223
|
+
// their terminal tool_results (both DO appear here — only a subagent's intermediate
|
|
224
|
+
// turns don't). A real parent TodoWrite plan, when the agent writes one, wins; the
|
|
225
|
+
// slice-derived progress is the fallback for the parallel-subagent shape that writes no
|
|
226
|
+
// parent plan (the pr-reviewer failure this fixes).
|
|
227
|
+
const sliceTracker = createSliceTracker();
|
|
228
|
+
let sawTodoPlan = false;
|
|
229
|
+
const emitSliceProgress = () => {
|
|
230
|
+
if (sawTodoPlan || !opts.onProgress)
|
|
231
|
+
return;
|
|
232
|
+
const progress = sliceTracker.progress();
|
|
233
|
+
if (progress)
|
|
234
|
+
opts.onProgress(progress);
|
|
235
|
+
};
|
|
177
236
|
const onEvent = (event) => {
|
|
178
237
|
const type = event.type;
|
|
179
238
|
if (type === 'assistant' && isObject(event.message)) {
|
|
@@ -188,10 +247,14 @@ export async function runClaudeCode(opts) {
|
|
|
188
247
|
block.name === 'TodoWrite' &&
|
|
189
248
|
opts.onProgress) {
|
|
190
249
|
const progress = todosToProgress(block.input?.todos);
|
|
191
|
-
if (progress)
|
|
250
|
+
if (progress) {
|
|
251
|
+
sawTodoPlan = true;
|
|
192
252
|
opts.onProgress(progress);
|
|
253
|
+
}
|
|
193
254
|
}
|
|
194
255
|
}
|
|
256
|
+
sliceTracker.onAssistant(content);
|
|
257
|
+
emitSliceProgress();
|
|
195
258
|
// Record this call BEFORE appending its turn: the prompt is the history that
|
|
196
259
|
// produced this response. The append-only array keeps each call's prompt a strict
|
|
197
260
|
// prefix of the next, so the backend's telemetry chain delta-compresses cleanly.
|
|
@@ -212,8 +275,11 @@ export async function runClaudeCode(opts) {
|
|
|
212
275
|
else if (type === 'user' && isObject(event.message)) {
|
|
213
276
|
// tool_result blocks the harness fed back to the model — part of the next prompt.
|
|
214
277
|
const content = event.message.content;
|
|
215
|
-
if (Array.isArray(content))
|
|
278
|
+
if (Array.isArray(content)) {
|
|
279
|
+
sliceTracker.onUser(content);
|
|
280
|
+
emitSliceProgress();
|
|
216
281
|
messages.push({ role: 'tool', content });
|
|
282
|
+
}
|
|
217
283
|
}
|
|
218
284
|
else if (type === 'result') {
|
|
219
285
|
if (typeof event.result === 'string')
|
|
@@ -239,12 +305,12 @@ export async function runClaudeCode(opts) {
|
|
|
239
305
|
// as already accepted so `-p` starts straight into the run. Best-effort: written
|
|
240
306
|
// before the CLI starts; unknown keys are harmless if a CLI version ignores them.
|
|
241
307
|
// (Ambient mode skips this — the developer's own config is already onboarded.)
|
|
308
|
+
// ADR 0026 D4: assert the pinned onboarding keys landed and log them with the CLI
|
|
309
|
+
// version, so a future first-run gate this set doesn't cover (which looks identical to
|
|
310
|
+
// a healthy-but-quiet subagent start) is diffable when the cold-start watchdog fires.
|
|
242
311
|
if (configHome) {
|
|
243
|
-
await
|
|
244
|
-
|
|
245
|
-
bypassPermissionsModeAccepted: true,
|
|
246
|
-
hasTrustDialogAccepted: true,
|
|
247
|
-
}), { mode: 0o600 }).catch(() => { });
|
|
312
|
+
await writeOnboardingPreseed(configHome);
|
|
313
|
+
await assertOnboardingKeysCurrent(configHome, process.env.CLAUDE_CLI_VERSION, opts.log);
|
|
248
314
|
}
|
|
249
315
|
// Repo-sourced Claude Skill (slice 2): install it as a native skill under the config dir's
|
|
250
316
|
// `skills/<name>/` so the CLI discovers and can invoke it. Written to the isolated per-run
|
|
@@ -271,6 +337,19 @@ export async function runClaudeCode(opts) {
|
|
|
271
337
|
}
|
|
272
338
|
: { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken }),
|
|
273
339
|
};
|
|
340
|
+
// ADR 0026 D2.1/D3: while the run is live, tail the CLI's `subagents/*.jsonl`
|
|
341
|
+
// transcripts (under the isolated config home) so a parallel-subagent review keeps the
|
|
342
|
+
// inactivity heartbeat alive (any new bytes ⇒ `onActivity`) and its otherwise-invisible
|
|
343
|
+
// token spend is lifted into the run's telemetry. Ambient mode has no isolated home to
|
|
344
|
+
// watch. Best-effort — a missing/renamed transcript layout just yields no extra signal.
|
|
345
|
+
const subagents = configHome
|
|
346
|
+
? startSubagentWatcher(join(configHome, 'subagents'), {
|
|
347
|
+
...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
|
|
348
|
+
secrets,
|
|
349
|
+
model: opts.model,
|
|
350
|
+
...(opts.log ? { log: opts.log } : {}),
|
|
351
|
+
})
|
|
352
|
+
: undefined;
|
|
274
353
|
try {
|
|
275
354
|
const { stderrTail } = await streamCli({
|
|
276
355
|
command: 'claude',
|
|
@@ -287,20 +366,43 @@ export async function runClaudeCode(opts) {
|
|
|
287
366
|
'bypassPermissions',
|
|
288
367
|
'--model',
|
|
289
368
|
opts.model,
|
|
290
|
-
|
|
291
|
-
opts.systemPrompt,
|
|
369
|
+
...appendArgs,
|
|
292
370
|
],
|
|
293
|
-
},
|
|
371
|
+
}, prompt, opts, env, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
|
|
372
|
+
// The parent's cumulative-usage fallback applies to the PARENT calls only (before the
|
|
373
|
+
// subagent calls, which carry their own per-turn tokens, are concatenated).
|
|
294
374
|
attributeCumulativeUsage(calls, usage);
|
|
375
|
+
// Final drain of any subagent transcript writes that landed after the last poll, then
|
|
376
|
+
// fold the subagents' usage + per-call telemetry into the run's outcome — their tokens
|
|
377
|
+
// never appear on the parent stream, so this is the only place they are accounted.
|
|
378
|
+
await subagents?.stop();
|
|
379
|
+
const subUsage = subagents?.usage() ?? { inputTokens: 0, outputTokens: 0 };
|
|
380
|
+
const subCalls = subagents?.calls() ?? [];
|
|
381
|
+
const mergedCalls = [...calls, ...subCalls];
|
|
382
|
+
// INVARIANT (do not "fix" this into a double count): the run total is the parent usage
|
|
383
|
+
// PLUS the subagent usage because the two are disjoint sources. The parent `usage` here
|
|
384
|
+
// is the terminal `result` event's cumulative, which covers ONLY the parent loop — the
|
|
385
|
+
// ADR 0026 incident is itself the proof: a heavily subagent-parallelised review reported
|
|
386
|
+
// ~0 tokens, i.e. the parent stream (and its `result` total) never included the subagent
|
|
387
|
+
// spend. The subagent tokens live exclusively in the `subagents/*.jsonl` transcripts (a
|
|
388
|
+
// directory distinct from the parent's `projects/` session transcript), which the watcher
|
|
389
|
+
// reads and nothing else does — so neither `calls` nor `usage` can already contain them.
|
|
390
|
+
const mergedUsage = usage || subUsage.inputTokens || subUsage.outputTokens
|
|
391
|
+
? {
|
|
392
|
+
inputTokens: (usage?.inputTokens ?? 0) + subUsage.inputTokens,
|
|
393
|
+
outputTokens: (usage?.outputTokens ?? 0) + subUsage.outputTokens,
|
|
394
|
+
}
|
|
395
|
+
: undefined;
|
|
295
396
|
return {
|
|
296
397
|
summary,
|
|
297
398
|
stats,
|
|
298
399
|
stderrTail,
|
|
299
|
-
...(
|
|
300
|
-
...(
|
|
400
|
+
...(mergedUsage ? { usage: mergedUsage } : {}),
|
|
401
|
+
...(mergedCalls.length ? { callMetrics: mergedCalls } : {}),
|
|
301
402
|
};
|
|
302
403
|
}
|
|
303
404
|
finally {
|
|
405
|
+
await subagents?.stop();
|
|
304
406
|
if (configHome) {
|
|
305
407
|
// Lift the CLI session transcripts (`projects/`) out for short-lived retention BEFORE the
|
|
306
408
|
// home is deleted — the credential lives at the home root, never in `projects/`, so this
|
|
@@ -348,38 +450,6 @@ function claudeUsage(raw) {
|
|
|
348
450
|
return undefined;
|
|
349
451
|
return { inputTokens: input, outputTokens: output };
|
|
350
452
|
}
|
|
351
|
-
/** Pull the text + reasoning out of a Claude `assistant` message's content blocks. */
|
|
352
|
-
function claudeAssistantContent(content) {
|
|
353
|
-
let text = '';
|
|
354
|
-
let reasoning = '';
|
|
355
|
-
let toolUses = 0;
|
|
356
|
-
for (const block of content) {
|
|
357
|
-
if (!isObject(block))
|
|
358
|
-
continue;
|
|
359
|
-
if (block.type === 'text' && typeof block.text === 'string')
|
|
360
|
-
text += block.text;
|
|
361
|
-
else if (block.type === 'thinking' && typeof block.thinking === 'string')
|
|
362
|
-
reasoning += block.thinking;
|
|
363
|
-
else if (block.type === 'tool_use')
|
|
364
|
-
toolUses += 1;
|
|
365
|
-
}
|
|
366
|
-
return { text, reasoning, toolUses };
|
|
367
|
-
}
|
|
368
|
-
/**
|
|
369
|
-
* Per-CALL token usage off a Claude `assistant` message's `usage` (this turn only, not
|
|
370
|
-
* the cumulative `result` total). `inputTokens` counts every billed input bucket (fresh
|
|
371
|
-
* + both cache buckets); `cachedInputTokens` is the cache share, surfaced separately.
|
|
372
|
-
*/
|
|
373
|
-
function claudeCallUsage(raw) {
|
|
374
|
-
if (!isObject(raw))
|
|
375
|
-
return { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
|
|
376
|
-
const cached = numberOf(raw.cache_read_input_tokens) + numberOf(raw.cache_creation_input_tokens);
|
|
377
|
-
return {
|
|
378
|
-
inputTokens: numberOf(raw.input_tokens) + cached,
|
|
379
|
-
cachedInputTokens: cached,
|
|
380
|
-
outputTokens: numberOf(raw.output_tokens),
|
|
381
|
-
};
|
|
382
|
-
}
|
|
383
453
|
// ---------------------------------------------------------------------------
|
|
384
454
|
// Codex
|
|
385
455
|
// ---------------------------------------------------------------------------
|
|
@@ -422,10 +492,9 @@ export async function runCodex(opts) {
|
|
|
422
492
|
await writeFile(join(codexHome, 'config.toml'), 'cli_auth_credentials_store = "file"\n', 'utf8');
|
|
423
493
|
}
|
|
424
494
|
// Codex has no system-prompt flag, so fold the composed role + best-practice
|
|
425
|
-
// context into the prompt itself (Claude Code instead rides --append-system-prompt
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
: opts.userPrompt;
|
|
495
|
+
// context into the prompt itself (Claude Code instead rides --append-system-prompt,
|
|
496
|
+
// falling back to this same fold when the prompt overflows argv).
|
|
497
|
+
const prompt = foldSystemPrompt(opts.systemPrompt, opts.userPrompt);
|
|
429
498
|
// Codex's `exec --json` is far thinner than Claude Code's stream: it surfaces only
|
|
430
499
|
// flat assistant text and (on `token_count` events) the per-turn `last_token_usage`
|
|
431
500
|
// plus a cumulative total. It never exposes the request transcript or structured
|
|
@@ -622,9 +691,6 @@ function codexLastTurnUsage(event) {
|
|
|
622
691
|
return undefined;
|
|
623
692
|
return { inputTokens: input, cachedInputTokens: cached, outputTokens: output };
|
|
624
693
|
}
|
|
625
|
-
function numberOf(value) {
|
|
626
|
-
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
627
|
-
}
|
|
628
694
|
/** Dispatch to the configured subscription harness runner. */
|
|
629
695
|
export function runSubscriptionHarness(harness, opts) {
|
|
630
696
|
return harness === 'claude-code' ? runClaudeCode(opts) : runCodex(opts);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { redact } from './redact.js';
|
|
2
|
+
// Shared parsing of Claude Code's stream-json / session-transcript envelope. The parent
|
|
3
|
+
// runner (`agent-runner.ts`) reads these off the CLI's stdout; the subagent watcher
|
|
4
|
+
// (`subagents.ts`) reads the same shapes off the `subagents/*.jsonl` transcripts. Kept in
|
|
5
|
+
// one place so both read usage/content identically and the cycle between the two modules
|
|
6
|
+
// is broken.
|
|
7
|
+
export function isObject(value) {
|
|
8
|
+
return typeof value === 'object' && value !== null;
|
|
9
|
+
}
|
|
10
|
+
export function numberOf(value) {
|
|
11
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
12
|
+
}
|
|
13
|
+
/** Scrub any leased-credential occurrences from a telemetry body (no-op when none). */
|
|
14
|
+
export function redactBody(text, secrets) {
|
|
15
|
+
return secrets.length ? redact(text, secrets) : text;
|
|
16
|
+
}
|
|
17
|
+
/** Pull the text + reasoning out of a Claude `assistant` message's content blocks. */
|
|
18
|
+
export function claudeAssistantContent(content) {
|
|
19
|
+
let text = '';
|
|
20
|
+
let reasoning = '';
|
|
21
|
+
let toolUses = 0;
|
|
22
|
+
for (const block of content) {
|
|
23
|
+
if (!isObject(block))
|
|
24
|
+
continue;
|
|
25
|
+
if (block.type === 'text' && typeof block.text === 'string')
|
|
26
|
+
text += block.text;
|
|
27
|
+
else if (block.type === 'thinking' && typeof block.thinking === 'string')
|
|
28
|
+
reasoning += block.thinking;
|
|
29
|
+
else if (block.type === 'tool_use')
|
|
30
|
+
toolUses += 1;
|
|
31
|
+
}
|
|
32
|
+
return { text, reasoning, toolUses };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Per-CALL token usage off a Claude `assistant` message's `usage` (this turn only, not
|
|
36
|
+
* the cumulative `result` total). `inputTokens` counts every billed input bucket (fresh
|
|
37
|
+
* + both cache buckets); `cachedInputTokens` is the cache share, surfaced separately.
|
|
38
|
+
*/
|
|
39
|
+
export function claudeCallUsage(raw) {
|
|
40
|
+
if (!isObject(raw))
|
|
41
|
+
return { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
|
|
42
|
+
const cached = numberOf(raw.cache_read_input_tokens) + numberOf(raw.cache_creation_input_tokens);
|
|
43
|
+
return {
|
|
44
|
+
inputTokens: numberOf(raw.input_tokens) + cached,
|
|
45
|
+
cachedInputTokens: cached,
|
|
46
|
+
outputTokens: numberOf(raw.output_tokens),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
// ADR 0026 D4 (paired assertion). A brand-new Claude Code config home would otherwise
|
|
4
|
+
// make `claude -p` block on the interactive onboarding / "trust this folder" /
|
|
5
|
+
// bypass-permissions acknowledgement prompts — which never get answered headlessly,
|
|
6
|
+
// hanging the job until the inactivity watchdog kills it with no output. We pre-seed a
|
|
7
|
+
// `.claude.json` marking those gates as already accepted.
|
|
8
|
+
//
|
|
9
|
+
// The hazard the ADR calls out: if a future CLI version adds a NEW first-run gate this
|
|
10
|
+
// set does not cover, the symptom is identical to a healthy-but-quiet subagent run (no
|
|
11
|
+
// stdout, low CPU), so the cold-start watchdog can't tell them apart on its own. This
|
|
12
|
+
// module centralises the pre-seeded keys as ONE source of truth and logs the pinned set
|
|
13
|
+
// (with the installed CLI version) so that, when the cold-start watchdog fires, an
|
|
14
|
+
// operator has the exact keys-vs-version pairing to diff against a new gate.
|
|
15
|
+
/**
|
|
16
|
+
* The onboarding gates we pre-accept in a fresh config home. Kept as a single constant so
|
|
17
|
+
* the write and the assertion below can never drift, and so a new gate is added in exactly
|
|
18
|
+
* one place. If the CLI renames/adds a key, this is where the fix lands.
|
|
19
|
+
*/
|
|
20
|
+
export const ONBOARDING_PRESEED_KEYS = {
|
|
21
|
+
hasCompletedOnboarding: true,
|
|
22
|
+
bypassPermissionsModeAccepted: true,
|
|
23
|
+
hasTrustDialogAccepted: true,
|
|
24
|
+
};
|
|
25
|
+
/** Write the onboarding pre-seed into `<configHome>/.claude.json`. Best-effort; never throws. */
|
|
26
|
+
export async function writeOnboardingPreseed(configHome) {
|
|
27
|
+
await writeFile(join(configHome, '.claude.json'), JSON.stringify(ONBOARDING_PRESEED_KEYS), {
|
|
28
|
+
mode: 0o600,
|
|
29
|
+
}).catch(() => { });
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Verify the pre-seed actually landed and log the pinned onboarding keys alongside the
|
|
33
|
+
* installed CLI version — the "one-line assertion after the pre-seed" from D4. It cannot
|
|
34
|
+
* introspect the CLI's true first-run gate set (the CLI never exposes it), so it does the
|
|
35
|
+
* two things it CAN do cheaply and deterministically: confirm every key we intended is
|
|
36
|
+
* present + truthy in the written file (catching a botched write), and emit a structured
|
|
37
|
+
* record pairing the keys with the CLI version so a future onboarding regression — surfaced
|
|
38
|
+
* by the cold-start watchdog as a silent, output-less start — is diffable against a new gate.
|
|
39
|
+
* Best-effort; never throws.
|
|
40
|
+
*/
|
|
41
|
+
export async function assertOnboardingKeysCurrent(configHome, cliVersion, log) {
|
|
42
|
+
const expected = Object.keys(ONBOARDING_PRESEED_KEYS);
|
|
43
|
+
let parsed = {};
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(await readFile(join(configHome, '.claude.json'), 'utf8'));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
log?.warn('onboarding pre-seed could not be read back after write', {
|
|
49
|
+
onboardingKeys: expected,
|
|
50
|
+
...(cliVersion ? { cliVersion } : {}),
|
|
51
|
+
});
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const missing = expected.filter((k) => parsed[k] !== true);
|
|
55
|
+
if (missing.length > 0) {
|
|
56
|
+
log?.warn('onboarding pre-seed is missing expected keys', {
|
|
57
|
+
onboardingKeys: expected,
|
|
58
|
+
missing,
|
|
59
|
+
...(cliVersion ? { cliVersion } : {}),
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
log?.info('onboarding pre-seed applied', {
|
|
64
|
+
onboardingKeys: expected,
|
|
65
|
+
...(cliVersion ? { cliVersion } : {}),
|
|
66
|
+
});
|
|
67
|
+
}
|
package/dist/runner.js
CHANGED
|
@@ -5,6 +5,13 @@ function intEnv(value, fallback) {
|
|
|
5
5
|
const n = value ? Number(value) : NaN;
|
|
6
6
|
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
7
7
|
}
|
|
8
|
+
/** Like {@link intEnv} but allows an explicit 0 (used to DISABLE a window). */
|
|
9
|
+
function intEnvAllowZero(value, fallback) {
|
|
10
|
+
if (value === undefined)
|
|
11
|
+
return fallback;
|
|
12
|
+
const n = Number(value);
|
|
13
|
+
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
|
14
|
+
}
|
|
8
15
|
export function loadRunnerLimits(env = process.env) {
|
|
9
16
|
return {
|
|
10
17
|
// 60 minutes: generous headroom for serious multi-file coding tasks while
|
|
@@ -17,6 +24,9 @@ export function loadRunnerLimits(env = process.env) {
|
|
|
17
24
|
// with git's own clear reason rather than this watchdog's "likely hung" message,
|
|
18
25
|
// for any configured window. See the invariant note in git.ts.
|
|
19
26
|
inactivityMs: intEnv(env.JOB_INACTIVITY_MS, 10 * 60_000),
|
|
27
|
+
// 2 minutes: comfortably longer than a warm agent's time-to-first-token yet far
|
|
28
|
+
// under the 10-minute inactivity kill, so a truly output-less start is flagged early.
|
|
29
|
+
coldStartMs: intEnvAllowZero(env.JOB_COLD_START_MS, 2 * 60_000),
|
|
20
30
|
};
|
|
21
31
|
}
|
|
22
32
|
function toView(entry) {
|
|
@@ -154,7 +164,27 @@ export class JobRegistry {
|
|
|
154
164
|
killReason ??= 'max-duration';
|
|
155
165
|
controller.abort(new Error('max duration exceeded'));
|
|
156
166
|
}, this.limits.maxDurationMs);
|
|
167
|
+
// ADR 0026 D4: a one-shot cold-start watchdog. If the job produces no activity within
|
|
168
|
+
// `coldStartMs`, record a structured diagnostic (a likely onboarding/auth wedge) so it
|
|
169
|
+
// is legible early — it does NOT abort the run (the inactivity watchdog still owns
|
|
170
|
+
// that). Cleared the moment the first activity arrives.
|
|
171
|
+
let sawActivity = false;
|
|
172
|
+
let coldStart;
|
|
173
|
+
if (this.limits.coldStartMs > 0) {
|
|
174
|
+
coldStart = setTimeout(() => {
|
|
175
|
+
if (sawActivity)
|
|
176
|
+
return;
|
|
177
|
+
const secs = Math.round(this.limits.coldStartMs / 1000);
|
|
178
|
+
const message = `agent produced no output ${secs}s after start; possible onboarding/auth wedge (phase: ${phase})`;
|
|
179
|
+
entry.coldStart = { atMs: Date.now(), message };
|
|
180
|
+
jobLog.warn('cold-start: no agent output', { afterMs: this.limits.coldStartMs, phase });
|
|
181
|
+
}, this.limits.coldStartMs);
|
|
182
|
+
}
|
|
157
183
|
const heartbeat = () => {
|
|
184
|
+
if (!sawActivity) {
|
|
185
|
+
sawActivity = true;
|
|
186
|
+
clearTimeout(coldStart);
|
|
187
|
+
}
|
|
158
188
|
entry.heartbeatAt = Date.now();
|
|
159
189
|
resetInactivity();
|
|
160
190
|
};
|
|
@@ -213,6 +243,7 @@ export class JobRegistry {
|
|
|
213
243
|
finally {
|
|
214
244
|
clearTimeout(inactivity);
|
|
215
245
|
clearTimeout(cap);
|
|
246
|
+
clearTimeout(coldStart);
|
|
216
247
|
entry.abort = undefined;
|
|
217
248
|
entry.heartbeatAt = Date.now();
|
|
218
249
|
}
|