@cat-factory/executor-harness 1.94.0 → 1.96.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 +11 -11
- package/dist/agent-capabilities.d.ts +61 -0
- package/dist/agent-capabilities.js +113 -0
- package/dist/agent-runner.d.ts +16 -1
- package/dist/agent-runner.js +62 -19
- package/dist/pi-workspace.js +5 -0
- package/dist/runner.d.ts +22 -0
- package/dist/runner.js +3 -0
- package/package.json +4 -4
- package/src/agent-capabilities.ts +163 -0
- package/src/agent-runner.ts +85 -17
- package/src/pi-workspace.ts +5 -0
- package/src/runner.ts +25 -0
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @cat-factory/executor-harness
|
|
2
2
|
|
|
3
3
|
The payload that runs **inside** a per-run Cloudflare Container (or a
|
|
4
|
-
[self-hosted runner](
|
|
4
|
+
[self-hosted runner](https://github.com/kibertoad/cat-factory/blob/main/backend/docs/runner-pool-integration.md)) to perform real
|
|
5
5
|
repo work with the [Pi coding agent](https://github.com/earendil-works/pi).
|
|
6
6
|
|
|
7
7
|
It is a thin TypeScript wrapper (a `node:http` server on `:8080`) that the
|
|
@@ -33,7 +33,7 @@ replayed `POST` **re-attaches** to the running job rather than starting a
|
|
|
33
33
|
duplicate (the durable driver's retries/replays are safe). Pi's todo-tool counts
|
|
34
34
|
are surfaced as `progress` while a job runs. The exact request/response shapes
|
|
35
35
|
cat-factory sends are documented in
|
|
36
|
-
[`docs/runner-pool-integration.md`](
|
|
36
|
+
[`docs/runner-pool-integration.md`](https://github.com/kibertoad/cat-factory/blob/main/backend/docs/runner-pool-integration.md).
|
|
37
37
|
|
|
38
38
|
`GET /jobs/{id}` is also the harness's observability channel: `spans`, `followUps`
|
|
39
39
|
and `callMetrics` are **drain-on-read**; each poll returns what accumulated since
|
|
@@ -62,7 +62,7 @@ The implementation job (`POST /run`) is the canonical sequence:
|
|
|
62
62
|
body's `proxyPhasePath` says the backend serves it, which is how a repair round's model spend
|
|
63
63
|
stays distinguishable from the first pass's in telemetry; without that flag the plain path is
|
|
64
64
|
used and the calls are recorded as unattributed
|
|
65
|
-
(see [token-burn instrumentation](
|
|
65
|
+
(see [token-burn instrumentation](https://github.com/kibertoad/cat-factory/blob/main/docs/initiatives/token-burn-instrumentation.md)),
|
|
66
66
|
3. **prepopulate dependencies**, when the job body carries `dependencyInstall`: the
|
|
67
67
|
service's install command is run with `sh -c` in the checkout BEFORE the agent starts, so
|
|
68
68
|
it reads real installed packages instead of inferring a library's capabilities from a
|
|
@@ -71,7 +71,7 @@ The implementation job (`POST /run`) is the canonical sequence:
|
|
|
71
71
|
steps 6 and 7, which start a fresh agent) and the run continues either way. Whatever the
|
|
72
72
|
install materialises is excluded from git first, so no later `git add -A` can sweep a
|
|
73
73
|
dependency tree into the pull request (see
|
|
74
|
-
[dependency prepopulation](
|
|
74
|
+
[dependency prepopulation](https://github.com/kibertoad/cat-factory/blob/main/docs/initiatives/agent-dependency-prepopulation.md)),
|
|
75
75
|
4. **resolve the repo's pull-request template**, when this dispatch opens a PR (`src/pr-template.ts`):
|
|
76
76
|
`.github/PULL_REQUEST_TEMPLATE.md` and its root/`docs/`/multi-template-directory variants, or
|
|
77
77
|
GitLab's `.gitlab/merge_request_templates/`, read straight off the checkout (a symlinked template
|
|
@@ -85,11 +85,11 @@ The implementation job (`POST /run`) is the canonical sequence:
|
|
|
85
85
|
6. **validate** the checkout, when the job body carries `validationChecks`: the service's
|
|
86
86
|
configured check commands (install/lint/test/build) run with `sh -c` in the checkout, and
|
|
87
87
|
while they fail and the attempt budget remains the agent is re-run with the captured output
|
|
88
|
-
as its instruction (see [pre-PR validation](
|
|
88
|
+
as its instruction (see [pre-PR validation](https://github.com/kibertoad/cat-factory/blob/main/docs/initiatives/pre-pr-validation.md)),
|
|
89
89
|
7. **prove the reproduction**, when the job body carries `reproduction`: the declared check is
|
|
90
90
|
run against the pre-fix tree and the tree the PR will open from, in two freshly-created
|
|
91
91
|
symmetric `git worktree` checkouts, and only red-then-green is reported as proof (see
|
|
92
|
-
[bugfix reproduction proof](
|
|
92
|
+
[bugfix reproduction proof](https://github.com/kibertoad/cat-factory/blob/main/backend/docs/adr/0033-bugfix-reproduction-proof.md)). Unlike
|
|
93
93
|
step 6 this NEVER gates the PR: a failed verification is fed back to the agent while budget
|
|
94
94
|
remains, then recorded as `inconclusive`. It runs BEFORE step 6 so validation stays the last
|
|
95
95
|
thing to touch the tree,
|
|
@@ -120,7 +120,7 @@ PR. Blueprint **commits onto a branch** (no history reset) and returns the tree.
|
|
|
120
120
|
A job body may carry `skills[]` (procedural playbooks) and `mcpServers[]` (MCP tool servers): the
|
|
121
121
|
harness MATERIALISES both and decides nothing about them; the backend has already resolved which
|
|
122
122
|
apply and dropped what this harness cannot serve (see
|
|
123
|
-
[`backend/docs/adr/0029-agent-kind-capabilities.md`](
|
|
123
|
+
[`backend/docs/adr/0029-agent-kind-capabilities.md`](https://github.com/kibertoad/cat-factory/blob/main/backend/docs/adr/0029-agent-kind-capabilities.md)).
|
|
124
124
|
|
|
125
125
|
- **Skills** install natively under `CLAUDE_CONFIG_DIR/skills/<name>/` for a leased-credential
|
|
126
126
|
claude-code run (the CLI discovers and invokes them), and under
|
|
@@ -279,7 +279,7 @@ docker.io/<org>/cat-factory-executor:<version>
|
|
|
279
279
|
Each is tagged with the package `version`, the commit `sha-…`, and `latest`.
|
|
280
280
|
|
|
281
281
|
**CI** does this automatically:
|
|
282
|
-
[`.github/workflows/docker-publish.yml`](
|
|
282
|
+
[`.github/workflows/docker-publish.yml`](https://github.com/kibertoad/cat-factory/blob/main/.github/workflows/docker-publish.yml)
|
|
283
283
|
republishes on every push to `main` that touches image content (`src/**`,
|
|
284
284
|
`Dockerfile`, `tsconfig.json`, `package.json`). Docker Hub is gated on the
|
|
285
285
|
`DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` repo secrets; without them it publishes
|
|
@@ -303,9 +303,9 @@ via env vars (`REGISTRIES`, `GHCR_OWNER`, `DOCKERHUB_ORG`, `TAG`, `PUSH_LATEST`,
|
|
|
303
303
|
|
|
304
304
|
A backend deployment references the image from `wrangler.toml`
|
|
305
305
|
(`[[containers]] image = "ghcr.io/<owner>/cat-factory-executor:<version>"`: see
|
|
306
|
-
[`deploy/backend`](
|
|
307
|
-
same image (see [`docs/runner-pool-integration.md`](
|
|
306
|
+
[`deploy/backend`](https://github.com/kibertoad/cat-factory/tree/main/deploy/backend)); a self-hosted runner pool pulls the
|
|
307
|
+
same image (see [`docs/runner-pool-integration.md`](https://github.com/kibertoad/cat-factory/blob/main/backend/docs/runner-pool-integration.md)).
|
|
308
308
|
The worker library's own test/dev `wrangler.toml` still references this
|
|
309
309
|
`Dockerfile` by local path so the acceptance suite can build it. Because the
|
|
310
310
|
version is the image tag, **bump this package via a changeset whenever you change
|
|
311
|
-
image content** (see [`CONTRIBUTING.md`](
|
|
311
|
+
image content** (see [`CONTRIBUTING.md`](https://github.com/kibertoad/cat-factory/blob/main/CONTRIBUTING.md)).
|
|
@@ -42,6 +42,67 @@ export interface McpServerSpec {
|
|
|
42
42
|
*/
|
|
43
43
|
secretKeys?: string[];
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* What the agent's CLI reported about ONE wired tool server when it started up.
|
|
47
|
+
*
|
|
48
|
+
* This is the OBSERVED half of the run's tool-server record, and it answers a question the
|
|
49
|
+
* backend's own half structurally cannot: the dispatch record says why the platform WITHHELD a
|
|
50
|
+
* tool, while this says a server the platform wired failed to start anyway. A vendor endpoint
|
|
51
|
+
* that 500s, an `npx` package that no longer resolves, a credential the vendor has since revoked
|
|
52
|
+
* — every one of those leaves the prompt promising a tool the agent then cannot call, and before
|
|
53
|
+
* this the only evidence was the agent saying so in prose, if it noticed at all.
|
|
54
|
+
*
|
|
55
|
+
* OBSERVED, never decided: nothing here changes what the run does. The harness reports what the
|
|
56
|
+
* CLI said and the backend records it beside what it decided; no code path branches on it.
|
|
57
|
+
*/
|
|
58
|
+
export interface ObservedMcpServer {
|
|
59
|
+
/** The server id the CLI named — the same id the backend declared (`--strict-mcp-config`). */
|
|
60
|
+
id: string;
|
|
61
|
+
status: ObservedMcpStatus;
|
|
62
|
+
/**
|
|
63
|
+
* How many of the CLI's exposed tools belong to this server (`mcp__<id>__…`).
|
|
64
|
+
*
|
|
65
|
+
* ABSENT and `0` are different facts and both are worth having: absent means this image counted
|
|
66
|
+
* nothing for the server, while `0` means it counted and the server contributed none: a server
|
|
67
|
+
* that connected and exposes nothing, which reads to the agent exactly like a server that was
|
|
68
|
+
* never wired. Never defaulted to 0.
|
|
69
|
+
*
|
|
70
|
+
* Two things leave it absent, and neither is "the server has no tools": the CLI listed no tools
|
|
71
|
+
* at all, or the tool namespace cannot say which of two declared servers a name belongs to (see
|
|
72
|
+
* {@link tallyToolsByServer}).
|
|
73
|
+
*/
|
|
74
|
+
toolCount?: number;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The status vocabulary of {@link ObservedMcpServer}, normalised from the CLI's own word.
|
|
78
|
+
*
|
|
79
|
+
* A CLOSED list mapped from an OPEN one, which is why `unknown` is a member rather than a reason
|
|
80
|
+
* to drop the row. The CLI's status strings are a third party's vocabulary and it may add to them;
|
|
81
|
+
* a server whose status this image cannot name is still a server the CLI knows about, and the
|
|
82
|
+
* honest report is "it was there, this image could not read its state" rather than silence
|
|
83
|
+
* (which reads as a server the CLI never mentioned) or a guess at `ready` (which would report a
|
|
84
|
+
* dead tool as a live one, the precise failure the whole unavailability vocabulary exists to
|
|
85
|
+
* prevent).
|
|
86
|
+
*
|
|
87
|
+
* `unknown` covers two causes that share one remedy, which is why they share one member: a word
|
|
88
|
+
* this image cannot map, and a word the CLI uses for a state that is not resolved YET. Neither
|
|
89
|
+
* says anything about the server, and the surface paints neither as a fault.
|
|
90
|
+
*/
|
|
91
|
+
export type ObservedMcpStatus = 'ready' | 'failed' | 'needs_auth' | 'unknown';
|
|
92
|
+
/**
|
|
93
|
+
* Read the claude-code CLI's startup report (`{"type":"system","subtype":"init"}`) into one
|
|
94
|
+
* {@link ObservedMcpServer} per server the CLI knows about.
|
|
95
|
+
*
|
|
96
|
+
* The CLI announces its resolved session ONCE, before the first model call: which MCP servers it
|
|
97
|
+
* loaded and with what status, and the flat list of tool names it will expose. Both halves are
|
|
98
|
+
* read here because neither answers the question alone — a `ready` server exposing no tools is as
|
|
99
|
+
* useless to the agent as a failed one, and a tool count with no status cannot say why.
|
|
100
|
+
*
|
|
101
|
+
* Returns `undefined` when the event names no servers at all, which keeps "this run wired none"
|
|
102
|
+
* and "this image observed none" from collapsing into an empty list on the backend's record.
|
|
103
|
+
* Pure, so the parsing is testable without a CLI: {@link runClaudeCode} feeds it the raw event.
|
|
104
|
+
*/
|
|
105
|
+
export declare function observeClaudeMcpInit(event: Record<string, unknown>): ObservedMcpServer[] | undefined;
|
|
45
106
|
/**
|
|
46
107
|
* The credential values carried by a run's tool servers, for {@link registerKnownSecrets}. An MCP
|
|
47
108
|
* server that fails to start routinely echoes its own argv or request headers into stderr, and
|
|
@@ -1,5 +1,118 @@
|
|
|
1
1
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Map one status word from the CLI onto {@link ObservedMcpStatus}.
|
|
5
|
+
*
|
|
6
|
+
* The synonyms are grouped rather than listed one-to-one because the CLI has spelled the same
|
|
7
|
+
* two states more than one way across versions (`connected`/`ready`, `failed`/`error`), and an
|
|
8
|
+
* image that pinned the exact spelling would silently start reporting `unknown` for every server
|
|
9
|
+
* on a CLI upgrade — a regression that looks identical to a genuine outage.
|
|
10
|
+
*/
|
|
11
|
+
function normalizeMcpStatus(value) {
|
|
12
|
+
if (typeof value !== 'string')
|
|
13
|
+
return 'unknown';
|
|
14
|
+
const status = value.trim().toLowerCase();
|
|
15
|
+
if (status === 'connected' || status === 'ready' || status === 'ok')
|
|
16
|
+
return 'ready';
|
|
17
|
+
if (status === 'failed' || status === 'error')
|
|
18
|
+
return 'failed';
|
|
19
|
+
// The vendor spells the OAuth-required state with a hyphen; the underscore form costs nothing
|
|
20
|
+
// to accept and is what a JSON-ish vocabulary tends to drift toward.
|
|
21
|
+
if (status === 'needs-auth' || status === 'needs_auth')
|
|
22
|
+
return 'needs_auth';
|
|
23
|
+
// Everything else, INCLUDING the CLI's `pending`. A server still handshaking when the session
|
|
24
|
+
// was announced has no resolved state, which is exactly what `unknown` says, and `needs_auth` is
|
|
25
|
+
// the tempting wrong guess for it: the surface paints that one amber as "waiting for you to
|
|
26
|
+
// authorize it", sending an operator to re-issue a working credential for a server that was
|
|
27
|
+
// merely slow and came up a second later.
|
|
28
|
+
return 'unknown';
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Read the claude-code CLI's startup report (`{"type":"system","subtype":"init"}`) into one
|
|
32
|
+
* {@link ObservedMcpServer} per server the CLI knows about.
|
|
33
|
+
*
|
|
34
|
+
* The CLI announces its resolved session ONCE, before the first model call: which MCP servers it
|
|
35
|
+
* loaded and with what status, and the flat list of tool names it will expose. Both halves are
|
|
36
|
+
* read here because neither answers the question alone — a `ready` server exposing no tools is as
|
|
37
|
+
* useless to the agent as a failed one, and a tool count with no status cannot say why.
|
|
38
|
+
*
|
|
39
|
+
* Returns `undefined` when the event names no servers at all, which keeps "this run wired none"
|
|
40
|
+
* and "this image observed none" from collapsing into an empty list on the backend's record.
|
|
41
|
+
* Pure, so the parsing is testable without a CLI: {@link runClaudeCode} feeds it the raw event.
|
|
42
|
+
*/
|
|
43
|
+
export function observeClaudeMcpInit(event) {
|
|
44
|
+
if (event.type !== 'system' || event.subtype !== 'init')
|
|
45
|
+
return undefined;
|
|
46
|
+
const reported = event.mcp_servers;
|
|
47
|
+
if (!Array.isArray(reported) || reported.length === 0)
|
|
48
|
+
return undefined;
|
|
49
|
+
const rows = [];
|
|
50
|
+
const declared = new Set();
|
|
51
|
+
for (const entry of reported) {
|
|
52
|
+
if (typeof entry !== 'object' || entry === null)
|
|
53
|
+
continue;
|
|
54
|
+
const record = entry;
|
|
55
|
+
const id = sanitizeServerId(record.name);
|
|
56
|
+
// An id this image cannot hold is dropped rather than reported under a mangled name: the
|
|
57
|
+
// whole row is only useful if it JOINS the backend's declaration, and `--strict-mcp-config`
|
|
58
|
+
// means every server the CLI loaded came from the config this harness wrote.
|
|
59
|
+
if (!id || declared.has(id))
|
|
60
|
+
continue;
|
|
61
|
+
declared.add(id);
|
|
62
|
+
rows.push({ id, status: normalizeMcpStatus(record.status) });
|
|
63
|
+
}
|
|
64
|
+
if (rows.length === 0)
|
|
65
|
+
return undefined;
|
|
66
|
+
// Counted from the CLI's own tool list rather than from a per-server field, because there is no
|
|
67
|
+
// per-server field: the CLI flattens every server's tools into one array namespaced by server
|
|
68
|
+
// id. A missing/non-array list leaves every count ABSENT rather than 0 (see `toolCount`).
|
|
69
|
+
const tally = tallyToolsByServer(event.tools, declared);
|
|
70
|
+
return rows.map((row) => ({
|
|
71
|
+
...row,
|
|
72
|
+
...(tally && !tally.ambiguous.has(row.id) ? { toolCount: tally.counts.get(row.id) ?? 0 } : {}),
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Tally the CLI's flat tool list (`mcp__<id>__<tool>`) against the servers the SAME event
|
|
77
|
+
* declared, or `undefined` when it carried no list, which is the distinction
|
|
78
|
+
* {@link ObservedMcpServer.toolCount} preserves.
|
|
79
|
+
*
|
|
80
|
+
* Matched against the declared ids rather than split on the first `__`, because the id vocabulary
|
|
81
|
+
* ({@link MCP_SERVER_ID_PATTERN}) permits an underscore: a server named `code__search` owns
|
|
82
|
+
* `mcp__code__search__query`, which a first-separator split files under a server called `code`,
|
|
83
|
+
* leaving the real one reporting `toolCount: 0`. That is the single most diagnostic value on the
|
|
84
|
+
* field, so the mis-split renders a fully healthy server as one that started and exposes nothing.
|
|
85
|
+
*
|
|
86
|
+
* The same underscore makes genuine ambiguity representable: with both `code` and `code__search`
|
|
87
|
+
* declared, `mcp__code__search__query` is a name either could own and nothing in the report says
|
|
88
|
+
* which. Neither server is counted then, and both are named `ambiguous` so their count stays
|
|
89
|
+
* absent. Guessing an owner would move a real tool onto the wrong server and take the other's
|
|
90
|
+
* count to a `0` that reads as a fault.
|
|
91
|
+
*/
|
|
92
|
+
function tallyToolsByServer(tools, declared) {
|
|
93
|
+
if (!Array.isArray(tools))
|
|
94
|
+
return undefined;
|
|
95
|
+
const counts = new Map();
|
|
96
|
+
const ambiguous = new Set();
|
|
97
|
+
for (const tool of tools) {
|
|
98
|
+
if (typeof tool !== 'string' || !tool.startsWith('mcp__'))
|
|
99
|
+
continue;
|
|
100
|
+
const owners = [];
|
|
101
|
+
for (const id of declared) {
|
|
102
|
+
const prefix = `mcp__${id}__`;
|
|
103
|
+
// The tool name after the prefix must be non-empty: `mcp__slack__` names no tool.
|
|
104
|
+
if (tool.length > prefix.length && tool.startsWith(prefix))
|
|
105
|
+
owners.push(id);
|
|
106
|
+
}
|
|
107
|
+
const [owner] = owners;
|
|
108
|
+
if (owners.length === 1 && owner)
|
|
109
|
+
counts.set(owner, (counts.get(owner) ?? 0) + 1);
|
|
110
|
+
else
|
|
111
|
+
for (const id of owners)
|
|
112
|
+
ambiguous.add(id);
|
|
113
|
+
}
|
|
114
|
+
return { counts, ambiguous };
|
|
115
|
+
}
|
|
3
116
|
/**
|
|
4
117
|
* The credential values carried by a run's tool servers, for {@link registerKnownSecrets}. An MCP
|
|
5
118
|
* server that fails to start routinely echoes its own argv or request headers into stderr, and
|
package/dist/agent-runner.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Logger } from './logger.js';
|
|
2
2
|
import { type HarnessCallMetric, type PiRunOutcome, type TodoProgress, type ToolSpan } from './pi.js';
|
|
3
|
-
import { type McpServerSpec, type SkillSpec } from './agent-capabilities.js';
|
|
3
|
+
import { type McpServerSpec, type ObservedMcpServer, type SkillSpec } from './agent-capabilities.js';
|
|
4
4
|
import { type ProgressGuardLimits } from './progress-guard.js';
|
|
5
5
|
import { type SliceReview } from './subagents.js';
|
|
6
6
|
/** Which subscription harness to run (the Pi harness uses `runPi` directly). */
|
|
@@ -92,6 +92,21 @@ export interface SubscriptionRunOptions {
|
|
|
92
92
|
* same row still rides the result, so a lost poll response costs nothing.
|
|
93
93
|
*/
|
|
94
94
|
onCallMetric?: (call: HarnessCallMetric) => void;
|
|
95
|
+
/**
|
|
96
|
+
* Called once with what the CLI reported about the tool servers it loaded, the moment it
|
|
97
|
+
* announces its resolved session (see {@link observeClaudeMcpInit}).
|
|
98
|
+
*
|
|
99
|
+
* The one thing the backend's own dispatch record cannot answer: it knows why it WITHHELD a
|
|
100
|
+
* tool, and this says a server it wired failed to start anyway. Reported even when every server
|
|
101
|
+
* came up, because "observed, all healthy" and "this image observed nothing" are different
|
|
102
|
+
* facts about a run and only the first one clears a wired server of suspicion.
|
|
103
|
+
*
|
|
104
|
+
* Whole-value latest-wins, not a delta — the CLI announces its session once, so a second call
|
|
105
|
+
* would only ever be a re-announcement of the same set. A harness whose CLI reports nothing
|
|
106
|
+
* (codex today) never calls this, which is what leaves the backend's record honestly empty
|
|
107
|
+
* rather than claiming every server failed.
|
|
108
|
+
*/
|
|
109
|
+
onToolServers?: (observed: ObservedMcpServer[]) => void;
|
|
95
110
|
/**
|
|
96
111
|
* The per-job child logger (jobId/repo/branch correlation). Threaded so the retained
|
|
97
112
|
* session-transcript path is logged for the run when the isolated config home is torn down.
|
package/dist/agent-runner.js
CHANGED
|
@@ -6,7 +6,7 @@ import { claudeAssistantContent, isObject, numberOf, redactBody } from './claude
|
|
|
6
6
|
import { createClaudeRunTelemetry, subagentDispatchId } from './claude-call-aggregator.js';
|
|
7
7
|
import { ToolCallTracker, recordClaudeToolResults, } from './tool-trajectory.js';
|
|
8
8
|
import { createCallMetricPublisher, publishCallMetric, } from './pi.js';
|
|
9
|
-
import { claudeAllowedToolPatterns, codexMcpConfigToml, mcpServerSecretValues, writeClaudeMcpConfig, } from './agent-capabilities.js';
|
|
9
|
+
import { claudeAllowedToolPatterns, codexMcpConfigToml, mcpServerSecretValues, observeClaudeMcpInit, writeClaudeMcpConfig, } from './agent-capabilities.js';
|
|
10
10
|
import { ProgressGuard } from './progress-guard.js';
|
|
11
11
|
import { killChildProcess, spawnDetached } from './process.js';
|
|
12
12
|
import { describeProcessExit } from './process-exit.js';
|
|
@@ -327,6 +327,60 @@ async function setUpClaudeMcp(servers, configHome) {
|
|
|
327
327
|
cleanup,
|
|
328
328
|
};
|
|
329
329
|
}
|
|
330
|
+
/**
|
|
331
|
+
* The LIVE publishers of a claude-code run: everything the stream has revealed so far that the
|
|
332
|
+
* backend should see before the run ends, rather than only in its terminal result.
|
|
333
|
+
*
|
|
334
|
+
* They are grouped because they share one rule and differ on everything else. The rule: each
|
|
335
|
+
* publishes a WHOLE current value (never a delta), so a dropped poll response costs nothing and
|
|
336
|
+
* the caller may fire them as often as it likes. What differs is what is at stake — progress is a
|
|
337
|
+
* disposable count the UI renders, while the slice reviews carry the slices' actual review WORK
|
|
338
|
+
* and are the only thing a resume of a wedged review can be rebuilt from, which is why they are
|
|
339
|
+
* published on the turn a slice lands rather than on the next progress tick.
|
|
340
|
+
*
|
|
341
|
+
* `lastTodo` is a GETTER because the event handler assigns it as the stream goes; taking the value
|
|
342
|
+
* would freeze the plan at construction time.
|
|
343
|
+
*
|
|
344
|
+
* Split out of {@link runClaudeCode} for the per-function line budget.
|
|
345
|
+
*/
|
|
346
|
+
function createClaudeLivePublishers(deps) {
|
|
347
|
+
const { opts, planTracker, sliceTracker } = deps;
|
|
348
|
+
return {
|
|
349
|
+
emitProgress: () => {
|
|
350
|
+
if (!opts.onProgress)
|
|
351
|
+
return;
|
|
352
|
+
const progress = mergeProgress(pickProgress(deps.lastTodo(), planTracker.progress()), sliceTracker.progress());
|
|
353
|
+
if (progress)
|
|
354
|
+
opts.onProgress(progress);
|
|
355
|
+
},
|
|
356
|
+
emitSliceReviews: () => {
|
|
357
|
+
if (!opts.onSliceReviews)
|
|
358
|
+
return;
|
|
359
|
+
const reviews = sliceTracker.sliceReviews();
|
|
360
|
+
if (reviews.length > 0)
|
|
361
|
+
opts.onSliceReviews(reviews);
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Publish the CLI's own startup report about the tool servers it loaded — the OBSERVED half of the
|
|
367
|
+
* run's tool-server record.
|
|
368
|
+
*
|
|
369
|
+
* Handed every event because it is the one thing `runClaudeCode` reads that is neither a turn nor
|
|
370
|
+
* a result: it arrives once, ahead of the first model call, and says whether the servers the
|
|
371
|
+
* backend wired actually came up. {@link observeClaudeMcpInit} answers `undefined` for every other
|
|
372
|
+
* event and for a run that wired none, so a server-less run reports nothing and the caller's
|
|
373
|
+
* record stays honestly absent rather than empty.
|
|
374
|
+
*
|
|
375
|
+
* Split out of {@link runClaudeCode} for the per-function line budget.
|
|
376
|
+
*/
|
|
377
|
+
function reportToolServerStartup(event, onToolServers) {
|
|
378
|
+
if (!onToolServers)
|
|
379
|
+
return;
|
|
380
|
+
const observed = observeClaudeMcpInit(event);
|
|
381
|
+
if (observed)
|
|
382
|
+
onToolServers(observed);
|
|
383
|
+
}
|
|
330
384
|
/**
|
|
331
385
|
* No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
|
|
332
386
|
* which cannot see the CLI's internal turns. The caller remembers each `tool_use` id's name off
|
|
@@ -455,24 +509,12 @@ export async function runClaudeCode(opts) {
|
|
|
455
509
|
const sliceTracker = createSliceTracker(secrets);
|
|
456
510
|
const planTracker = createTaskPlanTracker();
|
|
457
511
|
let lastTodo;
|
|
458
|
-
const emitProgress = (
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
};
|
|
465
|
-
// Publish the per-slice reviews the tracker has captured. Separate from `emitProgress` because
|
|
466
|
-
// the two answer different questions and have different lifetimes: progress is a disposable
|
|
467
|
-
// count the UI renders, while these carry the slices' actual review WORK and are persisted so a
|
|
468
|
-
// run that dies before its aggregation can be resumed from them.
|
|
469
|
-
const emitSliceReviews = () => {
|
|
470
|
-
if (!opts.onSliceReviews)
|
|
471
|
-
return;
|
|
472
|
-
const reviews = sliceTracker.sliceReviews();
|
|
473
|
-
if (reviews.length > 0)
|
|
474
|
-
opts.onSliceReviews(reviews);
|
|
475
|
-
};
|
|
512
|
+
const { emitProgress, emitSliceReviews } = createClaudeLivePublishers({
|
|
513
|
+
opts,
|
|
514
|
+
planTracker,
|
|
515
|
+
sliceTracker,
|
|
516
|
+
lastTodo: () => lastTodo,
|
|
517
|
+
});
|
|
476
518
|
// No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
|
|
477
519
|
// absent on this path until now. Claude Code reports a tool CALL (its name) on the `assistant`
|
|
478
520
|
// turn and that call's RESULT (`is_error`) on the following `user` turn, so correlate them by
|
|
@@ -484,6 +526,7 @@ export async function runClaudeCode(opts) {
|
|
|
484
526
|
const trajectory = createClaudeToolTrajectory(opts, secrets);
|
|
485
527
|
const onEvent = (event, meta) => {
|
|
486
528
|
const type = event.type;
|
|
529
|
+
reportToolServerStartup(event, opts.onToolServers);
|
|
487
530
|
// A subagent's turns ride the parent's stdout tagged with the dispatch that spawned them;
|
|
488
531
|
// `telemetry` routes them off the parent's chain (and decides who bills them). Progress, slice
|
|
489
532
|
// tracking, the guard and `stats` below deliberately see EVERY event: a subagent grinding on
|
package/dist/pi-workspace.js
CHANGED
|
@@ -189,6 +189,11 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
189
189
|
// land rather than only in the terminal output. Only the subscription runners fan work out
|
|
190
190
|
// across subagents, so this is the only path that can produce it.
|
|
191
191
|
onSliceReviews: opts.onSliceReviews,
|
|
192
|
+
// What the CLI reported about the tool servers it loaded. Wired for BOTH subscription
|
|
193
|
+
// harnesses even though only claude-code's stream carries the report today: the hook is a
|
|
194
|
+
// pass-through, and a codex run that never calls it leaves the backend's record honestly
|
|
195
|
+
// absent rather than claiming every server it wired failed to start.
|
|
196
|
+
onToolServers: opts.onToolServers,
|
|
192
197
|
// Stream this run's per-call telemetry to the job's live drain. The subscription
|
|
193
198
|
// harnesses are the only producers of `callMetrics` (Pi's calls are metered by the LLM
|
|
194
199
|
// proxy as they happen), so this is the only path that needs the hook.
|
package/dist/runner.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { FollowUpLine } from './follow-ups.js';
|
|
|
2
2
|
import type { ValidationReport } from './validation-checks.js';
|
|
3
3
|
import type { ReproductionReport } from './reproduction-proof.js';
|
|
4
4
|
import type { SliceReview } from './subagents.js';
|
|
5
|
+
import type { ObservedMcpServer } from './agent-capabilities.js';
|
|
5
6
|
import type { HarnessCallMetric, TodoProgress, ToolSpan } from './pi.js';
|
|
6
7
|
import { type Logger } from './logger.js';
|
|
7
8
|
import { type FailureCause } from './failure.js';
|
|
@@ -40,6 +41,15 @@ export interface RunOptions {
|
|
|
40
41
|
* already persisted. Absent for a job that dispatched no subagents.
|
|
41
42
|
*/
|
|
42
43
|
onSliceReviews?: (reviews: SliceReview[]) => void;
|
|
44
|
+
/**
|
|
45
|
+
* Receives what the agent's CLI reported about the tool servers (MCP) it loaded, once it
|
|
46
|
+
* announces its resolved session. Latest-wins (NOT a drain buffer) for the same reason as
|
|
47
|
+
* {@link onValidationReport}, with an extra one of its own: the CLI announces the set ONCE,
|
|
48
|
+
* near the start of the run, so a drain buffer would hand it to whichever poll happened to
|
|
49
|
+
* land next and lose it entirely if that poll response were dropped — on the single fact this
|
|
50
|
+
* whole channel exists to carry. Absent for a job that wired no tool servers.
|
|
51
|
+
*/
|
|
52
|
+
onToolServers?: (observed: ObservedMcpServer[]) => void;
|
|
43
53
|
/**
|
|
44
54
|
* Receives each per-call telemetry row the moment the agent's CLI stream yields it, so a
|
|
45
55
|
* run's model calls reach `llm_call_metrics` WHILE it runs rather than only in its terminal
|
|
@@ -208,6 +218,18 @@ export interface JobView<TResult extends JobResultBase = JobResultBase> {
|
|
|
208
218
|
* from. Absent for a job that dispatched no subagents.
|
|
209
219
|
*/
|
|
210
220
|
sliceReviews?: SliceReview[];
|
|
221
|
+
/**
|
|
222
|
+
* What the agent's CLI reported about the tool servers (MCP) wired for this job when it started
|
|
223
|
+
* up: per server, the status the CLI gave it and how many tools it contributed. A whole-value
|
|
224
|
+
* latest publish like {@link validationReport}, not drain-on-read — the CLI announces this once
|
|
225
|
+
* and every later poll re-reports the same set, so no poll can be the one that loses it.
|
|
226
|
+
*
|
|
227
|
+
* The complement of what the BACKEND recorded at dispatch, and the only source for the half it
|
|
228
|
+
* cannot see: the dispatch record says why the platform withheld a tool, this says a wired
|
|
229
|
+
* server failed to start anyway. Absent for a job that wired none, and for a harness whose CLI
|
|
230
|
+
* reports nothing — which is why it is absent rather than empty (see `ObservedMcpServer`).
|
|
231
|
+
*/
|
|
232
|
+
toolServers?: ObservedMcpServer[];
|
|
211
233
|
}
|
|
212
234
|
/** Watchdog windows that bound every job. Tunable via the container's env. */
|
|
213
235
|
export interface RunnerLimits {
|
package/dist/runner.js
CHANGED
|
@@ -270,6 +270,9 @@ export class JobRegistry {
|
|
|
270
270
|
onSliceReviews: (reviews) => {
|
|
271
271
|
entry.sliceReviews = reviews;
|
|
272
272
|
},
|
|
273
|
+
onToolServers: (observed) => {
|
|
274
|
+
entry.toolServers = observed;
|
|
275
|
+
},
|
|
273
276
|
onReproductionProof: (report) => {
|
|
274
277
|
entry.reproductionReport = report;
|
|
275
278
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.96.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",
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
"hono": "^4.13.0",
|
|
31
31
|
"typescript": "7.0.2",
|
|
32
32
|
"vitest": "^4.1.10",
|
|
33
|
-
"@cat-factory/kernel": "0.
|
|
34
|
-
"@cat-factory/server": "0.
|
|
35
|
-
"@cat-factory/spend": "0.15.
|
|
33
|
+
"@cat-factory/kernel": "0.264.0",
|
|
34
|
+
"@cat-factory/server": "0.244.0",
|
|
35
|
+
"@cat-factory/spend": "0.15.32"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"build": "tsc -p tsconfig.json",
|
|
@@ -59,6 +59,169 @@ export interface McpServerSpec {
|
|
|
59
59
|
secretKeys?: string[]
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* What the agent's CLI reported about ONE wired tool server when it started up.
|
|
64
|
+
*
|
|
65
|
+
* This is the OBSERVED half of the run's tool-server record, and it answers a question the
|
|
66
|
+
* backend's own half structurally cannot: the dispatch record says why the platform WITHHELD a
|
|
67
|
+
* tool, while this says a server the platform wired failed to start anyway. A vendor endpoint
|
|
68
|
+
* that 500s, an `npx` package that no longer resolves, a credential the vendor has since revoked
|
|
69
|
+
* — every one of those leaves the prompt promising a tool the agent then cannot call, and before
|
|
70
|
+
* this the only evidence was the agent saying so in prose, if it noticed at all.
|
|
71
|
+
*
|
|
72
|
+
* OBSERVED, never decided: nothing here changes what the run does. The harness reports what the
|
|
73
|
+
* CLI said and the backend records it beside what it decided; no code path branches on it.
|
|
74
|
+
*/
|
|
75
|
+
export interface ObservedMcpServer {
|
|
76
|
+
/** The server id the CLI named — the same id the backend declared (`--strict-mcp-config`). */
|
|
77
|
+
id: string
|
|
78
|
+
status: ObservedMcpStatus
|
|
79
|
+
/**
|
|
80
|
+
* How many of the CLI's exposed tools belong to this server (`mcp__<id>__…`).
|
|
81
|
+
*
|
|
82
|
+
* ABSENT and `0` are different facts and both are worth having: absent means this image counted
|
|
83
|
+
* nothing for the server, while `0` means it counted and the server contributed none: a server
|
|
84
|
+
* that connected and exposes nothing, which reads to the agent exactly like a server that was
|
|
85
|
+
* never wired. Never defaulted to 0.
|
|
86
|
+
*
|
|
87
|
+
* Two things leave it absent, and neither is "the server has no tools": the CLI listed no tools
|
|
88
|
+
* at all, or the tool namespace cannot say which of two declared servers a name belongs to (see
|
|
89
|
+
* {@link tallyToolsByServer}).
|
|
90
|
+
*/
|
|
91
|
+
toolCount?: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The status vocabulary of {@link ObservedMcpServer}, normalised from the CLI's own word.
|
|
96
|
+
*
|
|
97
|
+
* A CLOSED list mapped from an OPEN one, which is why `unknown` is a member rather than a reason
|
|
98
|
+
* to drop the row. The CLI's status strings are a third party's vocabulary and it may add to them;
|
|
99
|
+
* a server whose status this image cannot name is still a server the CLI knows about, and the
|
|
100
|
+
* honest report is "it was there, this image could not read its state" rather than silence
|
|
101
|
+
* (which reads as a server the CLI never mentioned) or a guess at `ready` (which would report a
|
|
102
|
+
* dead tool as a live one, the precise failure the whole unavailability vocabulary exists to
|
|
103
|
+
* prevent).
|
|
104
|
+
*
|
|
105
|
+
* `unknown` covers two causes that share one remedy, which is why they share one member: a word
|
|
106
|
+
* this image cannot map, and a word the CLI uses for a state that is not resolved YET. Neither
|
|
107
|
+
* says anything about the server, and the surface paints neither as a fault.
|
|
108
|
+
*/
|
|
109
|
+
export type ObservedMcpStatus = 'ready' | 'failed' | 'needs_auth' | 'unknown'
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Map one status word from the CLI onto {@link ObservedMcpStatus}.
|
|
113
|
+
*
|
|
114
|
+
* The synonyms are grouped rather than listed one-to-one because the CLI has spelled the same
|
|
115
|
+
* two states more than one way across versions (`connected`/`ready`, `failed`/`error`), and an
|
|
116
|
+
* image that pinned the exact spelling would silently start reporting `unknown` for every server
|
|
117
|
+
* on a CLI upgrade — a regression that looks identical to a genuine outage.
|
|
118
|
+
*/
|
|
119
|
+
function normalizeMcpStatus(value: unknown): ObservedMcpStatus {
|
|
120
|
+
if (typeof value !== 'string') return 'unknown'
|
|
121
|
+
const status = value.trim().toLowerCase()
|
|
122
|
+
if (status === 'connected' || status === 'ready' || status === 'ok') return 'ready'
|
|
123
|
+
if (status === 'failed' || status === 'error') return 'failed'
|
|
124
|
+
// The vendor spells the OAuth-required state with a hyphen; the underscore form costs nothing
|
|
125
|
+
// to accept and is what a JSON-ish vocabulary tends to drift toward.
|
|
126
|
+
if (status === 'needs-auth' || status === 'needs_auth') return 'needs_auth'
|
|
127
|
+
// Everything else, INCLUDING the CLI's `pending`. A server still handshaking when the session
|
|
128
|
+
// was announced has no resolved state, which is exactly what `unknown` says, and `needs_auth` is
|
|
129
|
+
// the tempting wrong guess for it: the surface paints that one amber as "waiting for you to
|
|
130
|
+
// authorize it", sending an operator to re-issue a working credential for a server that was
|
|
131
|
+
// merely slow and came up a second later.
|
|
132
|
+
return 'unknown'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Read the claude-code CLI's startup report (`{"type":"system","subtype":"init"}`) into one
|
|
137
|
+
* {@link ObservedMcpServer} per server the CLI knows about.
|
|
138
|
+
*
|
|
139
|
+
* The CLI announces its resolved session ONCE, before the first model call: which MCP servers it
|
|
140
|
+
* loaded and with what status, and the flat list of tool names it will expose. Both halves are
|
|
141
|
+
* read here because neither answers the question alone — a `ready` server exposing no tools is as
|
|
142
|
+
* useless to the agent as a failed one, and a tool count with no status cannot say why.
|
|
143
|
+
*
|
|
144
|
+
* Returns `undefined` when the event names no servers at all, which keeps "this run wired none"
|
|
145
|
+
* and "this image observed none" from collapsing into an empty list on the backend's record.
|
|
146
|
+
* Pure, so the parsing is testable without a CLI: {@link runClaudeCode} feeds it the raw event.
|
|
147
|
+
*/
|
|
148
|
+
export function observeClaudeMcpInit(
|
|
149
|
+
event: Record<string, unknown>,
|
|
150
|
+
): ObservedMcpServer[] | undefined {
|
|
151
|
+
if (event.type !== 'system' || event.subtype !== 'init') return undefined
|
|
152
|
+
const reported = event.mcp_servers
|
|
153
|
+
if (!Array.isArray(reported) || reported.length === 0) return undefined
|
|
154
|
+
const rows: { id: string; status: ObservedMcpStatus }[] = []
|
|
155
|
+
const declared = new Set<string>()
|
|
156
|
+
for (const entry of reported) {
|
|
157
|
+
if (typeof entry !== 'object' || entry === null) continue
|
|
158
|
+
const record = entry as Record<string, unknown>
|
|
159
|
+
const id = sanitizeServerId(record.name)
|
|
160
|
+
// An id this image cannot hold is dropped rather than reported under a mangled name: the
|
|
161
|
+
// whole row is only useful if it JOINS the backend's declaration, and `--strict-mcp-config`
|
|
162
|
+
// means every server the CLI loaded came from the config this harness wrote.
|
|
163
|
+
if (!id || declared.has(id)) continue
|
|
164
|
+
declared.add(id)
|
|
165
|
+
rows.push({ id, status: normalizeMcpStatus(record.status) })
|
|
166
|
+
}
|
|
167
|
+
if (rows.length === 0) return undefined
|
|
168
|
+
// Counted from the CLI's own tool list rather than from a per-server field, because there is no
|
|
169
|
+
// per-server field: the CLI flattens every server's tools into one array namespaced by server
|
|
170
|
+
// id. A missing/non-array list leaves every count ABSENT rather than 0 (see `toolCount`).
|
|
171
|
+
const tally = tallyToolsByServer(event.tools, declared)
|
|
172
|
+
return rows.map((row) => ({
|
|
173
|
+
...row,
|
|
174
|
+
...(tally && !tally.ambiguous.has(row.id) ? { toolCount: tally.counts.get(row.id) ?? 0 } : {}),
|
|
175
|
+
}))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** What {@link tallyToolsByServer} read out of the CLI's flat tool list. */
|
|
179
|
+
interface ToolTally {
|
|
180
|
+
/** Tools attributed to each declared server. A server with none is simply absent from the map. */
|
|
181
|
+
counts: ReadonlyMap<string, number>
|
|
182
|
+
/**
|
|
183
|
+
* Servers whose count could not be established, because at least one tool name belongs to more
|
|
184
|
+
* than one of them. Their count is reported ABSENT rather than short.
|
|
185
|
+
*/
|
|
186
|
+
ambiguous: ReadonlySet<string>
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Tally the CLI's flat tool list (`mcp__<id>__<tool>`) against the servers the SAME event
|
|
191
|
+
* declared, or `undefined` when it carried no list, which is the distinction
|
|
192
|
+
* {@link ObservedMcpServer.toolCount} preserves.
|
|
193
|
+
*
|
|
194
|
+
* Matched against the declared ids rather than split on the first `__`, because the id vocabulary
|
|
195
|
+
* ({@link MCP_SERVER_ID_PATTERN}) permits an underscore: a server named `code__search` owns
|
|
196
|
+
* `mcp__code__search__query`, which a first-separator split files under a server called `code`,
|
|
197
|
+
* leaving the real one reporting `toolCount: 0`. That is the single most diagnostic value on the
|
|
198
|
+
* field, so the mis-split renders a fully healthy server as one that started and exposes nothing.
|
|
199
|
+
*
|
|
200
|
+
* The same underscore makes genuine ambiguity representable: with both `code` and `code__search`
|
|
201
|
+
* declared, `mcp__code__search__query` is a name either could own and nothing in the report says
|
|
202
|
+
* which. Neither server is counted then, and both are named `ambiguous` so their count stays
|
|
203
|
+
* absent. Guessing an owner would move a real tool onto the wrong server and take the other's
|
|
204
|
+
* count to a `0` that reads as a fault.
|
|
205
|
+
*/
|
|
206
|
+
function tallyToolsByServer(tools: unknown, declared: ReadonlySet<string>): ToolTally | undefined {
|
|
207
|
+
if (!Array.isArray(tools)) return undefined
|
|
208
|
+
const counts = new Map<string, number>()
|
|
209
|
+
const ambiguous = new Set<string>()
|
|
210
|
+
for (const tool of tools) {
|
|
211
|
+
if (typeof tool !== 'string' || !tool.startsWith('mcp__')) continue
|
|
212
|
+
const owners: string[] = []
|
|
213
|
+
for (const id of declared) {
|
|
214
|
+
const prefix = `mcp__${id}__`
|
|
215
|
+
// The tool name after the prefix must be non-empty: `mcp__slack__` names no tool.
|
|
216
|
+
if (tool.length > prefix.length && tool.startsWith(prefix)) owners.push(id)
|
|
217
|
+
}
|
|
218
|
+
const [owner] = owners
|
|
219
|
+
if (owners.length === 1 && owner) counts.set(owner, (counts.get(owner) ?? 0) + 1)
|
|
220
|
+
else for (const id of owners) ambiguous.add(id)
|
|
221
|
+
}
|
|
222
|
+
return { counts, ambiguous }
|
|
223
|
+
}
|
|
224
|
+
|
|
62
225
|
/**
|
|
63
226
|
* The credential values carried by a run's tool servers, for {@link registerKnownSecrets}. An MCP
|
|
64
227
|
* server that fails to start routinely echoes its own argv or request headers into stderr, and
|
package/src/agent-runner.ts
CHANGED
|
@@ -24,8 +24,10 @@ import {
|
|
|
24
24
|
claudeAllowedToolPatterns,
|
|
25
25
|
codexMcpConfigToml,
|
|
26
26
|
mcpServerSecretValues,
|
|
27
|
+
observeClaudeMcpInit,
|
|
27
28
|
writeClaudeMcpConfig,
|
|
28
29
|
type McpServerSpec,
|
|
30
|
+
type ObservedMcpServer,
|
|
29
31
|
type SkillSpec,
|
|
30
32
|
} from './agent-capabilities.js'
|
|
31
33
|
import { ProgressGuard, type ProgressGuardLimits } from './progress-guard.js'
|
|
@@ -149,6 +151,21 @@ export interface SubscriptionRunOptions {
|
|
|
149
151
|
* same row still rides the result, so a lost poll response costs nothing.
|
|
150
152
|
*/
|
|
151
153
|
onCallMetric?: (call: HarnessCallMetric) => void
|
|
154
|
+
/**
|
|
155
|
+
* Called once with what the CLI reported about the tool servers it loaded, the moment it
|
|
156
|
+
* announces its resolved session (see {@link observeClaudeMcpInit}).
|
|
157
|
+
*
|
|
158
|
+
* The one thing the backend's own dispatch record cannot answer: it knows why it WITHHELD a
|
|
159
|
+
* tool, and this says a server it wired failed to start anyway. Reported even when every server
|
|
160
|
+
* came up, because "observed, all healthy" and "this image observed nothing" are different
|
|
161
|
+
* facts about a run and only the first one clears a wired server of suspicion.
|
|
162
|
+
*
|
|
163
|
+
* Whole-value latest-wins, not a delta — the CLI announces its session once, so a second call
|
|
164
|
+
* would only ever be a re-announcement of the same set. A harness whose CLI reports nothing
|
|
165
|
+
* (codex today) never calls this, which is what leaves the backend's record honestly empty
|
|
166
|
+
* rather than claiming every server failed.
|
|
167
|
+
*/
|
|
168
|
+
onToolServers?: (observed: ObservedMcpServer[]) => void
|
|
152
169
|
/**
|
|
153
170
|
* The per-job child logger (jobId/repo/branch correlation). Threaded so the retained
|
|
154
171
|
* session-transcript path is logged for the run when the isolated config home is torn down.
|
|
@@ -501,6 +518,67 @@ async function setUpClaudeMcp(
|
|
|
501
518
|
}
|
|
502
519
|
}
|
|
503
520
|
|
|
521
|
+
/**
|
|
522
|
+
* The LIVE publishers of a claude-code run: everything the stream has revealed so far that the
|
|
523
|
+
* backend should see before the run ends, rather than only in its terminal result.
|
|
524
|
+
*
|
|
525
|
+
* They are grouped because they share one rule and differ on everything else. The rule: each
|
|
526
|
+
* publishes a WHOLE current value (never a delta), so a dropped poll response costs nothing and
|
|
527
|
+
* the caller may fire them as often as it likes. What differs is what is at stake — progress is a
|
|
528
|
+
* disposable count the UI renders, while the slice reviews carry the slices' actual review WORK
|
|
529
|
+
* and are the only thing a resume of a wedged review can be rebuilt from, which is why they are
|
|
530
|
+
* published on the turn a slice lands rather than on the next progress tick.
|
|
531
|
+
*
|
|
532
|
+
* `lastTodo` is a GETTER because the event handler assigns it as the stream goes; taking the value
|
|
533
|
+
* would freeze the plan at construction time.
|
|
534
|
+
*
|
|
535
|
+
* Split out of {@link runClaudeCode} for the per-function line budget.
|
|
536
|
+
*/
|
|
537
|
+
function createClaudeLivePublishers(deps: {
|
|
538
|
+
opts: SubscriptionRunOptions
|
|
539
|
+
planTracker: ReturnType<typeof createTaskPlanTracker>
|
|
540
|
+
sliceTracker: ReturnType<typeof createSliceTracker>
|
|
541
|
+
lastTodo: () => TodoProgress | undefined
|
|
542
|
+
}): { emitProgress: () => void; emitSliceReviews: () => void } {
|
|
543
|
+
const { opts, planTracker, sliceTracker } = deps
|
|
544
|
+
return {
|
|
545
|
+
emitProgress: () => {
|
|
546
|
+
if (!opts.onProgress) return
|
|
547
|
+
const progress = mergeProgress(
|
|
548
|
+
pickProgress(deps.lastTodo(), planTracker.progress()),
|
|
549
|
+
sliceTracker.progress(),
|
|
550
|
+
)
|
|
551
|
+
if (progress) opts.onProgress(progress)
|
|
552
|
+
},
|
|
553
|
+
emitSliceReviews: () => {
|
|
554
|
+
if (!opts.onSliceReviews) return
|
|
555
|
+
const reviews = sliceTracker.sliceReviews()
|
|
556
|
+
if (reviews.length > 0) opts.onSliceReviews(reviews)
|
|
557
|
+
},
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Publish the CLI's own startup report about the tool servers it loaded — the OBSERVED half of the
|
|
563
|
+
* run's tool-server record.
|
|
564
|
+
*
|
|
565
|
+
* Handed every event because it is the one thing `runClaudeCode` reads that is neither a turn nor
|
|
566
|
+
* a result: it arrives once, ahead of the first model call, and says whether the servers the
|
|
567
|
+
* backend wired actually came up. {@link observeClaudeMcpInit} answers `undefined` for every other
|
|
568
|
+
* event and for a run that wired none, so a server-less run reports nothing and the caller's
|
|
569
|
+
* record stays honestly absent rather than empty.
|
|
570
|
+
*
|
|
571
|
+
* Split out of {@link runClaudeCode} for the per-function line budget.
|
|
572
|
+
*/
|
|
573
|
+
function reportToolServerStartup(
|
|
574
|
+
event: Record<string, unknown>,
|
|
575
|
+
onToolServers: ((observed: ObservedMcpServer[]) => void) | undefined,
|
|
576
|
+
): void {
|
|
577
|
+
if (!onToolServers) return
|
|
578
|
+
const observed = observeClaudeMcpInit(event)
|
|
579
|
+
if (observed) onToolServers(observed)
|
|
580
|
+
}
|
|
581
|
+
|
|
504
582
|
/**
|
|
505
583
|
* No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
|
|
506
584
|
* which cannot see the CLI's internal turns. The caller remembers each `tool_use` id's name off
|
|
@@ -645,23 +723,12 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
645
723
|
const sliceTracker = createSliceTracker(secrets)
|
|
646
724
|
const planTracker = createTaskPlanTracker()
|
|
647
725
|
let lastTodo: TodoProgress | undefined
|
|
648
|
-
const emitProgress = (
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
if (progress) opts.onProgress(progress)
|
|
655
|
-
}
|
|
656
|
-
// Publish the per-slice reviews the tracker has captured. Separate from `emitProgress` because
|
|
657
|
-
// the two answer different questions and have different lifetimes: progress is a disposable
|
|
658
|
-
// count the UI renders, while these carry the slices' actual review WORK and are persisted so a
|
|
659
|
-
// run that dies before its aggregation can be resumed from them.
|
|
660
|
-
const emitSliceReviews = (): void => {
|
|
661
|
-
if (!opts.onSliceReviews) return
|
|
662
|
-
const reviews = sliceTracker.sliceReviews()
|
|
663
|
-
if (reviews.length > 0) opts.onSliceReviews(reviews)
|
|
664
|
-
}
|
|
726
|
+
const { emitProgress, emitSliceReviews } = createClaudeLivePublishers({
|
|
727
|
+
opts,
|
|
728
|
+
planTracker,
|
|
729
|
+
sliceTracker,
|
|
730
|
+
lastTodo: () => lastTodo,
|
|
731
|
+
})
|
|
665
732
|
|
|
666
733
|
// No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
|
|
667
734
|
// absent on this path until now. Claude Code reports a tool CALL (its name) on the `assistant`
|
|
@@ -675,6 +742,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
675
742
|
|
|
676
743
|
const onEvent = (event: Record<string, unknown>, meta?: { final?: boolean }): void => {
|
|
677
744
|
const type = event.type
|
|
745
|
+
reportToolServerStartup(event, opts.onToolServers)
|
|
678
746
|
// A subagent's turns ride the parent's stdout tagged with the dispatch that spawned them;
|
|
679
747
|
// `telemetry` routes them off the parent's chain (and decides who bills them). Progress, slice
|
|
680
748
|
// tracking, the guard and `stats` below deliberately see EVERY event: a subagent grinding on
|
package/src/pi-workspace.ts
CHANGED
|
@@ -332,6 +332,11 @@ export async function runAgentInWorkspace(
|
|
|
332
332
|
// land rather than only in the terminal output. Only the subscription runners fan work out
|
|
333
333
|
// across subagents, so this is the only path that can produce it.
|
|
334
334
|
onSliceReviews: opts.onSliceReviews,
|
|
335
|
+
// What the CLI reported about the tool servers it loaded. Wired for BOTH subscription
|
|
336
|
+
// harnesses even though only claude-code's stream carries the report today: the hook is a
|
|
337
|
+
// pass-through, and a codex run that never calls it leaves the backend's record honestly
|
|
338
|
+
// absent rather than claiming every server it wired failed to start.
|
|
339
|
+
onToolServers: opts.onToolServers,
|
|
335
340
|
// Stream this run's per-call telemetry to the job's live drain. The subscription
|
|
336
341
|
// harnesses are the only producers of `callMetrics` (Pi's calls are metered by the LLM
|
|
337
342
|
// proxy as they happen), so this is the only path that needs the hook.
|
package/src/runner.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { FollowUpLine } from './follow-ups.js'
|
|
|
3
3
|
import type { ValidationReport } from './validation-checks.js'
|
|
4
4
|
import type { ReproductionReport } from './reproduction-proof.js'
|
|
5
5
|
import type { SliceReview } from './subagents.js'
|
|
6
|
+
import type { ObservedMcpServer } from './agent-capabilities.js'
|
|
6
7
|
import type { HarnessCallMetric, TodoProgress, ToolSpan } from './pi.js'
|
|
7
8
|
import { log, type Logger } from './logger.js'
|
|
8
9
|
import {
|
|
@@ -56,6 +57,15 @@ export interface RunOptions {
|
|
|
56
57
|
* already persisted. Absent for a job that dispatched no subagents.
|
|
57
58
|
*/
|
|
58
59
|
onSliceReviews?: (reviews: SliceReview[]) => void
|
|
60
|
+
/**
|
|
61
|
+
* Receives what the agent's CLI reported about the tool servers (MCP) it loaded, once it
|
|
62
|
+
* announces its resolved session. Latest-wins (NOT a drain buffer) for the same reason as
|
|
63
|
+
* {@link onValidationReport}, with an extra one of its own: the CLI announces the set ONCE,
|
|
64
|
+
* near the start of the run, so a drain buffer would hand it to whichever poll happened to
|
|
65
|
+
* land next and lose it entirely if that poll response were dropped — on the single fact this
|
|
66
|
+
* whole channel exists to carry. Absent for a job that wired no tool servers.
|
|
67
|
+
*/
|
|
68
|
+
onToolServers?: (observed: ObservedMcpServer[]) => void
|
|
59
69
|
/**
|
|
60
70
|
* Receives each per-call telemetry row the moment the agent's CLI stream yields it, so a
|
|
61
71
|
* run's model calls reach `llm_call_metrics` WHILE it runs rather than only in its terminal
|
|
@@ -224,6 +234,18 @@ export interface JobView<TResult extends JobResultBase = JobResultBase> {
|
|
|
224
234
|
* from. Absent for a job that dispatched no subagents.
|
|
225
235
|
*/
|
|
226
236
|
sliceReviews?: SliceReview[]
|
|
237
|
+
/**
|
|
238
|
+
* What the agent's CLI reported about the tool servers (MCP) wired for this job when it started
|
|
239
|
+
* up: per server, the status the CLI gave it and how many tools it contributed. A whole-value
|
|
240
|
+
* latest publish like {@link validationReport}, not drain-on-read — the CLI announces this once
|
|
241
|
+
* and every later poll re-reports the same set, so no poll can be the one that loses it.
|
|
242
|
+
*
|
|
243
|
+
* The complement of what the BACKEND recorded at dispatch, and the only source for the half it
|
|
244
|
+
* cannot see: the dispatch record says why the platform withheld a tool, this says a wired
|
|
245
|
+
* server failed to start anyway. Absent for a job that wired none, and for a harness whose CLI
|
|
246
|
+
* reports nothing — which is why it is absent rather than empty (see `ObservedMcpServer`).
|
|
247
|
+
*/
|
|
248
|
+
toolServers?: ObservedMcpServer[]
|
|
227
249
|
}
|
|
228
250
|
|
|
229
251
|
interface JobEntry<TResult extends JobResultBase> extends JobView<TResult> {
|
|
@@ -543,6 +565,9 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
|
|
|
543
565
|
onSliceReviews: (reviews) => {
|
|
544
566
|
entry.sliceReviews = reviews
|
|
545
567
|
},
|
|
568
|
+
onToolServers: (observed) => {
|
|
569
|
+
entry.toolServers = observed
|
|
570
|
+
},
|
|
546
571
|
onReproductionProof: (report) => {
|
|
547
572
|
entry.reproductionReport = report
|
|
548
573
|
},
|