@cat-factory/executor-harness 1.50.4 → 1.50.8

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.
@@ -0,0 +1,78 @@
1
+ import { readFile, writeFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import type { Logger } from './logger.js'
4
+
5
+ // ADR 0026 D4 (paired assertion). A brand-new Claude Code config home would otherwise
6
+ // make `claude -p` block on the interactive onboarding / "trust this folder" /
7
+ // bypass-permissions acknowledgement prompts — which never get answered headlessly,
8
+ // hanging the job until the inactivity watchdog kills it with no output. We pre-seed a
9
+ // `.claude.json` marking those gates as already accepted.
10
+ //
11
+ // The hazard the ADR calls out: if a future CLI version adds a NEW first-run gate this
12
+ // set does not cover, the symptom is identical to a healthy-but-quiet subagent run (no
13
+ // stdout, low CPU), so the cold-start watchdog can't tell them apart on its own. This
14
+ // module centralises the pre-seeded keys as ONE source of truth and logs the pinned set
15
+ // (with the installed CLI version) so that, when the cold-start watchdog fires, an
16
+ // operator has the exact keys-vs-version pairing to diff against a new gate.
17
+
18
+ /**
19
+ * The onboarding gates we pre-accept in a fresh config home. Kept as a single constant so
20
+ * the write and the assertion below can never drift, and so a new gate is added in exactly
21
+ * one place. If the CLI renames/adds a key, this is where the fix lands.
22
+ */
23
+ export const ONBOARDING_PRESEED_KEYS = {
24
+ hasCompletedOnboarding: true,
25
+ bypassPermissionsModeAccepted: true,
26
+ hasTrustDialogAccepted: true,
27
+ } as const
28
+
29
+ /** Write the onboarding pre-seed into `<configHome>/.claude.json`. Best-effort; never throws. */
30
+ export async function writeOnboardingPreseed(configHome: string): Promise<void> {
31
+ await writeFile(join(configHome, '.claude.json'), JSON.stringify(ONBOARDING_PRESEED_KEYS), {
32
+ mode: 0o600,
33
+ }).catch(() => {})
34
+ }
35
+
36
+ /**
37
+ * Verify the pre-seed actually landed and log the pinned onboarding keys alongside the
38
+ * installed CLI version — the "one-line assertion after the pre-seed" from D4. It cannot
39
+ * introspect the CLI's true first-run gate set (the CLI never exposes it), so it does the
40
+ * two things it CAN do cheaply and deterministically: confirm every key we intended is
41
+ * present + truthy in the written file (catching a botched write), and emit a structured
42
+ * record pairing the keys with the CLI version so a future onboarding regression — surfaced
43
+ * by the cold-start watchdog as a silent, output-less start — is diffable against a new gate.
44
+ * Best-effort; never throws.
45
+ */
46
+ export async function assertOnboardingKeysCurrent(
47
+ configHome: string,
48
+ cliVersion: string | undefined,
49
+ log: Logger | undefined,
50
+ ): Promise<void> {
51
+ const expected = Object.keys(ONBOARDING_PRESEED_KEYS)
52
+ let parsed: Record<string, unknown> = {}
53
+ try {
54
+ parsed = JSON.parse(await readFile(join(configHome, '.claude.json'), 'utf8')) as Record<
55
+ string,
56
+ unknown
57
+ >
58
+ } catch {
59
+ log?.warn('onboarding pre-seed could not be read back after write', {
60
+ onboardingKeys: expected,
61
+ ...(cliVersion ? { cliVersion } : {}),
62
+ })
63
+ return
64
+ }
65
+ const missing = expected.filter((k) => parsed[k] !== true)
66
+ if (missing.length > 0) {
67
+ log?.warn('onboarding pre-seed is missing expected keys', {
68
+ onboardingKeys: expected,
69
+ missing,
70
+ ...(cliVersion ? { cliVersion } : {}),
71
+ })
72
+ return
73
+ }
74
+ log?.info('onboarding pre-seed applied', {
75
+ onboardingKeys: expected,
76
+ ...(cliVersion ? { cliVersion } : {}),
77
+ })
78
+ }
package/src/runner.ts CHANGED
@@ -114,6 +114,19 @@ export interface JobView<TResult extends JobResultBase = JobResultBase> {
114
114
  * surfaces the first one (and only on a follow-ups-enabled coding run).
115
115
  */
116
116
  followUps?: FollowUpLine[]
117
+ /**
118
+ * ADR 0026 D4: set when the cold-start watchdog fired — the job produced NO activity
119
+ * within {@link RunnerLimits.coldStartMs} of starting, a likely onboarding/auth wedge.
120
+ * This does NOT fail the job (the inactivity/max-duration watchdogs still own that).
121
+ *
122
+ * Legibility today is via the per-job container log line emitted the moment it fires
123
+ * (the ~2-minute early signal the ADR wants); this field additionally carries the
124
+ * structured record on the GET /jobs/{id} view so an operator hitting the endpoint — or a
125
+ * future engine-side consumer — can read it without scraping logs. No engine code consumes
126
+ * it yet, so surfacing it up through the runner-transport layer is deliberately deferred.
127
+ * Absent on a job that produced output promptly (the overwhelming common case). Sticky once set.
128
+ */
129
+ coldStart?: { atMs: number; message: string }
117
130
  }
118
131
 
119
132
  interface JobEntry<TResult extends JobResultBase> extends JobView<TResult> {
@@ -133,6 +146,15 @@ export interface RunnerLimits {
133
146
  maxDurationMs: number
134
147
  /** Force-fail the job if the agent produces no output for this long (hang guard). */
135
148
  inactivityMs: number
149
+ /**
150
+ * ADR 0026 D4: a short first-output window. If the job produces NO activity within this
151
+ * long after start, emit a structured cold-start diagnostic (a likely onboarding/auth
152
+ * wedge) — WITHOUT killing the run. Purely a legibility signal so a genuine cold-start
153
+ * wedge surfaces in a couple of minutes instead of waiting out the full inactivity
154
+ * window. Safely under the clone-inclusive phases (a large clone still streams git
155
+ * progress, which counts as activity). Set to 0 to disable.
156
+ */
157
+ coldStartMs: number
136
158
  }
137
159
 
138
160
  function intEnv(value: string | undefined, fallback: number): number {
@@ -140,6 +162,13 @@ function intEnv(value: string | undefined, fallback: number): number {
140
162
  return Number.isFinite(n) && n > 0 ? n : fallback
141
163
  }
142
164
 
165
+ /** Like {@link intEnv} but allows an explicit 0 (used to DISABLE a window). */
166
+ function intEnvAllowZero(value: string | undefined, fallback: number): number {
167
+ if (value === undefined) return fallback
168
+ const n = Number(value)
169
+ return Number.isFinite(n) && n >= 0 ? n : fallback
170
+ }
171
+
143
172
  export function loadRunnerLimits(env: NodeJS.ProcessEnv = process.env): RunnerLimits {
144
173
  return {
145
174
  // 60 minutes: generous headroom for serious multi-file coding tasks while
@@ -152,6 +181,9 @@ export function loadRunnerLimits(env: NodeJS.ProcessEnv = process.env): RunnerLi
152
181
  // with git's own clear reason rather than this watchdog's "likely hung" message,
153
182
  // for any configured window. See the invariant note in git.ts.
154
183
  inactivityMs: intEnv(env.JOB_INACTIVITY_MS, 10 * 60_000),
184
+ // 2 minutes: comfortably longer than a warm agent's time-to-first-token yet far
185
+ // under the 10-minute inactivity kill, so a truly output-less start is flagged early.
186
+ coldStartMs: intEnvAllowZero(env.JOB_COLD_START_MS, 2 * 60_000),
155
187
  }
156
188
  }
157
189
 
@@ -298,7 +330,28 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
298
330
  killReason ??= 'max-duration'
299
331
  controller.abort(new Error('max duration exceeded'))
300
332
  }, this.limits.maxDurationMs)
333
+
334
+ // ADR 0026 D4: a one-shot cold-start watchdog. If the job produces no activity within
335
+ // `coldStartMs`, record a structured diagnostic (a likely onboarding/auth wedge) so it
336
+ // is legible early — it does NOT abort the run (the inactivity watchdog still owns
337
+ // that). Cleared the moment the first activity arrives.
338
+ let sawActivity = false
339
+ let coldStart: ReturnType<typeof setTimeout> | undefined
340
+ if (this.limits.coldStartMs > 0) {
341
+ coldStart = setTimeout(() => {
342
+ if (sawActivity) return
343
+ const secs = Math.round(this.limits.coldStartMs / 1000)
344
+ const message = `agent produced no output ${secs}s after start; possible onboarding/auth wedge (phase: ${phase})`
345
+ entry.coldStart = { atMs: Date.now(), message }
346
+ jobLog.warn('cold-start: no agent output', { afterMs: this.limits.coldStartMs, phase })
347
+ }, this.limits.coldStartMs)
348
+ }
349
+
301
350
  const heartbeat = (): void => {
351
+ if (!sawActivity) {
352
+ sawActivity = true
353
+ clearTimeout(coldStart)
354
+ }
302
355
  entry.heartbeatAt = Date.now()
303
356
  resetInactivity()
304
357
  }
@@ -361,6 +414,7 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
361
414
  } finally {
362
415
  clearTimeout(inactivity)
363
416
  clearTimeout(cap)
417
+ clearTimeout(coldStart)
364
418
  entry.abort = undefined
365
419
  entry.heartbeatAt = Date.now()
366
420
  }
