@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
@@ -9,26 +9,30 @@ import {
9
9
  type TrackedToolCall,
10
10
  recordClaudeToolResults,
11
11
  } from './tool-trajectory.js'
12
- import type { Logger } from './logger.js'
12
+ import { log, type Logger } from './logger.js'
13
+ import { NO_TOOL_WINDOW, type ToolProgressWindow } from './tool-silence.js'
13
14
  import {
14
15
  createCallMetricPublisher,
15
16
  publishCallMetric,
16
17
  type CallMetricPublisher,
17
18
  type HarnessCallMetric,
18
19
  type PiRunOutcome,
19
- type PiRunStats,
20
20
  type TodoProgress,
21
21
  type ToolSpan,
22
22
  } from './pi.js'
23
+ import type { PiRunStats } from './pi-reduction.js'
23
24
  import {
24
25
  claudeAllowedToolPatterns,
25
26
  codexMcpConfigToml,
26
27
  mcpServerSecretValues,
28
+ observeClaudeMcpInit,
27
29
  writeClaudeMcpConfig,
28
30
  type McpServerSpec,
31
+ type ObservedMcpServer,
29
32
  type SkillSpec,
30
33
  } from './agent-capabilities.js'
31
34
  import { ProgressGuard, type ProgressGuardLimits } from './progress-guard.js'
35
+ import { BoundedTail, JsonlLineReader } from './jsonl-stream.js'
32
36
  import { killChildProcess, spawnDetached } from './process.js'
33
37
  import { describeProcessExit } from './process-exit.js'
34
38
  import { redact, registerKnownSecrets, secretsToRedact } from './redact.js'
