@cat-factory/executor-harness 1.74.0 → 1.76.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/src/runner.ts CHANGED
@@ -180,11 +180,11 @@ export interface JobView<TResult extends JobResultBase = JobResultBase> {
180
180
  * within {@link RunnerLimits.coldStartMs} of starting, a likely onboarding/auth wedge.
181
181
  * This does NOT fail the job (the inactivity/max-duration watchdogs still own that).
182
182
  *
183
- * Legibility today is via the per-job container log line emitted the moment it fires
184
- * (the ~2-minute early signal the ADR wants); this field additionally carries the
185
- * structured record on the GET /jobs/{id} view so an operator hitting the endpointor a
186
- * future engine-side consumer can read it without scraping logs. No engine code consumes
187
- * it yet, so surfacing it up through the runner-transport layer is deliberately deferred.
183
+ * Legibility is via the per-job container log line emitted the moment it fires (the
184
+ * ~2-minute early signal the ADR wants), this field on the GET /jobs/{id} view for an
185
+ * operator hitting the endpoint, and when the job goes on to fail a sentence folded into
186
+ * {@link detail}, which is the path that reaches the run without a new field on every
187
+ * transport hop. Surfacing it on a still-RUNNING step (the early warning) remains deferred.
188
188
  * Absent on a job that produced output promptly (the overwhelming common case). Sticky once set.
189
189
  */
190
190
  coldStart?: { atMs: number; message: string }
@@ -423,15 +423,21 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
423
423
  controller.abort(new Error('max duration exceeded'))
424
424
  }, this.limits.maxDurationMs)
425
425
 
426
+ // When the run was last heard from — the agent's own output, or a synthetic keep-alive beat
427
+ // from an activity-silent phase (see `silenceClause`, which is careful not to claim more than
428
+ // that). Unset until the first of either, which is both the cold-start watchdog's "has it
429
+ // spoken yet" test and, on a failure, the difference between a run that died mid-work and one
430
+ // that never got going at all.
431
+ let lastActivityAt: number | undefined
432
+
426
433
  // ADR 0026 D4: a one-shot cold-start watchdog. If the job produces no activity within
427
434
  // `coldStartMs`, record a structured diagnostic (a likely onboarding/auth wedge) so it
428
435
  // is legible early — it does NOT abort the run (the inactivity watchdog still owns
429
436
  // that). Cleared the moment the first activity arrives.
430
- let sawActivity = false
431
437
  let coldStart: ReturnType<typeof setTimeout> | undefined
