@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.
@@ -2,16 +2,12 @@ import { spawn } from 'node:child_process';
2
2
  import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
3
  import { homedir, tmpdir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
+ import { claudeAssistantContent, claudeCallUsage, isObject, numberOf, redactBody, } from './claude-stream.js';
5
6
  import { killChildProcess, spawnDetached } from './process.js';
6
7
  import { redact, secretsToRedact } from './redact.js';
8
+ import { createSliceTracker, startSubagentWatcher } from './subagents.js';
9
+ import { assertOnboardingKeysCurrent, writeOnboardingPreseed } from './onboarding-preseed.js';
7
10
  import { retainSessionTranscripts } from './transcript-retention.js';
8
- function isObject(value) {
9
- return typeof value === 'object' && value !== null;
10
- }
11
- /** Scrub any leased-credential occurrences from a telemetry body (no-op when none). */
12
- function redactBody(text, secrets) {
13
- return secrets.length ? redact(text, secrets) : text;
14
- }
15
11
  /**
16
12
  * Fallback token attribution: if a CLI reported a cumulative total but no per-turn
17
13
  * usage (so every captured call has zero tokens), pin the whole total onto the LAST
@@ -34,10 +30,11 @@ function attributeCumulativeUsage(calls, usage) {
34
30
  * never argv), `onActivity` on every chunk, abort kills the child, and the close
35
31
  * handler resolves/rejects. The caller's `onEvent` accumulates the outcome.
36
32
  *
37
- * `prompt` is fed over stdin: for Claude Code that is just the task prompt (the
38
- * system prompt rides `--append-system-prompt`); for Codex which has no
39
- * system-prompt flag the caller prepends the composed system prompt to it so
40
- * the role + best-practice context is not lost.
33
+ * `prompt` is fed over stdin: for Claude Code that is normally just the task prompt (the
34
+ * system prompt rides `--append-system-prompt`), unless the system prompt is too large for
35
+ * argv, in which case it is folded into `prompt` (see `carryClaudeSystemPrompt`); for Codex
36
+ * which has no system-prompt flag the caller always prepends the composed system prompt
37
+ * so the role + best-practice context is not lost.
41
38
  */
42
39
  function streamCli(cli, prompt, opts, env, secrets, onEvent) {
43
40
  const { command, args } = cli;
@@ -122,6 +119,40 @@ function streamCli(cli, prompt, opts, env, secrets, onEvent) {
122
119
  });
123
120
  });
124
121
  }
122
+ /**
123
+ * Fold a composed system prompt into the task prompt so the role + best-practice context
124
+ * rides stdin as a single user turn. Used by the Codex runner (no system-prompt flag) and
125
+ * by the Claude runner's argv-overflow fallback. Empty system prompt ⇒ the task prompt is
126
+ * returned unchanged.
127
+ */
128
+ function foldSystemPrompt(systemPrompt, userPrompt) {
129
+ return systemPrompt ? `${systemPrompt}\n\n---\n\n${userPrompt}` : userPrompt;
130
+ }
131
+ /**
132
+ * Linux caps a SINGLE argv string at MAX_ARG_STRLEN (32 pages = 128 KiB) — a per-string limit,
133
+ * distinct from (and reached long before) the far larger total ARG_MAX for argv + env combined. A
134
+ * system prompt with best-practice fragments folded in can exceed that per-string cap, and `execve`
135
+ * then fails the whole spawn with `E2BIG` before the agent runs at all — the failure mode seen on
136
+ * the `pr-reviewer` step (a ~150 KiB composed prompt). The binding constraint is that per-string
137
+ * cap; 96 KiB stays comfortably under 128 KiB so the system-prompt argv can never approach it.
138
+ */
139
+ const MAX_ARGV_STRING_BYTES = 96 * 1024;
140
+ /**
141
+ * Decide how the Claude Code runner carries the composed system prompt. Small prompts ride
142
+ * `--append-system-prompt` (a real system turn, cacheable) as before; a prompt too large for a
143
+ * single argv string is instead folded into the stdin task prompt (like the Codex runner), which
144
+ * has no size ceiling. Pure so the branch is unit-testable without spawning the CLI.
145
+ */
146
+ export function carryClaudeSystemPrompt(systemPrompt, userPrompt) {
147
+ if (Buffer.byteLength(systemPrompt, 'utf8') <= MAX_ARGV_STRING_BYTES) {
148
+ return {
149
+ appendArgs: ['--append-system-prompt', systemPrompt],
150
+ prompt: userPrompt,
151
+ folded: false,
152
+ };
153
+ }
154
+ return { appendArgs: [], prompt: foldSystemPrompt(systemPrompt, userPrompt), folded: true };
155
+ }
125
156
  // ---------------------------------------------------------------------------
