@cat-factory/executor-harness 1.132.3 → 1.134.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.
Files changed (55) hide show
  1. package/README.md +47 -0
  2. package/dist/agent-env.d.ts +17 -0
  3. package/dist/agent-env.js +47 -0
  4. package/dist/agent-runner.d.ts +11 -2
  5. package/dist/agent-runner.js +3 -48
  6. package/dist/agent.d.ts +0 -11
  7. package/dist/agent.js +7 -132
  8. package/dist/captured-command.d.ts +1 -1
  9. package/dist/captured-command.js +3 -2
  10. package/dist/coding-agent.d.ts +35 -0
  11. package/dist/coding-agent.js +213 -41
  12. package/dist/docker-status.d.ts +89 -0
  13. package/dist/docker-status.js +147 -0
  14. package/dist/frontend-infra.js +4 -3
  15. package/dist/git.d.ts +48 -5
  16. package/dist/git.js +93 -26
  17. package/dist/guard-driver.d.ts +71 -0
  18. package/dist/guard-driver.js +171 -0
  19. package/dist/harness-server.js +13 -0
  20. package/dist/infra-standup.d.ts +69 -0
  21. package/dist/infra-standup.js +182 -0
  22. package/dist/job.d.ts +10 -0
  23. package/dist/multi-repo-coding.d.ts +17 -0
  24. package/dist/multi-repo-coding.js +55 -8
  25. package/dist/pi-workspace.d.ts +11 -0
  26. package/dist/pi-workspace.js +47 -0
  27. package/dist/pi.d.ts +8 -0
  28. package/dist/pi.js +16 -9
  29. package/dist/progress-guard.d.ts +56 -10
  30. package/dist/progress-guard.js +84 -22
  31. package/dist/runner.d.ts +1 -1
  32. package/dist/salvage.d.ts +180 -0
  33. package/dist/salvage.js +289 -0
  34. package/dist/workspace-probe.d.ts +85 -0
  35. package/dist/workspace-probe.js +124 -0
  36. package/package.json +4 -4
  37. package/src/agent-env.ts +49 -0
  38. package/src/agent-runner.ts +14 -53
  39. package/src/agent.ts +7 -158
  40. package/src/captured-command.ts +3 -2
  41. package/src/coding-agent.ts +252 -44
  42. package/src/docker-status.ts +201 -0
  43. package/src/frontend-infra.ts +4 -3
  44. package/src/git.ts +104 -26
  45. package/src/guard-driver.ts +203 -0
  46. package/src/harness-server.ts +13 -0
  47. package/src/infra-standup.ts +218 -0
  48. package/src/job.ts +10 -0
  49. package/src/multi-repo-coding.ts +59 -8
  50. package/src/pi-workspace.ts +72 -0
  51. package/src/pi.ts +27 -12
  52. package/src/progress-guard.ts +110 -34
  53. package/src/runner.ts +1 -1
  54. package/src/salvage.ts +407 -0
  55. package/src/workspace-probe.ts +155 -0
@@ -66,6 +66,12 @@ import {
66
66
  withPrTemplateNote,
67
67
  type PrTemplateResolution,
68
68
  } from './pr-template.js'
69
+ import {
70
+ describeSalvage,
71
+ salvageUntrackedWork,
72
+ type SalvageDelivery,
73
+ type SalvageReport,
74
+ } from './salvage.js'
69
75
 
70
76
  // The shared skeleton for the container coding agents that clone a repo, run Pi
71
77
  // against it and push the result on a branch. The implementation (`/run`) and
@@ -254,6 +260,13 @@ export interface CodingAgentOutcome {
254
260
  * attached to a perfectly successful run and the PR still opens.
255
261
  */
256
262
  reproductionReport?: ReproductionReport
263
+ /**
264
+ * What became of the new files the agent created and never committed. Absent means there were
265
+ * none to consider; `status: 'refused'` or `'failed'` means work was left behind and is NOT in
266
+ * the push, which the backend must be able to tell a human rather than presenting the run as a
267
+ * clean pass.
268
+ */
269
+ salvage?: SalvageReport
257
270
  }
258
271
 
