@cat-factory/executor-harness 1.78.0 → 1.82.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/agent-capabilities.d.ts +130 -0
- package/dist/agent-runner.d.ts +114 -0
- package/dist/agent-runner.js +15 -1
- package/dist/agent-shared.d.ts +18 -0
- package/dist/agent.d.ts +66 -0
- package/dist/bootstrap-mode.d.ts +20 -0
- package/dist/captured-command.d.ts +58 -0
- package/dist/claude-call-aggregator.d.ts +164 -0
- package/dist/claude-call-aggregator.js +123 -17
- package/dist/claude-stream.d.ts +56 -0
- package/dist/claude-stream.js +23 -0
- package/dist/coding-agent.d.ts +263 -0
- package/dist/dependency-install.d.ts +111 -0
- package/dist/effort.d.ts +19 -0
- package/dist/embed.d.ts +4 -0
- package/dist/failure.d.ts +42 -0
- package/dist/follow-ups.d.ts +28 -0
- package/dist/frontend-infra.d.ts +25 -0
- package/dist/fs-utils.d.ts +2 -0
- package/dist/git.d.ts +394 -0
- package/dist/host-markdown.d.ts +28 -0
- package/dist/inline.d.ts +10 -0
- package/dist/job.d.ts +666 -0
- package/dist/logger.d.ts +16 -0
- package/dist/onboarding-preseed.d.ts +24 -0
- package/dist/package-registries.d.ts +32 -0
- package/dist/pi-workspace.d.ts +194 -0
- package/dist/pi-workspace.js +4 -0
- package/dist/pi.d.ts +475 -0
- package/dist/pr-description.d.ts +85 -0
- package/dist/pr-template.d.ts +101 -0
- package/dist/process-exit.d.ts +7 -0
- package/dist/process.d.ts +19 -0
- package/dist/progress-guard.d.ts +88 -0
- package/dist/progress.d.ts +87 -0
- package/dist/redact.d.ts +31 -0
- package/dist/reproduction-proof.d.ts +224 -0
- package/dist/runner.d.ts +282 -0
- package/dist/runner.js +3 -0
- package/dist/server.d.ts +3 -0
- package/dist/structured-output.d.ts +75 -0
- package/dist/subagents.d.ts +88 -0
- package/dist/subagents.js +74 -4
- package/dist/transcript-retention.d.ts +21 -0
- package/dist/validation-checks.d.ts +159 -0
- package/dist/vcs-api.d.ts +73 -0
- package/dist/version.d.ts +2 -0
- package/package.json +9 -5
- package/src/agent-runner.ts +21 -2
- package/src/claude-call-aggregator.ts +181 -32
- package/src/claude-stream.ts +21 -0
- package/src/pi-workspace.ts +4 -0
- package/src/runner.ts +24 -0
- package/src/subagents.ts +57 -3
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { RunOptions } from './runner.js';
|
|
2
|
+
import type { Logger } from './logger.js';
|
|
3
|
+
/** One configured check, as it arrives on the job body. */
|
|
4
|
+
export interface ValidationCheckSpec {
|
|
5
|
+
label: string;
|
|
6
|
+
command: string;
|
|
7
|
+
}
|
|
8
|
+
/** The whole validation config a job carries. */
|
|
9
|
+
export interface ValidationChecksSpec {
|
|
10
|
+
checks: ValidationCheckSpec[];
|
|
11
|
+
/** How many agent+check rounds the loop may run (1 = check once, no repair round). */
|
|
12
|
+
maxAttempts: number;
|
|
13
|
+
}
|
|
14
|
+
/** One command's outcome within an attempt. */
|
|
15
|
+
export interface ValidationCheckOutcome {
|
|
16
|
+
label: string;
|
|
17
|
+
command: string;
|
|
18
|
+
exitCode: number;
|
|
19
|
+
passed: boolean;
|
|
20
|
+
outputTail?: string;
|
|
21
|
+
durationMs?: number;
|
|
22
|
+
timedOut?: boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* One attempt's result: the REPORT that crosses the wire, plus the FULL (scrubbed, 16k) output
|
|
26
|
+
* tails kept in memory for the repair prompt. The two are deliberately separate — see
|
|
27
|
+
* {@link VALIDATION_REPORT_TAIL_CHARS}.
|
|
28
|
+
*/
|
|
29
|
+
export interface ValidationAttempt {
|
|
30
|
+
report: ValidationReport;
|
|
31
|
+
/** Full scrubbed output per check label — never leaves the container. */
|
|
32
|
+
fullTails: Map<string, string>;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The ceiling the harness clamps a body-supplied `validationChecks.maxAttempts` to, and the
|
|
36
|
+
* default it applies when the body omits one.
|
|
37
|
+
*
|
|
38
|
+
* DELIBERATE DUPLICATES of `VALIDATION_MAX_ATTEMPTS_CEILING` / `VALIDATION_DEFAULT_MAX_ATTEMPTS`
|
|
39
|
+
* in `@cat-factory/contracts` — the published image takes no schema dependency, so the harness
|
|
40
|
+
* cannot import them. Keep the two in step: the API validates writes against the contracts
|
|
41
|
+
* values, so a harness clamping to a DIFFERENT ceiling would silently cap a budget an operator
|
|
42
|
+
* was allowed to save, with nothing to flag the mismatch.
|
|
43
|
+
*/
|
|
44
|
+
export declare const VALIDATION_MAX_ATTEMPTS_CEILING = 10;
|
|
45
|
+
export declare const VALIDATION_DEFAULT_MAX_ATTEMPTS = 3;
|
|
46
|
+
/**
|
|
47
|
+
* Parse the optional PRE-PR VALIDATION CHECKS envelope off the job body: the service's ordered
|
|
48
|
+
* `{ label, command }` pairs and the repair-round budget. Every entry needs a non-empty command;
|
|
49
|
+
* entries without one are dropped, and a spec that ends up with no usable check returns
|
|
50
|
+
* `undefined` — so a malformed body degrades to the exact pre-feature behaviour (no loop, PR
|
|
51
|
+
* opens as before) rather than failing an otherwise-good coding run. `maxAttempts` is clamped to
|
|
52
|
+
* a sane range so a bad body can't make a container loop forever.
|
|
53
|
+
*
|
|
54
|
+
* Lives with the feature rather than in `job.ts` so each pre-PR verification phase owns its own
|
|
55
|
+
* job-body parser next to the loop that consumes it (the reproduction proof's
|
|
56
|
+
* `parseReproductionSpec` is the sibling); `job.ts` stays the job SHAPE plus the generic
|
|
57
|
+
* assembly.
|
|
58
|
+
*/
|
|
59
|
+
export declare function parseValidationChecksSpec(value: unknown): ValidationChecksSpec | undefined;
|
|
60
|
+
/** One attempt's report — what the backend records on the step. */
|
|
61
|
+
export interface ValidationReport {
|
|
62
|
+
passed: boolean;
|
|
63
|
+
attempts: number;
|
|
64
|
+
maxAttempts: number;
|
|
65
|
+
outcomes: ValidationCheckOutcome[];
|
|
66
|
+
at: number;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Per-command output kept on the REPORT (what crosses the wire and lands in the run's persisted
|
|
70
|
+
* `detail` blob). Deliberately smaller than `MAX_CAPTURED_OUTPUT_CHARS` (`redact.ts`), which is what the
|
|
71
|
+
* AGENT sees in its repair prompt: the agent needs the full failure to fix it, the operator needs
|
|
72
|
+
* enough to recognise it, and a chatty build must not inflate every run's stored state.
|
|
73
|
+
*/
|
|
74
|
+
export declare const VALIDATION_REPORT_TAIL_CHARS = 4000;
|
|
75
|
+
/**
|
|
76
|
+
* The per-command watchdog: the longest a single check may run before it is killed and treated
|
|
77
|
+
* as a failure, so one hung `pnpm test` cannot wedge a run. Overridable via env for tests;
|
|
78
|
+
* defaults to 15 minutes (matching the ralph completion command's watchdog).
|
|
79
|
+
*/
|
|
80
|
+
export declare function validationCommandTimeoutMs(): number;
|
|
81
|
+
/**
|
|
82
|
+
* How often the check loop feeds the run's inactivity watchdog. Well under the harness's own
|
|
83
|
+
* `JOB_INACTIVITY_MS` (default 10 min) so a single slow command can never look wedged; matches
|
|
84
|
+
* the frontend stand-up's heartbeat, which exists for exactly the same reason. Overridable via
|
|
85
|
+
* env for tests, like {@link validationCommandTimeoutMs}.
|
|
86
|
+
*/
|
|
87
|
+
export declare function validationHeartbeatMs(): number;
|
|
88
|
+
/**
|
|
89
|
+
* Run every configured check IN ORDER against `cwd` and build the attempt's report.
|
|
90
|
+
*
|
|
91
|
+
* Runs all of them even after one fails, rather than short-circuiting: the agent repairing the
|
|
92
|
+
* checkout should see every problem at once instead of rediscovering the next one on the next
|
|
93
|
+
* round, which is the difference between one repair round and four. (A check whose failure makes
|
|
94
|
+
* the rest meaningless — e.g. a failed install — still costs only the cheap downstream failures.)
|
|
95
|
+
*
|
|
96
|
+
* Keeps the run's inactivity watchdog fed for the whole attempt. These commands are exactly the
|
|
97
|
+
* activity-SILENT kind — a cold `install`, a full `test` run, a `build` — and the harness spawns
|
|
98
|
+
* them itself rather than through the agent, so they emit no activity events of their own. The
|
|
99
|
+
* job-level watchdog (`JOB_INACTIVITY_MS`, default 10 min) is TIGHTER than one command's own
|
|
100
|
+
* watchdog ({@link validationCommandTimeoutMs}, default 15 min), so without this a legitimately
|
|
101
|
+
* slow check would abort the entire run as "inactivity" — mislabelling a healthy build as a
|
|
102
|
+
* wedge, and making the per-command timeout unreachable at stock settings.
|
|
103
|
+
*/
|
|
104
|
+
export declare function runValidationChecks(cwd: string, spec: ValidationChecksSpec, attempt: number, logger: Logger, opts: RunOptions): Promise<ValidationAttempt>;
|
|
105
|
+
/**
|
|
106
|
+
* The repair instruction handed to the agent after a failed attempt: the failing commands and
|
|
107
|
+
* their captured output, plus an explicit statement of the exit condition and the remaining
|
|
108
|
+
* budget. The FULL captured tail is used here (not the report's smaller bound) — the agent needs
|
|
109
|
+
* the whole failure to fix it, and this text never leaves the container.
|
|
110
|
+
*
|
|
111
|
+
* Deliberately prescriptive about scope: a validation loop that lets the agent "fix" the failure
|
|
112
|
+
* by weakening the check is worse than no loop at all, so the prompt forbids editing the
|
|
113
|
+
* commands' configuration to make them pass.
|
|
114
|
+
*/
|
|
115
|
+
export declare function buildRepairPrompt(report: ValidationReport, fullTails: Map<string, string>,
|
|
116
|
+
/**
|
|
117
|
+
* New files the agent created but never `git add`ed, if the caller can tell. The harness only
|
|
118
|
+
* auto-stages TRACKED edits (`git add -u`), so an uncommitted new file is invisible to the push
|
|
119
|
+
* — yet fully visible to the checks, which run against the working tree. Naming them here is
|
|
120
|
+
* what stops the loop going green on work the pull request would not contain.
|
|
121
|
+
*/
|
|
122
|
+
untrackedFiles?: string[]): string;
|
|
123
|
+
/**
|
|
124
|
+
* The pre-PR validation LOOP: run the checks, and while they fail and budget remains, hand the
|
|
125
|
+
* captured output back to the agent as its next instruction and check again. Returns the LAST
|
|
126
|
+
* attempt's report — `passed: true` means the caller may open the PR; `passed: false` means the
|
|
127
|
+
* budget is spent and the caller must FAIL the job with this report as the evidence, opening
|
|
128
|
+
* nothing.
|
|
129
|
+
*
|
|
130
|
+
* Generic by construction: it knows nothing about agent kinds, repos or PRs — only how to run
|
|
131
|
+
* commands in a directory and how to ask for another pass. Every input (`workDir`, `spec`,
|
|
132
|
+
* `opts.agentEnv`) is per-job, so two concurrent jobs on the ONE local-native host process cannot
|
|
133
|
+
* see each other's configuration (`validation-checks.concurrency.test.ts` pins this).
|
|
134
|
+
*
|
|
135
|
+
* `onAttempt` publishes each completed attempt on the job view so the loop is observable while it
|
|
136
|
+
* runs; `onAgentPass` lets the caller fold each repair pass's stats/usage/telemetry into the run's
|
|
137
|
+
* totals, so a 3-round loop reports what all 3 rounds actually spent.
|
|
138
|
+
*/
|
|
139
|
+
export declare function runValidationLoop<TRun>(args: {
|
|
140
|
+
workDir: string;
|
|
141
|
+
spec: ValidationChecksSpec;
|
|
142
|
+
logger: Logger;
|
|
143
|
+
opts: RunOptions;
|
|
144
|
+
runAgentPass: (userPrompt: string) => Promise<TRun>;
|
|
145
|
+
onAgentPass?: (run: TRun) => void;
|
|
146
|
+
/**
|
|
147
|
+
* Optional: the new files left uncommitted in the checkout, folded into each repair prompt.
|
|
148
|
+
* Injected rather than read here so this module stays git-agnostic (it knows only how to run
|
|
149
|
+
* commands in a directory and how to ask for another pass); the coding agent, which owns the
|
|
150
|
+
* checkout, supplies it. A throw is swallowed — a missing warning must never fail the loop.
|
|
151
|
+
*/
|
|
152
|
+
listUncommittedNewFiles?: () => Promise<string[]>;
|
|
153
|
+
}): Promise<ValidationReport>;
|
|
154
|
+
/**
|
|
155
|
+
* The failure message for a run whose pre-PR validation never went green: which checks failed
|
|
156
|
+
* (with exit codes) and the last one's captured output. Read by the operator on the step's
|
|
157
|
+
* failure card, so it must say what broke without needing the full report opened.
|
|
158
|
+
*/
|
|
159
|
+
export declare function validationFailureMessage(report: ValidationReport): string;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { PrSpec } from './job.js';
|
|
2
|
+
/**
|
|
3
|
+
* Classify a PR/MR-open REST failure by its HTTP status into an actionable remedy, else
|
|
4
|
+
* undefined (an unmapped status keeps just the raw `Failed to open … (HTTP n)` line). Like
|
|
5
|
+
* {@link describeGitFailure} this only APPENDS a cause + fix — the raw status line is
|
|
6
|
+
* load-bearing detail and stays. `provider` tailors the scope/permission wording (GitHub App
|
|
7
|
+
* Pull-requests permission / `repo` PAT scope vs GitLab `api` scope) and the noun (pull
|
|
8
|
+
* request vs merge request). Pure, so it is unit-tested per status.
|
|
9
|
+
*/
|
|
10
|
+
export declare function describePrOpenFailure(status: number, provider: 'github' | 'gitlab'): string | undefined;
|
|
11
|
+
export interface OpenPullRequestOptions {
|
|
12
|
+
owner: string;
|
|
13
|
+
name: string;
|
|
14
|
+
ghToken: string;
|
|
15
|
+
head: string;
|
|
16
|
+
base: string;
|
|
17
|
+
pr: PrSpec;
|
|
18
|
+
apiBase?: string;
|
|
19
|
+
/**
|
|
20
|
+
* The repo's clone URL. Used (when {@link provider} is absent) to detect the provider and,
|
|
21
|
+
* for GitLab, to derive the REST base + project path from its host — so the harness opens a
|
|
22
|
+
* GitLab **merge request** rather than POSTing to GitHub's pulls API. Absent ⇒ GitHub.
|
|
23
|
+
*/
|
|
24
|
+
cloneUrl?: string;
|
|
25
|
+
/**
|
|
26
|
+
* The VCS provider, when the dispatcher knows it (the server derives it from the configured
|
|
27
|
+
* source-control backend and sets `repo.provider`). AUTHORITATIVE — it overrides host
|
|
28
|
+
* inference — so a self-managed GitLab on an arbitrarily-named host (e.g. `git.acme.com`,
|
|
29
|
+
* which {@link inferVcsProvider} can't recognise) still opens a merge request instead of
|
|
30
|
+
* being misrouted to GitHub's API. Absent ⇒ inferred from {@link cloneUrl}'s host.
|
|
31
|
+
*/
|
|
32
|
+
provider?: 'github' | 'gitlab';
|
|
33
|
+
/**
|
|
34
|
+
* When the PR/MR for {@link head} ALREADY exists (a resumed run pushing onto a branch whose PR
|
|
35
|
+
* is open), replace its title and description with {@link pr} instead of leaving them alone.
|
|
36
|
+
*
|
|
37
|
+
* Set ONLY when {@link pr} carries the agent's own reviewer briefing. A resumed run is exactly
|
|
38
|
+
* the case that matters — eviction + re-dispatch, a ralph iteration, a retry — and without this
|
|
39
|
+
* the agent writes a briefing the platform reads, scrubs, caps and then silently drops. It must
|
|
40
|
+
* stay opt-in, though: refreshing from the GENERIC dispatch-time fallback would overwrite a
|
|
41
|
+
* description a human (or an earlier, better-informed run) had already written.
|
|
42
|
+
*
|
|
43
|
+
* The engine's managed verification-report region is carried across the rewrite by
|
|
44
|
+
* {@link preserveManagedSection}, and the update is best-effort — a failed refresh keeps the
|
|
45
|
+
* run's real outcome, which is the pushed work.
|
|
46
|
+
*/
|
|
47
|
+
refreshExisting?: boolean;
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The VCS host a clone URL points at. The harness is otherwise provider-agnostic (its git
|
|
52
|
+
* auth is a host-neutral GIT_ASKPASS credential), but the "open the PR/MR" REST call is not:
|
|
53
|
+
* GitHub and GitLab have different endpoints, so infer which to call from the host. GitHub is
|
|
54
|
+
* the default; a host of `gitlab.com` or one in the `gitlab.*` / `*.gitlab.*` family (covering
|
|
55
|
+
* self-managed instances named that way) is treated as GitLab.
|
|
56
|
+
*/
|
|
57
|
+
export declare function inferVcsProvider(cloneUrl: string): 'github' | 'gitlab';
|
|
58
|
+
/** The GitLab REST v4 base for a clone URL's host, e.g. `https://gitlab.com/api/v4`. */
|
|
59
|
+
export declare function gitlabApiBaseFromCloneUrl(cloneUrl: string): string;
|
|
60
|
+
/**
|
|
61
|
+
* The URL-encoded GitLab project path from a clone URL — the full namespace path (so subgroups
|
|
62
|
+
* survive), with the trailing `.git` stripped, e.g.
|
|
63
|
+
* `https://gitlab.com/group/sub/proj.git` → `group%2Fsub%2Fproj`.
|
|
64
|
+
*/
|
|
65
|
+
export declare function gitlabProjectPath(cloneUrl: string): string;
|
|
66
|
+
/**
|
|
67
|
+
* Open a PR (GitHub) or merge request (GitLab) for the pushed branch; returns its web URL.
|
|
68
|
+
* The provider is chosen from the EXPLICIT `opts.provider` when the dispatcher set it,
|
|
69
|
+
* falling back to host inference from the clone URL only when it didn't — so a self-managed
|
|
70
|
+
* GitLab whose host isn't named `gitlab.*` still opens an MR instead of being misrouted to
|
|
71
|
+
* GitHub's API. The GitHub path is unchanged.
|
|
72
|
+
*/
|
|
73
|
+
export declare function openPullRequest(opts: OpenPullRequestOptions): Promise<string | null>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.82.0",
|
|
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",
|
|
@@ -15,7 +15,11 @@
|
|
|
15
15
|
"main": "./dist/server.js",
|
|
16
16
|
"exports": {
|
|
17
17
|
".": "./dist/server.js",
|
|
18
|
-
"./embed": "./src/embed.ts"
|
|
18
|
+
"./embed": "./src/embed.ts",
|
|
19
|
+
"./claude-call-aggregator": {
|
|
20
|
+
"types": "./dist/claude-call-aggregator.d.ts",
|
|
21
|
+
"default": "./dist/claude-call-aggregator.js"
|
|
22
|
+
}
|
|
19
23
|
},
|
|
20
24
|
"publishConfig": {
|
|
21
25
|
"access": "public"
|
|
@@ -26,9 +30,9 @@
|
|
|
26
30
|
"hono": "^4.12.32",
|
|
27
31
|
"typescript": "7.0.2",
|
|
28
32
|
"vitest": "^4.1.10",
|
|
29
|
-
"@cat-factory/kernel": "0.
|
|
30
|
-
"@cat-factory/
|
|
31
|
-
"@cat-factory/
|
|
33
|
+
"@cat-factory/kernel": "0.201.0",
|
|
34
|
+
"@cat-factory/server": "0.185.1",
|
|
35
|
+
"@cat-factory/spend": "0.12.131"
|
|
32
36
|
},
|
|
33
37
|
"scripts": {
|
|
34
38
|
"build": "tsc -p tsconfig.json",
|
package/src/agent-runner.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { ProgressGuard, type ProgressGuardLimits } from './progress-guard.js'
|
|
|
26
26
|
import { killChildProcess, spawnDetached } from './process.js'
|
|
27
27
|
import { describeProcessExit } from './process-exit.js'
|
|
28
28
|
import { redact, registerKnownSecrets, secretsToRedact } from './redact.js'
|
|
29
|
-
import { createSliceTracker, startSubagentWatcher } from './subagents.js'
|
|
29
|
+
import { createSliceTracker, startSubagentWatcher, type SliceReview } from './subagents.js'
|
|
30
30
|
import {
|
|
31
31
|
createTaskPlanTracker,
|
|
32
32
|
mergeProgress,
|
|
@@ -123,6 +123,13 @@ export interface SubscriptionRunOptions {
|
|
|
123
123
|
onActivity?: () => void
|
|
124
124
|
/** Called with the latest subtask counts each time the CLI updates its todo/plan list. */
|
|
125
125
|
onProgress?: (progress: TodoProgress) => void
|
|
126
|
+
/**
|
|
127
|
+
* Called with the FULL set of per-slice reviews each time one lands, so the backend can persist
|
|
128
|
+
* a parallel review's completed work as it happens instead of only from the terminal result.
|
|
129
|
+
* A whole value rather than a delta: the set only grows and losing a finished slice's report to
|
|
130
|
+
* a dropped poll would defeat the point (see `SliceTracker.sliceReviews`).
|
|
131
|
+
*/
|
|
132
|
+
onSliceReviews?: (reviews: SliceReview[]) => void
|
|
126
133
|
/**
|
|
127
134
|
* Called with each per-call telemetry row as the CLI stream yields it, so the backend can
|
|
128
135
|
* record the run's model calls WHILE it runs instead of only from its terminal result. The
|
|
@@ -542,7 +549,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
542
549
|
// either/or; the plan then MERGES with the dispatch view (`mergeProgress`) rather than
|
|
543
550
|
// competing with it — picking the further-along view collapsed the list to the dispatched
|
|
544
551
|
// slices alone the moment the first subagent returned. See ./progress.ts.
|
|
545
|
-
const sliceTracker = createSliceTracker()
|
|
552
|
+
const sliceTracker = createSliceTracker(secrets)
|
|
546
553
|
const planTracker = createTaskPlanTracker()
|
|
547
554
|
let lastTodo: TodoProgress | undefined
|
|
548
555
|
const emitProgress = (): void => {
|
|
@@ -553,6 +560,15 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
553
560
|
)
|
|
554
561
|
if (progress) opts.onProgress(progress)
|
|
555
562
|
}
|
|
563
|
+
// Publish the per-slice reviews the tracker has captured. Separate from `emitProgress` because
|
|
564
|
+
// the two answer different questions and have different lifetimes: progress is a disposable
|
|
565
|
+
// count the UI renders, while these carry the slices' actual review WORK and are persisted so a
|
|
566
|
+
// run that dies before its aggregation can be resumed from them.
|
|
567
|
+
const emitSliceReviews = (): void => {
|
|
568
|
+
if (!opts.onSliceReviews) return
|
|
569
|
+
const reviews = sliceTracker.sliceReviews()
|
|
570
|
+
if (reviews.length > 0) opts.onSliceReviews(reviews)
|
|
571
|
+
}
|
|
556
572
|
|
|
557
573
|
// No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
|
|
558
574
|
// absent on this path until now. Claude Code reports a tool CALL (its name) on the `assistant`
|
|
@@ -626,6 +642,9 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
626
642
|
sliceTracker.onUser(content)
|
|
627
643
|
planTracker.onUser(content)
|
|
628
644
|
emitProgress()
|
|
645
|
+
// A slice's report lands on exactly this turn, so publish here: waiting for the next
|
|
646
|
+
// progress tick would risk the job dying with the report captured but never surfaced.
|
|
647
|
+
emitSliceReviews()
|
|
629
648
|
// Not on the at-close flush: the CLI has already exited, so tripping the guard there
|
|
630
649
|
// would kill nothing and only convert a clean exit into a spurious failure.
|
|
631
650
|
if (!meta?.final) feedGuard(content)
|
|
@@ -12,6 +12,19 @@ import type { HarnessCallMetric } from './pi.js'
|
|
|
12
12
|
// This aggregator folds every envelope sharing a `message.id` back into the one call it belongs to,
|
|
13
13
|
// and buffers that call's tool_result turns so the reconstructed prompt chain keeps the shape the
|
|
14
14
|
// model was actually sent: one assistant turn holding all its blocks, then the results.
|
|
15
|
+
//
|
|
16
|
+
// TWO CONSUMERS, and a change here reaches both. The container/host harness drives it from a
|
|
17
|
+
// coding job's stdout (`agent-runner.ts`), and the BACKEND drives it from the host `claude` an
|
|
18
|
+
// inline step runs on (`runtimes/local/src/harnessInline.ts`, importing this module through the
|
|
19
|
+
// package's `./claude-call-aggregator` subpath). Both parse the SAME `stream-json`, and the second
|
|
20
|
+
// used to carry its own lesser fold — which is how the inline path came to report one lumped call
|
|
21
|
+
// per step while the container path reported every turn. Keep this the only implementation; it is
|
|
22
|
+
// also the one place the per-block over-count above is fixed.
|
|
23
|
+
//
|
|
24
|
+
// The second consumer is why the transcript is BOUNDED and body assembly is OPTIONAL (see
|
|
25
|
+
// {@link MAX_TRANSCRIPT_CHARS} and `bodies`). Inside a container the reconstruction is one job's
|
|
26
|
+
// worth of memory in a box sized for it; in the backend it runs per concurrent inline step, in the
|
|
27
|
+
// orchestrator process, on precisely the long tool loops worth diagnosing.
|
|
15
28
|
|
|
16
29
|
/** One model call, assembled from every stream envelope that carried a piece of it. */
|
|
17
30
|
export interface AggregatedClaudeCall {
|
|
@@ -134,6 +147,128 @@ interface TranscriptTurn {
|
|
|
134
147
|
content: unknown
|
|
135
148
|
}
|
|
136
149
|
|
|
150
|
+
/**
|
|
151
|
+
* How much reconstructed transcript ONE conversation may retain.
|
|
152
|
+
*
|
|
153
|
+
* The stream feeds this without limit — a tool loop that reads large files grows the history by
|
|
154
|
+
* every one of them — and the reconstruction is held in the driver's own process. In the container
|
|
155
|
+
* that is a box sized for one job; in the BACKEND it is the orchestrator, where `streamCli` already
|
|
156
|
+
* refuses to retain the raw stream for exactly this reason (`harnessInline.ts` →
|
|
157
|
+
* `OUTPUT_TAIL_RETAIN_CHARS`: "a stalled tool-using run would otherwise park hundreds of MB in the
|
|
158
|
+
* orchestrator process — precisely on the runs worth diagnosing").
|
|
159
|
+
*
|
|
160
|
+
* 512 KiB because that is `LlmObservabilityService.MAX_BODY_CHARS`, the point past which the store
|
|
161
|
+
* truncates a body anyway: retaining more can only ever be thrown away. Deliberately NOT a
|
|
162
|
+
* per-deployment knob — it bounds a memory fault, and a number an operator can raise is one an
|
|
163
|
+
* operator can raise until the process dies.
|
|
164
|
+
*/
|
|
165
|
+
export const MAX_TRANSCRIPT_CHARS = 512 * 1024
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The role a turn carries when it is not a turn at all, but the note saying what stopped being
|
|
169
|
+
* retained. A distinct namespaced role rather than `system`, so nothing downstream can read it as a
|
|
170
|
+
* message that was actually sent — the same reason `seed` exists.
|
|
171
|
+
*/
|
|
172
|
+
const ELIDED_ROLE = 'cat-factory:elided'
|
|
173
|
+
|
|
174
|
+
/** The growing request transcript behind a conversation's `promptText`. */
|
|
175
|
+
interface Transcript {
|
|
176
|
+
/** The history as of NOW: what the call starting at this moment was sent. */
|
|
177
|
+
snapshot(): { text: string; messageCount: number }
|
|
178
|
+
/** Add a turn the conversation has now completed. */
|
|
179
|
+
append(turn: TranscriptTurn): void
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Retain the transcript up to {@link MAX_TRANSCRIPT_CHARS} and then STOP, stating what it stopped
|
|
184
|
+
* retaining rather than silently ending mid-conversation.
|
|
185
|
+
*
|
|
186
|
+
* Freezing the tail (rather than evicting the head) keeps the seed and the early history — the
|
|
187
|
+
* task, and the turns that explain what the loop is doing — and keeps each call's `promptText` a
|
|
188
|
+
* stable PREFIX plus a changing note, so the backend's chain delta-compresses right up to the bound
|
|
189
|
+
* and only then degrades to storing the (now capped) array. Evicting the head would drop the task
|
|
190
|
+
* itself and break the prefix property from the first eviction on.
|
|
191
|
+
*
|
|
192
|
+
* The seed is never dropped: it is what the CALLER sent, so it is bounded by the caller's own
|
|
193
|
+
* prompt rather than by the stream, and it is the half a reader cannot reconstruct from anything
|
|
194
|
+
* else.
|
|
195
|
+
*/
|
|
196
|
+
function createBoundedTranscript(
|
|
197
|
+
seed: TranscriptTurn[],
|
|
198
|
+
secrets: string[],
|
|
199
|
+
maxChars: number,
|
|
200
|
+
): Transcript {
|
|
201
|
+
const turns: TranscriptTurn[] = [...seed]
|
|
202
|
+
const sizeOf = (turn: TranscriptTurn): number => {
|
|
203
|
+
try {
|
|
204
|
+
return JSON.stringify(turn)?.length ?? 0
|
|
205
|
+
} catch {
|
|
206
|
+
// An un-serialisable turn cannot be retained at all, so charge it nothing and let the
|
|
207
|
+
// append below drop it on its own terms.
|
|
208
|
+
return Number.POSITIVE_INFINITY
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
let retained = turns.reduce((n, turn) => n + sizeOf(turn), 0)
|
|
212
|
+
const dropped = { turns: 0, chars: 0 }
|
|
213
|
+
return {
|
|
214
|
+
append(turn) {
|
|
215
|
+
const size = sizeOf(turn)
|
|
216
|
+
if (retained + size > maxChars) {
|
|
217
|
+
dropped.turns += 1
|
|
218
|
+
dropped.chars += Number.isFinite(size) ? size : 0
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
turns.push(turn)
|
|
222
|
+
retained += size
|
|
223
|
+
},
|
|
224
|
+
snapshot() {
|
|
225
|
+
const encoded = dropped.turns
|
|
226
|
+
? [
|
|
227
|
+
...turns,
|
|
228
|
+
{
|
|
229
|
+
role: ELIDED_ROLE,
|
|
230
|
+
content:
|
|
231
|
+
`${dropped.turns} later turn(s), ${dropped.chars} chars, were not retained: ` +
|
|
232
|
+
`this conversation reached the ${maxChars}-char reconstruction bound`,
|
|
233
|
+
},
|
|
234
|
+
]
|
|
235
|
+
: turns
|
|
236
|
+
return {
|
|
237
|
+
text: redactBody(safeSerialise(encoded), secrets),
|
|
238
|
+
messageCount: encoded.length,
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Count the turns and assemble NO bodies — for a driver that has nowhere to put them (the backend
|
|
246
|
+
* with `LLM_RECORD_PROMPTS` off, where the store drops every body it is handed).
|
|
247
|
+
*
|
|
248
|
+
* `messageCount` stays real, because it is a COUNT rather than a body and every consumer of the
|
|
249
|
+
* metric wants it. The point is not to omit data the gate would keep; it is that serialising a
|
|
250
|
+
* transcript the gate is about to drop is pure cost, and the whole reason bodies travel to the
|
|
251
|
+
* recorder as thunks (`CLAUDE.md` → "Telemetry & agent-context observability").
|
|
252
|
+
*/
|
|
253
|
+
function createCountingTranscript(seed: TranscriptTurn[]): Transcript {
|
|
254
|
+
let messageCount = seed.length
|
|
255
|
+
return {
|
|
256
|
+
append() {
|
|
257
|
+
messageCount += 1
|
|
258
|
+
},
|
|
259
|
+
snapshot: () => ({ text: '', messageCount }),
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Serialise a transcript for the store, never throwing into the stream that produced it. */
|
|
264
|
+
function safeSerialise(turns: TranscriptTurn[]): string {
|
|
265
|
+
try {
|
|
266
|
+
return JSON.stringify(turns) ?? ''
|
|
267
|
+
} catch {
|
|
268
|
+
return ''
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
137
272
|
/** The per-call telemetry the Claude Code stream yields, assembled behind one small surface. */
|
|
138
273
|
export interface ClaudeStreamTelemetry {
|
|
139
274
|
/** Fold an `assistant` envelope in (parent-loop turns only — see {@link isSubagentEvent}). */
|
|
@@ -155,20 +290,26 @@ export interface ClaudeStreamTelemetry {
|
|
|
155
290
|
* was not sent. A subagent's conversation seeds EMPTY — its prompt was minted by the CLI and never
|
|
156
291
|
* crosses this stream — which is also why its first call carries `messageCount: 0` (the backend's
|
|
157
292
|
* `latestChainTip` skips those on purpose: there is no re-sendable chain to delta against).
|
|
158
|
-
* Bodies are credential-scrubbed; they can echo the leased token
|
|
293
|
+
* Bodies are credential-scrubbed; they can echo the leased token — and assembled at all only when
|
|
294
|
+
* {@link ClaudeStreamTelemetryOptions.bodies} says a driver has somewhere to put them. The
|
|
295
|
+
* transcript is bounded either way ({@link MAX_TRANSCRIPT_CHARS}).
|
|
159
296
|
*
|
|
160
297
|
* Deliberately does NOT touch {@link PiRunStats}: the run's tool/output counters describe whether
|
|
161
298
|
* the agent ACTED at all (`agentNeverActed`), which is true of a subagent's turns whichever channel
|
|
162
299
|
* ends up owning their telemetry rows. The caller accumulates them off the raw stream instead.
|
|
163
300
|
*/
|
|
164
|
-
export function createClaudeStreamTelemetry(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
301
|
+
export function createClaudeStreamTelemetry(
|
|
302
|
+
opts: ClaudeStreamTelemetryOptions,
|
|
303
|
+
): ClaudeStreamTelemetry {
|
|
304
|
+
const bodies = opts.bodies ?? true
|
|
305
|
+
const transcript = bodies
|
|
306
|
+
? createBoundedTranscript(
|
|
307
|
+
opts.seed,
|
|
308
|
+
opts.secrets,
|
|
309
|
+
opts.maxTranscriptChars ?? MAX_TRANSCRIPT_CHARS,
|
|
310
|
+
)
|
|
311
|
+
: createCountingTranscript(opts.seed)
|
|
312
|
+
let sent = { text: '', messageCount: 0 }
|
|
172
313
|
|
|
173
314
|
// The aggregator IS the surface: the transcript and metric work happens in its callbacks, so
|
|
174
315
|
// there is nothing to wrap it in.
|
|
@@ -177,16 +318,15 @@ export function createClaudeStreamTelemetry(opts: {
|
|
|
177
318
|
// produced the response, and later envelopes of the same call must not see the turns it
|
|
178
319
|
// went on to add.
|
|
179
320
|
onCallStart: () => {
|
|
180
|
-
|
|
181
|
-
callMessageCount = messages.length
|
|
321
|
+
sent = transcript.snapshot()
|
|
182
322
|
},
|
|
183
323
|
onCall: (call) => {
|
|
184
324
|
opts.publish({
|
|
185
325
|
...(call.model ? { model: call.model } : {}),
|
|
186
|
-
promptText:
|
|
187
|
-
messageCount:
|
|
188
|
-
responseText: redactBody(call.text, opts.secrets),
|
|
189
|
-
reasoningText: redactBody(call.reasoning, opts.secrets),
|
|
326
|
+
promptText: sent.text,
|
|
327
|
+
messageCount: sent.messageCount,
|
|
328
|
+
responseText: bodies ? redactBody(call.text, opts.secrets) : '',
|
|
329
|
+
reasoningText: bodies ? redactBody(call.reasoning, opts.secrets) : '',
|
|
190
330
|
inputTokens: call.inputTokens,
|
|
191
331
|
cacheReadTokens: call.cacheReadTokens,
|
|
192
332
|
cacheWriteTokens: call.cacheWriteTokens,
|
|
@@ -195,12 +335,30 @@ export function createClaudeStreamTelemetry(opts: {
|
|
|
195
335
|
})
|
|
196
336
|
// Appended only now, so each call's prompt stays a strict prefix of the next and the
|
|
197
337
|
// backend's telemetry chain delta-compresses cleanly.
|
|
198
|
-
|
|
199
|
-
for (const result of call.toolResults)
|
|
338
|
+
transcript.append({ role: 'assistant', content: call.content })
|
|
339
|
+
for (const result of call.toolResults) transcript.append({ role: 'tool', content: result })
|
|
200
340
|
},
|
|
201
341
|
})
|
|
202
342
|
}
|
|
203
343
|
|
|
344
|
+
/** How one conversation's per-call telemetry is assembled. */
|
|
345
|
+
export interface ClaudeStreamTelemetryOptions {
|
|
346
|
+
seed: TranscriptTurn[]
|
|
347
|
+
secrets: string[]
|
|
348
|
+
publish: (metric: HarnessCallMetric) => void
|
|
349
|
+
/**
|
|
350
|
+
* Whether to assemble the prompt/response BODIES at all. Absent ⇒ true (the container harness,
|
|
351
|
+
* whose job result carries them).
|
|
352
|
+
*
|
|
353
|
+
* `false` for a driver whose store will drop them — the backend with `LLM_RECORD_PROMPTS` off —
|
|
354
|
+
* where reconstructing a transcript per call is pure cost. Token counts, `messageCount` and
|
|
355
|
+
* finish reasons are unaffected: only the bodies go.
|
|
356
|
+
*/
|
|
357
|
+
bodies?: boolean
|
|
358
|
+
/** Retention bound override (tests). Absent ⇒ {@link MAX_TRANSCRIPT_CHARS}. */
|
|
359
|
+
maxTranscriptChars?: number
|
|
360
|
+
}
|
|
361
|
+
|
|
204
362
|
/**
|
|
205
363
|
* The dispatch (`Agent`/`Task` tool_use) id a stream envelope is tagged with, or `undefined` for a
|
|
206
364
|
* parent-loop turn.
|
|
@@ -238,10 +396,7 @@ export function isSubagentEvent(event: Record<string, unknown>): boolean {
|
|
|
238
396
|
* one stream: folding them into a single chain is exactly the defect this whole module removes,
|
|
239
397
|
* one level down.
|
|
240
398
|
*/
|
|
241
|
-
function createSubagentStreamTelemetry(opts: {
|
|
242
|
-
secrets: string[]
|
|
243
|
-
publish: (metric: HarnessCallMetric) => void
|
|
244
|
-
}): {
|
|
399
|
+
function createSubagentStreamTelemetry(opts: Omit<ClaudeStreamTelemetryOptions, 'seed'>): {
|
|
245
400
|
onAssistant(dispatchId: string, message: Record<string, unknown>): void
|
|
246
401
|
onToolResult(dispatchId: string, content: unknown[]): void
|
|
247
402
|
flush(): void
|
|
@@ -251,11 +406,8 @@ function createSubagentStreamTelemetry(opts: {
|
|
|
251
406
|
let telemetry = perDispatch.get(dispatchId)
|
|
252
407
|
if (!telemetry) {
|
|
253
408
|
// Seeded EMPTY: the CLI minted this subagent's prompt and it never crossed the stream.
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
secrets: opts.secrets,
|
|
257
|
-
publish: opts.publish,
|
|
258
|
-
})
|
|
409
|
+
// Each dispatch gets its OWN retention bound, since each is its own conversation.
|
|
410
|
+
telemetry = createClaudeStreamTelemetry({ ...opts, seed: [] })
|
|
259
411
|
perDispatch.set(dispatchId, telemetry)
|
|
260
412
|
}
|
|
261
413
|
return telemetry
|
|
@@ -301,12 +453,9 @@ export interface ClaudeRunTelemetry {
|
|
|
301
453
|
* instead, on per-dispatch transcripts of their own. Dropping them in that case would leave the run
|
|
302
454
|
* billed by neither channel, and an under-count reads as a cheap run rather than as an error.
|
|
303
455
|
*/
|
|
304
|
-
export function createClaudeRunTelemetry(
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
watcherOwnsSubagents: boolean
|
|
308
|
-
publish: (metric: HarnessCallMetric) => void
|
|
309
|
-
}): ClaudeRunTelemetry {
|
|
456
|
+
export function createClaudeRunTelemetry(
|
|
457
|
+
opts: ClaudeStreamTelemetryOptions & { watcherOwnsSubagents: boolean },
|
|
458
|
+
): ClaudeRunTelemetry {
|
|
310
459
|
const parent = createClaudeStreamTelemetry(opts)
|
|
311
460
|
const subagents = opts.watcherOwnsSubagents ? undefined : createSubagentStreamTelemetry(opts)
|
|
312
461
|
let sawSubagentTurn = false
|
package/src/claude-stream.ts
CHANGED
|
@@ -57,6 +57,27 @@ export function claudeAssistantContent(content: unknown[]): {
|
|
|
57
57
|
return { text, reasoning, toolUses }
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The text a `tool_result` block carries. The CLI writes it either as a bare string or as an
|
|
62
|
+
* array of content blocks (the shape a subagent's terminal report arrives in), so both are read
|
|
63
|
+
* here rather than at each call site. Non-text blocks (an image a tool returned) contribute
|
|
64
|
+
* nothing. Returns '' when the block carries no readable text.
|
|
65
|
+
*
|
|
66
|
+
* This is what makes a parallel subagent's work observable to the harness at all: the parent
|
|
67
|
+
* stream shows a subagent's dispatch and its terminal `tool_result` and nothing in between, so
|
|
68
|
+
* this text is the ONLY place its findings surface outside its own untailed transcript.
|
|
69
|
+
*/
|
|
70
|
+
export function claudeToolResultText(block: Record<string, unknown>): string {
|
|
71
|
+
const content = block.content
|
|
72
|
+
if (typeof content === 'string') return content
|
|
73
|
+
if (!Array.isArray(content)) return ''
|
|
74
|
+
let text = ''
|
|
75
|
+
for (const part of content) {
|
|
76
|
+
if (isObject(part) && part.type === 'text' && typeof part.text === 'string') text += part.text
|
|
77
|
+
}
|
|
78
|
+
return text
|
|
79
|
+
}
|
|
80
|
+
|
|
60
81
|
/**
|
|
61
82
|
* Per-CALL token usage off a Claude `assistant` message's `usage` (this turn only, not
|
|
62
83
|
* the cumulative `result` total).
|
package/src/pi-workspace.ts
CHANGED
|
@@ -325,6 +325,10 @@ export async function runAgentInWorkspace(
|
|
|
325
325
|
expectsEdits: spec.expectsEdits ?? true,
|
|
326
326
|
onActivity: opts.onActivity,
|
|
327
327
|
onProgress: opts.onProgress,
|
|
328
|
+
// Per-slice review capture, so a parallel review's finished slices are persisted as they
|
|
329
|
+
// land rather than only in the terminal output. Only the subscription runners fan work out
|
|
330
|
+
// across subagents, so this is the only path that can produce it.
|
|
331
|
+
onSliceReviews: opts.onSliceReviews,
|
|
328
332
|
// Stream this run's per-call telemetry to the job's live drain. The subscription
|
|
329
333
|
// harnesses are the only producers of `callMetrics` (Pi's calls are metered by the LLM
|
|
330
334
|
// proxy as they happen), so this is the only path that needs the hook.
|