126
157
  // Claude Code
127
158
  // ---------------------------------------------------------------------------
@@ -161,19 +192,47 @@ export async function runClaudeCode(opts) {
161
192
  const stats = { toolCalls: 0, assistantChars: 0 };
162
193
  let summary = '';
163
194
  let usage;
195
+ // Decide how the composed system prompt is carried up front, so the telemetry seed below
196
+ // reflects what actually reaches the model: a small prompt rides `--append-system-prompt`
197
+ // (a real system turn), while an argv-overflowing prompt is folded into the first user turn
198
+ // — in which case NO system turn of ours is sent (the `E2BIG` fallback).
199
+ const { appendArgs, prompt, folded } = carryClaudeSystemPrompt(opts.systemPrompt, opts.userPrompt);
200
+ if (folded) {
201
+ opts.log?.warn('system prompt exceeds argv limit; folding into the task prompt', {
202
+ bytes: Buffer.byteLength(opts.systemPrompt, 'utf8'),
203
+ });
204
+ }
164
205
  // Reconstruct the full per-call request/response bodies for telemetry from the
165
206
  // stream. `--output-format stream-json --verbose` emits each turn as a near-verbatim
166
207
  // Anthropic Messages envelope, so `assistant` events carry the complete response
167
208
  // (text + tool_use blocks + usage), and `user` events carry the tool_result blocks
168
- // fed back — together the growing prompt transcript. We seed it with the two inputs
169
- // the harness supplies (they never appear in the stream): the system + first user
170
- // message. Bodies are credential-scrubbed (they can echo the leased token).
209
+ // fed back — together the growing prompt transcript. We seed it with the inputs the
210
+ // harness supplies (they never appear in the stream): the system + first user message
211
+ // when the prompt rides argv, or a single folded user turn when it doesn't — so the
212
+ // reconstruction never shows a system turn that was never sent. Bodies are
213
+ // credential-scrubbed (they can echo the leased token).
171
214
  const secrets = opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [];
172
- const messages = [
173
- { role: 'system', content: opts.systemPrompt },
174
- { role: 'user', content: opts.userPrompt },
175
- ];
215
+ const messages = folded
216
+ ? [{ role: 'user', content: prompt }]
217
+ : [
218
+ { role: 'system', content: opts.systemPrompt },
219
+ { role: 'user', content: opts.userPrompt },
220
+ ];
176
221
  const calls = [];
222
+ // ADR 0026 D2.1: derive slice progress from the parent stream's `Task` dispatches +
223
+ // their terminal tool_results (both DO appear here — only a subagent's intermediate
224
+ // turns don't). A real parent TodoWrite plan, when the agent writes one, wins; the
225
+ // slice-derived progress is the fallback for the parallel-subagent shape that writes no
226
+ // parent plan (the pr-reviewer failure this fixes).
227
+ const sliceTracker = createSliceTracker();
228
+ let sawTodoPlan = false;
229
+ const emitSliceProgress = () => {
230
+ if (sawTodoPlan || !opts.onProgress)
231
+ return;
232
+ const progress = sliceTracker.progress();
233
+ if (progress)
234
+ opts.onProgress(progress);
235
+ };
177
236
  const onEvent = (event) => {
178
237
  const type = event.type;
179
238
  if (type === 'assistant' && isObject(event.message)) {
@@ -188,10 +247,14 @@ export async function runClaudeCode(opts) {
188
247
  block.name === 'TodoWrite' &&
189
248
  opts.onProgress) {
190
249
  const progress = todosToProgress(block.input?.todos);
191
- if (progress)
250
+ if (progress) {
251
+ sawTodoPlan = true;
192
252
  opts.onProgress(progress);
253
+ }
193
254
  }
194
255
  }
256
+ sliceTracker.onAssistant(content);
257
+ emitSliceProgress();
195
258
  // Record this call BEFORE appending its turn: the prompt is the history that
196
259
  // produced this response. The append-only array keeps each call's prompt a strict
197
260
  // prefix of the next, so the backend's telemetry chain delta-compresses cleanly.
@@ -212,8 +275,11 @@ export async function runClaudeCode(opts) {
212
275
  else if (type === 'user' && isObject(event.message)) {
213
276
  // tool_result blocks the harness fed back to the model — part of the next prompt.
214
277
  const content = event.message.content;
215
- if (Array.isArray(content))
278
+ if (Array.isArray(content)) {
279
+ sliceTracker.onUser(content);
280
+ emitSliceProgress();
216
281
  messages.push({ role: 'tool', content });
282
+ }
217
283
  }
218
284
  else if (type === 'result') {
219
285
  if (typeof event.result === 'string')
@@ -239,12 +305,12 @@ export async function runClaudeCode(opts) {
239
305
  // as already accepted so `-p` starts straight into the run. Best-effort: written
240
306
  // before the CLI starts; unknown keys are harmless if a CLI version ignores them.
241
307
  // (Ambient mode skips this — the developer's own config is already onboarded.)
308
+ // ADR 0026 D4: assert the pinned onboarding keys landed and log them with the CLI
309
+ // version, so a future first-run gate this set doesn't cover (which looks identical to
310
+ // a healthy-but-quiet subagent start) is diffable when the cold-start watchdog fires.
242
311
  if (configHome) {
243
- await writeFile(join(configHome, '.claude.json'), JSON.stringify({
244
- hasCompletedOnboarding: true,
245
- bypassPermissionsModeAccepted: true,
246
- hasTrustDialogAccepted: true,
247
- }), { mode: 0o600 }).catch(() => { });
312
+ await writeOnboardingPreseed(configHome);
313
+ await assertOnboardingKeysCurrent(configHome, process.env.CLAUDE_CLI_VERSION, opts.log);
248
314
  }
249
315
  // Repo-sourced Claude Skill (slice 2): install it as a native skill under the config dir's
250
316
  // `skills/<name>/` so the CLI discovers and can invoke it. Written to the isolated per-run
@@ -271,6 +337,19 @@ export async function runClaudeCode(opts) {
271
337
  }
272
338
  : { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken }),
273
339
  };
340
+ // ADR 0026 D2.1/D3: while the run is live, tail the CLI's `subagents/*.jsonl`
341
+ // transcripts (under the isolated config home) so a parallel-subagent review keeps the
342
+ // inactivity heartbeat alive (any new bytes ⇒ `onActivity`) and its otherwise-invisible
343
+ // token spend is lifted into the run's telemetry. Ambient mode has no isolated home to
344
+ // watch. Best-effort — a missing/renamed transcript layout just yields no extra signal.
345
+ const subagents = configHome
346
+ ? startSubagentWatcher(join(configHome, 'subagents'), {
347
+ ...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
348
+ secrets,
349
+ model: opts.model,
350
+ ...(opts.log ? { log: opts.log } : {}),
351
+ })
352
+ : undefined;
274
353
  try {
275
354
  const { stderrTail } = await streamCli({
276
355
  command: 'claude',
@@ -287,20 +366,43 @@ export async function runClaudeCode(opts) {
287
366
  'bypassPermissions',
288
367
  '--model',
289
368
  opts.model,
290
- '--append-system-prompt',
291
- opts.systemPrompt,
369
+ ...appendArgs,
292
370
  ],
293
- }, opts.userPrompt, opts, env, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
371
+ }, prompt, opts, env, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
372
+ // The parent's cumulative-usage fallback applies to the PARENT calls only (before the
373
+ // subagent calls, which carry their own per-turn tokens, are concatenated).
294
374
  attributeCumulativeUsage(calls, usage);
375
+ // Final drain of any subagent transcript writes that landed after the last poll, then
376
+ // fold the subagents' usage + per-call telemetry into the run's outcome — their tokens
377
+ // never appear on the parent stream, so this is the only place they are accounted.
378
+ await subagents?.stop();
379
+ const subUsage = subagents?.usage() ?? { inputTokens: 0, outputTokens: 0 };
380
+ const subCalls = subagents?.calls() ?? [];
381
+ const mergedCalls = [...calls, ...subCalls];
382
+ // INVARIANT (do not "fix" this into a double count): the run total is the parent usage
383
+ // PLUS the subagent usage because the two are disjoint sources. The parent `usage` here
384
+ // is the terminal `result` event's cumulative, which covers ONLY the parent loop — the
385
+ // ADR 0026 incident is itself the proof: a heavily subagent-parallelised review reported
386
+ // ~0 tokens, i.e. the parent stream (and its `result` total) never included the subagent
387
+ // spend. The subagent tokens live exclusively in the `subagents/*.jsonl` transcripts (a
388
+ // directory distinct from the parent's `projects/` session transcript), which the watcher
389
+ // reads and nothing else does — so neither `calls` nor `usage` can already contain them.
390
+ const mergedUsage = usage || subUsage.inputTokens || subUsage.outputTokens
391
+ ? {
392
+ inputTokens: (usage?.inputTokens ?? 0) + subUsage.inputTokens,
393
+ outputTokens: (usage?.outputTokens ?? 0) + subUsage.outputTokens,
394
+ }
395
+ : undefined;
295
396
  return {
296
397
  summary,
297
398
  stats,
298
399
  stderrTail,
299
- ...(usage ? { usage } : {}),
300
- ...(calls.length ? { callMetrics: calls } : {}),
400
+ ...(mergedUsage ? { usage: mergedUsage } : {}),
401
+ ...(mergedCalls.length ? { callMetrics: mergedCalls } : {}),
301
402
  };
302
403
  }
303
404
  finally {
405
+ await subagents?.stop();
304
406
  if (configHome) {
305
407
  // Lift the CLI session transcripts (`projects/`) out for short-lived retention BEFORE the
306
408
  // home is deleted — the credential lives at the home root, never in `projects/`, so this
@@ -348,38 +450,6 @@ function claudeUsage(raw) {
348
450
  return undefined;
349
451
  return { inputTokens: input, outputTokens: output };
350
452
  }
351
- /** Pull the text + reasoning out of a Claude `assistant` message's content blocks. */
352
- function claudeAssistantContent(content) {
353
- let text = '';
354
- let reasoning = '';
355
- let toolUses = 0;
356
- for (const block of content) {
357
- if (!isObject(block))
358
- continue;
359
- if (block.type === 'text' && typeof block.text === 'string')
360
- text += block.text;
361
- else if (block.type === 'thinking' && typeof block.thinking === 'string')
362
- reasoning += block.thinking;
363
- else if (block.type === 'tool_use')
364
- toolUses += 1;
365
- }
366
- return { text, reasoning, toolUses };
367
- }
368
- /**
369
- * Per-CALL token usage off a Claude `assistant` message's `usage` (this turn only, not
370
- * the cumulative `result` total). `inputTokens` counts every billed input bucket (fresh
371
- * + both cache buckets); `cachedInputTokens` is the cache share, surfaced separately.
372
- */
373
- function claudeCallUsage(raw) {
374
- if (!isObject(raw))
375
- return { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
376
- const cached = numberOf(raw.cache_read_input_tokens) + numberOf(raw.cache_creation_input_tokens);
377
- return {
378
- inputTokens: numberOf(raw.input_tokens) + cached,
379
- cachedInputTokens: cached,
380
- outputTokens: numberOf(raw.output_tokens),
381
- };
382
- }
383
453
  // ---------------------------------------------------------------------------
384
454
  // Codex
385
455
  // ---------------------------------------------------------------------------
@@ -422,10 +492,9 @@ export async function runCodex(opts) {
422
492
  await writeFile(join(codexHome, 'config.toml'), 'cli_auth_credentials_store = "file"\n', 'utf8');
423
493
  }
424
494
  // Codex has no system-prompt flag, so fold the composed role + best-practice
425
- // context into the prompt itself (Claude Code instead rides --append-system-prompt).
426
- const prompt = opts.systemPrompt
427
- ? `${opts.systemPrompt}\n\n---\n\n${opts.userPrompt}`
428
- : opts.userPrompt;
495
+ // context into the prompt itself (Claude Code instead rides --append-system-prompt,
496
+ // falling back to this same fold when the prompt overflows argv).
497
+ const prompt = foldSystemPrompt(opts.systemPrompt, opts.userPrompt);
429
498
  // Codex's `exec --json` is far thinner than Claude Code's stream: it surfaces only
430
499
  // flat assistant text and (on `token_count` events) the per-turn `last_token_usage`
431
500
  // plus a cumulative total. It never exposes the request transcript or structured
@@ -622,9 +691,6 @@ function codexLastTurnUsage(event) {
622
691
  return undefined;
623
692
  return { inputTokens: input, cachedInputTokens: cached, outputTokens: output };
624
693
  }
625
- function numberOf(value) {
626
- return typeof value === 'number' && Number.isFinite(value) ? value : 0;
627
- }
628
694
  /** Dispatch to the configured subscription harness runner. */
629
695
  export function runSubscriptionHarness(harness, opts) {
630
696
  return harness === 'claude-code' ? runClaudeCode(opts) : runCodex(opts);
@@ -0,0 +1,48 @@
1
+ import { redact } from './redact.js';
2
+ // Shared parsing of Claude Code's stream-json / session-transcript envelope. The parent
3
+ // runner (`agent-runner.ts`) reads these off the CLI's stdout; the subagent watcher
4
+ // (`subagents.ts`) reads the same shapes off the `subagents/*.jsonl` transcripts. Kept in
5
+ // one place so both read usage/content identically and the cycle between the two modules
6
+ // is broken.
7
+ export function isObject(value) {
8
+ return typeof value === 'object' && value !== null;
9
+ }
10
+ export function numberOf(value) {
11
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
12
+ }
13
+ /** Scrub any leased-credential occurrences from a telemetry body (no-op when none). */
14
+ export function redactBody(text, secrets) {
15
+ return secrets.length ? redact(text, secrets) : text;
16
+ }
17
+ /** Pull the text + reasoning out of a Claude `assistant` message's content blocks. */
18
+ export function claudeAssistantContent(content) {
19
+ let text = '';
20
+ let reasoning = '';
21
+ let toolUses = 0;
22
+ for (const block of content) {
23
+ if (!isObject(block))
24
+ continue;
25
+ if (block.type === 'text' && typeof block.text === 'string')
26
+ text += block.text;
27
+ else if (block.type === 'thinking' && typeof block.thinking === 'string')
28
+ reasoning += block.thinking;
29
+ else if (block.type === 'tool_use')
30
+ toolUses += 1;
31
+ }
32
+ return { text, reasoning, toolUses };
33
+ }
34
+ /**
35
+ * Per-CALL token usage off a Claude `assistant` message's `usage` (this turn only, not
36
+ * the cumulative `result` total). `inputTokens` counts every billed input bucket (fresh
37
+ * + both cache buckets); `cachedInputTokens` is the cache share, surfaced separately.
38
+ */
39
+ export function claudeCallUsage(raw) {
40
+ if (!isObject(raw))
41
+ return { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
42
+ const cached = numberOf(raw.cache_read_input_tokens) + numberOf(raw.cache_creation_input_tokens);
43
+ return {
44
+ inputTokens: numberOf(raw.input_tokens) + cached,
45
+ cachedInputTokens: cached,
46
+ outputTokens: numberOf(raw.output_tokens),
47
+ };
48
+ }
@@ -0,0 +1,67 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ // ADR 0026 D4 (paired assertion). A brand-new Claude Code config home would otherwise
4
+ // make `claude -p` block on the interactive onboarding / "trust this folder" /
5
+ // bypass-permissions acknowledgement prompts — which never get answered headlessly,
6
+ // hanging the job until the inactivity watchdog kills it with no output. We pre-seed a
7
+ // `.claude.json` marking those gates as already accepted.
8
+ //
9
+ // The hazard the ADR calls out: if a future CLI version adds a NEW first-run gate this
10
+ // set does not cover, the symptom is identical to a healthy-but-quiet subagent run (no
11
+ // stdout, low CPU), so the cold-start watchdog can't tell them apart on its own. This
12
+ // module centralises the pre-seeded keys as ONE source of truth and logs the pinned set
13
+ // (with the installed CLI version) so that, when the cold-start watchdog fires, an
14
+ // operator has the exact keys-vs-version pairing to diff against a new gate.
15
+ /**
16
+ * The onboarding gates we pre-accept in a fresh config home. Kept as a single constant so
17
+ * the write and the assertion below can never drift, and so a new gate is added in exactly
18
+ * one place. If the CLI renames/adds a key, this is where the fix lands.
19
+ */
20
+ export const ONBOARDING_PRESEED_KEYS = {
21
+ hasCompletedOnboarding: true,
22
+ bypassPermissionsModeAccepted: true,
23
+ hasTrustDialogAccepted: true,
24
+ };
25
+ /** Write the onboarding pre-seed into `<configHome>/.claude.json`. Best-effort; never throws. */
26
+ export async function writeOnboardingPreseed(configHome) {
27
+ await writeFile(join(configHome, '.claude.json'), JSON.stringify(ONBOARDING_PRESEED_KEYS), {
28
+ mode: 0o600,
29
+ }).catch(() => { });
30
+ }
31
+ /**
32
+ * Verify the pre-seed actually landed and log the pinned onboarding keys alongside the
33
+ * installed CLI version — the "one-line assertion after the pre-seed" from D4. It cannot
34
+ * introspect the CLI's true first-run gate set (the CLI never exposes it), so it does the
35
+ * two things it CAN do cheaply and deterministically: confirm every key we intended is
36
+ * present + truthy in the written file (catching a botched write), and emit a structured
37
+ * record pairing the keys with the CLI version so a future onboarding regression — surfaced
38
+ * by the cold-start watchdog as a silent, output-less start — is diffable against a new gate.
39
+ * Best-effort; never throws.
40
+ */
41
+ export async function assertOnboardingKeysCurrent(configHome, cliVersion, log) {
42
+ const expected = Object.keys(ONBOARDING_PRESEED_KEYS);
43
+ let parsed = {};
44
+ try {
45
+ parsed = JSON.parse(await readFile(join(configHome, '.claude.json'), 'utf8'));
46
+ }
47
+ catch {
48
+ log?.warn('onboarding pre-seed could not be read back after write', {
49
+ onboardingKeys: expected,
50
+ ...(cliVersion ? { cliVersion } : {}),
51
+ });
52
+ return;
53
+ }
54
+ const missing = expected.filter((k) => parsed[k] !== true);
55
+ if (missing.length > 0) {
56
+ log?.warn('onboarding pre-seed is missing expected keys', {
57
+ onboardingKeys: expected,
58
+ missing,
59
+ ...(cliVersion ? { cliVersion } : {}),
60
+ });
61
+ return;
62
+ }
63
+ log?.info('onboarding pre-seed applied', {
64
+ onboardingKeys: expected,
65
+ ...(cliVersion ? { cliVersion } : {}),
66
+ });
67
+ }
package/dist/runner.js CHANGED
@@ -5,6 +5,13 @@ function intEnv(value, fallback) {
5
5
  const n = value ? Number(value) : NaN;
6
6
  return Number.isFinite(n) && n > 0 ? n : fallback;
7
7
  }
8
+ /** Like {@link intEnv} but allows an explicit 0 (used to DISABLE a window). */
9
+ function intEnvAllowZero(value, fallback) {
10
+ if (value === undefined)
11
+ return fallback;
12
+ const n = Number(value);
13
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
14
+ }
8
15
  export function loadRunnerLimits(env = process.env) {
9
16
  return {
10
17
  // 60 minutes: generous headroom for serious multi-file coding tasks while
@@ -17,6 +24,9 @@ export function loadRunnerLimits(env = process.env) {
17
24
  // with git's own clear reason rather than this watchdog's "likely hung" message,
18
25
  // for any configured window. See the invariant note in git.ts.
19
26
  inactivityMs: intEnv(env.JOB_INACTIVITY_MS, 10 * 60_000),
27
+ // 2 minutes: comfortably longer than a warm agent's time-to-first-token yet far
28
+ // under the 10-minute inactivity kill, so a truly output-less start is flagged early.
29
+ coldStartMs: intEnvAllowZero(env.JOB_COLD_START_MS, 2 * 60_000),
20
30
  };
21
31
  }
22
32
  function toView(entry) {
@@ -154,7 +164,27 @@ export class JobRegistry {
154
164
  killReason ??= 'max-duration';
155
165
  controller.abort(new Error('max duration exceeded'));
156
166
  }, this.limits.maxDurationMs);
167
+ // ADR 0026 D4: a one-shot cold-start watchdog. If the job produces no activity within
168
+ // `coldStartMs`, record a structured diagnostic (a likely onboarding/auth wedge) so it
169
+ // is legible early — it does NOT abort the run (the inactivity watchdog still owns
170
+ // that). Cleared the moment the first activity arrives.
171
+ let sawActivity = false;
172
+ let coldStart;
173
+ if (this.limits.coldStartMs > 0) {
174
+ coldStart = setTimeout(() => {
175
+ if (sawActivity)
176
+ return;
177
+ const secs = Math.round(this.limits.coldStartMs / 1000);
178
+ const message = `agent produced no output ${secs}s after start; possible onboarding/auth wedge (phase: ${phase})`;
179
+ entry.coldStart = { atMs: Date.now(), message };
180
+ jobLog.warn('cold-start: no agent output', { afterMs: this.limits.coldStartMs, phase });
181
+ }, this.limits.coldStartMs);
182
+ }
157
183
  const heartbeat = () => {
184
+ if (!sawActivity) {
185
+ sawActivity = true;
186
+ clearTimeout(coldStart);
187
+ }
158
188
  entry.heartbeatAt = Date.now();
159
189
  resetInactivity();
160
190
  };
@@ -213,6 +243,7 @@ export class JobRegistry {
213
243
  finally {
214
244
  clearTimeout(inactivity);
215
245
  clearTimeout(cap);
246
+ clearTimeout(coldStart);
216
247
  entry.abort = undefined;
217
248
  entry.heartbeatAt = Date.now();
218
249
  }