259
272
  /**
@@ -335,7 +348,7 @@ function createWorkBranchPusher(args: {
335
348
  logger: Logger
336
349
  signal: AbortSignal | undefined
337
350
  }): {
338
- pushWorkOnce: () => Promise<void>
351
+ pushWorkOnce: (override?: AbortSignal) => Promise<void>
339
352
  inFlightPush: () => Promise<void> | null
340
353
  checkpoint: ReturnType<typeof setInterval>
341
354
  } {
@@ -345,10 +358,16 @@ function createWorkBranchPusher(args: {
345
358
  // force push against. Starts unset even on a RESUMED branch: the tip we merely cloned is an
346
359
  // earlier run's work, so a rewrite of it is refused (and re-driven) rather than forced away.
347
360
  let publishedSha: string | undefined
348
- const pushWorkOnce = (): Promise<void> => {
361
+ // `override` replaces the RUN's signal for this one push. Every ordinary push rides the run's
362
+ // signal, so a watchdog kill stops it. The rescue push cannot: the run's signal is ABORTED on
363
+ // exactly the paths that need a rescue, and an aborted signal makes `execFile` reject before it
364
+ // spawns, so a rescue on it is a guaranteed no-op. See {@link withSalvagedWork}.
365
+ const pushWorkOnce = (override?: AbortSignal): Promise<void> => {
349
366
  if (pushInFlight) return pushInFlight
367
+ const pushSignal = override ?? signal
350
368
  pushInFlight = (async () => {
351
- if (!(await unpublishedWorkBranchTip({ dir, baseSha, publishedSha, signal }))) return
369
+ if (!(await unpublishedWorkBranchTip({ dir, baseSha, publishedSha, signal: pushSignal })))
370
+ return
352
371
  // The rule the lease is entitled to lives beside the push ({@link workBranchLease}); the
353
372
  // warn is here, because a withheld lease is how a rewrite this pass cannot claim fails the
354
373
  // push it is about to make, and the run's log is where that is read.
@@ -357,7 +376,7 @@ function createWorkBranchPusher(args: {
357
376
  branch: spec.pushBranch,
358
377
  baseSha,
359
378
  publishedSha,
360
- signal,
379
+ signal: pushSignal,
361
380
  onWithheld: (probe) =>
362
381
  logger.warn('coding-agent: push lease withheld, the branch dropped its pre-run tip', {
363
382
  baseSha,
@@ -365,7 +384,7 @@ function createWorkBranchPusher(args: {
365
384
  probe,
366
385
  }),
367
386
  })
368
- publishedSha = await pushBranch(dir, spec.pushBranch, spec.ghToken, signal, lease)
387
+ publishedSha = await pushBranch(dir, spec.pushBranch, spec.ghToken, pushSignal, lease)
369
388
  })().finally(() => {
370
389
  pushInFlight = null
371
390
  })
@@ -399,6 +418,50 @@ function createWorkBranchPusher(args: {
399
418
  return { pushWorkOnce, inFlightPush, checkpoint }
400
419
  }
401
420
 
421
+ /**
422
+ * Exclude the harness's own sentinel files from this checkout's git, and start tailing the
423
+ * follow-up one when the run streams follow-ups.
424
+ *
425
+ * Each sentinel is a file the PLATFORM writes into the agent's cwd (its effort self-assessment,
426
+ * its PR briefing, its follow-up items), so a `git add -A` by the agent would commit the
427
+ * platform's own bookkeeping into a customer's pull request. The exclude goes in
428
+ * `.git/info/exclude`, which is per-clone and never lands in the repo. `readEffortReport` also
429
+ * removes its file after the run, but that cannot un-stage a mid-run commit; only the exclude
430
+ * prevents one. A bare filename pattern matches at any depth, so a monorepo `workDir` is covered.
431
+ *
432
+ * The caller owns the returned interval's lifetime (it clears `followUpTick`). Extracted from
433
+ * {@link runCodingAgent} for the per-function line budget.
434
+ */
435
+ async function armCheckoutSentinels(args: {
436
+ dir: string
437
+ workDir: string
438
+ spec: CodingAgentSpec
439
+ logger: Logger
440
+ opts: RunOptions
441
+ }): Promise<{
442
+ followUpTailer: FollowUpTailer | undefined
443
+ followUpTick: ReturnType<typeof setInterval> | undefined
444
+ }> {
445
+ const { dir, workDir, spec, logger, opts } = args
446
+ const { signal } = opts
447
+ await excludeFromGit(dir, EFFORT_REPORT_FILE, signal)
448
+ await excludeFromGit(dir, PR_DESCRIPTION_FILE, signal)
449
+
450
+ // The follow-up sentinel lives in the agent's working directory (its cwd), where the prompt
451
+ // tells it to write; the other two are read from both the checkout root and the cwd.
452
+ const followUpTailer =
453
+ spec.streamFollowUps && opts.onFollowUp
454
+ ? new FollowUpTailer(join(workDir, FOLLOW_UPS_FILENAME), opts.onFollowUp, logger)
455
+ : undefined
456
+ if (!followUpTailer) return { followUpTailer: undefined, followUpTick: undefined }
457
+ await excludeFromGit(dir, FOLLOW_UPS_FILENAME, signal)
458
+ const followUpTick = setInterval(() => {
459
+ void followUpTailer.poll()
460
+ }, followUpPollIntervalMs())
461
+ followUpTick.unref?.()
462
+ return { followUpTailer, followUpTick }
463
+ }
464
+
402
465
  export async function runCodingAgent(
403
466
  spec: CodingAgentSpec,
404
467
  opts: RunOptions = {},
@@ -435,33 +498,16 @@ export async function runCodingAgent(
435
498
  const workDir = serviceDirectory ? join(dir, serviceDirectory) : dir
436
499
  if (serviceDirectory) await mkdir(workDir, { recursive: true })
437
500
 
438
- // Every container agent is asked to write its effort self-assessment to `.cat-effort.json`
439
- // in its cwd (the backend appends EFFORT_REPORT_GUIDANCE to every container prompt). Locally
440
- // exclude it from git — exactly like the follow-ups sentinel below — so the agent's own
441
- // `git add` can never stage it into the PR. `readEffortReport` also removes it after the run,
442
- // but that cannot un-stage a mid-run commit; the per-clone exclude is what prevents it. A bare
443
- // filename pattern matches the file in any subdirectory, so it covers a monorepo `workDir` too.
444
- await excludeFromGit(dir, EFFORT_REPORT_FILE, signal)
445
- // Same treatment for the agent-authored PR-description sentinel: excluded locally so the
446
- // agent's own `git add` can never stage the briefing into the PR it describes.
447
- await excludeFromGit(dir, PR_DESCRIPTION_FILE, signal)
448
-
449
- // Follow-up companion: tail the Coder's sentinel file and stream new items out on the
450
- // job view. Locally exclude it from git first so the agent's own `git add` can never
451
- // stage it and it never surfaces as an untracked leftover or in the PR. The sentinel
452
- // lives in the agent's working directory (its cwd), where the prompt tells it to write.
453
- const followUpTailer =
454
- spec.streamFollowUps && opts.onFollowUp
455
- ? new FollowUpTailer(join(workDir, FOLLOW_UPS_FILENAME), opts.onFollowUp, logger)
456
- : undefined
457
- let followUpTick: ReturnType<typeof setInterval> | undefined
458
- if (followUpTailer) {
459
- await excludeFromGit(dir, FOLLOW_UPS_FILENAME, signal)
460
- followUpTick = setInterval(() => {
461
- void followUpTailer.poll()
462
- }, followUpPollIntervalMs())
463
- followUpTick.unref?.()
464
- }
501
+ // The harness's own side-channel files in this checkout: excluded from git so the agent's
502
+ // `git add` can never stage one into the PR, and the follow-up one tailed while the agent
503
+ // works. See {@link armCheckoutSentinels}.
504
+ const { followUpTailer, followUpTick } = await armCheckoutSentinels({
505
+ dir,
506
+ workDir,
507
+ spec,
508
+ logger,
509
+ opts,
510
+ })
465
511
 
466
512
  // DEPENDENCY PREPOPULATION: install the service's dependencies into the checkout BEFORE the
467
513
  // agent's first turn, so it reads real packages instead of inferring capabilities from a
@@ -639,6 +685,23 @@ export async function runCodingAgent(
639
685
  agentRun,
640
686
  prTemplate,
641
687
  })
688
+ } catch (error) {
689
+ // The run was killed mid-flight: the progress guard tripped, a watchdog fired, or the
690
+ // container is going away. Everything the agent had not committed dies with the checkout,
691
+ // and on a greenfield task that is all of it. Salvage it onto the work branch and push,
692
+ // so a retry resumes on top of the work instead of starting over.
693
+ //
694
+ // Best-effort and non-masking: the ORIGINAL failure is what the run reports, so a salvage
695
+ // that itself fails may not replace it. What the salvage found is joined onto that
696
+ // failure's message instead, because "the run was aborted" and "its work is on the branch,
697
+ // reviewed by nobody" are one fact a person needs together.
698
+ //
699
+ // The checkpoint is stopped HERE rather than only in the `finally` below: it fires
700
+ // `pushWorkOnce`, which coalesces, so a checkpoint starting behind the rescue would be
701
+ // handed the rescue's push and a rescue starting behind a checkpoint would be handed a
702
+ // push made BEFORE the salvage commit existed — reporting as pushed a commit that is not.
703
+ clearInterval(checkpoint)
704
+ throw await withSalvagedWork(error, { dir, logger, pushWorkOnce, inFlightPush })
642
705
  } finally {
643
706
  // Safety net for the throw path (the happy path already cleared these above).
644
707
  clearInterval(checkpoint)
@@ -649,6 +712,141 @@ export async function runCodingAgent(
649
712
  )
650
713
  }
