@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
package/src/git.ts CHANGED
@@ -604,14 +604,86 @@ export async function commitTrackedEdits(
604
604
  * --exclude-standard`). The harness deliberately never blanket-stages new files (the
605
605
  * agent owns commit selection), so this is exactly what {@link commitTrackedEdits}
606
606
  * does NOT capture — a NEW file the agent created but forgot to commit. The caller
607
- * surfaces it as a warning so that silent loss is at least observable in the logs.
607
+ * surfaces it as a warning, and the salvage commits it, so that loss is at least observable.
608
+ *
609
+ * `-z` for the reason given on {@link splitNulPaths}: the default output C-QUOTES any path git
610
+ * considers unusual, and a quoted path is not the name of a file. The salvage stages exactly
611
+ * what this returns, so a single accented filename would make its one `git add` exit 128 and
612
+ * discard the whole all-or-nothing salvage.
608
613
  */
609
614
  export async function listUntrackedFiles(dir: string, signal?: AbortSignal): Promise<string[]> {
610
- const out = await git(['ls-files', '--others', '--exclude-standard'], { cwd: dir, signal })
611
- return out
612
- .split('\n')
613
- .map((line) => line.replace(/\r$/, '').trim())
614
- .filter((path) => path !== '')
615
+ return splitNulPaths(
616
+ await git(['ls-files', '--others', '--exclude-standard', '-z'], { cwd: dir, signal }),
617
+ )
618
+ }
619
+
620
+ /**
621
+ * Split git's NUL-delimited path output into real, unescaped paths.
622
+ *
623
+ * Every path-listing git command here passes `-z`, and this is why: without it git renders a
624
+ * path containing a non-ASCII byte, a quote, a backslash or a newline as a C-QUOTED STRING
625
+ * (`"caf\303\251.ts"`), quotes and octal escapes included. That string is not a filename — feed
626
+ * it back to `git add` and the command exits 128 with `pathspec ... did not match any files`, and
627
+ * `stat` on it reports nothing. `-z` turns the quoting off entirely (a NUL cannot occur in a path,
628
+ * so no escaping is needed) and is the ONLY setting that is correct for every path: `core.quotePath
629
+ * =false` covers the non-ASCII case alone and still quotes the other three.
630
+ *
631
+ * A trailing NUL leaves an empty final field, which is dropped along with any other blank.
632
+ */
633
+ function splitNulPaths(out: string): string[] {
634
+ return out.split('\0').filter((path) => path !== '')
635
+ }
636
+
637
+ /**
638
+ * The raw `git status --porcelain -z --untracked-files=all` output for `dir` — every path git
639
+ * considers changed, with untracked files enumerated INDIVIDUALLY rather than collapsed to their
640
+ * directory.
641
+ *
642
+ * The raw string, not a parsed list, because its two consumers want different things from it and
643
+ * the parse ({@link changedPathsFromPorcelain}) is pure and shared: the workspace probe wants "is
644
+ * anything here at all", the salvage wants the paths themselves. Gitignored paths are absent by
645
+ * construction, which is what keeps a dependency install from reading as agent progress.
646
+ *
647
+ * NOTHING IS STAGED, unlike {@link hasAgentChanges}: this runs mid-flight, while the agent is
648
+ * still working, so a `git add -A` here would silently stage files the agent had not chosen and
649
+ * change what a later `commitTrackedEdits` captures.
650
+ */
651
+ export async function workingTreeStatus(dir: string, signal?: AbortSignal): Promise<string> {
652
+ return git(['status', '--porcelain', '-z', '--untracked-files=all'], { cwd: dir, signal })
653
+ }
654
+
655
+ /**
656
+ * Stage exactly `paths` and commit them with `message`, returning the new commit's sha (or null
657
+ * when git found nothing to commit — a path that vanished between listing and staging).
658
+ *
659
+ * Three separate things stop a path being read as something other than a path. `--` terminates
660
+ * the options, so one beginning with `-` cannot be read as a flag. Each path is a separate argv
661
+ * entry, so no shell ever sees them. And each is prefixed `:(literal)`, which is what `--` does
662
+ * NOT cover: everything after `--` is a PATHSPEC, not a filename, so its leading `:` is read as
663
+ * pathspec magic and its wildcards are matched as a glob. An agent-authored `:notes.txt` makes a
664
+ * bare `git add -- :notes.txt` exit 128 on `did not match any files` — and since this stages every
665
+ * path in ONE command, that one name discards the whole all-or-nothing salvage, exactly as an
666
+ * unquoted accented name did. `:(literal)` matches the entry as itself and nothing else.
667
+ *
668
+ * The caller has already decided WHICH paths belong; this only commits them.
669
+ */
670
+ export async function commitPaths(
671
+ dir: string,
672
+ paths: string[],
673
+ message: string,
674
+ signal?: AbortSignal,
675
+ ): Promise<string | null> {
676
+ if (paths.length === 0) return null
677
+ await git(['add', '--', ...paths.map(literalPathspec)], { cwd: dir, signal })
678
+ const staged = await git(['diff', '--cached', '--name-only'], { cwd: dir, signal })
679
+ if (staged.trim() === '') return null
680
+ await git(['commit', '-m', message], { cwd: dir, signal })
681
+ return headCommit(dir, signal)
682
+ }
683
+
684
+ /** One path as a pathspec that matches only itself — see {@link commitPaths} for why. */
685
+ function literalPathspec(path: string): string {
686
+ return `:(literal)${path}`
615
687
  }
616
688
 
617
689
  /**
@@ -624,14 +696,12 @@ export async function listUntrackedFiles(dir: string, signal?: AbortSignal): Pro
624
696
  * and enumerating them would cost a multi-megabyte listing to learn a single name.
625
697
  */
626
698
  export async function listUntrackedPaths(dir: string, signal?: AbortSignal): Promise<string[]> {
627
- const out = await git(
628
- ['ls-files', '--others', '--exclude-standard', '--directory', '--no-empty-directory'],
629
- { cwd: dir, signal },
699
+ return splitNulPaths(
700
+ await git(
701
+ ['ls-files', '--others', '--exclude-standard', '--directory', '--no-empty-directory', '-z'],
702
+ { cwd: dir, signal },
703
+ ),
630
704
  )
631
- return out
632
- .split('\n')
633
- .map((line) => line.replace(/\r$/, '').trim())
634
- .filter((path) => path !== '')
635
705
  }
636
706
 
637
707
  /**
@@ -796,21 +866,29 @@ export async function hasDiffAgainstBase(
796
866
  }
797
867
 
798
868
  /**
799
- * Parse the paths out of `git status --porcelain` (v1) output. Each line is
800
- * `XY <path>`, or `XY <old> -> <new>` for a rename/copy (we keep the new path);
801
- * git quotes paths with special characters, which we unquote. Blank lines are
802
- * skipped. Pure so the no-op detection can be tested without spawning git.
869
+ * Parse the paths out of `git status --porcelain -z` (v1) output.
870
+ *
871
+ * `-z` rather than the default, for the reason given on {@link splitNulPaths}: the default
872
+ * C-QUOTES any path git considers unusual, so `caf\u00e9.ts` arrives as the seven-character
873
+ * literal `"caf\303\251.ts"` and every consumer that then touches the file misses it. In `-z`
874
+ * every field is a real path and nothing is escaped.
875
+ *
876
+ * Each NUL-terminated field is `XY <path>`. A rename or copy (`R`/`C` in either status column)
877
+ * spends a SECOND field on its original path, which is consumed and dropped: we keep the new
878
+ * path, the one that now exists in the tree. Pure so the no-op detection and the workspace
879
+ * probe's sentinel rule can be tested without spawning git.
803
880
  */
804
881
  export function changedPathsFromPorcelain(status: string): string[] {
882
+ const fields = status.split('\0')
805
883
  const paths: string[] = []
806
- for (const raw of status.split('\n')) {
807
- const line = raw.replace(/\r$/, '')
808
- if (line.trim() === '') continue
809
- let path = line.slice(3)
810
- const arrow = path.indexOf(' -> ')
811
- if (arrow !== -1) path = path.slice(arrow + 4)
812
- path = path.trim().replace(/^"(.*)"$/, '$1')
813
- if (path) paths.push(path)
884
+ for (let index = 0; index < fields.length; index++) {
885
+ const entry = fields[index] ?? ''
886
+ if (entry === '') continue
887
+ // `XY ` then the path. A field too short to hold both is not a status entry (a stray
888
+ // trailing fragment), so there is no path in it to keep.
889
+ if (entry.length <= 3) continue
890
+ if (entry[0] === 'R' || entry[0] === 'C' || entry[1] === 'R' || entry[1] === 'C') index++
891
+ paths.push(entry.slice(3))
814
892
  }
815
893
  return paths
816
894
  }
@@ -824,7 +902,7 @@ export function changedPathsFromPorcelain(status: string): string[] {
824
902
  */
825
903
  export async function hasAgentChanges(dir: string, signal?: AbortSignal): Promise<boolean> {
826
904
  await git(['add', '-A'], { cwd: dir, signal })
827
- const status = await git(['status', '--porcelain'], { cwd: dir, signal })
905
+ const status = await git(['status', '--porcelain', '-z'], { cwd: dir, signal })
828
906
  return changedPathsFromPorcelain(status).length > 0
829
907
  }
830
908
 
@@ -0,0 +1,203 @@
1
+ import { ProgressGuard, type ProgressGuardLimits } from './progress-guard.js'
2
+ import type { WorkspaceProbe } from './workspace-probe.js'
3
+ import { log as defaultLog, type Logger } from './logger.js'
4
+
5
+ // The bridge between the SYNCHRONOUS {@link ProgressGuard} and the ASYNCHRONOUS evidence one of
6
+ // its bounds needs. Both runners feed the guard from a sync stream handler (`pi.ts`'s JSONL line
7
+ // reader, `agent-runner.ts`'s tool_result pairing) and neither can await inside one, so the driver
8
+ // owns the probe's lifetime instead. Shared rather than copied per runner: a decision this one
9
+ // ("has the run stopped making progress") must come out the same way on both, which is why
10
+ // `ProgressGuard` itself was extracted in the first place.
11
+
12
+ /** The whole cause chain of a thrown value, one line, so a probe failure names what actually broke. */
13
+ function describeCause(error: unknown): string {
14
+ const parts: string[] = []
15
+ let current: unknown = error
16
+ for (let depth = 0; depth < 8 && current !== undefined && current !== null; depth++) {
17
+ parts.push(current instanceof Error ? current.message : String(current))
18
+ current = current instanceof Error ? (current.cause as unknown) : undefined
19
+ }
20
+ return parts.filter((part) => part !== '').join(': ')
21
+ }
22
+
23
+ /** One run's guard plus the async settlement of the bound the stream alone cannot decide. */
24
+ export interface GuardDriver {
25
+ /** Feed one tool-call signal (name + error flag) — the claude-code runner's shape. */
26
+ observeSignal: (tool: { name: string; isError: boolean }) => void
27
+ /** Feed one parsed Pi `--mode json` event; a non-tool-call event is a no-op. */
28
+ observeEvent: (event: Record<string, unknown>) => void
29
+ /** Whether the guard has decided to kill this run. */
30
+ aborted: () => boolean
31
+ }
32
+
33
+ /**
34
+ * Drive one run's {@link ProgressGuard}, resolving the one verdict the stream cannot settle.
35
+ *
36
+ * An `abort` verdict fires {@link onAbort} immediately: every streak bound (consecutive errors /
37
+ * web calls / MCP calls / non-action calls) reads only the stream, so the stream is all the
38
+ * evidence there is.
39
+ *
40
+ * A `needs-workspace-evidence` verdict — the no-edit bound — starts a probe of the working tree
41
+ * and acts on its answer:
42
+ *
43
+ * - MUTATED: the run has changed the repository, whichever tool it used. The bound is satisfied
44
+ * permanently, exactly as a recognised edit-tool call satisfies it, so no second probe is ever
45
+ * made and the run continues.
46
+ * - CLEAN: abort, with the evidence in the message.
47
+ * - THREW: inconclusive, which is neither a pass nor a fail. The bound is re-armed (it can trip
48
+ * again after another `maxToolCallsWithoutEdit` action calls) and the cause is warned. Failing
49
+ * open is deliberate: killing a productive run is the expensive error, and the streak bounds,
50
+ * the inactivity watchdog and the wall-clock cap all still hold the run.
51
+ *
52
+ * With no probe wired the bound falls back to its old tool-name-only judgement, so a caller with
53
+ * no checkout to probe is no worse off than before.
54
+ */
55
+ export function createGuardDriver(deps: {
56
+ guard: ProgressGuard
57
+ probe?: WorkspaceProbe | undefined
58
+ /** Kill the run with this diagnostic. Called at most once. */
59
+ onAbort: (reason: string) => void
60
+ log?: Logger | undefined
61
+ }): GuardDriver {
62
+ const logger = deps.log ?? defaultLog
63
+ let aborted = false
64
+ let probing = false
65
+
66
+ const abort = (reason: string): void => {
67
+ if (aborted) return
68
+ aborted = true
69
+ deps.onAbort(reason)
70
+ }
71
+
72
+ const settleFromWorkspace = (provisional: string): void => {
73
+ const probe = deps.probe
74
+ if (!probe) {
75
+ // No checkout to probe: the tool-name reading is the only evidence there is, so act on it
76
+ // rather than leaving the bound permanently unenforceable.
77
+ abort(`${provisional} Aborting before it burns the whole run.`)
78
+ return
79
+ }
80
+ probing = true
81
+ void probe()
82
+ .then((evidence) => {
83
+ if (aborted) return
84
+ if (evidence.mutated) {
85
+ deps.guard.noteWorkspaceMutation()
86
+ logger.info('progress-guard: working tree shows the run IS changing the repository', {
87
+ headSha: evidence.headSha,
88
+ headMoved: evidence.headMoved,
89
+ dirtyPathCount: evidence.dirtyPathCount,
90
+ })
91
+ return
92
+ }
93
+ abort(
94
+ `${provisional} The working tree agrees: at ${evidence.headSha} there is nothing ` +
95
+ `uncommitted and HEAD has not moved since this pass began, so the repository is ` +
96
+ `unchanged. Aborting before it burns the whole run.`,
97
+ )
98
+ })
99
+ .catch((error: unknown) => {
100
+ if (aborted) return
101
+ deps.guard.rearmNoEditBound()
102
+ logger.warn('progress-guard: workspace probe failed; treating it as inconclusive', {
103
+ error: describeCause(error),
104
+ })
105
+ })
106
+ .finally(() => {
107
+ probing = false
108
+ })
109
+ }
110
+
111
+ const act = (verdict: ReturnType<ProgressGuard['observeSignal']>): void => {
112
+ if (aborted || !verdict) return
113
+ if (verdict.kind === 'needs-workspace-evidence') {
114
+ // A probe already in flight owns the current question. The guard itself suppresses a second
115
+ // `needs-workspace-evidence` until one is answered; this is the belt to that braces.
116
+ if (!probing) settleFromWorkspace(verdict.reason)
117
+ return
118
+ }
119
+ abort(verdict.reason)
120
+ }
121
+
122
+ return {
123
+ observeSignal: (tool) => {
124
+ if (aborted) return
125
+ act(deps.guard.observeSignal(tool))
126
+ },
127
+ observeEvent: (event) => {
128
+ if (aborted) return
129
+ act(deps.guard.observe(event))
130
+ },
131
+ aborted: () => aborted,
132
+ }
133
+ }
134
+
135
+ /** What {@link createClaudeProgressGuard} needs off the run options, and nothing more. */
136
+ export interface ClaudeGuardOptions {
137
+ guardLimits?: ProgressGuardLimits | undefined
138
+ expectsEdits?: boolean | undefined
139
+ workspaceProbe?: WorkspaceProbe | undefined
140
+ log?: Logger | undefined
141
+ }
142
+
143
+ /**
144
+ * No-progress guard on the claude-code CLI's own tool stream — the claude-code analogue of runPi's
145
+ * guard, which cannot see the CLI's internal turns. The caller remembers each `tool_use` id's name
146
+ * off the assistant turn (`rememberTool`) and hands the following user turn's content to
147
+ * `feedGuard`, which pairs each `tool_result`'s `is_error` with that name.
148
+ *
149
+ * The first abort trips it: the diagnostic is recorded (readable via `reason()`, which the catch
150
+ * surfaces over the generic abort message) and `guardAbort` fires, folded into streamCli's signal
151
+ * so a tripped guard kills the CLI the same way the external watchdog does. Disabled when the
152
+ * caller supplies no limits (only the external watchdog then bounds the run).
153
+ *
154
+ * Lives here rather than in `agent-runner.ts` because the async half of it — the workspace probe
155
+ * behind the no-edit bound — is the same collaborator the Pi runner drives.
156
+ */
157
+ export function createClaudeProgressGuard(opts: ClaudeGuardOptions): {
158
+ rememberTool: (id: string, name: string) => void
159
+ feedGuard: (content: unknown[]) => void
160
+ guardAbort: AbortController
161
+ reason: () => string | undefined
162
+ } {
163
+ const toolNames = new Map<string, string>()
164
+ const guardAbort = new AbortController()
165
+ let guardReason: string | undefined
166
+
167
+ const limits = opts.guardLimits
168
+ const driver = limits
169
+ ? createGuardDriver({
170
+ guard: new ProgressGuard(limits, opts.expectsEdits ?? true),
171
+ probe: opts.workspaceProbe,
172
+ log: opts.log,
173
+ onAbort: (reason) => {
174
+ guardReason = reason
175
+ guardAbort.abort()
176
+ },
177
+ })
178
+ : undefined
179
+
180
+ const feedGuard = (content: unknown[]): void => {
181
+ if (!driver || driver.aborted()) return
182
+ for (const block of content) {
183
+ if (!isRecord(block) || block.type !== 'tool_result') continue
184
+ const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined
185
+ const name = id ? toolNames.get(id) : undefined
186
+ if (id) toolNames.delete(id)
187
+ if (!name) continue
188
+ driver.observeSignal({ name, isError: block.is_error === true })
189
+ if (driver.aborted()) return
190
+ }
191
+ }
192
+
193
+ return {
194
+ rememberTool: (id, name) => toolNames.set(id, name),
195
+ feedGuard,
196
+ guardAbort,
197
+ reason: () => guardReason,
198
+ }
199
+ }
200
+
201
+ function isRecord(value: unknown): value is Record<string, unknown> {
202
+ return typeof value === 'object' && value !== null
203
+ }
@@ -5,6 +5,7 @@ import { parseAgentJob, parseInlineJob } from './job.js'
5
5
  import { handleAgent } from './agent.js'
6
6
  import { handleInline } from './inline.js'
7
7
  import { redactSecrets } from './git.js'
8
+ import { readDockerStatus } from './docker-status.js'
8
9
  import { JobRegistry, loadRunnerLimits, type JobResultBase, type RunOptions } from './runner.js'
9
10
  import { log } from './logger.js'
10
11
  import { HARNESS_VERSION } from './version.js'
@@ -128,10 +129,22 @@ const server = createServer((req, res) => {
128
129
  // fail loudly early (see version.ts). Unauthenticated like the rest of /health — the
129
130
  // version is not a secret. An old image predating this field simply omits it, which the
130
131
  // backend treats as a stale signal.
132
+ //
133
+ // `docker` is this container's own verdict on its daemon (see docker-status.ts). It rides
134
+ // /health because that is where an operator and a boot-time probe already look, and because
135
+ // the alternative was every agent discovering an absent daemon for itself, one failed
136
+ // compose command at a time. Reported, never enforced here: what REFUSES on it is the
137
+ // stand-up in agent.ts, which is the only place that knows a job wanted a daemon.
138
+ //
139
+ // Deliberately the BOOT record, not a live probe. /health is polled, and a probe per poll
140
+ // would spawn a process per poll to answer a question this endpoint is not the one to act
141
+ // on; the stand-up re-confirms a recorded absence at the moment it matters
142
+ // (`resolveDockerVerdict`), so a stale negative here never becomes a stale refusal there.
131
143
  return send(res, 200, {
132
144
  status: 'ok',
133
145
  ...(HARNESS_VERSION ? { version: HARNESS_VERSION } : {}),
134
146
  capabilities: HARNESS_BODY_CAPABILITIES,
147
+ docker: await readDockerStatus(),
135
148
  })
136
149
  }
137
150
  // All non-health endpoints are gated by the optional shared secret.
@@ -0,0 +1,218 @@
1
+ // The run's infra stand-up: the docker-compose dependencies a local-mode service declares, and
2
+ // the frontend build/serve + WireMock flow the UI-test runs use instead. Split out of agent.ts,
3
+ // which owns the agent MODES; this owns what surrounds a mode with the dependencies it needs and
4
+ // guarantees the matching teardown. `manageInfra` is the one entry point a mode calls.
5
+
6
+ import { execFile } from 'node:child_process'
7
+ import { promisify } from 'node:util'
8
+ import type { AgentInfraSpec, InfraSetupRecord, ServiceInfraSpec } from './job.js'
9
+ import {
10
+ type DockerProbe,
11
+ probeDockerServing,
12
+ readDockerStatus,
13
+ resolveDockerVerdict,
14
+ } from './docker-status.js'
15
+ import { standUpFrontend, tearDownFrontend } from './frontend-infra.js'
16
+ import { captureRedactedOutput, redactSecrets } from './redact.js'
17
+ import type { RunOptions } from './runner.js'
18
+ import type { Logger } from './logger.js'
19
+
20
+ const exec = promisify(execFile)
21
+
22
+ /**
23
+ * Bring the service's docker-compose dependencies up (local infra only). Best-effort:
24
+ * runs `docker compose -f <path> up -d --wait` in the checkout. A compose failure is logged
25
+ * and surfaced to the agent (as a prompt note) rather than failing the job — the agent can
26
+ * still run unit-level tests and report what it could. A no-op for ephemeral / no-infra /
27
+ * no-compose-path runs.
28
+ *
29
+ * A CONFIRMED absence of a Docker daemon short-circuits it: the container's own probe
30
+ * ({@link readDockerStatus}, recorded by `entrypoint.sh`) already knows there is nothing to
31
+ * talk to, so running compose against it would only turn a fact this container holds into a
32
+ * connection error the agent has to interpret. The record then carries `dockerAvailable: false`
33
+ * and the stated cause, which is what makes the Tester step say why it ran no infra instead of
34
+ * looking like a Tester that simply chose not to. Anything OTHER than a confirmed absence
35
+ * attempts as before (`DockerStatus.available` in docker-status.ts states why "undecided" is its
36
+ * own value).
37
+ *
38
+ * "Confirmed", not merely recorded: {@link resolveDockerVerdict} re-checks a recorded absence
39
+ * against a live daemon first, so a warm-pool container whose sidecar came up late is not
40
+ * latched into refusing infra that works. `probe` is that check, injected so the unit suite can
41
+ * state both answers on a machine that has its own daemon either way.
42
+ *
43
+ * Whether it succeeds or fails, the (redacted, bounded) command output is captured into a
44
+ * {@link InfraSetupRecord} returned alongside the prompt `note`, so the backend can surface
45
+ * the in-container dependency stand-up logs on the Tester step — the failure-class artifact
46
+ * the orchestrator-side provisioning logs can't see.
47
+ *
48
+ * Exported for the unit suite (like {@link buildInfraNotes}): the refusal branch is a decision
49
+ * this container makes about itself, and the acceptance suite can only exercise it on a machine
50
+ * where the daemon genuinely fails.
51
+ */
52
+ export async function standUpInfra(
53
+ dir: string,
54
+ infra: ServiceInfraSpec,
55
+ signal: AbortSignal | undefined,
56
+ logger: Logger,
57
+ probe: DockerProbe = probeDockerServing,
58
+ ): Promise<{ started: boolean; note?: string; record?: InfraSetupRecord }> {
59
+ if (infra.environment !== 'local' || infra.noInfraDependencies || !infra.composePath) {
60
+ return { started: false }
61
+ }
62
+ const startedAt = Date.now()
63
+ const recorded = await readDockerStatus()
64
+ const docker = await resolveDockerVerdict(recorded, probe)
65
+ if (docker.refusal) {
66
+ const note = `the dependencies could not be started: ${docker.refusal}`
67
+ logger.warn('agent(explore): infra stand-up refused, no docker daemon', {
68
+ composePath: infra.composePath,
69
+ dockerSource: recorded.source,
70
+ dockerReason: recorded.reason,
71
+ })
72
+ return {
73
+ started: false,
74
+ note,
75
+ record: {
76
+ started: false,
77
+ dockerAvailable: false,
78
+ composePath: infra.composePath,
79
+ at: Date.now(),
80
+ durationMs: Date.now() - startedAt,
81
+ error: redactSecrets(note),
82
+ },
83
+ }
84
+ }
85
+ try {
86
+ logger.info('agent(explore): standing up infra', { composePath: infra.composePath })
87
+ // Raise maxBuffer well above the 1MB default so a chatty compose stand-up can't fail the
88
+ // (best-effort) infra step with ENOBUFS; the captured output is tail-bounded on storage.
89
+ const { stdout, stderr } = await exec(
90
+ 'docker',
91
+ ['compose', '-f', infra.composePath, 'up', '-d', '--wait'],
92
+ { cwd: dir, signal, timeout: 5 * 60_000, maxBuffer: 16 * 1024 * 1024 },
93
+ )
94
+ const logs = captureRedactedOutput(stdout, stderr)
95
+ return {
96
+ started: true,
97
+ record: {
98
+ started: true,
99
+ dockerAvailable: true,
100
+ composePath: infra.composePath,
101
+ at: Date.now(),
102
+ durationMs: Date.now() - startedAt,
103
+ ...(logs ? { logs } : {}),
104
+ },
105
+ }
106
+ } catch (err) {
107
+ const note = err instanceof Error ? err.message : String(err)
108
+ logger.warn('agent(explore): infra stand-up failed', { error: note })
109
+ // `execFile` rejections carry the partial stdout/stderr on the error object — capture them
110
+ // so the stored logs explain the failure (a port clash, a pull-auth error, an exited
111
+ // dependency), not just the one-line exit message.
112
+ const e = err as { stdout?: unknown; stderr?: unknown }
113
+ const logs = captureRedactedOutput(e.stdout, e.stderr)
114
+ return {
115
+ started: false,
116
+ note,
117
+ record: {
118
+ started: false,
119
+ // A compose failure with a REACHABLE daemon: the two `false`s above and here are
120
+ // different diagnoses (nothing to talk to vs the stack itself did not come up), and
121
+ // only stating both keeps the second from being read as the first. Read off the
122
+ // RESOLVED verdict, so a container whose daemon came up after boot claims the daemon it
123
+ // actually reached rather than the one its boot record still denies.
124
+ ...(docker.available === true ? { dockerAvailable: true } : {}),
125
+ composePath: infra.composePath,
126
+ at: Date.now(),
127
+ durationMs: Date.now() - startedAt,
128
+ error: redactSecrets(note),
129
+ ...(logs ? { logs } : {}),
130
+ },
131
+ }
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Stand the run's infra up and return a single cleanup handle, dispatching on the spec's
137
+ * `kind`: the frontend UI-test flow (`kind: 'frontend'`) builds/serves the app + WireMock as
138
+ * processes (torn down by killing them); the default backend-service flow stands the
139
+ * docker-compose stack up (torn down with `docker compose down`). Unifying the two here keeps
140
+ * `runExploreMode` free of the branch and guarantees the matching teardown runs in its finally.
141
+ *
142
+ * `dir` is the clone ROOT; `workDir` is the service subtree (equal to `dir` when the run is not
143
+ * monorepo-scoped). The docker-compose stand-up runs at the root (its `composePath` is
144
+ * repo-relative), but the FRONTEND stand-up runs in `workDir`: a monorepo frontend's
145
+ * `package.json` / `outputDir` / `mocks/` all live under the service subtree, so installing,
146
+ * building, serving and seeding WireMock from the root would target the wrong directory.
147
+ */
148
+ export async function manageInfra(
149
+ dir: string,
150
+ workDir: string,
151
+ infra: AgentInfraSpec,
152
+ opts: RunOptions,
153
+ logger: Logger,
154
+ ): Promise<{
155
+ note?: string
156
+ serveUrl?: string
157
+ record?: InfraSetupRecord
158
+ cleanup: () => Promise<void>
159
+ }> {
160
+ if (infra.kind === 'frontend') {
161
+ // `onActivity` feeds the inactivity watchdog through the frontend build/serve stand-up,
162
+ // which (unlike docker-compose's 5-min-capped `up`) can run past the inactivity window.
163
+ // Runs in `workDir` so a monorepo frontend builds/serves from its own package subtree.
164
+ const fe = await standUpFrontend(workDir, infra, opts, logger)
165
+ return {
166
+ ...(fe.note ? { note: fe.note } : {}),
167
+ ...(fe.serveUrl ? { serveUrl: fe.serveUrl } : {}),
168
+ record: fe.record,
169
+ cleanup: () => tearDownFrontend(fe.processes, logger),
170
+ }
171
+ }
172
+ const standUp = await standUpInfra(dir, infra, opts.signal, logger)
173
+ return {
174
+ ...(standUp.note ? { note: standUp.note } : {}),
175
+ ...(standUp.record ? { record: standUp.record } : {}),
176
+ cleanup: () => tearDownInfra(dir, infra),
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Build the dynamic infra notes appended to the agent's user prompt from a stand-up outcome.
182
+ * A stand-up problem (a failed build / compose) is flagged as a concern to test around; a
183
+ * frontend serve URL points the UI tester at the app that was just built + served and pre-empts
184
+ * a live-backend CORS failure being mis-reported as an app defect. Pure (no IO) so the exact
185
+ * wording + ordering is unit-tested; returns the notes in order (problem first, serve URL next).
186
+ */
187
+ export function buildInfraNotes(managed: { note?: string; serveUrl?: string }): string[] {
188
+ const notes: string[] = []
189
+ if (managed.note) {
190
+ notes.push(
191
+ `standing the infra up reported a problem (${managed.note}). Test what you can and ` +
192
+ `flag any dependency-related gaps as concerns.`,
193
+ )
194
+ }
195
+ if (managed.serveUrl) {
196
+ notes.push(
197
+ `The frontend under test is built and served at ${managed.serveUrl}, with its other ` +
198
+ `backend upstreams handled by WireMock. Drive your UI tests against ${managed.serveUrl}. ` +
199
+ `If a call to a live backend fails with a CORS / cross-origin error, that is an infra ` +
200
+ `gap (the backend must allow the ${managed.serveUrl} origin), not an app defect — flag ` +
201
+ `it as a concern rather than a failing test.`,
202
+ )
203
+ }
204
+ return notes
205
+ }
206
+
207
+ /** Tear the docker-compose dependencies down (best-effort; a no-op when none were started). */
208
+ async function tearDownInfra(dir: string, infra: ServiceInfraSpec): Promise<void> {
209
+ if (infra.environment !== 'local' || infra.noInfraDependencies || !infra.composePath) return
210
+ try {
211
+ await exec('docker', ['compose', '-f', infra.composePath, 'down', '-v'], {
212
+ cwd: dir,
213
+ timeout: 2 * 60_000,
214
+ })
215
+ } catch {
216
+ // The container is ephemeral and torn down with the run anyway — ignore.
217
+ }
218
+ }
package/src/job.ts CHANGED
@@ -945,6 +945,16 @@ export interface GuardLimitsSpec {
945
945
  export interface InfraSetupRecord {
946
946
  /** Whether `docker compose up --wait` succeeded (the dependencies are up). */
947
947
  started: boolean
948
+ /**
949
+ * Whether this container had a Docker daemon to talk to at all, when it knows.
950
+ *
951
+ * The distinction `started` alone cannot make: a stack that failed to come up and a container
952
+ * with no daemon are the same `started: false` and opposite problems (one is the service's
953
+ * compose file, the other is the executor image or the sandbox it runs in). ABSENT means this
954
+ * container's probe reached no verdict — never assume `false` from absence, which is the exact
955
+ * mistake that let a daemon-less image read as an ordinary infra failure for months.
956
+ */
957
+ dockerAvailable?: boolean
948
958
  /** The repo-relative compose file that was stood up. */
949
959
  composePath?: string
950
960
  /** Epoch ms the stand-up attempt finished. */