@@ -136,6 +140,18 @@ export interface SubscriptionRunOptions {
136
140
  * DID dies with the container.
137
141
  */
138
142
  onSpan?: (span: ToolSpan) => void
143
+ /**
144
+ * Opens this stream's tool-silence window (see `RunOptions.beginToolWindow`), closed when the
145
+ * CLI exits. Both subscription CLIs report tool activity — claude-code on the `tool_result`
146
+ * turn that answers each call, codex on its tool/command/exec events — so a window either
147
+ * opens is one the run can beat. It is deliberately NOT tied to {@link onSpan}: the trajectory
148
+ * is an observability opt-in, and the codex stream produces none at all while still doing tool
149
+ * work, which a span-keyed window would have read as a run making no progress.
150
+ *
151
+ * A caller with no tool loop (the inline one-shot completion) passes nothing; see the note at
152
+ * `handleInline`.
153
+ */
154
+ beginToolWindow?: () => ToolProgressWindow
139
155
  /**
140
156
  * Called with the FULL set of per-slice reviews each time one lands, so the backend can persist
141
157
  * a parallel review's completed work as it happens instead of only from the terminal result.
@@ -149,6 +165,21 @@ export interface SubscriptionRunOptions {
149
165
  * same row still rides the result, so a lost poll response costs nothing.
150
166
  */
151
167
  onCallMetric?: (call: HarnessCallMetric) => void
168
+ /**
169
+ * Called once with what the CLI reported about the tool servers it loaded, the moment it
170
+ * announces its resolved session (see {@link observeClaudeMcpInit}).
171
+ *
172
+ * The one thing the backend's own dispatch record cannot answer: it knows why it WITHHELD a
173
+ * tool, and this says a server it wired failed to start anyway. Reported even when every server
174
+ * came up, because "observed, all healthy" and "this image observed nothing" are different
175
+ * facts about a run and only the first one clears a wired server of suspicion.
176
+ *
177
+ * Whole-value latest-wins, not a delta — the CLI announces its session once, so a second call
178
+ * would only ever be a re-announcement of the same set. A harness whose CLI reports nothing
179
+ * (codex today) never calls this, which is what leaves the backend's record honestly empty
180
+ * rather than claiming every server failed.
181
+ */
182
+ onToolServers?: (observed: ObservedMcpServer[]) => void
152
183
  /**
153
184
  * The per-job child logger (jobId/repo/branch correlation). Threaded so the retained
154
185
  * session-transcript path is logged for the run when the isolated config home is torn down.
@@ -210,9 +241,10 @@ function streamCli(
210
241
  child.stdin.on('error', () => {})
211
242
  child.stdin.end(prompt)
212
243
 
213
- let stderr = ''
244
+ // 8 KB is well over the 700 B tail anyone quotes below, and the CLI's stderr is diagnostic
245
+ // noise rather than a product, so a bounded tail is all this ever needed to be.
246
+ const stderr = new BoundedTail(8_000)
214
247
  let aborted = false
215
- let lineBuffer = ''
216
248
 
217
249
  const killChild = (): void => killChildProcess(child)
218
250
 
@@ -236,16 +268,9 @@ function streamCli(
236
268
  }
237
269
  }
238
270
 
239
- const consumeStdout = (text: string): void => {
240
- lineBuffer += text
241
- let nl = lineBuffer.indexOf('\n')
242
- while (nl !== -1) {
243
- const line = lineBuffer.slice(0, nl).trim()
244
- lineBuffer = lineBuffer.slice(nl + 1)
245
- nl = lineBuffer.indexOf('\n')
246
- processLine(line)
247
- }
248
- }
271
+ // Bounded framing, shared with `runPi`: an unterminated record must not be able to grow
272
+ // until parsing it stalls the loop the watchdogs and poll handlers run on (audit F6).
273
+ const reader = new JsonlLineReader(processLine)
249
274
 
250
275
  const onAbort = (): void => {
251
276
  aborted = true
@@ -255,12 +280,11 @@ function streamCli(
255
280
 
256
281
  child.stdout.on('data', (chunk: Buffer) => {
257
282
  opts.onActivity?.()
258
- consumeStdout(chunk.toString())
283
+ reader.push(chunk.toString())
259
284
  })
260
285
  child.stderr.on('data', (chunk: Buffer) => {
261
286
  opts.onActivity?.()
262
- stderr += chunk.toString()
263
- if (stderr.length > 8_000) stderr = stderr.slice(-8_000)
287
+ stderr.push(chunk.toString())
264
288
  })
265
289
 
266
290
  child.on('error', (err) => {
@@ -269,8 +293,19 @@ function streamCli(
269
293
  })
270
294
  child.on('close', (code, signal) => {
271
295
  opts.signal?.removeEventListener('abort', onAbort)
272
- const stderrTail = redact(stderr, secrets).slice(-700)
273
- if (lineBuffer.trim()) processLine(lineBuffer.trim(), true)
296
+ const stderrTail = redact(stderr.toString(), secrets).slice(-700)
297
+ reader.flush()
298
+ // Surface an oversized record the reader refused to buffer ONCE (a count, not per line),
299
+ // for the same reason `runPi` does: a dropped record costs this run its progress, its
300
+ // trajectory and its per-call telemetry for that turn, and a silent loss reads exactly
301
+ // like a CLI that never emitted it. Falls back to the module logger so the report cannot
302
+ // depend on a caller having wired a per-job one.
303
+ if (reader.droppedLines > 0) {
304
+ ;(opts.log ?? log).warn('agent CLI: skipped oversized JSONL records', {
305
+ command,
306
+ oversizedLines: reader.droppedLines,
307
+ })
308
+ }
274
309
  if (aborted) {
275
310
  // Carry the tail on the rejection so a caller that REPLACES this generic message with a
276
311
  // more specific cause (the no-progress guard's diagnostic) can still append it — the
@@ -501,6 +536,67 @@ async function setUpClaudeMcp(
501
536
  }
502
537
  }
503
538
 
539
+ /**
540
+ * The LIVE publishers of a claude-code run: everything the stream has revealed so far that the
541
+ * backend should see before the run ends, rather than only in its terminal result.
542
+ *
543
+ * They are grouped because they share one rule and differ on everything else. The rule: each
544
+ * publishes a WHOLE current value (never a delta), so a dropped poll response costs nothing and
545
+ * the caller may fire them as often as it likes. What differs is what is at stake — progress is a
546
+ * disposable count the UI renders, while the slice reviews carry the slices' actual review WORK
547
+ * and are the only thing a resume of a wedged review can be rebuilt from, which is why they are
548
+ * published on the turn a slice lands rather than on the next progress tick.
549
+ *
550
+ * `lastTodo` is a GETTER because the event handler assigns it as the stream goes; taking the value
551
+ * would freeze the plan at construction time.
552
+ *
553
+ * Split out of {@link runClaudeCode} for the per-function line budget.
554
+ */
555
+ function createClaudeLivePublishers(deps: {
556
+ opts: SubscriptionRunOptions
557
+ planTracker: ReturnType<typeof createTaskPlanTracker>
558
+ sliceTracker: ReturnType<typeof createSliceTracker>
559
+ lastTodo: () => TodoProgress | undefined
560
+ }): { emitProgress: () => void; emitSliceReviews: () => void } {
561
+ const { opts, planTracker, sliceTracker } = deps
562
+ return {
563
+ emitProgress: () => {
564
+ if (!opts.onProgress) return
565
+ const progress = mergeProgress(
566
+ pickProgress(deps.lastTodo(), planTracker.progress()),
567
+ sliceTracker.progress(),
568
+ )
569
+ if (progress) opts.onProgress(progress)
570
+ },
571
+ emitSliceReviews: () => {
572
+ if (!opts.onSliceReviews) return
573
+ const reviews = sliceTracker.sliceReviews()
574
+ if (reviews.length > 0) opts.onSliceReviews(reviews)
575
+ },
576
+ }
577
+ }
578
+
579
+ /**
580
+ * Publish the CLI's own startup report about the tool servers it loaded — the OBSERVED half of the
581
+ * run's tool-server record.
582
+ *
583
+ * Handed every event because it is the one thing `runClaudeCode` reads that is neither a turn nor
584
+ * a result: it arrives once, ahead of the first model call, and says whether the servers the
585
+ * backend wired actually came up. {@link observeClaudeMcpInit} answers `undefined` for every other
586
+ * event and for a run that wired none, so a server-less run reports nothing and the caller's
587
+ * record stays honestly absent rather than empty.
588
+ *
589
+ * Split out of {@link runClaudeCode} for the per-function line budget.
590
+ */
591
+ function reportToolServerStartup(
592
+ event: Record<string, unknown>,
593
+ onToolServers: ((observed: ObservedMcpServer[]) => void) | undefined,
594
+ ): void {
595
+ if (!onToolServers) return
596
+ const observed = observeClaudeMcpInit(event)
597
+ if (observed) onToolServers(observed)
598
+ }
599
+
504
600
  /**
505
601
  * No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
506
602
  * which cannot see the CLI's internal turns. The caller remembers each `tool_use` id's name off
@@ -581,6 +677,25 @@ function createClaudeToolTrajectory(
581
677
  }
582
678
  }
583
679
 
680
+ /**
681
+ * Open this run's tool-silence window, or the inert one when the caller wired no watchdog. One
682
+ * definition so both runners resolve "is there a watchdog?" identically, and so neither carries
683
+ * the optional-call noise at the point where it should simply have a window.
684
+ */
685
+ function openToolWindow(opts: SubscriptionRunOptions): ToolProgressWindow {
686
+ return opts.beginToolWindow ? opts.beginToolWindow() : NO_TOOL_WINDOW
687
+ }
688
+
689
+ /**
690
+ * Whether a claude-code `user` turn carries a `tool_result` block, i.e. whether a tool call just
691
+ * COMPLETED — the progress the tool-silence watchdog measures. Tested explicitly rather than
692
+ * taken from "the model sent a user turn", which a plain follow-up prompt also is: a watchdog
693
+ * reset handed out for work that did nothing is the same as no watchdog.
694
+ */
695
+ function carriesToolResult(content: unknown[]): boolean {
696
+ return content.some((block) => isObject(block) && block.type === 'tool_result')
697
+ }
698
+
584
699
  export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRunOutcome> {
585
700
  const stats: PiRunStats = { toolCalls: 0, assistantChars: 0 }
586
701
  let summary = ''
@@ -645,23 +760,12 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
645
760
  const sliceTracker = createSliceTracker(secrets)
646
761
  const planTracker = createTaskPlanTracker()
647
762
  let lastTodo: TodoProgress | undefined
648
- const emitProgress = (): void => {
649
- if (!opts.onProgress) return
650
- const progress = mergeProgress(
651
- pickProgress(lastTodo, planTracker.progress()),
652
- sliceTracker.progress(),
653
- )
654
- if (progress) opts.onProgress(progress)
655
- }
656
- // Publish the per-slice reviews the tracker has captured. Separate from `emitProgress` because
657
- // the two answer different questions and have different lifetimes: progress is a disposable
658
- // count the UI renders, while these carry the slices' actual review WORK and are persisted so a
659
- // run that dies before its aggregation can be resumed from them.
660
- const emitSliceReviews = (): void => {
661
- if (!opts.onSliceReviews) return
662
- const reviews = sliceTracker.sliceReviews()
663
- if (reviews.length > 0) opts.onSliceReviews(reviews)
664
- }
763
+ const { emitProgress, emitSliceReviews } = createClaudeLivePublishers({
764
+ opts,
765
+ planTracker,
766
+ sliceTracker,
767
+ lastTodo: () => lastTodo,
768
+ })
665
769
 
666
770
  // No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
667
771
  // absent on this path until now. Claude Code reports a tool CALL (its name) on the `assistant`
@@ -672,9 +776,13 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
672
776
  const progressGuard = createClaudeProgressGuard(opts)
673
777
  const { rememberTool, feedGuard, guardAbort } = progressGuard
674
778
  const trajectory = createClaudeToolTrajectory(opts, secrets)
779
+ // This stream's tool-silence window; opened just before the CLI starts and closed in the
780
+ // `finally` below, so it can only ever be armed while the CLI it watches is running.
781
+ let toolWindow: ToolProgressWindow = NO_TOOL_WINDOW
675
782
 
676
783
  const onEvent = (event: Record<string, unknown>, meta?: { final?: boolean }): void => {
677
784
  const type = event.type
785
+ reportToolServerStartup(event, opts.onToolServers)
678
786
  // A subagent's turns ride the parent's stdout tagged with the dispatch that spawned them;
679
787
  // `telemetry` routes them off the parent's chain (and decides who bills them). Progress, slice
680
788
  // tracking, the guard and `stats` below deliberately see EVERY event: a subagent grinding on
@@ -708,6 +816,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
708
816
  // tool_result blocks the harness fed back to the model — part of the next prompt.
709
817
  const content = (event.message as Record<string, unknown>).content
710
818
  if (Array.isArray(content)) {
819
+ if (carriesToolResult(content)) toolWindow.toolCompleted()
711
820
  sliceTracker.onUser(content)
712
821
  planTracker.onUser(content)
713
822
  emitProgress()
@@ -756,6 +865,9 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
756
865
  ? AbortSignal.any([opts.signal, guardAbort.signal])
757
866
  : guardAbort.signal
758
867
 
868
+ // Opened around the CLI itself, not around this function: everything above is per-run setup
869
+ // (the config home, the skills, the MCP config) which completes no tool calls by nature.
870
+ toolWindow = openToolWindow(opts)
759
871
  try {
760
872
  const { stderrTail } = await streamCli(
761
873
  {
@@ -827,6 +939,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
827
939
  }
828
940
  throw withAgentReport(err, terminalReport, secrets)
829
941
  } finally {
942
+ toolWindow.close()
830
943
  await subagents?.stop()
831
944
  await home.dispose()
832
945
  }
@@ -1029,6 +1142,30 @@ function claudeUsage(raw: unknown): { inputTokens: number; outputTokens: number
1029
1142
  // Codex
1030
1143
  // ---------------------------------------------------------------------------
1031
1144
 
1145
+ /**
1146
+ * The assistant text a codex event carries, or `''`. Two shapes because the CLI changed its
1147
+ * stream between versions and the harness serves both: the flat `agent_message*` events and the
1148
+ * newer `item.completed` envelope around a message item.
1149
+ */
1150
+ function codexAssistantText(event: Record<string, unknown>, type: string): string {
1151
+ const isMessage =
1152
+ type.includes('agent_message') || (type === 'item.completed' && isCodexMessageItem(event))
1153
+ return (isMessage ? extractText(event) : '') ?? ''
1154
+ }
1155
+
1156
+ /**
1157
+ * Whether a codex event reports tool activity — a substring test because the CLI names these
1158
+ * events differently across versions (`exec_command_end`, `item.*` around a command execution,
1159
+ * `tool_*`) and the harness cares only that SOMETHING ran.
1160
+ *
1161
+ * This is also the tool-silence watchdog's only signal on this stream. Codex exposes no
1162
+ * structured tool bodies, so `runCodex` produces no `ToolSpan` at all, and a window keyed on the
1163
+ * trajectory would have force-failed every codex pass that outran it while the run was working.
1164
+ */
1165
+ function isCodexToolActivity(type: string): boolean {
1166
+ return type.includes('tool') || type.includes('command') || type.includes('exec')
1167
+ }
1168
+
1032
1169
  /**
1033
1170
  * Run the Codex CLI headlessly against `opts.cwd`, authenticated with the leased
1034
1171
  * ChatGPT `auth.json` bundle written to an isolated CODEX_HOME, talking direct to
@@ -1090,6 +1227,9 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
1090
1227
  // context into the prompt itself (Claude Code instead rides --append-system-prompt,
1091
1228
  // falling back to this same fold when the prompt overflows argv).
1092
1229
  const prompt = foldSystemPrompt(opts.systemPrompt, opts.userPrompt)
1230
+ // This stream's tool-silence window (see the claude runner for the shape); opened just before
1231
+ // the CLI starts and closed in the `finally` below.
1232
+ let toolWindow: ToolProgressWindow = NO_TOOL_WINDOW
1093
1233
 
1094
1234
  // Codex's `exec --json` is far thinner than Claude Code's stream: it surfaces only
1095
1235
  // flat assistant text and (on `token_count` events) the per-turn `last_token_usage`
@@ -1103,19 +1243,15 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
1103
1243
 
1104
1244
  const onEvent = (event: Record<string, unknown>): void => {
1105
1245
  const type = typeof event.type === 'string' ? event.type : ''
1106
- if (
1107
- type.includes('agent_message') ||
1108
- (type === 'item.completed' && isCodexMessageItem(event))
1109
- ) {
1110
- const text = extractText(event)
1111
- if (text) {
1112
- stats.assistantChars += text.length
1113
- summary = text
1114
- pendingText = text
1115
- }
1246
+ const text = codexAssistantText(event, type)
1247
+ if (text) {
1248
+ stats.assistantChars += text.length
1249
+ summary = text
1250
+ pendingText = text
1116
1251
  }
1117
- if (type.includes('tool') || type.includes('command') || type.includes('exec')) {
1252
+ if (isCodexToolActivity(type)) {
1118
1253
  stats.toolCalls += 1
1254
+ toolWindow.toolCompleted()
1119
1255
  }
1120
1256
  const progress = codexPlanProgress(event)
1121
1257
  if (progress && opts.onProgress) opts.onProgress(progress)
@@ -1146,6 +1282,7 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
1146
1282
  }
1147
1283
  }
1148
1284
 
1285
+ toolWindow = openToolWindow(opts)
1149
1286
  try {
1150
1287
  const { stderrTail } = await streamCli(
1151
1288
  {
@@ -1213,6 +1350,7 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
1213
1350
  // stream, not on stderr — so a bad exit carries the last thing the agent said.
1214
1351
  throw withAgentReport(err, summary, secrets)
1215
1352
  } finally {
1353
+ toolWindow.close()
1216
1354
  if (codexHome) {
1217
1355
  // Lift the CLI session transcripts (`sessions/`) out for short-lived retention BEFORE the
1218
1356
  // home is deleted — the credential (`auth.json`) lives at the home root, never in
package/src/agent.ts CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  unmergedPaths,
28
28
  } from './git.js'
29
29
  import { inferVcsProvider, openPullRequest } from './vcs-api.js'
30
- import type { PiRunStats, RunDiagnostics } from './pi.js'
30
+ import type { PiRunStats, RunDiagnostics } from './pi-reduction.js'
31
31
  import { applyPrDescription } from './pr-description.js'
32
32
  import {
33
33
  makeDirClaimer,
@@ -1,7 +1,7 @@
1
1
  import { opendir } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
3
  import type { AgentJob, AgentResult } from './job.js'
4
- import type { PiRunStats } from './pi.js'
4
+ import type { PiRunStats } from './pi-reduction.js'
5
5
  import type { RunOptions } from './runner.js'
6
6
  import {
7
7
  NEVER_ACTED_CAUSE,
@@ -101,6 +101,7 @@ export async function runBootstrap(job: AgentJob, opts: RunOptions): Promise<Age
101
101
  dir,
102
102
  target: boot.target,
103
103
  ghToken: job.ghToken,
104
+ signal,
104
105
  message: fromScratch
105
106
  ? 'Bootstrap new repository'
106
107
  : `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
@@ -30,7 +30,8 @@ import {
30
30
  } from './git.js'
31
31
  import { openPullRequest } from './vcs-api.js'
32
32
  import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js'
33
- import type { HarnessCallMetric, PiRunStats } from './pi.js'
33
+ import type { HarnessCallMetric } from './pi.js'
34
+ import type { PiRunStats } from './pi-reduction.js'
34
35
  import { EFFORT_REPORT_FILE, type EffortReport } from './effort.js'
35
36
  import {
36
37
  type AgentPrDescription,
package/src/embed.ts CHANGED
@@ -6,19 +6,22 @@
6
6
  // only the reusable primitives are exposed here.
7
7
 
8
8
  export {
9
- PI_MAX_OUTPUT_TOKENS,
10
9
  writePiModelsConfig,
11
10
  writeAgentsContext,
12
11
  runPi,
13
- summarizePiRun,
14
- parsePiOutput,
15
12
  parseTodoProgress,
16
- terminalRunError,
17
13
  type PiRunOutcome,
18
- type PiRunStats,
19
14
  type TodoItem,
20
15
  type TodoProgress,
21
16
  } from './pi.js'
17
+ export {
18
+ PI_MAX_OUTPUT_TOKENS,
19
+ parsePiOutput,
20
+ summarizePiRun,
21
+ terminalRunError,
22
+ type PiRunReduction,
23
+ type PiRunStats,
24
+ } from './pi-reduction.js'
22
25
  export {
23
26
  DEFAULT_PROGRESS_GUARD_LIMITS,
24
27
  progressGuardLimitsFromEnv,
package/src/failure.ts CHANGED
@@ -20,6 +20,10 @@
20
20
  *
21
21
  * - `inactivity-timeout` — the inactivity watchdog fired (no agent output for the window).
22
22
  * - `max-duration` — the overall wall-clock cap fired.
23
+ * - `no-tool-progress` — the tool-silence watchdog fired: the agent kept TALKING but completed
24
+ * no tool call for the window. Distinct from `inactivity-timeout` on
25
+ * purpose, because the two need different fixes: one says the container
26
+ * went quiet, this one says the model rabbit-holed while streaming.
23
27
  * - `agent` — the agent ran but produced an unusable/failed result, or threw.
24
28
  * - `git` — a git operation failed (clone/push/merge/PR).
25
29
  * - `api` — an upstream API call failed (e.g. the GitHub/GitLab PR/MR REST call).
@@ -28,15 +32,25 @@
28
32
  * - `no-usable-output` — the agent finished but returned no usable report / structured output.
29
33
  * - `no-changes` — a coding agent finished without producing any change to push.
30
34
  */
31
- export type FailureCause =
32
- | 'inactivity-timeout'
33
- | 'max-duration'
34
- | 'agent'
35
- | 'git'
36
- | 'api'
37
- | 'llm-upstream'
38
- | 'no-usable-output'
39
- | 'no-changes'
35
+ export const FAILURE_CAUSES = [
36
+ 'inactivity-timeout',
37
+ 'max-duration',
38
+ 'no-tool-progress',
39
+ 'agent',
40
+ 'git',
41
+ 'api',
42
+ 'llm-upstream',
43
+ 'no-usable-output',
44
+ 'no-changes',
45
+ ] as const
46
+
47
+ /**
48
+ * See {@link FAILURE_CAUSES}. Derived from the array rather than declared beside it so the two
49
+ * cannot disagree, and so the list is ENUMERABLE at runtime — which is what lets
50
+ * `failure-cause.conformity.test.ts` check this image's vocabulary against the kernel union that
51
+ * has to classify it (the two are kept in step by hand; the image can carry no workspace dep).
52
+ */
53
+ export type FailureCause = (typeof FAILURE_CAUSES)[number]
40
54
 
41
55
  /**
42
56
  * A thrown failure that carries a structured {@link FailureCause}, so a `git` / `api`
@@ -77,3 +91,16 @@ export function inactivityAbortMessage(inactivityMs: number): string {
77
91
  export function maxDurationAbortMessage(maxDurationMs: number): string {
78
92
  return `Aborted: exceeded max duration of ${Math.round(maxDurationMs / 1000)}s`
79
93
  }
94
+
95
+ /**
96
+ * The tool-silence-watchdog abort message. Human-readable only, like its two siblings — the
97
+ * backend reads the structured `no-tool-progress` {@link FailureCause}. Says what it observed
98
+ * (output, but no completed tool call) rather than "hung": the run was demonstrably alive, which
99
+ * is exactly why the inactivity watchdog never fired.
100
+ */
101
+ export function toolSilenceAbortMessage(toolSilenceMs: number): string {
102
+ return (
103
+ `Aborted: the agent produced output but completed no tool call for ` +
104
+ `${Math.round(toolSilenceMs / 1000)}s`
105
+ )
106
+ }
package/src/git.ts CHANGED
@@ -1081,25 +1081,33 @@ export async function pushBranch(
1081
1081
  * .gitignore and/or license picked on the new-repo page), so a fast-forward is
1082
1082
  * impossible. The Worker pre-flights that the target is empty or holds only that
1083
1083
  * boilerplate, so overwriting it is safe and intended.
1084
+ *
1085
+ * `signal` is the job watchdog's, and threading it is load-bearing rather than tidy: without
1086
+ * it the six commands below are bounded only by their own per-command timeouts, so an abort
1087
+ * raised during the push phase cannot interrupt them and the job keeps working for up to
1088
+ * ~6 × `GIT_TIMEOUT_MS` past its max-duration kill. Every other git helper here threads it.
1084
1089
  */
1085
1090
  export async function reinitAndPush(opts: {
1086
1091
  dir: string
1087
1092
  target: BootstrapTargetSpec
1088
1093
  ghToken: string
1089
1094
  message: string
1095
+ signal?: AbortSignal
1090
1096
  }): Promise<void> {
1091
- await rm(join(opts.dir, '.git'), { recursive: true, force: true })
1092
- await git(['init'], { cwd: opts.dir })
1097
+ const { dir, signal } = opts
1098
+ await rm(join(dir, '.git'), { recursive: true, force: true })
1099
+ await git(['init'], { cwd: dir, signal })
1093
1100
  // Start the history on the target's default branch (init may default to master).
1094
- await git(['checkout', '-b', opts.target.defaultBranch], { cwd: opts.dir })
1095
- await git(['config', 'user.name', GIT_AUTHOR], { cwd: opts.dir })
1096
- await git(['config', 'user.email', GIT_EMAIL], { cwd: opts.dir })
1097
- await git(['add', '-A'], { cwd: opts.dir })
1098
- await git(['commit', '-m', opts.message], { cwd: opts.dir })
1101
+ await git(['checkout', '-b', opts.target.defaultBranch], { cwd: dir, signal })
1102
+ await git(['config', 'user.name', GIT_AUTHOR], { cwd: dir, signal })
1103
+ await git(['config', 'user.email', GIT_EMAIL], { cwd: dir, signal })
1104
+ await git(['add', '-A'], { cwd: dir, signal })
1105
+ await git(['commit', '-m', opts.message], { cwd: dir, signal })
1099
1106
  const url = authenticatedCloneUrl(opts.target.cloneUrl)
1100
- await git(['remote', 'add', 'origin', url], { cwd: opts.dir })
1107
+ await git(['remote', 'add', 'origin', url], { cwd: dir, signal })
1101
1108
  await git(['push', '--force', '-u', 'origin', opts.target.defaultBranch], {
1102
- cwd: opts.dir,
1109
+ cwd: dir,
1110
+ signal,
1103
1111
  env: await authEnv(opts.ghToken),
1104
1112
  })
1105
1113
  }
package/src/inline.ts CHANGED
@@ -35,6 +35,12 @@ function deriveFinishReason(calls: HarnessCallMetric[] | undefined): 'stop' | 'l
35
35
  * directory. The job's watchdog (inactivity + max-duration, see {@link JobRegistry}) bounds
36
36
  * it through `opts.signal`; `opts.onActivity` keeps the inactivity timer alive while the CLI
37
37
  * streams. The temp cwd is always removed.
38
+ *
39
+ * `opts.beginToolWindow` is deliberately NOT forwarded. An inline completion is a one-shot
40
+ * answer with no checkout and no tool loop, so the tool-silence watchdog would arm a window this
41
+ * run could never beat and could only ever expire — force-failing a healthy completion under a
42
+ * cause ("kept talking, completed no tool call") that misdescribes what it is. The inactivity and
43
+ * max-duration watchdogs still bound it, which is the whole bound this work ever had.
38
44
  */
39
45
  export async function handleInline(job: InlineJob, opts: RunOptions): Promise<InlineResult> {
40
46
  opts.onPhase?.('agent')
package/src/job.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'