651
714
 
715
+ /**
716
+ * Prefix a run's summary with the salvage note, when there is one worth a human's attention.
717
+ *
718
+ * The test is the same in all three cases: did the agent produce something the push does NOT
719
+ * carry, which nothing else on a passing run would say. A refused or failed salvage is that, and
720
+ * so is a withheld secret-bearing file — the run looks clean and the file is not on the branch.
721
+ * A salvage that simply worked needs no note here: its files ARE in the push, and the commit
722
+ * message on the branch says where they came from.
723
+ */
724
+ function withSalvageNote(summary: string, salvage: SalvageReport): string {
725
+ const missedWork = salvage.status === 'refused' || salvage.status === 'failed'
726
+ if (!missedWork && (salvage.withheld?.length ?? 0) === 0) return summary
727
+ const note = describeSalvage(salvage)
728
+ return note ? `${note}\n\n${summary}` : summary
729
+ }
730
+
731
+ /**
732
+ * How long the rescue of an aborted run's work gets, on its own clock.
733
+ *
734
+ * Bounded because the run's own bounds no longer apply: the rescue deliberately runs OFF the run's
735
+ * signal (see {@link rescueSignal}), so without this a wedged git command would hold the container
736
+ * open until the platform reclaims it. Generous enough for a status, an add, a commit and a push
737
+ * over a slow network, and each git command inside it still carries its own tighter ceiling.
738
+ * Overridable via env for tests.
739
+ */
740
+ function salvageRescueMs(): number {
741
+ const n = Number(process.env.JOB_SALVAGE_RESCUE_MS)
742
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 120_000
743
+ }
744
+
745
+ /**
746
+ * The signal the rescue runs on: a FRESH one, never the run's.
747
+ *
748
+ * The rescue exists for the runs whose lifetime is already over — a watchdog fired, the guard
749
+ * tripped, the container is being evicted — and on those paths `opts.signal` is ABORTED. Node's
750
+ * `execFile` rejects on an already-aborted signal before it spawns anything, so passing it here
751
+ * makes every git call in the salvage and its push fail instantly: the rescue would be a
752
+ * guaranteed no-op in precisely the cases it was written for. Its own timeout is what bounds it
753
+ * instead.
754
+ */
755
+ function rescueSignal(): AbortSignal {
756
+ return AbortSignal.timeout(salvageRescueMs())
757
+ }
758
+
759
+ /**
760
+ * Salvage what an aborted run left uncommitted, push it, and return the error to rethrow with the
761
+ * salvage stated on it.
762
+ *
763
+ * Returns rather than throws so the caller's `throw` stays visible at the call site, and so this
764
+ * can never REPLACE the failure being reported: a salvage that throws is swallowed, because the
765
+ * reason the run died is strictly more useful than the reason its rescue did.
766
+ *
767
+ * The push is what makes the salvage worth anything — the commit lives in a container that is
768
+ * about to be reclaimed — and it is reported HONESTLY: a commit that could not be pushed is lost
769
+ * exactly as the uncommitted files would have been, so the note says so rather than naming a sha
770
+ * nobody will ever be able to fetch.
771
+ *
772
+ * Two things have to happen before the salvage, and the caller has already stopped the checkpoint
773
+ * interval for the first. The second is here: any push the checkpoint had IN FLIGHT is drained,
774
+ * because `pushWorkOnce` coalesces onto it and would otherwise hand the rescue a push that was
775
+ * made before the salvage commit existed.
776
+ *
777
+ * Exported for its test: every collaborator it needs is a parameter, so the ordering and the
778
+ * signal it pushes on can be asserted against a real repository without a container.
779
+ */
780
+ export async function withSalvagedWork(
781
+ error: unknown,
782
+ args: {
783
+ dir: string
784
+ logger: Logger
785
+ pushWorkOnce: (override?: AbortSignal) => Promise<void>
786
+ inFlightPush: () => Promise<void> | null
787
+ },
788
+ ): Promise<unknown> {
789
+ const cause = error instanceof Error ? error.message : String(error)
790
+ const signal = rescueSignal()
791
+ await drainInFlightPush(args.inFlightPush, args.logger)
792
+ const note = await salvageUntrackedWork({
793
+ dir: args.dir,
794
+ occasion: { kind: 'aborted', cause },
795
+ logger: args.logger,
796
+ signal,
797
+ })
798
+ .then(async (report) => {
799
+ if (report.status !== 'committed') return describeSalvage(report)
800
+ return describeSalvage(report, await deliverSalvage(args, signal))
801
+ })
802
+ .catch((salvageError: unknown) => {
803
+ args.logger.error('coding-agent: salvage of an aborted run failed', {
804
+ reason: salvageError instanceof Error ? salvageError.message : String(salvageError),
805
+ })
806
+ return undefined
807
+ })
808
+ if (!note) return error
809
+ if (error instanceof Error) {
810
+ error.message = `${error.message} ${note}`
811
+ return error
812
+ }
813
+ return new Error(`${cause} ${note}`)
814
+ }
815
+
816
+ /**
817
+ * Wait out the checkpoint push already running, so the rescue's own push is not coalesced onto it.
818
+ *
819
+ * Its outcome is irrelevant and never propagates: it was a best-effort checkpoint of work that
820
+ * predates the salvage, and the rescue is about to push again anyway.
821
+ */
822
+ async function drainInFlightPush(
823
+ inFlightPush: () => Promise<void> | null,
824
+ logger: Logger,
825
+ ): Promise<void> {
826
+ const pending = inFlightPush()
827
+ if (!pending) return
828
+ await pending.catch((error: unknown) => {
829
+ logger.warn('coding-agent: the checkpoint push in flight at the abort did not land', {
830
+ reason: error instanceof Error ? error.message : String(error),
831
+ })
832
+ })
833
+ }
834
+
835
+ /** Push the salvage commit, reporting whether it actually landed rather than assuming it did. */
836
+ async function deliverSalvage(
837
+ args: { logger: Logger; pushWorkOnce: (override?: AbortSignal) => Promise<void> },
838
+ signal: AbortSignal,
839
+ ): Promise<SalvageDelivery> {
840
+ try {
841
+ await args.pushWorkOnce(signal)
842
+ return { pushed: true }
843
+ } catch (error) {
844
+ const reason = error instanceof Error ? error.message : String(error)
845
+ args.logger.error('coding-agent: the salvage commit could not be pushed', { reason })
846
+ return { pushed: false, reason }
847
+ }
848
+ }
849
+
652
850
  /**
653
851
  * Clone (or RESUME an existing branch) into `dir`, fetch any read-only reference branches, and
654
852
  * capture the pre-run branch tip. Extracted from {@link runCodingAgent} so its body stays small;
@@ -812,7 +1010,7 @@ async function finalizeCodingRun(args: {
812
1010
  prTemplate,
813
1011
  } = args
814
1012
  const { signal } = opts
815
- const { summary, stats, stderrTail, usage, callMetrics, effortReport } = agentRun
1013
+ const { stats, stderrTail, usage, callMetrics, effortReport } = agentRun
816
1014
  let outcome: CodingAgentOutcome
817
1015
 
818
1016
  // Stop tailing the follow-up sentinel and flush any items written after the last
@@ -845,17 +1043,25 @@ async function finalizeCodingRun(args: {
845
1043
  const inflight = inFlightPush()
846
1044
  if (inflight) await inflight.catch(() => {})
847
1045
 
848
- // Surface (don't fail on) untracked, non-ignored files the agent left behind:
849
- // `commitTrackedEdits` only captures edits to ALREADY tracked files, so a NEW
850
- // file the agent created but forgot to commit is silently dropped. Logging it
851
- // makes that loss observable when a PR turns out to be missing a file.
852
- const leftover = await listUntrackedFiles(dir, signal)
853
- if (leftover.length > 0) {
854
- logger.warn('coding-agent: uncommitted new files left behind (not pushed)', {
855
- count: leftover.length,
856
- files: leftover.slice(0, 20),
857
- })
858
- }
1046
+ // Recover the untracked, non-ignored files the agent left behind. `commitTrackedEdits` above
1047
+ // only captures edits to ALREADY tracked files, so a NEW file the agent created and forgot to
1048
+ // commit used to be listed, warned about and dropped and on a greenfield task EVERY file is
1049
+ // new, which made that warning the whole deliverable going in the bin. Observable is not
1050
+ // recovered, so commit them. Guardrails (a dependency/build deny-list, a file-count and byte
1051
+ // bound, an all-or-nothing refusal over it) live in `salvage.ts`; this path is coding mode by
1052
+ // construction, which is the other rule it must obey.
1053
+ const salvage = await salvageUntrackedWork({
1054
+ dir,
1055
+ occasion: { kind: 'settled' },
1056
+ logger,
1057
+ ...(signal ? { signal } : {}),
1058
+ })
1059
+ // A salvage that COMMITTED needs no announcement: its files are in the push and its commit
1060
+ // message says where they came from. A refused or failed one means work the agent produced is
1061
+ // NOT in the pull request, on a run that otherwise reads as a clean pass — so say it in the
1062
+ // summary, which is the harness's own account of the run and already reaches the step a human
1063
+ // reads. The agent's text follows it, unchanged.
1064
+ const summary = withSalvageNote(agentRun.summary, salvage)
859
1065
 
860
1066
  // A fresh run produced work iff the branch advanced past its pre-run tip. A RESUMED
861
1067
  // run already carries prior work — UNLESS that branch turns out to have nothing ahead
@@ -886,6 +1092,7 @@ async function finalizeCodingRun(args: {
886
1092
  ...(usage ? { usage } : {}),
887
1093
  ...(callMetrics ? { callMetrics } : {}),
888
1094
  ...(effortReport ? { effortReport } : {}),
1095
+ ...(salvage.status === 'none' ? {} : { salvage }),
889
1096
  }
890
1097
  } else {
891
1098
  opts.onPhase?.('push')
@@ -901,6 +1108,7 @@ async function finalizeCodingRun(args: {
901
1108
  ...(callMetrics ? { callMetrics } : {}),
902
1109
  ...(effortReport ? { effortReport } : {}),
903
1110
  ...(prDescription ? { prDescription } : {}),
1111
+ ...(salvage.status === 'none' ? {} : { salvage }),
904
1112
  }
905
1113
  }
906
1114
 
@@ -0,0 +1,201 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { promisify } from 'node:util'
4
+
5
+ // What this container knows about its own Docker daemon, as recorded by `entrypoint.sh`.
6
+ //
7
+ // The Tester's local-mode infra stand-up (`docker compose up --wait`) is the only thing in the
8
+ // harness that needs a daemon, and for months there was none: the image installed
9
+ // `docker-ce-rootless-extras` (the wrappers that START a daemon) but never `docker-ce` (the
10
+ // daemon), and the entrypoint backgrounded the start in a subshell where its exit status was
11
+ // unobservable. Every local-infra Tester run degraded to a no-infra run, and the only trace was
12
+ // a compose error in a prompt note. This module is the answer that was missing: the entrypoint
13
+ // probes the daemon once and records the verdict, and everything that would otherwise ASSUME a
14
+ // daemon reads it instead.
15
+ //
16
+ // The recorded verdict describes BOOT, and a container outlives its boot, so nothing refuses on
17
+ // it unconfirmed: `resolveDockerVerdict` re-checks a recorded absence against a live daemon and
18
+ // keeps the record for what only the record holds, the cause and the daemon's own log tail.
19
+ //
20
+ // The three-valued shape is deliberate and is the point (CLAUDE.md, "Degrade loudly"): a daemon
21
+ // that FAILED and a daemon nobody asked about are different facts with different correct
22
+ // reactions, and collapsing them would either refuse stand-ups that work or silently attempt
23
+ // ones that cannot. Only a DECIDED `false` refuses.
24
+
25
+ /**
26
+ * Where `entrypoint.sh` records its verdict. The two halves of one contract: change this and the
27
+ * `DOCKER_STATUS_FILE` default in `entrypoint.sh` together. `HARNESS_DOCKER_STATUS_FILE`
28
+ * overrides both (the acceptance suite and the unit tests point them at a temp file).
29
+ */
30
+ export const DOCKER_STATUS_FILE = '/tmp/harness-docker-status.json'
31
+
32
+ /** Which daemon the verdict is about. Closed vocabulary, written by `entrypoint.sh`. */
33
+ export type DockerSource =
34
+ /** The rootless daemon this container starts for itself. */
35
+ | 'rootless'
36
+ /** A sidecar/external daemon a self-hosted pool wired in via `DOCKER_HOST`. */
37
+ | 'external'
38
+ /** No daemon in this image at all (no `dockerd` on PATH). */
39
+ | 'none'
40
+ /**
41
+ * Nothing recorded a verdict. NOT a failure: the native host-process transport
42
+ * (`LOCAL_NATIVE_AGENTS`) runs this harness with no entrypoint at all, on a developer's
43
+ * machine where Docker usually works fine.
44
+ */
45
+ | 'unreported'
46
+
47
+ /**
48
+ * The container's Docker verdict.
49
+ *
50
+ * `available` is THREE-valued on purpose. `undefined` means "not decided" — the entrypoint's
51
+ * bounded wait is still running, or nothing recorded anything (native mode) — and a caller must
52
+ * treat it as it behaved before this existed: attempt, and report what happened. `false` is a
53
+ * DECIDED absence and is the only value anything refuses on.
54
+ */
55
+ export interface DockerStatus {
56
+ available: boolean | undefined
57
+ source: DockerSource
58
+ /** Why, in the entrypoint's own closed vocabulary (`serving`/`failed`/`missing`/…). */
59
+ reason: string
60
+ /** A human detail for the failing cases: the dockerd log tail, or what was unreachable. */
61
+ detail?: string
62
+ }
63
+
64
+ /** The verdict when nothing recorded one (see {@link DockerSource} `unreported`). */
65
+ const UNREPORTED: DockerStatus = {
66
+ available: undefined,
67
+ source: 'unreported',
68
+ reason: 'no docker status was recorded for this harness process',
69
+ }
70
+
71
+ const SOURCES: readonly DockerSource[] = ['rootless', 'external', 'none', 'unreported']
72
+
73
+ /**
74
+ * Read the recorded verdict, or {@link UNREPORTED} when there is none.
75
+ *
76
+ * Defensive by design: the file crosses a shell→Node boundary, so an unreadable, truncated or
77
+ * malformed one answers "not decided" rather than throwing. That is the same disposition as an
78
+ * absent file, and it is the safe one — a parse bug here must not turn into a Tester that refuses
79
+ * to stand its dependencies up.
80
+ */
81
+ export async function readDockerStatus(
82
+ path: string = process.env.HARNESS_DOCKER_STATUS_FILE?.trim() || DOCKER_STATUS_FILE,
83
+ ): Promise<DockerStatus> {
84
+ let raw: string
85
+ try {
86
+ raw = await readFile(path, 'utf8')
87
+ } catch {
88
+ return UNREPORTED
89
+ }
90
+ let parsed: unknown
91
+ try {
92
+ parsed = JSON.parse(raw)
93
+ } catch {
94
+ return UNREPORTED
95
+ }
96
+ if (typeof parsed !== 'object' || parsed === null) return UNREPORTED
97
+ const record = parsed as Record<string, unknown>
98
+ const source = SOURCES.includes(record.source as DockerSource)
99
+ ? (record.source as DockerSource)
100
+ : 'unreported'
101
+ const detail = typeof record.detail === 'string' && record.detail ? record.detail : undefined
102
+ return {
103
+ available: typeof record.available === 'boolean' ? record.available : undefined,
104
+ source,
105
+ reason: typeof record.reason === 'string' && record.reason ? record.reason : UNREPORTED.reason,
106
+ ...(detail ? { detail } : {}),
107
+ }
108
+ }
109
+
110
+ /**
111
+ * The sentence a Tester (and the human reading its step) gets instead of a compose error, when
112
+ * the daemon is decidedly absent. It names the cause the agent could not have discovered and the
113
+ * consequence, because the agent's next move differs: with no daemon there is nothing to retry,
114
+ * and the useful run is the one that tests what it can and flags the dependency gap.
115
+ *
116
+ * TOTAL over {@link DockerSource}, deliberately. `unreported` is not a hypothetical arm: the
117
+ * reader above preserves a recorded `available: false` while degrading a source word this build
118
+ * does not know, so an absence whose source is `unreported` is exactly what a status file written
119
+ * by a NEWER entrypoint produces here. A ternary chain ending in the rootless arm answered that
120
+ * case by naming a daemon nobody said anything about: a guess, in the one sentence whose entire
121
+ * job is to tell a human which thing to go and fix. The `never` arm keeps the compile-time half:
122
+ * adding a source without a sentence stops building.
123
+ */
124
+ export function describeDockerAbsence(status: DockerStatus): string {
125
+ return status.detail
126
+ ? `${absenceCause(status.source)} (${status.detail})`
127
+ : absenceCause(status.source)
128
+ }
129
+
130
+ function absenceCause(source: DockerSource): string {
131
+ switch (source) {
132
+ case 'none':
133
+ return 'this executor image ships no Docker daemon'
134
+ case 'external':
135
+ return 'the external Docker daemon this container was pointed at is unreachable'
136
+ case 'rootless':
137
+ return 'this container could not start its rootless Docker daemon'
138
+ case 'unreported':
139
+ return 'no Docker daemon answered in this container, and the recorded verdict did not say which one was tried'
140
+ default:
141
+ return unnamedSource(source)
142
+ }
143
+ }
144
+
145
+ function unnamedSource(source: never): string {
146
+ return `no Docker daemon answered in this container (unrecognised source ${JSON.stringify(source)})`
147
+ }
148
+
149
+ /** Whether a daemon is answering RIGHT NOW. Injected so the unit suite can state either answer. */
150
+ export type DockerProbe = () => Promise<boolean>
151
+
152
+ /** A live probe may not outlast the thing it is guarding; a hung socket is an absent daemon here. */
153
+ const PROBE_TIMEOUT_MS = 10_000
154
+
155
+ const execFileAsync = promisify(execFile)
156
+
157
+ /**
158
+ * The default {@link DockerProbe}: `docker version` talks to the SERVER, unlike the client-only
159
+ * `docker --version`, which answers happily with no daemon at all.
160
+ */
161
+ export const probeDockerServing: DockerProbe = async () => {
162
+ try {
163
+ await execFileAsync('docker', ['version', '--format', '{{.Server.Version}}'], {
164
+ timeout: PROBE_TIMEOUT_MS,
165
+ })
166
+ return true
167
+ } catch {
168
+ return false
169
+ }
170
+ }
171
+
172
+ /** What a stand-up is entitled to conclude about the daemon at the moment it is about to run. */
173
+ export interface DockerVerdict {
174
+ /** Three-valued exactly as {@link DockerStatus.available}, and read the same way. */
175
+ available: boolean | undefined
176
+ /** Set only for a CONFIRMED absence: the sentence to refuse with. Absent means proceed. */
177
+ refusal?: string
178
+ }
179
+
180
+ /**
181
+ * Resolve what to do now, from what boot recorded plus what a daemon says today.
182
+ *
183
+ * `entrypoint.sh` probes once, at boot, within a bounded wait. A container outlives that: a warm
184
+ * pool serves many jobs from one, and a sidecar daemon that took longer than the wait allows is
185
+ * serving perfectly well by the second job. Refusing off the recorded verdict alone latches that
186
+ * container into refusing local infra that in fact works, for its whole life, with a stale
187
+ * sentence explaining why. So a recorded absence is a HYPOTHESIS here, and the live probe settles
188
+ * it; the recorded verdict is still what supplies the cause and the daemon's own log tail, which
189
+ * no probe can reconstruct.
190
+ *
191
+ * Only a recorded `false` is re-confirmed. "Not decided" keeps attempting exactly as before: the
192
+ * point of the third value is that nothing turns it into a refusal, and a probe here would.
193
+ */
194
+ export async function resolveDockerVerdict(
195
+ status: DockerStatus,
196
+ probe: DockerProbe = probeDockerServing,
197
+ ): Promise<DockerVerdict> {
198
+ if (status.available !== false) return { available: status.available }
199
+ if (await probe()) return { available: true }
200
+ return { available: false, refusal: describeDockerAbsence(status) }
201
+ }
@@ -5,6 +5,7 @@ import { join } from 'node:path'
5
5
  import type { FrontendInfraSpec, InfraSetupRecord } from './job.js'
