@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
@@ -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
- import { claudeAllowedToolPatterns, codexMcpConfigToml, mcpServerSecretValues, writeClaudeMcpConfig, } from './agent-capabilities.js';
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
@@ -327,6 +333,60 @@ async function setUpClaudeMcp(servers, configHome) {
327
333
  cleanup,
328
334
  };
329
335
  }
336
+ /**
337
+ * The LIVE publishers of a claude-code run: everything the stream has revealed so far that the
338
+ * backend should see before the run ends, rather than only in its terminal result.
339
+ *
340
+ * They are grouped because they share one rule and differ on everything else. The rule: each
341
+ * publishes a WHOLE current value (never a delta), so a dropped poll response costs nothing and
342
+ * the caller may fire them as often as it likes. What differs is what is at stake — progress is a
343
+ * disposable count the UI renders, while the slice reviews carry the slices' actual review WORK
344
+ * and are the only thing a resume of a wedged review can be rebuilt from, which is why they are
345
+ * published on the turn a slice lands rather than on the next progress tick.
346
+ *
347
+ * `lastTodo` is a GETTER because the event handler assigns it as the stream goes; taking the value
348
+ * would freeze the plan at construction time.
349
+ *
350
+ * Split out of {@link runClaudeCode} for the per-function line budget.
351
+ */
352
+ function createClaudeLivePublishers(deps) {
353
+ const { opts, planTracker, sliceTracker } = deps;
354
+ return {
355
+ emitProgress: () => {
356
+ if (!opts.onProgress)
357
+ return;
358
+ const progress = mergeProgress(pickProgress(deps.lastTodo(), planTracker.progress()), sliceTracker.progress());
359
+ if (progress)
360
+ opts.onProgress(progress);
361
+ },
362
+ emitSliceReviews: () => {
363
+ if (!opts.onSliceReviews)
364
+ return;
365
+ const reviews = sliceTracker.sliceReviews();
366
+ if (reviews.length > 0)
367
+ opts.onSliceReviews(reviews);
368
+ },
369
+ };
370
+ }
371
+ /**
372
+ * Publish the CLI's own startup report about the tool servers it loaded — the OBSERVED half of the
373
+ * run's tool-server record.
374
+ *
375
+ * Handed every event because it is the one thing `runClaudeCode` reads that is neither a turn nor
376
+ * a result: it arrives once, ahead of the first model call, and says whether the servers the
377
+ * backend wired actually came up. {@link observeClaudeMcpInit} answers `undefined` for every other
378
+ * event and for a run that wired none, so a server-less run reports nothing and the caller's
379
+ * record stays honestly absent rather than empty.
380
+ *
381
+ * Split out of {@link runClaudeCode} for the per-function line budget.
382
+ */
383
+ function reportToolServerStartup(event, onToolServers) {
384
+ if (!onToolServers)
385
+ return;
386
+ const observed = observeClaudeMcpInit(event);
387
+ if (observed)
388
+ onToolServers(observed);
389
+ }
330
390
  /**
331
391
  * No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
332
392
  * which cannot see the CLI's internal turns. The caller remembers each `tool_use` id's name off
@@ -394,6 +454,23 @@ function createClaudeToolTrajectory(opts, secrets) {
394
454
  onToolResults: (content) => recordClaudeToolResults(tracker, content, (call) => onSpan({ ...call, bodies: 'stored' })),
395
455
  };
396
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
+ }
397
474
  export async function runClaudeCode(opts) {
398
475
  const stats = { toolCalls: 0, assistantChars: 0 };
399
476
  let summary = '';
@@ -455,24 +532,12 @@ export async function runClaudeCode(opts) {
455
532
  const sliceTracker = createSliceTracker(secrets);
456
533
  const planTracker = createTaskPlanTracker();
457
534
  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
- };
535
+ const { emitProgress, emitSliceReviews } = createClaudeLivePublishers({
536
+ opts,
537
+ planTracker,
538
+ sliceTracker,
539
+ lastTodo: () => lastTodo,
540
+ });
476
541
  // No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
477
542
  // absent on this path until now. Claude Code reports a tool CALL (its name) on the `assistant`
478
543
  // turn and that call's RESULT (`is_error`) on the following `user` turn, so correlate them by
@@ -482,8 +547,12 @@ export async function runClaudeCode(opts) {
482
547
  const progressGuard = createClaudeProgressGuard(opts);
483
548
  const { rememberTool, feedGuard, guardAbort } = progressGuard;
484
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;
485
553
  const onEvent = (event, meta) => {
486
554
  const type = event.type;
555
+ reportToolServerStartup(event, opts.onToolServers);
487
556
  // A subagent's turns ride the parent's stdout tagged with the dispatch that spawned them;
488
557
  // `telemetry` routes them off the parent's chain (and decides who bills them). Progress, slice
489
558
  // tracking, the guard and `stats` below deliberately see EVERY event: a subagent grinding on
@@ -520,6 +589,8 @@ export async function runClaudeCode(opts) {
520
589
  // tool_result blocks the harness fed back to the model — part of the next prompt.
521
590
  const content = event.message.content;
522
591
  if (Array.isArray(content)) {
592
+ if (carriesToolResult(content))
593
+ toolWindow.toolCompleted();
523
594
  sliceTracker.onUser(content);
524
595
  planTracker.onUser(content);
525
596
  emitProgress();
@@ -567,6 +638,9 @@ export async function runClaudeCode(opts) {
567
638
  const runSignal = opts.signal
568
639
  ? AbortSignal.any([opts.signal, guardAbort.signal])
569
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);
570
644
  try {
571
645
  const { stderrTail } = await streamCli({
572
646
  command: 'claude',
@@ -630,6 +704,7 @@ export async function runClaudeCode(opts) {
630
704
  throw withAgentReport(err, terminalReport, secrets);
631
705
  }
632
706
  finally {
707
+ toolWindow.close();
633
708
  await subagents?.stop();
634
709
  await home.dispose();
635
710
  }
@@ -782,6 +857,27 @@ function claudeUsage(raw) {
782
857
  // ---------------------------------------------------------------------------
783
858
  // Codex
784
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
+ }
785
881
  /**
786
882
  * Run the Codex CLI headlessly against `opts.cwd`, authenticated with the leased
787
883
  * ChatGPT `auth.json` bundle written to an isolated CODEX_HOME, talking direct to
@@ -838,6 +934,9 @@ export async function runCodex(opts) {
838
934
  // context into the prompt itself (Claude Code instead rides --append-system-prompt,
839
935
  // falling back to this same fold when the prompt overflows argv).
840
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;
841
940
  // Codex's `exec --json` is far thinner than Claude Code's stream: it surfaces only
842
941
  // flat assistant text and (on `token_count` events) the per-turn `last_token_usage`
843
942
  // plus a cumulative total. It never exposes the request transcript or structured
@@ -849,17 +948,15 @@ export async function runCodex(opts) {
849
948
  let pendingText = '';
850
949
  const onEvent = (event) => {
851
950
  const type = typeof event.type === 'string' ? event.type : '';
852
- if (type.includes('agent_message') ||
853
- (type === 'item.completed' && isCodexMessageItem(event))) {
854
- const text = extractText(event);
855
- if (text) {
856
- stats.assistantChars += text.length;
857
- summary = text;
858
- pendingText = text;
859
- }
951
+ const text = codexAssistantText(event, type);
952
+ if (text) {
953
+ stats.assistantChars += text.length;
954
+ summary = text;
955
+ pendingText = text;
860
956
  }
861
- if (type.includes('tool') || type.includes('command') || type.includes('exec')) {
957
+ if (isCodexToolActivity(type)) {
862
958
  stats.toolCalls += 1;
959
+ toolWindow.toolCompleted();
863
960
  }
864
961
  const progress = codexPlanProgress(event);
865
962
  if (progress && opts.onProgress)
@@ -888,6 +985,7 @@ export async function runCodex(opts) {
888
985
  pendingText = '';
889
986
  }
890
987
  };
988
+ toolWindow = openToolWindow(opts);
891
989
  try {
892
990
  const { stderrTail } = await streamCli({
893
991
  command: 'codex',
@@ -942,6 +1040,7 @@ export async function runCodex(opts) {
942
1040
  throw withAgentReport(err, summary, secrets);
943
1041
  }
944
1042
  finally {
1043
+ toolWindow.close();
945
1044
  if (codexHome) {
946
1045
  // Lift the CLI session transcripts (`sessions/`) out for short-lived retention BEFORE the
947
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
+ }