@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 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](../../docs/runner-pool-integration.md)) to perform real
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`](../../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](../../../docs/initiatives/token-burn-instrumentation.md)),
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](../../../docs/initiatives/agent-dependency-prepopulation.md)),
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](../../../docs/initiatives/pre-pr-validation.md)),
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](../../docs/adr/0033-bugfix-reproduction-proof.md)). Unlike
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`](../../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`](../../../.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`](../../../deploy/backend)); a self-hosted runner pool pulls the
307
- same image (see [`docs/runner-pool-integration.md`](../../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`](../../../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
@@ -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.
@@ -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
- if (!opts.onProgress)
460
- return;
461
- const progress = mergeProgress(pickProgress(lastTodo, planTracker.progress()), sliceTracker.progress());
462
- if (progress)
463
- opts.onProgress(progress);
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
@@ -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.94.0",
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.249.0",
34
- "@cat-factory/server": "0.229.0",
35
- "@cat-factory/spend": "0.15.14"
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
@@ -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 = (): void => {
649
- if (!opts.onProgress) return
650
- const progress = mergeProgress(
651
- pickProgress(lastTodo, planTracker.progress()),
652
- sliceTracker.progress(),
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
@@ -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
  },