@cat-factory/executor-harness 1.96.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.
package/README.md CHANGED
@@ -210,9 +210,12 @@ Kimi / DeepSeek) and meters spend. The provider key never enters the container.
210
210
  | File | Responsibility |
211
211
  | ------------------ | ------------------------------------------------------------------------------------------------------- |
212
212
  | `src/server.ts` | HTTP entry point; routes `/health`, `/run`, `/bootstrap`, `/blueprint`, `/jobs/{id}`. |
213
- | `src/runner.ts` | `JobRegistry`: async job lifecycle, idempotent on `jobId`, progress tracking. |
213
+ | `src/runner.ts` | `JobRegistry`: async job lifecycle, idempotent on `jobId`, progress tracking, and the three per-job watchdogs (max-duration, inactivity, tool-silence). |
214
+ | `src/jsonl-stream.ts` | The BOUNDS on a child CLI's streams, shared by both runners: `JsonlLineReader` frames its JSONL stdout while refusing to buffer a runaway record, `BoundedTail` keeps a capped tail of raw output for failure quoting. Both watchdog timers and the poll endpoints share one event loop with this parsing, so an unbounded buffer here is how a container stops answering polls with no watchdog having fired. |
214
215
  | `src/job.ts` | Request types + validators for the job specs. |
215
216
  | `src/pi.ts` | Pi provider config, non-interactive run, JSON-line event + todo-progress parsing, global `AGENTS.md` guidance. |
217
+ | `src/pi-reduction.ts` | Reducing a Pi event stream to what the run PRODUCED (summary, stats, diagnostics, terminal failure), FOLDED as records stream rather than over a retained array — memory is O(largest record), not O(records). The array-taking entry points offline tooling uses are defined in terms of the same reducer. |
218
+ | `src/tool-silence.ts` | The tool-silence watchdog (F13) and the `ToolProgressWindow` an agent stream opens, beats and closes. Separate from the phase marker on purpose: a window is only meaningful while something able to reset it is running. |
216
219
  | `src/git.ts` | clone / branch / commit / push + GitHub PR creation; bootstrap history reset + force-push. |
217
220
  | `src/bootstrap.ts` | The `/bootstrap` handler (clone-or-empty → adapt → reinit + force-push). |
218
221
  | `src/blueprint.ts` | The `/blueprint` handler (decompose → render `blueprints/` → commit on branch). |
@@ -240,6 +243,7 @@ runner):
240
243
  | `PORT` | `8080` | HTTP port the harness listens on. |
241
244
  | `JOB_MAX_DURATION_MS` | `3600000` (60m) | Hard ceiling on a job's wall-clock time; force-fails after. |
242
245
  | `JOB_INACTIVITY_MS` | `600000` (10m) | Kills a hung agent that produces no output for this long. |
246
+ | `JOB_TOOL_SILENCE_MS` | half `JOB_MAX_DURATION_MS` (30m at its default) | Kills an agent that keeps producing output but completes no tool call for this long: the "chatty hang" neither watchdog above can see, since streamed output resets the inactivity timer on every chunk while nothing gets done (stuck-run audit F13). Armed ONLY while an agent CLI that reports completed tool calls is running (each runner opens its own window and closes it on exit), so clone / dependency install / push / a validation loop's check commands sit outside it — they are activity-silent by nature and bounded by their own per-command timeouts — and each repair pass opens a fresh window. Derived from the job ceiling rather than fixed. It fires only when output arrived during the window that elapsed, which is what leaves a genuinely quiet run to `JOB_INACTIVITY_MS` and its clearer diagnostic. `0` disables it. |
243
247
  | `JOB_MAX_CONSECUTIVE_MCP_CALLS` | `40` | Consecutive tool-server (`mcp__*`) calls with no other tool call between before the run counts as a lookup loop. The counter-bound the no-edit exemption above owes; a per-kind `tuning.guardLimits` entry can only RAISE it. |
244
248
  | `JOB_MAX_CONSECUTIVE_NON_ACTION_CALLS` | `200` | Consecutive calls of ANY no-edit-exempt family (reads, searches, web, tool servers, subagent dispatches) with no action call between them. The backstop above the per-family caps, since each of those resets on a call outside its own family; sized as a backstop rather than a research judgement, and reset by any `bash`/edit. |
245
249
  | `JOB_COLD_START_MS` | `120000` (2m) | First-output window (ADR 0026 D4). A job that has produced nothing this long records a cold-start diagnostic (a likely onboarding/auth wedge) WITHOUT being killed: logged, exposed on `GET /jobs/{id}`, and folded into the failure `detail` if the job goes on to fail. `0` disables it. |
