@cat-factory/executor-harness 1.145.1 → 1.147.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.
@@ -2,6 +2,7 @@ import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { log } from './logger.js';
4
4
  import { harnessListenPort } from './harness-port.js';
5
+ import { probeDockerWorkload } from './docker-capability.js';
5
6
  // ---------------------------------------------------------------------------
6
7
  // What this machine actually has, probed ONCE per job and stated to the agent.
7
8
  //
@@ -31,6 +32,12 @@ import { harnessListenPort } from './harness-port.js';
31
32
  // a daemon this machine is CONFIGURED for but which has not answered yet is a fourth state
32
33
  // that resolves to `unknown`, never to the absence a refused connection looks like: the
33
34
  // image's daemon is started in the background and the job begins before it is ready.
35
+ // - A daemon that ANSWERS is still not a daemon that WORKS, which is the same mistake one level
36
+ // in. A rootless daemon nested in a sandbox serves while its snapshotter cannot mount any
37
+ // image layer, so `docker info` succeeds and `docker build` / `docker run` / `docker pull`
38
+ // all fail (issue #2120). Only a container that RAN settles that, so the reachable case is
39
+ // split by a real workload (docker-capability.ts) into `usable`, `unusable`, and a daemon
40
+ // that answered while the check itself could not be carried out.
34
41
  //
35
42
  // Deliberately NOT here: the agent's own tools (web search, file tools, MCP servers). Those are
36
43
  // the CLI's, they differ per harness, and each is already stated where it is true. Claiming one
@@ -260,10 +267,33 @@ export async function probeEnvironment(run, opts = {}) {
260
267
  showVersion: probe.showVersion,
261
268
  presence: toolPresence(await run(probe.command, probe.args)),
262
269
  }))),
263
- probeDockerDaemon(run, opts),
270
+ probeDockerCapability(run, opts),
264
271
  ]);
265
272
  return { tools, dockerDaemon, harnessPort: opts.harnessPort ?? harnessListenPort() };
266
273
  }
