@cat-factory/executor-harness 1.94.0 → 1.98.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.
Files changed (47) hide show
  1. package/README.md +16 -12
  2. package/dist/agent-capabilities.d.ts +61 -0
  3. package/dist/agent-capabilities.js +113 -0
  4. package/dist/agent-runner.d.ts +30 -2
  5. package/dist/agent-runner.js +146 -47
  6. package/dist/bootstrap-mode.js +1 -0
  7. package/dist/coding-agent.d.ts +2 -1
  8. package/dist/embed.d.ts +2 -1
  9. package/dist/embed.js +2 -1
  10. package/dist/failure.d.ts +19 -1
  11. package/dist/failure.js +40 -0
  12. package/dist/git.d.ts +6 -0
  13. package/dist/git.js +16 -9
  14. package/dist/inline.d.ts +6 -0
  15. package/dist/inline.js +6 -0
  16. package/dist/job.d.ts +2 -1
  17. package/dist/jsonl-stream.d.ts +70 -0
  18. package/dist/jsonl-stream.js +149 -0
  19. package/dist/pi-reduction.d.ts +136 -0
  20. package/dist/pi-reduction.js +303 -0
  21. package/dist/pi-workspace.d.ts +2 -1
  22. package/dist/pi-workspace.js +11 -1
  23. package/dist/pi.d.ts +8 -81
  24. package/dist/pi.js +124 -310
  25. package/dist/runner.d.ts +53 -0
  26. package/dist/runner.js +53 -3
  27. package/dist/structured-output.js +2 -1
  28. package/dist/tool-silence.d.ts +74 -0
  29. package/dist/tool-silence.js +99 -0
  30. package/package.json +4 -4
  31. package/src/agent-capabilities.ts +163 -0
  32. package/src/agent-runner.ts +185 -47
  33. package/src/agent.ts +1 -1
  34. package/src/bootstrap-mode.ts +2 -1
  35. package/src/coding-agent.ts +2 -1
  36. package/src/embed.ts +8 -5
  37. package/src/failure.ts +36 -9
  38. package/src/git.ts +17 -9
  39. package/src/inline.ts +6 -0
  40. package/src/job.ts +2 -1
  41. package/src/jsonl-stream.ts +149 -0
  42. package/src/pi-reduction.ts +359 -0
  43. package/src/pi-workspace.ts +12 -3
  44. package/src/pi.ts +144 -349
  45. package/src/runner.ts +116 -4
  46. package/src/structured-output.ts +2 -1
  47. package/src/tool-silence.ts +125 -0
package/dist/runner.d.ts CHANGED
@@ -2,8 +2,10 @@ 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';
8
+ import { type ToolProgressWindow } from './tool-silence.js';
7
9
  import { type FailureCause } from './failure.js';
8
10
  /** Non-secret correlation fields a job carries on every log line (jobId, repo, branch, …). */
9
11
  type LogFields = Record<string, unknown>;