432
438
  if (this.limits.coldStartMs > 0) {
433
439
  coldStart = setTimeout(() => {
434
- if (sawActivity) return
440
+ if (lastActivityAt !== undefined) return
435
441
  const secs = Math.round(this.limits.coldStartMs / 1000)
436
442
  const message = `agent produced no output ${secs}s after start; possible onboarding/auth wedge (phase: ${phase})`
437
443
  entry.coldStart = { atMs: Date.now(), message }
@@ -440,11 +446,9 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
440
446
  }
441
447
 
442
448
  const heartbeat = (): void => {
443
- if (!sawActivity) {
444
- sawActivity = true
445
- clearTimeout(coldStart)
446
- }
447
- entry.heartbeatAt = Date.now()
449
+ if (lastActivityAt === undefined) clearTimeout(coldStart)
450
+ lastActivityAt = Date.now()
451
+ entry.heartbeatAt = lastActivityAt
448
452
  resetInactivity()
449
453
  }
450
454
  resetInactivity()
@@ -506,13 +510,16 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
506
510
  // breadcrumb names where it hung (markPhase below would otherwise overwrite it).
507
511
  const failedInPhase = phase
508
512
  markPhase('failed')
509
- const { message, cause, detail } = this.describeFailure(
513
+ const { message, cause, detail } = this.describeFailure({
510
514
  killReason,
511
515
  error,
512
- failedInPhase,
516
+ phase: failedInPhase,
513
517
  lastTool,
514
518
  phaseTimingsMs,
515
- )
519
+ lastActivityAt,
520
+ startedAt: entry.startedAt,
521
+ coldStart: entry.coldStart,
522
+ })
516
523
  entry.state = 'failed'
517
524
  entry.error = message
518
525
  entry.failureCause = cause
@@ -540,48 +547,128 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
540
547
  * breadcrumb of where they hung, no longer a regex-stable phrase; a thrown error keeps its own
541
548
  * message and its structured cause when tagged (a git op → `git`, an upstream API call → `api`),
542
549
  * else `agent`. All strings are credential-scrubbed.
550
+ *
551
+ * `detail` is where the evidence the harness already holds but the one-line `error` has no room
552
+ * for lands: the phase breakdown, the {@link failureBreadcrumb} (last completed tool + how long
553
+ * the run had been silent), and the cold-start diagnostic when that watchdog recorded one. It is
554
+ * the only one of the three that reaches the run's failure record, so a diagnostic that isn't
555
+ * folded in here is effectively invisible outside the container log.
543
556
  */
544
- private describeFailure(
545
- killReason: 'inactivity' | 'max-duration' | undefined,
546
- error: unknown,
547
- phase: string,
548
- lastTool: { name: string; at: number } | undefined,
549
- phaseTimingsMs: Record<string, number>,
550
- ): { message: string; cause: FailureCause; detail: string } {
551
- // `lastTool` is the last tool that COMPLETED (a span is emitted on tool end), so when the
552
- // hang is inside a still-running tool the breadcrumb points at the prior one — worded
553
- // "last completed tool" so the reader knows the stuck call may be the next, unfinished one.
554
- const breadcrumb = lastTool
555
- ? `last completed tool ${lastTool.name} ${Math.round((Date.now() - lastTool.at) / 1000)}s ago`
556
- : 'no tool had completed yet'
557
- const phaseBreakdown = Object.entries(phaseTimingsMs)
557
+ private describeFailure(ctx: FailureContext): {
558
+ message: string
559
+ cause: FailureCause
560
+ detail: string
561
+ } {
562
+ const breadcrumb = failureBreadcrumb(ctx)
563
+ const phaseBreakdown = Object.entries(ctx.phaseTimingsMs)
558
564
  .map(([p, ms]) => `${p}=${Math.round(ms / 1000)}s`)
559
565
  .join(', ')
560
- if (killReason === 'inactivity') {
566
+ const cold = ctx.coldStart ? ` Cold start: ${ctx.coldStart.message}.` : ''
567
+ if (ctx.killReason === 'inactivity') {
561
568
  return {
562
569
  message: redactSecrets(
563
- `${inactivityAbortMessage(this.limits.inactivityMs)} (likely hung in ${phase} phase; ${breadcrumb})`,
570
+ `${inactivityAbortMessage(this.limits.inactivityMs)} (likely hung in ${ctx.phase} phase; ${breadcrumb})`,
564
571
  ),
565
572
  cause: 'inactivity-timeout',
566
- detail: redactSecrets(`Phase timings: ${phaseBreakdown || '(none)'}. ${breadcrumb}.`),
573
+ detail: redactSecrets(
574
+ `Phase timings: ${phaseBreakdown || '(none)'}. ${breadcrumb}.${cold}`,
575
+ ),
567
576
  }
568
577
  }
569
- if (killReason === 'max-duration') {
578
+ if (ctx.killReason === 'max-duration') {
570
579
  return {
571
580
  message: redactSecrets(maxDurationAbortMessage(this.limits.maxDurationMs)),
572
581
  cause: 'max-duration',
573
- detail: redactSecrets(`Phase timings: ${phaseBreakdown || '(none)'}. ${breadcrumb}.`),
582
+ detail: redactSecrets(
583
+ `Phase timings: ${phaseBreakdown || '(none)'}. ${breadcrumb}.${cold}`,
584
+ ),
574
585
  }
575
586
  }
576
- const raw = error instanceof Error ? error.message : String(error)
587
+ const raw = ctx.error instanceof Error ? ctx.error.message : String(ctx.error)
577
588
  // A thrown error tagged with a structured cause (a git op / an upstream API call) keeps
578
589
  // it; an untagged throw is a generic agent failure.
579
590
  return {
580
591
  message: redactSecrets(raw),
581
- cause: failureCauseOf(error) ?? 'agent',
592
+ cause: failureCauseOf(ctx.error) ?? 'agent',
582
593
  detail: redactSecrets(
583
- `${phaseBreakdown ? `Phase timings: ${phaseBreakdown}. ` : ''}Failed in ${phase} phase; ${breadcrumb}.`,
594
+ `${phaseBreakdown ? `Phase timings: ${phaseBreakdown}. ` : ''}Failed in ${ctx.phase} phase; ${breadcrumb}.${cold}`,
584
595
  ),
585
596
  }
586
597
  }
587
598
  }
599
+
600
+ /**
601
+ * Everything known about a job the moment it failed. One value rather than a growing positional
602
+ * list, and every field REQUIRED (explicitly `undefined` where absent) so a new failure dimension
603
+ * has to be threaded at the call site instead of silently defaulting away.
604
+ */
605
+ interface FailureContext {
606
+ /** Which watchdog killed it; unset when the run threw on its own. */
607
+ killReason: 'inactivity' | 'max-duration' | undefined
608
+ error: unknown
609
+ /** The phase the job was IN when it failed (captured before the `failed` transition). */
610
+ phase: string
611
+ /** The last tool that COMPLETED, when any had. */
612
+ lastTool: { name: string; at: number } | undefined
613
+ phaseTimingsMs: Record<string, number>
614
+ /** When the run last produced any output; `undefined` ⇒ it never produced a single byte. */
615
+ lastActivityAt: number | undefined
616
+ /** Job start — the silence window's origin when there was never any output. */
617
+ startedAt: number
618
+ /** The cold-start diagnostic, when that watchdog recorded one (see {@link JobView.coldStart}). */
619
+ coldStart: { atMs: number; message: string } | undefined
620
+ }
621
+
622
+ /**
623
+ * How long a run must have been quiet before the breadcrumb calls it out. Well above a slow
624
+ * model turn or a long tool call, so this fires on a genuine stall rather than on normal
625
+ * think time.
626
+ */
627
+ const SILENCE_BREADCRUMB_MS = 30_000
628
+
629
+ /**
630
+ * Where the job was, and how quiet it had gone, when it failed.
631
+ *
632
+ * The silence half matters because the exit status alone cannot distinguish a crash from a
633
+ * stall: an agent CLI that gives up on a failing upstream request exits NON-ZERO with nothing
634
+ * on stderr, which reads exactly like a crash — while its phase timing (minutes) and its
635
+ * silence (all of them) say "it never got an answer". Omitted when the run was producing
636
+ * output right up to the failure (the common case, where it is noise), and for an inactivity
637
+ * kill, whose own message already states the window it waited out.
638
+ */
639
+ function failureBreadcrumb(ctx: FailureContext): string {
640
+ const now = Date.now()
641
+ // `lastTool` is the last tool that COMPLETED (a span is emitted on tool end), so when the
642
+ // hang is inside a still-running tool the breadcrumb points at the prior one — worded
643
+ // "last completed tool" so the reader knows the stuck call may be the next, unfinished one.
644
+ const tool = ctx.lastTool
645
+ ? `last completed tool ${ctx.lastTool.name} ${Math.round((now - ctx.lastTool.at) / 1000)}s ago`
646
+ : 'no tool had completed yet'
647
+ return [tool, silenceClause(ctx, now)].filter(Boolean).join(', ')
648
+ }
649
+
650
+ /**
651
+ * The silence half of {@link failureBreadcrumb}; empty when silence isn't part of the story —
652
+ * which includes the fast failures (a missing env var, a git auth rejection) where the run was
653
+ * never going to have spoken yet and saying so would be pure noise.
654
+ *
655
+ * What it measures is the ACTIVITY channel, which carries the agent's own output plus the
656
+ * synthetic keep-alive beats the activity-silent phases feed the inactivity watchdog (dependency
657
+ * install, pre-PR validation, the reproduction proof, the frontend stand-up). So the wording
658
+ * claims no more than the channel supports — "no activity", not "no agent output": a run whose
659
+ * install phase beat every 30s and then died has been heard from, even though the agent itself
660
+ * never spoke. The window's origin is the job start, so it spans the `starting`/`clone` phases
661
+ * too; the phase breakdown sits beside it in the same `detail` for the reader who needs the
662
+ * split.
663
+ *
664
+ * Making this say "the AGENT last spoke" specifically would mean separating real output from
665
+ * liveness beats at the {@link RunOptions} seam, which is a change to what the cold-start and
666
+ * inactivity watchdogs fire on — deliberately not folded into this diagnostic-only fix.
667
+ */
668
+ function silenceClause(ctx: FailureContext, now: number): string {
669
+ if (ctx.killReason === 'inactivity') return ''
670
+ const silentMs = now - (ctx.lastActivityAt ?? ctx.startedAt)
671
+ if (silentMs < SILENCE_BREADCRUMB_MS) return ''
672
+ const secs = Math.round(silentMs / 1000)
673
+ return ctx.lastActivityAt === undefined ? `no activity at all in ${secs}s` : `silent for ${secs}s`
674
+ }
@@ -1,4 +1,4 @@
1
- import { runCapturedCommand } from './captured-command.js'
1
+ import { fencedOutput, runCapturedCommand } from './captured-command.js'
2
2
  import type { RunOptions } from './runner.js'
3
3
  import type { Logger } from './logger.js'
4
4
 
@@ -249,7 +249,11 @@ export function buildRepairPrompt(
249
249
  const reason = o.timedOut
250
250
  ? `timed out after ${Math.round((o.durationMs ?? 0) / 1000)}s`
251
251
  : `exited ${o.exitCode}`
252
- return `### ${o.label} ${reason}\n\n\`\`\`\n$ ${o.command}\n${body}\n\`\`\``
252
+ // Fenced through the shared helper: a failing lint or test routinely prints backticks
253
+ // (a rule quoting a template literal, a fixture echoing a fenced snippet), and a fixed
254
+ // three-tick fence closes on the first such run — spilling the rest of the failure, and
255
+ // the repair INSTRUCTIONS below it, into what the model reads as prose.
256
+ return `### ${o.label} — ${reason}\n\n${fencedOutput(`$ ${o.command}\n${body}`)}`
253
257
  })
254
258
  .join('\n\n')
255
259
  const remaining = report.maxAttempts - report.attempts