6
6
  import type { RunOptions } from './runner.js'
7
7
  import { killChildProcess } from './process.js'
8
+ import { agentChildEnv } from './agent-env.js'
8
9
  import { pathExists } from './fs-utils.js'
9
10
  import { captureRedactedOutput, redactSecrets } from './redact.js'
10
11
  import { log, type Logger } from './logger.js'
@@ -134,7 +135,7 @@ export async function standUpFrontend(
134
135
  signal,
135
136
  timeout: 8 * 60_000,
136
137
  maxBuffer: 16 * 1024 * 1024,
137
- env: { ...process.env, ...jobEnv },
138
+ env: agentChildEnv(jobEnv),
138
139
  })
139
140
  pushOutput(installed.stdout, installed.stderr)
140
141
 
@@ -147,7 +148,7 @@ export async function standUpFrontend(
147
148
  signal,
148
149
  timeout: 12 * 60_000,
149
150
  maxBuffer: 16 * 1024 * 1024,
150
- env: { ...process.env, ...jobEnv, ...buildEnv },
151
+ env: agentChildEnv(jobEnv, buildEnv),
151
152
  })
152
153
  pushOutput(built.stdout, built.stderr)
153
154
 
@@ -305,7 +306,7 @@ function startServe(
305
306
  // Reserved names were already filtered from `infra.env` at parse; PORT wins last so
306
307
  // the health-check's port is authoritative even if a binding tried to set it.
307
308
  // (Spreading an undefined `infra.env` is a no-op, so no `?? {}` fallback is needed.)
308
- env: { ...process.env, ...infra.env, PORT: String(servePort) },
309
+ env: agentChildEnv(infra.env, { PORT: String(servePort) }),
309
310
  }),
310
311
  'serve',
311
312
  logger,