@@ -0,0 +1,276 @@
1
+ import { readdir, stat } from 'node:fs/promises'
2
+ import { createReadStream } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { claudeAssistantContent, claudeCallUsage, isObject, redactBody } from './claude-stream.js'
5
+ import type { Logger } from './logger.js'
6
+ import type { HarnessCallMetric, TodoProgress } from './pi.js'
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:
11
+ //
12
+ // - the parent stream falls quiet for the whole (potentially 15+ minute) parallel
13
+ // review, so the inactivity heartbeat freezes and a healthy run looks wedged (P3);
14
+ // - every subagent's token spend is written to a SEPARATE `subagents/*.jsonl`
15
+ // transcript under the CLI's config home and never reaches the parent stream, so
16
+ // the run's telemetry reports ~0 tokens while hundreds of thousands are spent (P3).
17
+ //
18
+ // This module closes both without disabling the (context-bounding, ADR-0023-wanted)
19
+ // subagent parallelism:
20
+ //
21
+ // - {@link createSliceTracker} derives the slice plan + per-slice progress from the
22
+ // PARENT stream alone — the `Task` tool_use dispatch and its terminal tool_result
23
+ // DO appear there (only the subagent's intermediate turns don't), so slices/progress
24
+ // need no file watching (D2.1);
25
+ // - {@link startSubagentWatcher} tails the `subagents/*.jsonl` transcripts for the
26
+ // heartbeat (any new bytes ⇒ `onActivity`) and sums each subagent turn's usage into
27
+ // the run's telemetry (D3).
28
+ //
29
+ // Both degrade gracefully: the CLI's subagent transcript layout is not a stable contract,
30
+ // so a missing directory, an unreadable file, or an unparseable line is swallowed and the
31
+ // harness falls back to today's parent-stream-only behaviour.
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Slice / progress tracking off the PARENT stream (D2.1)
35
+ // ---------------------------------------------------------------------------
36
+
37
+ interface TrackedSlice {
38
+ /** The `Task` tool_use id, used to pair the terminal tool_result. */
39
+ toolUseId: string
40
+ /** The subagent's description (`Review <slice> slice`), rendered as the progress label. */
41
+ description: string
42
+ done: boolean
43
+ }
44
+
45
+ /** Tracks parallel `Task` subagents seen on the parent stream to derive slice progress. */
46
+ export interface SliceTracker {
47
+ /** Feed an `assistant` message's content blocks: registers any `Task` dispatches. */
48
+ onAssistant(content: unknown[]): void
49
+ /** Feed a `user` message's content blocks: marks the paired subagent(s) complete. */
50
+ onUser(content: unknown[]): void
51
+ /** Whether any `Task` subagent has been dispatched (⇒ this run parallelised). */
52
+ hasSlices(): boolean
53
+ /**
54
+ * 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.
57
+ */
58
+ progress(): TodoProgress | undefined
59
+ }
60
+
61
+ export function createSliceTracker(): SliceTracker {
62
+ // Insertion-ordered so the progress `items` render in dispatch order.
63
+ const slices = new Map<string, TrackedSlice>()
64
+
65
+ return {
66
+ onAssistant(content) {
67
+ if (!Array.isArray(content)) return
68
+ for (const block of content) {
69
+ if (!isObject(block) || block.type !== 'tool_use' || block.name !== 'Task') continue
70
+ const id = typeof block.id === 'string' ? block.id : undefined
71
+ if (!id || slices.has(id)) continue
72
+ const input = isObject(block.input) ? block.input : {}
73
+ const description =
74
+ typeof input.description === 'string' && input.description.trim()
75
+ ? input.description.trim()
76
+ : `Subagent ${slices.size + 1}`
77
+ slices.set(id, { toolUseId: id, description, done: false })
78
+ }
79
+ },
80
+ onUser(content) {
81
+ if (!Array.isArray(content)) return
82
+ for (const block of content) {
83
+ if (!isObject(block) || block.type !== 'tool_result') continue
84
+ const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined
85
+ const slice = id ? slices.get(id) : undefined
86
+ if (slice) slice.done = true
87
+ }
88
+ },
89
+ hasSlices() {
90
+ return slices.size > 0
91
+ },
92
+ progress() {
93
+ if (slices.size === 0) return undefined
94
+ const items = [...slices.values()].map((s) => ({
95
+ label: s.description,
96
+ status: (s.done ? 'completed' : 'in_progress') as 'completed' | 'in_progress',
97
+ }))
98
+ const completed = items.filter((i) => i.status === 'completed').length
99
+ return {
100
+ completed,
101
+ inProgress: items.length - completed,
102
+ total: items.length,
103
+ items,
104
+ }
105
+ },
106
+ }
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Subagent transcript watcher (heartbeat + usage) (D3)
111
+ // ---------------------------------------------------------------------------
112
+
113
+ /** Default poll cadence for the transcript directory; well under the git timeout margin. */
114
+ const DEFAULT_POLL_MS = 3_000
115
+
116
+ export interface SubagentWatcherOptions {
117
+ /** Fed the heartbeat when a transcript grows, so the inactivity watchdog sees the run is alive. */
118
+ onActivity?: () => void
119
+ /** Leased-credential strings to scrub from captured bodies (the transcripts can echo the token). */
120
+ secrets?: string[]
121
+ /** Fallback model id stamped on a subagent call whose transcript omits one. */
122
+ model?: string
123
+ /** Poll cadence (ms); overridable for tests. */
124
+ intervalMs?: number
125
+ log?: Logger
126
+ }
127
+
128
+ export interface SubagentWatcher {
129
+ /** Do a final poll, then stop watching. Idempotent; never throws. */
130
+ stop(): Promise<void>
131
+ /** Cumulative subagent usage lifted so far (input + output tokens). */
132
+ usage(): { inputTokens: number; outputTokens: number }
133
+ /** The per-call telemetry rows lifted from the subagent transcripts so far. */
134
+ calls(): HarnessCallMetric[]
135
+ }
136
+
137
+ /**
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.
145
+ */
146
+ export function startSubagentWatcher(dir: string, opts: SubagentWatcherOptions): SubagentWatcher {
147
+ const secrets = opts.secrets ?? []
148
+ const offsets = new Map<string, number>()
149
+ const calls: HarnessCallMetric[] = []
150
+ const usage = { inputTokens: 0, outputTokens: 0 }
151
+ // Per-file partial-line remainder, carried as raw BYTES (not a decoded string). A JSONL
152
+ // record can straddle two polls (the file is appended between ticks), and the byte offset
153
+ // we stop at can fall in the middle of a multi-byte UTF-8 character; decoding a partial
154
+ // read to a string would replace that split character with U+FFFD and corrupt the line.
155
+ // Buffering bytes and decoding only whole lines keeps the captured text faithful.
156
+ const carry = new Map<string, Buffer>()
157
+ let polling = false
158
+
159
+ const ingestLine = (line: string): void => {
160
+ const trimmed = line.trim()
161
+ if (!trimmed.startsWith('{')) return
162
+ let event: Record<string, unknown>
163
+ try {
164
+ event = JSON.parse(trimmed) as Record<string, unknown>
165
+ } catch {
166
+ return
167
+ }
168
+ // Subagent transcripts mirror the session-transcript envelope: an `assistant` entry
169
+ // whose `message` carries the Anthropic `usage` + `content`. Read defensively.
170
+ if (event.type !== 'assistant' || !isObject(event.message)) return
171
+ const message = event.message as Record<string, unknown>
172
+ const u = claudeCallUsage(message.usage)
173
+ if (u.inputTokens === 0 && u.outputTokens === 0) return
174
+ const content = Array.isArray(message.content) ? message.content : []
175
+ const { text, reasoning } = claudeAssistantContent(content)
176
+ calls.push({
177
+ ...(typeof message.model === 'string'
178
+ ? { model: message.model }
179
+ : opts.model
180
+ ? { model: opts.model }
181
+ : {}),
182
+ // The subagent's own transcript isn't a re-sendable prompt chain, so we don't
183
+ // reconstruct the request side (kept empty); the response + tokens are faithful.
184
+ promptText: '',
185
+ messageCount: 0,
186
+ responseText: redactBody(text, secrets),
187
+ reasoningText: redactBody(reasoning, secrets),
188
+ inputTokens: u.inputTokens,
189
+ cachedInputTokens: u.cachedInputTokens,
190
+ outputTokens: u.outputTokens,
191
+ finishReason: typeof message.stop_reason === 'string' ? message.stop_reason : null,
192
+ })
193
+ usage.inputTokens += u.inputTokens
194
+ usage.outputTokens += u.outputTokens
195
+ }
196
+
197
+ const NEWLINE = 0x0a
198
+ const readNew = (path: string, from: number, to: number): Promise<void> =>
199
+ new Promise((resolve) => {
200
+ // Tail as raw bytes and split on the newline byte, decoding each COMPLETE line to
201
+ // UTF-8 only on that boundary (a '\n' is a single byte, never part of a multi-byte
202
+ // sequence), so a record — or a multi-byte character — that spans this read and the
203
+ // next is reassembled from the byte carry rather than corrupted at the seam.
204
+ let buffer = carry.get(path) ?? Buffer.alloc(0)
205
+ const stream = createReadStream(path, { start: from, end: to - 1 })
206
+ stream.on('data', (chunk: Buffer) => {
207
+ buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk
208
+ let nl = buffer.indexOf(NEWLINE)
209
+ while (nl !== -1) {
210
+ ingestLine(buffer.subarray(0, nl).toString('utf8'))
211
+ buffer = buffer.subarray(nl + 1)
212
+ nl = buffer.indexOf(NEWLINE)
213
+ }
214
+ })
215
+ stream.on('error', () => resolve())
216
+ stream.on('close', () => {
217
+ // Copy the remainder out of the shared chunk backing store before caching it, so a
218
+ // later Buffer.concat can't be aliased by a reused stream buffer.
219
+ carry.set(path, Buffer.from(buffer))
220
+ resolve()
221
+ })
222
+ })
223
+
224
+ const pollOnce = async (): Promise<void> => {
225
+ if (polling) return
226
+ polling = true
227
+ 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
+ let grew = false
235
+ for (const name of entries) {
236
+ const path = join(dir, name)
237
+ let size: number
238
+ try {
239
+ size = (await stat(path)).size
240
+ } catch {
241
+ continue
242
+ }
243
+ const from = offsets.get(path) ?? 0
244
+ if (size <= from) continue
245
+ grew = true
246
+ await readNew(path, from, size)
247
+ offsets.set(path, size)
248
+ }
249
+ if (grew) opts.onActivity?.()
250
+ } catch (e) {
251
+ opts.log?.warn('subagent transcript poll failed', { error: String(e) })
252
+ } finally {
253
+ polling = false
254
+ }
255
+ }
256
+
257
+ const timer = setInterval(() => void pollOnce(), opts.intervalMs ?? DEFAULT_POLL_MS)
258
+ // Don't let the watcher's timer keep the container process alive on its own.
259
+ timer.unref?.()
260
+
261
+ return {
262
+ // Always does a final drain (idempotent clear of the timer), so a late transcript
263
+ // write between the last tick and stop is still captured, and a second stop() picks up
264
+ // anything appended since — the per-file offsets make re-polling safe (no double count).
265
+ async stop() {
266
+ clearInterval(timer)
267
+ await pollOnce()
268
+ },
269
+ usage() {
270
+ return { ...usage }
271
+ },
272
+ calls() {
273
+ return calls
274
+ },
275
+ }
276
+ }