@cat-factory/executor-harness 1.60.0 → 1.64.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
@@ -73,7 +73,16 @@ The implementation job (`POST /run`) is the canonical sequence:
73
73
  6. **commit, push** a branch and **open a PR**, returning `{ prUrl, branch, summary }` — but
74
74
  ONLY if step 4 ended green. A spent budget returns an error result with the validation report
75
75
  and opens no PR. Absent `validationChecks` / `reproduction`, steps 4 and 5 do not happen at
76
- all.
76
+ all. The PR's description prefers the agent-authored reviewer briefing over the generic
77
+ dispatch-time text the job body carries: a PR-opening agent is prompted to write one to the
78
+ `.cat-pr-description.md` sentinel at the checkout root (one per sibling repo in a multi-repo
79
+ run; an optional leading `# <title>` line, when it is the file's only `#` heading, sets the PR
80
+ title), and `src/pr-description.ts` lifts it — secret-scrubbed, size-capped with a visible
81
+ note, made inert for the host by `src/host-markdown.ts`, kept out of the commit like the
82
+ effort/follow-ups sentinels — onto `openPullRequest`. Absent or unusable ⇒ the fallback text,
83
+ unchanged. On a RESUMED run the PR already exists, so an agent briefing additionally refreshes
84
+ its title/description in place (carrying the engine's managed report region across); the
85
+ generic fallback never does, so a human's edit is safe.
77
86
 
78
87
  Bootstrap differs at the ends — it may start from an empty dir, and **resets
79
88
  history to one commit and force-pushes** the default branch instead of opening a
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { claudeAssistantContent, claudeCallUsage, isObject, numberOf, redactBody, } from './claude-stream.js';
6
6
  import { createCallMetricPublisher, publishCallMetric, } from './pi.js';
7
+ import { ProgressGuard } from './progress-guard.js';
7
8
  import { killChildProcess, spawnDetached } from './process.js';
8
9
  import { redact, secretsToRedact } from './redact.js';
9
10
  import { createSliceTracker, startSubagentWatcher } from './subagents.js';
@@ -58,7 +59,12 @@ function streamCli(cli, prompt, opts, env, secrets, onEvent) {
58
59
  let aborted = false;
59
60
  let lineBuffer = '';
60
61
  const killChild = () => killChildProcess(child);
61
- const processLine = (line) => {
62
+ // `final` marks the at-close flush of a trailing unterminated line: the CLI has already
63
+ // exited, so an observer must not act on that record in a way that KILLS the run (mirrors
64
+ // `runPi`'s `runGuard = false` flush — without it, a guard tripping on the last buffered
65
+ // record could turn a clean exit into a spurious "no progress" failure). The record's
66
+ // progress/telemetry signal is still delivered; only kill decisions are suppressed.
67
+ const processLine = (line, final = false) => {
62
68
  if (!line.startsWith('{'))
63
69
  return;
64
70
  let event;
@@ -69,7 +75,7 @@ function streamCli(cli, prompt, opts, env, secrets, onEvent) {
69
75
  return;
70
76
  }
71
77
  try {
72
- onEvent(event);
78
+ onEvent(event, { final });
73
79
  }
74
80
  catch {
75
81
  // A faulty observer must never break the run.
@@ -106,11 +112,14 @@ function streamCli(cli, prompt, opts, env, secrets, onEvent) {
106
112
  });
107
113
  child.on('close', (code) => {
108
114
  opts.signal?.removeEventListener('abort', onAbort);
109
- if (lineBuffer.trim())
110
- processLine(lineBuffer.trim());
111
115
  const stderrTail = redact(stderr, secrets).slice(-700);
116
+ if (lineBuffer.trim())
117
+ processLine(lineBuffer.trim(), true);
112
118
  if (aborted) {
113
- reject(new Error('agent run aborted by watchdog'));
119
+ // Carry the tail on the rejection so a caller that REPLACES this generic message with a
120
+ // more specific cause (the no-progress guard's diagnostic) can still append it — the
121
+ // stderr is often the only evidence of what the CLI was doing when it was killed.
122
+ reject(Object.assign(new Error('agent run aborted by watchdog'), { stderrTail }));
114
123
  return;
115
124
  }
116
125
  if (code !== 0) {
@@ -246,7 +255,44 @@ export async function runClaudeCode(opts) {
246
255
  if (progress)
247
256
  opts.onProgress(progress);
248
257
  };
249
- const onEvent = (event) => {
258
+ // No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
259
+ // absent on this path until now. Claude Code reports a tool CALL (its name) on the `assistant`
260
+ // turn and that call's RESULT (`is_error`) on the following `user` turn, so correlate them by
261
+ // `tool_use` id to feed the guard a {name,isError} signal. A tripped guard aborts the CLI via
262
+ // `guardAbort` (folded into streamCli's signal below) and the run then fails with its
263
+ // diagnostic. Disabled when the caller supplies no limits (only the external watchdog bounds it).
264
+ const guard = opts.guardLimits
265
+ ? new ProgressGuard(opts.guardLimits, opts.expectsEdits ?? true)
266
+ : undefined;
267
+ const toolNames = new Map();
268
+ const guardAbort = new AbortController();
269
+ let guardReason;
270
+ // Feed a user turn's settled tool calls to the guard, pairing each `tool_result`'s `is_error`
271
+ // with the name captured for its `tool_use` id on the assistant turn. The FIRST reason trips
272
+ // it: record the diagnostic and abort the CLI (streamCli's close handler rejects; the catch
273
+ // below surfaces `guardReason` over the generic abort message). A standalone closure so the
274
+ // per-block loop doesn't nest onEvent past the readable-depth limit.
275
+ const feedGuard = (content) => {
276
+ if (!guard || guardReason)
277
+ return;
278
+ for (const block of content) {
279
+ if (!isObject(block) || block.type !== 'tool_result')
280
+ continue;
281
+ const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined;
282
+ const name = id ? toolNames.get(id) : undefined;
283
+ if (id)
284
+ toolNames.delete(id);
285
+ if (!name)
286
+ continue;
287
+ const reason = guard.observeSignal({ name, isError: block.is_error === true });
288
+ if (reason) {
289
+ guardReason = reason;
290
+ guardAbort.abort();
291
+ return;
292
+ }
293
+ }
294
+ };
295
+ const onEvent = (event, meta) => {
250
296
  const type = event.type;
251
297
  if (type === 'assistant' && isObject(event.message)) {
252
298
  const message = event.message;
@@ -255,7 +301,14 @@ export async function runClaudeCode(opts) {
255
301
  stats.assistantChars += text.length;
256
302
  stats.toolCalls += toolUses;
257
303
  for (const block of content) {
258
- if (isObject(block) && block.type === 'tool_use' && block.name === 'TodoWrite') {
304
+ if (!isObject(block) || block.type !== 'tool_use')
305
+ continue;
306
+ // Remember each call's name against its id so the guard can pair it with the
307
+ // `is_error` its `tool_result` carries on the next `user` turn.
308
+ if (typeof block.id === 'string' && typeof block.name === 'string') {
309
+ toolNames.set(block.id, block.name);
310
+ }
311
+ if (block.name === 'TodoWrite') {
259
312
  const progress = todosToProgress(block.input?.todos);
260
313
  if (progress)
261
314
  lastTodo = progress;
@@ -288,6 +341,10 @@ export async function runClaudeCode(opts) {
288
341
  sliceTracker.onUser(content);
289
342
  planTracker.onUser(content);
290
343
  emitProgress();
344
+ // Not on the at-close flush: the CLI has already exited, so tripping the guard there
345
+ // would kill nothing and only convert a clean exit into a spurious failure.
346
+ if (!meta?.final)
347
+ feedGuard(content);
291
348
  messages.push({ role: 'tool', content });
292
349
  }
293
350
  }
@@ -350,6 +407,11 @@ export async function runClaudeCode(opts) {
350
407
  ...(opts.log ? { log: opts.log } : {}),
351
408
  })
352
409
  : undefined;
410
+ // Fold the guard's abort into the run signal so a tripped guard kills the CLI the same way the
411
+ // external watchdog does; `guardReason` (set above) distinguishes the two at the catch below.
412
+ const runSignal = opts.signal
413
+ ? AbortSignal.any([opts.signal, guardAbort.signal])
414
+ : guardAbort.signal;
353
415
  try {
354
416
  const { stderrTail } = await streamCli({
355
417
  command: 'claude',
@@ -368,7 +430,7 @@ export async function runClaudeCode(opts) {
368
430
  opts.model,
369
431
  ...appendArgs,
370
432
  ],
371
- }, prompt, opts, env, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
433
+ }, prompt, { ...opts, signal: runSignal }, env, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
372
434
  return await assembleClaudeOutcome({
373
435
  summary,
374
436
  stats,
@@ -379,6 +441,17 @@ export async function runClaudeCode(opts) {
379
441
  subagents,
380
442
  });
381
443
  }
444
+ catch (err) {
445
+ // A tripped no-progress guard aborted the CLI; streamCli rejects with its generic abort
446
+ // message, so replace it with the guard's actionable diagnostic — carrying the stderr tail
447
+ // it attached, since that is usually the only evidence of what the CLI was doing when it was
448
+ // killed. Byte-for-byte the shape `runPi` fails with.
449
+ if (guardReason) {
450
+ const tail = err?.stderrTail;
451
+ throw new Error(tail ? `${guardReason} Agent stderr: ${tail}` : guardReason);
452
+ }
453
+ throw err;
454
+ }
382
455
  finally {
383
456
  await subagents?.stop();
384
457
  if (configHome) {
package/dist/agent.js CHANGED
@@ -6,7 +6,9 @@ import { promisify } from 'node:util';
6
6
  import { standUpFrontend, tearDownFrontend } from './frontend-infra.js';
7
7
  import { configurePackageRegistries } from './package-registries.js';
8
8
  import { captureRedactedOutput, redactSecrets, registerKnownSecrets } from './redact.js';
9
- import { cloneRepo, commitAll, conflictDiff, fetchPullRequestHead, fetchReferenceBranches, hasAgentChanges, headCommit, inferVcsProvider, mergeBranch, openPullRequest, prepareExistingCheckout, pushBranch, reinitAndPush, unmergedPaths, } from './git.js';
9
+ import { cloneRepo, commitAll, conflictDiff, fetchPullRequestHead, fetchReferenceBranches, hasAgentChanges, headCommit, mergeBranch, prepareExistingCheckout, pushBranch, reinitAndPush, unmergedPaths, } from './git.js';
10
+ import { inferVcsProvider, openPullRequest } from './vcs-api.js';
11
+ import { applyPrDescription } from './pr-description.js';
10
12
  import { makeDirClaimer, noChangesReason, runCodingAgent, runMultiRepoCoding, } from './coding-agent.js';
11
13
  import { validationFailureMessage } from './validation-checks.js';
12
14
  import { acquireRepoCheckout, agentNeverActed, agentOutputTail, NEVER_ACTED_CAUSE, runAgentInWorkspace, unusableFinalAnswerCause, withWorkspace, } from './pi-workspace.js';
@@ -822,7 +824,7 @@ function buildSingleRepoCodingSpec(job, pushBranch) {
822
824
  */
823
825
  async function runSingleRepoCoding(job, opts) {
824
826
  const pushBranch = job.pushBranch ?? job.newBranch ?? job.branch;
825
- const { summary, stats, stderrTail, pushed, usage, callMetrics, validation, validationReport, reproductionReport, effortReport, } = await runCodingAgent(buildSingleRepoCodingSpec(job, pushBranch), opts);
827
+ const { summary, stats, stderrTail, pushed, usage, callMetrics, validation, validationReport, reproductionReport, effortReport, prDescription, } = await runCodingAgent(buildSingleRepoCodingSpec(job, pushBranch), opts);
826
828
  // Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
827
829
  // `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
828
830
  const ralphVerdict = validation ? { ralphVerdict: validation } : {};
@@ -894,7 +896,11 @@ async function runSingleRepoCoding(job, opts) {
894
896
  ghToken: job.ghToken,
895
897
  head: pushBranch,
896
898
  base: job.repo.baseBranch,
897
- pr: job.pr,
899
+ // The agent-authored briefing (title/body) wins field-wise over the dispatch-time text.
900
+ pr: applyPrDescription(job.pr, prDescription),
901
+ // A resumed run's PR is already open, so refresh it rather than lose the briefing to the
902
+ // duplicate-PR 422 — only from a REAL briefing (see `refreshExisting` for why).
903
+ ...(prDescription ? { refreshExisting: true } : {}),
898
904
  apiBase: job.githubApiBase,
899
905
  // The provider (set by the server from the configured backend) selects GitHub-PR vs
900
906
  // GitLab-MR authoritatively; the clone URL supplies the GitLab REST base + project path.
@@ -7,6 +7,24 @@ import { redact } from './redact.js';
7
7
  export function isObject(value) {
8
8
  return typeof value === 'object' && value !== null;
9
9
  }
10
+ /**
11
+ * The tool names the Claude Code CLI dispatches a parallel subagent under. `Agent` is what the
12
+ * shipped schema declares (`AgentInput` in `sdk-tools.d.ts`, carrying `description` / `prompt` /
13
+ * `subagent_type`); `Task` is the older name for the same dispatch. Both are matched because the
14
+ * harness runs against whatever CLI the image happens to bundle, and matching only the old name
15
+ * is what left a CLI 2.1.x pr-review reporting no slices at all.
16
+ *
17
+ * Note the asymmetry: keeping the legacy `Task` here is the one place a CLI rename could produce a
18
+ * FALSE signal rather than merely no signal — if a future build were to name a plain task-list
19
+ * tool `Task`, its writes would be counted as in-flight slices. We accept that because no shipped
20
+ * build does (the incremental plan tool is `TaskCreate`/`TaskUpdate`, tracked separately in
21
+ * `progress.ts`), and dropping legacy coverage is the more likely regression.
22
+ *
23
+ * Lives here rather than in `subagents.ts` because BOTH the slice tracker and the no-progress
24
+ * guard (`pi.ts`, which `subagents.ts` imports — so it cannot import back) must agree on what a
25
+ * subagent dispatch looks like.
26
+ */
27
+ export const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task']);
10
28
  export function numberOf(value) {
11
29
  return typeof value === 'number' && Number.isFinite(value) ? value : 0;
12
30
  }
@@ -3,9 +3,11 @@ import { join } from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { killChildProcess, spawnDetached } from './process.js';
5
5
  import { MAX_CAPTURED_OUTPUT_CHARS, redactSecrets } from './redact.js';
6
- import { branchAheadOfBase, changedFilesSinceBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, fetchReferenceBranches, headCommit, listUntrackedFiles, openPullRequest, prepareExistingCheckout, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
6
+ import { branchAheadOfBase, changedFilesSinceBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, fetchReferenceBranches, headCommit, listUntrackedFiles, prepareExistingCheckout, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
7
+ import { openPullRequest } from './vcs-api.js';
7
8
  import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js';
8
9
  import { EFFORT_REPORT_FILE } from './effort.js';
10
+ import { applyPrDescription, PR_DESCRIPTION_FILE, readPrDescription, } from './pr-description.js';
9
11
  import { acquireRepoCheckout, agentNeverActed, agentOutputTail, runAgentInWorkspace, withWorkspace, } from './pi-workspace.js';
10
12
  import { log } from './logger.js';
11
13
  import { runValidationLoop, } from './validation-checks.js';
@@ -115,6 +117,9 @@ export async function runCodingAgent(spec, opts = {}) {
115
117
  // but that cannot un-stage a mid-run commit; the per-clone exclude is what prevents it. A bare
116
118
  // filename pattern matches the file in any subdirectory, so it covers a monorepo `workDir` too.
117
119
  await excludeFromGit(dir, EFFORT_REPORT_FILE, signal);
120
+ // Same treatment for the agent-authored PR-description sentinel: excluded locally so the
121
+ // agent's own `git add` can never stage the briefing into the PR it describes.
122
+ await excludeFromGit(dir, PR_DESCRIPTION_FILE, signal);
118
123
  // Follow-up companion: tail the Coder's sentinel file and stream new items out on the
119
124
  // job view. Locally exclude it from git first so the agent's own `git add` can never
120
125
  // stage it and it never surfaces as an untracked leftover or in the PR. The sentinel
@@ -376,6 +381,12 @@ async function finalizeCodingRun(args) {
376
381
  // Safety net for forgotten edits: commit changes to TRACKED files only (never
377
382
  // untracked scratch files/artifacts — the agent owns committing new files).
378
383
  await commitTrackedEdits(dir, spec.commitMessage, signal);
384
+ // The agent-authored PR description, read AFTER the validation loop (a repair round may have
385
+ // changed what the briefing should say) and removed so it never lingers in the checkout. The
386
+ // prompt asks for it at the top level of the checkout; a monorepo agent working in a service
387
+ // subdirectory may drop it in its cwd instead, so probe the checkout root first, then the cwd.
388
+ const prDescription = (await readPrDescription(dir)) ??
389
+ (workDir !== dir ? await readPrDescription(workDir) : undefined);
379
390
  // Stop periodic checkpoints and let any in-flight one settle BEFORE the final
380
391
  // push, so the two never run a concurrent `git push` to the same branch (the
381
392
  // final push below is then a fresh attempt whose failure is the real signal).
@@ -438,6 +449,7 @@ async function finalizeCodingRun(args) {
438
449
  ...(usage ? { usage } : {}),
439
450
  ...(callMetrics ? { callMetrics } : {}),
440
451
  ...(effortReport ? { effortReport } : {}),
452
+ ...(prDescription ? { prDescription } : {}),
441
453
  };
442
454
  }
443
455
  // Ralph loop: run the programmatic completion command against the pushed/committed
@@ -698,7 +710,7 @@ export async function runMultiRepoCoding(job, opts = {}) {
698
710
  multiRepo: true,
699
711
  }, opts);
700
712
  // Commit forgotten tracked edits, then push + open a PR for each repo the run actually changed.
701
- const { primaryPushed, primaryPrUrl, peerPullRequests } = await pushMultiRepoLegs(legs, job, logger, opts);
713
+ const { primaryPushed, primaryPrUrl, peerPullRequests } = await pushMultiRepoLegs(legs, job, logger, opts, root);
702
714
  const anyWork = primaryPushed || peerPullRequests.length > 0;
703
715
  if (!anyWork) {
704
716
  // Nothing changed in ANY repo. For the implementer this is a failure (as in the
@@ -799,6 +811,9 @@ async function prepareMultiRepoCheckouts(root, legs, job, logger, opts) {
799
811
  await createBranch(dir, leg.workBranch, signal);
800
812
  }
801
813
  leg.dir = dir;
814
+ // Exclude the agent-authored PR-description sentinel locally (as the single-repo path does)
815
+ // so the agent's own `git add` can never stage the briefing into the PR it describes.
816
+ await excludeFromGit(dir, PR_DESCRIPTION_FILE, signal);
802
817
  // The branch tip before the agent runs. Captured BEFORE the resume base refresh below so
803
818
  // that refresh's merge commit counts as advancement and is pushed (as in the single-repo
804
819
  // path). A fresh leg produced work iff its branch advances past this; a resumed leg already
@@ -844,7 +859,9 @@ async function prepareMultiRepoCheckouts(root, legs, job, logger, opts) {
844
859
  * no PR; a read-only reference leg is never committed or pushed). Extracted so the multi-repo body
845
860
  * stays small; returns the primary's push/PR state plus the peer PRs.
846
861
  */
847
- async function pushMultiRepoLegs(legs, job, logger, opts) {
862
+ async function pushMultiRepoLegs(legs, job, logger, opts,
863
+ /** The workspace root the agent ran in — the fallback probe for the primary's briefing. */
864
+ root) {
848
865
  const { signal } = opts;
849
866
  opts.onPhase?.('push');
850
867
  let primaryPushed = false;
@@ -855,6 +872,14 @@ async function pushMultiRepoLegs(legs, job, logger, opts) {
855
872
  // guarantee (the spec carries no branch/PR, and the clone phase gave it no work branch).
856
873
  if (leg.readOnly)
857
874
  continue;
875
+ // Lift (and remove) the agent-authored PR description for THIS repo's PR before anything
876
+ // else touches the checkout — each sibling checkout carries its own briefing for its own PR.
877
+ // The agent's cwd here is the WORKSPACE ROOT rather than any one checkout, so an agent that
878
+ // read the prompt loosely may well have written a single briefing there instead. Fall back
879
+ // to it for the PRIMARY leg only: at the root there is nothing to say which repo it
880
+ // describes, and the primary is the one the run is actually about.
881
+ const agentPrDescription = (await readPrDescription(leg.dir)) ??
882
+ (leg.primary ? await readPrDescription(root) : undefined);
858
883
  await commitTrackedEdits(leg.dir, job.commitMessage ?? leg.pr?.title ?? 'Agent changes', signal);
859
884
  const advanced = await branchHasCommitsSince(leg.dir, leg.baseSha, signal);
860
885
  let hasWork = advanced || leg.resumed;
@@ -884,7 +909,10 @@ async function pushMultiRepoLegs(legs, job, logger, opts) {
884
909
  ghToken: leg.ghToken,
885
910
  head: leg.workBranch,
886
911
  base: leg.repo.baseBranch,
887
- pr: leg.pr,
912
+ pr: applyPrDescription(leg.pr, agentPrDescription),
913
+ // See the single-repo call site: refresh a resumed leg's already-open PR, but only
914
+ // when the text is the agent's own briefing rather than the dispatch-time fallback.
915
+ ...(agentPrDescription ? { refreshExisting: true } : {}),
888
916
  apiBase: job.githubApiBase,
889
917
  cloneUrl: leg.repo.cloneUrl,
890
918
  ...(leg.repo.provider ? { provider: leg.repo.provider } : {}),
package/dist/embed.js CHANGED
@@ -4,5 +4,6 @@
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, DEFAULT_PROGRESS_GUARD_LIMITS, writePiModelsConfig, writeAgentsContext, runPi, summarizePiRun, parsePiOutput, parseTodoProgress, progressGuardLimitsFromEnv, terminalRunError, } from './pi.js';
7
+ export { PI_MAX_OUTPUT_TOKENS, writePiModelsConfig, writeAgentsContext, runPi, summarizePiRun, parsePiOutput, parseTodoProgress, terminalRunError, } from './pi.js';
8
+ export { DEFAULT_PROGRESS_GUARD_LIMITS, progressGuardLimitsFromEnv, } from './progress-guard.js';
8
9
  export { cloneRepo, createBranch, changedPathsFromPorcelain, hasAgentChanges, redactSecrets, } from './git.js';