@cat-factory/executor-harness 1.50.10 → 1.50.14
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 +30 -29
- package/dist/job.js +75 -58
- package/dist/subagents.js +73 -18
- package/package.json +3 -3
- package/src/agent-runner.ts +29 -31
- package/src/job.ts +109 -55
- package/src/subagents.ts +88 -24
package/dist/agent-runner.js
CHANGED
|
@@ -5,7 +5,7 @@ import { dirname, join } from 'node:path';
|
|
|
5
5
|
import { claudeAssistantContent, claudeCallUsage, isObject, numberOf, redactBody, } from './claude-stream.js';
|
|
6
6
|
import { killChildProcess, spawnDetached } from './process.js';
|
|
7
7
|
import { redact, secretsToRedact } from './redact.js';
|
|
8
|
-
import { createSliceTracker, startSubagentWatcher } from './subagents.js';
|
|
8
|
+
import { createSliceTracker, pickProgress, startSubagentWatcher } from './subagents.js';
|
|
9
9
|
import { assertOnboardingKeysCurrent, writeOnboardingPreseed } from './onboarding-preseed.js';
|
|
10
10
|
import { retainSessionTranscripts } from './transcript-retention.js';
|
|
11
11
|
/**
|
|
@@ -219,17 +219,19 @@ export async function runClaudeCode(opts) {
|
|
|
219
219
|
{ role: 'user', content: opts.userPrompt },
|
|
220
220
|
];
|
|
221
221
|
const calls = [];
|
|
222
|
-
// ADR 0026 D2.1:
|
|
223
|
-
// their terminal tool_results
|
|
224
|
-
// turns don't)
|
|
225
|
-
//
|
|
226
|
-
//
|
|
222
|
+
// ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from TWO reconciled
|
|
223
|
+
// sources. The parent's `Task` dispatches + their terminal tool_results DO appear on this
|
|
224
|
+
// stream (only a subagent's intermediate turns don't), so `sliceTracker` derives per-slice
|
|
225
|
+
// progress for the parallel-subagent shape; a parent `TodoWrite` plan (the sequential
|
|
226
|
+
// shape) is tracked in `lastTodo`. `pickProgress` picks whichever is further along on each
|
|
227
|
+
// update, so neither masks the other — the pr-reviewer prompt writes its todo plan ONCE
|
|
228
|
+
// and never marks it done, which used to gate the slice signal off and pin progress at 0%.
|
|
227
229
|
const sliceTracker = createSliceTracker();
|
|
228
|
-
let
|
|
229
|
-
const
|
|
230
|
-
if (
|
|
230
|
+
let lastTodo;
|
|
231
|
+
const emitProgress = () => {
|
|
232
|
+
if (!opts.onProgress)
|
|
231
233
|
return;
|
|
232
|
-
const progress = sliceTracker.progress();
|
|
234
|
+
const progress = pickProgress(lastTodo, sliceTracker.progress());
|
|
233
235
|
if (progress)
|
|
234
236
|
opts.onProgress(progress);
|
|
235
237
|
};
|
|
@@ -242,19 +244,14 @@ export async function runClaudeCode(opts) {
|
|
|
242
244
|
stats.assistantChars += text.length;
|
|
243
245
|
stats.toolCalls += toolUses;
|
|
244
246
|
for (const block of content) {
|
|
245
|
-
if (isObject(block) &&
|
|
246
|
-
block.type === 'tool_use' &&
|
|
247
|
-
block.name === 'TodoWrite' &&
|
|
248
|
-
opts.onProgress) {
|
|
247
|
+
if (isObject(block) && block.type === 'tool_use' && block.name === 'TodoWrite') {
|
|
249
248
|
const progress = todosToProgress(block.input?.todos);
|
|
250
|
-
if (progress)
|
|
251
|
-
|
|
252
|
-
opts.onProgress(progress);
|
|
253
|
-
}
|
|
249
|
+
if (progress)
|
|
250
|
+
lastTodo = progress;
|
|
254
251
|
}
|
|
255
252
|
}
|
|
256
253
|
sliceTracker.onAssistant(content);
|
|
257
|
-
|
|
254
|
+
emitProgress();
|
|
258
255
|
// Record this call BEFORE appending its turn: the prompt is the history that
|
|
259
256
|
// produced this response. The append-only array keeps each call's prompt a strict
|
|
260
257
|
// prefix of the next, so the backend's telemetry chain delta-compresses cleanly.
|
|
@@ -277,7 +274,7 @@ export async function runClaudeCode(opts) {
|
|
|
277
274
|
const content = event.message.content;
|
|
278
275
|
if (Array.isArray(content)) {
|
|
279
276
|
sliceTracker.onUser(content);
|
|
280
|
-
|
|
277
|
+
emitProgress();
|
|
281
278
|
messages.push({ role: 'tool', content });
|
|
282
279
|
}
|
|
283
280
|
}
|
|
@@ -337,13 +334,16 @@ export async function runClaudeCode(opts) {
|
|
|
337
334
|
}
|
|
338
335
|
: { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken }),
|
|
339
336
|
};
|
|
340
|
-
// ADR 0026
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
337
|
+
// ADR 0026 D3 (path corrected by ADR 0027 Defect A): while the run is live, tail the CLI's
|
|
338
|
+
// subagent `*.jsonl` transcripts so a parallel-subagent review keeps the inactivity
|
|
339
|
+
// heartbeat alive (any new bytes ⇒ `onActivity`) and its otherwise-invisible token spend is
|
|
340
|
+
// lifted into the run's telemetry. The CLI writes them per-session under
|
|
341
|
+
// `<configHome>/projects/<encoded-cwd>/<session-uuid>/subagents/*.jsonl`, so we watch the
|
|
342
|
+
// `projects` tree and let the watcher discover the `subagents/` dir (the session uuid isn't
|
|
343
|
+
// known up front). Ambient mode has no isolated home to watch. Best-effort — a
|
|
344
|
+
// missing/renamed transcript layout just yields no extra signal.
|
|
345
345
|
const subagents = configHome
|
|
346
|
-
? startSubagentWatcher(join(configHome, '
|
|
346
|
+
? startSubagentWatcher(join(configHome, 'projects'), {
|
|
347
347
|
...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
|
|
348
348
|
secrets,
|
|
349
349
|
model: opts.model,
|
|
@@ -384,9 +384,10 @@ export async function runClaudeCode(opts) {
|
|
|
384
384
|
// is the terminal `result` event's cumulative, which covers ONLY the parent loop — the
|
|
385
385
|
// ADR 0026 incident is itself the proof: a heavily subagent-parallelised review reported
|
|
386
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`
|
|
388
|
-
//
|
|
389
|
-
//
|
|
387
|
+
// spend. The subagent tokens live exclusively in the per-session `subagents/*.jsonl`
|
|
388
|
+
// transcripts, which the watcher reads and nothing else does; it deliberately EXCLUDES the
|
|
389
|
+
// sibling parent session transcript (whose usage `result` already totals), so neither
|
|
390
|
+
// `calls` nor `usage` can already contain the subagent spend.
|
|
390
391
|
const mergedUsage = usage || subUsage.inputTokens || subUsage.outputTokens
|
|
391
392
|
? {
|
|
392
393
|
inputTokens: (usage?.inputTokens ?? 0) + subUsage.inputTokens,
|
package/dist/job.js
CHANGED
|
@@ -647,45 +647,82 @@ export function parseAgentJob(input) {
|
|
|
647
647
|
// preview dispatch to send dummy values it has no reason to supply. Every other mode still
|
|
648
648
|
// requires them (throws when missing/empty), exactly as before.
|
|
649
649
|
const agentField = (value, path) => mode === 'preview' ? (typeof value === 'string' ? value : '') : str(value, path);
|
|
650
|
+
// Parse each field, then hand the pieces to `assembleAgentJob` for the (large) object literal —
|
|
651
|
+
// the parse/assemble split keeps both within the cyclomatic-complexity budget. Behaviour is
|
|
652
|
+
// byte-identical (the literal + host validation moved verbatim).
|
|
653
|
+
const job = assembleAgentJob(o, mode, agentField, {
|
|
654
|
+
output: parseAgentOutputSpec(o.output),
|
|
655
|
+
pr: parseAgentPrSpec(o.pr),
|
|
656
|
+
infra: parseAgentInfraSpec(o.infra),
|
|
657
|
+
peerRepos: parsePeerRepos(o.peerRepos),
|
|
658
|
+
referenceRepos: parseReferenceRepos(o.referenceRepos),
|
|
659
|
+
referenceBranches: parseReferenceBranches(o.referenceBranches),
|
|
660
|
+
bootstrap: parseAgentBootstrapSpec(o.bootstrap),
|
|
661
|
+
contextFiles: parseContextFiles(o.contextFiles),
|
|
662
|
+
packageRegistries: parsePackageRegistries(o.packageRegistries),
|
|
663
|
+
skill: parseSkillSpec(o.skill),
|
|
664
|
+
testSecrets: parseTestSecrets(o.testSecrets),
|
|
665
|
+
guardLimits: parseGuardLimits(o.guardLimits),
|
|
666
|
+
validation: parseValidationSpec(o.validation),
|
|
667
|
+
reviewPrNumber: posInt(o.reviewPrNumber),
|
|
668
|
+
});
|
|
669
|
+
assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl');
|
|
670
|
+
if (job.githubApiBase)
|
|
671
|
+
assertAllowedHost(job.githubApiBase, 'githubApiBase');
|
|
672
|
+
// Bootstrap pushes the result to a SEPARATE target repo, so its clone URL must be an
|
|
673
|
+
// allowed GitHub host too (the installation token is sent to it on the force-push).
|
|
674
|
+
if (job.bootstrap)
|
|
675
|
+
assertAllowedHost(job.bootstrap.target.cloneUrl, 'bootstrap.target.cloneUrl');
|
|
676
|
+
// Each peer repo's clone URL receives the installation token on clone/push, so it must be
|
|
677
|
+
// an allowed GitHub host too — a body-supplied peer pointing at an attacker host would
|
|
678
|
+
// exfiltrate the token exactly like a rogue primary clone URL.
|
|
679
|
+
for (const [i, peer] of (job.peerRepos ?? []).entries()) {
|
|
680
|
+
assertAllowedHost(peer.repo.cloneUrl, `peerRepos[${i}].repo.cloneUrl`);
|
|
681
|
+
}
|
|
682
|
+
// Each reference repo's clone URL receives the installation/PAT token on clone (read-only,
|
|
683
|
+
// never pushed), so it must be an allowed host too — a body-supplied reference pointing at an
|
|
684
|
+
// attacker host would exfiltrate the token exactly like a rogue peer clone URL.
|
|
685
|
+
for (const [i, ref] of (job.referenceRepos ?? []).entries()) {
|
|
686
|
+
assertAllowedHost(ref.repo.cloneUrl, `referenceRepos[${i}].repo.cloneUrl`);
|
|
687
|
+
}
|
|
688
|
+
return job;
|
|
689
|
+
}
|
|
690
|
+
/** Parse the optional structured-output spec (`{ kind, shapeHint?, repair?, failOnUnusableFinal? }`). */
|
|
691
|
+
function parseAgentOutputSpec(raw) {
|
|
692
|
+
if (typeof raw !== 'object' || raw === null)
|
|
693
|
+
return undefined;
|
|
694
|
+
const so = raw;
|
|
695
|
+
const kind = so.kind === 'structured' ? 'structured' : 'prose';
|
|
696
|
+
const spec = { kind };
|
|
697
|
+
if (typeof so.shapeHint === 'string')
|
|
698
|
+
spec.shapeHint = so.shapeHint;
|
|
699
|
+
// Carry an explicit `repair: false` through — the handler defaults to repair-on
|
|
700
|
+
// when absent, so dropping `false` would silently re-enable the repair call for a
|
|
701
|
+
// kind that opted out (it keys off `output.repair === false`).
|
|
702
|
+
if (typeof so.repair === 'boolean')
|
|
703
|
+
spec.repair = so.repair;
|
|
704
|
+
// Carry the opt-in truncation gate through (document producers set it); dropping
|
|
705
|
+
// it would silently re-enable laundering a cut-off reply into a half-baked doc.
|
|
706
|
+
if (so.failOnUnusableFinal === true)
|
|
707
|
+
spec.failOnUnusableFinal = true;
|
|
708
|
+
return spec;
|
|
709
|
+
}
|
|
710
|
+
/** Parse the optional PR spec (`{ title, body }`). */
|
|
711
|
+
function parseAgentPrSpec(raw) {
|
|
712
|
+
if (typeof raw !== 'object' || raw === null)
|
|
713
|
+
return undefined;
|
|
714
|
+
const p = raw;
|
|
715
|
+
return { title: str(p.title, 'pr.title'), body: typeof p.body === 'string' ? p.body : '' };
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Assemble the {@link AgentJob} object from the request `o` + the pre-parsed {@link
|
|
719
|
+
* ParsedAgentJobParts}. Extracted from {@link parseAgentJob} so the large conditional-spread
|
|
720
|
+
* literal doesn't blow the complexity budget; behaviour is byte-identical (spread order preserved).
|
|
721
|
+
*/
|
|
722
|
+
function assembleAgentJob(o, mode, agentField, parts) {
|
|
723
|
+
const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, packageRegistries, skill, testSecrets, guardLimits, validation, reviewPrNumber, } = parts;
|
|
650
724
|
const repo = (o.repo ?? {});
|
|
651
|
-
|
|
652
|
-
? (() => {
|
|
653
|
-
const so = o.output;
|
|
654
|
-
const kind = so.kind === 'structured' ? 'structured' : 'prose';
|
|
655
|
-
const spec = { kind };
|
|
656
|
-
if (typeof so.shapeHint === 'string')
|
|
657
|
-
spec.shapeHint = so.shapeHint;
|
|
658
|
-
// Carry an explicit `repair: false` through — the handler defaults to repair-on
|
|
659
|
-
// when absent, so dropping `false` would silently re-enable the repair call for a
|
|
660
|
-
// kind that opted out (it keys off `output.repair === false`).
|
|
661
|
-
if (typeof so.repair === 'boolean')
|
|
662
|
-
spec.repair = so.repair;
|
|
663
|
-
// Carry the opt-in truncation gate through (document producers set it); dropping
|
|
664
|
-
// it would silently re-enable laundering a cut-off reply into a half-baked doc.
|
|
665
|
-
if (so.failOnUnusableFinal === true)
|
|
666
|
-
spec.failOnUnusableFinal = true;
|
|
667
|
-
return spec;
|
|
668
|
-
})()
|
|
669
|
-
: undefined;
|
|
670
|
-
const pr = typeof o.pr === 'object' && o.pr !== null
|
|
671
|
-
? (() => {
|
|
672
|
-
const p = o.pr;
|
|
673
|
-
return { title: str(p.title, 'pr.title'), body: typeof p.body === 'string' ? p.body : '' };
|
|
674
|
-
})()
|
|
675
|
-
: undefined;
|
|
676
|
-
const infra = parseAgentInfraSpec(o.infra);
|
|
677
|
-
const peerRepos = parsePeerRepos(o.peerRepos);
|
|
678
|
-
const referenceRepos = parseReferenceRepos(o.referenceRepos);
|
|
679
|
-
const referenceBranches = parseReferenceBranches(o.referenceBranches);
|
|
680
|
-
const bootstrap = parseAgentBootstrapSpec(o.bootstrap);
|
|
681
|
-
const contextFiles = parseContextFiles(o.contextFiles);
|
|
682
|
-
const packageRegistries = parsePackageRegistries(o.packageRegistries);
|
|
683
|
-
const skill = parseSkillSpec(o.skill);
|
|
684
|
-
const testSecrets = parseTestSecrets(o.testSecrets);
|
|
685
|
-
const guardLimits = parseGuardLimits(o.guardLimits);
|
|
686
|
-
const validation = parseValidationSpec(o.validation);
|
|
687
|
-
const reviewPrNumber = posInt(o.reviewPrNumber);
|
|
688
|
-
const job = {
|
|
725
|
+
return {
|
|
689
726
|
jobId: str(o.jobId, 'jobId'),
|
|
690
727
|
mode,
|
|
691
728
|
systemPrompt: agentField(o.systemPrompt, 'systemPrompt'),
|
|
@@ -723,24 +760,4 @@ export function parseAgentJob(input) {
|
|
|
723
760
|
...(guardLimits ? { guardLimits } : {}),
|
|
724
761
|
...(validation ? { validation } : {}),
|
|
725
762
|
};
|
|
726
|
-
assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl');
|
|
727
|
-
if (job.githubApiBase)
|
|
728
|
-
assertAllowedHost(job.githubApiBase, 'githubApiBase');
|
|
729
|
-
// Bootstrap pushes the result to a SEPARATE target repo, so its clone URL must be an
|
|
730
|
-
// allowed GitHub host too (the installation token is sent to it on the force-push).
|
|
731
|
-
if (job.bootstrap)
|
|
732
|
-
assertAllowedHost(job.bootstrap.target.cloneUrl, 'bootstrap.target.cloneUrl');
|
|
733
|
-
// Each peer repo's clone URL receives the installation token on clone/push, so it must be
|
|
734
|
-
// an allowed GitHub host too — a body-supplied peer pointing at an attacker host would
|
|
735
|
-
// exfiltrate the token exactly like a rogue primary clone URL.
|
|
736
|
-
for (const [i, peer] of (job.peerRepos ?? []).entries()) {
|
|
737
|
-
assertAllowedHost(peer.repo.cloneUrl, `peerRepos[${i}].repo.cloneUrl`);
|
|
738
|
-
}
|
|
739
|
-
// Each reference repo's clone URL receives the installation/PAT token on clone (read-only,
|
|
740
|
-
// never pushed), so it must be an allowed host too — a body-supplied reference pointing at an
|
|
741
|
-
// attacker host would exfiltrate the token exactly like a rogue peer clone URL.
|
|
742
|
-
for (const [i, ref] of (job.referenceRepos ?? []).entries()) {
|
|
743
|
-
assertAllowedHost(ref.repo.cloneUrl, `referenceRepos[${i}].repo.cloneUrl`);
|
|
744
|
-
}
|
|
745
|
-
return job;
|
|
746
763
|
}
|
package/dist/subagents.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readdir, stat } from 'node:fs/promises';
|
|
2
2
|
import { createReadStream } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
4
|
import { claudeAssistantContent, claudeCallUsage, isObject, redactBody } from './claude-stream.js';
|
|
5
5
|
export function createSliceTracker() {
|
|
6
6
|
// Insertion-ordered so the progress `items` render in dispatch order.
|
|
@@ -54,21 +54,84 @@ export function createSliceTracker() {
|
|
|
54
54
|
},
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Reconcile the two redundant views of the same slice work into the one to surface
|
|
59
|
+
* (ADR 0027 Defect B). A pr-reviewer run has BOTH a parent `TodoWrite` plan (the slices,
|
|
60
|
+
* written once at grouping time) and the {@link SliceTracker}'s `Task`-dispatch view. The
|
|
61
|
+
* sequential shape advances the todo plan; the parallel-subagent shape advances ONLY the
|
|
62
|
+
* slice tracker (the CLI writes the plan once and never marks it done, while the parallel
|
|
63
|
+
* `Task`s report in-flight/complete). Neither alone covers both shapes, and gating the
|
|
64
|
+
* slice tracker OFF whenever a todo plan exists (the old behaviour) pinned parallel runs
|
|
65
|
+
* at 0%. So prefer whichever view is further along: more `completed`, then more
|
|
66
|
+
* `inProgress` (an all-pending todo plan must not beat live in-flight slices), then more
|
|
67
|
+
* `total` (the richer view — the todo plan can carry an extra "aggregate" entry), else the
|
|
68
|
+
* todo plan. Pure + total; returns whichever single input is present when only one is.
|
|
69
|
+
*/
|
|
70
|
+
export function pickProgress(todo, slice) {
|
|
71
|
+
if (!todo)
|
|
72
|
+
return slice;
|
|
73
|
+
if (!slice)
|
|
74
|
+
return todo;
|
|
75
|
+
if (slice.completed !== todo.completed)
|
|
76
|
+
return slice.completed > todo.completed ? slice : todo;
|
|
77
|
+
if (slice.inProgress !== todo.inProgress)
|
|
78
|
+
return slice.inProgress > todo.inProgress ? slice : todo;
|
|
79
|
+
if (slice.total !== todo.total)
|
|
80
|
+
return slice.total > todo.total ? slice : todo;
|
|
81
|
+
return todo;
|
|
82
|
+
}
|
|
57
83
|
// ---------------------------------------------------------------------------
|
|
58
84
|
// Subagent transcript watcher (heartbeat + usage) (D3)
|
|
59
85
|
// ---------------------------------------------------------------------------
|
|
60
86
|
/** Default poll cadence for the transcript directory; well under the git timeout margin. */
|
|
61
87
|
const DEFAULT_POLL_MS = 3_000;
|
|
62
88
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
89
|
+
* Recursively collect every `*.jsonl` file that lives inside a `subagents/` directory
|
|
90
|
+
* anywhere under `root` (the CLI's `<configHome>/projects` tree). The Claude CLI writes
|
|
91
|
+
* each parallel `Task` subagent's transcript to
|
|
92
|
+
* `<configHome>/projects/<encoded-cwd>/<session-uuid>/subagents/agent-*.jsonl`; the
|
|
93
|
+
* session-uuid dir isn't known before the CLI mints it, so we DISCOVER the `subagents/`
|
|
94
|
+
* dir by walking rather than guessing its path (ADR 0027 Defect A). Files NOT under a
|
|
95
|
+
* `subagents/` dir — critically the parent's own `<session-uuid>.jsonl` session transcript,
|
|
96
|
+
* whose per-turn usage the terminal `result` event already totals — are deliberately
|
|
97
|
+
* excluded: reading them would double-count the parent. `root` itself counts as inside a
|
|
98
|
+
* `subagents/` dir when its own basename is `subagents` (so passing the leaf dir works too).
|
|
99
|
+
* Best-effort: an unreadable directory is skipped, never thrown.
|
|
100
|
+
*/
|
|
101
|
+
async function findSubagentTranscripts(root) {
|
|
102
|
+
const out = [];
|
|
103
|
+
const walk = async (dir, inSubagents) => {
|
|
104
|
+
let entries;
|
|
105
|
+
try {
|
|
106
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return; // dir not created yet (or vanished) — try again next tick
|
|
110
|
+
}
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
const full = join(dir, entry.name);
|
|
113
|
+
if (entry.isDirectory()) {
|
|
114
|
+
await walk(full, inSubagents || entry.name === 'subagents');
|
|
115
|
+
}
|
|
116
|
+
else if (inSubagents && entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
117
|
+
out.push(full);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
await walk(root, basename(root) === 'subagents');
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Start watching `root` (the CLI's `<configHome>/projects` tree) for subagent `*.jsonl`
|
|
126
|
+
* transcripts — any file under a `subagents/` directory beneath it (see
|
|
127
|
+
* {@link findSubagentTranscripts}) — tailing each file by byte offset. New content feeds
|
|
128
|
+
* `onActivity` (heartbeat) and each assistant turn carrying usage is lifted into a
|
|
129
|
+
* {@link HarnessCallMetric} + summed into the cumulative usage. Best-effort throughout: the
|
|
130
|
+
* tree may not exist yet (created lazily by the CLI), a file may be mid-write, and the
|
|
131
|
+
* line/usage shape may change across CLI versions — every such case is swallowed so the
|
|
132
|
+
* watcher can only ever ADD signal, never break the run.
|
|
70
133
|
*/
|
|
71
|
-
export function startSubagentWatcher(
|
|
134
|
+
export function startSubagentWatcher(root, opts) {
|
|
72
135
|
const secrets = opts.secrets ?? [];
|
|
73
136
|
const offsets = new Map();
|
|
74
137
|
const calls = [];
|
|
@@ -151,16 +214,8 @@ export function startSubagentWatcher(dir, opts) {
|
|
|
151
214
|
return;
|
|
152
215
|
polling = true;
|
|
153
216
|
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
217
|
let grew = false;
|
|
162
|
-
for (const
|
|
163
|
-
const path = join(dir, name);
|
|
218
|
+
for (const path of await findSubagentTranscripts(root)) {
|
|
164
219
|
let size;
|
|
165
220
|
try {
|
|
166
221
|
size = (await stat(path)).size;
|
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.14",
|
|
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.
|
|
30
|
-
"@cat-factory/spend": "0.12.
|
|
29
|
+
"@cat-factory/server": "0.141.3",
|
|
30
|
+
"@cat-factory/spend": "0.12.73"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
33
|
"build": "tsc -p tsconfig.json",
|
package/src/agent-runner.ts
CHANGED
|
@@ -13,7 +13,7 @@ import type { Logger } from './logger.js'
|
|
|
13
13
|
import type { HarnessCallMetric, PiRunOutcome, PiRunStats, TodoProgress } from './pi.js'
|
|
14
14
|
import { killChildProcess, spawnDetached } from './process.js'
|
|
15
15
|
import { redact, secretsToRedact } from './redact.js'
|
|
16
|
-
import { createSliceTracker, startSubagentWatcher } from './subagents.js'
|
|
16
|
+
import { createSliceTracker, pickProgress, startSubagentWatcher } from './subagents.js'
|
|
17
17
|
import { assertOnboardingKeysCurrent, writeOnboardingPreseed } from './onboarding-preseed.js'
|
|
18
18
|
import { retainSessionTranscripts } from './transcript-retention.js'
|
|
19
19
|
|
|
@@ -325,16 +325,18 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
325
325
|
]
|
|
326
326
|
const calls: HarnessCallMetric[] = []
|
|
327
327
|
|
|
328
|
-
// ADR 0026 D2.1:
|
|
329
|
-
// their terminal tool_results
|
|
330
|
-
// turns don't)
|
|
331
|
-
//
|
|
332
|
-
//
|
|
328
|
+
// ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from TWO reconciled
|
|
329
|
+
// sources. The parent's `Task` dispatches + their terminal tool_results DO appear on this
|
|
330
|
+
// stream (only a subagent's intermediate turns don't), so `sliceTracker` derives per-slice
|
|
331
|
+
// progress for the parallel-subagent shape; a parent `TodoWrite` plan (the sequential
|
|
332
|
+
// shape) is tracked in `lastTodo`. `pickProgress` picks whichever is further along on each
|
|
333
|
+
// update, so neither masks the other — the pr-reviewer prompt writes its todo plan ONCE
|
|
334
|
+
// and never marks it done, which used to gate the slice signal off and pin progress at 0%.
|
|
333
335
|
const sliceTracker = createSliceTracker()
|
|
334
|
-
let
|
|
335
|
-
const
|
|
336
|
-
if (
|
|
337
|
-
const progress = sliceTracker.progress()
|
|
336
|
+
let lastTodo: TodoProgress | undefined
|
|
337
|
+
const emitProgress = (): void => {
|
|
338
|
+
if (!opts.onProgress) return
|
|
339
|
+
const progress = pickProgress(lastTodo, sliceTracker.progress())
|
|
338
340
|
if (progress) opts.onProgress(progress)
|
|
339
341
|
}
|
|
340
342
|
|
|
@@ -347,21 +349,13 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
347
349
|
stats.assistantChars += text.length
|
|
348
350
|
stats.toolCalls += toolUses
|
|
349
351
|
for (const block of content) {
|
|
350
|
-
if (
|
|
351
|
-
isObject(block) &&
|
|
352
|
-
block.type === 'tool_use' &&
|
|
353
|
-
block.name === 'TodoWrite' &&
|
|
354
|
-
opts.onProgress
|
|
355
|
-
) {
|
|
352
|
+
if (isObject(block) && block.type === 'tool_use' && block.name === 'TodoWrite') {
|
|
356
353
|
const progress = todosToProgress((block.input as Record<string, unknown>)?.todos)
|
|
357
|
-
if (progress)
|
|
358
|
-
sawTodoPlan = true
|
|
359
|
-
opts.onProgress(progress)
|
|
360
|
-
}
|
|
354
|
+
if (progress) lastTodo = progress
|
|
361
355
|
}
|
|
362
356
|
}
|
|
363
357
|
sliceTracker.onAssistant(content)
|
|
364
|
-
|
|
358
|
+
emitProgress()
|
|
365
359
|
// Record this call BEFORE appending its turn: the prompt is the history that
|
|
366
360
|
// produced this response. The append-only array keeps each call's prompt a strict
|
|
367
361
|
// prefix of the next, so the backend's telemetry chain delta-compresses cleanly.
|
|
@@ -383,7 +377,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
383
377
|
const content = (event.message as Record<string, unknown>).content
|
|
384
378
|
if (Array.isArray(content)) {
|
|
385
379
|
sliceTracker.onUser(content)
|
|
386
|
-
|
|
380
|
+
emitProgress()
|
|
387
381
|
messages.push({ role: 'tool', content })
|
|
388
382
|
}
|
|
389
383
|
} else if (type === 'result') {
|
|
@@ -446,13 +440,16 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
446
440
|
: { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken! }),
|
|
447
441
|
}
|
|
448
442
|
|
|
449
|
-
// ADR 0026
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
443
|
+
// ADR 0026 D3 (path corrected by ADR 0027 Defect A): while the run is live, tail the CLI's
|
|
444
|
+
// subagent `*.jsonl` transcripts so a parallel-subagent review keeps the inactivity
|
|
445
|
+
// heartbeat alive (any new bytes ⇒ `onActivity`) and its otherwise-invisible token spend is
|
|
446
|
+
// lifted into the run's telemetry. The CLI writes them per-session under
|
|
447
|
+
// `<configHome>/projects/<encoded-cwd>/<session-uuid>/subagents/*.jsonl`, so we watch the
|
|
448
|
+
// `projects` tree and let the watcher discover the `subagents/` dir (the session uuid isn't
|
|
449
|
+
// known up front). Ambient mode has no isolated home to watch. Best-effort — a
|
|
450
|
+
// missing/renamed transcript layout just yields no extra signal.
|
|
454
451
|
const subagents = configHome
|
|
455
|
-
? startSubagentWatcher(join(configHome, '
|
|
452
|
+
? startSubagentWatcher(join(configHome, 'projects'), {
|
|
456
453
|
...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
|
|
457
454
|
secrets,
|
|
458
455
|
model: opts.model,
|
|
@@ -502,9 +499,10 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
502
499
|
// is the terminal `result` event's cumulative, which covers ONLY the parent loop — the
|
|
503
500
|
// ADR 0026 incident is itself the proof: a heavily subagent-parallelised review reported
|
|
504
501
|
// ~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`
|
|
506
|
-
//
|
|
507
|
-
//
|
|
502
|
+
// spend. The subagent tokens live exclusively in the per-session `subagents/*.jsonl`
|
|
503
|
+
// transcripts, which the watcher reads and nothing else does; it deliberately EXCLUDES the
|
|
504
|
+
// sibling parent session transcript (whose usage `result` already totals), so neither
|
|
505
|
+
// `calls` nor `usage` can already contain the subagent spend.
|
|
508
506
|
const mergedUsage =
|
|
509
507
|
usage || subUsage.inputTokens || subUsage.outputTokens
|
|
510
508
|
? {
|
package/src/job.ts
CHANGED
|
@@ -1250,44 +1250,116 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
1250
1250
|
// requires them (throws when missing/empty), exactly as before.
|
|
1251
1251
|
const agentField = (value: unknown, path: string): string =>
|
|
1252
1252
|
mode === 'preview' ? (typeof value === 'string' ? value : '') : str(value, path)
|
|
1253
|
+
// Parse each field, then hand the pieces to `assembleAgentJob` for the (large) object literal —
|
|
1254
|
+
// the parse/assemble split keeps both within the cyclomatic-complexity budget. Behaviour is
|
|
1255
|
+
// byte-identical (the literal + host validation moved verbatim).
|
|
1256
|
+
const job = assembleAgentJob(o, mode, agentField, {
|
|
1257
|
+
output: parseAgentOutputSpec(o.output),
|
|
1258
|
+
pr: parseAgentPrSpec(o.pr),
|
|
1259
|
+
infra: parseAgentInfraSpec(o.infra),
|
|
1260
|
+
peerRepos: parsePeerRepos(o.peerRepos),
|
|
1261
|
+
referenceRepos: parseReferenceRepos(o.referenceRepos),
|
|
1262
|
+
referenceBranches: parseReferenceBranches(o.referenceBranches),
|
|
1263
|
+
bootstrap: parseAgentBootstrapSpec(o.bootstrap),
|
|
1264
|
+
contextFiles: parseContextFiles(o.contextFiles),
|
|
1265
|
+
packageRegistries: parsePackageRegistries(o.packageRegistries),
|
|
1266
|
+
skill: parseSkillSpec(o.skill),
|
|
1267
|
+
testSecrets: parseTestSecrets(o.testSecrets),
|
|
1268
|
+
guardLimits: parseGuardLimits(o.guardLimits),
|
|
1269
|
+
validation: parseValidationSpec(o.validation),
|
|
1270
|
+
reviewPrNumber: posInt(o.reviewPrNumber),
|
|
1271
|
+
})
|
|
1272
|
+
assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl')
|
|
1273
|
+
if (job.githubApiBase) assertAllowedHost(job.githubApiBase, 'githubApiBase')
|
|
1274
|
+
// Bootstrap pushes the result to a SEPARATE target repo, so its clone URL must be an
|
|
1275
|
+
// allowed GitHub host too (the installation token is sent to it on the force-push).
|
|
1276
|
+
if (job.bootstrap) assertAllowedHost(job.bootstrap.target.cloneUrl, 'bootstrap.target.cloneUrl')
|
|
1277
|
+
// Each peer repo's clone URL receives the installation token on clone/push, so it must be
|
|
1278
|
+
// an allowed GitHub host too — a body-supplied peer pointing at an attacker host would
|
|
1279
|
+
// exfiltrate the token exactly like a rogue primary clone URL.
|
|
1280
|
+
for (const [i, peer] of (job.peerRepos ?? []).entries()) {
|
|
1281
|
+
assertAllowedHost(peer.repo.cloneUrl, `peerRepos[${i}].repo.cloneUrl`)
|
|
1282
|
+
}
|
|
1283
|
+
// Each reference repo's clone URL receives the installation/PAT token on clone (read-only,
|
|
1284
|
+
// never pushed), so it must be an allowed host too — a body-supplied reference pointing at an
|
|
1285
|
+
// attacker host would exfiltrate the token exactly like a rogue peer clone URL.
|
|
1286
|
+
for (const [i, ref] of (job.referenceRepos ?? []).entries()) {
|
|
1287
|
+
assertAllowedHost(ref.repo.cloneUrl, `referenceRepos[${i}].repo.cloneUrl`)
|
|
1288
|
+
}
|
|
1289
|
+
return job
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/** The pre-parsed field bundle {@link parseAgentJob} hands to {@link assembleAgentJob}. */
|
|
1293
|
+
interface ParsedAgentJobParts {
|
|
1294
|
+
output: AgentOutputSpec | undefined
|
|
1295
|
+
pr: { title: string; body: string } | undefined
|
|
1296
|
+
infra: ReturnType<typeof parseAgentInfraSpec>
|
|
1297
|
+
peerRepos: ReturnType<typeof parsePeerRepos>
|
|
1298
|
+
referenceRepos: ReturnType<typeof parseReferenceRepos>
|
|
1299
|
+
referenceBranches: ReturnType<typeof parseReferenceBranches>
|
|
1300
|
+
bootstrap: ReturnType<typeof parseAgentBootstrapSpec>
|
|
1301
|
+
contextFiles: ReturnType<typeof parseContextFiles>
|
|
1302
|
+
packageRegistries: ReturnType<typeof parsePackageRegistries>
|
|
1303
|
+
skill: ReturnType<typeof parseSkillSpec>
|
|
1304
|
+
testSecrets: ReturnType<typeof parseTestSecrets>
|
|
1305
|
+
guardLimits: ReturnType<typeof parseGuardLimits>
|
|
1306
|
+
validation: ReturnType<typeof parseValidationSpec>
|
|
1307
|
+
reviewPrNumber: number | undefined
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/** Parse the optional structured-output spec (`{ kind, shapeHint?, repair?, failOnUnusableFinal? }`). */
|
|
1311
|
+
function parseAgentOutputSpec(raw: unknown): AgentOutputSpec | undefined {
|
|
1312
|
+
if (typeof raw !== 'object' || raw === null) return undefined
|
|
1313
|
+
const so = raw as Record<string, unknown>
|
|
1314
|
+
const kind = so.kind === 'structured' ? 'structured' : 'prose'
|
|
1315
|
+
const spec: AgentOutputSpec = { kind }
|
|
1316
|
+
if (typeof so.shapeHint === 'string') spec.shapeHint = so.shapeHint
|
|
1317
|
+
// Carry an explicit `repair: false` through — the handler defaults to repair-on
|
|
1318
|
+
// when absent, so dropping `false` would silently re-enable the repair call for a
|
|
1319
|
+
// kind that opted out (it keys off `output.repair === false`).
|
|
1320
|
+
if (typeof so.repair === 'boolean') spec.repair = so.repair
|
|
1321
|
+
// Carry the opt-in truncation gate through (document producers set it); dropping
|
|
1322
|
+
// it would silently re-enable laundering a cut-off reply into a half-baked doc.
|
|
1323
|
+
if (so.failOnUnusableFinal === true) spec.failOnUnusableFinal = true
|
|
1324
|
+
return spec
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/** Parse the optional PR spec (`{ title, body }`). */
|
|
1328
|
+
function parseAgentPrSpec(raw: unknown): { title: string; body: string } | undefined {
|
|
1329
|
+
if (typeof raw !== 'object' || raw === null) return undefined
|
|
1330
|
+
const p = raw as Record<string, unknown>
|
|
1331
|
+
return { title: str(p.title, 'pr.title'), body: typeof p.body === 'string' ? p.body : '' }
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* Assemble the {@link AgentJob} object from the request `o` + the pre-parsed {@link
|
|
1336
|
+
* ParsedAgentJobParts}. Extracted from {@link parseAgentJob} so the large conditional-spread
|
|
1337
|
+
* literal doesn't blow the complexity budget; behaviour is byte-identical (spread order preserved).
|
|
1338
|
+
*/
|
|
1339
|
+
function assembleAgentJob(
|
|
1340
|
+
o: Record<string, unknown>,
|
|
1341
|
+
mode: AgentJob['mode'],
|
|
1342
|
+
agentField: (value: unknown, path: string) => string,
|
|
1343
|
+
parts: ParsedAgentJobParts,
|
|
1344
|
+
): AgentJob {
|
|
1345
|
+
const {
|
|
1346
|
+
output,
|
|
1347
|
+
pr,
|
|
1348
|
+
infra,
|
|
1349
|
+
peerRepos,
|
|
1350
|
+
referenceRepos,
|
|
1351
|
+
referenceBranches,
|
|
1352
|
+
bootstrap,
|
|
1353
|
+
contextFiles,
|
|
1354
|
+
packageRegistries,
|
|
1355
|
+
skill,
|
|
1356
|
+
testSecrets,
|
|
1357
|
+
guardLimits,
|
|
1358
|
+
validation,
|
|
1359
|
+
reviewPrNumber,
|
|
1360
|
+
} = parts
|
|
1253
1361
|
const repo = (o.repo ?? {}) as Record<string, unknown>
|
|
1254
|
-
|
|
1255
|
-
typeof o.output === 'object' && o.output !== null
|
|
1256
|
-
? (() => {
|
|
1257
|
-
const so = o.output as Record<string, unknown>
|
|
1258
|
-
const kind = so.kind === 'structured' ? 'structured' : 'prose'
|
|
1259
|
-
const spec: AgentOutputSpec = { kind }
|
|
1260
|
-
if (typeof so.shapeHint === 'string') spec.shapeHint = so.shapeHint
|
|
1261
|
-
// Carry an explicit `repair: false` through — the handler defaults to repair-on
|
|
1262
|
-
// when absent, so dropping `false` would silently re-enable the repair call for a
|
|
1263
|
-
// kind that opted out (it keys off `output.repair === false`).
|
|
1264
|
-
if (typeof so.repair === 'boolean') spec.repair = so.repair
|
|
1265
|
-
// Carry the opt-in truncation gate through (document producers set it); dropping
|
|
1266
|
-
// it would silently re-enable laundering a cut-off reply into a half-baked doc.
|
|
1267
|
-
if (so.failOnUnusableFinal === true) spec.failOnUnusableFinal = true
|
|
1268
|
-
return spec
|
|
1269
|
-
})()
|
|
1270
|
-
: undefined
|
|
1271
|
-
const pr =
|
|
1272
|
-
typeof o.pr === 'object' && o.pr !== null
|
|
1273
|
-
? (() => {
|
|
1274
|
-
const p = o.pr as Record<string, unknown>
|
|
1275
|
-
return { title: str(p.title, 'pr.title'), body: typeof p.body === 'string' ? p.body : '' }
|
|
1276
|
-
})()
|
|
1277
|
-
: undefined
|
|
1278
|
-
const infra = parseAgentInfraSpec(o.infra)
|
|
1279
|
-
const peerRepos = parsePeerRepos(o.peerRepos)
|
|
1280
|
-
const referenceRepos = parseReferenceRepos(o.referenceRepos)
|
|
1281
|
-
const referenceBranches = parseReferenceBranches(o.referenceBranches)
|
|
1282
|
-
const bootstrap = parseAgentBootstrapSpec(o.bootstrap)
|
|
1283
|
-
const contextFiles = parseContextFiles(o.contextFiles)
|
|
1284
|
-
const packageRegistries = parsePackageRegistries(o.packageRegistries)
|
|
1285
|
-
const skill = parseSkillSpec(o.skill)
|
|
1286
|
-
const testSecrets = parseTestSecrets(o.testSecrets)
|
|
1287
|
-
const guardLimits = parseGuardLimits(o.guardLimits)
|
|
1288
|
-
const validation = parseValidationSpec(o.validation)
|
|
1289
|
-
const reviewPrNumber = posInt(o.reviewPrNumber)
|
|
1290
|
-
const job: AgentJob = {
|
|
1362
|
+
return {
|
|
1291
1363
|
jobId: str(o.jobId, 'jobId'),
|
|
1292
1364
|
mode,
|
|
1293
1365
|
systemPrompt: agentField(o.systemPrompt, 'systemPrompt'),
|
|
@@ -1325,22 +1397,4 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
1325
1397
|
...(guardLimits ? { guardLimits } : {}),
|
|
1326
1398
|
...(validation ? { validation } : {}),
|
|
1327
1399
|
}
|
|
1328
|
-
assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl')
|
|
1329
|
-
if (job.githubApiBase) assertAllowedHost(job.githubApiBase, 'githubApiBase')
|
|
1330
|
-
// Bootstrap pushes the result to a SEPARATE target repo, so its clone URL must be an
|
|
1331
|
-
// allowed GitHub host too (the installation token is sent to it on the force-push).
|
|
1332
|
-
if (job.bootstrap) assertAllowedHost(job.bootstrap.target.cloneUrl, 'bootstrap.target.cloneUrl')
|
|
1333
|
-
// Each peer repo's clone URL receives the installation token on clone/push, so it must be
|
|
1334
|
-
// an allowed GitHub host too — a body-supplied peer pointing at an attacker host would
|
|
1335
|
-
// exfiltrate the token exactly like a rogue primary clone URL.
|
|
1336
|
-
for (const [i, peer] of (job.peerRepos ?? []).entries()) {
|
|
1337
|
-
assertAllowedHost(peer.repo.cloneUrl, `peerRepos[${i}].repo.cloneUrl`)
|
|
1338
|
-
}
|
|
1339
|
-
// Each reference repo's clone URL receives the installation/PAT token on clone (read-only,
|
|
1340
|
-
// never pushed), so it must be an allowed host too — a body-supplied reference pointing at an
|
|
1341
|
-
// attacker host would exfiltrate the token exactly like a rogue peer clone URL.
|
|
1342
|
-
for (const [i, ref] of (job.referenceRepos ?? []).entries()) {
|
|
1343
|
-
assertAllowedHost(ref.repo.cloneUrl, `referenceRepos[${i}].repo.cloneUrl`)
|
|
1344
|
-
}
|
|
1345
|
-
return job
|
|
1346
1400
|
}
|
package/src/subagents.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { readdir, stat } from 'node:fs/promises'
|
|
2
|
-
import { createReadStream } from 'node:fs'
|
|
3
|
-
import { join } from 'node:path'
|
|
2
|
+
import { createReadStream, type Dirent } from 'node:fs'
|
|
3
|
+
import { basename, join } from 'node:path'
|
|
4
4
|
import { claudeAssistantContent, claudeCallUsage, isObject, redactBody } from './claude-stream.js'
|
|
5
5
|
import type { Logger } from './logger.js'
|
|
6
6
|
import type { HarnessCallMetric, TodoProgress } from './pi.js'
|
|
7
7
|
|
|
8
|
-
// ADR 0026 D2.1 + D3. When the Claude Code CLI reviews a large PR
|
|
9
|
-
// out across parallel `Task` subagents. Two things then go dark to the
|
|
10
|
-
// only reads the PARENT process's stream-json stdout:
|
|
8
|
+
// ADR 0026 D2.1 + D3, corrected by ADR 0027. When the Claude Code CLI reviews a large PR
|
|
9
|
+
// it fans the work out across parallel `Task` subagents. Two things then go dark to the
|
|
10
|
+
// harness, which only reads the PARENT process's stream-json stdout:
|
|
11
11
|
//
|
|
12
12
|
// - the parent stream falls quiet for the whole (potentially 15+ minute) parallel
|
|
13
13
|
// review, so the inactivity heartbeat freezes and a healthy run looks wedged (P3);
|
|
@@ -21,11 +21,19 @@ import type { HarnessCallMetric, TodoProgress } from './pi.js'
|
|
|
21
21
|
// - {@link createSliceTracker} derives the slice plan + per-slice progress from the
|
|
22
22
|
// PARENT stream alone — the `Task` tool_use dispatch and its terminal tool_result
|
|
23
23
|
// DO appear there (only the subagent's intermediate turns don't), so slices/progress
|
|
24
|
-
// need no file watching (D2.1)
|
|
24
|
+
// need no file watching (D2.1). {@link pickProgress} reconciles it with any parent
|
|
25
|
+
// TodoWrite plan (ADR 0027 Defect B);
|
|
25
26
|
// - {@link startSubagentWatcher} tails the `subagents/*.jsonl` transcripts for the
|
|
26
27
|
// heartbeat (any new bytes ⇒ `onActivity`) and sums each subagent turn's usage into
|
|
27
28
|
// the run's telemetry (D3).
|
|
28
29
|
//
|
|
30
|
+
// The CLI does NOT write those transcripts to `<configHome>/subagents` (the location ADR
|
|
31
|
+
// 0026 assumed, which never exists — ADR 0027 Defect A). It writes them PER SESSION under
|
|
32
|
+
// `<configHome>/projects/<encoded-cwd>/<session-uuid>/subagents/agent-*.jsonl`, and the
|
|
33
|
+
// session-uuid dir isn't known before the CLI mints it — so the watcher is pointed at the
|
|
34
|
+
// `projects` root and DISCOVERS the `subagents/` dir by walking (see
|
|
35
|
+
// {@link findSubagentTranscripts}).
|
|
36
|
+
//
|
|
29
37
|
// Both degrade gracefully: the CLI's subagent transcript layout is not a stable contract,
|
|
30
38
|
// so a missing directory, an unreadable file, or an unparseable line is swallowed and the
|
|
31
39
|
// harness falls back to today's parent-stream-only behaviour.
|
|
@@ -52,8 +60,10 @@ export interface SliceTracker {
|
|
|
52
60
|
hasSlices(): boolean
|
|
53
61
|
/**
|
|
54
62
|
* Progress derived from the dispatched subagents (completed / in-flight / total),
|
|
55
|
-
* or undefined when none have been dispatched.
|
|
56
|
-
*
|
|
63
|
+
* or undefined when none have been dispatched. Reconciled with any parent TodoWrite
|
|
64
|
+
* plan by {@link pickProgress} — it is NOT gated off by the presence of a todo plan
|
|
65
|
+
* (that gate was ADR 0027 Defect B: the pr-reviewer prompt writes the plan ONCE at
|
|
66
|
+
* grouping time and never marks it done, which used to permanently mask this signal).
|
|
57
67
|
*/
|
|
58
68
|
progress(): TodoProgress | undefined
|
|
59
69
|
}
|
|
@@ -106,6 +116,31 @@ export function createSliceTracker(): SliceTracker {
|
|
|
106
116
|
}
|
|
107
117
|
}
|
|
108
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Reconcile the two redundant views of the same slice work into the one to surface
|
|
121
|
+
* (ADR 0027 Defect B). A pr-reviewer run has BOTH a parent `TodoWrite` plan (the slices,
|
|
122
|
+
* written once at grouping time) and the {@link SliceTracker}'s `Task`-dispatch view. The
|
|
123
|
+
* sequential shape advances the todo plan; the parallel-subagent shape advances ONLY the
|
|
124
|
+
* slice tracker (the CLI writes the plan once and never marks it done, while the parallel
|
|
125
|
+
* `Task`s report in-flight/complete). Neither alone covers both shapes, and gating the
|
|
126
|
+
* slice tracker OFF whenever a todo plan exists (the old behaviour) pinned parallel runs
|
|
127
|
+
* at 0%. So prefer whichever view is further along: more `completed`, then more
|
|
128
|
+
* `inProgress` (an all-pending todo plan must not beat live in-flight slices), then more
|
|
129
|
+
* `total` (the richer view — the todo plan can carry an extra "aggregate" entry), else the
|
|
130
|
+
* todo plan. Pure + total; returns whichever single input is present when only one is.
|
|
131
|
+
*/
|
|
132
|
+
export function pickProgress(
|
|
133
|
+
todo: TodoProgress | undefined,
|
|
134
|
+
slice: TodoProgress | undefined,
|
|
135
|
+
): TodoProgress | undefined {
|
|
136
|
+
if (!todo) return slice
|
|
137
|
+
if (!slice) return todo
|
|
138
|
+
if (slice.completed !== todo.completed) return slice.completed > todo.completed ? slice : todo
|
|
139
|
+
if (slice.inProgress !== todo.inProgress) return slice.inProgress > todo.inProgress ? slice : todo
|
|
140
|
+
if (slice.total !== todo.total) return slice.total > todo.total ? slice : todo
|
|
141
|
+
return todo
|
|
142
|
+
}
|
|
143
|
+
|
|
109
144
|
// ---------------------------------------------------------------------------
|
|
110
145
|
// Subagent transcript watcher (heartbeat + usage) (D3)
|
|
111
146
|
// ---------------------------------------------------------------------------
|
|
@@ -135,15 +170,51 @@ export interface SubagentWatcher {
|
|
|
135
170
|
}
|
|
136
171
|
|
|
137
172
|
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
173
|
+
* Recursively collect every `*.jsonl` file that lives inside a `subagents/` directory
|
|
174
|
+
* anywhere under `root` (the CLI's `<configHome>/projects` tree). The Claude CLI writes
|
|
175
|
+
* each parallel `Task` subagent's transcript to
|
|
176
|
+
* `<configHome>/projects/<encoded-cwd>/<session-uuid>/subagents/agent-*.jsonl`; the
|
|
177
|
+
* session-uuid dir isn't known before the CLI mints it, so we DISCOVER the `subagents/`
|
|
178
|
+
* dir by walking rather than guessing its path (ADR 0027 Defect A). Files NOT under a
|
|
179
|
+
* `subagents/` dir — critically the parent's own `<session-uuid>.jsonl` session transcript,
|
|
180
|
+
* whose per-turn usage the terminal `result` event already totals — are deliberately
|
|
181
|
+
* excluded: reading them would double-count the parent. `root` itself counts as inside a
|
|
182
|
+
* `subagents/` dir when its own basename is `subagents` (so passing the leaf dir works too).
|
|
183
|
+
* Best-effort: an unreadable directory is skipped, never thrown.
|
|
184
|
+
*/
|
|
185
|
+
async function findSubagentTranscripts(root: string): Promise<string[]> {
|
|
186
|
+
const out: string[] = []
|
|
187
|
+
const walk = async (dir: string, inSubagents: boolean): Promise<void> => {
|
|
188
|
+
let entries: Dirent[]
|
|
189
|
+
try {
|
|
190
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
191
|
+
} catch {
|
|
192
|
+
return // dir not created yet (or vanished) — try again next tick
|
|
193
|
+
}
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
const full = join(dir, entry.name)
|
|
196
|
+
if (entry.isDirectory()) {
|
|
197
|
+
await walk(full, inSubagents || entry.name === 'subagents')
|
|
198
|
+
} else if (inSubagents && entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
199
|
+
out.push(full)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
await walk(root, basename(root) === 'subagents')
|
|
204
|
+
return out
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Start watching `root` (the CLI's `<configHome>/projects` tree) for subagent `*.jsonl`
|
|
209
|
+
* transcripts — any file under a `subagents/` directory beneath it (see
|
|
210
|
+
* {@link findSubagentTranscripts}) — tailing each file by byte offset. New content feeds
|
|
211
|
+
* `onActivity` (heartbeat) and each assistant turn carrying usage is lifted into a
|
|
212
|
+
* {@link HarnessCallMetric} + summed into the cumulative usage. Best-effort throughout: the
|
|
213
|
+
* tree may not exist yet (created lazily by the CLI), a file may be mid-write, and the
|
|
214
|
+
* line/usage shape may change across CLI versions — every such case is swallowed so the
|
|
215
|
+
* watcher can only ever ADD signal, never break the run.
|
|
145
216
|
*/
|
|
146
|
-
export function startSubagentWatcher(
|
|
217
|
+
export function startSubagentWatcher(root: string, opts: SubagentWatcherOptions): SubagentWatcher {
|
|
147
218
|
const secrets = opts.secrets ?? []
|
|
148
219
|
const offsets = new Map<string, number>()
|
|
149
220
|
const calls: HarnessCallMetric[] = []
|
|
@@ -225,15 +296,8 @@ export function startSubagentWatcher(dir: string, opts: SubagentWatcherOptions):
|
|
|
225
296
|
if (polling) return
|
|
226
297
|
polling = true
|
|
227
298
|
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
299
|
let grew = false
|
|
235
|
-
for (const
|
|
236
|
-
const path = join(dir, name)
|
|
300
|
+
for (const path of await findSubagentTranscripts(root)) {
|
|
237
301
|
let size: number
|
|
238
302
|
try {
|
|
239
303
|
size = (await stat(path)).size
|