@@ -1,4 +1,5 @@
1
- import type { Logger } from './logger.js';
1
+ import { type Logger } from './logger.js';
2
+ import { type ToolProgressWindow } from './tool-silence.js';
2
3
  import { type HarnessCallMetric, type PiRunOutcome, type TodoProgress, type ToolSpan } from './pi.js';
3
4
  import { type McpServerSpec, type ObservedMcpServer, type SkillSpec } from './agent-capabilities.js';
4
5
  import { type ProgressGuardLimits } from './progress-guard.js';
@@ -79,6 +80,18 @@ export interface SubscriptionRunOptions {
79
80
  * DID dies with the container.
80
81
  */
81
82
  onSpan?: (span: ToolSpan) => void;
83
+ /**
84
+ * Opens this stream's tool-silence window (see `RunOptions.beginToolWindow`), closed when the
85
+ * CLI exits. Both subscription CLIs report tool activity — claude-code on the `tool_result`
86
+ * turn that answers each call, codex on its tool/command/exec events — so a window either
87
+ * opens is one the run can beat. It is deliberately NOT tied to {@link onSpan}: the trajectory
88
+ * is an observability opt-in, and the codex stream produces none at all while still doing tool
89
+ * work, which a span-keyed window would have read as a run making no progress.
90
+ *
91
+ * A caller with no tool loop (the inline one-shot completion) passes nothing; see the note at
92
+ * `handleInline`.
93
+ */
94
+ beginToolWindow?: () => ToolProgressWindow;
82
95
  /**
83
96
  * Called with the FULL set of per-slice reviews each time one lands, so the backend can persist
84
97
  * a parallel review's completed work as it happens instead of only from the terminal result.
@@ -5,9 +5,12 @@ import { dirname, join } from 'node:path';
5
5
  import { claudeAssistantContent, isObject, numberOf, redactBody } from './claude-stream.js';
6
6
  import { createClaudeRunTelemetry, subagentDispatchId } from './claude-call-aggregator.js';
7
7
  import { ToolCallTracker, recordClaudeToolResults, } from './tool-trajectory.js';
8
+ import { log } from './logger.js';
9
+ import { NO_TOOL_WINDOW } from './tool-silence.js';
8
10
  import { createCallMetricPublisher, publishCallMetric, } from './pi.js';
9
11
  import { claudeAllowedToolPatterns, codexMcpConfigToml, mcpServerSecretValues, observeClaudeMcpInit, writeClaudeMcpConfig, } from './agent-capabilities.js';
10
12
  import { ProgressGuard } from './progress-guard.js';
13
+ import { BoundedTail, JsonlLineReader } from './jsonl-stream.js';
11
14
  import { killChildProcess, spawnDetached } from './process.js';
12
15
  import { describeProcessExit } from './process-exit.js';
13
16
  import { redact, registerKnownSecrets, secretsToRedact } from './redact.js';
@@ -59,9 +62,10 @@ function streamCli(cli, prompt, opts, env, secrets, onEvent) {
59
62
  });
60
63
  child.stdin.on('error', () => { });
61
64
  child.stdin.end(prompt);
62
- let stderr = '';
65
+ // 8 KB is well over the 700 B tail anyone quotes below, and the CLI's stderr is diagnostic
66
+ // noise rather than a product, so a bounded tail is all this ever needed to be.
67
+ const stderr = new BoundedTail(8_000);
63
68
  let aborted = false;
64
- let lineBuffer = '';
65
69
  const killChild = () => killChildProcess(child);
66
70
  // `final` marks the at-close flush of a trailing unterminated line: the CLI has already
67
71
  // exited, so an observer must not act on that record in a way that KILLS the run (mirrors
@@ -85,16 +89,9 @@ function streamCli(cli, prompt, opts, env, secrets, onEvent) {
85
89
  // A faulty observer must never break the run.
86
90
  }
87
91
  };
88
- const consumeStdout = (text) => {
89
- lineBuffer += text;
90
- let nl = lineBuffer.indexOf('\n');
91
- while (nl !== -1) {
92
- const line = lineBuffer.slice(0, nl).trim();
93
- lineBuffer = lineBuffer.slice(nl + 1);
94
- nl = lineBuffer.indexOf('\n');
95
- processLine(line);
96
- }
97
- };
92
+ // Bounded framing, shared with `runPi`: an unterminated record must not be able to grow
93
+ // until parsing it stalls the loop the watchdogs and poll handlers run on (audit F6).
94
+ const reader = new JsonlLineReader(processLine);
98
95
  const onAbort = () => {
99
96
  aborted = true;
100
97
  killChild();
@@ -102,13 +99,11 @@ function streamCli(cli, prompt, opts, env, secrets, onEvent) {
102
99
  opts.signal?.addEventListener('abort', onAbort, { once: true });
103
100
  child.stdout.on('data', (chunk) => {
104
101
  opts.onActivity?.();
105
- consumeStdout(chunk.toString());
102
+ reader.push(chunk.toString());
106
103
  });
107
104
  child.stderr.on('data', (chunk) => {
108
105
  opts.onActivity?.();
109
- stderr += chunk.toString();
110
- if (stderr.length > 8_000)
111
- stderr = stderr.slice(-8_000);
106
+ stderr.push(chunk.toString());
112
107
  });
113
108
  child.on('error', (err) => {
114
109
  opts.signal?.removeEventListener('abort', onAbort);
@@ -116,9 +111,20 @@ function streamCli(cli, prompt, opts, env, secrets, onEvent) {
116
111
  });
117
112
  child.on('close', (code, signal) => {
118
113
  opts.signal?.removeEventListener('abort', onAbort);
119
- const stderrTail = redact(stderr, secrets).slice(-700);
120
- if (lineBuffer.trim())
121
- processLine(lineBuffer.trim(), true);
114
+ const stderrTail = redact(stderr.toString(), secrets).slice(-700);
115
+ reader.flush();
116
+ // Surface an oversized record the reader refused to buffer ONCE (a count, not per line),
117
+ // for the same reason `runPi` does: a dropped record costs this run its progress, its
118
+ // trajectory and its per-call telemetry for that turn, and a silent loss reads exactly
119
+ // like a CLI that never emitted it. Falls back to the module logger so the report cannot
120
+ // depend on a caller having wired a per-job one.
121
+ if (reader.droppedLines > 0) {
122
+ ;
123
+ (opts.log ?? log).warn('agent CLI: skipped oversized JSONL records', {
124
+ command,
125
+ oversizedLines: reader.droppedLines,
126
+ });
127
+ }
122
128
  if (aborted) {
123
129
  // Carry the tail on the rejection so a caller that REPLACES this generic message with a
124
130
  // more specific cause (the no-progress guard's diagnostic) can still append it — the
@@ -448,6 +454,23 @@ function createClaudeToolTrajectory(opts, secrets) {
448
454
  onToolResults: (content) => recordClaudeToolResults(tracker, content, (call) => onSpan({ ...call, bodies: 'stored' })),
449
455
  };
450
456
  }
457
+ /**
458
+ * Open this run's tool-silence window, or the inert one when the caller wired no watchdog. One
459
+ * definition so both runners resolve "is there a watchdog?" identically, and so neither carries
460
+ * the optional-call noise at the point where it should simply have a window.
461
+ */
462
+ function openToolWindow(opts) {
463
+ return opts.beginToolWindow ? opts.beginToolWindow() : NO_TOOL_WINDOW;
464
+ }
465
+ /**
466
+ * Whether a claude-code `user` turn carries a `tool_result` block, i.e. whether a tool call just
467
+ * COMPLETED — the progress the tool-silence watchdog measures. Tested explicitly rather than
468
+ * taken from "the model sent a user turn", which a plain follow-up prompt also is: a watchdog
469
+ * reset handed out for work that did nothing is the same as no watchdog.
470
+ */
471
+ function carriesToolResult(content) {
472
+ return content.some((block) => isObject(block) && block.type === 'tool_result');
473
+ }
451
474
  export async function runClaudeCode(opts) {
452
475
  const stats = { toolCalls: 0, assistantChars: 0 };
453
476
  let summary = '';
@@ -524,6 +547,9 @@ export async function runClaudeCode(opts) {
524
547
  const progressGuard = createClaudeProgressGuard(opts);
525
548
  const { rememberTool, feedGuard, guardAbort } = progressGuard;
526
549
  const trajectory = createClaudeToolTrajectory(opts, secrets);
550
+ // This stream's tool-silence window; opened just before the CLI starts and closed in the
551
+ // `finally` below, so it can only ever be armed while the CLI it watches is running.
552
+ let toolWindow = NO_TOOL_WINDOW;
527
553
  const onEvent = (event, meta) => {
528
554
  const type = event.type;
529
555
  reportToolServerStartup(event, opts.onToolServers);
@@ -563,6 +589,8 @@ export async function runClaudeCode(opts) {
563
589
  // tool_result blocks the harness fed back to the model — part of the next prompt.
564
590
  const content = event.message.content;
565
591
  if (Array.isArray(content)) {
592
+ if (carriesToolResult(content))
593
+ toolWindow.toolCompleted();
566
594
  sliceTracker.onUser(content);
567
595
  planTracker.onUser(content);
568
596
  emitProgress();
@@ -610,6 +638,9 @@ export async function runClaudeCode(opts) {
610
638
  const runSignal = opts.signal
611
639
  ? AbortSignal.any([opts.signal, guardAbort.signal])
612
640
  : guardAbort.signal;
641
+ // Opened around the CLI itself, not around this function: everything above is per-run setup
642
+ // (the config home, the skills, the MCP config) which completes no tool calls by nature.
643
+ toolWindow = openToolWindow(opts);
613
644
  try {
614
645
  const { stderrTail } = await streamCli({
615
646
  command: 'claude',
@@ -673,6 +704,7 @@ export async function runClaudeCode(opts) {
673
704
  throw withAgentReport(err, terminalReport, secrets);
674
705
  }
675
706
  finally {
707
+ toolWindow.close();
676
708
  await subagents?.stop();
677
709
  await home.dispose();
678
710
  }
@@ -825,6 +857,27 @@ function claudeUsage(raw) {
825
857
  // ---------------------------------------------------------------------------
826
858
  // Codex
827
859
  // ---------------------------------------------------------------------------
860
+ /**
861
+ * The assistant text a codex event carries, or `''`. Two shapes because the CLI changed its
862
+ * stream between versions and the harness serves both: the flat `agent_message*` events and the
863
+ * newer `item.completed` envelope around a message item.
864
+ */
865
+ function codexAssistantText(event, type) {
866
+ const isMessage = type.includes('agent_message') || (type === 'item.completed' && isCodexMessageItem(event));
867
+ return (isMessage ? extractText(event) : '') ?? '';
868
+ }
869
+ /**
870
+ * Whether a codex event reports tool activity — a substring test because the CLI names these
871
+ * events differently across versions (`exec_command_end`, `item.*` around a command execution,
872
+ * `tool_*`) and the harness cares only that SOMETHING ran.
873
+ *
874
+ * This is also the tool-silence watchdog's only signal on this stream. Codex exposes no
875
+ * structured tool bodies, so `runCodex` produces no `ToolSpan` at all, and a window keyed on the
876
+ * trajectory would have force-failed every codex pass that outran it while the run was working.
877
+ */
878
+ function isCodexToolActivity(type) {
879
+ return type.includes('tool') || type.includes('command') || type.includes('exec');
880
+ }
828
881
  /**
829
882
  * Run the Codex CLI headlessly against `opts.cwd`, authenticated with the leased
830
883
  * ChatGPT `auth.json` bundle written to an isolated CODEX_HOME, talking direct to
@@ -881,6 +934,9 @@ export async function runCodex(opts) {
881
934
  // context into the prompt itself (Claude Code instead rides --append-system-prompt,
882
935
  // falling back to this same fold when the prompt overflows argv).
883
936
  const prompt = foldSystemPrompt(opts.systemPrompt, opts.userPrompt);
937
+ // This stream's tool-silence window (see the claude runner for the shape); opened just before
938
+ // the CLI starts and closed in the `finally` below.
939
+ let toolWindow = NO_TOOL_WINDOW;
884
940
  // Codex's `exec --json` is far thinner than Claude Code's stream: it surfaces only
885
941
  // flat assistant text and (on `token_count` events) the per-turn `last_token_usage`
886
942
  // plus a cumulative total. It never exposes the request transcript or structured
@@ -892,17 +948,15 @@ export async function runCodex(opts) {
892
948
  let pendingText = '';
893
949
  const onEvent = (event) => {
894
950
  const type = typeof event.type === 'string' ? event.type : '';
895
- if (type.includes('agent_message') ||
896
- (type === 'item.completed' && isCodexMessageItem(event))) {
897
- const text = extractText(event);
898
- if (text) {
899
- stats.assistantChars += text.length;
900
- summary = text;
901
- pendingText = text;
902
- }
951
+ const text = codexAssistantText(event, type);
952
+ if (text) {
953
+ stats.assistantChars += text.length;
954
+ summary = text;
955
+ pendingText = text;
903
956
  }
904
- if (type.includes('tool') || type.includes('command') || type.includes('exec')) {
957
+ if (isCodexToolActivity(type)) {
905
958
  stats.toolCalls += 1;
959
+ toolWindow.toolCompleted();
906
960
  }
907
961
  const progress = codexPlanProgress(event);
908
962
  if (progress && opts.onProgress)
@@ -931,6 +985,7 @@ export async function runCodex(opts) {
931
985
  pendingText = '';
932
986
  }
933
987
  };
988
+ toolWindow = openToolWindow(opts);
934
989
  try {
935
990
  const { stderrTail } = await streamCli({
936
991
  command: 'codex',
@@ -985,6 +1040,7 @@ export async function runCodex(opts) {
985
1040
  throw withAgentReport(err, summary, secrets);
986
1041
  }
987
1042
  finally {
1043
+ toolWindow.close();
988
1044
  if (codexHome) {
989
1045
  // Lift the CLI session transcripts (`sessions/`) out for short-lived retention BEFORE the
990
1046
  // home is deleted — the credential (`auth.json`) lives at the home root, never in
@@ -81,6 +81,7 @@ export async function runBootstrap(job, opts) {
81
81
  dir,
82
82
  target: boot.target,
83
83
  ghToken: job.ghToken,
84
+ signal,
84
85
  message: fromScratch
85
86
  ? 'Bootstrap new repository'
86
87
  : `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
@@ -1,5 +1,6 @@
1
1
  import type { AgentJob, AgentResult, HarnessAuthFields, RepoSpec, SkillSpec, McpServerSpec } from './job.js';
2
- import type { HarnessCallMetric, PiRunStats } from './pi.js';
2
+ import type { HarnessCallMetric } from './pi.js';
3
+ import type { PiRunStats } from './pi-reduction.js';
3
4
  import { type EffortReport } from './effort.js';
4
5
  import { type AgentPrDescription } from './pr-description.js';
5
6
  import type { ProgressGuardLimits } from './progress-guard.js';
package/dist/embed.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- export { PI_MAX_OUTPUT_TOKENS, writePiModelsConfig, writeAgentsContext, runPi, summarizePiRun, parsePiOutput, parseTodoProgress, terminalRunError, type PiRunOutcome, type PiRunStats, type TodoItem, type TodoProgress, } from './pi.js';
1
+ export { writePiModelsConfig, writeAgentsContext, runPi, parseTodoProgress, type PiRunOutcome, type TodoItem, type TodoProgress, } from './pi.js';
2
+ export { PI_MAX_OUTPUT_TOKENS, parsePiOutput, summarizePiRun, terminalRunError, type PiRunReduction, type PiRunStats, } from './pi-reduction.js';
2
3
  export { DEFAULT_PROGRESS_GUARD_LIMITS, progressGuardLimitsFromEnv, type ProgressGuardLimits, } from './progress-guard.js';
3
4
  export { cloneRepo, createBranch, changedPathsFromPorcelain, hasAgentChanges, redactSecrets, } from './git.js';
4
5
  export type { RepoSpec } from './job.js';
package/dist/embed.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // repo, write the agent context, point Pi at an OpenAI-compatible endpoint, run
5
5
  // it, and inspect what changed. The HTTP server / job lifecycle stays internal;
6
6
  // only the reusable primitives are exposed here.
7
- export { PI_MAX_OUTPUT_TOKENS, writePiModelsConfig, writeAgentsContext, runPi, summarizePiRun, parsePiOutput, parseTodoProgress, terminalRunError, } from './pi.js';
7
+ export { writePiModelsConfig, writeAgentsContext, runPi, parseTodoProgress, } from './pi.js';
8
+ export { PI_MAX_OUTPUT_TOKENS, parsePiOutput, summarizePiRun, terminalRunError, } from './pi-reduction.js';
8
9
  export { DEFAULT_PROGRESS_GUARD_LIMITS, progressGuardLimitsFromEnv, } from './progress-guard.js';
9
10
  export { cloneRepo, createBranch, changedPathsFromPorcelain, hasAgentChanges, redactSecrets, } from './git.js';
package/dist/failure.d.ts CHANGED
@@ -5,6 +5,10 @@
5
5
  *
6
6
  * - `inactivity-timeout` — the inactivity watchdog fired (no agent output for the window).
7
7
  * - `max-duration` — the overall wall-clock cap fired.
8
+ * - `no-tool-progress` — the tool-silence watchdog fired: the agent kept TALKING but completed
9
+ * no tool call for the window. Distinct from `inactivity-timeout` on
10
+ * purpose, because the two need different fixes: one says the container
11
+ * went quiet, this one says the model rabbit-holed while streaming.
8
12
  * - `agent` — the agent ran but produced an unusable/failed result, or threw.
9
13
  * - `git` — a git operation failed (clone/push/merge/PR).
10
14
  * - `api` — an upstream API call failed (e.g. the GitHub/GitLab PR/MR REST call).
@@ -13,7 +17,14 @@
13
17
  * - `no-usable-output` — the agent finished but returned no usable report / structured output.
14
18
  * - `no-changes` — a coding agent finished without producing any change to push.
15
19
  */
16
- export type FailureCause = 'inactivity-timeout' | 'max-duration' | 'agent' | 'git' | 'api' | 'llm-upstream' | 'no-usable-output' | 'no-changes';
20
+ export declare const FAILURE_CAUSES: readonly ['inactivity-timeout', 'max-duration', 'no-tool-progress', 'agent', 'git', 'api', 'llm-upstream', 'no-usable-output', 'no-changes'];
21
+ /**
22
+ * See {@link FAILURE_CAUSES}. Derived from the array rather than declared beside it so the two
23
+ * cannot disagree, and so the list is ENUMERABLE at runtime — which is what lets
24
+ * `failure-cause.conformity.test.ts` check this image's vocabulary against the kernel union that
25
+ * has to classify it (the two are kept in step by hand; the image can carry no workspace dep).
26
+ */
27
+ export type FailureCause = (typeof FAILURE_CAUSES)[number];
17
28
  /**
18
29
  * A thrown failure that carries a structured {@link FailureCause}, so a `git` / `api`
19
30
  * operation that fails deep in a helper surfaces its real cause instead of being flattened
@@ -40,3 +51,10 @@ export declare function inactivityAbortMessage(inactivityMs: number): string;
40
51
  * in error-message coverage I5), so it is free to change.
41
52
  */
42
53
  export declare function maxDurationAbortMessage(maxDurationMs: number): string;
54
+ /**
55
+ * The tool-silence-watchdog abort message. Human-readable only, like its two siblings — the
56
+ * backend reads the structured `no-tool-progress` {@link FailureCause}. Says what it observed
57
+ * (output, but no completed tool call) rather than "hung": the run was demonstrably alive, which
58
+ * is exactly why the inactivity watchdog never fired.
59
+ */
60
+ export declare function toolSilenceAbortMessage(toolSilenceMs: number): string;
package/dist/failure.js CHANGED
@@ -12,6 +12,36 @@
12
12
  // the facade-owned eviction sentinel `(container evicted or crashed)`, which
13
13
  // `job.logic.isContainerEvictionError` still matches for a DISPATCH-time throw that carries no job
14
14
  // view — and which the harness must keep NOT emitting for a non-eviction failure.
15
+ /**
16
+ * The structured reason a harness job failed, surfaced on the job view's `failureCause`.
17
+ * Covers only HARNESS-owned failures — container eviction is detected by the runtime facade
18
+ * (a vanished container → `(container evicted or crashed)`), never set here.
19
+ *
20
+ * - `inactivity-timeout` — the inactivity watchdog fired (no agent output for the window).
21
+ * - `max-duration` — the overall wall-clock cap fired.
22
+ * - `no-tool-progress` — the tool-silence watchdog fired: the agent kept TALKING but completed
23
+ * no tool call for the window. Distinct from `inactivity-timeout` on
24
+ * purpose, because the two need different fixes: one says the container
25
+ * went quiet, this one says the model rabbit-holed while streaming.
26
+ * - `agent` — the agent ran but produced an unusable/failed result, or threw.
27
+ * - `git` — a git operation failed (clone/push/merge/PR).
28
+ * - `api` — an upstream API call failed (e.g. the GitHub/GitLab PR/MR REST call).
29
+ * - `llm-upstream` — the model provider rejected every call (auth/quota/rate-limit) and Pi
30
+ * exhausted its retries, so the run never produced a result.
31
+ * - `no-usable-output` — the agent finished but returned no usable report / structured output.
32
+ * - `no-changes` — a coding agent finished without producing any change to push.
33
+ */
34
+ export const FAILURE_CAUSES = [
35
+ 'inactivity-timeout',
36
+ 'max-duration',
37
+ 'no-tool-progress',
38
+ 'agent',
39
+ 'git',
40
+ 'api',
41
+ 'llm-upstream',
42
+ 'no-usable-output',
43
+ 'no-changes',
44
+ ];
15
45
  /**
16
46
  * A thrown failure that carries a structured {@link FailureCause}, so a `git` / `api`
17
47
  * operation that fails deep in a helper surfaces its real cause instead of being flattened
@@ -48,3 +78,13 @@ export function inactivityAbortMessage(inactivityMs) {
48
78
  export function maxDurationAbortMessage(maxDurationMs) {
49
79
  return `Aborted: exceeded max duration of ${Math.round(maxDurationMs / 1000)}s`;
50
80
  }
81
+ /**
82
+ * The tool-silence-watchdog abort message. Human-readable only, like its two siblings — the
83
+ * backend reads the structured `no-tool-progress` {@link FailureCause}. Says what it observed
84
+ * (output, but no completed tool call) rather than "hung": the run was demonstrably alive, which
85
+ * is exactly why the inactivity watchdog never fired.
86
+ */
87
+ export function toolSilenceAbortMessage(toolSilenceMs) {
88
+ return (`Aborted: the agent produced output but completed no tool call for ` +
89
+ `${Math.round(toolSilenceMs / 1000)}s`);
90
+ }
package/dist/git.d.ts CHANGED
@@ -385,10 +385,16 @@ export declare function pushBranch(dir: string, branch: string, ghToken: string,
385
385
  * .gitignore and/or license picked on the new-repo page), so a fast-forward is
386
386
  * impossible. The Worker pre-flights that the target is empty or holds only that
387
387
  * boilerplate, so overwriting it is safe and intended.
388
+ *
389
+ * `signal` is the job watchdog's, and threading it is load-bearing rather than tidy: without
390
+ * it the six commands below are bounded only by their own per-command timeouts, so an abort
391
+ * raised during the push phase cannot interrupt them and the job keeps working for up to
392
+ * ~6 × `GIT_TIMEOUT_MS` past its max-duration kill. Every other git helper here threads it.
388
393
  */
389
394
  export declare function reinitAndPush(opts: {
390
395
  dir: string;
391
396
  target: BootstrapTargetSpec;
392
397
  ghToken: string;
393
398
  message: string;
399
+ signal?: AbortSignal;
394
400
  }): Promise<void>;
package/dist/git.js CHANGED
@@ -872,20 +872,27 @@ export async function pushBranch(dir, branch, ghToken, signal) {
872
872
  * .gitignore and/or license picked on the new-repo page), so a fast-forward is
873
873
  * impossible. The Worker pre-flights that the target is empty or holds only that
874
874
  * boilerplate, so overwriting it is safe and intended.
875
+ *
876
+ * `signal` is the job watchdog's, and threading it is load-bearing rather than tidy: without
877
+ * it the six commands below are bounded only by their own per-command timeouts, so an abort
878
+ * raised during the push phase cannot interrupt them and the job keeps working for up to
879
+ * ~6 × `GIT_TIMEOUT_MS` past its max-duration kill. Every other git helper here threads it.
875
880
  */
876
881
  export async function reinitAndPush(opts) {
877
- await rm(join(opts.dir, '.git'), { recursive: true, force: true });
878
- await git(['init'], { cwd: opts.dir });
882
+ const { dir, signal } = opts;
883
+ await rm(join(dir, '.git'), { recursive: true, force: true });
884
+ await git(['init'], { cwd: dir, signal });
879
885
  // Start the history on the target's default branch (init may default to master).
880
- await git(['checkout', '-b', opts.target.defaultBranch], { cwd: opts.dir });
881
- await git(['config', 'user.name', GIT_AUTHOR], { cwd: opts.dir });
882
- await git(['config', 'user.email', GIT_EMAIL], { cwd: opts.dir });
883
- await git(['add', '-A'], { cwd: opts.dir });
884
- await git(['commit', '-m', opts.message], { cwd: opts.dir });
886
+ await git(['checkout', '-b', opts.target.defaultBranch], { cwd: dir, signal });
887
+ await git(['config', 'user.name', GIT_AUTHOR], { cwd: dir, signal });
888
+ await git(['config', 'user.email', GIT_EMAIL], { cwd: dir, signal });
889
+ await git(['add', '-A'], { cwd: dir, signal });
890
+ await git(['commit', '-m', opts.message], { cwd: dir, signal });
885
891
  const url = authenticatedCloneUrl(opts.target.cloneUrl);
886
- await git(['remote', 'add', 'origin', url], { cwd: opts.dir });
892
+ await git(['remote', 'add', 'origin', url], { cwd: dir, signal });
887
893
  await git(['push', '--force', '-u', 'origin', opts.target.defaultBranch], {
888
- cwd: opts.dir,
894
+ cwd: dir,
895
+ signal,
889
896
  env: await authEnv(opts.ghToken),
890
897
  });
891
898
  }
package/dist/inline.d.ts CHANGED
@@ -6,5 +6,11 @@ import type { RunOptions } from './runner.js';
6
6
  * directory. The job's watchdog (inactivity + max-duration, see {@link JobRegistry}) bounds
7
7
  * it through `opts.signal`; `opts.onActivity` keeps the inactivity timer alive while the CLI
8
8
  * streams. The temp cwd is always removed.
9
+ *
10
+ * `opts.beginToolWindow` is deliberately NOT forwarded. An inline completion is a one-shot
11
+ * answer with no checkout and no tool loop, so the tool-silence watchdog would arm a window this
12
+ * run could never beat and could only ever expire — force-failing a healthy completion under a
13
+ * cause ("kept talking, completed no tool call") that misdescribes what it is. The inactivity and
14
+ * max-duration watchdogs still bound it, which is the whole bound this work ever had.
9
15
  */
10
16
  export declare function handleInline(job: InlineJob, opts: RunOptions): Promise<InlineResult>;
package/dist/inline.js CHANGED
@@ -29,6 +29,12 @@ function deriveFinishReason(calls) {
29
29
  * directory. The job's watchdog (inactivity + max-duration, see {@link JobRegistry}) bounds
30
30
  * it through `opts.signal`; `opts.onActivity` keeps the inactivity timer alive while the CLI
31
31
  * streams. The temp cwd is always removed.
32
+ *
33
+ * `opts.beginToolWindow` is deliberately NOT forwarded. An inline completion is a one-shot
34
+ * answer with no checkout and no tool loop, so the tool-silence watchdog would arm a window this
35
+ * run could never beat and could only ever expire — force-failing a healthy completion under a
36
+ * cause ("kept talking, completed no tool call") that misdescribes what it is. The inactivity and
37
+ * max-duration watchdogs still bound it, which is the whole bound this work ever had.
32
38
  */
33
39
  export async function handleInline(job, opts) {
34
40
  opts.onPhase?.('agent');
package/dist/job.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { HarnessCallMetric, PiRunStats } from './pi.js';
1
+ import type { HarnessCallMetric } from './pi.js';
2
+ import type { PiRunStats } from './pi-reduction.js';
2
3
  import type { HarnessKind } from './pi-workspace.js';
3
4
  import type { FailureCause } from './failure.js';
4
5
  import type { EffortReport } from './effort.js';
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Longest single JSONL record either CLI may emit before the reader stops buffering it.
3
+ *
4
+ * Deliberately far above the largest LEGITIMATE record — the terminal `agent_end`, which carries
5
+ * the run's whole message transcript including tool results — because dropping that one costs the
6
+ * run its summary and stats. The cap is not a size policy, it is the ceiling that keeps a
7
+ * runaway producer from growing the buffer until parsing it wedges the event loop, so it only
8
+ * has to be low enough that one parse of it stays well inside the poll cadence.
9
+ */
10
+ export declare const MAX_JSONL_LINE_CHARS: number;
11
+ /**
12
+ * A fixed-size tail of a text stream, for output kept ONLY to quote back on a failure.
13
+ *
14
+ * Retaining a whole run's stdout to slice the last 2 KB off it at close is the memory half of
15
+ * F6: a chatty agent's output is unbounded, and the container OOMing is another way for a job to
16
+ * stop answering polls with no watchdog having fired. The tail is trimmed lazily — only once it
17
+ * has grown past twice the bound — so a run that streams thousands of chunks pays an amortized
18
+ * O(1) copy per chunk rather than an O(maxChars) slice on every one of them.
19
+ */
20
+ export declare class BoundedTail {
21
+ private readonly maxChars;
22
+ private text;
23
+ private total;
24
+ constructor(maxChars: number);
25
+ push(chunk: string): void;
26
+ /** The last `maxChars` characters seen. */
27
+ toString(): string;
28
+ /** Everything ever pushed, whether or not it is still retained. */
29
+ get totalChars(): number;
30
+ /**
31
+ * Characters dropped off the FRONT because the tail is bounded; 0 while everything still fits.
32
+ *
33
+ * A caller that renders the tail to a human owes them this: a bounded tail is the opposite of a
34
+ * prefix, so a reader who assumes one concludes the producer stopped where the text begins.
35
+ * Diagnostic quotes (a stderr tail) need no such note — being a tail is what they are for.
36
+ */
37
+ get droppedChars(): number;
38
+ }
39
+ /**
40
+ * Frames a child's LF-delimited JSONL stdout into complete records, bounding what it will buffer
41
+ * for any one of them.
42
+ *
43
+ * `onLine` is invoked per complete record with `final: false`, and once more from {@link flush}
44
+ * with `final: true` for a trailing record that arrived without its newline (a clean exit can
45
+ * leave the last event unterminated). `final` is what lets a caller deliver the record's
46
+ * progress/telemetry signal while suppressing any decision that would KILL the run: the process
47
+ * has already exited, so a guard tripping on that last buffered record would turn a clean exit
48
+ * into a spurious failure.
49
+ *
50
+ * A record that outgrows {@link MAX_JSONL_LINE_CHARS} is DROPPED, not truncated: a partial JSON
51
+ * document is not a record, and handing the parser half of one would report it as corrupt output
52
+ * rather than as the bound firing. The reader then resynchronises on the next newline, so the
53
+ * oversized record costs its own signal and nothing after it. Callers report {@link droppedLines}
54
+ * at close (never per line) so the loss is diagnosable instead of silent.
55
+ */
56
+ export declare class JsonlLineReader {
57
+ private readonly onLine;
58
+ private readonly maxLineChars;
59
+ private buffer;
60
+ /** True while discarding the tail of a record that already blew the cap. */
61
+ private skipping;
62
+ private dropped;
63
+ constructor(onLine: (line: string, final: boolean) => void, maxLineChars?: number);
64
+ /** Feed one stdout chunk, emitting every complete record it finishes. */
65
+ push(text: string): void;
66
+ /** Emit any trailing unterminated record (see the class doc); call once, after the child exits. */
67
+ flush(): void;
68
+ /** Records dropped for exceeding the line cap; 0 on every ordinary run. */
69
+ get droppedLines(): number;
70
+ }