@cat-factory/executor-harness 1.50.12 → 1.50.16

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/src/job.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { HarnessCallMetric, PiRunStats } from './pi.js'
2
2
  import type { HarnessKind } from './pi-workspace.js'
3
3
  import type { FailureCause } from './failure.js'
4
+ import type { EffortReport } from './effort.js'
4
5
 
5
6
  // The job the Worker's ContainerAgentExecutor POSTs to /run. Kept as plain
6
7
  // types with a hand-rolled validator so the image needs no schema dependency.
@@ -921,6 +922,12 @@ export interface AgentResult {
921
922
  * {@link HarnessCallMetric}.
922
923
  */
923
924
  callMetrics?: HarnessCallMetric[]
925
+ /**
926
+ * The agent's effort self-assessment (how hard the work was, what reduced its effectiveness,
927
+ * the key obstacles), lifted from its sentinel file after the run. The backend forwards it onto
928
+ * the job result and records it on the step for run details. Absent when the agent wrote none.
929
+ */
930
+ effortReport?: EffortReport
924
931
  }
925
932
 
926
933
  /** Parse the coding-mode bootstrap spec, or undefined when absent. Validates the target. */
@@ -2,6 +2,7 @@ import { mkdir, mkdtemp, rm } 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'
5
+ import { readEffortReport } from './effort.js'
5
6
  import { log } from './logger.js'
