@cat-factory/executor-harness 1.145.1 → 1.149.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -8
- package/dist/agent.js +8 -2
- package/dist/docker-capability.d.ts +158 -0
- package/dist/docker-capability.js +510 -0
- package/dist/docker-command.d.ts +30 -0
- package/dist/docker-command.js +91 -0
- package/dist/docker-probe-image.d.ts +97 -0
- package/dist/docker-probe-image.js +283 -0
- package/dist/docker-status.d.ts +99 -19
- package/dist/docker-status.js +93 -36
- package/dist/environment-inventory.d.ts +57 -6
- package/dist/environment-inventory.js +149 -17
- package/dist/harness-server.js +7 -1
- package/dist/infra-standup.d.ts +15 -12
- package/dist/infra-standup.js +73 -23
- package/dist/job.d.ts +20 -0
- package/dist/redact.d.ts +15 -0
- package/dist/redact.js +18 -0
- package/package.json +5 -5
- package/src/agent.ts +8 -2
- package/src/docker-capability.ts +767 -0
- package/src/docker-command.ts +117 -0
- package/src/docker-probe-image.ts +328 -0
- package/src/docker-status.ts +148 -38
- package/src/environment-inventory.ts +216 -30
- package/src/harness-server.ts +7 -1
- package/src/infra-standup.ts +77 -23
- package/src/job.ts +20 -0
- package/src/redact.ts +19 -0
package/dist/docker-status.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
2
1
|
import { readFile } from 'node:fs/promises';
|
|
3
|
-
import {
|
|
2
|
+
import { probeDockerWorkload } from './docker-capability.js';
|
|
3
|
+
import { log } from './logger.js';
|
|
4
4
|
// What this container knows about its own Docker daemon, as recorded by `entrypoint.sh`.
|
|
5
5
|
//
|
|
6
6
|
// The Tester's local-mode infra stand-up (`docker compose up --wait`) is the only thing in the
|
|
@@ -13,8 +13,12 @@ import { promisify } from 'node:util';
|
|
|
13
13
|
// daemon reads it instead.
|
|
14
14
|
//
|
|
15
15
|
// The recorded verdict describes BOOT, and a container outlives its boot, so nothing refuses on
|
|
16
|
-
// it unconfirmed: `resolveDockerVerdict` re-checks
|
|
17
|
-
//
|
|
16
|
+
// it unconfirmed: `resolveDockerVerdict` re-checks it against a live daemon and keeps the record
|
|
17
|
+
// for what only the record holds, the cause and the daemon's own log tail. What it records is
|
|
18
|
+
// also only that a SOCKET answered, which is a weaker fact than any caller wants, so the live
|
|
19
|
+
// check RUNS A CONTAINER (docker-capability.ts) rather than settling for the daemon's word about
|
|
20
|
+
// itself. It still reports that weaker fact alongside, since a check that could not be carried
|
|
21
|
+
// out is what the boot record has to be read against, and nothing else establishes it.
|
|
18
22
|
//
|
|
19
23
|
// The three-valued shape is deliberate and is the point (CLAUDE.md, "Degrade loudly"): a daemon
|
|
20
24
|
// that FAILED and a daemon nobody asked about are different facts with different correct
|
|
@@ -106,42 +110,95 @@ function absenceCause(source) {
|
|
|
106
110
|
function unnamedSource(source) {
|
|
107
111
|
return `no Docker daemon answered in this container (unrecognised source ${JSON.stringify(source)})`;
|
|
108
112
|
}
|
|
109
|
-
/** A live probe may not outlast the thing it is guarding; a hung socket is an absent daemon here. */
|
|
110
|
-
const PROBE_TIMEOUT_MS = 10_000;
|
|
111
|
-
const execFileAsync = promisify(execFile);
|
|
112
113
|
/**
|
|
113
|
-
* The default {@link DockerProbe}:
|
|
114
|
-
*
|
|
114
|
+
* The default {@link DockerProbe}: the process-wide workload probe, which loads a one-layer
|
|
115
|
+
* image and runs a container from it, memoised per container.
|
|
116
|
+
*
|
|
117
|
+
* Named for what it answers. It was `probeDockerServing`, which is the fact this module exists to
|
|
118
|
+
* say is not enough.
|
|
115
119
|
*/
|
|
116
|
-
export const
|
|
117
|
-
try {
|
|
118
|
-
await execFileAsync('docker', ['version', '--format', '{{.Server.Version}}'], {
|
|
119
|
-
timeout: PROBE_TIMEOUT_MS,
|
|
120
|
-
});
|
|
121
|
-
return true;
|
|
122
|
-
}
|
|
123
|
-
catch {
|
|
124
|
-
return false;
|
|
125
|
-
}
|
|
126
|
-
};
|
|
120
|
+
export const probeLiveDockerCapability = (signal) => probeDockerWorkload(signal);
|
|
127
121
|
/**
|
|
128
|
-
*
|
|
122
|
+
* The sentence for a daemon that is serving and cannot run anything. It names what was tried,
|
|
123
|
+
* because "docker is unavailable" against a daemon the agent can see answering reads as a bug in
|
|
124
|
+
* the platform rather than as the sandbox limit it is.
|
|
125
|
+
*/
|
|
126
|
+
export function describeDockerUnusable(workload) {
|
|
127
|
+
return `this container's Docker daemon is reachable but cannot run a container (${workload.detail})`;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Resolve what to do now, from what boot recorded plus what the daemon can do today.
|
|
131
|
+
*
|
|
132
|
+
* `entrypoint.sh` probes once, at boot, within a bounded wait, and it probes for a SOCKET. Two
|
|
133
|
+
* things follow, and the branches below are one each.
|
|
129
134
|
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
* it; the recorded verdict is still what supplies the cause and the daemon's own log tail, which
|
|
136
|
-
* no probe can reconstruct.
|
|
135
|
+
* A recorded absence is a HYPOTHESIS. A container outlives its boot: a warm pool serves many jobs
|
|
136
|
+
* from one, and a sidecar daemon that took longer than the wait allows is serving perfectly well
|
|
137
|
+
* by the second job. Refusing off the record alone latches that container into refusing local
|
|
138
|
+
* infra that works, for its whole life, with a stale sentence explaining why. The record is still
|
|
139
|
+
* what supplies the cause and the daemon's own log tail, which no probe can reconstruct.
|
|
137
140
|
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
141
|
+
* A recorded PRESENCE is a hypothesis too, and that half was missing. `serving` is not `usable`:
|
|
142
|
+
* a rootless daemon in a sandbox answers while unable to mount any image layer, so compose ran
|
|
143
|
+
* and died on a mount error the agent then had to interpret. So the probe is consulted in both
|
|
144
|
+
* directions, and it runs a real container rather than asking the daemon about itself.
|
|
145
|
+
*
|
|
146
|
+
* A check that could not be CARRIED OUT settles nothing, and the cheap fact is what decides
|
|
147
|
+
* there. Falling straight back to the boot record would re-latch the very refusal the paragraph
|
|
148
|
+
* above rules out: the four ways the workload check can come back undeterminable (no payload in
|
|
149
|
+
* this image variant, an architecture it is not built for, a `docker load` the engine refuses, a
|
|
150
|
+
* timeout) have nothing to do with whether a daemon is up, so a warm container whose sidecar
|
|
151
|
+
* arrived late would be denied local infra for the rest of its life over a stale sentence. So a
|
|
152
|
+
* daemon that ANSWERED contradicts a recorded absence exactly as the old `docker version` probe
|
|
153
|
+
* did, and only a check that never reached a daemon at all leaves the record to decide.
|
|
154
|
+
*
|
|
155
|
+
* "Not decided" still keeps attempting, untouched. The point of the third value is that NOTHING
|
|
156
|
+
* turns it into a refusal: the entrypoint's bounded wait may still be running, and a workload
|
|
157
|
+
* probe against a daemon that has not finished starting fails for a reason that says nothing
|
|
158
|
+
* about what it will do a second later.
|
|
140
159
|
*/
|
|
141
|
-
export async function resolveDockerVerdict(status,
|
|
142
|
-
if (status.available
|
|
143
|
-
return { available:
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
160
|
+
export async function resolveDockerVerdict(status, opts = {}) {
|
|
161
|
+
if (status.available === undefined)
|
|
162
|
+
return { available: undefined };
|
|
163
|
+
const workload = await askTotally(opts.probe ?? probeLiveDockerCapability, opts.signal, opts.logger);
|
|
164
|
+
if (workload.status === 'usable')
|
|
165
|
+
return { available: true, daemon: true, workload };
|
|
166
|
+
if (workload.status === 'unusable') {
|
|
167
|
+
return {
|
|
168
|
+
available: false,
|
|
169
|
+
refusal: describeDockerUnusable(workload),
|
|
170
|
+
daemon: true,
|
|
171
|
+
workload,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (workload.daemonAnswered)
|
|
175
|
+
return { available: true, daemon: true, workload };
|
|
176
|
+
return status.available
|
|
177
|
+
? { available: true, workload }
|
|
178
|
+
: { available: false, refusal: describeDockerAbsence(status), workload };
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Call the probe and answer even if it throws.
|
|
182
|
+
*
|
|
183
|
+
* The default probe is total by construction and says so, but this is the seam an injected one
|
|
184
|
+
* arrives through, and the caller is a stand-up documented as best-effort: a throw here would
|
|
185
|
+
* fail a job over the mechanism whose whole purpose is to make a failure legible. A throw settles
|
|
186
|
+
* nothing about the daemon, so it becomes the same value as any other check that could not be
|
|
187
|
+
* carried out and the boot record decides, exactly as it did before the probe existed.
|
|
188
|
+
*/
|
|
189
|
+
async function askTotally(probe, signal, logger) {
|
|
190
|
+
try {
|
|
191
|
+
return await probe(signal);
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
195
|
+
(logger ?? log).warn('docker: the live daemon check threw; falling back to the boot record', {
|
|
196
|
+
error: message,
|
|
197
|
+
});
|
|
198
|
+
return {
|
|
199
|
+
status: 'unknown',
|
|
200
|
+
reason: `the live Docker check could not be carried out (${message})`,
|
|
201
|
+
daemonAnswered: false,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
147
204
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Logger } from './logger.js';
|
|
2
|
+
import { type ContainerEgress, type DockerWorkload } from './docker-capability.js';
|
|
2
3
|
/**
|
|
3
4
|
* What running one probe did, kept as RAW as the spawn: whether the binary ran, and with what.
|
|
4
5
|
* Classification into presence lives in pure code below, because the two callers classify the
|
|
@@ -35,6 +36,44 @@ export type ToolPresence = {
|
|
|
35
36
|
status: 'unknown';
|
|
36
37
|
reason: string;
|
|
37
38
|
};
|
|
39
|
+
/**
|
|
40
|
+
* What this machine's Docker daemon is good for, which is FIVE answers and not three.
|
|
41
|
+
*
|
|
42
|
+
* `absent` and `unknown` mean for the daemon what they mean for any other tool. The three that
|
|
43
|
+
* are particular to Docker split the case where a daemon ANSWERED, because answering is not the
|
|
44
|
+
* question anyone is asking:
|
|
45
|
+
*
|
|
46
|
+
* - `usable`: a container was built and run on it here. `docker build` / `run` / `compose`
|
|
47
|
+
* work, and this is the only state that may say so.
|
|
48
|
+
* - `unusable`: a container could NOT be run, with the daemon serving throughout. The state
|
|
49
|
+
* issue #2120 is about; stated as a prohibition, with the cause.
|
|
50
|
+
* - `serving`: it answered, and the workload check could not be carried out (no payload on
|
|
51
|
+
* this machine, an unmapped architecture, a timeout). Neither of the other two,
|
|
52
|
+
* and rendered as "try it if you need it".
|
|
53
|
+
*
|
|
54
|
+
* `usable` then carries what a nested container could REACH, which is a second fact and not a
|
|
55
|
+
* sixth state: what an agent may do with the daemon and what its containers can fetch are
|
|
56
|
+
* different questions, and each of the three egress answers changes the advice without changing
|
|
57
|
+
* the verdict on the daemon.
|
|
58
|
+
*/
|
|
59
|
+
export type DockerCapability = {
|
|
60
|
+
status: 'usable';
|
|
61
|
+
server?: string;
|
|
62
|
+
egress: ContainerEgress;
|
|
63
|
+
} | {
|
|
64
|
+
status: 'unusable';
|
|
65
|
+
server?: string;
|
|
66
|
+
detail: string;
|
|
67
|
+
} | {
|
|
68
|
+
status: 'serving';
|
|
69
|
+
server?: string;
|
|
70
|
+
reason: string;
|
|
71
|
+
} | {
|
|
72
|
+
status: 'absent';
|
|
73
|
+
} | {
|
|
74
|
+
status: 'unknown';
|
|
75
|
+
reason: string;
|
|
76
|
+
};
|
|
38
77
|
/** One probed entry: the name the agent would type, and what came back. */
|
|
39
78
|
export interface ProbedTool {
|
|
40
79
|
name: string;
|
|
@@ -46,12 +85,13 @@ export interface ProbedTool {
|
|
|
46
85
|
export interface EnvironmentInventory {
|
|
47
86
|
tools: ProbedTool[];
|
|
48
87
|
/**
|
|
49
|
-
*
|
|
50
|
-
* image's `entrypoint.sh` starts a rootless daemon BEST-EFFORT and execs
|
|
51
|
-
* waiting for it, so at job start this probe is the only thing that knows
|
|
52
|
-
* "has not answered yet" is one of its answers (see {@link probeDockerDaemon})
|
|
88
|
+
* What the Docker daemon is good for: not the CLI's presence, and not merely whether the
|
|
89
|
+
* daemon answered. The image's `entrypoint.sh` starts a rootless daemon BEST-EFFORT and execs
|
|
90
|
+
* the server without waiting for it, so at job start this probe is the only thing that knows
|
|
91
|
+
* how that went; "has not answered yet" is one of its answers (see {@link probeDockerDaemon})
|
|
92
|
+
* and "answered, but cannot run a container" is another (see {@link DockerCapability}).
|
|
53
93
|
*/
|
|
54
|
-
dockerDaemon:
|
|
94
|
+
dockerDaemon: DockerCapability;
|
|
55
95
|
/**
|
|
56
96
|
* The port the harness's own job server holds in this network namespace. Not probed: the
|
|
57
97
|
* process reads its own {@link harnessListenPort}, which is the only honest answer when a
|
|
@@ -124,6 +164,18 @@ export interface ProbeEnvironmentOptions {
|
|
|
124
164
|
* only so the suite can assert the rendered line without an ambient `PORT` deciding its text.
|
|
125
165
|
*/
|
|
126
166
|
harnessPort?: number;
|
|
167
|
+
/**
|
|
168
|
+
* Whether the daemon can actually RUN a container, defaulting to the process-wide probe
|
|
169
|
+
* (docker-capability.ts). Asked only once a daemon has answered, since there is nothing to run
|
|
170
|
+
* a workload on otherwise, and memoised per container so a warm pool pays for it once.
|
|
171
|
+
*/
|
|
172
|
+
workload?: (signal?: AbortSignal) => Promise<DockerWorkload>;
|
|
173
|
+
/**
|
|
174
|
+
* The job's signal, forwarded to the probes that spawn something. The workload check starts a
|
|
175
|
+
* CONTAINER, so a cancelled job must stop paying for it rather than hold the daemon for the
|
|
176
|
+
* rest of its budget.
|
|
177
|
+
*/
|
|
178
|
+
signal?: AbortSignal;
|
|
127
179
|
}
|
|
128
180
|
/**
|
|
129
181
|
* Probe the machine. EVERYTHING runs concurrently, the daemon included.
|
|
@@ -163,7 +215,6 @@ export declare function renderEnvironmentInventory(inventory: EnvironmentInvento
|
|
|
163
215
|
* the pass itself, and it says so in a log line rather than silently shortening the prompt.
|
|
164
216
|
*/
|
|
165
217
|
export declare function appendEnvironmentInventory(systemPrompt: string, opts?: {
|
|
166
|
-
signal?: AbortSignal;
|
|
167
218
|
log?: Logger;
|
|
168
219
|
run?: ProbeRunner;
|
|
169
220
|
} & ProbeEnvironmentOptions): Promise<string>;
|
|
@@ -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,18 @@ 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.
|
|
41
|
+
// - And a daemon that runs containers is still not one whose containers have a NETWORK, which
|
|
42
|
+
// is the same mistake one level in again. Loading and running a local image needs no network,
|
|
43
|
+
// so a daemon started with `--iptables=false` passes the workload check while every nested
|
|
44
|
+
// container is cut off (issue #2174): what an agent then hits is a `docker build` whose
|
|
45
|
+
// `RUN npm ci` sits in retry backoff for about seven minutes before failing. So `usable`
|
|
46
|
+
// carries its own egress verdict, and the line below says something different for each.
|
|
34
47
|
//
|
|
35
48
|
// Deliberately NOT here: the agent's own tools (web search, file tools, MCP servers). Those are
|
|
36
49
|
// the CLI's, they differ per harness, and each is already stated where it is true. Claiming one
|
|
@@ -260,10 +273,33 @@ export async function probeEnvironment(run, opts = {}) {
|
|
|
260
273
|
showVersion: probe.showVersion,
|
|
261
274
|
presence: toolPresence(await run(probe.command, probe.args)),
|
|
262
275
|
}))),
|
|
263
|
-
|
|
276
|
+
probeDockerCapability(run, opts),
|
|
264
277
|
]);
|
|
265
278
|
return { tools, dockerDaemon, harnessPort: opts.harnessPort ?? harnessListenPort() };
|
|
266
279
|
}
|
|
280
|
+
/**
|
|
281
|
+
* The daemon's full answer: whether one is reachable, and then whether it can run a container.
|
|
282
|
+
*
|
|
283
|
+
* The two steps are kept apart because they fail for unrelated reasons and only the FIRST has a
|
|
284
|
+
* cheap answer. A daemon nobody can reach has no workload to run, so the check that costs a
|
|
285
|
+
* container start is asked only where there is something to ask it of; a daemon that answered
|
|
286
|
+
* carries its server version into every one of the three states that follow it, because the
|
|
287
|
+
* agent reading the line is entitled to know which daemon the verdict is about.
|
|
288
|
+
*/
|
|
289
|
+
async function probeDockerCapability(run, opts) {
|
|
290
|
+
const daemon = await probeDockerDaemon(run, opts);
|
|
291
|
+
if (daemon.status === 'absent')
|
|
292
|
+
return { status: 'absent' };
|
|
293
|
+
if (daemon.status === 'unknown')
|
|
294
|
+
return { status: 'unknown', reason: daemon.reason };
|
|
295
|
+
const server = daemon.version ? { server: daemon.version } : {};
|
|
296
|
+
const workload = await (opts.workload ?? probeDockerWorkload)(opts.signal);
|
|
297
|
+
if (workload.status === 'usable')
|
|
298
|
+
return { status: 'usable', ...server, egress: workload.egress };
|
|
299
|
+
if (workload.status === 'unusable')
|
|
300
|
+
return { status: 'unusable', ...server, detail: workload.detail };
|
|
301
|
+
return { status: 'serving', ...server, reason: workload.reason };
|
|
302
|
+
}
|
|
267
303
|
/**
|
|
268
304
|
* Ask the daemon itself, and do not mistake a daemon that is STARTING for one that is not there.
|
|
269
305
|
*
|
|
@@ -377,20 +413,109 @@ function harnessPortLine(port) {
|
|
|
377
413
|
'check aimed at it passes without your service ever having run. Bind anything you start ' +
|
|
378
414
|
'somewhere else.');
|
|
379
415
|
}
|
|
380
|
-
/**
|
|
416
|
+
/**
|
|
417
|
+
* The Docker line, which says something different in each of the five cases, and is TOTAL over
|
|
418
|
+
* them: adding a state without deciding what an agent should do about it stops the build.
|
|
419
|
+
*
|
|
420
|
+
* Only `usable` may claim the commands work, and it may only be reached by having RUN one. The
|
|
421
|
+
* line that used to stand here made that claim off `docker info` alone, which is how every agent
|
|
422
|
+
* in a run was told, as fact, that a daemon which could not mount a single image layer would
|
|
423
|
+
* build and run one.
|
|
424
|
+
*/
|
|
381
425
|
function dockerDaemonLine(daemon) {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
426
|
+
const server = 'server' in daemon && daemon.server ? ` (server ${daemon.server})` : '';
|
|
427
|
+
switch (daemon.status) {
|
|
428
|
+
case 'usable':
|
|
429
|
+
return `A Docker daemon is reachable${server} and the platform ran a container on it: ${usableCommands(daemon.egress)} work here. ${egressSentence(daemon.egress)}`;
|
|
430
|
+
case 'unusable':
|
|
431
|
+
return (`A Docker daemon is reachable${server} but it CANNOT run a container: the platform ` +
|
|
432
|
+
`built a one-layer image and tried to run it here, and that failed (${daemon.detail}). ` +
|
|
433
|
+
'`docker build`, `docker run`, `docker pull` of a multi-layer image and ' +
|
|
434
|
+
'`docker compose up` all fail for the same reason, so there is nothing to retry and no ' +
|
|
435
|
+
'flag that works around it. Produce the Dockerfile or compose file you were asked for, ' +
|
|
436
|
+
'say in one line that it could not be built or run here, and move on.');
|
|
437
|
+
case 'serving':
|
|
438
|
+
return (`A Docker daemon is reachable${server}, but whether it can actually build or run an ` +
|
|
439
|
+
`image was NOT established (${daemon.reason}). Reaching the daemon is not the same fact: ` +
|
|
440
|
+
'a sandboxed one answers while being unable to mount any image layer. Try it if you need ' +
|
|
441
|
+
'it, and do not read a failure as a defect in the work.');
|
|
442
|
+
case 'unknown':
|
|
443
|
+
return ('Whether a Docker daemon is reachable could not be determined ' +
|
|
444
|
+
`(${daemon.reason}): try it if you need it, and do not read a failure as a defect in the work.`);
|
|
445
|
+
case 'absent':
|
|
446
|
+
return ('NO Docker daemon is reachable: `docker build`, `docker run` and `docker compose up` ' +
|
|
447
|
+
'will fail here whatever the CLI reports. Produce the Dockerfile or compose file you ' +
|
|
448
|
+
'were asked for, say in one line that you could not build it here, and move on.');
|
|
449
|
+
default:
|
|
450
|
+
return unnamedCapability(daemon);
|
|
386
451
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
452
|
+
}
|
|
453
|
+
function unnamedCapability(daemon) {
|
|
454
|
+
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.`;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Which commands the `usable` line may claim, which is the EGRESS verdict's business and not the
|
|
458
|
+
* daemon's.
|
|
459
|
+
*
|
|
460
|
+
* Split out because the line used to open with the full list and then, one sentence later, tell
|
|
461
|
+
* the agent that every `RUN` line which fetches anything fails. A block that also says not to
|
|
462
|
+
* spend turns re-checking it cannot afford to state and then retract the same fact: an agent
|
|
463
|
+
* reading the first sentence has already been told `docker build` works here, which is the exact
|
|
464
|
+
* shape of the lie issue #2174 is about. TOTAL over {@link ContainerEgress}, like its sibling.
|
|
465
|
+
*/
|
|
466
|
+
function usableCommands(egress) {
|
|
467
|
+
switch (egress.status) {
|
|
468
|
+
case 'blocked':
|
|
469
|
+
// Deliberately omits `docker build`: it is the one the missing NAT rule actually breaks,
|
|
470
|
+
// and the sentence that follows explains which part of it and why.
|
|
471
|
+
return '`docker run` and `docker compose up` of images that are already built';
|
|
472
|
+
case 'reachable':
|
|
473
|
+
case 'undetermined':
|
|
474
|
+
return '`docker build`, `docker run` and `docker compose up`';
|
|
475
|
+
default:
|
|
476
|
+
return unnamedEgressCommands(egress);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
/** The commands claimed for an egress verdict this build does not know: none of them. */
|
|
480
|
+
function unnamedEgressCommands(egress) {
|
|
481
|
+
return `the docker commands the platform could name for its verdict ${JSON.stringify(egress)}`;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* The second half of the `usable` line: what a container started HERE can reach, and what to do
|
|
485
|
+
* about it. TOTAL over {@link ContainerEgress} for the same reason the line above is over the
|
|
486
|
+
* daemon's states.
|
|
487
|
+
*
|
|
488
|
+
* The `blocked` arm is the one this exists for, and it is precise about WHICH commands break,
|
|
489
|
+
* because "docker has no network" is not true and an agent that believed it would skip work it
|
|
490
|
+
* could have done. The daemon has a network: it pulls base images and compose pulls its services
|
|
491
|
+
* normally. What has none is the container each `RUN` line executes in. The seven minutes are
|
|
492
|
+
* named because the failure does not look like a failure from inside the agent's loop: npm turns
|
|
493
|
+
* "no route" into `EAI_AGAIN` only once its retry backoff gives up, so the build reads as a hang
|
|
494
|
+
* and the natural response is to wait longer.
|
|
495
|
+
*/
|
|
496
|
+
function egressSentence(egress) {
|
|
497
|
+
switch (egress.status) {
|
|
498
|
+
case 'reachable':
|
|
499
|
+
return 'A container started here also reaches the network, so a build that installs dependencies works.';
|
|
500
|
+
case 'blocked':
|
|
501
|
+
return ('A container started here could reach NOTHING the platform tried, and that is a fact ' +
|
|
502
|
+
`about this sandbox rather than about your work (${egress.detail}). The daemon itself is ` +
|
|
503
|
+
'fine: it pulls base images, and `docker compose up` of pre-built images works. What ' +
|
|
504
|
+
'fails is every `RUN` line in a `docker build` that fetches from the public internet ' +
|
|
505
|
+
'(`npm ci`, `apk add`, `pip install`), and it fails SLOWLY: npm reports `EAI_AGAIN` only ' +
|
|
506
|
+
'after some seven minutes of retry backoff, so it reads as a hang. Do not wait it out ' +
|
|
507
|
+
'and do not retry. Vendor what you need, skip the image build, verify some other way, or ' +
|
|
508
|
+
'say in one line that you could not verify it here. If this project already builds ' +
|
|
509
|
+
'against a mirror inside this network, that is not one of the addresses tried above and ' +
|
|
510
|
+
'is worth one attempt.');
|
|
511
|
+
case 'undetermined':
|
|
512
|
+
return `Whether a container started here can reach the network was NOT established (${egress.reason}), so try it if you need it and do not read a failure as a defect in the work.`;
|
|
513
|
+
default:
|
|
514
|
+
return unnamedEgress(egress);
|
|
390
515
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
516
|
+
}
|
|
517
|
+
function unnamedEgress(egress) {
|
|
518
|
+
return `Whether a container started here can reach the network could not be determined (the platform reported an unrecognised verdict ${JSON.stringify(egress)}).`;
|
|
394
519
|
}
|
|
395
520
|
/**
|
|
396
521
|
* Probe the machine and fold the inventory onto `systemPrompt`. THE composition point: the harness
|
|
@@ -408,18 +533,25 @@ function dockerDaemonLine(daemon) {
|
|
|
408
533
|
*/
|
|
409
534
|
export async function appendEnvironmentInventory(systemPrompt, opts = {}) {
|
|
410
535
|
const logger = opts.log ?? log;
|
|
536
|
+
// Everything that is not this function's OWN is forwarded by construction, rather than key by
|
|
537
|
+
// key. The list of copied keys silently dropped `workload`, whose whole point is that a suite
|
|
538
|
+
// can inject one: a test driving THIS entry point (the only one `handleAgent` uses) got the
|
|
539
|
+
// real probe instead, which starts a container on whatever machine the suite runs on.
|
|
540
|
+
const { log: _log, run, ...probeOptions } = opts;
|
|
411
541
|
try {
|
|
412
|
-
const inventory = await probeEnvironment(
|
|
413
|
-
...(opts.sleep ? { sleep: opts.sleep } : {}),
|
|
414
|
-
...(opts.daemonExpected === undefined ? {} : { daemonExpected: opts.daemonExpected }),
|
|
415
|
-
...(opts.harnessPort === undefined ? {} : { harnessPort: opts.harnessPort }),
|
|
416
|
-
});
|
|
542
|
+
const inventory = await probeEnvironment(run ?? spawnProbeRunner(opts.signal), probeOptions);
|
|
417
543
|
logger.info('agent: probed the environment', {
|
|
418
544
|
installed: inventory.tools
|
|
419
545
|
.filter((t) => t.presence.status === 'present')
|
|
420
546
|
.map((t) => t.name)
|
|
421
547
|
.join(','),
|
|
422
548
|
dockerDaemon: inventory.dockerDaemon.status,
|
|
549
|
+
// Present only when a nested container was actually asked, which is the one state that has
|
|
550
|
+
// an egress answer at all. A word here for every other state would report "no measurement"
|
|
551
|
+
// and "measured, and it cannot get out" in the same field.
|
|
552
|
+
...(inventory.dockerDaemon.status === 'usable'
|
|
553
|
+
? { dockerEgress: inventory.dockerDaemon.egress.status }
|
|
554
|
+
: {}),
|
|
423
555
|
// The unknowns, by NAME: the block tells the agent a probe failed, and this is the only place
|
|
424
556
|
// an operator can see WHICH, since the reason the agent reads is deliberately wordy prose.
|
|
425
557
|
unknown: inventory.tools
|
package/dist/harness-server.js
CHANGED
|
@@ -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.
|
package/dist/infra-standup.d.ts
CHANGED
|
@@ -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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* looking like a Tester that simply chose not to. Anything OTHER than a confirmed
|
|
18
|
-
* attempts as before (`DockerStatus.available` in docker-status.ts states why
|
|
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
|
|
22
|
-
* against a live daemon first, so a warm-pool container whose sidecar came up late is not
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|