@cat-factory/executor-harness 1.135.0 → 1.137.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 +46 -0
- package/dist/agent.js +22 -3
- package/dist/environment-inventory.d.ts +158 -0
- package/dist/environment-inventory.js +412 -0
- package/package.json +2 -2
- package/src/agent.ts +23 -3
- package/src/environment-inventory.ts +512 -0
package/README.md
CHANGED
|
@@ -116,6 +116,51 @@ Bootstrap differs at the ends: it may start from an empty dir, and **resets
|
|
|
116
116
|
history to one commit and force-pushes** the default branch instead of opening a
|
|
117
117
|
PR. Blueprint **commits onto a branch** (no history reset) and returns the tree.
|
|
118
118
|
|
|
119
|
+
### The environment is probed once, not by the agent
|
|
120
|
+
|
|
121
|
+
Before any mode branches, `handleAgent` probes the machine it is about to run on and appends an
|
|
122
|
+
`ENVIRONMENT INVENTORY` block to the job's system prompt (`src/environment-inventory.ts`). It names
|
|
123
|
+
the toolchain that answered and its versions, the curated list of tools that did not, and whether a
|
|
124
|
+
Docker DAEMON is actually reachable.
|
|
125
|
+
|
|
126
|
+
It exists because the platform used to ask every agent to find this out for itself, and every agent
|
|
127
|
+
did: in one measured run an architect ran `for c in docker kubectl helm kustomize; …` and then
|
|
128
|
+
`docker info`, and the coder it handed off to rediscovered both answers thirty calls later. Four
|
|
129
|
+
calls out of a forty-call budget for facts this process holds before the agent's first turn. The
|
|
130
|
+
backend cannot hold them (it composes its prompt before a transport is chosen, and the same body
|
|
131
|
+
reaches this image, a deployment's own image variant and, under `LOCAL_NATIVE_AGENTS`, the
|
|
132
|
+
developer's own machine), so it states the POLICY and names no tooling at all.
|
|
133
|
+
|
|
134
|
+
Three rules bind anything added to it:
|
|
135
|
+
|
|
136
|
+
- **A failed probe is not an absence.** Only `ENOENT` means "not installed"; a timeout or a refused
|
|
137
|
+
spawn renders on its own line as could-not-be-determined, because the two lead an agent to
|
|
138
|
+
opposite next moves.
|
|
139
|
+
- **An unlisted tool is unknown too**, which the block's last line says. The probe list is curated,
|
|
140
|
+
so silence about `terraform` must not read as its absence.
|
|
141
|
+
- **The Docker daemon is answered by running `docker info`**, never by finding the CLI. The CLI is
|
|
142
|
+
installed in this image unconditionally, `entrypoint.sh` starts the rootless daemon best-effort
|
|
143
|
+
and execs the server without waiting for it, so at job start this probe is the only thing that
|
|
144
|
+
knows how that went.
|
|
145
|
+
- **A daemon that is STARTING is not a daemon that is absent.** Because the entrypoint does not
|
|
146
|
+
wait, the backend dispatches seconds before there is a socket, and `docker info` is then refused
|
|
147
|
+
at once rather than slowly. So a refusal is read against `DOCKER_HOST`, which the entrypoint sets
|
|
148
|
+
whenever something is meant to serve a daemon here: unset means nothing was coming and the
|
|
149
|
+
absence is stated definitively, set means one short retry and then could-not-be-determined. The
|
|
150
|
+
absent wording tells an agent `docker compose up` "will fail here whatever the CLI reports", which
|
|
151
|
+
is a prohibition, so it may only be reached where nothing is going to answer.
|
|
152
|
+
- **A tool the platform did not provide is not installable system-wide either**, since the job runs
|
|
153
|
+
unprivileged. The line says that instead of banning installation outright: `pnpm` is absent from
|
|
154
|
+
this image (only the UI variant carries it), so it is routinely the package manager the job's own
|
|
155
|
+
repository declares, and a flat prohibition pushed agents onto `npm install` against a pnpm
|
|
156
|
+
lockfile. Reaching a project's own manager for that project alone is allowed and named.
|
|
157
|
+
|
|
158
|
+
Composed at exactly ONE point, onto the job's own `systemPrompt`, which every mode already forwards
|
|
159
|
+
and all three CLIs already carry (claude-code's `--append-system-prompt` and its oversized-argv
|
|
160
|
+
fallback, Codex's fold, Pi's `AGENTS.md`). `test/environment-inventory.coverage.test.ts` pins that:
|
|
161
|
+
a mode that folded its own copy would state the machine twice, and one that folded none would leave
|
|
162
|
+
its agent probing, with nothing failing either way.
|
|
163
|
+
|
|
119
164
|
### The work-branch push is CHECKPOINTED, so it is lease-guarded
|
|
120
165
|
|
|
121
166
|
Step 8's push is not the run's first: every `JOB_CHECKPOINT_INTERVAL_MS` (60s) the harness pushes
|
|
@@ -393,6 +438,7 @@ verdict is stated.
|
|
|
393
438
|
| `src/bootstrap-mode.ts` | The repo-bootstrap MODE: clone-a-reference-or-scaffold → run the agent → refuse to push an empty tree → reinit + force-push to the pre-created target repo. |
|
|
394
439
|
| `src/artifact-upload.ts` | The OUTBOUND half of the artifact seam: parses the body's `artifactUpload` and projects it onto the agent's env as `ARTIFACT_UPLOAD_URL` / `ARTIFACT_UPLOAD_TOKEN`, registering the token for redaction first. Passes through what the body carries and decides nothing: which kinds get the seam is the backend's call. |
|
|
395
440
|
| `src/codex-images.ts` | Codex's own `image_gen` output, staged where the agent can reach it: creates `$CODEX_HOME/generated_images` as a symlink into `.cat-context/binary-output/generated/` before the CLI starts, sweeps anything a failed redirect left behind, and unlinks (never follows) the redirect at teardown — a failed unlink is REPORTED, because that unlink is what stops the recursive delete reaching the checkout. Exists because codex exposes no path for what it generated AND `$CODEX_HOME` holds the run's decrypted credential, so neither asking the agent nor sending it there is available. |
|
|
441
|
+
| `src/environment-inventory.ts` | What the MACHINE holds, probed once per job and appended to the agent's system prompt as an ENVIRONMENT INVENTORY block. The only layer that can state it: the backend composes its prompt before a transport is chosen, and the same body serves this image, a deployment's own variant and the developer's laptop under `LOCAL_NATIVE_AGENTS`. Three-valued on purpose, so a probe that failed renders as unknown rather than as an absence, and the Docker DAEMON is answered by running `docker info` rather than by finding the CLI, which is installed here either way. See [The environment is probed once, not by the agent](#the-environment-is-probed-once-not-by-the-agent). |
|
|
396
442
|
| `src/agent-shared.ts` | The few helpers every agent MODE shares (effort-report folding, the capability fields forwarded to `runAgentInWorkspace`). |
|
|
397
443
|
| `src/logger.ts` | Structured logging. |
|
|
398
444
|
| `src/docker-status.ts` | This container's own verdict about its Docker daemon, as recorded by `entrypoint.sh`. Three-valued on purpose: a daemon that FAILED and a daemon nobody asked about are different facts, and only a DECIDED absence refuses a stand-up. See [Local infra: the container's Docker daemon](#local-infra-the-containers-docker-daemon). |
|
package/dist/agent.js
CHANGED
|
@@ -17,6 +17,7 @@ import { runMultiRepoCoding } from './multi-repo-coding.js';
|
|
|
17
17
|
import { validationFailureMessage } from './validation-checks.js';
|
|
18
18
|
import { prepopulateDependencies, withDependencyNote } from './dependency-install.js';
|
|
19
19
|
import { agentCapabilities, mergeEffort } from './agent-shared.js';
|
|
20
|
+
import { appendEnvironmentInventory } from './environment-inventory.js';
|
|
20
21
|
import { runBootstrap } from './bootstrap-mode.js';
|
|
21
22
|
import { acquireRepoCheckout, agentNeverActed, agentOutputTail, NEVER_ACTED_CAUSE, runAgentInWorkspace, unusableFinalAnswerCause, withWorkspace, } from './pi-workspace.js';
|
|
22
23
|
import { diagnosticsSuffix, resolveStructuredOutput, } from './structured-output.js';
|
|
@@ -126,9 +127,27 @@ export async function handleAgent(job, opts = {}) {
|
|
|
126
127
|
});
|
|
127
128
|
if (job.mode === 'preview')
|
|
128
129
|
return await runPreviewMode(job, scoped);
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
130
|
+
// THE composition point for the environment inventory (see `environment-inventory.ts`): the
|
|
131
|
+
// machine is probed ONCE here, before any mode branches, and the result is folded onto the
|
|
132
|
+
// job's own system prompt. Every mode, every repair round and all three agent CLIs read that
|
|
133
|
+
// one field, so none of them can end up without the block and none can carry it twice.
|
|
134
|
+
// `preview` returns above because it runs no agent at all, so there is no prompt to fold onto.
|
|
135
|
+
//
|
|
136
|
+
// This sits on the critical path AHEAD of the clone, which is the cost of having one
|
|
137
|
+
// composition point instead of one per mode (each mode owns its own clone, so there is no
|
|
138
|
+
// single post-clone place to put this). The pass is sized for that: everything in it runs
|
|
139
|
+
// concurrently, every probe is a call that answers in milliseconds or is wedged, and the one
|
|
140
|
+
// deliberate wait is a single short retry for a daemon that is still starting.
|
|
141
|
+
const staged = {
|
|
142
|
+
...job,
|
|
143
|
+
systemPrompt: await appendEnvironmentInventory(job.systemPrompt, {
|
|
144
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
145
|
+
...(opts.log ? { log: opts.log } : {}),
|
|
146
|
+
}),
|
|
147
|
+
};
|
|
148
|
+
return staged.mode === 'coding'
|
|
149
|
+
? await runCodingMode(staged, scoped)
|
|
150
|
+
: await runExploreMode(staged, scoped);
|
|
132
151
|
}
|
|
133
152
|
finally {
|
|
134
153
|
if (scopeDir)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { type Logger } from './logger.js';
|
|
2
|
+
/**
|
|
3
|
+
* What running one probe did, kept as RAW as the spawn: whether the binary ran, and with what.
|
|
4
|
+
* Classification into presence lives in pure code below, because the two callers classify the
|
|
5
|
+
* same result differently: a non-zero exit proves an ordinary tool is installed and proves the
|
|
6
|
+
* Docker daemon is not reachable.
|
|
7
|
+
*
|
|
8
|
+
* `found` is the outcome with no exit code, and it exists so that the Windows second look cannot
|
|
9
|
+
* be mistaken for a run: the platform's own oracle located the binary and NOTHING WAS EXECUTED.
|
|
10
|
+
* Collapsing it into `ran` with a synthesised `exitCode: 0` is what let `docker info` report a
|
|
11
|
+
* reachable daemon on the strength of `where docker`, which is the CLI-for-daemon half-truth this
|
|
12
|
+
* whole file exists to remove.
|
|
13
|
+
*/
|
|
14
|
+
export type ProbeResult = {
|
|
15
|
+
outcome: 'ran';
|
|
16
|
+
exitCode: number;
|
|
17
|
+
output: string;
|
|
18
|
+
} | {
|
|
19
|
+
outcome: 'found';
|
|
20
|
+
} | {
|
|
21
|
+
outcome: 'missing';
|
|
22
|
+
} | {
|
|
23
|
+
outcome: 'failed';
|
|
24
|
+
reason: string;
|
|
25
|
+
};
|
|
26
|
+
/** Run one probe. Injected so the suite drives every branch without needing the real binaries. */
|
|
27
|
+
export type ProbeRunner = (command: string, args: string[]) => Promise<ProbeResult>;
|
|
28
|
+
/** What one probe learned. `unknown` is a THIRD answer, never folded into `absent`. */
|
|
29
|
+
export type ToolPresence = {
|
|
30
|
+
status: 'present';
|
|
31
|
+
version?: string;
|
|
32
|
+
} | {
|
|
33
|
+
status: 'absent';
|
|
34
|
+
} | {
|
|
35
|
+
status: 'unknown';
|
|
36
|
+
reason: string;
|
|
37
|
+
};
|
|
38
|
+
/** One probed entry: the name the agent would type, and what came back. */
|
|
39
|
+
export interface ProbedTool {
|
|
40
|
+
name: string;
|
|
41
|
+
presence: ToolPresence;
|
|
42
|
+
/** Whether the rendered line carries the version. Off where a yes/no is the whole answer. */
|
|
43
|
+
showVersion: boolean;
|
|
44
|
+
}
|
|
45
|
+
/** Everything one job's probe pass learned about the machine it is about to run on. */
|
|
46
|
+
export interface EnvironmentInventory {
|
|
47
|
+
tools: ProbedTool[];
|
|
48
|
+
/**
|
|
49
|
+
* Whether a Docker daemon actually answered: the readiness fact, not the CLI's presence. The
|
|
50
|
+
* image's `entrypoint.sh` starts a rootless daemon BEST-EFFORT and execs the server without
|
|
51
|
+
* waiting for it, so at job start this probe is the only thing that knows how that went, and
|
|
52
|
+
* "has not answered yet" is one of its answers (see {@link probeDockerDaemon}).
|
|
53
|
+
*/
|
|
54
|
+
dockerDaemon: ToolPresence;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The real runner: spawn the tool, bounded, and report what the spawn did.
|
|
58
|
+
*
|
|
59
|
+
* The Windows second look is for the NATIVE transport on a developer's own machine, not for the
|
|
60
|
+
* image. `execFile` without a shell resolves `.exe` and not `.cmd`, and Node refuses to spawn a
|
|
61
|
+
* `.cmd` at all without one, so a perfectly installed `npm` or `pnpm` comes back `ENOENT` there and
|
|
62
|
+
* would be stated to the agent as NOT INSTALLED. `where` is the platform's own presence oracle and
|
|
63
|
+
* answers that without running the shim, at the cost of the version, which is the honest trade: the
|
|
64
|
+
* rendered line then names the tool with no number rather than claiming one or denying the tool.
|
|
65
|
+
*
|
|
66
|
+
* It answers PRESENCE and nothing else, which is why it returns `found` and why it ignores `args`
|
|
67
|
+
* without pretending otherwise: `where <command>` locates a binary, so it can say nothing about
|
|
68
|
+
* what that binary would have printed or exited with. The version is not the only thing lost. A
|
|
69
|
+
* probe whose ANSWER is its exit code (`docker info`) gets no answer at all from this branch, and
|
|
70
|
+
* {@link daemonPresence} is where that is turned into an unknown rather than a reachable daemon.
|
|
71
|
+
*/
|
|
72
|
+
export declare function spawnProbeRunner(signal?: AbortSignal): ProbeRunner;
|
|
73
|
+
/**
|
|
74
|
+
* What the `where` oracle's own result says about the TOOL. Pure, and separate from the spawn, so
|
|
75
|
+
* every branch is asserted on any platform: reproducing the failing one for real needs a process
|
|
76
|
+
* whose PATH cannot reach `where`, and mutating the live `PATH` to get one leaks into whatever test
|
|
77
|
+
* runs next (on Windows `PATH` and `Path` are one variable, so restoring them is not symmetric).
|
|
78
|
+
*
|
|
79
|
+
* The distinction the branches exist for: `where` exiting non-zero IS the tool's absence, but
|
|
80
|
+
* `where` not answering at all says nothing about the tool, so that becomes a FAILED probe. Passing
|
|
81
|
+
* the oracle's result straight back reported the ORACLE's own `missing` as the tool's, which put a
|
|
82
|
+
* fully installed npm on the "Not installed" line on any host where `where` cannot be spawned.
|
|
83
|
+
*
|
|
84
|
+
* A located tool is `found`, never a synthesised `ran` with `exitCode: 0`: the oracle located a
|
|
85
|
+
* binary and executed nothing, so it cannot answer for a probe whose answer IS its exit code.
|
|
86
|
+
*/
|
|
87
|
+
export declare function readOracle(located: ProbeResult): ProbeResult;
|
|
88
|
+
/**
|
|
89
|
+
* An ordinary tool's presence: it ran (whatever it exited with) ⇒ installed, `ENOENT` ⇒ not
|
|
90
|
+
* installed, anything else ⇒ the probe failed and we do not know. `helm version` on a host with
|
|
91
|
+
* no cluster config exits non-zero and still proves helm is installed.
|
|
92
|
+
*/
|
|
93
|
+
export declare function toolPresence(result: ProbeResult): ToolPresence;
|
|
94
|
+
/**
|
|
95
|
+
* The daemon's presence, which reads the SAME result differently in exactly TWO places, and
|
|
96
|
+
* delegates the rest: duplicating the other three branches to add these two is how a change to
|
|
97
|
+
* how a version is read, or how a failure reason is carried, gets made in one classifier only.
|
|
98
|
+
*
|
|
99
|
+
* - A non-zero exit is "cannot connect to the Docker daemon", so it is an absence rather than the
|
|
100
|
+
* proof-of-install a non-zero exit is everywhere else.
|
|
101
|
+
* - `found` means the Windows oracle located the CLI and ran NOTHING, so the daemon was never
|
|
102
|
+
* asked. That is an unknown. Reading it as `toolPresence` would is what made `where docker`
|
|
103
|
+
* enough to tell an agent `docker build` works here.
|
|
104
|
+
*/
|
|
105
|
+
export declare function daemonPresence(result: ProbeResult): ToolPresence;
|
|
106
|
+
/** What one probe pass may be told, so the suite drives the daemon's retry without waiting on it. */
|
|
107
|
+
export interface ProbeEnvironmentOptions {
|
|
108
|
+
/** Injected so tests exercise the retry without paying {@link DAEMON_RETRY_DELAY_MS}. */
|
|
109
|
+
sleep?: (ms: number) => Promise<void>;
|
|
110
|
+
/**
|
|
111
|
+
* Whether a daemon is CONFIGURED to serve this machine, defaulting to {@link daemonIsConfigured}.
|
|
112
|
+
* Stated as the fact rather than as the `DOCKER_HOST` string it is read from, so a test can drive
|
|
113
|
+
* both sides of the branch without an ambient environment variable deciding the outcome for it.
|
|
114
|
+
*/
|
|
115
|
+
daemonExpected?: boolean;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Probe the machine. EVERYTHING runs concurrently, the daemon included.
|
|
119
|
+
*
|
|
120
|
+
* The daemon probe used to be sequenced after the tool pass, so that a missing CLI could
|
|
121
|
+
* short-circuit it. That bought a cleaner `absent` and charged the whole job for it: two ceilings
|
|
122
|
+
* back to back on the critical path, ahead of the clone, on every dispatch. It is unnecessary,
|
|
123
|
+
* because `docker info` answers the CLI question too: with no CLI on PATH the spawn comes back
|
|
124
|
+
* `missing`, which {@link daemonPresence} already reads as the same absence the short-circuit
|
|
125
|
+
* produced. Asking directly also removes a second defect the sequencing needed: reading the CLI's
|
|
126
|
+
* presence back out of `tools` defaulted a MISSING list entry to `absent`, so trimming the curated
|
|
127
|
+
* list would have had the block assert "NO Docker daemon is reachable" with nothing ever asked.
|
|
128
|
+
*/
|
|
129
|
+
export declare function probeEnvironment(run: ProbeRunner, opts?: ProbeEnvironmentOptions): Promise<EnvironmentInventory>;
|
|
130
|
+
/**
|
|
131
|
+
* The block appended to the agent's system prompt. Pure, so what the agent reads is asserted
|
|
132
|
+
* against a probe result rather than against whichever machine the suite happens to run on.
|
|
133
|
+
*
|
|
134
|
+
* Each line is a different KIND of claim, and they are kept apart because collapsing them is the
|
|
135
|
+
* failure this replaces: what is here, what is not, what could not be determined, what the Docker
|
|
136
|
+
* daemon actually said, and, last because it governs everything the other lines omit, that the
|
|
137
|
+
* list is bounded.
|
|
138
|
+
*/
|
|
139
|
+
export declare function renderEnvironmentInventory(inventory: EnvironmentInventory): string;
|
|
140
|
+
/**
|
|
141
|
+
* Probe the machine and fold the inventory onto `systemPrompt`. THE composition point: the harness
|
|
142
|
+
* calls this once per job, in `handleAgent`, before any mode branches, so every mode and every CLI
|
|
143
|
+
* (claude-code, codex, Pi) inherits it from the job's own system prompt instead of each folding a
|
|
144
|
+
* copy, which is how one of them would silently end up without it, and another with it twice.
|
|
145
|
+
*
|
|
146
|
+
* It rides the SYSTEM prompt rather than the task prompt so it survives the claude runner's
|
|
147
|
+
* argv-size branch (`carryClaudeSystemPrompt` folds an oversized system prompt into stdin whole),
|
|
148
|
+
* Codex's unconditional fold and Pi's `AGENTS.md` write, none of which can drop part of it.
|
|
149
|
+
*
|
|
150
|
+
* Never throws: an inventory is context, and a job whose probe pass fell over is still a job worth
|
|
151
|
+
* running. A probe that fails is already reported as unknown by construction, so this catch is for
|
|
152
|
+
* the pass itself, and it says so in a log line rather than silently shortening the prompt.
|
|
153
|
+
*/
|
|
154
|
+
export declare function appendEnvironmentInventory(systemPrompt: string, opts?: {
|
|
155
|
+
signal?: AbortSignal;
|
|
156
|
+
log?: Logger;
|
|
157
|
+
run?: ProbeRunner;
|
|
158
|
+
} & ProbeEnvironmentOptions): Promise<string>;
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { log } from './logger.js';
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// What this machine actually has, probed ONCE per job and stated to the agent.
|
|
6
|
+
//
|
|
7
|
+
// The platform used to tell every agent to discover its own environment ("probe for a tool before
|
|
8
|
+
// relying on it"), and every agent did, repeatedly, and often twice within one run: an architect
|
|
9
|
+
// ran `for c in docker kubectl helm kustomize; do command -v $c; done`, then `docker info`, and
|
|
10
|
+
// the coder it handed off to rediscovered both answers thirty calls later. Four calls out of a
|
|
11
|
+
// forty-call budget, spent on facts the harness holds before the agent's first turn.
|
|
12
|
+
//
|
|
13
|
+
// This is the layer that CAN hold them, and the only one. The backend composes its prompt before a
|
|
14
|
+
// transport is even chosen, and the same job body serves the harness image, a deployment's own
|
|
15
|
+
// image variant and (under `LOCAL_NATIVE_AGENTS`) the developer's own machine, where the
|
|
16
|
+
// toolchain is theirs. So the backend states the POLICY (no cluster credentials; an artifact this
|
|
17
|
+
// environment cannot execute is still a correct artifact) and names no tooling at all, while this
|
|
18
|
+
// file states the FACTS, from a probe of the machine the agent is about to run on.
|
|
19
|
+
//
|
|
20
|
+
// Three rules the block is built around:
|
|
21
|
+
//
|
|
22
|
+
// - ABSENT and UNKNOWN must not render the same. Only a spawn that came back `ENOENT` is an
|
|
23
|
+
// absence; a probe that failed says so and is reported as neither present nor absent.
|
|
24
|
+
// - A tool NOT ON THE LIST is unknown too, which the block's last line says. The list is curated
|
|
25
|
+
// (what agents were observed rediscovering), so silence about `terraform` has to read as
|
|
26
|
+
// "nobody looked", never as "it isn't there".
|
|
27
|
+
// - The Docker DAEMON is a separate fact from the docker CLI, and it is the one that decides
|
|
28
|
+
// anything: `command -v docker` succeeds in this image and `docker build` still fails, which
|
|
29
|
+
// is the half-truth the old instruction produced. It is answered by RUNNING `docker info`, and
|
|
30
|
+
// a daemon this machine is CONFIGURED for but which has not answered yet is a fourth state
|
|
31
|
+
// that resolves to `unknown`, never to the absence a refused connection looks like: the
|
|
32
|
+
// image's daemon is started in the background and the job begins before it is ready.
|
|
33
|
+
//
|
|
34
|
+
// Deliberately NOT here: the agent's own tools (web search, file tools, MCP servers). Those are
|
|
35
|
+
// the CLI's, they differ per harness, and each is already stated where it is true. Claiming one
|
|
36
|
+
// here would be the platform asserting a capability the dispatch may not have delivered, which is
|
|
37
|
+
// the defect this whole change exists to remove.
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
const execFileAsync = promisify(execFile);
|
|
40
|
+
/**
|
|
41
|
+
* The curated probe list: the toolchain an agent assumes, plus the tools runs were observed
|
|
42
|
+
* burning calls to discover. Kept SHORT on purpose. Every entry is a spawn and a few more words
|
|
43
|
+
* in every system prompt, and the block's closing line keeps an unlisted tool honestly unknown
|
|
44
|
+
* rather than implicitly absent.
|
|
45
|
+
*
|
|
46
|
+
* `showVersion` is on where the number changes what the agent does (comparing against a target's
|
|
47
|
+
* declared engines, a language's syntax level) and off where presence is the whole question.
|
|
48
|
+
*/
|
|
49
|
+
const PROBES = [
|
|
50
|
+
{ name: 'node', command: 'node', args: ['--version'], showVersion: true },
|
|
51
|
+
{ name: 'npm', command: 'npm', args: ['--version'], showVersion: true },
|
|
52
|
+
{ name: 'pnpm', command: 'pnpm', args: ['--version'], showVersion: true },
|
|
53
|
+
{ name: 'git', command: 'git', args: ['--version'], showVersion: true },
|
|
54
|
+
{ name: 'python3', command: 'python3', args: ['--version'], showVersion: true },
|
|
55
|
+
{ name: 'docker', command: 'docker', args: ['--version'], showVersion: false },
|
|
56
|
+
{ name: 'jq', command: 'jq', args: ['--version'], showVersion: false },
|
|
57
|
+
{ name: 'rg', command: 'rg', args: ['--version'], showVersion: false },
|
|
58
|
+
{ name: 'curl', command: 'curl', args: ['--version'], showVersion: false },
|
|
59
|
+
{ name: 'make', command: 'make', args: ['--version'], showVersion: false },
|
|
60
|
+
{ name: 'kubectl', command: 'kubectl', args: ['version', '--client'], showVersion: false },
|
|
61
|
+
{ name: 'helm', command: 'helm', args: ['version'], showVersion: false },
|
|
62
|
+
{ name: 'kustomize', command: 'kustomize', args: ['version'], showVersion: false },
|
|
63
|
+
];
|
|
64
|
+
/** The daemon readiness probe: the one probe whose EXIT CODE, not its presence, is the answer. */
|
|
65
|
+
const DOCKER_INFO = {
|
|
66
|
+
command: 'docker',
|
|
67
|
+
args: ['info', '--format', '{{.ServerVersion}}'],
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Every probe here is either instant or wedged: a `--version` banner, and a `docker info` that
|
|
71
|
+
* either connects or is REFUSED by a missing socket in milliseconds. So the ceiling is sized to
|
|
72
|
+
* cut a wedged binary out of the job's critical path, not to wait for anything.
|
|
73
|
+
*
|
|
74
|
+
* It used to be ten seconds, on the stated grounds that `docker info` "can genuinely take a
|
|
75
|
+
* moment" against a daemon still coming up. That was wrong in the way that matters: a daemon that
|
|
76
|
+
* is not up yet has no socket to connect to, so docker exits non-zero AT ONCE and the ceiling is
|
|
77
|
+
* never reached. Waiting for a starting daemon is a different problem and is solved where it
|
|
78
|
+
* actually lives, in {@link probeDockerDaemon}'s retry.
|
|
79
|
+
*/
|
|
80
|
+
const PROBE_TIMEOUT_MS = 5_000;
|
|
81
|
+
/**
|
|
82
|
+
* How long a daemon that was EXPECTED here gets to answer before the block says it could not be
|
|
83
|
+
* determined. One short retry, not a readiness wait: the honest verdict for a daemon still coming
|
|
84
|
+
* up is `unknown`, which the rendered line turns into "try it if you need it", so there is nothing
|
|
85
|
+
* to buy by blocking the agent's first turn any longer than this.
|
|
86
|
+
*/
|
|
87
|
+
const DAEMON_RETRY_DELAY_MS = 1_500;
|
|
88
|
+
/**
|
|
89
|
+
* The real runner: spawn the tool, bounded, and report what the spawn did.
|
|
90
|
+
*
|
|
91
|
+
* The Windows second look is for the NATIVE transport on a developer's own machine, not for the
|
|
92
|
+
* image. `execFile` without a shell resolves `.exe` and not `.cmd`, and Node refuses to spawn a
|
|
93
|
+
* `.cmd` at all without one, so a perfectly installed `npm` or `pnpm` comes back `ENOENT` there and
|
|
94
|
+
* would be stated to the agent as NOT INSTALLED. `where` is the platform's own presence oracle and
|
|
95
|
+
* answers that without running the shim, at the cost of the version, which is the honest trade: the
|
|
96
|
+
* rendered line then names the tool with no number rather than claiming one or denying the tool.
|
|
97
|
+
*
|
|
98
|
+
* It answers PRESENCE and nothing else, which is why it returns `found` and why it ignores `args`
|
|
99
|
+
* without pretending otherwise: `where <command>` locates a binary, so it can say nothing about
|
|
100
|
+
* what that binary would have printed or exited with. The version is not the only thing lost. A
|
|
101
|
+
* probe whose ANSWER is its exit code (`docker info`) gets no answer at all from this branch, and
|
|
102
|
+
* {@link daemonPresence} is where that is turned into an unknown rather than a reachable daemon.
|
|
103
|
+
*/
|
|
104
|
+
export function spawnProbeRunner(signal) {
|
|
105
|
+
const attempt = async (command, args) => {
|
|
106
|
+
try {
|
|
107
|
+
const { stdout, stderr } = await execFileAsync(command, args, {
|
|
108
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
109
|
+
maxBuffer: 256 * 1024,
|
|
110
|
+
windowsHide: true,
|
|
111
|
+
...(signal ? { signal } : {}),
|
|
112
|
+
});
|
|
113
|
+
return { outcome: 'ran', exitCode: 0, output: `${stdout}\n${stderr}` };
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
return spawnFailure(err);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
return async (command, args) => {
|
|
120
|
+
const first = await attempt(command, args);
|
|
121
|
+
if (first.outcome !== 'missing' || process.platform !== 'win32')
|
|
122
|
+
return first;
|
|
123
|
+
return readOracle(await attempt('where', [command]));
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* What the `where` oracle's own result says about the TOOL. Pure, and separate from the spawn, so
|
|
128
|
+
* every branch is asserted on any platform: reproducing the failing one for real needs a process
|
|
129
|
+
* whose PATH cannot reach `where`, and mutating the live `PATH` to get one leaks into whatever test
|
|
130
|
+
* runs next (on Windows `PATH` and `Path` are one variable, so restoring them is not symmetric).
|
|
131
|
+
*
|
|
132
|
+
* The distinction the branches exist for: `where` exiting non-zero IS the tool's absence, but
|
|
133
|
+
* `where` not answering at all says nothing about the tool, so that becomes a FAILED probe. Passing
|
|
134
|
+
* the oracle's result straight back reported the ORACLE's own `missing` as the tool's, which put a
|
|
135
|
+
* fully installed npm on the "Not installed" line on any host where `where` cannot be spawned.
|
|
136
|
+
*
|
|
137
|
+
* A located tool is `found`, never a synthesised `ran` with `exitCode: 0`: the oracle located a
|
|
138
|
+
* binary and executed nothing, so it cannot answer for a probe whose answer IS its exit code.
|
|
139
|
+
*/
|
|
140
|
+
export function readOracle(located) {
|
|
141
|
+
if (located.outcome === 'ran') {
|
|
142
|
+
return located.exitCode === 0 ? { outcome: 'found' } : { outcome: 'missing' };
|
|
143
|
+
}
|
|
144
|
+
if (located.outcome === 'found')
|
|
145
|
+
return located;
|
|
146
|
+
return {
|
|
147
|
+
outcome: 'failed',
|
|
148
|
+
reason: located.outcome === 'failed'
|
|
149
|
+
? located.reason
|
|
150
|
+
: 'the tool could not be spawned, and `where` could not be run to locate it',
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Read a rejected `execFile` back into a {@link ProbeResult}.
|
|
155
|
+
*
|
|
156
|
+
* `ENOENT` is the ONLY absence: the binary is not on PATH. A numeric `code` means the binary RAN
|
|
157
|
+
* and exited non-zero, which is a different fact and belongs to whoever asked. Everything else
|
|
158
|
+
* (a timeout, where the child is killed and there is no exit code; `EACCES`; an aborted job) is a
|
|
159
|
+
* failure OF THE PROBE, which is what keeps it out of the absent list.
|
|
160
|
+
*
|
|
161
|
+
* Every reason is WRITTEN HERE, in words, and the raw `code` is never one of them. The reason is
|
|
162
|
+
* rendered verbatim into the agent's system prompt, so passing `String(e.code)` through published
|
|
163
|
+
* `ABORT_ERR` (a cancelled job, on all thirteen entries at once), `EACCES` and
|
|
164
|
+
* `ERR_CHILD_PROCESS_STDIO_MAXBUFFER` to a model as prose. A code nobody has mapped yet says only
|
|
165
|
+
* that the probe did not run, which is the whole of what the block needs from it.
|
|
166
|
+
*/
|
|
167
|
+
function spawnFailure(err) {
|
|
168
|
+
const e = err;
|
|
169
|
+
if (e.code === 'ENOENT')
|
|
170
|
+
return { outcome: 'missing' };
|
|
171
|
+
if (typeof e.code === 'number') {
|
|
172
|
+
return { outcome: 'ran', exitCode: e.code, output: `${e.stdout ?? ''}\n${e.stderr ?? ''}` };
|
|
173
|
+
}
|
|
174
|
+
// Abort before the timeout check: `execFile`'s `AbortError` carries no `killed`, so an aborted
|
|
175
|
+
// job would otherwise fall through to the catch-all and read as a machine that answered nothing.
|
|
176
|
+
if (e.code === 'ABORT_ERR' || e.name === 'AbortError') {
|
|
177
|
+
return { outcome: 'failed', reason: 'the job was cancelled before the probe finished' };
|
|
178
|
+
}
|
|
179
|
+
if (e.killed)
|
|
180
|
+
return { outcome: 'failed', reason: 'the probe timed out' };
|
|
181
|
+
if (e.code === 'EACCES' || e.code === 'EPERM') {
|
|
182
|
+
return { outcome: 'failed', reason: 'the probe was not permitted to run here' };
|
|
183
|
+
}
|
|
184
|
+
return { outcome: 'failed', reason: 'the probe could not be run' };
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The first version-shaped token in a `--version` banner, capped so a chatty tool can't run away.
|
|
188
|
+
*
|
|
189
|
+
* Deliberately no leading `\b`: the token is routinely glued to a letter (`v26.7.0`, `helm
|
|
190
|
+
* v3.16.2`), and a word boundary between `v` and `2` does not exist, so the anchored form skipped
|
|
191
|
+
* the major and reported `26.7.0` as `7.0`.
|
|
192
|
+
*/
|
|
193
|
+
function firstVersion(output) {
|
|
194
|
+
const match = /\d+\.\d+(?:\.\d+)?(?:[-+][\w.]+)?/.exec(output);
|
|
195
|
+
return match ? match[0].slice(0, 24) : undefined;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* An ordinary tool's presence: it ran (whatever it exited with) ⇒ installed, `ENOENT` ⇒ not
|
|
199
|
+
* installed, anything else ⇒ the probe failed and we do not know. `helm version` on a host with
|
|
200
|
+
* no cluster config exits non-zero and still proves helm is installed.
|
|
201
|
+
*/
|
|
202
|
+
export function toolPresence(result) {
|
|
203
|
+
if (result.outcome === 'missing')
|
|
204
|
+
return { status: 'absent' };
|
|
205
|
+
if (result.outcome === 'failed')
|
|
206
|
+
return { status: 'unknown', reason: result.reason };
|
|
207
|
+
if (result.outcome === 'found')
|
|
208
|
+
return { status: 'present' };
|
|
209
|
+
const version = firstVersion(result.output);
|
|
210
|
+
return version ? { status: 'present', version } : { status: 'present' };
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The daemon's presence, which reads the SAME result differently in exactly TWO places, and
|
|
214
|
+
* delegates the rest: duplicating the other three branches to add these two is how a change to
|
|
215
|
+
* how a version is read, or how a failure reason is carried, gets made in one classifier only.
|
|
216
|
+
*
|
|
217
|
+
* - A non-zero exit is "cannot connect to the Docker daemon", so it is an absence rather than the
|
|
218
|
+
* proof-of-install a non-zero exit is everywhere else.
|
|
219
|
+
* - `found` means the Windows oracle located the CLI and ran NOTHING, so the daemon was never
|
|
220
|
+
* asked. That is an unknown. Reading it as `toolPresence` would is what made `where docker`
|
|
221
|
+
* enough to tell an agent `docker build` works here.
|
|
222
|
+
*/
|
|
223
|
+
export function daemonPresence(result) {
|
|
224
|
+
if (result.outcome === 'found') {
|
|
225
|
+
return {
|
|
226
|
+
status: 'unknown',
|
|
227
|
+
reason: 'only the docker CLI was located; the daemon was not asked',
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
if (result.outcome === 'ran' && result.exitCode !== 0)
|
|
231
|
+
return { status: 'absent' };
|
|
232
|
+
return toolPresence(result);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Whether anything is supposed to serve a Docker daemon here, which `entrypoint.sh` makes knowable:
|
|
236
|
+
* it EXPORTS `DOCKER_HOST` both when a pool hands us an external daemon and when it starts the
|
|
237
|
+
* rootless one, and leaves it unset in the one branch where no daemon is coming. Reading the
|
|
238
|
+
* machine's own configuration, not per-job state, so `process.env` is the right source here.
|
|
239
|
+
*/
|
|
240
|
+
function daemonIsConfigured() {
|
|
241
|
+
return (process.env.DOCKER_HOST ?? '').trim() !== '';
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Probe the machine. EVERYTHING runs concurrently, the daemon included.
|
|
245
|
+
*
|
|
246
|
+
* The daemon probe used to be sequenced after the tool pass, so that a missing CLI could
|
|
247
|
+
* short-circuit it. That bought a cleaner `absent` and charged the whole job for it: two ceilings
|
|
248
|
+
* back to back on the critical path, ahead of the clone, on every dispatch. It is unnecessary,
|
|
249
|
+
* because `docker info` answers the CLI question too: with no CLI on PATH the spawn comes back
|
|
250
|
+
* `missing`, which {@link daemonPresence} already reads as the same absence the short-circuit
|
|
251
|
+
* produced. Asking directly also removes a second defect the sequencing needed: reading the CLI's
|
|
252
|
+
* presence back out of `tools` defaulted a MISSING list entry to `absent`, so trimming the curated
|
|
253
|
+
* list would have had the block assert "NO Docker daemon is reachable" with nothing ever asked.
|
|
254
|
+
*/
|
|
255
|
+
export async function probeEnvironment(run, opts = {}) {
|
|
256
|
+
const [tools, dockerDaemon] = await Promise.all([
|
|
257
|
+
Promise.all(PROBES.map(async (probe) => ({
|
|
258
|
+
name: probe.name,
|
|
259
|
+
showVersion: probe.showVersion,
|
|
260
|
+
presence: toolPresence(await run(probe.command, probe.args)),
|
|
261
|
+
}))),
|
|
262
|
+
probeDockerDaemon(run, opts),
|
|
263
|
+
]);
|
|
264
|
+
return { tools, dockerDaemon };
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Ask the daemon itself, and do not mistake a daemon that is STARTING for one that is not there.
|
|
268
|
+
*
|
|
269
|
+
* `entrypoint.sh` launches `dockerd-rootless.sh` detached and `exec`s the server without waiting,
|
|
270
|
+
* so `/health` answers (and the backend POSTs `/run`) seconds before the rootless daemon has
|
|
271
|
+
* finished its userns and fuse-overlayfs setup. Until then there is no socket, so `docker info`
|
|
272
|
+
* is refused AT ONCE. Read as a plain absence that produced the block's most consequential line,
|
|
273
|
+
* "NO Docker daemon is reachable ... will fail here whatever the CLI reports", on a machine whose
|
|
274
|
+
* daemon was up moments later, which authoritatively told a tester step not to try.
|
|
275
|
+
*
|
|
276
|
+
* Whether a daemon is CONFIGURED here is what separates the two (see {@link daemonIsConfigured}):
|
|
277
|
+
*
|
|
278
|
+
* - refused with no `DOCKER_HOST` ⇒ ABSENT. Nothing was coming. This is also the developer's
|
|
279
|
+
* laptop with Docker Desktop shut down, where absent is exactly right.
|
|
280
|
+
* - refused with `DOCKER_HOST` set ⇒ one short retry, then UNKNOWN. A daemon was expected and
|
|
281
|
+
* has not answered yet, which is neither of the other two answers, and the rendered line turns
|
|
282
|
+
* it into "try it if you need it" rather than into a prohibition.
|
|
283
|
+
*/
|
|
284
|
+
async function probeDockerDaemon(run, opts) {
|
|
285
|
+
const expected = opts.daemonExpected ?? daemonIsConfigured();
|
|
286
|
+
const ask = async () => daemonPresence(await run(DOCKER_INFO.command, DOCKER_INFO.args));
|
|
287
|
+
const first = await ask();
|
|
288
|
+
if (first.status !== 'absent' || !expected)
|
|
289
|
+
return first;
|
|
290
|
+
await (opts.sleep ?? defaultSleep)(DAEMON_RETRY_DELAY_MS);
|
|
291
|
+
const second = await ask();
|
|
292
|
+
if (second.status !== 'absent')
|
|
293
|
+
return second;
|
|
294
|
+
return {
|
|
295
|
+
status: 'unknown',
|
|
296
|
+
reason: 'a daemon is configured for this machine but had not answered when the job started; it may ' +
|
|
297
|
+
'still be coming up',
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
function defaultSleep(ms) {
|
|
301
|
+
return new Promise((resolve) => {
|
|
302
|
+
setTimeout(resolve, ms);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
/** Render one tool for the `Installed:` line: `node 26.7.0`, or just `jq`. */
|
|
306
|
+
function installedLabel(tool) {
|
|
307
|
+
const version = tool.presence.status === 'present' ? tool.presence.version : undefined;
|
|
308
|
+
return tool.showVersion && version ? `${tool.name} ${version}` : tool.name;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* The block appended to the agent's system prompt. Pure, so what the agent reads is asserted
|
|
312
|
+
* against a probe result rather than against whichever machine the suite happens to run on.
|
|
313
|
+
*
|
|
314
|
+
* Each line is a different KIND of claim, and they are kept apart because collapsing them is the
|
|
315
|
+
* failure this replaces: what is here, what is not, what could not be determined, what the Docker
|
|
316
|
+
* daemon actually said, and, last because it governs everything the other lines omit, that the
|
|
317
|
+
* list is bounded.
|
|
318
|
+
*/
|
|
319
|
+
export function renderEnvironmentInventory(inventory) {
|
|
320
|
+
const installed = inventory.tools.filter((t) => t.presence.status === 'present');
|
|
321
|
+
const absent = inventory.tools.filter((t) => t.presence.status === 'absent');
|
|
322
|
+
// Flattened while the presence is still narrowed: the REASON is what makes an entry an unknown
|
|
323
|
+
// rather than an absence, so it has to reach the rendered line, and picking it back out of a
|
|
324
|
+
// `ProbedTool` later needs a re-narrowing branch nothing can ever take.
|
|
325
|
+
const unknown = inventory.tools.flatMap((t) => t.presence.status === 'unknown' ? [`${t.name} (${t.presence.reason})`] : []);
|
|
326
|
+
const lines = [
|
|
327
|
+
'ENVIRONMENT INVENTORY: the platform probed this machine when the job started. These are ' +
|
|
328
|
+
'facts about where you are running; do not spend turns re-checking them.',
|
|
329
|
+
];
|
|
330
|
+
if (installed.length > 0) {
|
|
331
|
+
lines.push(`Installed: ${installed.map(installedLabel).join(', ')}.`);
|
|
332
|
+
}
|
|
333
|
+
if (absent.length > 0) {
|
|
334
|
+
lines.push(`Not installed: ${absent.map((t) => t.name).join(', ')}. You are unprivileged here, so a ` +
|
|
335
|
+
'SYSTEM install of one of these will fail, and a tool the platform did not provide is not ' +
|
|
336
|
+
"a defect in the work. Where one of them is the project's own package manager, reaching " +
|
|
337
|
+
'it for that project alone (`npx <manager>`, a repo-local install) is fine and is the one ' +
|
|
338
|
+
'thing worth trying.');
|
|
339
|
+
}
|
|
340
|
+
if (unknown.length > 0) {
|
|
341
|
+
lines.push('Could not be determined, because the PROBE itself failed. Treat each as neither present ' +
|
|
342
|
+
`nor absent: ${unknown.join(', ')}.`);
|
|
343
|
+
}
|
|
344
|
+
lines.push(dockerDaemonLine(inventory.dockerDaemon));
|
|
345
|
+
// Stated as a FACT and not as an errand. This block is appended to the system prompt after the
|
|
346
|
+
// effort-report directive, whose closing sentences are the prompt's ordering rule (write the
|
|
347
|
+
// sentinel, then reply, and no tool call after the reply). "Check for that one yourself before
|
|
348
|
+
// relying on it" sat after that rule and invited exactly the trailing tool call it forbids, which
|
|
349
|
+
// is the displacement that once cost an architect run its design. The agent needs to know the
|
|
350
|
+
// list is bounded; when to go looking is the sandbox directive's business, and it is not last.
|
|
351
|
+
lines.push('Nothing else was probed. A tool named on none of these lines is unknown to the platform ' +
|
|
352
|
+
'rather than missing.');
|
|
353
|
+
return lines.join('\n');
|
|
354
|
+
}
|
|
355
|
+
/** The Docker line, which says something different in each of the three cases. */
|
|
356
|
+
function dockerDaemonLine(daemon) {
|
|
357
|
+
if (daemon.status === 'present') {
|
|
358
|
+
const server = daemon.version ? ` (server ${daemon.version})` : '';
|
|
359
|
+
return (`A Docker daemon is reachable${server}: \`docker build\`, \`docker run\` and ` +
|
|
360
|
+
'`docker compose up` work here.');
|
|
361
|
+
}
|
|
362
|
+
if (daemon.status === 'unknown') {
|
|
363
|
+
return ('Whether a Docker daemon is reachable could not be determined ' +
|
|
364
|
+
`(${daemon.reason}): try it if you need it, and do not read a failure as a defect in the work.`);
|
|
365
|
+
}
|
|
366
|
+
return ('NO Docker daemon is reachable: `docker build`, `docker run` and `docker compose up` will ' +
|
|
367
|
+
'fail here whatever the CLI reports. Produce the Dockerfile or compose file you were asked ' +
|
|
368
|
+
'for, say in one line that you could not build it here, and move on.');
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Probe the machine and fold the inventory onto `systemPrompt`. THE composition point: the harness
|
|
372
|
+
* calls this once per job, in `handleAgent`, before any mode branches, so every mode and every CLI
|
|
373
|
+
* (claude-code, codex, Pi) inherits it from the job's own system prompt instead of each folding a
|
|
374
|
+
* copy, which is how one of them would silently end up without it, and another with it twice.
|
|
375
|
+
*
|
|
376
|
+
* It rides the SYSTEM prompt rather than the task prompt so it survives the claude runner's
|
|
377
|
+
* argv-size branch (`carryClaudeSystemPrompt` folds an oversized system prompt into stdin whole),
|
|
378
|
+
* Codex's unconditional fold and Pi's `AGENTS.md` write, none of which can drop part of it.
|
|
379
|
+
*
|
|
380
|
+
* Never throws: an inventory is context, and a job whose probe pass fell over is still a job worth
|
|
381
|
+
* running. A probe that fails is already reported as unknown by construction, so this catch is for
|
|
382
|
+
* the pass itself, and it says so in a log line rather than silently shortening the prompt.
|
|
383
|
+
*/
|
|
384
|
+
export async function appendEnvironmentInventory(systemPrompt, opts = {}) {
|
|
385
|
+
const logger = opts.log ?? log;
|
|
386
|
+
try {
|
|
387
|
+
const inventory = await probeEnvironment(opts.run ?? spawnProbeRunner(opts.signal), {
|
|
388
|
+
...(opts.sleep ? { sleep: opts.sleep } : {}),
|
|
389
|
+
...(opts.daemonExpected === undefined ? {} : { daemonExpected: opts.daemonExpected }),
|
|
390
|
+
});
|
|
391
|
+
logger.info('agent: probed the environment', {
|
|
392
|
+
installed: inventory.tools
|
|
393
|
+
.filter((t) => t.presence.status === 'present')
|
|
394
|
+
.map((t) => t.name)
|
|
395
|
+
.join(','),
|
|
396
|
+
dockerDaemon: inventory.dockerDaemon.status,
|
|
397
|
+
// The unknowns, by NAME: the block tells the agent a probe failed, and this is the only place
|
|
398
|
+
// an operator can see WHICH, since the reason the agent reads is deliberately wordy prose.
|
|
399
|
+
unknown: inventory.tools
|
|
400
|
+
.filter((t) => t.presence.status === 'unknown')
|
|
401
|
+
.map((t) => t.name)
|
|
402
|
+
.join(','),
|
|
403
|
+
});
|
|
404
|
+
return `${systemPrompt}\n\n${renderEnvironmentInventory(inventory)}`;
|
|
405
|
+
}
|
|
406
|
+
catch (err) {
|
|
407
|
+
logger.warn('agent: the environment probe pass failed; dispatching without an inventory', {
|
|
408
|
+
error: err instanceof Error ? err.message : String(err),
|
|
409
|
+
});
|
|
410
|
+
return systemPrompt;
|
|
411
|
+
}
|
|
412
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.137.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",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@cat-factory/kernel": "0.321.0",
|
|
29
|
-
"@cat-factory/server": "0.306.
|
|
29
|
+
"@cat-factory/server": "0.306.6",
|
|
30
30
|
"@cat-factory/spend": "0.16.17",
|
|
31
31
|
"@hono/node-server": "^2.1.1",
|
|
32
32
|
"@types/node": "^26.2.0",
|
package/src/agent.ts
CHANGED
|
@@ -37,6 +37,7 @@ import { runMultiRepoCoding } from './multi-repo-coding.js'
|
|
|
37
37
|
import { validationFailureMessage } from './validation-checks.js'
|
|
38
38
|
import { prepopulateDependencies, withDependencyNote } from './dependency-install.js'
|
|
39
39
|
import { agentCapabilities, mergeEffort } from './agent-shared.js'
|
|
40
|
+
import { appendEnvironmentInventory } from './environment-inventory.js'
|
|
40
41
|
import { runBootstrap } from './bootstrap-mode.js'
|
|
41
42
|
import {
|
|
42
43
|
acquireRepoCheckout,
|
|
@@ -176,9 +177,28 @@ export async function handleAgent(job: AgentJob, opts: RunOptions = {}): Promise
|
|
|
176
177
|
...artifactUploadEnv(job.artifactUpload),
|
|
177
178
|
})
|
|
178
179
|
if (job.mode === 'preview') return await runPreviewMode(job, scoped)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
180
|
+
// THE composition point for the environment inventory (see `environment-inventory.ts`): the
|
|
181
|
+
// machine is probed ONCE here, before any mode branches, and the result is folded onto the
|
|
182
|
+
// job's own system prompt. Every mode, every repair round and all three agent CLIs read that
|
|
183
|
+
// one field, so none of them can end up without the block and none can carry it twice.
|
|
184
|
+
// `preview` returns above because it runs no agent at all, so there is no prompt to fold onto.
|
|
185
|
+
//
|
|
186
|
+
// This sits on the critical path AHEAD of the clone, which is the cost of having one
|
|
187
|
+
// composition point instead of one per mode (each mode owns its own clone, so there is no
|
|
188
|
+
// single post-clone place to put this). The pass is sized for that: everything in it runs
|
|
189
|
+
// concurrently, every probe is a call that answers in milliseconds or is wedged, and the one
|
|
190
|
+
// deliberate wait is a single short retry for a daemon that is still starting.
|
|
191
|
+
|
|
192
|
+
const staged: AgentJob = {
|
|
193
|
+
...job,
|
|
194
|
+
systemPrompt: await appendEnvironmentInventory(job.systemPrompt, {
|
|
195
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
196
|
+
...(opts.log ? { log: opts.log } : {}),
|
|
197
|
+
}),
|
|
198
|
+
}
|
|
199
|
+
return staged.mode === 'coding'
|
|
200
|
+
? await runCodingMode(staged, scoped)
|
|
201
|
+
: await runExploreMode(staged, scoped)
|
|
182
202
|
} finally {
|
|
183
203
|
if (scopeDir) await rm(scopeDir, { recursive: true, force: true }).catch(() => {})
|
|
184
204
|
}
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
import { log, type Logger } from './logger.js'
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// What this machine actually has, probed ONCE per job and stated to the agent.
|
|
7
|
+
//
|
|
8
|
+
// The platform used to tell every agent to discover its own environment ("probe for a tool before
|
|
9
|
+
// relying on it"), and every agent did, repeatedly, and often twice within one run: an architect
|
|
10
|
+
// ran `for c in docker kubectl helm kustomize; do command -v $c; done`, then `docker info`, and
|
|
11
|
+
// the coder it handed off to rediscovered both answers thirty calls later. Four calls out of a
|
|
12
|
+
// forty-call budget, spent on facts the harness holds before the agent's first turn.
|
|
13
|
+
//
|
|
14
|
+
// This is the layer that CAN hold them, and the only one. The backend composes its prompt before a
|
|
15
|
+
// transport is even chosen, and the same job body serves the harness image, a deployment's own
|
|
16
|
+
// image variant and (under `LOCAL_NATIVE_AGENTS`) the developer's own machine, where the
|
|
17
|
+
// toolchain is theirs. So the backend states the POLICY (no cluster credentials; an artifact this
|
|
18
|
+
// environment cannot execute is still a correct artifact) and names no tooling at all, while this
|
|
19
|
+
// file states the FACTS, from a probe of the machine the agent is about to run on.
|
|
20
|
+
//
|
|
21
|
+
// Three rules the block is built around:
|
|
22
|
+
//
|
|
23
|
+
// - ABSENT and UNKNOWN must not render the same. Only a spawn that came back `ENOENT` is an
|
|
24
|
+
// absence; a probe that failed says so and is reported as neither present nor absent.
|
|
25
|
+
// - A tool NOT ON THE LIST is unknown too, which the block's last line says. The list is curated
|
|
26
|
+
// (what agents were observed rediscovering), so silence about `terraform` has to read as
|
|
27
|
+
// "nobody looked", never as "it isn't there".
|
|
28
|
+
// - The Docker DAEMON is a separate fact from the docker CLI, and it is the one that decides
|
|
29
|
+
// anything: `command -v docker` succeeds in this image and `docker build` still fails, which
|
|
30
|
+
// is the half-truth the old instruction produced. It is answered by RUNNING `docker info`, and
|
|
31
|
+
// a daemon this machine is CONFIGURED for but which has not answered yet is a fourth state
|
|
32
|
+
// that resolves to `unknown`, never to the absence a refused connection looks like: the
|
|
33
|
+
// image's daemon is started in the background and the job begins before it is ready.
|
|
34
|
+
//
|
|
35
|
+
// Deliberately NOT here: the agent's own tools (web search, file tools, MCP servers). Those are
|
|
36
|
+
// the CLI's, they differ per harness, and each is already stated where it is true. Claiming one
|
|
37
|
+
// here would be the platform asserting a capability the dispatch may not have delivered, which is
|
|
38
|
+
// the defect this whole change exists to remove.
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
const execFileAsync = promisify(execFile)
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What running one probe did, kept as RAW as the spawn: whether the binary ran, and with what.
|
|
45
|
+
* Classification into presence lives in pure code below, because the two callers classify the
|
|
46
|
+
* same result differently: a non-zero exit proves an ordinary tool is installed and proves the
|
|
47
|
+
* Docker daemon is not reachable.
|
|
48
|
+
*
|
|
49
|
+
* `found` is the outcome with no exit code, and it exists so that the Windows second look cannot
|
|
50
|
+
* be mistaken for a run: the platform's own oracle located the binary and NOTHING WAS EXECUTED.
|
|
51
|
+
* Collapsing it into `ran` with a synthesised `exitCode: 0` is what let `docker info` report a
|
|
52
|
+
* reachable daemon on the strength of `where docker`, which is the CLI-for-daemon half-truth this
|
|
53
|
+
* whole file exists to remove.
|
|
54
|
+
*/
|
|
55
|
+
export type ProbeResult =
|
|
56
|
+
| { outcome: 'ran'; exitCode: number; output: string }
|
|
57
|
+
| { outcome: 'found' }
|
|
58
|
+
| { outcome: 'missing' }
|
|
59
|
+
| { outcome: 'failed'; reason: string }
|
|
60
|
+
|
|
61
|
+
/** Run one probe. Injected so the suite drives every branch without needing the real binaries. */
|
|
62
|
+
export type ProbeRunner = (command: string, args: string[]) => Promise<ProbeResult>
|
|
63
|
+
|
|
64
|
+
/** What one probe learned. `unknown` is a THIRD answer, never folded into `absent`. */
|
|
65
|
+
export type ToolPresence =
|
|
66
|
+
| { status: 'present'; version?: string }
|
|
67
|
+
| { status: 'absent' }
|
|
68
|
+
| { status: 'unknown'; reason: string }
|
|
69
|
+
|
|
70
|
+
/** One probed entry: the name the agent would type, and what came back. */
|
|
71
|
+
export interface ProbedTool {
|
|
72
|
+
name: string
|
|
73
|
+
presence: ToolPresence
|
|
74
|
+
/** Whether the rendered line carries the version. Off where a yes/no is the whole answer. */
|
|
75
|
+
showVersion: boolean
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Everything one job's probe pass learned about the machine it is about to run on. */
|
|
79
|
+
export interface EnvironmentInventory {
|
|
80
|
+
tools: ProbedTool[]
|
|
81
|
+
/**
|
|
82
|
+
* Whether a Docker daemon actually answered: the readiness fact, not the CLI's presence. The
|
|
83
|
+
* image's `entrypoint.sh` starts a rootless daemon BEST-EFFORT and execs the server without
|
|
84
|
+
* waiting for it, so at job start this probe is the only thing that knows how that went, and
|
|
85
|
+
* "has not answered yet" is one of its answers (see {@link probeDockerDaemon}).
|
|
86
|
+
*/
|
|
87
|
+
dockerDaemon: ToolPresence
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The curated probe list: the toolchain an agent assumes, plus the tools runs were observed
|
|
92
|
+
* burning calls to discover. Kept SHORT on purpose. Every entry is a spawn and a few more words
|
|
93
|
+
* in every system prompt, and the block's closing line keeps an unlisted tool honestly unknown
|
|
94
|
+
* rather than implicitly absent.
|
|
95
|
+
*
|
|
96
|
+
* `showVersion` is on where the number changes what the agent does (comparing against a target's
|
|
97
|
+
* declared engines, a language's syntax level) and off where presence is the whole question.
|
|
98
|
+
*/
|
|
99
|
+
const PROBES: ReadonlyArray<{
|
|
100
|
+
name: string
|
|
101
|
+
command: string
|
|
102
|
+
args: string[]
|
|
103
|
+
showVersion: boolean
|
|
104
|
+
}> = [
|
|
105
|
+
{ name: 'node', command: 'node', args: ['--version'], showVersion: true },
|
|
106
|
+
{ name: 'npm', command: 'npm', args: ['--version'], showVersion: true },
|
|
107
|
+
{ name: 'pnpm', command: 'pnpm', args: ['--version'], showVersion: true },
|
|
108
|
+
{ name: 'git', command: 'git', args: ['--version'], showVersion: true },
|
|
109
|
+
{ name: 'python3', command: 'python3', args: ['--version'], showVersion: true },
|
|
110
|
+
{ name: 'docker', command: 'docker', args: ['--version'], showVersion: false },
|
|
111
|
+
{ name: 'jq', command: 'jq', args: ['--version'], showVersion: false },
|
|
112
|
+
{ name: 'rg', command: 'rg', args: ['--version'], showVersion: false },
|
|
113
|
+
{ name: 'curl', command: 'curl', args: ['--version'], showVersion: false },
|
|
114
|
+
{ name: 'make', command: 'make', args: ['--version'], showVersion: false },
|
|
115
|
+
{ name: 'kubectl', command: 'kubectl', args: ['version', '--client'], showVersion: false },
|
|
116
|
+
{ name: 'helm', command: 'helm', args: ['version'], showVersion: false },
|
|
117
|
+
{ name: 'kustomize', command: 'kustomize', args: ['version'], showVersion: false },
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
/** The daemon readiness probe: the one probe whose EXIT CODE, not its presence, is the answer. */
|
|
121
|
+
const DOCKER_INFO: { command: string; args: string[] } = {
|
|
122
|
+
command: 'docker',
|
|
123
|
+
args: ['info', '--format', '{{.ServerVersion}}'],
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Every probe here is either instant or wedged: a `--version` banner, and a `docker info` that
|
|
128
|
+
* either connects or is REFUSED by a missing socket in milliseconds. So the ceiling is sized to
|
|
129
|
+
* cut a wedged binary out of the job's critical path, not to wait for anything.
|
|
130
|
+
*
|
|
131
|
+
* It used to be ten seconds, on the stated grounds that `docker info` "can genuinely take a
|
|
132
|
+
* moment" against a daemon still coming up. That was wrong in the way that matters: a daemon that
|
|
133
|
+
* is not up yet has no socket to connect to, so docker exits non-zero AT ONCE and the ceiling is
|
|
134
|
+
* never reached. Waiting for a starting daemon is a different problem and is solved where it
|
|
135
|
+
* actually lives, in {@link probeDockerDaemon}'s retry.
|
|
136
|
+
*/
|
|
137
|
+
const PROBE_TIMEOUT_MS = 5_000
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* How long a daemon that was EXPECTED here gets to answer before the block says it could not be
|
|
141
|
+
* determined. One short retry, not a readiness wait: the honest verdict for a daemon still coming
|
|
142
|
+
* up is `unknown`, which the rendered line turns into "try it if you need it", so there is nothing
|
|
143
|
+
* to buy by blocking the agent's first turn any longer than this.
|
|
144
|
+
*/
|
|
145
|
+
const DAEMON_RETRY_DELAY_MS = 1_500
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The real runner: spawn the tool, bounded, and report what the spawn did.
|
|
149
|
+
*
|
|
150
|
+
* The Windows second look is for the NATIVE transport on a developer's own machine, not for the
|
|
151
|
+
* image. `execFile` without a shell resolves `.exe` and not `.cmd`, and Node refuses to spawn a
|
|
152
|
+
* `.cmd` at all without one, so a perfectly installed `npm` or `pnpm` comes back `ENOENT` there and
|
|
153
|
+
* would be stated to the agent as NOT INSTALLED. `where` is the platform's own presence oracle and
|
|
154
|
+
* answers that without running the shim, at the cost of the version, which is the honest trade: the
|
|
155
|
+
* rendered line then names the tool with no number rather than claiming one or denying the tool.
|
|
156
|
+
*
|
|
157
|
+
* It answers PRESENCE and nothing else, which is why it returns `found` and why it ignores `args`
|
|
158
|
+
* without pretending otherwise: `where <command>` locates a binary, so it can say nothing about
|
|
159
|
+
* what that binary would have printed or exited with. The version is not the only thing lost. A
|
|
160
|
+
* probe whose ANSWER is its exit code (`docker info`) gets no answer at all from this branch, and
|
|
161
|
+
* {@link daemonPresence} is where that is turned into an unknown rather than a reachable daemon.
|
|
162
|
+
*/
|
|
163
|
+
export function spawnProbeRunner(signal?: AbortSignal): ProbeRunner {
|
|
164
|
+
const attempt = async (command: string, args: string[]): Promise<ProbeResult> => {
|
|
165
|
+
try {
|
|
166
|
+
const { stdout, stderr } = await execFileAsync(command, args, {
|
|
167
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
168
|
+
maxBuffer: 256 * 1024,
|
|
169
|
+
windowsHide: true,
|
|
170
|
+
...(signal ? { signal } : {}),
|
|
171
|
+
})
|
|
172
|
+
return { outcome: 'ran', exitCode: 0, output: `${stdout}\n${stderr}` }
|
|
173
|
+
} catch (err) {
|
|
174
|
+
return spawnFailure(err)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return async (command, args) => {
|
|
178
|
+
const first = await attempt(command, args)
|
|
179
|
+
if (first.outcome !== 'missing' || process.platform !== 'win32') return first
|
|
180
|
+
return readOracle(await attempt('where', [command]))
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* What the `where` oracle's own result says about the TOOL. Pure, and separate from the spawn, so
|
|
186
|
+
* every branch is asserted on any platform: reproducing the failing one for real needs a process
|
|
187
|
+
* whose PATH cannot reach `where`, and mutating the live `PATH` to get one leaks into whatever test
|
|
188
|
+
* runs next (on Windows `PATH` and `Path` are one variable, so restoring them is not symmetric).
|
|
189
|
+
*
|
|
190
|
+
* The distinction the branches exist for: `where` exiting non-zero IS the tool's absence, but
|
|
191
|
+
* `where` not answering at all says nothing about the tool, so that becomes a FAILED probe. Passing
|
|
192
|
+
* the oracle's result straight back reported the ORACLE's own `missing` as the tool's, which put a
|
|
193
|
+
* fully installed npm on the "Not installed" line on any host where `where` cannot be spawned.
|
|
194
|
+
*
|
|
195
|
+
* A located tool is `found`, never a synthesised `ran` with `exitCode: 0`: the oracle located a
|
|
196
|
+
* binary and executed nothing, so it cannot answer for a probe whose answer IS its exit code.
|
|
197
|
+
*/
|
|
198
|
+
export function readOracle(located: ProbeResult): ProbeResult {
|
|
199
|
+
if (located.outcome === 'ran') {
|
|
200
|
+
return located.exitCode === 0 ? { outcome: 'found' } : { outcome: 'missing' }
|
|
201
|
+
}
|
|
202
|
+
if (located.outcome === 'found') return located
|
|
203
|
+
return {
|
|
204
|
+
outcome: 'failed',
|
|
205
|
+
reason:
|
|
206
|
+
located.outcome === 'failed'
|
|
207
|
+
? located.reason
|
|
208
|
+
: 'the tool could not be spawned, and `where` could not be run to locate it',
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Read a rejected `execFile` back into a {@link ProbeResult}.
|
|
214
|
+
*
|
|
215
|
+
* `ENOENT` is the ONLY absence: the binary is not on PATH. A numeric `code` means the binary RAN
|
|
216
|
+
* and exited non-zero, which is a different fact and belongs to whoever asked. Everything else
|
|
217
|
+
* (a timeout, where the child is killed and there is no exit code; `EACCES`; an aborted job) is a
|
|
218
|
+
* failure OF THE PROBE, which is what keeps it out of the absent list.
|
|
219
|
+
*
|
|
220
|
+
* Every reason is WRITTEN HERE, in words, and the raw `code` is never one of them. The reason is
|
|
221
|
+
* rendered verbatim into the agent's system prompt, so passing `String(e.code)` through published
|
|
222
|
+
* `ABORT_ERR` (a cancelled job, on all thirteen entries at once), `EACCES` and
|
|
223
|
+
* `ERR_CHILD_PROCESS_STDIO_MAXBUFFER` to a model as prose. A code nobody has mapped yet says only
|
|
224
|
+
* that the probe did not run, which is the whole of what the block needs from it.
|
|
225
|
+
*/
|
|
226
|
+
function spawnFailure(err: unknown): ProbeResult {
|
|
227
|
+
const e = err as NodeJS.ErrnoException & { killed?: boolean; stdout?: string; stderr?: string }
|
|
228
|
+
if (e.code === 'ENOENT') return { outcome: 'missing' }
|
|
229
|
+
if (typeof e.code === 'number') {
|
|
230
|
+
return { outcome: 'ran', exitCode: e.code, output: `${e.stdout ?? ''}\n${e.stderr ?? ''}` }
|
|
231
|
+
}
|
|
232
|
+
// Abort before the timeout check: `execFile`'s `AbortError` carries no `killed`, so an aborted
|
|
233
|
+
// job would otherwise fall through to the catch-all and read as a machine that answered nothing.
|
|
234
|
+
if (e.code === 'ABORT_ERR' || (e as Error).name === 'AbortError') {
|
|
235
|
+
return { outcome: 'failed', reason: 'the job was cancelled before the probe finished' }
|
|
236
|
+
}
|
|
237
|
+
if (e.killed) return { outcome: 'failed', reason: 'the probe timed out' }
|
|
238
|
+
if (e.code === 'EACCES' || e.code === 'EPERM') {
|
|
239
|
+
return { outcome: 'failed', reason: 'the probe was not permitted to run here' }
|
|
240
|
+
}
|
|
241
|
+
return { outcome: 'failed', reason: 'the probe could not be run' }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The first version-shaped token in a `--version` banner, capped so a chatty tool can't run away.
|
|
246
|
+
*
|
|
247
|
+
* Deliberately no leading `\b`: the token is routinely glued to a letter (`v26.7.0`, `helm
|
|
248
|
+
* v3.16.2`), and a word boundary between `v` and `2` does not exist, so the anchored form skipped
|
|
249
|
+
* the major and reported `26.7.0` as `7.0`.
|
|
250
|
+
*/
|
|
251
|
+
function firstVersion(output: string): string | undefined {
|
|
252
|
+
const match = /\d+\.\d+(?:\.\d+)?(?:[-+][\w.]+)?/.exec(output)
|
|
253
|
+
return match ? match[0].slice(0, 24) : undefined
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* An ordinary tool's presence: it ran (whatever it exited with) ⇒ installed, `ENOENT` ⇒ not
|
|
258
|
+
* installed, anything else ⇒ the probe failed and we do not know. `helm version` on a host with
|
|
259
|
+
* no cluster config exits non-zero and still proves helm is installed.
|
|
260
|
+
*/
|
|
261
|
+
export function toolPresence(result: ProbeResult): ToolPresence {
|
|
262
|
+
if (result.outcome === 'missing') return { status: 'absent' }
|
|
263
|
+
if (result.outcome === 'failed') return { status: 'unknown', reason: result.reason }
|
|
264
|
+
if (result.outcome === 'found') return { status: 'present' }
|
|
265
|
+
const version = firstVersion(result.output)
|
|
266
|
+
return version ? { status: 'present', version } : { status: 'present' }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The daemon's presence, which reads the SAME result differently in exactly TWO places, and
|
|
271
|
+
* delegates the rest: duplicating the other three branches to add these two is how a change to
|
|
272
|
+
* how a version is read, or how a failure reason is carried, gets made in one classifier only.
|
|
273
|
+
*
|
|
274
|
+
* - A non-zero exit is "cannot connect to the Docker daemon", so it is an absence rather than the
|
|
275
|
+
* proof-of-install a non-zero exit is everywhere else.
|
|
276
|
+
* - `found` means the Windows oracle located the CLI and ran NOTHING, so the daemon was never
|
|
277
|
+
* asked. That is an unknown. Reading it as `toolPresence` would is what made `where docker`
|
|
278
|
+
* enough to tell an agent `docker build` works here.
|
|
279
|
+
*/
|
|
280
|
+
export function daemonPresence(result: ProbeResult): ToolPresence {
|
|
281
|
+
if (result.outcome === 'found') {
|
|
282
|
+
return {
|
|
283
|
+
status: 'unknown',
|
|
284
|
+
reason: 'only the docker CLI was located; the daemon was not asked',
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (result.outcome === 'ran' && result.exitCode !== 0) return { status: 'absent' }
|
|
288
|
+
return toolPresence(result)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** What one probe pass may be told, so the suite drives the daemon's retry without waiting on it. */
|
|
292
|
+
export interface ProbeEnvironmentOptions {
|
|
293
|
+
/** Injected so tests exercise the retry without paying {@link DAEMON_RETRY_DELAY_MS}. */
|
|
294
|
+
sleep?: (ms: number) => Promise<void>
|
|
295
|
+
/**
|
|
296
|
+
* Whether a daemon is CONFIGURED to serve this machine, defaulting to {@link daemonIsConfigured}.
|
|
297
|
+
* Stated as the fact rather than as the `DOCKER_HOST` string it is read from, so a test can drive
|
|
298
|
+
* both sides of the branch without an ambient environment variable deciding the outcome for it.
|
|
299
|
+
*/
|
|
300
|
+
daemonExpected?: boolean
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Whether anything is supposed to serve a Docker daemon here, which `entrypoint.sh` makes knowable:
|
|
305
|
+
* it EXPORTS `DOCKER_HOST` both when a pool hands us an external daemon and when it starts the
|
|
306
|
+
* rootless one, and leaves it unset in the one branch where no daemon is coming. Reading the
|
|
307
|
+
* machine's own configuration, not per-job state, so `process.env` is the right source here.
|
|
308
|
+
*/
|
|
309
|
+
function daemonIsConfigured(): boolean {
|
|
310
|
+
return (process.env.DOCKER_HOST ?? '').trim() !== ''
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Probe the machine. EVERYTHING runs concurrently, the daemon included.
|
|
315
|
+
*
|
|
316
|
+
* The daemon probe used to be sequenced after the tool pass, so that a missing CLI could
|
|
317
|
+
* short-circuit it. That bought a cleaner `absent` and charged the whole job for it: two ceilings
|
|
318
|
+
* back to back on the critical path, ahead of the clone, on every dispatch. It is unnecessary,
|
|
319
|
+
* because `docker info` answers the CLI question too: with no CLI on PATH the spawn comes back
|
|
320
|
+
* `missing`, which {@link daemonPresence} already reads as the same absence the short-circuit
|
|
321
|
+
* produced. Asking directly also removes a second defect the sequencing needed: reading the CLI's
|
|
322
|
+
* presence back out of `tools` defaulted a MISSING list entry to `absent`, so trimming the curated
|
|
323
|
+
* list would have had the block assert "NO Docker daemon is reachable" with nothing ever asked.
|
|
324
|
+
*/
|
|
325
|
+
export async function probeEnvironment(
|
|
326
|
+
run: ProbeRunner,
|
|
327
|
+
opts: ProbeEnvironmentOptions = {},
|
|
328
|
+
): Promise<EnvironmentInventory> {
|
|
329
|
+
const [tools, dockerDaemon] = await Promise.all([
|
|
330
|
+
Promise.all(
|
|
331
|
+
PROBES.map(async (probe) => ({
|
|
332
|
+
name: probe.name,
|
|
333
|
+
showVersion: probe.showVersion,
|
|
334
|
+
presence: toolPresence(await run(probe.command, probe.args)),
|
|
335
|
+
})),
|
|
336
|
+
),
|
|
337
|
+
probeDockerDaemon(run, opts),
|
|
338
|
+
])
|
|
339
|
+
return { tools, dockerDaemon }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Ask the daemon itself, and do not mistake a daemon that is STARTING for one that is not there.
|
|
344
|
+
*
|
|
345
|
+
* `entrypoint.sh` launches `dockerd-rootless.sh` detached and `exec`s the server without waiting,
|
|
346
|
+
* so `/health` answers (and the backend POSTs `/run`) seconds before the rootless daemon has
|
|
347
|
+
* finished its userns and fuse-overlayfs setup. Until then there is no socket, so `docker info`
|
|
348
|
+
* is refused AT ONCE. Read as a plain absence that produced the block's most consequential line,
|
|
349
|
+
* "NO Docker daemon is reachable ... will fail here whatever the CLI reports", on a machine whose
|
|
350
|
+
* daemon was up moments later, which authoritatively told a tester step not to try.
|
|
351
|
+
*
|
|
352
|
+
* Whether a daemon is CONFIGURED here is what separates the two (see {@link daemonIsConfigured}):
|
|
353
|
+
*
|
|
354
|
+
* - refused with no `DOCKER_HOST` ⇒ ABSENT. Nothing was coming. This is also the developer's
|
|
355
|
+
* laptop with Docker Desktop shut down, where absent is exactly right.
|
|
356
|
+
* - refused with `DOCKER_HOST` set ⇒ one short retry, then UNKNOWN. A daemon was expected and
|
|
357
|
+
* has not answered yet, which is neither of the other two answers, and the rendered line turns
|
|
358
|
+
* it into "try it if you need it" rather than into a prohibition.
|
|
359
|
+
*/
|
|
360
|
+
async function probeDockerDaemon(
|
|
361
|
+
run: ProbeRunner,
|
|
362
|
+
opts: ProbeEnvironmentOptions,
|
|
363
|
+
): Promise<ToolPresence> {
|
|
364
|
+
const expected = opts.daemonExpected ?? daemonIsConfigured()
|
|
365
|
+
const ask = async (): Promise<ToolPresence> =>
|
|
366
|
+
daemonPresence(await run(DOCKER_INFO.command, DOCKER_INFO.args))
|
|
367
|
+
const first = await ask()
|
|
368
|
+
if (first.status !== 'absent' || !expected) return first
|
|
369
|
+
await (opts.sleep ?? defaultSleep)(DAEMON_RETRY_DELAY_MS)
|
|
370
|
+
const second = await ask()
|
|
371
|
+
if (second.status !== 'absent') return second
|
|
372
|
+
return {
|
|
373
|
+
status: 'unknown',
|
|
374
|
+
reason:
|
|
375
|
+
'a daemon is configured for this machine but had not answered when the job started; it may ' +
|
|
376
|
+
'still be coming up',
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function defaultSleep(ms: number): Promise<void> {
|
|
381
|
+
return new Promise((resolve) => {
|
|
382
|
+
setTimeout(resolve, ms)
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Render one tool for the `Installed:` line: `node 26.7.0`, or just `jq`. */
|
|
387
|
+
function installedLabel(tool: ProbedTool): string {
|
|
388
|
+
const version = tool.presence.status === 'present' ? tool.presence.version : undefined
|
|
389
|
+
return tool.showVersion && version ? `${tool.name} ${version}` : tool.name
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The block appended to the agent's system prompt. Pure, so what the agent reads is asserted
|
|
394
|
+
* against a probe result rather than against whichever machine the suite happens to run on.
|
|
395
|
+
*
|
|
396
|
+
* Each line is a different KIND of claim, and they are kept apart because collapsing them is the
|
|
397
|
+
* failure this replaces: what is here, what is not, what could not be determined, what the Docker
|
|
398
|
+
* daemon actually said, and, last because it governs everything the other lines omit, that the
|
|
399
|
+
* list is bounded.
|
|
400
|
+
*/
|
|
401
|
+
export function renderEnvironmentInventory(inventory: EnvironmentInventory): string {
|
|
402
|
+
const installed = inventory.tools.filter((t) => t.presence.status === 'present')
|
|
403
|
+
const absent = inventory.tools.filter((t) => t.presence.status === 'absent')
|
|
404
|
+
// Flattened while the presence is still narrowed: the REASON is what makes an entry an unknown
|
|
405
|
+
// rather than an absence, so it has to reach the rendered line, and picking it back out of a
|
|
406
|
+
// `ProbedTool` later needs a re-narrowing branch nothing can ever take.
|
|
407
|
+
const unknown = inventory.tools.flatMap((t) =>
|
|
408
|
+
t.presence.status === 'unknown' ? [`${t.name} (${t.presence.reason})`] : [],
|
|
409
|
+
)
|
|
410
|
+
const lines = [
|
|
411
|
+
'ENVIRONMENT INVENTORY: the platform probed this machine when the job started. These are ' +
|
|
412
|
+
'facts about where you are running; do not spend turns re-checking them.',
|
|
413
|
+
]
|
|
414
|
+
if (installed.length > 0) {
|
|
415
|
+
lines.push(`Installed: ${installed.map(installedLabel).join(', ')}.`)
|
|
416
|
+
}
|
|
417
|
+
if (absent.length > 0) {
|
|
418
|
+
lines.push(
|
|
419
|
+
`Not installed: ${absent.map((t) => t.name).join(', ')}. You are unprivileged here, so a ` +
|
|
420
|
+
'SYSTEM install of one of these will fail, and a tool the platform did not provide is not ' +
|
|
421
|
+
"a defect in the work. Where one of them is the project's own package manager, reaching " +
|
|
422
|
+
'it for that project alone (`npx <manager>`, a repo-local install) is fine and is the one ' +
|
|
423
|
+
'thing worth trying.',
|
|
424
|
+
)
|
|
425
|
+
}
|
|
426
|
+
if (unknown.length > 0) {
|
|
427
|
+
lines.push(
|
|
428
|
+
'Could not be determined, because the PROBE itself failed. Treat each as neither present ' +
|
|
429
|
+
`nor absent: ${unknown.join(', ')}.`,
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
lines.push(dockerDaemonLine(inventory.dockerDaemon))
|
|
433
|
+
// Stated as a FACT and not as an errand. This block is appended to the system prompt after the
|
|
434
|
+
// effort-report directive, whose closing sentences are the prompt's ordering rule (write the
|
|
435
|
+
// sentinel, then reply, and no tool call after the reply). "Check for that one yourself before
|
|
436
|
+
// relying on it" sat after that rule and invited exactly the trailing tool call it forbids, which
|
|
437
|
+
// is the displacement that once cost an architect run its design. The agent needs to know the
|
|
438
|
+
// list is bounded; when to go looking is the sandbox directive's business, and it is not last.
|
|
439
|
+
lines.push(
|
|
440
|
+
'Nothing else was probed. A tool named on none of these lines is unknown to the platform ' +
|
|
441
|
+
'rather than missing.',
|
|
442
|
+
)
|
|
443
|
+
return lines.join('\n')
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** The Docker line, which says something different in each of the three cases. */
|
|
447
|
+
function dockerDaemonLine(daemon: ToolPresence): string {
|
|
448
|
+
if (daemon.status === 'present') {
|
|
449
|
+
const server = daemon.version ? ` (server ${daemon.version})` : ''
|
|
450
|
+
return (
|
|
451
|
+
`A Docker daemon is reachable${server}: \`docker build\`, \`docker run\` and ` +
|
|
452
|
+
'`docker compose up` work here.'
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
if (daemon.status === 'unknown') {
|
|
456
|
+
return (
|
|
457
|
+
'Whether a Docker daemon is reachable could not be determined ' +
|
|
458
|
+
`(${daemon.reason}): try it if you need it, and do not read a failure as a defect in the work.`
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
return (
|
|
462
|
+
'NO Docker daemon is reachable: `docker build`, `docker run` and `docker compose up` will ' +
|
|
463
|
+
'fail here whatever the CLI reports. Produce the Dockerfile or compose file you were asked ' +
|
|
464
|
+
'for, say in one line that you could not build it here, and move on.'
|
|
465
|
+
)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Probe the machine and fold the inventory onto `systemPrompt`. THE composition point: the harness
|
|
470
|
+
* calls this once per job, in `handleAgent`, before any mode branches, so every mode and every CLI
|
|
471
|
+
* (claude-code, codex, Pi) inherits it from the job's own system prompt instead of each folding a
|
|
472
|
+
* copy, which is how one of them would silently end up without it, and another with it twice.
|
|
473
|
+
*
|
|
474
|
+
* It rides the SYSTEM prompt rather than the task prompt so it survives the claude runner's
|
|
475
|
+
* argv-size branch (`carryClaudeSystemPrompt` folds an oversized system prompt into stdin whole),
|
|
476
|
+
* Codex's unconditional fold and Pi's `AGENTS.md` write, none of which can drop part of it.
|
|
477
|
+
*
|
|
478
|
+
* Never throws: an inventory is context, and a job whose probe pass fell over is still a job worth
|
|
479
|
+
* running. A probe that fails is already reported as unknown by construction, so this catch is for
|
|
480
|
+
* the pass itself, and it says so in a log line rather than silently shortening the prompt.
|
|
481
|
+
*/
|
|
482
|
+
export async function appendEnvironmentInventory(
|
|
483
|
+
systemPrompt: string,
|
|
484
|
+
opts: { signal?: AbortSignal; log?: Logger; run?: ProbeRunner } & ProbeEnvironmentOptions = {},
|
|
485
|
+
): Promise<string> {
|
|
486
|
+
const logger = opts.log ?? log
|
|
487
|
+
try {
|
|
488
|
+
const inventory = await probeEnvironment(opts.run ?? spawnProbeRunner(opts.signal), {
|
|
489
|
+
...(opts.sleep ? { sleep: opts.sleep } : {}),
|
|
490
|
+
...(opts.daemonExpected === undefined ? {} : { daemonExpected: opts.daemonExpected }),
|
|
491
|
+
})
|
|
492
|
+
logger.info('agent: probed the environment', {
|
|
493
|
+
installed: inventory.tools
|
|
494
|
+
.filter((t) => t.presence.status === 'present')
|
|
495
|
+
.map((t) => t.name)
|
|
496
|
+
.join(','),
|
|
497
|
+
dockerDaemon: inventory.dockerDaemon.status,
|
|
498
|
+
// The unknowns, by NAME: the block tells the agent a probe failed, and this is the only place
|
|
499
|
+
// an operator can see WHICH, since the reason the agent reads is deliberately wordy prose.
|
|
500
|
+
unknown: inventory.tools
|
|
501
|
+
.filter((t) => t.presence.status === 'unknown')
|
|
502
|
+
.map((t) => t.name)
|
|
503
|
+
.join(','),
|
|
504
|
+
})
|
|
505
|
+
return `${systemPrompt}\n\n${renderEnvironmentInventory(inventory)}`
|
|
506
|
+
} catch (err) {
|
|
507
|
+
logger.warn('agent: the environment probe pass failed; dispatching without an inventory', {
|
|
508
|
+
error: err instanceof Error ? err.message : String(err),
|
|
509
|
+
})
|
|
510
|
+
return systemPrompt
|
|
511
|
+
}
|
|
512
|
+
}
|