@cat-factory/executor-harness 1.50.6 → 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 +70 -51
- 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 +79 -64
- 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
|
|
@@ -223,6 +219,20 @@ export async function runClaudeCode(opts) {
|
|
|
223
219
|
{ role: 'user', content: opts.userPrompt },
|
|
224
220
|
];
|
|
225
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
|
+
};
|
|
226
236
|
const onEvent = (event) => {
|
|
227
237
|
const type = event.type;
|
|
228
238
|
if (type === 'assistant' && isObject(event.message)) {
|
|
@@ -237,10 +247,14 @@ export async function runClaudeCode(opts) {
|
|
|
237
247
|
block.name === 'TodoWrite' &&
|
|
238
248
|
opts.onProgress) {
|
|
239
249
|
const progress = todosToProgress(block.input?.todos);
|
|
240
|
-
if (progress)
|
|
250
|
+
if (progress) {
|
|
251
|
+
sawTodoPlan = true;
|
|
241
252
|
opts.onProgress(progress);
|
|
253
|
+
}
|
|
242
254
|
}
|
|
243
255
|
}
|
|
256
|
+
sliceTracker.onAssistant(content);
|
|
257
|
+
emitSliceProgress();
|
|
244
258
|
// Record this call BEFORE appending its turn: the prompt is the history that
|
|
245
259
|
// produced this response. The append-only array keeps each call's prompt a strict
|
|
246
260
|
// prefix of the next, so the backend's telemetry chain delta-compresses cleanly.
|
|
@@ -261,8 +275,11 @@ export async function runClaudeCode(opts) {
|
|
|
261
275
|
else if (type === 'user' && isObject(event.message)) {
|
|
262
276
|
// tool_result blocks the harness fed back to the model — part of the next prompt.
|
|
263
277
|
const content = event.message.content;
|
|
264
|
-
if (Array.isArray(content))
|
|
278
|
+
if (Array.isArray(content)) {
|
|
279
|
+
sliceTracker.onUser(content);
|
|
280
|
+
emitSliceProgress();
|
|
265
281
|
messages.push({ role: 'tool', content });
|
|
282
|
+
}
|
|
266
283
|
}
|
|
267
284
|
else if (type === 'result') {
|
|
268
285
|
if (typeof event.result === 'string')
|
|
@@ -288,12 +305,12 @@ export async function runClaudeCode(opts) {
|
|
|
288
305
|
// as already accepted so `-p` starts straight into the run. Best-effort: written
|
|
289
306
|
// before the CLI starts; unknown keys are harmless if a CLI version ignores them.
|
|
290
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.
|
|
291
311
|
if (configHome) {
|
|
292
|
-
await
|
|
293
|
-
|
|
294
|
-
bypassPermissionsModeAccepted: true,
|
|
295
|
-
hasTrustDialogAccepted: true,
|
|
296
|
-
}), { mode: 0o600 }).catch(() => { });
|
|
312
|
+
await writeOnboardingPreseed(configHome);
|
|
313
|
+
await assertOnboardingKeysCurrent(configHome, process.env.CLAUDE_CLI_VERSION, opts.log);
|
|
297
314
|
}
|
|
298
315
|
// Repo-sourced Claude Skill (slice 2): install it as a native skill under the config dir's
|
|
299
316
|
// `skills/<name>/` so the CLI discovers and can invoke it. Written to the isolated per-run
|
|
@@ -320,6 +337,19 @@ export async function runClaudeCode(opts) {
|
|
|
320
337
|
}
|
|
321
338
|
: { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken }),
|
|
322
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;
|
|
323
353
|
try {
|
|
324
354
|
const { stderrTail } = await streamCli({
|
|
325
355
|
command: 'claude',
|
|
@@ -339,16 +369,40 @@ export async function runClaudeCode(opts) {
|
|
|
339
369
|
...appendArgs,
|
|
340
370
|
],
|
|
341
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).
|
|
342
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;
|
|
343
396
|
return {
|
|
344
397
|
summary,
|
|
345
398
|
stats,
|
|
346
399
|
stderrTail,
|
|
347
|
-
...(
|
|
348
|
-
...(
|
|
400
|
+
...(mergedUsage ? { usage: mergedUsage } : {}),
|
|
401
|
+
...(mergedCalls.length ? { callMetrics: mergedCalls } : {}),
|
|
349
402
|
};
|
|
350
403
|
}
|
|
351
404
|
finally {
|
|
405
|
+
await subagents?.stop();
|
|
352
406
|
if (configHome) {
|
|
353
407
|
// Lift the CLI session transcripts (`projects/`) out for short-lived retention BEFORE the
|
|
354
408
|
// home is deleted — the credential lives at the home root, never in `projects/`, so this
|
|
@@ -396,38 +450,6 @@ function claudeUsage(raw) {
|
|
|
396
450
|
return undefined;
|
|
397
451
|
return { inputTokens: input, outputTokens: output };
|
|
398
452
|
}
|
|
399
|
-
/** Pull the text + reasoning out of a Claude `assistant` message's content blocks. */
|
|
400
|
-
function claudeAssistantContent(content) {
|
|
401
|
-
let text = '';
|
|
402
|
-
let reasoning = '';
|
|
403
|
-
let toolUses = 0;
|
|
404
|
-
for (const block of content) {
|
|
405
|
-
if (!isObject(block))
|
|
406
|
-
continue;
|
|
407
|
-
if (block.type === 'text' && typeof block.text === 'string')
|
|
408
|
-
text += block.text;
|
|
409
|
-
else if (block.type === 'thinking' && typeof block.thinking === 'string')
|
|
410
|
-
reasoning += block.thinking;
|
|
411
|
-
else if (block.type === 'tool_use')
|
|
412
|
-
toolUses += 1;
|
|
413
|
-
}
|
|
414
|
-
return { text, reasoning, toolUses };
|
|
415
|
-
}
|
|
416
|
-
/**
|
|
417
|
-
* Per-CALL token usage off a Claude `assistant` message's `usage` (this turn only, not
|
|
418
|
-
* the cumulative `result` total). `inputTokens` counts every billed input bucket (fresh
|
|
419
|
-
* + both cache buckets); `cachedInputTokens` is the cache share, surfaced separately.
|
|
420
|
-
*/
|
|
421
|
-
function claudeCallUsage(raw) {
|
|
422
|
-
if (!isObject(raw))
|
|
423
|
-
return { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
|
|
424
|
-
const cached = numberOf(raw.cache_read_input_tokens) + numberOf(raw.cache_creation_input_tokens);
|
|
425
|
-
return {
|
|
426
|
-
inputTokens: numberOf(raw.input_tokens) + cached,
|
|
427
|
-
cachedInputTokens: cached,
|
|
428
|
-
outputTokens: numberOf(raw.output_tokens),
|
|
429
|
-
};
|
|
430
|
-
}
|
|
431
453
|
// ---------------------------------------------------------------------------
|
|
432
454
|
// Codex
|
|
433
455
|
// ---------------------------------------------------------------------------
|
|
@@ -669,9 +691,6 @@ function codexLastTurnUsage(event) {
|
|
|
669
691
|
return undefined;
|
|
670
692
|
return { inputTokens: input, cachedInputTokens: cached, outputTokens: output };
|
|
671
693
|
}
|
|
672
|
-
function numberOf(value) {
|
|
673
|
-
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
674
|
-
}
|
|
675
694
|
/** Dispatch to the configured subscription harness runner. */
|
|
676
695
|
export function runSubscriptionHarness(harness, opts) {
|
|
677
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
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { claudeAssistantContent, claudeCallUsage, isObject, redactBody } from './claude-stream.js';
|
|
5
|
+
export function createSliceTracker() {
|
|
6
|
+
// Insertion-ordered so the progress `items` render in dispatch order.
|
|
7
|
+
const slices = new Map();
|
|
8
|
+
return {
|
|
9
|
+
onAssistant(content) {
|
|
10
|
+
if (!Array.isArray(content))
|
|
11
|
+
return;
|
|
12
|
+
for (const block of content) {
|
|
13
|
+
if (!isObject(block) || block.type !== 'tool_use' || block.name !== 'Task')
|
|
14
|
+
continue;
|
|
15
|
+
const id = typeof block.id === 'string' ? block.id : undefined;
|
|
16
|
+
if (!id || slices.has(id))
|
|
17
|
+
continue;
|
|
18
|
+
const input = isObject(block.input) ? block.input : {};
|
|
19
|
+
const description = typeof input.description === 'string' && input.description.trim()
|
|
20
|
+
? input.description.trim()
|
|
21
|
+
: `Subagent ${slices.size + 1}`;
|
|
22
|
+
slices.set(id, { toolUseId: id, description, done: false });
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
onUser(content) {
|
|
26
|
+
if (!Array.isArray(content))
|
|
27
|
+
return;
|
|
28
|
+
for (const block of content) {
|
|
29
|
+
if (!isObject(block) || block.type !== 'tool_result')
|
|
30
|
+
continue;
|
|
31
|
+
const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined;
|
|
32
|
+
const slice = id ? slices.get(id) : undefined;
|
|
33
|
+
if (slice)
|
|
34
|
+
slice.done = true;
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
hasSlices() {
|
|
38
|
+
return slices.size > 0;
|
|
39
|
+
},
|
|
40
|
+
progress() {
|
|
41
|
+
if (slices.size === 0)
|
|
42
|
+
return undefined;
|
|
43
|
+
const items = [...slices.values()].map((s) => ({
|
|
44
|
+
label: s.description,
|
|
45
|
+
status: (s.done ? 'completed' : 'in_progress'),
|
|
46
|
+
}));
|
|
47
|
+
const completed = items.filter((i) => i.status === 'completed').length;
|
|
48
|
+
return {
|
|
49
|
+
completed,
|
|
50
|
+
inProgress: items.length - completed,
|
|
51
|
+
total: items.length,
|
|
52
|
+
items,
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Subagent transcript watcher (heartbeat + usage) (D3)
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
/** Default poll cadence for the transcript directory; well under the git timeout margin. */
|
|
61
|
+
const DEFAULT_POLL_MS = 3_000;
|
|
62
|
+
/**
|
|
63
|
+
* Start watching `dir` (the CLI's `<configHome>/subagents`) for `*.jsonl` transcripts,
|
|
64
|
+
* tailing each file by byte offset. New content feeds `onActivity` (heartbeat) and each
|
|
65
|
+
* assistant turn carrying usage is lifted into a {@link HarnessCallMetric} + summed into
|
|
66
|
+
* the cumulative usage. Best-effort throughout: the directory may not exist yet (created
|
|
67
|
+
* lazily by the CLI), a file may be mid-write, and the line/usage shape may change across
|
|
68
|
+
* CLI versions — every such case is swallowed so the watcher can only ever ADD signal,
|
|
69
|
+
* never break the run.
|
|
70
|
+
*/
|
|
71
|
+
export function startSubagentWatcher(dir, opts) {
|
|
72
|
+
const secrets = opts.secrets ?? [];
|
|
73
|
+
const offsets = new Map();
|
|
74
|
+
const calls = [];
|
|
75
|
+
const usage = { inputTokens: 0, outputTokens: 0 };
|
|
76
|
+
// Per-file partial-line remainder, carried as raw BYTES (not a decoded string). A JSONL
|
|
77
|
+
// record can straddle two polls (the file is appended between ticks), and the byte offset
|
|
78
|
+
// we stop at can fall in the middle of a multi-byte UTF-8 character; decoding a partial
|
|
79
|
+
// read to a string would replace that split character with U+FFFD and corrupt the line.
|
|
80
|
+
// Buffering bytes and decoding only whole lines keeps the captured text faithful.
|
|
81
|
+
const carry = new Map();
|
|
82
|
+
let polling = false;
|
|
83
|
+
const ingestLine = (line) => {
|
|
84
|
+
const trimmed = line.trim();
|
|
85
|
+
if (!trimmed.startsWith('{'))
|
|
86
|
+
return;
|
|
87
|
+
let event;
|
|
88
|
+
try {
|
|
89
|
+
event = JSON.parse(trimmed);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// Subagent transcripts mirror the session-transcript envelope: an `assistant` entry
|
|
95
|
+
// whose `message` carries the Anthropic `usage` + `content`. Read defensively.
|
|
96
|
+
if (event.type !== 'assistant' || !isObject(event.message))
|
|
97
|
+
return;
|
|
98
|
+
const message = event.message;
|
|
99
|
+
const u = claudeCallUsage(message.usage);
|
|
100
|
+
if (u.inputTokens === 0 && u.outputTokens === 0)
|
|
101
|
+
return;
|
|
102
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
103
|
+
const { text, reasoning } = claudeAssistantContent(content);
|
|
104
|
+
calls.push({
|
|
105
|
+
...(typeof message.model === 'string'
|
|
106
|
+
? { model: message.model }
|
|
107
|
+
: opts.model
|
|
108
|
+
? { model: opts.model }
|
|
109
|
+
: {}),
|
|
110
|
+
// The subagent's own transcript isn't a re-sendable prompt chain, so we don't
|
|
111
|
+
// reconstruct the request side (kept empty); the response + tokens are faithful.
|
|
112
|
+
promptText: '',
|
|
113
|
+
messageCount: 0,
|
|
114
|
+
responseText: redactBody(text, secrets),
|
|
115
|
+
reasoningText: redactBody(reasoning, secrets),
|
|
116
|
+
inputTokens: u.inputTokens,
|
|
117
|
+
cachedInputTokens: u.cachedInputTokens,
|
|
118
|
+
outputTokens: u.outputTokens,
|
|
119
|
+
finishReason: typeof message.stop_reason === 'string' ? message.stop_reason : null,
|
|
120
|
+
});
|
|
121
|
+
usage.inputTokens += u.inputTokens;
|
|
122
|
+
usage.outputTokens += u.outputTokens;
|
|
123
|
+
};
|
|
124
|
+
const NEWLINE = 0x0a;
|
|
125
|
+
const readNew = (path, from, to) => new Promise((resolve) => {
|
|
126
|
+
// Tail as raw bytes and split on the newline byte, decoding each COMPLETE line to
|
|
127
|
+
// UTF-8 only on that boundary (a '\n' is a single byte, never part of a multi-byte
|
|
128
|
+
// sequence), so a record — or a multi-byte character — that spans this read and the
|
|
129
|
+
// next is reassembled from the byte carry rather than corrupted at the seam.
|
|
130
|
+
let buffer = carry.get(path) ?? Buffer.alloc(0);
|
|
131
|
+
const stream = createReadStream(path, { start: from, end: to - 1 });
|
|
132
|
+
stream.on('data', (chunk) => {
|
|
133
|
+
buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
|
|
134
|
+
let nl = buffer.indexOf(NEWLINE);
|
|
135
|
+
while (nl !== -1) {
|
|
136
|
+
ingestLine(buffer.subarray(0, nl).toString('utf8'));
|
|
137
|
+
buffer = buffer.subarray(nl + 1);
|
|
138
|
+
nl = buffer.indexOf(NEWLINE);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
stream.on('error', () => resolve());
|
|
142
|
+
stream.on('close', () => {
|
|
143
|
+
// Copy the remainder out of the shared chunk backing store before caching it, so a
|
|
144
|
+
// later Buffer.concat can't be aliased by a reused stream buffer.
|
|
145
|
+
carry.set(path, Buffer.from(buffer));
|
|
146
|
+
resolve();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
const pollOnce = async () => {
|
|
150
|
+
if (polling)
|
|
151
|
+
return;
|
|
152
|
+
polling = true;
|
|
153
|
+
try {
|
|
154
|
+
let entries;
|
|
155
|
+
try {
|
|
156
|
+
entries = (await readdir(dir)).filter((n) => n.endsWith('.jsonl'));
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return; // dir not created yet (or vanished) — try again next tick
|
|
160
|
+
}
|
|
161
|
+
let grew = false;
|
|
162
|
+
for (const name of entries) {
|
|
163
|
+
const path = join(dir, name);
|
|
164
|
+
let size;
|
|
165
|
+
try {
|
|
166
|
+
size = (await stat(path)).size;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const from = offsets.get(path) ?? 0;
|
|
172
|
+
if (size <= from)
|
|
173
|
+
continue;
|
|
174
|
+
grew = true;
|
|
175
|
+
await readNew(path, from, size);
|
|
176
|
+
offsets.set(path, size);
|
|
177
|
+
}
|
|
178
|
+
if (grew)
|
|
179
|
+
opts.onActivity?.();
|
|
180
|
+
}
|
|
181
|
+
catch (e) {
|
|
182
|
+
opts.log?.warn('subagent transcript poll failed', { error: String(e) });
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
polling = false;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
const timer = setInterval(() => void pollOnce(), opts.intervalMs ?? DEFAULT_POLL_MS);
|
|
189
|
+
// Don't let the watcher's timer keep the container process alive on its own.
|
|
190
|
+
timer.unref?.();
|
|
191
|
+
return {
|
|
192
|
+
// Always does a final drain (idempotent clear of the timer), so a late transcript
|
|
193
|
+
// write between the last tick and stop is still captured, and a second stop() picks up
|
|
194
|
+
// anything appended since — the per-file offsets make re-polling safe (no double count).
|
|
195
|
+
async stop() {
|
|
196
|
+
clearInterval(timer);
|
|
197
|
+
await pollOnce();
|
|
198
|
+
},
|
|
199
|
+
usage() {
|
|
200
|
+
return { ...usage };
|
|
201
|
+
},
|
|
202
|
+
calls() {
|
|
203
|
+
return calls;
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.50.
|
|
3
|
+
"version": "1.50.8",
|
|
4
4
|
"description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"hono": "^4.12.30",
|
|
27
27
|
"typescript": "7.0.2",
|
|
28
28
|
"vitest": "^4.1.10",
|
|
29
|
-
"@cat-factory/server": "0.140.
|
|
30
|
-
"@cat-factory/spend": "0.12.
|
|
29
|
+
"@cat-factory/server": "0.140.2",
|
|
30
|
+
"@cat-factory/spend": "0.12.68"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
33
|
"build": "tsc -p tsconfig.json",
|
package/src/agent-runner.ts
CHANGED
|
@@ -2,10 +2,19 @@ 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 {
|
|
6
|
+
claudeAssistantContent,
|
|
7
|
+
claudeCallUsage,
|
|
8
|
+
isObject,
|
|
9
|
+
numberOf,
|
|
10
|
+
redactBody,
|
|
11
|
+
} from './claude-stream.js'
|
|
5
12
|
import type { Logger } from './logger.js'
|
|
6
13
|
import type { HarnessCallMetric, PiRunOutcome, PiRunStats, TodoProgress } from './pi.js'
|
|
7
14
|
import { killChildProcess, spawnDetached } from './process.js'
|
|
8
15
|
import { redact, secretsToRedact } from './redact.js'
|
|
16
|
+
import { createSliceTracker, startSubagentWatcher } from './subagents.js'
|
|
17
|
+
import { assertOnboardingKeysCurrent, writeOnboardingPreseed } from './onboarding-preseed.js'
|
|
9
18
|
import { retainSessionTranscripts } from './transcript-retention.js'
|
|
10
19
|
|
|
11
20
|
// The alternate (subscription) harness runners. The Pi harness reaches models
|
|
@@ -79,15 +88,6 @@ export interface SubscriptionRunOptions {
|
|
|
79
88
|
log?: Logger
|
|
80
89
|
}
|
|
81
90
|
|
|
82
|
-
function isObject(value: unknown): value is Record<string, unknown> {
|
|
83
|
-
return typeof value === 'object' && value !== null
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Scrub any leased-credential occurrences from a telemetry body (no-op when none). */
|
|
87
|
-
function redactBody(text: string, secrets: string[]): string {
|
|
88
|
-
return secrets.length ? redact(text, secrets) : text
|
|
89
|
-
}
|
|
90
|
-
|
|
91
91
|
/**
|
|
92
92
|
* Fallback token attribution: if a CLI reported a cumulative total but no per-turn
|
|
93
93
|
* usage (so every captured call has zero tokens), pin the whole total onto the LAST
|
|
@@ -325,6 +325,19 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
325
325
|
]
|
|
326
326
|
const calls: HarnessCallMetric[] = []
|
|
327
327
|
|
|
328
|
+
// ADR 0026 D2.1: derive slice progress from the parent stream's `Task` dispatches +
|
|
329
|
+
// their terminal tool_results (both DO appear here — only a subagent's intermediate
|
|
330
|
+
// turns don't). A real parent TodoWrite plan, when the agent writes one, wins; the
|
|
331
|
+
// slice-derived progress is the fallback for the parallel-subagent shape that writes no
|
|
332
|
+
// parent plan (the pr-reviewer failure this fixes).
|
|
333
|
+
const sliceTracker = createSliceTracker()
|
|
334
|
+
let sawTodoPlan = false
|
|
335
|
+
const emitSliceProgress = (): void => {
|
|
336
|
+
if (sawTodoPlan || !opts.onProgress) return
|
|
337
|
+
const progress = sliceTracker.progress()
|
|
338
|
+
if (progress) opts.onProgress(progress)
|
|
339
|
+
}
|
|
340
|
+
|
|
328
341
|
const onEvent = (event: Record<string, unknown>): void => {
|
|
329
342
|
const type = event.type
|
|
330
343
|
if (type === 'assistant' && isObject(event.message)) {
|
|
@@ -341,9 +354,14 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
341
354
|
opts.onProgress
|
|
342
355
|
) {
|
|
343
356
|
const progress = todosToProgress((block.input as Record<string, unknown>)?.todos)
|
|
344
|
-
if (progress)
|
|
357
|
+
if (progress) {
|
|
358
|
+
sawTodoPlan = true
|
|
359
|
+
opts.onProgress(progress)
|
|
360
|
+
}
|
|
345
361
|
}
|
|
346
362
|
}
|
|
363
|
+
sliceTracker.onAssistant(content)
|
|
364
|
+
emitSliceProgress()
|
|
347
365
|
// Record this call BEFORE appending its turn: the prompt is the history that
|
|
348
366
|
// produced this response. The append-only array keeps each call's prompt a strict
|
|
349
367
|
// prefix of the next, so the backend's telemetry chain delta-compresses cleanly.
|
|
@@ -363,7 +381,11 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
363
381
|
} else if (type === 'user' && isObject(event.message)) {
|
|
364
382
|
// tool_result blocks the harness fed back to the model — part of the next prompt.
|
|
365
383
|
const content = (event.message as Record<string, unknown>).content
|
|
366
|
-
if (Array.isArray(content))
|
|
384
|
+
if (Array.isArray(content)) {
|
|
385
|
+
sliceTracker.onUser(content)
|
|
386
|
+
emitSliceProgress()
|
|
387
|
+
messages.push({ role: 'tool', content })
|
|
388
|
+
}
|
|
367
389
|
} else if (type === 'result') {
|
|
368
390
|
if (typeof event.result === 'string') summary = event.result
|
|
369
391
|
usage = claudeUsage(event.usage) ?? usage
|
|
@@ -389,16 +411,12 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
389
411
|
// as already accepted so `-p` starts straight into the run. Best-effort: written
|
|
390
412
|
// before the CLI starts; unknown keys are harmless if a CLI version ignores them.
|
|
391
413
|
// (Ambient mode skips this — the developer's own config is already onboarded.)
|
|
414
|
+
// ADR 0026 D4: assert the pinned onboarding keys landed and log them with the CLI
|
|
415
|
+
// version, so a future first-run gate this set doesn't cover (which looks identical to
|
|
416
|
+
// a healthy-but-quiet subagent start) is diffable when the cold-start watchdog fires.
|
|
392
417
|
if (configHome) {
|
|
393
|
-
await
|
|
394
|
-
|
|
395
|
-
JSON.stringify({
|
|
396
|
-
hasCompletedOnboarding: true,
|
|
397
|
-
bypassPermissionsModeAccepted: true,
|
|
398
|
-
hasTrustDialogAccepted: true,
|
|
399
|
-
}),
|
|
400
|
-
{ mode: 0o600 },
|
|
401
|
-
).catch(() => {})
|
|
418
|
+
await writeOnboardingPreseed(configHome)
|
|
419
|
+
await assertOnboardingKeysCurrent(configHome, process.env.CLAUDE_CLI_VERSION, opts.log)
|
|
402
420
|
}
|
|
403
421
|
|
|
404
422
|
// Repo-sourced Claude Skill (slice 2): install it as a native skill under the config dir's
|
|
@@ -428,6 +446,20 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
428
446
|
: { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken! }),
|
|
429
447
|
}
|
|
430
448
|
|
|
449
|
+
// ADR 0026 D2.1/D3: while the run is live, tail the CLI's `subagents/*.jsonl`
|
|
450
|
+
// transcripts (under the isolated config home) so a parallel-subagent review keeps the
|
|
451
|
+
// inactivity heartbeat alive (any new bytes ⇒ `onActivity`) and its otherwise-invisible
|
|
452
|
+
// token spend is lifted into the run's telemetry. Ambient mode has no isolated home to
|
|
453
|
+
// watch. Best-effort — a missing/renamed transcript layout just yields no extra signal.
|
|
454
|
+
const subagents = configHome
|
|
455
|
+
? startSubagentWatcher(join(configHome, 'subagents'), {
|
|
456
|
+
...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
|
|
457
|
+
secrets,
|
|
458
|
+
model: opts.model,
|
|
459
|
+
...(opts.log ? { log: opts.log } : {}),
|
|
460
|
+
})
|
|
461
|
+
: undefined
|
|
462
|
+
|
|
431
463
|
try {
|
|
432
464
|
const { stderrTail } = await streamCli(
|
|
433
465
|
{
|
|
@@ -455,15 +487,40 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
455
487
|
onEvent,
|
|
456
488
|
)
|
|
457
489
|
|
|
490
|
+
// The parent's cumulative-usage fallback applies to the PARENT calls only (before the
|
|
491
|
+
// subagent calls, which carry their own per-turn tokens, are concatenated).
|
|
458
492
|
attributeCumulativeUsage(calls, usage)
|
|
493
|
+
// Final drain of any subagent transcript writes that landed after the last poll, then
|
|
494
|
+
// fold the subagents' usage + per-call telemetry into the run's outcome — their tokens
|
|
495
|
+
// never appear on the parent stream, so this is the only place they are accounted.
|
|
496
|
+
await subagents?.stop()
|
|
497
|
+
const subUsage = subagents?.usage() ?? { inputTokens: 0, outputTokens: 0 }
|
|
498
|
+
const subCalls = subagents?.calls() ?? []
|
|
499
|
+
const mergedCalls = [...calls, ...subCalls]
|
|
500
|
+
// INVARIANT (do not "fix" this into a double count): the run total is the parent usage
|
|
501
|
+
// PLUS the subagent usage because the two are disjoint sources. The parent `usage` here
|
|
502
|
+
// is the terminal `result` event's cumulative, which covers ONLY the parent loop — the
|
|
503
|
+
// ADR 0026 incident is itself the proof: a heavily subagent-parallelised review reported
|
|
504
|
+
// ~0 tokens, i.e. the parent stream (and its `result` total) never included the subagent
|
|
505
|
+
// spend. The subagent tokens live exclusively in the `subagents/*.jsonl` transcripts (a
|
|
506
|
+
// directory distinct from the parent's `projects/` session transcript), which the watcher
|
|
507
|
+
// reads and nothing else does — so neither `calls` nor `usage` can already contain them.
|
|
508
|
+
const mergedUsage =
|
|
509
|
+
usage || subUsage.inputTokens || subUsage.outputTokens
|
|
510
|
+
? {
|
|
511
|
+
inputTokens: (usage?.inputTokens ?? 0) + subUsage.inputTokens,
|
|
512
|
+
outputTokens: (usage?.outputTokens ?? 0) + subUsage.outputTokens,
|
|
513
|
+
}
|
|
514
|
+
: undefined
|
|
459
515
|
return {
|
|
460
516
|
summary,
|
|
461
517
|
stats,
|
|
462
518
|
stderrTail,
|
|
463
|
-
...(
|
|
464
|
-
...(
|
|
519
|
+
...(mergedUsage ? { usage: mergedUsage } : {}),
|
|
520
|
+
...(mergedCalls.length ? { callMetrics: mergedCalls } : {}),
|
|
465
521
|
}
|
|
466
522
|
} finally {
|
|
523
|
+
await subagents?.stop()
|
|
467
524
|
if (configHome) {
|
|
468
525
|
// Lift the CLI session transcripts (`projects/`) out for short-lived retention BEFORE the
|
|
469
526
|
// home is deleted — the credential lives at the home root, never in `projects/`, so this
|
|
@@ -511,44 +568,6 @@ function claudeUsage(raw: unknown): { inputTokens: number; outputTokens: number
|
|
|
511
568
|
return { inputTokens: input, outputTokens: output }
|
|
512
569
|
}
|
|
513
570
|
|
|
514
|
-
/** Pull the text + reasoning out of a Claude `assistant` message's content blocks. */
|
|
515
|
-
function claudeAssistantContent(content: unknown[]): {
|
|
516
|
-
text: string
|
|
517
|
-
reasoning: string
|
|
518
|
-
toolUses: number
|
|
519
|
-
} {
|
|
520
|
-
let text = ''
|
|
521
|
-
let reasoning = ''
|
|
522
|
-
let toolUses = 0
|
|
523
|
-
for (const block of content) {
|
|
524
|
-
if (!isObject(block)) continue
|
|
525
|
-
if (block.type === 'text' && typeof block.text === 'string') text += block.text
|
|
526
|
-
else if (block.type === 'thinking' && typeof block.thinking === 'string')
|
|
527
|
-
reasoning += block.thinking
|
|
528
|
-
else if (block.type === 'tool_use') toolUses += 1
|
|
529
|
-
}
|
|
530
|
-
return { text, reasoning, toolUses }
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
/**
|
|
534
|
-
* Per-CALL token usage off a Claude `assistant` message's `usage` (this turn only, not
|
|
535
|
-
* the cumulative `result` total). `inputTokens` counts every billed input bucket (fresh
|
|
536
|
-
* + both cache buckets); `cachedInputTokens` is the cache share, surfaced separately.
|
|
537
|
-
*/
|
|
538
|
-
function claudeCallUsage(raw: unknown): {
|
|
539
|
-
inputTokens: number
|
|
540
|
-
cachedInputTokens: number
|
|
541
|
-
outputTokens: number
|
|
542
|
-
} {
|
|
543
|
-
if (!isObject(raw)) return { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }
|
|
544
|
-
const cached = numberOf(raw.cache_read_input_tokens) + numberOf(raw.cache_creation_input_tokens)
|
|
545
|
-
return {
|
|
546
|
-
inputTokens: numberOf(raw.input_tokens) + cached,
|
|
547
|
-
cachedInputTokens: cached,
|
|
548
|
-
outputTokens: numberOf(raw.output_tokens),
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
|
|
552
571
|
// ---------------------------------------------------------------------------
|
|
553
572
|
// Codex
|
|
554
573
|
// ---------------------------------------------------------------------------
|
|
@@ -808,10 +827,6 @@ function codexLastTurnUsage(event: Record<string, unknown>):
|
|
|
808
827
|
return { inputTokens: input, cachedInputTokens: cached, outputTokens: output }
|
|
809
828
|
}
|
|
810
829
|
|
|
811
|
-
function numberOf(value: unknown): number {
|
|
812
|
-
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
|
813
|
-
}
|
|
814
|
-
|
|
815
830
|
/** Dispatch to the configured subscription harness runner. */
|
|
816
831
|
export function runSubscriptionHarness(
|
|
817
832
|
harness: SubscriptionHarness,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { redact } from './redact.js'
|
|
2
|
+
|
|
3
|
+
// Shared parsing of Claude Code's stream-json / session-transcript envelope. The parent
|
|
4
|
+
// runner (`agent-runner.ts`) reads these off the CLI's stdout; the subagent watcher
|
|
5
|
+
// (`subagents.ts`) reads the same shapes off the `subagents/*.jsonl` transcripts. Kept in
|
|
6
|
+
// one place so both read usage/content identically and the cycle between the two modules
|
|
7
|
+
// is broken.
|
|
8
|
+
|
|
9
|
+
export function isObject(value: unknown): value is Record<string, unknown> {
|
|
10
|
+
return typeof value === 'object' && value !== null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function numberOf(value: unknown): number {
|
|
14
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Scrub any leased-credential occurrences from a telemetry body (no-op when none). */
|
|
18
|
+
export function redactBody(text: string, secrets: string[]): string {
|
|
19
|
+
return secrets.length ? redact(text, secrets) : text
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Pull the text + reasoning out of a Claude `assistant` message's content blocks. */
|
|
23
|
+
export function claudeAssistantContent(content: unknown[]): {
|
|
24
|
+
text: string
|
|
25
|
+
reasoning: string
|
|
26
|
+
toolUses: number
|
|
27
|
+
} {
|
|
28
|
+
let text = ''
|
|
29
|
+
let reasoning = ''
|
|
30
|
+
let toolUses = 0
|
|
31
|
+
for (const block of content) {
|
|
32
|
+
if (!isObject(block)) continue
|
|
33
|
+
if (block.type === 'text' && typeof block.text === 'string') text += block.text
|
|
34
|
+
else if (block.type === 'thinking' && typeof block.thinking === 'string')
|
|
35
|
+
reasoning += block.thinking
|
|
36
|
+
else if (block.type === 'tool_use') toolUses += 1
|
|
37
|
+
}
|
|
38
|
+
return { text, reasoning, toolUses }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Per-CALL token usage off a Claude `assistant` message's `usage` (this turn only, not
|
|
43
|
+
* the cumulative `result` total). `inputTokens` counts every billed input bucket (fresh
|
|
44
|
+
* + both cache buckets); `cachedInputTokens` is the cache share, surfaced separately.
|
|
45
|
+
*/
|
|
46
|
+
export function claudeCallUsage(raw: unknown): {
|
|
47
|
+
inputTokens: number
|
|
48
|
+
cachedInputTokens: number
|
|
49
|
+
outputTokens: number
|
|
50
|
+
} {
|
|
51
|
+
if (!isObject(raw)) return { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }
|
|
52
|
+
const cached = numberOf(raw.cache_read_input_tokens) + numberOf(raw.cache_creation_input_tokens)
|
|
53
|
+
return {
|
|
54
|
+
inputTokens: numberOf(raw.input_tokens) + cached,
|
|
55
|
+
cachedInputTokens: cached,
|
|
56
|
+
outputTokens: numberOf(raw.output_tokens),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { Logger } from './logger.js'
|
|
4
|
+
|
|
5
|
+
// ADR 0026 D4 (paired assertion). A brand-new Claude Code config home would otherwise
|
|
6
|
+
// make `claude -p` block on the interactive onboarding / "trust this folder" /
|
|
7
|
+
// bypass-permissions acknowledgement prompts — which never get answered headlessly,
|
|
8
|
+
// hanging the job until the inactivity watchdog kills it with no output. We pre-seed a
|
|
9
|
+
// `.claude.json` marking those gates as already accepted.
|
|
10
|
+
//
|
|
11
|
+
// The hazard the ADR calls out: if a future CLI version adds a NEW first-run gate this
|
|
12
|
+
// set does not cover, the symptom is identical to a healthy-but-quiet subagent run (no
|
|
13
|
+
// stdout, low CPU), so the cold-start watchdog can't tell them apart on its own. This
|
|
14
|
+
// module centralises the pre-seeded keys as ONE source of truth and logs the pinned set
|
|
15
|
+
// (with the installed CLI version) so that, when the cold-start watchdog fires, an
|
|
16
|
+
// operator has the exact keys-vs-version pairing to diff against a new gate.
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The onboarding gates we pre-accept in a fresh config home. Kept as a single constant so
|
|
20
|
+
* the write and the assertion below can never drift, and so a new gate is added in exactly
|
|
21
|
+
* one place. If the CLI renames/adds a key, this is where the fix lands.
|
|
22
|
+
*/
|
|
23
|
+
export const ONBOARDING_PRESEED_KEYS = {
|
|
24
|
+
hasCompletedOnboarding: true,
|
|
25
|
+
bypassPermissionsModeAccepted: true,
|
|
26
|
+
hasTrustDialogAccepted: true,
|
|
27
|
+
} as const
|
|
28
|
+
|
|
29
|
+
/** Write the onboarding pre-seed into `<configHome>/.claude.json`. Best-effort; never throws. */
|
|
30
|
+
export async function writeOnboardingPreseed(configHome: string): Promise<void> {
|
|
31
|
+
await writeFile(join(configHome, '.claude.json'), JSON.stringify(ONBOARDING_PRESEED_KEYS), {
|
|
32
|
+
mode: 0o600,
|
|
33
|
+
}).catch(() => {})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Verify the pre-seed actually landed and log the pinned onboarding keys alongside the
|
|
38
|
+
* installed CLI version — the "one-line assertion after the pre-seed" from D4. It cannot
|
|
39
|
+
* introspect the CLI's true first-run gate set (the CLI never exposes it), so it does the
|
|
40
|
+
* two things it CAN do cheaply and deterministically: confirm every key we intended is
|
|
41
|
+
* present + truthy in the written file (catching a botched write), and emit a structured
|
|
42
|
+
* record pairing the keys with the CLI version so a future onboarding regression — surfaced
|
|
43
|
+
* by the cold-start watchdog as a silent, output-less start — is diffable against a new gate.
|
|
44
|
+
* Best-effort; never throws.
|
|
45
|
+
*/
|
|
46
|
+
export async function assertOnboardingKeysCurrent(
|
|
47
|
+
configHome: string,
|
|
48
|
+
cliVersion: string | undefined,
|
|
49
|
+
log: Logger | undefined,
|
|
50
|
+
): Promise<void> {
|
|
51
|
+
const expected = Object.keys(ONBOARDING_PRESEED_KEYS)
|
|
52
|
+
let parsed: Record<string, unknown> = {}
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(await readFile(join(configHome, '.claude.json'), 'utf8')) as Record<
|
|
55
|
+
string,
|
|
56
|
+
unknown
|
|
57
|
+
>
|
|
58
|
+
} catch {
|
|
59
|
+
log?.warn('onboarding pre-seed could not be read back after write', {
|
|
60
|
+
onboardingKeys: expected,
|
|
61
|
+
...(cliVersion ? { cliVersion } : {}),
|
|
62
|
+
})
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
const missing = expected.filter((k) => parsed[k] !== true)
|
|
66
|
+
if (missing.length > 0) {
|
|
67
|
+
log?.warn('onboarding pre-seed is missing expected keys', {
|
|
68
|
+
onboardingKeys: expected,
|
|
69
|
+
missing,
|
|
70
|
+
...(cliVersion ? { cliVersion } : {}),
|
|
71
|
+
})
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
log?.info('onboarding pre-seed applied', {
|
|
75
|
+
onboardingKeys: expected,
|
|
76
|
+
...(cliVersion ? { cliVersion } : {}),
|
|
77
|
+
})
|
|
78
|
+
}
|
package/src/runner.ts
CHANGED
|
@@ -114,6 +114,19 @@ export interface JobView<TResult extends JobResultBase = JobResultBase> {
|
|
|
114
114
|
* surfaces the first one (and only on a follow-ups-enabled coding run).
|
|
115
115
|
*/
|
|
116
116
|
followUps?: FollowUpLine[]
|
|
117
|
+
/**
|
|
118
|
+
* ADR 0026 D4: set when the cold-start watchdog fired — the job produced NO activity
|
|
119
|
+
* within {@link RunnerLimits.coldStartMs} of starting, a likely onboarding/auth wedge.
|
|
120
|
+
* This does NOT fail the job (the inactivity/max-duration watchdogs still own that).
|
|
121
|
+
*
|
|
122
|
+
* Legibility today is via the per-job container log line emitted the moment it fires
|
|
123
|
+
* (the ~2-minute early signal the ADR wants); this field additionally carries the
|
|
124
|
+
* structured record on the GET /jobs/{id} view so an operator hitting the endpoint — or a
|
|
125
|
+
* future engine-side consumer — can read it without scraping logs. No engine code consumes
|
|
126
|
+
* it yet, so surfacing it up through the runner-transport layer is deliberately deferred.
|
|
127
|
+
* Absent on a job that produced output promptly (the overwhelming common case). Sticky once set.
|
|
128
|
+
*/
|
|
129
|
+
coldStart?: { atMs: number; message: string }
|
|
117
130
|
}
|
|
118
131
|
|
|
119
132
|
interface JobEntry<TResult extends JobResultBase> extends JobView<TResult> {
|
|
@@ -133,6 +146,15 @@ export interface RunnerLimits {
|
|
|
133
146
|
maxDurationMs: number
|
|
134
147
|
/** Force-fail the job if the agent produces no output for this long (hang guard). */
|
|
135
148
|
inactivityMs: number
|
|
149
|
+
/**
|
|
150
|
+
* ADR 0026 D4: a short first-output window. If the job produces NO activity within this
|
|
151
|
+
* long after start, emit a structured cold-start diagnostic (a likely onboarding/auth
|
|
152
|
+
* wedge) — WITHOUT killing the run. Purely a legibility signal so a genuine cold-start
|
|
153
|
+
* wedge surfaces in a couple of minutes instead of waiting out the full inactivity
|
|
154
|
+
* window. Safely under the clone-inclusive phases (a large clone still streams git
|
|
155
|
+
* progress, which counts as activity). Set to 0 to disable.
|
|
156
|
+
*/
|
|
157
|
+
coldStartMs: number
|
|
136
158
|
}
|
|
137
159
|
|
|
138
160
|
function intEnv(value: string | undefined, fallback: number): number {
|
|
@@ -140,6 +162,13 @@ function intEnv(value: string | undefined, fallback: number): number {
|
|
|
140
162
|
return Number.isFinite(n) && n > 0 ? n : fallback
|
|
141
163
|
}
|
|
142
164
|
|
|
165
|
+
/** Like {@link intEnv} but allows an explicit 0 (used to DISABLE a window). */
|
|
166
|
+
function intEnvAllowZero(value: string | undefined, fallback: number): number {
|
|
167
|
+
if (value === undefined) return fallback
|
|
168
|
+
const n = Number(value)
|
|
169
|
+
return Number.isFinite(n) && n >= 0 ? n : fallback
|
|
170
|
+
}
|
|
171
|
+
|
|
143
172
|
export function loadRunnerLimits(env: NodeJS.ProcessEnv = process.env): RunnerLimits {
|
|
144
173
|
return {
|
|
145
174
|
// 60 minutes: generous headroom for serious multi-file coding tasks while
|
|
@@ -152,6 +181,9 @@ export function loadRunnerLimits(env: NodeJS.ProcessEnv = process.env): RunnerLi
|
|
|
152
181
|
// with git's own clear reason rather than this watchdog's "likely hung" message,
|
|
153
182
|
// for any configured window. See the invariant note in git.ts.
|
|
154
183
|
inactivityMs: intEnv(env.JOB_INACTIVITY_MS, 10 * 60_000),
|
|
184
|
+
// 2 minutes: comfortably longer than a warm agent's time-to-first-token yet far
|
|
185
|
+
// under the 10-minute inactivity kill, so a truly output-less start is flagged early.
|
|
186
|
+
coldStartMs: intEnvAllowZero(env.JOB_COLD_START_MS, 2 * 60_000),
|
|
155
187
|
}
|
|
156
188
|
}
|
|
157
189
|
|
|
@@ -298,7 +330,28 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
|
|
|
298
330
|
killReason ??= 'max-duration'
|
|
299
331
|
controller.abort(new Error('max duration exceeded'))
|
|
300
332
|
}, this.limits.maxDurationMs)
|
|
333
|
+
|
|
334
|
+
// ADR 0026 D4: a one-shot cold-start watchdog. If the job produces no activity within
|
|
335
|
+
// `coldStartMs`, record a structured diagnostic (a likely onboarding/auth wedge) so it
|
|
336
|
+
// is legible early — it does NOT abort the run (the inactivity watchdog still owns
|
|
337
|
+
// that). Cleared the moment the first activity arrives.
|
|
338
|
+
let sawActivity = false
|
|
339
|
+
let coldStart: ReturnType<typeof setTimeout> | undefined
|
|
340
|
+
if (this.limits.coldStartMs > 0) {
|
|
341
|
+
coldStart = setTimeout(() => {
|
|
342
|
+
if (sawActivity) return
|
|
343
|
+
const secs = Math.round(this.limits.coldStartMs / 1000)
|
|
344
|
+
const message = `agent produced no output ${secs}s after start; possible onboarding/auth wedge (phase: ${phase})`
|
|
345
|
+
entry.coldStart = { atMs: Date.now(), message }
|
|
346
|
+
jobLog.warn('cold-start: no agent output', { afterMs: this.limits.coldStartMs, phase })
|
|
347
|
+
}, this.limits.coldStartMs)
|
|
348
|
+
}
|
|
349
|
+
|
|
301
350
|
const heartbeat = (): void => {
|
|
351
|
+
if (!sawActivity) {
|
|
352
|
+
sawActivity = true
|
|
353
|
+
clearTimeout(coldStart)
|
|
354
|
+
}
|
|
302
355
|
entry.heartbeatAt = Date.now()
|
|
303
356
|
resetInactivity()
|
|
304
357
|
}
|
|
@@ -361,6 +414,7 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
|
|
|
361
414
|
} finally {
|
|
362
415
|
clearTimeout(inactivity)
|
|
363
416
|
clearTimeout(cap)
|
|
417
|
+
clearTimeout(coldStart)
|
|
364
418
|
entry.abort = undefined
|
|
365
419
|
entry.heartbeatAt = Date.now()
|
|
366
420
|
}
|
package/src/subagents.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { readdir, stat } from 'node:fs/promises'
|
|
2
|
+
import { createReadStream } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { claudeAssistantContent, claudeCallUsage, isObject, redactBody } from './claude-stream.js'
|
|
5
|
+
import type { Logger } from './logger.js'
|
|
6
|
+
import type { HarnessCallMetric, TodoProgress } from './pi.js'
|
|
7
|
+
|
|
8
|
+
// ADR 0026 D2.1 + D3. When the Claude Code CLI reviews a large PR it fans the work
|
|
9
|
+
// out across parallel `Task` subagents. Two things then go dark to the harness, which
|
|
10
|
+
// only reads the PARENT process's stream-json stdout:
|
|
11
|
+
//
|
|
12
|
+
// - the parent stream falls quiet for the whole (potentially 15+ minute) parallel
|
|
13
|
+
// review, so the inactivity heartbeat freezes and a healthy run looks wedged (P3);
|
|
14
|
+
// - every subagent's token spend is written to a SEPARATE `subagents/*.jsonl`
|
|
15
|
+
// transcript under the CLI's config home and never reaches the parent stream, so
|
|
16
|
+
// the run's telemetry reports ~0 tokens while hundreds of thousands are spent (P3).
|
|
17
|
+
//
|
|
18
|
+
// This module closes both without disabling the (context-bounding, ADR-0023-wanted)
|
|
19
|
+
// subagent parallelism:
|
|
20
|
+
//
|
|
21
|
+
// - {@link createSliceTracker} derives the slice plan + per-slice progress from the
|
|
22
|
+
// PARENT stream alone — the `Task` tool_use dispatch and its terminal tool_result
|
|
23
|
+
// DO appear there (only the subagent's intermediate turns don't), so slices/progress
|
|
24
|
+
// need no file watching (D2.1);
|
|
25
|
+
// - {@link startSubagentWatcher} tails the `subagents/*.jsonl` transcripts for the
|
|
26
|
+
// heartbeat (any new bytes ⇒ `onActivity`) and sums each subagent turn's usage into
|
|
27
|
+
// the run's telemetry (D3).
|
|
28
|
+
//
|
|
29
|
+
// Both degrade gracefully: the CLI's subagent transcript layout is not a stable contract,
|
|
30
|
+
// so a missing directory, an unreadable file, or an unparseable line is swallowed and the
|
|
31
|
+
// harness falls back to today's parent-stream-only behaviour.
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Slice / progress tracking off the PARENT stream (D2.1)
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
interface TrackedSlice {
|
|
38
|
+
/** The `Task` tool_use id, used to pair the terminal tool_result. */
|
|
39
|
+
toolUseId: string
|
|
40
|
+
/** The subagent's description (`Review <slice> slice`), rendered as the progress label. */
|
|
41
|
+
description: string
|
|
42
|
+
done: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Tracks parallel `Task` subagents seen on the parent stream to derive slice progress. */
|
|
46
|
+
export interface SliceTracker {
|
|
47
|
+
/** Feed an `assistant` message's content blocks: registers any `Task` dispatches. */
|
|
48
|
+
onAssistant(content: unknown[]): void
|
|
49
|
+
/** Feed a `user` message's content blocks: marks the paired subagent(s) complete. */
|
|
50
|
+
onUser(content: unknown[]): void
|
|
51
|
+
/** Whether any `Task` subagent has been dispatched (⇒ this run parallelised). */
|
|
52
|
+
hasSlices(): boolean
|
|
53
|
+
/**
|
|
54
|
+
* Progress derived from the dispatched subagents (completed / in-flight / total),
|
|
55
|
+
* or undefined when none have been dispatched. Used ONLY as a fallback when the
|
|
56
|
+
* agent never wrote a parent TodoWrite plan — a real todo list, when present, wins.
|
|
57
|
+
*/
|
|
58
|
+
progress(): TodoProgress | undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createSliceTracker(): SliceTracker {
|
|
62
|
+
// Insertion-ordered so the progress `items` render in dispatch order.
|
|
63
|
+
const slices = new Map<string, TrackedSlice>()
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
onAssistant(content) {
|
|
67
|
+
if (!Array.isArray(content)) return
|
|
68
|
+
for (const block of content) {
|
|
69
|
+
if (!isObject(block) || block.type !== 'tool_use' || block.name !== 'Task') continue
|
|
70
|
+
const id = typeof block.id === 'string' ? block.id : undefined
|
|
71
|
+
if (!id || slices.has(id)) continue
|
|
72
|
+
const input = isObject(block.input) ? block.input : {}
|
|
73
|
+
const description =
|
|
74
|
+
typeof input.description === 'string' && input.description.trim()
|
|
75
|
+
? input.description.trim()
|
|
76
|
+
: `Subagent ${slices.size + 1}`
|
|
77
|
+
slices.set(id, { toolUseId: id, description, done: false })
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
onUser(content) {
|
|
81
|
+
if (!Array.isArray(content)) return
|
|
82
|
+
for (const block of content) {
|
|
83
|
+
if (!isObject(block) || block.type !== 'tool_result') continue
|
|
84
|
+
const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined
|
|
85
|
+
const slice = id ? slices.get(id) : undefined
|
|
86
|
+
if (slice) slice.done = true
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
hasSlices() {
|
|
90
|
+
return slices.size > 0
|
|
91
|
+
},
|
|
92
|
+
progress() {
|
|
93
|
+
if (slices.size === 0) return undefined
|
|
94
|
+
const items = [...slices.values()].map((s) => ({
|
|
95
|
+
label: s.description,
|
|
96
|
+
status: (s.done ? 'completed' : 'in_progress') as 'completed' | 'in_progress',
|
|
97
|
+
}))
|
|
98
|
+
const completed = items.filter((i) => i.status === 'completed').length
|
|
99
|
+
return {
|
|
100
|
+
completed,
|
|
101
|
+
inProgress: items.length - completed,
|
|
102
|
+
total: items.length,
|
|
103
|
+
items,
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Subagent transcript watcher (heartbeat + usage) (D3)
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/** Default poll cadence for the transcript directory; well under the git timeout margin. */
|
|
114
|
+
const DEFAULT_POLL_MS = 3_000
|
|
115
|
+
|
|
116
|
+
export interface SubagentWatcherOptions {
|
|
117
|
+
/** Fed the heartbeat when a transcript grows, so the inactivity watchdog sees the run is alive. */
|
|
118
|
+
onActivity?: () => void
|
|
119
|
+
/** Leased-credential strings to scrub from captured bodies (the transcripts can echo the token). */
|
|
120
|
+
secrets?: string[]
|
|
121
|
+
/** Fallback model id stamped on a subagent call whose transcript omits one. */
|
|
122
|
+
model?: string
|
|
123
|
+
/** Poll cadence (ms); overridable for tests. */
|
|
124
|
+
intervalMs?: number
|
|
125
|
+
log?: Logger
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface SubagentWatcher {
|
|
129
|
+
/** Do a final poll, then stop watching. Idempotent; never throws. */
|
|
130
|
+
stop(): Promise<void>
|
|
131
|
+
/** Cumulative subagent usage lifted so far (input + output tokens). */
|
|
132
|
+
usage(): { inputTokens: number; outputTokens: number }
|
|
133
|
+
/** The per-call telemetry rows lifted from the subagent transcripts so far. */
|
|
134
|
+
calls(): HarnessCallMetric[]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Start watching `dir` (the CLI's `<configHome>/subagents`) for `*.jsonl` transcripts,
|
|
139
|
+
* tailing each file by byte offset. New content feeds `onActivity` (heartbeat) and each
|
|
140
|
+
* assistant turn carrying usage is lifted into a {@link HarnessCallMetric} + summed into
|
|
141
|
+
* the cumulative usage. Best-effort throughout: the directory may not exist yet (created
|
|
142
|
+
* lazily by the CLI), a file may be mid-write, and the line/usage shape may change across
|
|
143
|
+
* CLI versions — every such case is swallowed so the watcher can only ever ADD signal,
|
|
144
|
+
* never break the run.
|
|
145
|
+
*/
|
|
146
|
+
export function startSubagentWatcher(dir: string, opts: SubagentWatcherOptions): SubagentWatcher {
|
|
147
|
+
const secrets = opts.secrets ?? []
|
|
148
|
+
const offsets = new Map<string, number>()
|
|
149
|
+
const calls: HarnessCallMetric[] = []
|
|
150
|
+
const usage = { inputTokens: 0, outputTokens: 0 }
|
|
151
|
+
// Per-file partial-line remainder, carried as raw BYTES (not a decoded string). A JSONL
|
|
152
|
+
// record can straddle two polls (the file is appended between ticks), and the byte offset
|
|
153
|
+
// we stop at can fall in the middle of a multi-byte UTF-8 character; decoding a partial
|
|
154
|
+
// read to a string would replace that split character with U+FFFD and corrupt the line.
|
|
155
|
+
// Buffering bytes and decoding only whole lines keeps the captured text faithful.
|
|
156
|
+
const carry = new Map<string, Buffer>()
|
|
157
|
+
let polling = false
|
|
158
|
+
|
|
159
|
+
const ingestLine = (line: string): void => {
|
|
160
|
+
const trimmed = line.trim()
|
|
161
|
+
if (!trimmed.startsWith('{')) return
|
|
162
|
+
let event: Record<string, unknown>
|
|
163
|
+
try {
|
|
164
|
+
event = JSON.parse(trimmed) as Record<string, unknown>
|
|
165
|
+
} catch {
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
// Subagent transcripts mirror the session-transcript envelope: an `assistant` entry
|
|
169
|
+
// whose `message` carries the Anthropic `usage` + `content`. Read defensively.
|
|
170
|
+
if (event.type !== 'assistant' || !isObject(event.message)) return
|
|
171
|
+
const message = event.message as Record<string, unknown>
|
|
172
|
+
const u = claudeCallUsage(message.usage)
|
|
173
|
+
if (u.inputTokens === 0 && u.outputTokens === 0) return
|
|
174
|
+
const content = Array.isArray(message.content) ? message.content : []
|
|
175
|
+
const { text, reasoning } = claudeAssistantContent(content)
|
|
176
|
+
calls.push({
|
|
177
|
+
...(typeof message.model === 'string'
|
|
178
|
+
? { model: message.model }
|
|
179
|
+
: opts.model
|
|
180
|
+
? { model: opts.model }
|
|
181
|
+
: {}),
|
|
182
|
+
// The subagent's own transcript isn't a re-sendable prompt chain, so we don't
|
|
183
|
+
// reconstruct the request side (kept empty); the response + tokens are faithful.
|
|
184
|
+
promptText: '',
|
|
185
|
+
messageCount: 0,
|
|
186
|
+
responseText: redactBody(text, secrets),
|
|
187
|
+
reasoningText: redactBody(reasoning, secrets),
|
|
188
|
+
inputTokens: u.inputTokens,
|
|
189
|
+
cachedInputTokens: u.cachedInputTokens,
|
|
190
|
+
outputTokens: u.outputTokens,
|
|
191
|
+
finishReason: typeof message.stop_reason === 'string' ? message.stop_reason : null,
|
|
192
|
+
})
|
|
193
|
+
usage.inputTokens += u.inputTokens
|
|
194
|
+
usage.outputTokens += u.outputTokens
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const NEWLINE = 0x0a
|
|
198
|
+
const readNew = (path: string, from: number, to: number): Promise<void> =>
|
|
199
|
+
new Promise((resolve) => {
|
|
200
|
+
// Tail as raw bytes and split on the newline byte, decoding each COMPLETE line to
|
|
201
|
+
// UTF-8 only on that boundary (a '\n' is a single byte, never part of a multi-byte
|
|
202
|
+
// sequence), so a record — or a multi-byte character — that spans this read and the
|
|
203
|
+
// next is reassembled from the byte carry rather than corrupted at the seam.
|
|
204
|
+
let buffer = carry.get(path) ?? Buffer.alloc(0)
|
|
205
|
+
const stream = createReadStream(path, { start: from, end: to - 1 })
|
|
206
|
+
stream.on('data', (chunk: Buffer) => {
|
|
207
|
+
buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk
|
|
208
|
+
let nl = buffer.indexOf(NEWLINE)
|
|
209
|
+
while (nl !== -1) {
|
|
210
|
+
ingestLine(buffer.subarray(0, nl).toString('utf8'))
|
|
211
|
+
buffer = buffer.subarray(nl + 1)
|
|
212
|
+
nl = buffer.indexOf(NEWLINE)
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
stream.on('error', () => resolve())
|
|
216
|
+
stream.on('close', () => {
|
|
217
|
+
// Copy the remainder out of the shared chunk backing store before caching it, so a
|
|
218
|
+
// later Buffer.concat can't be aliased by a reused stream buffer.
|
|
219
|
+
carry.set(path, Buffer.from(buffer))
|
|
220
|
+
resolve()
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
const pollOnce = async (): Promise<void> => {
|
|
225
|
+
if (polling) return
|
|
226
|
+
polling = true
|
|
227
|
+
try {
|
|
228
|
+
let entries: string[]
|
|
229
|
+
try {
|
|
230
|
+
entries = (await readdir(dir)).filter((n) => n.endsWith('.jsonl'))
|
|
231
|
+
} catch {
|
|
232
|
+
return // dir not created yet (or vanished) — try again next tick
|
|
233
|
+
}
|
|
234
|
+
let grew = false
|
|
235
|
+
for (const name of entries) {
|
|
236
|
+
const path = join(dir, name)
|
|
237
|
+
let size: number
|
|
238
|
+
try {
|
|
239
|
+
size = (await stat(path)).size
|
|
240
|
+
} catch {
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
const from = offsets.get(path) ?? 0
|
|
244
|
+
if (size <= from) continue
|
|
245
|
+
grew = true
|
|
246
|
+
await readNew(path, from, size)
|
|
247
|
+
offsets.set(path, size)
|
|
248
|
+
}
|
|
249
|
+
if (grew) opts.onActivity?.()
|
|
250
|
+
} catch (e) {
|
|
251
|
+
opts.log?.warn('subagent transcript poll failed', { error: String(e) })
|
|
252
|
+
} finally {
|
|
253
|
+
polling = false
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const timer = setInterval(() => void pollOnce(), opts.intervalMs ?? DEFAULT_POLL_MS)
|
|
258
|
+
// Don't let the watcher's timer keep the container process alive on its own.
|
|
259
|
+
timer.unref?.()
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
// Always does a final drain (idempotent clear of the timer), so a late transcript
|
|
263
|
+
// write between the last tick and stop is still captured, and a second stop() picks up
|
|
264
|
+
// anything appended since — the per-file offsets make re-polling safe (no double count).
|
|
265
|
+
async stop() {
|
|
266
|
+
clearInterval(timer)
|
|
267
|
+
await pollOnce()
|
|
268
|
+
},
|
|
269
|
+
usage() {
|
|
270
|
+
return { ...usage }
|
|
271
|
+
},
|
|
272
|
+
calls() {
|
|
273
|
+
return calls
|
|
274
|
+
},
|
|
275
|
+
}
|
|
276
|
+
}
|