@@ -15,6 +17,22 @@ export interface RunOptions {
15
17
  onProgress?: (progress: TodoProgress) => void;
16
18
  /** Receives one compact {@link ToolSpan} per completed tool call (observability). */
17
19
  onSpan?: (span: ToolSpan) => void;
20
+ /**
21
+ * Opens the tool-silence window (stuck-run audit F13) for ONE agent stream, returning the
22
+ * handle that stream beats on every completed tool call and closes when it ends.
23
+ *
24
+ * Called by the agent-CLI runners themselves rather than by the phase marker, because the
25
+ * window is only meaningful while something able to RESET it is running and only the runner
26
+ * knows whether its CLI reports completed tool calls at all. A caller that runs no tool loop
27
+ * (the inline one-shot completion) simply does not forward this, which is a statement, not an
28
+ * omission: the run stays bounded by the inactivity and max-duration watchdogs, and a window
29
+ * nothing could ever beat would only be able to expire.
30
+ *
31
+ * Absent ⇒ no window is opened and this watchdog is silent for that work. It fails toward NOT
32
+ * killing on purpose: this audit exists as much to stop recovery machinery ending healthy runs
33
+ * as to bound wedged ones, and the wall-clock cap is underneath either way.
34
+ */
35
+ beginToolWindow?: () => ToolProgressWindow;
18
36
  /** Receives the forward-looking follow-up / question items the Coder streamed since the last poll. */
19
37
  onFollowUp?: (items: FollowUpLine[]) => void;
20
38
  /**
@@ -40,6 +58,15 @@ export interface RunOptions {
40
58
  * already persisted. Absent for a job that dispatched no subagents.
41
59
  */
42
60
  onSliceReviews?: (reviews: SliceReview[]) => void;
61
+ /**
62
+ * Receives what the agent's CLI reported about the tool servers (MCP) it loaded, once it
63
+ * announces its resolved session. Latest-wins (NOT a drain buffer) for the same reason as
64
+ * {@link onValidationReport}, with an extra one of its own: the CLI announces the set ONCE,
65
+ * near the start of the run, so a drain buffer would hand it to whichever poll happened to
66
+ * land next and lose it entirely if that poll response were dropped — on the single fact this
67
+ * whole channel exists to carry. Absent for a job that wired no tool servers.
68
+ */
69
+ onToolServers?: (observed: ObservedMcpServer[]) => void;
43
70
  /**
44
71
  * Receives each per-call telemetry row the moment the agent's CLI stream yields it, so a
45
72
  * run's model calls reach `llm_call_metrics` WHILE it runs rather than only in its terminal
@@ -208,6 +235,18 @@ export interface JobView<TResult extends JobResultBase = JobResultBase> {
208
235
  * from. Absent for a job that dispatched no subagents.
209
236
  */
210
237
  sliceReviews?: SliceReview[];
238
+ /**
239
+ * What the agent's CLI reported about the tool servers (MCP) wired for this job when it started
240
+ * up: per server, the status the CLI gave it and how many tools it contributed. A whole-value
241
+ * latest publish like {@link validationReport}, not drain-on-read — the CLI announces this once
242
+ * and every later poll re-reports the same set, so no poll can be the one that loses it.
243
+ *
244
+ * The complement of what the BACKEND recorded at dispatch, and the only source for the half it
245
+ * cannot see: the dispatch record says why the platform withheld a tool, this says a wired
246
+ * server failed to start anyway. Absent for a job that wired none, and for a harness whose CLI
247
+ * reports nothing — which is why it is absent rather than empty (see `ObservedMcpServer`).
248
+ */
249
+ toolServers?: ObservedMcpServer[];
211
250
  }
212
251
  /** Watchdog windows that bound every job. Tunable via the container's env. */
213
252
  export interface RunnerLimits {
@@ -224,6 +263,20 @@ export interface RunnerLimits {
224
263
  * progress, which counts as activity). Set to 0 to disable.
225
264
  */
226
265
  coldStartMs: number;
266
+ /**
267
+ * Stuck-run audit F13: force-fail the job if a running agent stream completes no tool call for
268
+ * this long. The gap the other two watchdogs structurally cannot see — a model that keeps
269
+ * talking (or thinking out loud) resets the inactivity timer on every chunk while completing
270
+ * nothing, so the only remaining bound was the full wall-clock cap and the engine's
271
+ * ~70-minute poll budget behind it.
272
+ *
273
+ * Armed only for as long as a stream that REPORTS completed tool calls is running (see
274
+ * {@link RunOptions.beginToolWindow}), so the activity-silent stretches — clone, dependency
275
+ * install, push, a validation loop's check commands — are outside it by construction: they
276
+ * legitimately complete no tool calls, and they are bounded by their own per-command timeouts.
277
+ * Set to 0 to disable.
278
+ */
279
+ toolSilenceMs: number;
227
280
  }
228
281
  export declare function loadRunnerLimits(env?: NodeJS.ProcessEnv): RunnerLimits;
