@cat-factory/executor-harness 1.62.0 → 1.64.2

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.
@@ -19,11 +19,13 @@ import {
19
19
  type PiRunStats,
20
20
  type TodoProgress,
21
21
  } from './pi.js'
22
+ import { ProgressGuard, type ProgressGuardLimits } from './progress-guard.js'
22
23
  import { killChildProcess, spawnDetached } from './process.js'
23
24
  import { redact, secretsToRedact } from './redact.js'
24
25
  import { createSliceTracker, startSubagentWatcher } from './subagents.js'
25
26
  import {
26
27
  createTaskPlanTracker,
28
+ mergeProgress,
27
29
  normalizeStatus,
28
30
  pickProgress,
29
31
  toProgress,
@@ -101,6 +103,17 @@ export interface SubscriptionRunOptions {
101
103
  extraEnv?: Record<string, string>
102
104
  /** Aborting this kills the CLI (the job's inactivity/max-duration watchdog). */
103
105
  signal?: AbortSignal
106
+ /**
107
+ * Fully-resolved no-progress guard limits (env defaults merged loosen-only with the kind's
108
+ * tuning + any complexity-scaled allowance). When set, the claude-code runner runs the SAME
109
+ * {@link ProgressGuard} as Pi over the CLI's tool stream and kills a run that has plainly
110
+ * stopped making progress (no-edit probing, error-retry loop, web rabbit-hole) rather than
111
+ * letting it burn the whole wall-clock budget. Omitted ⇒ the guard is disabled for this run
112
+ * (only the external watchdog bounds it), preserving the pre-guard behaviour.
113
+ */
114
+ guardLimits?: ProgressGuardLimits
115
+ /** Whether this run is expected to edit files (false for assess-only runs); gates the no-edit bound. */
116
+ expectsEdits?: boolean
104
117
  /** Called on every chunk of CLI output, so the watchdog sees the agent is alive. */
105
118
  onActivity?: () => void
106
119
  /** Called with the latest subtask counts each time the CLI updates its todo/plan list. */
@@ -154,7 +167,7 @@ function streamCli(
154
167
  opts: SubscriptionRunOptions,
155
168
  env: Record<string, string>,
156
169
  secrets: string[],
157
- onEvent: (event: Record<string, unknown>) => void,
170
+ onEvent: (event: Record<string, unknown>, meta?: { final?: boolean }) => void,
158
171
  ): Promise<{ stderrTail: string }> {
159
172
  const { command, args } = cli
160
173
  return new Promise((resolve, reject) => {
@@ -178,7 +191,12 @@ function streamCli(
178
191
 
179
192
  const killChild = (): void => killChildProcess(child)
180
193
 
181
- const processLine = (line: string): void => {
194
+ // `final` marks the at-close flush of a trailing unterminated line: the CLI has already
195
+ // exited, so an observer must not act on that record in a way that KILLS the run (mirrors
196
+ // `runPi`'s `runGuard = false` flush — without it, a guard tripping on the last buffered
197
+ // record could turn a clean exit into a spurious "no progress" failure). The record's
198
+ // progress/telemetry signal is still delivered; only kill decisions are suppressed.
199
+ const processLine = (line: string, final = false): void => {
182
200
  if (!line.startsWith('{')) return
183
201
  let event: Record<string, unknown>
184
202
  try {
@@ -187,7 +205,7 @@ function streamCli(
187
205
  return
188
206
  }
189
207
  try {
190
- onEvent(event)
208
+ onEvent(event, { final })
191
209
  } catch {
192
210
  // A faulty observer must never break the run.
193
211
  }
@@ -226,10 +244,13 @@ function streamCli(
226
244
  })
227
245
  child.on('close', (code) => {
228
246
  opts.signal?.removeEventListener('abort', onAbort)
229
- if (lineBuffer.trim()) processLine(lineBuffer.trim())
230
247
  const stderrTail = redact(stderr, secrets).slice(-700)
248
+ if (lineBuffer.trim()) processLine(lineBuffer.trim(), true)
231
249
  if (aborted) {
232
- reject(new Error('agent run aborted by watchdog'))
250
+ // Carry the tail on the rejection so a caller that REPLACES this generic message with a
251
+ // more specific cause (the no-progress guard's diagnostic) can still append it — the
252
+ // stderr is often the only evidence of what the CLI was doing when it was killed.
253
+ reject(Object.assign(new Error('agent run aborted by watchdog'), { stderrTail }))
233
254
  return
234
255
  }
235
256
  if (code !== 0) {
@@ -358,31 +379,67 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
358
379
  // may still rewrite below (a published call must be final — see the publisher).
359
380
  const publisher = createCallMetricPublisher(calls, opts.onCallMetric)
360
381
 
361
- // ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from TWO reconciled
362
- // sources. The parent's subagent dispatches + their terminal tool_results DO appear on this
363
- // stream (only a subagent's intermediate turns don't), so `sliceTracker` derives per-slice
364
- // progress for the parallel shape; the parent's own plan (the sequential shape) is tracked
365
- // by `planTracker` + `lastTodo`. `pickProgress` picks whichever is further along on each
366
- // update, so neither masks the other — the pr-reviewer prompt writes its plan ONCE and never
367
- // marks it done, which used to gate the slice signal off and pin progress at 0%.
382
+ // ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from the two views the run
383
+ // produces of the SAME slicing. The parent's subagent dispatches + their terminal tool_results
384
+ // DO appear on this stream (only a subagent's intermediate turns don't), so `sliceTracker`
385
+ // knows which slices are in flight and which have returned; the parent's own plan (tracked by
386
+ // `planTracker` + `lastTodo`) is the only place a not-yet-dispatched slice is named at all.
368
387
  //
369
388
  // The plan arrives in one of two tool vocabularies depending on the bundled CLI build:
370
389
  // `TodoWrite` (whole-list snapshots, tracked in `lastTodo`) or the incremental
371
390
  // `TaskCreate`/`TaskUpdate` pair (tracked by `planTracker`, which needs the tool RESULTS too
372
- // because the task id is minted there). Both are read see ./progress.ts.
391
+ // because the task id is minted there). Both are read, and `pickProgress` resolves that
392
+ // either/or; the plan then MERGES with the dispatch view (`mergeProgress`) rather than
393
+ // competing with it — picking the further-along view collapsed the list to the dispatched
394
+ // slices alone the moment the first subagent returned. See ./progress.ts.
373
395
  const sliceTracker = createSliceTracker()
374
396
  const planTracker = createTaskPlanTracker()
375
397
  let lastTodo: TodoProgress | undefined
376
398
  const emitProgress = (): void => {
377
399
  if (!opts.onProgress) return
378
- const progress = pickProgress(
400
+ const progress = mergeProgress(
379
401
  pickProgress(lastTodo, planTracker.progress()),
380
402
  sliceTracker.progress(),
381
403
  )
382
404
  if (progress) opts.onProgress(progress)
383
405
  }
384
406
 
385
- const onEvent = (event: Record<string, unknown>): void => {
407
+ // No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
408
+ // absent on this path until now. Claude Code reports a tool CALL (its name) on the `assistant`
409
+ // turn and that call's RESULT (`is_error`) on the following `user` turn, so correlate them by
410
+ // `tool_use` id to feed the guard a {name,isError} signal. A tripped guard aborts the CLI via
411
+ // `guardAbort` (folded into streamCli's signal below) and the run then fails with its
412
+ // diagnostic. Disabled when the caller supplies no limits (only the external watchdog bounds it).
413
+ const guard = opts.guardLimits
414
+ ? new ProgressGuard(opts.guardLimits, opts.expectsEdits ?? true)
415
+ : undefined
416
+ const toolNames = new Map<string, string>()
417
+ const guardAbort = new AbortController()
418
+ let guardReason: string | undefined
419
+
420
+ // Feed a user turn's settled tool calls to the guard, pairing each `tool_result`'s `is_error`
421
+ // with the name captured for its `tool_use` id on the assistant turn. The FIRST reason trips
422
+ // it: record the diagnostic and abort the CLI (streamCli's close handler rejects; the catch
423
+ // below surfaces `guardReason` over the generic abort message). A standalone closure so the
424
+ // per-block loop doesn't nest onEvent past the readable-depth limit.
425
+ const feedGuard = (content: unknown[]): void => {
426
+ if (!guard || guardReason) return
427
+ for (const block of content) {
428
+ if (!isObject(block) || block.type !== 'tool_result') continue
429
+ const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined
430
+ const name = id ? toolNames.get(id) : undefined
431
+ if (id) toolNames.delete(id)
432
+ if (!name) continue
433
+ const reason = guard.observeSignal({ name, isError: block.is_error === true })
434
+ if (reason) {
435
+ guardReason = reason
436
+ guardAbort.abort()
437
+ return
438
+ }
439
+ }
440
+ }
441
+
442
+ const onEvent = (event: Record<string, unknown>, meta?: { final?: boolean }): void => {
386
443
  const type = event.type
387
444
  if (type === 'assistant' && isObject(event.message)) {
388
445
  const message = event.message as Record<string, unknown>
@@ -391,7 +448,13 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
391
448
  stats.assistantChars += text.length
392
449
  stats.toolCalls += toolUses
393
450
  for (const block of content) {
394
- if (isObject(block) && block.type === 'tool_use' && block.name === 'TodoWrite') {
451
+ if (!isObject(block) || block.type !== 'tool_use') continue
452
+ // Remember each call's name against its id so the guard can pair it with the
453
+ // `is_error` its `tool_result` carries on the next `user` turn.
454
+ if (typeof block.id === 'string' && typeof block.name === 'string') {
455
+ toolNames.set(block.id, block.name)
456
+ }
457
+ if (block.name === 'TodoWrite') {
395
458
  const progress = todosToProgress((block.input as Record<string, unknown>)?.todos)
396
459
  if (progress) lastTodo = progress
397
460
  }
@@ -422,6 +485,9 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
422
485
  sliceTracker.onUser(content)
423
486
  planTracker.onUser(content)
424
487
  emitProgress()
488
+ // Not on the at-close flush: the CLI has already exited, so tripping the guard there
489
+ // would kill nothing and only convert a clean exit into a spurious failure.
490
+ if (!meta?.final) feedGuard(content)
425
491
  messages.push({ role: 'tool', content })
426
492
  }
427
493
  } else if (type === 'result') {
@@ -488,6 +554,12 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
488
554
  })
489
555
  : undefined
490
556
 
557
+ // Fold the guard's abort into the run signal so a tripped guard kills the CLI the same way the
558
+ // external watchdog does; `guardReason` (set above) distinguishes the two at the catch below.
559
+ const runSignal = opts.signal
560
+ ? AbortSignal.any([opts.signal, guardAbort.signal])
561
+ : guardAbort.signal
562
+
491
563
  try {
492
564
  const { stderrTail } = await streamCli(
493
565
  {
@@ -509,7 +581,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
509
581
  ],
510
582
  },
511
583
  prompt,
512
- opts,
584
+ { ...opts, signal: runSignal },
513
585
  env,
514
586
  opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [],
515
587
  onEvent,
@@ -524,6 +596,16 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
524
596
  usage,
525
597
  subagents,
526
598
  })
599
+ } catch (err) {
600
+ // A tripped no-progress guard aborted the CLI; streamCli rejects with its generic abort
601
+ // message, so replace it with the guard's actionable diagnostic — carrying the stderr tail
602
+ // it attached, since that is usually the only evidence of what the CLI was doing when it was
603
+ // killed. Byte-for-byte the shape `runPi` fails with.
604
+ if (guardReason) {
605
+ const tail = (err as { stderrTail?: string } | undefined)?.stderrTail
606
+ throw new Error(tail ? `${guardReason} Agent stderr: ${tail}` : guardReason)
607
+ }
608
+ throw err
527
609
  } finally {
528
610
  await subagents?.stop()
529
611
  if (configHome) {
@@ -10,6 +10,25 @@ export function isObject(value: unknown): value is Record<string, unknown> {
10
10
  return typeof value === 'object' && value !== null
11
11
  }
12
12
 
13
+ /**
14
+ * The tool names the Claude Code CLI dispatches a parallel subagent under. `Agent` is what the
15
+ * shipped schema declares (`AgentInput` in `sdk-tools.d.ts`, carrying `description` / `prompt` /
16
+ * `subagent_type`); `Task` is the older name for the same dispatch. Both are matched because the
17
+ * harness runs against whatever CLI the image happens to bundle, and matching only the old name
18
+ * is what left a CLI 2.1.x pr-review reporting no slices at all.
19
+ *
20
+ * Note the asymmetry: keeping the legacy `Task` here is the one place a CLI rename could produce a
21
+ * FALSE signal rather than merely no signal — if a future build were to name a plain task-list
22
+ * tool `Task`, its writes would be counted as in-flight slices. We accept that because no shipped
23
+ * build does (the incremental plan tool is `TaskCreate`/`TaskUpdate`, tracked separately in
24
+ * `progress.ts`), and dropping legacy coverage is the more likely regression.
25
+ *
26
+ * Lives here rather than in `subagents.ts` because BOTH the slice tracker and the no-progress
27
+ * guard (`pi.ts`, which `subagents.ts` imports — so it cannot import back) must agree on what a
28
+ * subagent dispatch looks like.
29
+ */
30
+ export const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task'])
31
+
13
32
  export function numberOf(value: unknown): number {
14
33
  return typeof value === 'number' && Number.isFinite(value) ? value : 0
15
34
  }
@@ -46,7 +46,7 @@ import {
46
46
  runAgentInWorkspace,
47
47
  withWorkspace,
48
48
  } from './pi-workspace.js'
49
- import type { ProgressGuardLimits } from './pi.js'
49
+ import type { ProgressGuardLimits } from './progress-guard.js'
50
50
  import type { RunOptions } from './runner.js'
51
51
  import { log, type Logger } from './logger.js'
52
52
  import {
package/src/embed.ts CHANGED
@@ -7,21 +7,23 @@
7
7
 
8
8
  export {
9
9
  PI_MAX_OUTPUT_TOKENS,
10
- DEFAULT_PROGRESS_GUARD_LIMITS,
11
10
  writePiModelsConfig,
12
11
  writeAgentsContext,
13
12
  runPi,
14
13
  summarizePiRun,
15
14
  parsePiOutput,
16
15
  parseTodoProgress,
17
- progressGuardLimitsFromEnv,
18
16
  terminalRunError,
19
17
  type PiRunOutcome,
20
18
  type PiRunStats,
21
- type ProgressGuardLimits,
22
19
  type TodoItem,
23
20
  type TodoProgress,
24
21
  } from './pi.js'
22
+ export {
23
+ DEFAULT_PROGRESS_GUARD_LIMITS,
24
+ progressGuardLimitsFromEnv,
25
+ type ProgressGuardLimits,
26
+ } from './progress-guard.js'
25
27
  export {
26
28
  cloneRepo,
27
29
  createBranch,
@@ -1,4 +1,4 @@
1
- import { mkdir, mkdtemp, rm } from 'node:fs/promises'
1
+ import { mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import type { RepoSpec, SkillSpec } from './job.js'
@@ -8,13 +8,10 @@ import {
8
8
  type ContextFileInfo,
9
9
  type PiRunOutcome,
10
10
  type PiRunStats,
11
- type ProgressGuardLimits,
12
11
  type RunDiagnostics,
13
12
  CONTEXT_DIR,
14
13
  materializeContextFiles,
15
14
  materializeSkillResources,
16
- mergeGuardLimits,
17
- progressGuardLimitsFromEnv,
18
15
  runPi,
19
16
  webSearchConfigFromEnv,
20
17
  webSearchProxyEnv,
@@ -22,6 +19,11 @@ import {
22
19
  writePiModelsConfig,
23
20
  writeWebToolsConfig,
24
21
  } from './pi.js'
22
+ import {
23
+ type ProgressGuardLimits,
24
+ mergeGuardLimits,
25
+ progressGuardLimitsFromEnv,
26
+ } from './progress-guard.js'
25
27
  import type { RunOptions } from './runner.js'
26
28
  import { type SubscriptionHarness, runSubscriptionHarness } from './agent-runner.js'
27
29
 
@@ -226,6 +228,31 @@ export interface AgentRunSpec {
226
228
  multiRepo?: boolean
227
229
  }
228
230
 
231
+ /**
232
+ * Whether the run's checkout actually ships a `blueprints/` folder — what gates the blueprint
233
+ * orientation note in AGENTS.md (an external repo has none, so the note would be ~10 lines of
234
+ * dead guidance pointing at files that don't exist, re-sent on every turn).
235
+ *
236
+ * A MULTI-REPO run's `dir` is the workspace ROOT with each repo checked out as a sibling under
237
+ * it, so the root itself never holds `blueprints/`: the legs are checked too, and the note is
238
+ * included when ANY leg ships one (it orients the agent to the concept, and the agent finds the
239
+ * per-repo folder from there). Best-effort throughout — any stat/readdir failure simply omits
240
+ * the note rather than failing the dispatch.
241
+ */
242
+ export async function checkoutHasBlueprints(dir: string, multiRepo: boolean): Promise<boolean> {
243
+ const isBlueprintDir = (path: string): Promise<boolean> =>
244
+ stat(join(path, 'blueprints'))
245
+ .then((s) => s.isDirectory())
246
+ .catch(() => false)
247
+ if (await isBlueprintDir(dir)) return true
248
+ if (!multiRepo) return false
249
+ const legs = await readdir(dir, { withFileTypes: true }).catch(() => [])
250
+ const checks = await Promise.all(
251
+ legs.filter((e) => e.isDirectory()).map((e) => isBlueprintDir(join(dir, e.name))),
252
+ )
253
+ return checks.some(Boolean)
254
+ }
255
+
229
256
  /**
230
257
  * Write Pi's global agent context (`~/.pi/agent/AGENTS.md`) + provider config,
231
258
  * then run Pi once in `spec.dir` and return its summary/stats/stderr. The context
@@ -272,6 +299,13 @@ export async function runAgentInWorkspace(
272
299
  ...(spec.skill ? { skill: spec.skill } : {}),
273
300
  ...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
274
301
  signal: opts.signal,
302
+ // Run the SAME no-progress guard Pi gets (previously claude-code/codex had none): env
303
+ // defaults merged loosen-only with the kind's tuning + the backend's complexity-scaled
304
+ // no-edit allowance, so a claude-code run that stops making progress is killed early
305
+ // instead of burning the full wall-clock budget. The claude runner consumes it; codex
306
+ // ignores it for now (its stream isn't wired to the guard).
307
+ guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
308
+ expectsEdits: spec.expectsEdits ?? true,
275
309
  onActivity: opts.onActivity,
276
310
  onProgress: opts.onProgress,
277
311
  // Stream this run's per-call telemetry to the job's live drain. The subscription
@@ -303,11 +337,13 @@ export async function runAgentInWorkspace(
303
337
  }
304
338
  const webSearch = webSearchConfigFromEnv({ ...process.env, ...extraEnv })
305
339
  if (webSearch) await writeWebToolsConfig(webSearch)
340
+ const hasBlueprints = await checkoutHasBlueprints(spec.dir, spec.multiRepo === true)
306
341
  await writeAgentsContext(spec.systemPrompt, {
307
342
  webSearch: Boolean(webSearch),
308
343
  guidance: spec.webToolsGuidance,
309
344
  serviceDirectory: spec.serviceDirectory,
310
345
  contextFiles,
346
+ hasBlueprints,
311
347
  ...(spec.multiRepo ? { multiRepo: true } : {}),
312
348
  })
313
349
  await writePiModelsConfig({ model: spec.model, proxyBaseUrl })