6
7
  import {
7
8
  type ContextFileInfo,
@@ -258,7 +259,7 @@ export async function runAgentInWorkspace(
258
259
  if (!spec.ambientAuth && !spec.subscriptionToken) {
259
260
  throw new Error(`The ${spec.harness} harness requires a subscription token`)
260
261
  }
261
- return runSubscriptionHarness(spec.harness, {
262
+ const subOutcome = await runSubscriptionHarness(spec.harness, {
262
263
  cwd: spec.dir,
263
264
  model: spec.model,
264
265
  systemPrompt: subscriptionSystemPrompt(spec.systemPrompt, contextFiles),
@@ -272,6 +273,7 @@ export async function runAgentInWorkspace(
272
273
  onProgress: opts.onProgress,
273
274
  ...(opts.log ? { log: opts.log } : {}),
274
275
  })
276
+ return withEffortReport(spec.dir, subOutcome)
275
277
  }
276
278
  if (!spec.proxyBaseUrl || !spec.sessionToken) {
277
279
  throw new Error('The Pi harness requires proxyBaseUrl and sessionToken')
@@ -301,7 +303,7 @@ export async function runAgentInWorkspace(
301
303
  })
302
304
  await writePiModelsConfig({ model: spec.model, proxyBaseUrl })
303
305
  const { signal, onActivity, onProgress, onSpan } = opts
304
- return runPi({
306
+ const piOutcome = await runPi({
305
307
  cwd: spec.dir,
306
308
  model: spec.model,
307
309
  userPrompt: spec.userPrompt,
@@ -316,6 +318,17 @@ export async function runAgentInWorkspace(
316
318
  guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
317
319
  extraEnv,
318
320
  })
321
+ return withEffortReport(spec.dir, piOutcome)
322
+ }
323
+
324
+ /**
325
+ * Lift the agent's effort self-assessment off its sentinel file in `dir` and fold it onto the
326
+ * run outcome. Shared by both harness paths so EVERY container agent's effort report is captured
327
+ * in one place. Never throws (a bad/absent report just yields no `effortReport`).
328
+ */
329
+ async function withEffortReport(dir: string, outcome: PiRunOutcome): Promise<PiRunOutcome> {
330
+ const effortReport = await readEffortReport(dir)
331
+ return effortReport ? { ...outcome, effortReport } : outcome
319
332
  }
320
333
 
321
334
  /**
package/src/pi.ts CHANGED
@@ -7,6 +7,7 @@ import { pathExists } from './fs-utils.js'
7
7
  import { redactSecrets } from './redact.js'
8
8
  import { HarnessFailure } from './failure.js'
9
9
  import { log } from './logger.js'
10
+ import type { EffortReport } from './effort.js'
10
11
 
11
12
  // Drives the Pi coding-agent CLI. Pi is pointed at the Worker's OpenAI-compatible
12
13
  // proxy via a custom provider in ~/.pi/agent/models.json, authenticated with the
@@ -534,6 +535,12 @@ export interface PiRunOutcome {
534
535
  callMetrics?: HarnessCallMetric[]
535
536
  /** Output-quality signals (truncation / empty final answer); see {@link RunDiagnostics}. */
536
537
  diagnostics?: RunDiagnostics
538
+ /**
539
+ * The agent's effort self-assessment, lifted from its sentinel file after the run (how hard the
540
+ * work was, what reduced its effectiveness, the key obstacles). Absent when the agent wrote none.
541
+ * See {@link EffortReport}.
542
+ */
543
+ effortReport?: EffortReport
537
544
  }
538
545
 
539
546
  /**
package/src/subagents.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  import { readdir, stat } from 'node:fs/promises'
2
- import { createReadStream } from 'node:fs'
3
- import { join } from 'node:path'
2
+ import { createReadStream, type Dirent } from 'node:fs'
3
+ import { basename, join } from 'node:path'
4
4
  import { claudeAssistantContent, claudeCallUsage, isObject, redactBody } from './claude-stream.js'
5
5
  import type { Logger } from './logger.js'
6
6
  import type { HarnessCallMetric, TodoProgress } from './pi.js'
7
7
 
8
- // ADR 0026 D2.1 + D3. When the Claude Code CLI reviews a large PR it fans the work
9
- // out across parallel `Task` subagents. Two things then go dark to the harness, which
10
- // only reads the PARENT process's stream-json stdout:
8
+ // ADR 0026 D2.1 + D3, corrected by ADR 0027. When the Claude Code CLI reviews a large PR
9
+ // it fans the work out across parallel `Task` subagents. Two things then go dark to the
10
+ // harness, which only reads the PARENT process's stream-json stdout:
11
11
  //
12
12
  // - the parent stream falls quiet for the whole (potentially 15+ minute) parallel
13
13
  // review, so the inactivity heartbeat freezes and a healthy run looks wedged (P3);
@@ -21,11 +21,19 @@ import type { HarnessCallMetric, TodoProgress } from './pi.js'
21
21
  // - {@link createSliceTracker} derives the slice plan + per-slice progress from the
22
22
  // PARENT stream alone — the `Task` tool_use dispatch and its terminal tool_result
23
23
  // DO appear there (only the subagent's intermediate turns don't), so slices/progress
24
- // need no file watching (D2.1);
24
+ // need no file watching (D2.1). {@link pickProgress} reconciles it with any parent
25
+ // TodoWrite plan (ADR 0027 Defect B);
25
26
  // - {@link startSubagentWatcher} tails the `subagents/*.jsonl` transcripts for the
26
27
  // heartbeat (any new bytes ⇒ `onActivity`) and sums each subagent turn's usage into
27
28
  // the run's telemetry (D3).
28
29
  //
30
+ // The CLI does NOT write those transcripts to `<configHome>/subagents` (the location ADR
31
+ // 0026 assumed, which never exists — ADR 0027 Defect A). It writes them PER SESSION under
32
+ // `<configHome>/projects/<encoded-cwd>/<session-uuid>/subagents/agent-*.jsonl`, and the
33
+ // session-uuid dir isn't known before the CLI mints it — so the watcher is pointed at the
34
+ // `projects` root and DISCOVERS the `subagents/` dir by walking (see
35
+ // {@link findSubagentTranscripts}).
36
+ //
29
37
  // Both degrade gracefully: the CLI's subagent transcript layout is not a stable contract,
30
38
  // so a missing directory, an unreadable file, or an unparseable line is swallowed and the
31
39
  // harness falls back to today's parent-stream-only behaviour.
@@ -52,8 +60,10 @@ export interface SliceTracker {
52
60
  hasSlices(): boolean
53
61
  /**
54
62
  * Progress derived from the dispatched subagents (completed / in-flight / total),
55
- * or undefined when none have been dispatched. Used ONLY as a fallback when the
56
- * agent never wrote a parent TodoWrite plan a real todo list, when present, wins.
63
+ * or undefined when none have been dispatched. Reconciled with any parent TodoWrite
64
+ * plan by {@link pickProgress} it is NOT gated off by the presence of a todo plan
65
+ * (that gate was ADR 0027 Defect B: the pr-reviewer prompt writes the plan ONCE at
66
+ * grouping time and never marks it done, which used to permanently mask this signal).
57
67
  */
58
68
  progress(): TodoProgress | undefined
59
69
  }
@@ -106,6 +116,31 @@ export function createSliceTracker(): SliceTracker {
106
116
  }
107
117
  }
108
118
 
119
+ /**
120
+ * Reconcile the two redundant views of the same slice work into the one to surface
121
+ * (ADR 0027 Defect B). A pr-reviewer run has BOTH a parent `TodoWrite` plan (the slices,
122
+ * written once at grouping time) and the {@link SliceTracker}'s `Task`-dispatch view. The
123
+ * sequential shape advances the todo plan; the parallel-subagent shape advances ONLY the
124
+ * slice tracker (the CLI writes the plan once and never marks it done, while the parallel
125
+ * `Task`s report in-flight/complete). Neither alone covers both shapes, and gating the
126
+ * slice tracker OFF whenever a todo plan exists (the old behaviour) pinned parallel runs
127
+ * at 0%. So prefer whichever view is further along: more `completed`, then more
128
+ * `inProgress` (an all-pending todo plan must not beat live in-flight slices), then more
129
+ * `total` (the richer view — the todo plan can carry an extra "aggregate" entry), else the
130
+ * todo plan. Pure + total; returns whichever single input is present when only one is.
131
+ */
132
+ export function pickProgress(
133
+ todo: TodoProgress | undefined,
134
+ slice: TodoProgress | undefined,
135
+ ): TodoProgress | undefined {
136
+ if (!todo) return slice
137
+ if (!slice) return todo
138
+ if (slice.completed !== todo.completed) return slice.completed > todo.completed ? slice : todo
139
+ if (slice.inProgress !== todo.inProgress) return slice.inProgress > todo.inProgress ? slice : todo
140
+ if (slice.total !== todo.total) return slice.total > todo.total ? slice : todo
141
+ return todo
142
+ }
143
+
109
144
  // ---------------------------------------------------------------------------
110
145
  // Subagent transcript watcher (heartbeat + usage) (D3)
111
146
  // ---------------------------------------------------------------------------
@@ -135,15 +170,51 @@ export interface SubagentWatcher {
135
170
  }
136
171
 
137
172
  /**
138
- * Start watching `dir` (the CLI's `<configHome>/subagents`) for `*.jsonl` transcripts,
139
- * tailing each file by byte offset. New content feeds `onActivity` (heartbeat) and each
140
- * assistant turn carrying usage is lifted into a {@link HarnessCallMetric} + summed into
141
- * the cumulative usage. Best-effort throughout: the directory may not exist yet (created
142
- * lazily by the CLI), a file may be mid-write, and the line/usage shape may change across
143
- * CLI versions every such case is swallowed so the watcher can only ever ADD signal,
144
- * never break the run.
173
+ * Recursively collect every `*.jsonl` file that lives inside a `subagents/` directory
174
+ * anywhere under `root` (the CLI's `<configHome>/projects` tree). The Claude CLI writes
175
+ * each parallel `Task` subagent's transcript to
176
+ * `<configHome>/projects/<encoded-cwd>/<session-uuid>/subagents/agent-*.jsonl`; the
177
+ * session-uuid dir isn't known before the CLI mints it, so we DISCOVER the `subagents/`
178
+ * dir by walking rather than guessing its path (ADR 0027 Defect A). Files NOT under a
179
+ * `subagents/` dir — critically the parent's own `<session-uuid>.jsonl` session transcript,
180
+ * whose per-turn usage the terminal `result` event already totals — are deliberately
181
+ * excluded: reading them would double-count the parent. `root` itself counts as inside a
182
+ * `subagents/` dir when its own basename is `subagents` (so passing the leaf dir works too).
183
+ * Best-effort: an unreadable directory is skipped, never thrown.
184
+ */
185
+ async function findSubagentTranscripts(root: string): Promise<string[]> {
186
+ const out: string[] = []
187
+ const walk = async (dir: string, inSubagents: boolean): Promise<void> => {
188
+ let entries: Dirent[]
189
+ try {
190
+ entries = await readdir(dir, { withFileTypes: true })
191
+ } catch {
192
+ return // dir not created yet (or vanished) — try again next tick
193
+ }
194
+ for (const entry of entries) {
195
+ const full = join(dir, entry.name)
196
+ if (entry.isDirectory()) {
197
+ await walk(full, inSubagents || entry.name === 'subagents')
198
+ } else if (inSubagents && entry.isFile() && entry.name.endsWith('.jsonl')) {
199
+ out.push(full)
200
+ }
201
+ }
202
+ }
203
+ await walk(root, basename(root) === 'subagents')
204
+ return out
205
+ }
206
+
207
+ /**
208
+ * Start watching `root` (the CLI's `<configHome>/projects` tree) for subagent `*.jsonl`
209
+ * transcripts — any file under a `subagents/` directory beneath it (see
210
+ * {@link findSubagentTranscripts}) — tailing each file by byte offset. New content feeds
211
+ * `onActivity` (heartbeat) and each assistant turn carrying usage is lifted into a
212
+ * {@link HarnessCallMetric} + summed into the cumulative usage. Best-effort throughout: the
213
+ * tree may not exist yet (created lazily by the CLI), a file may be mid-write, and the
214
+ * line/usage shape may change across CLI versions — every such case is swallowed so the
215
+ * watcher can only ever ADD signal, never break the run.
145
216
  */
146
- export function startSubagentWatcher(dir: string, opts: SubagentWatcherOptions): SubagentWatcher {
217
+ export function startSubagentWatcher(root: string, opts: SubagentWatcherOptions): SubagentWatcher {
147
218
  const secrets = opts.secrets ?? []
148
219
  const offsets = new Map<string, number>()
149
220
  const calls: HarnessCallMetric[] = []
@@ -225,15 +296,8 @@ export function startSubagentWatcher(dir: string, opts: SubagentWatcherOptions):
225
296
  if (polling) return
226
297
  polling = true
227
298
  try {
228
- let entries: string[]
229
- try {
230
- entries = (await readdir(dir)).filter((n) => n.endsWith('.jsonl'))
231
- } catch {
232
- return // dir not created yet (or vanished) — try again next tick
233
- }
234
299
  let grew = false
235
- for (const name of entries) {
236
- const path = join(dir, name)
300
+ for (const path of await findSubagentTranscripts(root)) {
237
301
  let size: number
238
302
  try {
239
303
  size = (await stat(path)).size