@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/README.md +21 -8
- package/dist/agent-runner.js +167 -55
- package/dist/agent.js +63 -5
- package/dist/captured-command.js +16 -0
- package/dist/coding-agent.js +44 -2
- package/dist/dependency-install.js +245 -0
- package/dist/git.js +45 -0
- package/dist/job.js +4 -1
- package/dist/process-exit.js +18 -0
- package/dist/runner.js +91 -23
- package/dist/validation-checks.js +6 -2
- package/package.json +4 -4
- package/src/agent-runner.ts +203 -58
- package/src/agent.ts +73 -5
- package/src/captured-command.ts +17 -0
- package/src/coding-agent.ts +57 -2
- package/src/dependency-install.ts +333 -0
- package/src/git.ts +52 -0
- package/src/job.ts +17 -0
- package/src/process-exit.ts +19 -0
- package/src/runner.ts +124 -37
- package/src/validation-checks.ts +6 -2
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
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
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 (
|
|
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 (
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
+
}
|
package/src/validation-checks.ts
CHANGED
|
@@ -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
|
-
|
|
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
|