229
282
  /**
package/dist/runner.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { redactSecrets } from './redact.js';
2
2
  import { log } from './logger.js';
3
- import { failureCauseOf, inactivityAbortMessage, maxDurationAbortMessage, } from './failure.js';
3
+ import { ToolSilenceWatchdog } from './tool-silence.js';
4
+ import { failureCauseOf, inactivityAbortMessage, maxDurationAbortMessage, toolSilenceAbortMessage, } from './failure.js';
4
5
  function intEnv(value, fallback) {
5
6
  const n = value ? Number(value) : NaN;
6
7
  return Number.isFinite(n) && n > 0 ? n : fallback;
@@ -13,22 +14,43 @@ function intEnvAllowZero(value, fallback) {
13
14
  return Number.isFinite(n) && n >= 0 ? n : fallback;
14
15
  }
15
16
  export function loadRunnerLimits(env = process.env) {
17
+ const maxDurationMs = intEnv(env.JOB_MAX_DURATION_MS, 60 * 60_000);
18
+ const inactivityMs = intEnv(env.JOB_INACTIVITY_MS, 10 * 60_000);
16
19
  return {
17
20
  // 60 minutes: generous headroom for serious multi-file coding tasks while
18
21
  // still bounding a runaway container.
19
- maxDurationMs: intEnv(env.JOB_MAX_DURATION_MS, 60 * 60_000),
22
+ maxDurationMs,
20
23
  // 10 minutes of zero output is treated as hung (a single long LLM/tool call
21
24
  // is far shorter; Pi streams events as it works). The per-git command ceiling
22
25
  // (`GIT_TIMEOUT_MS` in git.ts) is DERIVED from this value — a fixed margin below
23
26
  // it — so a slow clone/push (which emits no activity events) always times out
24
27
  // with git's own clear reason rather than this watchdog's "likely hung" message,
25
28
  // for any configured window. See the invariant note in git.ts.
26
- inactivityMs: intEnv(env.JOB_INACTIVITY_MS, 10 * 60_000),
29
+ inactivityMs,
27
30
  // 2 minutes: comfortably longer than a warm agent's time-to-first-token yet far
28
31
  // under the 10-minute inactivity kill, so a truly output-less start is flagged early.
29
32
  coldStartMs: intEnvAllowZero(env.JOB_COLD_START_MS, 2 * 60_000),
33
+ toolSilenceMs: intEnvAllowZero(env.JOB_TOOL_SILENCE_MS, toolSilenceDefault(maxDurationMs, inactivityMs)),
30
34
  };
31
35
  }
36
+ /**
37
+ * The tool-silence window when the operator has not set one: HALF the configured wall-clock cap,
38
+ * DERIVED rather than a constant so lowering `JOB_MAX_DURATION_MS` tightens it too (a fixed
39
+ * 30 minutes would sit past the whole budget of a deployment that runs 20-minute jobs, i.e. be
40
+ * silently disabled).
41
+ *
42
+ * Floored at the inactivity window so the default never races the gone-quiet diagnostic more
43
+ * often than it has to. The floor is a sizing choice, NOT the thing that keeps the two watchdogs
44
+ * apart: they anchor on different events (the last completed tool call vs the last byte of
45
+ * output), so the tool-silence anchor is always the earlier of the two and equal windows would
46
+ * still have it firing first. What actually keeps this watchdog off a hang is the expiry test in
47
+ * {@link ToolSilenceWatchdog}, which fires only when output arrived DURING the window that
48
+ * elapsed — and which also holds for an operator who sets `JOB_TOOL_SILENCE_MS` below
49
+ * `JOB_INACTIVITY_MS`, where no default-side clamp applies at all.
50
+ */
51
+ function toolSilenceDefault(maxDurationMs, inactivityMs) {
52
+ return Math.max(Math.round(maxDurationMs / 2), inactivityMs);
53
+ }
32
54
  function toView(entry) {
33
55
  const { promise: _promise, spanBuffer: _spanBuffer, followUpBuffer: _followUpBuffer, callMetricBuffer: _callMetricBuffer, callMetricSeq: _callMetricSeq, abort: _abort, ...view } = entry;
34
56
  return { ...view };
@@ -224,6 +246,20 @@ export class JobRegistry {
224
246
  // spoken yet" test and, on a failure, the difference between a run that died mid-work and one
225
247
  // that never got going at all.
226
248
  let lastActivityAt;
249
+ // Stuck-run audit F13: the third watchdog, and the only one that can see a model which keeps
250
+ // TALKING while completing nothing — its output resets the inactivity timer on every chunk,
251
+ // and it is nowhere near the wall-clock cap. It is armed by the agent stream itself rather
252
+ // than by the phase marker (see `RunOptions.beginToolWindow`) and reads `lastActivityAt` at
253
+ // expiry, which is what keeps the gone-quiet case with the inactivity watchdog that owns it.
254
+ const toolSilence = new ToolSilenceWatchdog({
255
+ windowMs: this.limits.toolSilenceMs,
256
+ lastActivityAt: () => lastActivityAt,
257
+ onExpired: () => {
258
+ // First watchdog to fire wins the reason (see `resetInactivity` above).
259
+ killReason ??= 'no-tool-progress';
260
+ controller.abort(new Error('no tool progress'));
261
+ },
262
+ });
227
263
  // ADR 0026 D4: a one-shot cold-start watchdog. If the job produces no activity within
228
264
  // `coldStartMs`, record a structured diagnostic (a likely onboarding/auth wedge) so it
229
265
  // is legible early — it does NOT abort the run (the inactivity watchdog still owns
@@ -261,6 +297,7 @@ export class JobRegistry {
261
297
  entry.spanBuffer.push(span);
262
298
  lastTool = { name: span.tool, at: span.endedAt };
263
299
  },
300
+ beginToolWindow: () => toolSilence.open(),
264
301
  onFollowUp: (items) => {
265
302
  entry.followUpBuffer.push(...items);
266
303
  },
@@ -270,6 +307,9 @@ export class JobRegistry {
270
307
  onSliceReviews: (reviews) => {
271
308
  entry.sliceReviews = reviews;
272
309
  },
310
+ onToolServers: (observed) => {
311
+ entry.toolServers = observed;
312
+ },
273
313
  onReproductionProof: (report) => {
274
314
  entry.reproductionReport = report;
275
315
  },
@@ -335,6 +375,7 @@ export class JobRegistry {
335
375
  clearTimeout(inactivity);
336
376
  clearTimeout(cap);
337
377
  clearTimeout(coldStart);
378
+ toolSilence.stop();
338
379
  entry.abort = undefined;
339
380
  entry.heartbeatAt = Date.now();
340
381
  }
@@ -373,6 +414,15 @@ export class JobRegistry {
373
414
  detail: redactSecrets(`Phase timings: ${phaseBreakdown || '(none)'}. ${breadcrumb}.${cold}`),
374
415
  };
375
416
  }
417
+ if (ctx.killReason === 'no-tool-progress') {
418
+ return {
419
+ // The breadcrumb carries the last completed tool, which is the whole diagnostic here:
420
+ // it names what the agent was doing when it stopped doing anything.
421
+ message: redactSecrets(`${toolSilenceAbortMessage(this.limits.toolSilenceMs)} (${breadcrumb})`),
422
+ cause: 'no-tool-progress',
423
+ detail: redactSecrets(`Phase timings: ${phaseBreakdown || '(none)'}. ${breadcrumb}.${cold}`),
424
+ };
425
+ }
376
426
  const raw = ctx.error instanceof Error ? ctx.error.message : String(ctx.error);
377
427
  // A thrown error tagged with a structured cause (a git op / an upstream API call) keeps
378
428
  // it; an untagged throw is a generic agent failure.
@@ -1,6 +1,7 @@
1
1
  import { redact, redactSecrets, secretsToRedact } from './redact.js';
2
2
  import { log } from './logger.js';
3
- import { PI_MAX_OUTPUT_TOKENS, phasedProxyBaseUrl } from './pi.js';
3
+ import { phasedProxyBaseUrl } from './pi.js';
4
+ import { PI_MAX_OUTPUT_TOKENS } from './pi-reduction.js';
4
5
  // A reusable abstraction for the "agent returns a structured JSON document as its
5
6
  // final assistant message" pattern (requirements, blueprint, merger — and any future
6
7
  // kind). An agent of this kind emits its result as text, not a tool call, and the
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The live window for ONE tool-reporting agent stream. Opened by the stream, beaten by each
3
+ * completed tool call, closed when the stream ends (cleanly or not) — so the window can never
4
+ * outlive the only thing able to reset it.
5
+ */
6
+ export interface ToolProgressWindow {
7
+ /** A tool call completed: the only evidence this watchdog accepts as progress. */
8
+ toolCompleted(): void;
9
+ /** The stream ended; the window closes with it. Idempotent. */
10
+ close(): void;
11
+ }
12
+ /**
13
+ * A window that measures nothing: what the watchdog hands out when disabled, and what a producer
14
+ * substitutes when its caller wired no watchdog at all. Having one means every producer holds a
15
+ * real window and states its tool progress unconditionally, rather than guarding each call site
16
+ * with a `?.` that reads as though beating the window were optional.
17
+ */
18
+ export declare const NO_TOOL_WINDOW: ToolProgressWindow;
19
+ export interface ToolSilenceDeps {
20
+ /** The window length. `<= 0` disables the watchdog entirely (every window is inert). */
21
+ windowMs: number;
22
+ /**
23
+ * When the run last produced ANY output, or undefined if it never has — the same clock the
24
+ * inactivity watchdog resets on. Read at EXPIRY (see {@link ToolSilenceWatchdog.open}), which
25
+ * is what keeps this watchdog off the gone-quiet case.
26
+ */
27
+ lastActivityAt: () => number | undefined;
28
+ /** Called when a window expires with the run demonstrably still talking. */
29
+ onExpired: () => void;
30
+ }
31
+ /**
32
+ * Hands out {@link ToolProgressWindow}s and fires `onExpired` for one that goes a full window
33
+ * without a completed tool call while the run keeps producing output.
34
+ *
35
+ * ONE window is open at a time (a job runs one agent stream at a time, and a repair loop runs
36
+ * them in sequence). Opening a second supersedes the first, and a superseded handle's calls are
37
+ * ignored rather than reaching back into the live window — a stale `close()` from a stream that
38
+ * finished after its successor started would otherwise disarm the watchdog for the rest of the job.
39
+ */
40
+ export declare class ToolSilenceWatchdog {
41
+ private readonly deps;
42
+ private timer;
43
+ /** Identity of the window currently open; every handout takes the next number. */
44
+ private openId;
45
+ /** When the live window was last armed — the start of the span `onExpired` is a verdict about. */
46
+ private armedAt;
47
+ constructor(deps: ToolSilenceDeps);
48
+ /** Open a window for one agent stream. Returns an inert handle when the watchdog is disabled. */
49
+ open(): ToolProgressWindow;
50
+ /** Disarm for good (the job settled). Safe to call with no window open. */
51
+ stop(): void;
52
+ private arm;
53
+ /**
54
+ * A window elapsed with no completed tool call. Fire ONLY if the run was talking through it.
55
+ *
56
+ * `no-tool-progress` claims something specific — output arrived, but nothing got done — and
57
+ * that is only a truthful reading when output actually arrived DURING the window that just
58
+ * expired. A window that passed in total silence is the INACTIVITY watchdog's fact, whose
59
+ * diagnostic ("the container went quiet") is the one an operator can act on; relabelling it as
60
+ * a rabbit-hole would send them looking at the model instead of at the hang.
61
+ *
62
+ * This is a structural guard, not a tie-breaker: the two timers anchor on different events (the
63
+ * last tool call vs the last byte of output), so no arithmetic between the two window LENGTHS
64
+ * can order them. Equal windows put the tool-silence anchor strictly earlier — it fires first —
65
+ * and an operator setting `JOB_TOOL_SILENCE_MS` below `JOB_INACTIVITY_MS` gets that on every
66
+ * quiet run. Deciding from what the expired window actually SAW is independent of both numbers.
67
+ *
68
+ * A deferred window re-arms rather than standing down, so a run that goes quiet and then
69
+ * resumes its monologue is still caught, one full window later. The deferral terminates:
70
+ * either output resumes (the next expiry has output in its window and fires) or it does not
71
+ * (inactivity fires).
72
+ */
73
+ private expire;
74
+ }
@@ -0,0 +1,99 @@
1
+ // The tool-silence watchdog (stuck-run audit F13): the third bound on a job, and the only one
2
+ // that can see a model which keeps TALKING while completing nothing. Its output resets the
3
+ // inactivity timer on every chunk, and it is nowhere near the wall-clock cap.
4
+ //
5
+ // WHY THIS IS ITS OWN MODULE — the window is only meaningful while something that REPORTS
6
+ // completed tool calls is running, and only that producer knows whether it does. Keying the
7
+ // window on the job's coarse phase label instead looked equivalent and was not: `agent` is a
8
+ // telemetry breadcrumb several call sites mark for several different things (a Codex pass, a
9
+ // tool-less inline completion, the label restored around a repair loop's shell commands), so a
10
+ // phase-armed window spent most of its time armed over work that could not possibly reset it.
11
+ // The producer opens its own window instead, which makes "armed" and "can beat" the same fact by
12
+ // construction rather than by two call sites agreeing.
13
+ /**
14
+ * A window that measures nothing: what the watchdog hands out when disabled, and what a producer
15
+ * substitutes when its caller wired no watchdog at all. Having one means every producer holds a
16
+ * real window and states its tool progress unconditionally, rather than guarding each call site
17
+ * with a `?.` that reads as though beating the window were optional.
18
+ */
19
+ export const NO_TOOL_WINDOW = { toolCompleted: () => { }, close: () => { } };
20
+ /**
21
+ * Hands out {@link ToolProgressWindow}s and fires `onExpired` for one that goes a full window
22
+ * without a completed tool call while the run keeps producing output.
23
+ *
24
+ * ONE window is open at a time (a job runs one agent stream at a time, and a repair loop runs
25
+ * them in sequence). Opening a second supersedes the first, and a superseded handle's calls are
26
+ * ignored rather than reaching back into the live window — a stale `close()` from a stream that
27
+ * finished after its successor started would otherwise disarm the watchdog for the rest of the job.
28
+ */
29
+ export class ToolSilenceWatchdog {
30
+ deps;
31
+ timer;
32
+ /** Identity of the window currently open; every handout takes the next number. */
33
+ openId = 0;
34
+ /** When the live window was last armed — the start of the span `onExpired` is a verdict about. */
35
+ armedAt = 0;
36
+ constructor(deps) {
37
+ this.deps = deps;
38
+ }
39
+ /** Open a window for one agent stream. Returns an inert handle when the watchdog is disabled. */
40
+ open() {
41
+ if (this.deps.windowMs <= 0)
42
+ return NO_TOOL_WINDOW;
43
+ const id = ++this.openId;
44
+ this.arm();
45
+ const live = () => this.openId === id;
46
+ return {
47
+ toolCompleted: () => {
48
+ if (live())
49
+ this.arm();
50
+ },
51
+ close: () => {
52
+ if (!live())
53
+ return;
54
+ // Retire the id as well as the timer, so a late `toolCompleted()` from this stream
55
+ // cannot re-arm a window whose producer has already gone.
56
+ this.openId++;
57
+ this.stop();
58
+ },
59
+ };
60
+ }
61
+ /** Disarm for good (the job settled). Safe to call with no window open. */
62
+ stop() {
63
+ clearTimeout(this.timer);
64
+ this.timer = undefined;
65
+ }
66
+ arm() {
67
+ clearTimeout(this.timer);
68
+ this.armedAt = Date.now();
69
+ this.timer = setTimeout(() => this.expire(), this.deps.windowMs);
70
+ }
71
+ /**
72
+ * A window elapsed with no completed tool call. Fire ONLY if the run was talking through it.
73
+ *
74
+ * `no-tool-progress` claims something specific — output arrived, but nothing got done — and
75
+ * that is only a truthful reading when output actually arrived DURING the window that just
76
+ * expired. A window that passed in total silence is the INACTIVITY watchdog's fact, whose
77
+ * diagnostic ("the container went quiet") is the one an operator can act on; relabelling it as
78
+ * a rabbit-hole would send them looking at the model instead of at the hang.
79
+ *
80
+ * This is a structural guard, not a tie-breaker: the two timers anchor on different events (the
81
+ * last tool call vs the last byte of output), so no arithmetic between the two window LENGTHS
82
+ * can order them. Equal windows put the tool-silence anchor strictly earlier — it fires first —
83
+ * and an operator setting `JOB_TOOL_SILENCE_MS` below `JOB_INACTIVITY_MS` gets that on every
84
+ * quiet run. Deciding from what the expired window actually SAW is independent of both numbers.
85
+ *
86
+ * A deferred window re-arms rather than standing down, so a run that goes quiet and then
87
+ * resumes its monologue is still caught, one full window later. The deferral terminates:
88
+ * either output resumes (the next expiry has output in its window and fires) or it does not
89
+ * (inactivity fires).
90
+ */
91
+ expire() {
92
+ const lastActivityAt = this.deps.lastActivityAt();
93
+ if (lastActivityAt === undefined || lastActivityAt <= this.armedAt) {
94
+ this.arm();
95
+ return;
96
+ }
97
+ this.deps.onExpired();
98
+ }
99
+ }
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.98.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.267.0",
34
+ "@cat-factory/server": "0.247.0",
35
+ "@cat-factory/spend": "0.15.35"
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