274
+ /**
275
+ * The daemon's full answer: whether one is reachable, and then whether it can run a container.
276
+ *
277
+ * The two steps are kept apart because they fail for unrelated reasons and only the FIRST has a
278
+ * cheap answer. A daemon nobody can reach has no workload to run, so the check that costs a
279
+ * container start is asked only where there is something to ask it of; a daemon that answered
280
+ * carries its server version into every one of the three states that follow it, because the
281
+ * agent reading the line is entitled to know which daemon the verdict is about.
282
+ */
283
+ async function probeDockerCapability(run, opts) {
284
+ const daemon = await probeDockerDaemon(run, opts);
285
+ if (daemon.status === 'absent')
286
+ return { status: 'absent' };
287
+ if (daemon.status === 'unknown')
288
+ return { status: 'unknown', reason: daemon.reason };
289
+ const server = daemon.version ? { server: daemon.version } : {};
290
+ const workload = await (opts.workload ?? probeDockerWorkload)(opts.signal);
291
+ if (workload.status === 'usable')
292
+ return { status: 'usable', ...server };
293
+ if (workload.status === 'unusable')
294
+ return { status: 'unusable', ...server, detail: workload.detail };
295
+ return { status: 'serving', ...server, reason: workload.reason };
296
+ }
267
297
  /**
268
298
  * Ask the daemon itself, and do not mistake a daemon that is STARTING for one that is not there.
269
299
  *
@@ -377,20 +407,46 @@ function harnessPortLine(port) {
377
407
  'check aimed at it passes without your service ever having run. Bind anything you start ' +
378
408
  'somewhere else.');
379
409
  }
380
- /** The Docker line, which says something different in each of the three cases. */
410
+ /**
411
+ * The Docker line, which says something different in each of the five cases, and is TOTAL over
412
+ * them: adding a state without deciding what an agent should do about it stops the build.
413
+ *
414
+ * Only `usable` may claim the commands work, and it may only be reached by having RUN one. The
415
+ * line that used to stand here made that claim off `docker info` alone, which is how every agent
416
+ * in a run was told, as fact, that a daemon which could not mount a single image layer would
417
+ * build and run one.
418
+ */
381
419
  function dockerDaemonLine(daemon) {
382
- if (daemon.status === 'present') {
383
- const server = daemon.version ? ` (server ${daemon.version})` : '';
384
- return (`A Docker daemon is reachable${server}: \`docker build\`, \`docker run\` and ` +
385
- '`docker compose up` work here.');
386
- }
387
- if (daemon.status === 'unknown') {
388
- return ('Whether a Docker daemon is reachable could not be determined ' +
389
- `(${daemon.reason}): try it if you need it, and do not read a failure as a defect in the work.`);
420
+ const server = 'server' in daemon && daemon.server ? ` (server ${daemon.server})` : '';
421
+ switch (daemon.status) {
422
+ case 'usable':
423
+ return (`A Docker daemon is reachable${server} and the platform ran a container on it: ` +
424
+ '`docker build`, `docker run` and `docker compose up` work here.');
425
+ case 'unusable':
426
+ return (`A Docker daemon is reachable${server} but it CANNOT run a container: the platform ` +
427
+ `built a one-layer image and tried to run it here, and that failed (${daemon.detail}). ` +
428
+ '`docker build`, `docker run`, `docker pull` of a multi-layer image and ' +
429
+ '`docker compose up` all fail for the same reason, so there is nothing to retry and no ' +
430
+ 'flag that works around it. Produce the Dockerfile or compose file you were asked for, ' +
431
+ 'say in one line that it could not be built or run here, and move on.');
432
+ case 'serving':
433
+ return (`A Docker daemon is reachable${server}, but whether it can actually build or run an ` +
434
+ `image was NOT established (${daemon.reason}). Reaching the daemon is not the same fact: ` +
435
+ 'a sandboxed one answers while being unable to mount any image layer. Try it if you need ' +
436
+ 'it, and do not read a failure as a defect in the work.');
437
+ case 'unknown':
438
+ return ('Whether a Docker daemon is reachable could not be determined ' +
439
+ `(${daemon.reason}): try it if you need it, and do not read a failure as a defect in the work.`);
440
+ case 'absent':
441
+ return ('NO Docker daemon is reachable: `docker build`, `docker run` and `docker compose up` ' +
442
+ 'will fail here whatever the CLI reports. Produce the Dockerfile or compose file you ' +
443
+ 'were asked for, say in one line that you could not build it here, and move on.');
444
+ default:
445
+ return unnamedCapability(daemon);
390
446
  }
391
- return ('NO Docker daemon is reachable: `docker build`, `docker run` and `docker compose up` will ' +
392
- 'fail here whatever the CLI reports. Produce the Dockerfile or compose file you were asked ' +
393
- 'for, say in one line that you could not build it here, and move on.');
447
+ }
448
+ function unnamedCapability(daemon) {
449
+ return `Whether a Docker daemon is reachable could not be determined (the platform reported an unrecognised verdict ${JSON.stringify(daemon)}): try it if you need it.`;
394
450
  }
395
451
  /**
396
452
  * Probe the machine and fold the inventory onto `systemPrompt`. THE composition point: the harness
@@ -408,12 +464,13 @@ function dockerDaemonLine(daemon) {
408
464
  */
409
465
  export async function appendEnvironmentInventory(systemPrompt, opts = {}) {
410
466
  const logger = opts.log ?? log;
467
+ // Everything that is not this function's OWN is forwarded by construction, rather than key by
468
+ // key. The list of copied keys silently dropped `workload`, whose whole point is that a suite
469
+ // can inject one: a test driving THIS entry point (the only one `handleAgent` uses) got the
470
+ // real probe instead, which starts a container on whatever machine the suite runs on.
471
+ const { log: _log, run, ...probeOptions } = opts;
411
472
  try {
412
- const inventory = await probeEnvironment(opts.run ?? spawnProbeRunner(opts.signal), {
413
- ...(opts.sleep ? { sleep: opts.sleep } : {}),
414
- ...(opts.daemonExpected === undefined ? {} : { daemonExpected: opts.daemonExpected }),
415
- ...(opts.harnessPort === undefined ? {} : { harnessPort: opts.harnessPort }),
416
- });
473
+ const inventory = await probeEnvironment(run ?? spawnProbeRunner(opts.signal), probeOptions);
417
474
  logger.info('agent: probed the environment', {
418
475
  installed: inventory.tools
419
476
  .filter((t) => t.presence.status === 'present')
@@ -6,6 +6,7 @@ import { handleAgent } from './agent.js';
6
6
  import { handleInline } from './inline.js';
7
7
  import { redactSecrets } from './git.js';
8
8
  import { readDockerStatus } from './docker-status.js';
9
+ import { reportedDockerWorkload } from './docker-capability.js';
9
10
  import { harnessListenPort } from './harness-port.js';
10
11
  import { JobRegistry, loadRunnerLimits } from './runner.js';
11
12
  import { log } from './logger.js';
@@ -122,11 +123,16 @@ const server = createServer((req, res) => {
122
123
  // would spawn a process per poll to answer a question this endpoint is not the one to act
123
124
  // on; the stand-up re-confirms a recorded absence at the moment it matters
124
125
  // (`resolveDockerVerdict`), so a stale negative here never becomes a stale refusal there.
126
+ //
127
+ // `workload` is the other half, and the reason the block used to mislead: what the record
128
+ // says is `serving`, and serving is not usable. It reports the last measurement any job
129
+ // took (docker-capability.ts) and NEVER takes one itself, for the same polling reason,
130
+ // which is why `unmeasured` is one of the words it can answer.
125
131
  return send(res, 200, {
126
132
  status: 'ok',
127
133
  ...(HARNESS_VERSION ? { version: HARNESS_VERSION } : {}),
128
134
  capabilities: HARNESS_BODY_CAPABILITIES,
129
- docker: await readDockerStatus(),
135
+ docker: { ...(await readDockerStatus()), workload: reportedDockerWorkload() },
130
136
  });
131
137
  }
132
138
  // All non-health endpoints are gated by the optional shared secret.
@@ -9,19 +9,22 @@ import type { Logger } from './logger.js';
9
9
  * still run unit-level tests and report what it could. A no-op for ephemeral / no-infra /
10
10
  * no-compose-path runs.
11
11
  *
12
- * A CONFIRMED absence of a Docker daemon short-circuits it: the container's own probe
13
- * ({@link readDockerStatus}, recorded by `entrypoint.sh`) already knows there is nothing to
14
- * talk to, so running compose against it would only turn a fact this container holds into a
15
- * connection error the agent has to interpret. The record then carries `dockerAvailable: false`
16
- * and the stated cause, which is what makes the Tester step say why it ran no infra instead of
17
- * looking like a Tester that simply chose not to. Anything OTHER than a confirmed absence
18
- * attempts as before (`DockerStatus.available` in docker-status.ts states why "undecided" is its
19
- * own value).
12
+ * A CONFIRMED absence of a USABLE Docker daemon short-circuits it: the container already knows
13
+ * compose cannot work, so running it would only turn a fact this container holds into an error
14
+ * the agent has to interpret. The record then carries the stated cause plus the two facts that
15
+ * decide where a human should look (`dockerAvailable`: was anything answering, `dockerWorkload`:
16
+ * what a container did on it), which is what makes the Tester step say why it ran no infra
17
+ * instead of looking like a Tester that simply chose not to. Anything OTHER than a confirmed
18
+ * negative attempts as before (`DockerStatus.available` in docker-status.ts states why
19
+ * "undecided" is its own value).
20
20
  *
21
- * "Confirmed", not merely recorded: {@link resolveDockerVerdict} re-checks a recorded absence
22
- * against a live daemon first, so a warm-pool container whose sidecar came up late is not
23
- * latched into refusing infra that works. `probe` is that check, injected so the unit suite can
24
- * state both answers on a machine that has its own daemon either way.
21
+ * "Confirmed", not merely recorded: {@link resolveDockerVerdict} re-checks the boot record
22
+ * against a live daemon first, so a warm-pool container whose sidecar came up late is not latched
23
+ * into refusing infra that works. It re-checks a recorded PRESENCE too, by running an actual
24
+ * container: a rootless daemon in a sandbox answers `docker version` while being unable to mount
25
+ * an image, and compose against that one died on a mount error inside the very mechanism that
26
+ * exists to explain why infra did not come up. `probe` is that check, injected so the unit suite
27
+ * can state every answer on a machine that has its own daemon either way.
25
28
  *
26
29
  * Whether it succeeds or fails, the (redacted, bounded) command output is captured into a
27
30
  * {@link InfraSetupRecord} returned alongside the prompt `note`, so the backend can surface
@@ -4,7 +4,7 @@
4
4
  // guarantees the matching teardown. `manageInfra` is the one entry point a mode calls.
5
5
  import { execFile } from 'node:child_process';
6
6
  import { promisify } from 'node:util';
7
- import { probeDockerServing, readDockerStatus, resolveDockerVerdict, } from './docker-status.js';
7
+ import { probeLiveDockerCapability, readDockerStatus, resolveDockerVerdict, } from './docker-status.js';
8
8
  import { standUpFrontend, tearDownFrontend } from './frontend-infra.js';
9
9
  import { captureRedactedOutput, redactSecrets } from './redact.js';
10
10
  const exec = promisify(execFile);
@@ -15,19 +15,22 @@ const exec = promisify(execFile);
15
15
  * still run unit-level tests and report what it could. A no-op for ephemeral / no-infra /
16
16
  * no-compose-path runs.
17
17
  *
18
- * A CONFIRMED absence of a Docker daemon short-circuits it: the container's own probe
19
- * ({@link readDockerStatus}, recorded by `entrypoint.sh`) already knows there is nothing to
20
- * talk to, so running compose against it would only turn a fact this container holds into a
21
- * connection error the agent has to interpret. The record then carries `dockerAvailable: false`
22
- * and the stated cause, which is what makes the Tester step say why it ran no infra instead of
23
- * looking like a Tester that simply chose not to. Anything OTHER than a confirmed absence
24
- * attempts as before (`DockerStatus.available` in docker-status.ts states why "undecided" is its
25
- * own value).
18
+ * A CONFIRMED absence of a USABLE Docker daemon short-circuits it: the container already knows
19
+ * compose cannot work, so running it would only turn a fact this container holds into an error
20
+ * the agent has to interpret. The record then carries the stated cause plus the two facts that
21
+ * decide where a human should look (`dockerAvailable`: was anything answering, `dockerWorkload`:
22
+ * what a container did on it), which is what makes the Tester step say why it ran no infra
23
+ * instead of looking like a Tester that simply chose not to. Anything OTHER than a confirmed
24
+ * negative attempts as before (`DockerStatus.available` in docker-status.ts states why
25
+ * "undecided" is its own value).
26
26
  *
27
- * "Confirmed", not merely recorded: {@link resolveDockerVerdict} re-checks a recorded absence
28
- * against a live daemon first, so a warm-pool container whose sidecar came up late is not
29
- * latched into refusing infra that works. `probe` is that check, injected so the unit suite can
30
- * state both answers on a machine that has its own daemon either way.
27
+ * "Confirmed", not merely recorded: {@link resolveDockerVerdict} re-checks the boot record
28
+ * against a live daemon first, so a warm-pool container whose sidecar came up late is not latched
29
+ * into refusing infra that works. It re-checks a recorded PRESENCE too, by running an actual
30
+ * container: a rootless daemon in a sandbox answers `docker version` while being unable to mount
31
+ * an image, and compose against that one died on a mount error inside the very mechanism that
32
+ * exists to explain why infra did not come up. `probe` is that check, injected so the unit suite
33
+ * can state every answer on a machine that has its own daemon either way.
31
34
  *
32
35
  * Whether it succeeds or fails, the (redacted, bounded) command output is captured into a
33
36
  * {@link InfraSetupRecord} returned alongside the prompt `note`, so the backend can surface
@@ -38,26 +41,42 @@ const exec = promisify(execFile);
38
41
  * this container makes about itself, and the acceptance suite can only exercise it on a machine
39
42
  * where the daemon genuinely fails.
40
43
  */
41
- export async function standUpInfra(dir, infra, signal, logger, probe = probeDockerServing) {
44
+ export async function standUpInfra(dir, infra, signal, logger, probe = probeLiveDockerCapability) {
42
45
  if (infra.environment !== 'local' || infra.noInfraDependencies || !infra.composePath) {
43
46
  return { started: false };
44
47
  }
45
48
  const startedAt = Date.now();
46
49
  const recorded = await readDockerStatus();
47
- const docker = await resolveDockerVerdict(recorded, probe);
50
+ const docker = await resolveDockerVerdict(recorded, {
51
+ probe,
52
+ ...(signal ? { signal } : {}),
53
+ logger,
54
+ });
48
55
  if (docker.refusal) {
49
56
  const note = `the dependencies could not be started: ${docker.refusal}`;
50
- logger.warn('agent(explore): infra stand-up refused, no docker daemon', {
57
+ logger.warn('agent(explore): infra stand-up refused, no usable docker daemon', {
51
58
  composePath: infra.composePath,
52
59
  dockerSource: recorded.source,
53
60
  dockerReason: recorded.reason,
61
+ // What the LIVE check found, which is the only place the second refusal cause exists: the
62
+ // boot record's own words for a daemon that answers and cannot run anything are `serving`
63
+ // and nothing else, so a log line carrying the record alone describes the wrong failure.
64
+ dockerWorkload: docker.workload?.status ?? 'unmeasured',
65
+ ...(docker.workload?.status === 'unusable' ? { dockerDetail: docker.workload.detail } : {}),
54
66
  });
55
67
  return {
56
68
  started: false,
57
69
  note,
58
70
  record: {
59
71
  started: false,
60
- dockerAvailable: false,
72
+ // NOT a flat `false`. Two refusals reach this branch and they have opposite fixes: with
73
+ // nothing answering, the executor image or the sandbox running it is what to go and look
74
+ // at; with a daemon that answers and cannot run a container, that daemon is up and an
75
+ // operator sent to restart it finds nothing wrong. `dockerAvailable` answers only the
76
+ // first question and `dockerWorkload` the second, so neither has to carry the other's
77
+ // fact (the same rule the compose-failure branch below states for its own `false`).
78
+ dockerAvailable: docker.daemon === true,
79
+ ...workloadRecord(docker.workload),
61
80
  composePath: infra.composePath,
62
81
  at: Date.now(),
63
82
  durationMs: Date.now() - startedAt,
@@ -76,6 +95,7 @@ export async function standUpInfra(dir, infra, signal, logger, probe = probeDock
76
95
  record: {
77
96
  started: true,
78
97
  dockerAvailable: true,
98
+ ...workloadRecord(docker.workload),
79
99
  composePath: infra.composePath,
80
100
  at: Date.now(),
81
101
  durationMs: Date.now() - startedAt,
@@ -96,12 +116,13 @@ export async function standUpInfra(dir, infra, signal, logger, probe = probeDock
96
116
  note,
97
117
  record: {
98
118
  started: false,
99
- // A compose failure with a REACHABLE daemon: the two `false`s above and here are
100
- // different diagnoses (nothing to talk to vs the stack itself did not come up), and
101
- // only stating both keeps the second from being read as the first. Read off the
102
- // RESOLVED verdict, so a container whose daemon came up after boot claims the daemon it
103
- // actually reached rather than the one its boot record still denies.
104
- ...(docker.available === true ? { dockerAvailable: true } : {}),
119
+ // A compose failure with a REACHABLE daemon, which is a third diagnosis again: the stack
120
+ // itself did not come up. Read off the RESOLVED verdict, so a container whose daemon came
121
+ // up after boot claims the daemon it actually reached rather than the one its boot record
122
+ // still denies, and OMITTED rather than `false` when nothing answered the live check,
123
+ // because the boot record's word for that is a hypothesis and not a measurement.
124
+ ...(docker.daemon === true ? { dockerAvailable: true } : {}),
125
+ ...workloadRecord(docker.workload),
105
126
  composePath: infra.composePath,
106
127
  at: Date.now(),
107
128
  durationMs: Date.now() - startedAt,
@@ -111,6 +132,20 @@ export async function standUpInfra(dir, infra, signal, logger, probe = probeDock
111
132
  };
112
133
  }
113
134
  }
135
+ /**
136
+ * What the live check measured, for the record the Tester step shows.
137
+ *
138
+ * Its own field beside `dockerAvailable` because the two answer different questions and only one
139
+ * of them has a boolean's worth of answers: a daemon either answered or it did not, while what a
140
+ * container DID on it is `usable`, `unusable`, or a check that could not be carried out. Absent
141
+ * when nothing was measured at all (an undecided boot record probes nothing), which is not the
142
+ * same as a check that ran and could not tell.
143
+ */
144
+ function workloadRecord(workload) {
145
+ if (!workload)
146
+ return {};
147
+ return { dockerWorkload: workload.status === 'unknown' ? 'undetermined' : workload.status };
148
+ }
114
149
  /**
115
150
  * Stand the run's infra up and return a single cleanup handle, dispatching on the spec's
116
151
  * `kind`: the frontend UI-test flow (`kind: 'frontend'`) builds/serves the app + WireMock as
package/dist/job.d.ts CHANGED
@@ -556,6 +556,16 @@ export interface InfraSetupRecord {
556
556
  * mistake that let a daemon-less image read as an ordinary infra failure for months.
557
557
  */
558
558
  dockerAvailable?: boolean;
559
+ /**
560
+ * What a real container DID on that daemon, when the platform measured it.
561
+ *
562
+ * The third diagnosis, and the one `dockerAvailable` structurally cannot carry: a rootless
563
+ * daemon nested in a sandbox answers throughout while unable to mount any image layer, so it is
564
+ * `dockerAvailable: true` and no stack can come up on it (issue #2120). Reporting that as an
565
+ * absent daemon sends a human to restart one that is already up. `undetermined` is a check that
566
+ * ran and could not tell; ABSENT means nothing was measured at all.
567
+ */
568
+ dockerWorkload?: 'usable' | 'unusable' | 'undetermined';
559
569
  /** The repo-relative compose file that was stood up. */
560
570
  composePath?: string;
561
571
  /** Epoch ms the stand-up attempt finished. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.145.1",
3
+ "version": "1.147.0",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,10 +25,10 @@
25
25
  "access": "public"
26
26
  },
27
27
  "devDependencies": {
28
- "@cat-factory/contracts": "0.334.0",
29
- "@cat-factory/kernel": "0.323.2",
30
- "@cat-factory/server": "0.307.9",
31
- "@cat-factory/spend": "0.16.26",
28
+ "@cat-factory/contracts": "0.338.0",
29
+ "@cat-factory/kernel": "0.327.0",
30
+ "@cat-factory/server": "0.310.1",
31
+ "@cat-factory/spend": "0.17.3",
32
32
  "@hono/node-server": "^2.1.1",
33
33
  "@types/node": "^26.4.0",
34
34
  "hono": "^4.13.5",
package/src/agent.ts CHANGED
@@ -187,8 +187,11 @@ export async function handleAgent(job: AgentJob, opts: RunOptions = {}): Promise
187
187
  // This sits on the critical path AHEAD of the clone, which is the cost of having one
188
188
  // composition point instead of one per mode (each mode owns its own clone, so there is no
189
189
  // single post-clone place to put this). The pass is sized for that: everything in it runs
190
- // concurrently, every probe is a call that answers in milliseconds or is wedged, and the one
191
- // deliberate wait is a single short retry for a daemon that is still starting.
190
+ // concurrently, and every probe either answers in milliseconds or is bounded. Two of them are
191
+ // deliberate waits rather than instant answers: one short retry for a daemon that is still
192
+ // starting, and, only once a daemon has answered, the container the platform runs to find out
193
+ // whether it can run one at all (`docker-capability.ts`, budgeted and memoised per container
194
+ // for a positive). Both take the job's signal, so an abandoned run stops paying at once.
192
195
 
193
196
  const staged: AgentJob = {
194
197
  ...job,