@cat-factory/executor-harness 1.50.6 → 1.50.10

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
@@ -223,6 +219,20 @@ export async function runClaudeCode(opts) {
223
219
  { role: 'user', content: opts.userPrompt },
224
220
  ];
225
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
+ };
226
236
  const onEvent = (event) => {
227
237
  const type = event.type;
228
238
  if (type === 'assistant' && isObject(event.message)) {
@@ -237,10 +247,14 @@ export async function runClaudeCode(opts) {
237
247
  block.name === 'TodoWrite' &&
238
248
  opts.onProgress) {
239
249
  const progress = todosToProgress(block.input?.todos);
240
- if (progress)
250
+ if (progress) {
251
+ sawTodoPlan = true;
241
252
  opts.onProgress(progress);
253
+ }
242
254
  }
243
255
  }
256
+ sliceTracker.onAssistant(content);
257
+ emitSliceProgress();
244
258
  // Record this call BEFORE appending its turn: the prompt is the history that
245
259
  // produced this response. The append-only array keeps each call's prompt a strict
246
260
  // prefix of the next, so the backend's telemetry chain delta-compresses cleanly.
@@ -261,8 +275,11 @@ export async function runClaudeCode(opts) {
261
275
  else if (type === 'user' && isObject(event.message)) {
262
276
  // tool_result blocks the harness fed back to the model — part of the next prompt.
263
277
  const content = event.message.content;
264
- if (Array.isArray(content))
278
+ if (Array.isArray(content)) {
279
+ sliceTracker.onUser(content);
280
+ emitSliceProgress();
265
281
  messages.push({ role: 'tool', content });
282
+ }
266
283
  }
267
284
  else if (type === 'result') {
268
285
  if (typeof event.result === 'string')
@@ -288,12 +305,12 @@ export async function runClaudeCode(opts) {
288
305
  // as already accepted so `-p` starts straight into the run. Best-effort: written
289
306
  // before the CLI starts; unknown keys are harmless if a CLI version ignores them.
290
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.
291
311
  if (configHome) {
292
- await writeFile(join(configHome, '.claude.json'), JSON.stringify({
293
- hasCompletedOnboarding: true,
294
- bypassPermissionsModeAccepted: true,
295
- hasTrustDialogAccepted: true,
296
- }), { mode: 0o600 }).catch(() => { });
312
+ await writeOnboardingPreseed(configHome);
313
+ await assertOnboardingKeysCurrent(configHome, process.env.CLAUDE_CLI_VERSION, opts.log);
297
314
  }
298
315
  // Repo-sourced Claude Skill (slice 2): install it as a native skill under the config dir's
299
316
  // `skills/<name>/` so the CLI discovers and can invoke it. Written to the isolated per-run
@@ -320,6 +337,19 @@ export async function runClaudeCode(opts) {
320
337
  }
321
338
  : { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken }),
322
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;
323
353
  try {
324
354
  const { stderrTail } = await streamCli({
325
355
  command: 'claude',
@@ -339,16 +369,40 @@ export async function runClaudeCode(opts) {
339
369
  ...appendArgs,
340
370
  ],
341
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).
342
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;
343
396
  return {
344
397
  summary,
345
398
  stats,
346
399
  stderrTail,
347
- ...(usage ? { usage } : {}),
348
- ...(calls.length ? { callMetrics: calls } : {}),
400
+ ...(mergedUsage ? { usage: mergedUsage } : {}),
401
+ ...(mergedCalls.length ? { callMetrics: mergedCalls } : {}),
349
402
  };
350
403
  }
351
404
  finally {
405
+ await subagents?.stop();
352
406
  if (configHome) {
353
407
  // Lift the CLI session transcripts (`projects/`) out for short-lived retention BEFORE the
354
408
  // home is deleted — the credential lives at the home root, never in `projects/`, so this
@@ -396,38 +450,6 @@ function claudeUsage(raw) {
396
450
  return undefined;
397
451
  return { inputTokens: input, outputTokens: output };
398
452
  }
399
- /** Pull the text + reasoning out of a Claude `assistant` message's content blocks. */
400
- function claudeAssistantContent(content) {
401
- let text = '';
402
- let reasoning = '';
403
- let toolUses = 0;
404
- for (const block of content) {
405
- if (!isObject(block))
406
- continue;
407
- if (block.type === 'text' && typeof block.text === 'string')
408
- text += block.text;
409
- else if (block.type === 'thinking' && typeof block.thinking === 'string')
410
- reasoning += block.thinking;
411
- else if (block.type === 'tool_use')
412
- toolUses += 1;
413
- }
414
- return { text, reasoning, toolUses };
415
- }
416
- /**
417
- * Per-CALL token usage off a Claude `assistant` message's `usage` (this turn only, not
418
- * the cumulative `result` total). `inputTokens` counts every billed input bucket (fresh
419
- * + both cache buckets); `cachedInputTokens` is the cache share, surfaced separately.
420
- */
421
- function claudeCallUsage(raw) {
422
- if (!isObject(raw))
423
- return { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
424
- const cached = numberOf(raw.cache_read_input_tokens) + numberOf(raw.cache_creation_input_tokens);
425
- return {
426
- inputTokens: numberOf(raw.input_tokens) + cached,
427
- cachedInputTokens: cached,
428
- outputTokens: numberOf(raw.output_tokens),
429
- };
430
- }
431
453
  // ---------------------------------------------------------------------------
432
454
  // Codex
433
455
  // ---------------------------------------------------------------------------
@@ -669,9 +691,6 @@ function codexLastTurnUsage(event) {
669
691
  return undefined;
670
692
  return { inputTokens: input, cachedInputTokens: cached, outputTokens: output };
671
693
  }
672
- function numberOf(value) {
673
- return typeof value === 'number' && Number.isFinite(value) ? value : 0;
674
- }
675
694
  /** Dispatch to the configured subscription harness runner. */
676
695
  export function runSubscriptionHarness(harness, opts) {
677
696
  return harness === 'claude-code' ? runClaudeCode(opts) : runCodex(opts);
package/dist/agent.js CHANGED
@@ -6,7 +6,7 @@ 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, fetchReferenceBranches, hasAgentChanges, headCommit, mergeBranch, openPullRequest, prepareExistingCheckout, pushBranch, reinitAndPush, unmergedPaths, } from './git.js';
9
+ import { cloneRepo, commitAll, conflictDiff, fetchPullRequestHead, fetchReferenceBranches, hasAgentChanges, headCommit, inferVcsProvider, mergeBranch, openPullRequest, prepareExistingCheckout, pushBranch, reinitAndPush, unmergedPaths, } from './git.js';
10
10
  import { makeDirClaimer, noChangesReason, runCodingAgent, runMultiRepoCoding, } from './coding-agent.js';
11
11
  import { acquireRepoCheckout, agentNeverActed, agentOutputTail, NEVER_ACTED_CAUSE, runAgentInWorkspace, unusableFinalAnswerCause, withWorkspace, } from './pi-workspace.js';
12
12
  import { diagnosticsSuffix, resolveStructuredOutput, } from './structured-output.js';
@@ -405,6 +405,28 @@ async function runExploreMode(job, opts) {
405
405
  fetched: fetched.length,
406
406
  });
407
407
  }
408
+ // The pr-reviewer reviews an EXISTING PR: fetch its HEAD into `origin/pr-head` so the
409
+ // read-only agent can inspect the PROPOSED code — files the PR adds (absent from this base
410
+ // checkout) and the head version of every modified file. The agent holds no git credential
411
+ // of its own, so this harness-side fetch (token out of band) is the only way the head is
412
+ // reachable; the prompt then diffs `origin/<base>...origin/pr-head`. Best-effort: on failure
413
+ // the review proceeds on the base checkout + the injected `.cat-context/pr-diff.md`.
414
+ if (job.reviewPrNumber !== undefined) {
415
+ const provider = job.repo.provider ?? inferVcsProvider(job.repo.cloneUrl);
416
+ const fetched = await fetchPullRequestHead({
417
+ dir,
418
+ number: job.reviewPrNumber,
419
+ provider,
420
+ ghToken: job.ghToken,
421
+ signal: opts.signal,
422
+ onSkip: (reason) => logger.warn('agent(explore): PR head fetch skipped', {
423
+ number: job.reviewPrNumber,
424
+ provider,
425
+ reason,
426
+ }),
427
+ });
428
+ logger.info('agent(explore): PR head fetch', { number: job.reviewPrNumber, fetched });
429
+ }
408
430
  // Optional infra stand-up (the tester): bring the service's docker-compose
409
431
  // dependencies up at the repo root for the duration of the run, tearing them down in
410
432
  // the `finally`. A stand-up failure is non-fatal — it's surfaced to the agent as a
@@ -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
+ }
package/dist/git.js CHANGED
@@ -691,6 +691,46 @@ export async function fetchReferenceBranches(opts) {
691
691
  await excludeFromGit(dir, `${REFERENCE_WORKTREE_DIR}/`, signal);
692
692
  return fetched;
693
693
  }
694
+ /** The local tracking ref a fetched PR/MR head lands on, so the reviewer reads `origin/pr-head`. */
695
+ export const PR_HEAD_REF = 'refs/remotes/origin/pr-head';
696
+ /**
697
+ * The `git fetch` refspec that maps a PR/MR's server-side HEAD ref onto {@link PR_HEAD_REF}. A
698
+ * PR head is a synthetic ref the host maintains, NOT part of a normal clone: GitHub exposes it at
699
+ * `refs/pull/<n>/head`, GitLab at `refs/merge-requests/<n>/head`. Pure so the provider branch is
700
+ * unit-tested without a network. The leading `+` forces the update (the ref is read-only here).
701
+ */
702
+ export function pullHeadRefspec(number, provider) {
703
+ const src = provider === 'gitlab' ? `refs/merge-requests/${number}/head` : `refs/pull/${number}/head`;
704
+ return `+${src}:${PR_HEAD_REF}`;
705
+ }
706
+ /**
707
+ * Fetch the reviewed PR/MR's HEAD into {@link PR_HEAD_REF} so a read-only reviewer can inspect the
708
+ * PROPOSED code — files the PR adds (absent from the base checkout) and the head version of every
709
+ * modified file — with `git diff origin/<base>...origin/pr-head`, `git show origin/pr-head:<path>`.
710
+ * The base clone never includes the pull ref, and the container agent holds no git credential of
711
+ * its own (the token lives with the harness), so the agent's own `git fetch pull/<n>/head` fails
712
+ * on a private repo — this harness-side fetch (which carries the token out of band via GIT_ASKPASS,
713
+ * exactly like {@link fetchReferenceBranches}) is what actually makes the head reachable.
714
+ *
715
+ * Best-effort: a fetch failure (a closed/deleted PR, a host without the pull ref, a transient
716
+ * network error) is reported via `onSkip` and swallowed — the review then proceeds on the base
717
+ * checkout + the injected diff, never fails. Returns whether the head was fetched.
718
+ */
719
+ export async function fetchPullRequestHead(opts) {
720
+ const { dir, number, provider, ghToken, signal, onSkip } = opts;
721
+ try {
722
+ await git(['fetch', '--no-tags', 'origin', pullHeadRefspec(number, provider)], {
723
+ cwd: dir,
724
+ signal,
725
+ env: await authEnv(ghToken),
726
+ });
727
+ return true;
728
+ }
729
+ catch (err) {
730
+ onSkip?.(err instanceof Error ? err.message : String(err));
731
+ return false;
732
+ }
733
+ }
694
734
  /**
695
735
  * Push the work branch to origin. The remote URL carries only the username, so
696
736
  * the token is supplied here via the askpass env (never in argv).
package/dist/job.js CHANGED
@@ -684,6 +684,7 @@ export function parseAgentJob(input) {
684
684
  const testSecrets = parseTestSecrets(o.testSecrets);
685
685
  const guardLimits = parseGuardLimits(o.guardLimits);
686
686
  const validation = parseValidationSpec(o.validation);
687
+ const reviewPrNumber = posInt(o.reviewPrNumber);
687
688
  const job = {
688
689
  jobId: str(o.jobId, 'jobId'),
689
690
  mode,
@@ -715,6 +716,7 @@ export function parseAgentJob(input) {
715
716
  ...(peerRepos.length ? { peerRepos } : {}),
716
717
  ...(referenceRepos.length ? { referenceRepos } : {}),
717
718
  ...(referenceBranches.length ? { referenceBranches } : {}),
719
+ ...(reviewPrNumber !== undefined ? { reviewPrNumber } : {}),
718
720
  ...(o.noChangesIsError === false ? { noChangesIsError: false } : {}),
719
721
  ...(o.persistentCheckout === true ? { persistentCheckout: true } : {}),
720
722
  ...(o.streamFollowUps === true ? { streamFollowUps: true } : {}),
@@ -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
  }