@cat-factory/executor-harness 1.132.3 → 1.135.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 +49 -0
- package/dist/agent-capabilities.d.ts +21 -24
- package/dist/agent-capabilities.js +22 -50
- package/dist/agent-env.d.ts +17 -0
- package/dist/agent-env.js +47 -0
- package/dist/agent-runner.d.ts +18 -2
- package/dist/agent-runner.js +29 -231
- package/dist/agent-shared.d.ts +14 -5
- package/dist/agent-shared.js +14 -5
- package/dist/agent.d.ts +0 -11
- package/dist/agent.js +7 -138
- package/dist/captured-command.d.ts +1 -1
- package/dist/captured-command.js +3 -2
- package/dist/claude-cli.d.ts +90 -0
- package/dist/claude-cli.js +181 -0
- package/dist/claude-home.d.ts +41 -0
- package/dist/claude-home.js +159 -0
- package/dist/coding-agent.d.ts +35 -0
- package/dist/coding-agent.js +213 -41
- package/dist/docker-status.d.ts +89 -0
- package/dist/docker-status.js +147 -0
- package/dist/frontend-infra.js +4 -3
- package/dist/git.d.ts +48 -5
- package/dist/git.js +93 -26
- package/dist/guard-driver.d.ts +71 -0
- package/dist/guard-driver.js +171 -0
- package/dist/harness-server.js +13 -0
- package/dist/infra-standup.d.ts +69 -0
- package/dist/infra-standup.js +182 -0
- package/dist/job.d.ts +10 -0
- package/dist/multi-repo-coding.d.ts +17 -0
- package/dist/multi-repo-coding.js +61 -16
- package/dist/pi-workspace.d.ts +11 -0
- package/dist/pi-workspace.js +126 -57
- package/dist/pi.d.ts +8 -0
- package/dist/pi.js +16 -9
- package/dist/progress-guard.d.ts +56 -10
- package/dist/progress-guard.js +84 -22
- package/dist/runner.d.ts +1 -1
- package/dist/salvage.d.ts +180 -0
- package/dist/salvage.js +289 -0
- package/dist/workspace-probe.d.ts +85 -0
- package/dist/workspace-probe.js +124 -0
- package/package.json +4 -4
- package/src/agent-capabilities.ts +25 -51
- package/src/agent-env.ts +49 -0
- package/src/agent-runner.ts +40 -267
- package/src/agent-shared.ts +16 -5
- package/src/agent.ts +7 -164
- package/src/captured-command.ts +3 -2
- package/src/claude-cli.ts +217 -0
- package/src/claude-home.ts +233 -0
- package/src/coding-agent.ts +252 -44
- package/src/docker-status.ts +201 -0
- package/src/frontend-infra.ts +4 -3
- package/src/git.ts +104 -26
- package/src/guard-driver.ts +203 -0
- package/src/harness-server.ts +13 -0
- package/src/infra-standup.ts +218 -0
- package/src/job.ts +10 -0
- package/src/multi-repo-coding.ts +65 -16
- package/src/pi-workspace.ts +161 -57
- package/src/pi.ts +27 -12
- package/src/progress-guard.ts +110 -34
- package/src/runner.ts +1 -1
- package/src/salvage.ts +407 -0
- package/src/workspace-probe.ts +155 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { promisify } from 'node:util'
|
|
4
|
+
|
|
5
|
+
// What this container knows about its own Docker daemon, as recorded by `entrypoint.sh`.
|
|
6
|
+
//
|
|
7
|
+
// The Tester's local-mode infra stand-up (`docker compose up --wait`) is the only thing in the
|
|
8
|
+
// harness that needs a daemon, and for months there was none: the image installed
|
|
9
|
+
// `docker-ce-rootless-extras` (the wrappers that START a daemon) but never `docker-ce` (the
|
|
10
|
+
// daemon), and the entrypoint backgrounded the start in a subshell where its exit status was
|
|
11
|
+
// unobservable. Every local-infra Tester run degraded to a no-infra run, and the only trace was
|
|
12
|
+
// a compose error in a prompt note. This module is the answer that was missing: the entrypoint
|
|
13
|
+
// probes the daemon once and records the verdict, and everything that would otherwise ASSUME a
|
|
14
|
+
// daemon reads it instead.
|
|
15
|
+
//
|
|
16
|
+
// The recorded verdict describes BOOT, and a container outlives its boot, so nothing refuses on
|
|
17
|
+
// it unconfirmed: `resolveDockerVerdict` re-checks a recorded absence against a live daemon and
|
|
18
|
+
// keeps the record for what only the record holds, the cause and the daemon's own log tail.
|
|
19
|
+
//
|
|
20
|
+
// The three-valued shape is deliberate and is the point (CLAUDE.md, "Degrade loudly"): a daemon
|
|
21
|
+
// that FAILED and a daemon nobody asked about are different facts with different correct
|
|
22
|
+
// reactions, and collapsing them would either refuse stand-ups that work or silently attempt
|
|
23
|
+
// ones that cannot. Only a DECIDED `false` refuses.
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Where `entrypoint.sh` records its verdict. The two halves of one contract: change this and the
|
|
27
|
+
* `DOCKER_STATUS_FILE` default in `entrypoint.sh` together. `HARNESS_DOCKER_STATUS_FILE`
|
|
28
|
+
* overrides both (the acceptance suite and the unit tests point them at a temp file).
|
|
29
|
+
*/
|
|
30
|
+
export const DOCKER_STATUS_FILE = '/tmp/harness-docker-status.json'
|
|
31
|
+
|
|
32
|
+
/** Which daemon the verdict is about. Closed vocabulary, written by `entrypoint.sh`. */
|
|
33
|
+
export type DockerSource =
|
|
34
|
+
/** The rootless daemon this container starts for itself. */
|
|
35
|
+
| 'rootless'
|
|
36
|
+
/** A sidecar/external daemon a self-hosted pool wired in via `DOCKER_HOST`. */
|
|
37
|
+
| 'external'
|
|
38
|
+
/** No daemon in this image at all (no `dockerd` on PATH). */
|
|
39
|
+
| 'none'
|
|
40
|
+
/**
|
|
41
|
+
* Nothing recorded a verdict. NOT a failure: the native host-process transport
|
|
42
|
+
* (`LOCAL_NATIVE_AGENTS`) runs this harness with no entrypoint at all, on a developer's
|
|
43
|
+
* machine where Docker usually works fine.
|
|
44
|
+
*/
|
|
45
|
+
| 'unreported'
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The container's Docker verdict.
|
|
49
|
+
*
|
|
50
|
+
* `available` is THREE-valued on purpose. `undefined` means "not decided" — the entrypoint's
|
|
51
|
+
* bounded wait is still running, or nothing recorded anything (native mode) — and a caller must
|
|
52
|
+
* treat it as it behaved before this existed: attempt, and report what happened. `false` is a
|
|
53
|
+
* DECIDED absence and is the only value anything refuses on.
|
|
54
|
+
*/
|
|
55
|
+
export interface DockerStatus {
|
|
56
|
+
available: boolean | undefined
|
|
57
|
+
source: DockerSource
|
|
58
|
+
/** Why, in the entrypoint's own closed vocabulary (`serving`/`failed`/`missing`/…). */
|
|
59
|
+
reason: string
|
|
60
|
+
/** A human detail for the failing cases: the dockerd log tail, or what was unreachable. */
|
|
61
|
+
detail?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The verdict when nothing recorded one (see {@link DockerSource} `unreported`). */
|
|
65
|
+
const UNREPORTED: DockerStatus = {
|
|
66
|
+
available: undefined,
|
|
67
|
+
source: 'unreported',
|
|
68
|
+
reason: 'no docker status was recorded for this harness process',
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const SOURCES: readonly DockerSource[] = ['rootless', 'external', 'none', 'unreported']
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read the recorded verdict, or {@link UNREPORTED} when there is none.
|
|
75
|
+
*
|
|
76
|
+
* Defensive by design: the file crosses a shell→Node boundary, so an unreadable, truncated or
|
|
77
|
+
* malformed one answers "not decided" rather than throwing. That is the same disposition as an
|
|
78
|
+
* absent file, and it is the safe one — a parse bug here must not turn into a Tester that refuses
|
|
79
|
+
* to stand its dependencies up.
|
|
80
|
+
*/
|
|
81
|
+
export async function readDockerStatus(
|
|
82
|
+
path: string = process.env.HARNESS_DOCKER_STATUS_FILE?.trim() || DOCKER_STATUS_FILE,
|
|
83
|
+
): Promise<DockerStatus> {
|
|
84
|
+
let raw: string
|
|
85
|
+
try {
|
|
86
|
+
raw = await readFile(path, 'utf8')
|
|
87
|
+
} catch {
|
|
88
|
+
return UNREPORTED
|
|
89
|
+
}
|
|
90
|
+
let parsed: unknown
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(raw)
|
|
93
|
+
} catch {
|
|
94
|
+
return UNREPORTED
|
|
95
|
+
}
|
|
96
|
+
if (typeof parsed !== 'object' || parsed === null) return UNREPORTED
|
|
97
|
+
const record = parsed as Record<string, unknown>
|
|
98
|
+
const source = SOURCES.includes(record.source as DockerSource)
|
|
99
|
+
? (record.source as DockerSource)
|
|
100
|
+
: 'unreported'
|
|
101
|
+
const detail = typeof record.detail === 'string' && record.detail ? record.detail : undefined
|
|
102
|
+
return {
|
|
103
|
+
available: typeof record.available === 'boolean' ? record.available : undefined,
|
|
104
|
+
source,
|
|
105
|
+
reason: typeof record.reason === 'string' && record.reason ? record.reason : UNREPORTED.reason,
|
|
106
|
+
...(detail ? { detail } : {}),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The sentence a Tester (and the human reading its step) gets instead of a compose error, when
|
|
112
|
+
* the daemon is decidedly absent. It names the cause the agent could not have discovered and the
|
|
113
|
+
* consequence, because the agent's next move differs: with no daemon there is nothing to retry,
|
|
114
|
+
* and the useful run is the one that tests what it can and flags the dependency gap.
|
|
115
|
+
*
|
|
116
|
+
* TOTAL over {@link DockerSource}, deliberately. `unreported` is not a hypothetical arm: the
|
|
117
|
+
* reader above preserves a recorded `available: false` while degrading a source word this build
|
|
118
|
+
* does not know, so an absence whose source is `unreported` is exactly what a status file written
|
|
119
|
+
* by a NEWER entrypoint produces here. A ternary chain ending in the rootless arm answered that
|
|
120
|
+
* case by naming a daemon nobody said anything about: a guess, in the one sentence whose entire
|
|
121
|
+
* job is to tell a human which thing to go and fix. The `never` arm keeps the compile-time half:
|
|
122
|
+
* adding a source without a sentence stops building.
|
|
123
|
+
*/
|
|
124
|
+
export function describeDockerAbsence(status: DockerStatus): string {
|
|
125
|
+
return status.detail
|
|
126
|
+
? `${absenceCause(status.source)} (${status.detail})`
|
|
127
|
+
: absenceCause(status.source)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function absenceCause(source: DockerSource): string {
|
|
131
|
+
switch (source) {
|
|
132
|
+
case 'none':
|
|
133
|
+
return 'this executor image ships no Docker daemon'
|
|
134
|
+
case 'external':
|
|
135
|
+
return 'the external Docker daemon this container was pointed at is unreachable'
|
|
136
|
+
case 'rootless':
|
|
137
|
+
return 'this container could not start its rootless Docker daemon'
|
|
138
|
+
case 'unreported':
|
|
139
|
+
return 'no Docker daemon answered in this container, and the recorded verdict did not say which one was tried'
|
|
140
|
+
default:
|
|
141
|
+
return unnamedSource(source)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function unnamedSource(source: never): string {
|
|
146
|
+
return `no Docker daemon answered in this container (unrecognised source ${JSON.stringify(source)})`
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Whether a daemon is answering RIGHT NOW. Injected so the unit suite can state either answer. */
|
|
150
|
+
export type DockerProbe = () => Promise<boolean>
|
|
151
|
+
|
|
152
|
+
/** A live probe may not outlast the thing it is guarding; a hung socket is an absent daemon here. */
|
|
153
|
+
const PROBE_TIMEOUT_MS = 10_000
|
|
154
|
+
|
|
155
|
+
const execFileAsync = promisify(execFile)
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The default {@link DockerProbe}: `docker version` talks to the SERVER, unlike the client-only
|
|
159
|
+
* `docker --version`, which answers happily with no daemon at all.
|
|
160
|
+
*/
|
|
161
|
+
export const probeDockerServing: DockerProbe = async () => {
|
|
162
|
+
try {
|
|
163
|
+
await execFileAsync('docker', ['version', '--format', '{{.Server.Version}}'], {
|
|
164
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
165
|
+
})
|
|
166
|
+
return true
|
|
167
|
+
} catch {
|
|
168
|
+
return false
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** What a stand-up is entitled to conclude about the daemon at the moment it is about to run. */
|
|
173
|
+
export interface DockerVerdict {
|
|
174
|
+
/** Three-valued exactly as {@link DockerStatus.available}, and read the same way. */
|
|
175
|
+
available: boolean | undefined
|
|
176
|
+
/** Set only for a CONFIRMED absence: the sentence to refuse with. Absent means proceed. */
|
|
177
|
+
refusal?: string
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Resolve what to do now, from what boot recorded plus what a daemon says today.
|
|
182
|
+
*
|
|
183
|
+
* `entrypoint.sh` probes once, at boot, within a bounded wait. A container outlives that: a warm
|
|
184
|
+
* pool serves many jobs from one, and a sidecar daemon that took longer than the wait allows is
|
|
185
|
+
* serving perfectly well by the second job. Refusing off the recorded verdict alone latches that
|
|
186
|
+
* container into refusing local infra that in fact works, for its whole life, with a stale
|
|
187
|
+
* sentence explaining why. So a recorded absence is a HYPOTHESIS here, and the live probe settles
|
|
188
|
+
* it; the recorded verdict is still what supplies the cause and the daemon's own log tail, which
|
|
189
|
+
* no probe can reconstruct.
|
|
190
|
+
*
|
|
191
|
+
* Only a recorded `false` is re-confirmed. "Not decided" keeps attempting exactly as before: the
|
|
192
|
+
* point of the third value is that nothing turns it into a refusal, and a probe here would.
|
|
193
|
+
*/
|
|
194
|
+
export async function resolveDockerVerdict(
|
|
195
|
+
status: DockerStatus,
|
|
196
|
+
probe: DockerProbe = probeDockerServing,
|
|
197
|
+
): Promise<DockerVerdict> {
|
|
198
|
+
if (status.available !== false) return { available: status.available }
|
|
199
|
+
if (await probe()) return { available: true }
|
|
200
|
+
return { available: false, refusal: describeDockerAbsence(status) }
|
|
201
|
+
}
|
package/src/frontend-infra.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from 'node:path'
|
|
|
5
5
|
import type { FrontendInfraSpec, InfraSetupRecord } from './job.js'
|
|
6
6
|
import type { RunOptions } from './runner.js'
|
|
7
7
|
import { killChildProcess } from './process.js'
|
|
8
|
+
import { agentChildEnv } from './agent-env.js'
|
|
8
9
|
import { pathExists } from './fs-utils.js'
|
|
9
10
|
import { captureRedactedOutput, redactSecrets } from './redact.js'
|
|
10
11
|
import { log, type Logger } from './logger.js'
|
|
@@ -134,7 +135,7 @@ export async function standUpFrontend(
|
|
|
134
135
|
signal,
|
|
135
136
|
timeout: 8 * 60_000,
|
|
136
137
|
maxBuffer: 16 * 1024 * 1024,
|
|
137
|
-
env:
|
|
138
|
+
env: agentChildEnv(jobEnv),
|
|
138
139
|
})
|
|
139
140
|
pushOutput(installed.stdout, installed.stderr)
|
|
140
141
|
|
|
@@ -147,7 +148,7 @@ export async function standUpFrontend(
|
|
|
147
148
|
signal,
|
|
148
149
|
timeout: 12 * 60_000,
|
|
149
150
|
maxBuffer: 16 * 1024 * 1024,
|
|
150
|
-
env:
|
|
151
|
+
env: agentChildEnv(jobEnv, buildEnv),
|
|
151
152
|
})
|
|
152
153
|
pushOutput(built.stdout, built.stderr)
|
|
153
154
|
|
|
@@ -305,7 +306,7 @@ function startServe(
|
|
|
305
306
|
// Reserved names were already filtered from `infra.env` at parse; PORT wins last so
|
|
306
307
|
// the health-check's port is authoritative even if a binding tried to set it.
|
|
307
308
|
// (Spreading an undefined `infra.env` is a no-op, so no `?? {}` fallback is needed.)
|
|
308
|
-
env:
|
|
309
|
+
env: agentChildEnv(infra.env, { PORT: String(servePort) }),
|
|
309
310
|
}),
|
|
310
311
|
'serve',
|
|
311
312
|
logger,
|
package/src/git.ts
CHANGED
|
@@ -604,14 +604,86 @@ export async function commitTrackedEdits(
|
|
|
604
604
|
* --exclude-standard`). The harness deliberately never blanket-stages new files (the
|
|
605
605
|
* agent owns commit selection), so this is exactly what {@link commitTrackedEdits}
|
|
606
606
|
* does NOT capture — a NEW file the agent created but forgot to commit. The caller
|
|
607
|
-
* surfaces it as a warning so that
|
|
607
|
+
* surfaces it as a warning, and the salvage commits it, so that loss is at least observable.
|
|
608
|
+
*
|
|
609
|
+
* `-z` for the reason given on {@link splitNulPaths}: the default output C-QUOTES any path git
|
|
610
|
+
* considers unusual, and a quoted path is not the name of a file. The salvage stages exactly
|
|
611
|
+
* what this returns, so a single accented filename would make its one `git add` exit 128 and
|
|
612
|
+
* discard the whole all-or-nothing salvage.
|
|
608
613
|
*/
|
|
609
614
|
export async function listUntrackedFiles(dir: string, signal?: AbortSignal): Promise<string[]> {
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
+
return splitNulPaths(
|
|
616
|
+
await git(['ls-files', '--others', '--exclude-standard', '-z'], { cwd: dir, signal }),
|
|
617
|
+
)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Split git's NUL-delimited path output into real, unescaped paths.
|
|
622
|
+
*
|
|
623
|
+
* Every path-listing git command here passes `-z`, and this is why: without it git renders a
|
|
624
|
+
* path containing a non-ASCII byte, a quote, a backslash or a newline as a C-QUOTED STRING
|
|
625
|
+
* (`"caf\303\251.ts"`), quotes and octal escapes included. That string is not a filename — feed
|
|
626
|
+
* it back to `git add` and the command exits 128 with `pathspec ... did not match any files`, and
|
|
627
|
+
* `stat` on it reports nothing. `-z` turns the quoting off entirely (a NUL cannot occur in a path,
|
|
628
|
+
* so no escaping is needed) and is the ONLY setting that is correct for every path: `core.quotePath
|
|
629
|
+
* =false` covers the non-ASCII case alone and still quotes the other three.
|
|
630
|
+
*
|
|
631
|
+
* A trailing NUL leaves an empty final field, which is dropped along with any other blank.
|
|
632
|
+
*/
|
|
633
|
+
function splitNulPaths(out: string): string[] {
|
|
634
|
+
return out.split('\0').filter((path) => path !== '')
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* The raw `git status --porcelain -z --untracked-files=all` output for `dir` — every path git
|
|
639
|
+
* considers changed, with untracked files enumerated INDIVIDUALLY rather than collapsed to their
|
|
640
|
+
* directory.
|
|
641
|
+
*
|
|
642
|
+
* The raw string, not a parsed list, because its two consumers want different things from it and
|
|
643
|
+
* the parse ({@link changedPathsFromPorcelain}) is pure and shared: the workspace probe wants "is
|
|
644
|
+
* anything here at all", the salvage wants the paths themselves. Gitignored paths are absent by
|
|
645
|
+
* construction, which is what keeps a dependency install from reading as agent progress.
|
|
646
|
+
*
|
|
647
|
+
* NOTHING IS STAGED, unlike {@link hasAgentChanges}: this runs mid-flight, while the agent is
|
|
648
|
+
* still working, so a `git add -A` here would silently stage files the agent had not chosen and
|
|
649
|
+
* change what a later `commitTrackedEdits` captures.
|
|
650
|
+
*/
|
|
651
|
+
export async function workingTreeStatus(dir: string, signal?: AbortSignal): Promise<string> {
|
|
652
|
+
return git(['status', '--porcelain', '-z', '--untracked-files=all'], { cwd: dir, signal })
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Stage exactly `paths` and commit them with `message`, returning the new commit's sha (or null
|
|
657
|
+
* when git found nothing to commit — a path that vanished between listing and staging).
|
|
658
|
+
*
|
|
659
|
+
* Three separate things stop a path being read as something other than a path. `--` terminates
|
|
660
|
+
* the options, so one beginning with `-` cannot be read as a flag. Each path is a separate argv
|
|
661
|
+
* entry, so no shell ever sees them. And each is prefixed `:(literal)`, which is what `--` does
|
|
662
|
+
* NOT cover: everything after `--` is a PATHSPEC, not a filename, so its leading `:` is read as
|
|
663
|
+
* pathspec magic and its wildcards are matched as a glob. An agent-authored `:notes.txt` makes a
|
|
664
|
+
* bare `git add -- :notes.txt` exit 128 on `did not match any files` — and since this stages every
|
|
665
|
+
* path in ONE command, that one name discards the whole all-or-nothing salvage, exactly as an
|
|
666
|
+
* unquoted accented name did. `:(literal)` matches the entry as itself and nothing else.
|
|
667
|
+
*
|
|
668
|
+
* The caller has already decided WHICH paths belong; this only commits them.
|
|
669
|
+
*/
|
|
670
|
+
export async function commitPaths(
|
|
671
|
+
dir: string,
|
|
672
|
+
paths: string[],
|
|
673
|
+
message: string,
|
|
674
|
+
signal?: AbortSignal,
|
|
675
|
+
): Promise<string | null> {
|
|
676
|
+
if (paths.length === 0) return null
|
|
677
|
+
await git(['add', '--', ...paths.map(literalPathspec)], { cwd: dir, signal })
|
|
678
|
+
const staged = await git(['diff', '--cached', '--name-only'], { cwd: dir, signal })
|
|
679
|
+
if (staged.trim() === '') return null
|
|
680
|
+
await git(['commit', '-m', message], { cwd: dir, signal })
|
|
681
|
+
return headCommit(dir, signal)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** One path as a pathspec that matches only itself — see {@link commitPaths} for why. */
|
|
685
|
+
function literalPathspec(path: string): string {
|
|
686
|
+
return `:(literal)${path}`
|
|
615
687
|
}
|
|
616
688
|
|
|
617
689
|
/**
|
|
@@ -624,14 +696,12 @@ export async function listUntrackedFiles(dir: string, signal?: AbortSignal): Pro
|
|
|
624
696
|
* and enumerating them would cost a multi-megabyte listing to learn a single name.
|
|
625
697
|
*/
|
|
626
698
|
export async function listUntrackedPaths(dir: string, signal?: AbortSignal): Promise<string[]> {
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
699
|
+
return splitNulPaths(
|
|
700
|
+
await git(
|
|
701
|
+
['ls-files', '--others', '--exclude-standard', '--directory', '--no-empty-directory', '-z'],
|
|
702
|
+
{ cwd: dir, signal },
|
|
703
|
+
),
|
|
630
704
|
)
|
|
631
|
-
return out
|
|
632
|
-
.split('\n')
|
|
633
|
-
.map((line) => line.replace(/\r$/, '').trim())
|
|
634
|
-
.filter((path) => path !== '')
|
|
635
705
|
}
|
|
636
706
|
|
|
637
707
|
/**
|
|
@@ -796,21 +866,29 @@ export async function hasDiffAgainstBase(
|
|
|
796
866
|
}
|
|
797
867
|
|
|
798
868
|
/**
|
|
799
|
-
* Parse the paths out of `git status --porcelain` (v1) output.
|
|
800
|
-
*
|
|
801
|
-
*
|
|
802
|
-
*
|
|
869
|
+
* Parse the paths out of `git status --porcelain -z` (v1) output.
|
|
870
|
+
*
|
|
871
|
+
* `-z` rather than the default, for the reason given on {@link splitNulPaths}: the default
|
|
872
|
+
* C-QUOTES any path git considers unusual, so `caf\u00e9.ts` arrives as the seven-character
|
|
873
|
+
* literal `"caf\303\251.ts"` and every consumer that then touches the file misses it. In `-z`
|
|
874
|
+
* every field is a real path and nothing is escaped.
|
|
875
|
+
*
|
|
876
|
+
* Each NUL-terminated field is `XY <path>`. A rename or copy (`R`/`C` in either status column)
|
|
877
|
+
* spends a SECOND field on its original path, which is consumed and dropped: we keep the new
|
|
878
|
+
* path, the one that now exists in the tree. Pure so the no-op detection and the workspace
|
|
879
|
+
* probe's sentinel rule can be tested without spawning git.
|
|
803
880
|
*/
|
|
804
881
|
export function changedPathsFromPorcelain(status: string): string[] {
|
|
882
|
+
const fields = status.split('\0')
|
|
805
883
|
const paths: string[] = []
|
|
806
|
-
for (
|
|
807
|
-
const
|
|
808
|
-
if (
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
if (
|
|
812
|
-
|
|
813
|
-
|
|
884
|
+
for (let index = 0; index < fields.length; index++) {
|
|
885
|
+
const entry = fields[index] ?? ''
|
|
886
|
+
if (entry === '') continue
|
|
887
|
+
// `XY ` then the path. A field too short to hold both is not a status entry (a stray
|
|
888
|
+
// trailing fragment), so there is no path in it to keep.
|
|
889
|
+
if (entry.length <= 3) continue
|
|
890
|
+
if (entry[0] === 'R' || entry[0] === 'C' || entry[1] === 'R' || entry[1] === 'C') index++
|
|
891
|
+
paths.push(entry.slice(3))
|
|
814
892
|
}
|
|
815
893
|
return paths
|
|
816
894
|
}
|
|
@@ -824,7 +902,7 @@ export function changedPathsFromPorcelain(status: string): string[] {
|
|
|
824
902
|
*/
|
|
825
903
|
export async function hasAgentChanges(dir: string, signal?: AbortSignal): Promise<boolean> {
|
|
826
904
|
await git(['add', '-A'], { cwd: dir, signal })
|
|
827
|
-
const status = await git(['status', '--porcelain'], { cwd: dir, signal })
|
|
905
|
+
const status = await git(['status', '--porcelain', '-z'], { cwd: dir, signal })
|
|
828
906
|
return changedPathsFromPorcelain(status).length > 0
|
|
829
907
|
}
|
|
830
908
|
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { ProgressGuard, type ProgressGuardLimits } from './progress-guard.js'
|
|
2
|
+
import type { WorkspaceProbe } from './workspace-probe.js'
|
|
3
|
+
import { log as defaultLog, type Logger } from './logger.js'
|
|
4
|
+
|
|
5
|
+
// The bridge between the SYNCHRONOUS {@link ProgressGuard} and the ASYNCHRONOUS evidence one of
|
|
6
|
+
// its bounds needs. Both runners feed the guard from a sync stream handler (`pi.ts`'s JSONL line
|
|
7
|
+
// reader, `agent-runner.ts`'s tool_result pairing) and neither can await inside one, so the driver
|
|
8
|
+
// owns the probe's lifetime instead. Shared rather than copied per runner: a decision this one
|
|
9
|
+
// ("has the run stopped making progress") must come out the same way on both, which is why
|
|
10
|
+
// `ProgressGuard` itself was extracted in the first place.
|
|
11
|
+
|
|
12
|
+
/** The whole cause chain of a thrown value, one line, so a probe failure names what actually broke. */
|
|
13
|
+
function describeCause(error: unknown): string {
|
|
14
|
+
const parts: string[] = []
|
|
15
|
+
let current: unknown = error
|
|
16
|
+
for (let depth = 0; depth < 8 && current !== undefined && current !== null; depth++) {
|
|
17
|
+
parts.push(current instanceof Error ? current.message : String(current))
|
|
18
|
+
current = current instanceof Error ? (current.cause as unknown) : undefined
|
|
19
|
+
}
|
|
20
|
+
return parts.filter((part) => part !== '').join(': ')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One run's guard plus the async settlement of the bound the stream alone cannot decide. */
|
|
24
|
+
export interface GuardDriver {
|
|
25
|
+
/** Feed one tool-call signal (name + error flag) — the claude-code runner's shape. */
|
|
26
|
+
observeSignal: (tool: { name: string; isError: boolean }) => void
|
|
27
|
+
/** Feed one parsed Pi `--mode json` event; a non-tool-call event is a no-op. */
|
|
28
|
+
observeEvent: (event: Record<string, unknown>) => void
|
|
29
|
+
/** Whether the guard has decided to kill this run. */
|
|
30
|
+
aborted: () => boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Drive one run's {@link ProgressGuard}, resolving the one verdict the stream cannot settle.
|
|
35
|
+
*
|
|
36
|
+
* An `abort` verdict fires {@link onAbort} immediately: every streak bound (consecutive errors /
|
|
37
|
+
* web calls / MCP calls / non-action calls) reads only the stream, so the stream is all the
|
|
38
|
+
* evidence there is.
|
|
39
|
+
*
|
|
40
|
+
* A `needs-workspace-evidence` verdict — the no-edit bound — starts a probe of the working tree
|
|
41
|
+
* and acts on its answer:
|
|
42
|
+
*
|
|
43
|
+
* - MUTATED: the run has changed the repository, whichever tool it used. The bound is satisfied
|
|
44
|
+
* permanently, exactly as a recognised edit-tool call satisfies it, so no second probe is ever
|
|
45
|
+
* made and the run continues.
|
|
46
|
+
* - CLEAN: abort, with the evidence in the message.
|
|
47
|
+
* - THREW: inconclusive, which is neither a pass nor a fail. The bound is re-armed (it can trip
|
|
48
|
+
* again after another `maxToolCallsWithoutEdit` action calls) and the cause is warned. Failing
|
|
49
|
+
* open is deliberate: killing a productive run is the expensive error, and the streak bounds,
|
|
50
|
+
* the inactivity watchdog and the wall-clock cap all still hold the run.
|
|
51
|
+
*
|
|
52
|
+
* With no probe wired the bound falls back to its old tool-name-only judgement, so a caller with
|
|
53
|
+
* no checkout to probe is no worse off than before.
|
|
54
|
+
*/
|
|
55
|
+
export function createGuardDriver(deps: {
|
|
56
|
+
guard: ProgressGuard
|
|
57
|
+
probe?: WorkspaceProbe | undefined
|
|
58
|
+
/** Kill the run with this diagnostic. Called at most once. */
|
|
59
|
+
onAbort: (reason: string) => void
|
|
60
|
+
log?: Logger | undefined
|
|
61
|
+
}): GuardDriver {
|
|
62
|
+
const logger = deps.log ?? defaultLog
|
|
63
|
+
let aborted = false
|
|
64
|
+
let probing = false
|
|
65
|
+
|
|
66
|
+
const abort = (reason: string): void => {
|
|
67
|
+
if (aborted) return
|
|
68
|
+
aborted = true
|
|
69
|
+
deps.onAbort(reason)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const settleFromWorkspace = (provisional: string): void => {
|
|
73
|
+
const probe = deps.probe
|
|
74
|
+
if (!probe) {
|
|
75
|
+
// No checkout to probe: the tool-name reading is the only evidence there is, so act on it
|
|
76
|
+
// rather than leaving the bound permanently unenforceable.
|
|
77
|
+
abort(`${provisional} Aborting before it burns the whole run.`)
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
probing = true
|
|
81
|
+
void probe()
|
|
82
|
+
.then((evidence) => {
|
|
83
|
+
if (aborted) return
|
|
84
|
+
if (evidence.mutated) {
|
|
85
|
+
deps.guard.noteWorkspaceMutation()
|
|
86
|
+
logger.info('progress-guard: working tree shows the run IS changing the repository', {
|
|
87
|
+
headSha: evidence.headSha,
|
|
88
|
+
headMoved: evidence.headMoved,
|
|
89
|
+
dirtyPathCount: evidence.dirtyPathCount,
|
|
90
|
+
})
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
abort(
|
|
94
|
+
`${provisional} The working tree agrees: at ${evidence.headSha} there is nothing ` +
|
|
95
|
+
`uncommitted and HEAD has not moved since this pass began, so the repository is ` +
|
|
96
|
+
`unchanged. Aborting before it burns the whole run.`,
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
.catch((error: unknown) => {
|
|
100
|
+
if (aborted) return
|
|
101
|
+
deps.guard.rearmNoEditBound()
|
|
102
|
+
logger.warn('progress-guard: workspace probe failed; treating it as inconclusive', {
|
|
103
|
+
error: describeCause(error),
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
.finally(() => {
|
|
107
|
+
probing = false
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const act = (verdict: ReturnType<ProgressGuard['observeSignal']>): void => {
|
|
112
|
+
if (aborted || !verdict) return
|
|
113
|
+
if (verdict.kind === 'needs-workspace-evidence') {
|
|
114
|
+
// A probe already in flight owns the current question. The guard itself suppresses a second
|
|
115
|
+
// `needs-workspace-evidence` until one is answered; this is the belt to that braces.
|
|
116
|
+
if (!probing) settleFromWorkspace(verdict.reason)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
abort(verdict.reason)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
observeSignal: (tool) => {
|
|
124
|
+
if (aborted) return
|
|
125
|
+
act(deps.guard.observeSignal(tool))
|
|
126
|
+
},
|
|
127
|
+
observeEvent: (event) => {
|
|
128
|
+
if (aborted) return
|
|
129
|
+
act(deps.guard.observe(event))
|
|
130
|
+
},
|
|
131
|
+
aborted: () => aborted,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** What {@link createClaudeProgressGuard} needs off the run options, and nothing more. */
|
|
136
|
+
export interface ClaudeGuardOptions {
|
|
137
|
+
guardLimits?: ProgressGuardLimits | undefined
|
|
138
|
+
expectsEdits?: boolean | undefined
|
|
139
|
+
workspaceProbe?: WorkspaceProbe | undefined
|
|
140
|
+
log?: Logger | undefined
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* No-progress guard on the claude-code CLI's own tool stream — the claude-code analogue of runPi's
|
|
145
|
+
* guard, which cannot see the CLI's internal turns. The caller remembers each `tool_use` id's name
|
|
146
|
+
* off the assistant turn (`rememberTool`) and hands the following user turn's content to
|
|
147
|
+
* `feedGuard`, which pairs each `tool_result`'s `is_error` with that name.
|
|
148
|
+
*
|
|
149
|
+
* The first abort trips it: the diagnostic is recorded (readable via `reason()`, which the catch
|
|
150
|
+
* surfaces over the generic abort message) and `guardAbort` fires, folded into streamCli's signal
|
|
151
|
+
* so a tripped guard kills the CLI the same way the external watchdog does. Disabled when the
|
|
152
|
+
* caller supplies no limits (only the external watchdog then bounds the run).
|
|
153
|
+
*
|
|
154
|
+
* Lives here rather than in `agent-runner.ts` because the async half of it — the workspace probe
|
|
155
|
+
* behind the no-edit bound — is the same collaborator the Pi runner drives.
|
|
156
|
+
*/
|
|
157
|
+
export function createClaudeProgressGuard(opts: ClaudeGuardOptions): {
|
|
158
|
+
rememberTool: (id: string, name: string) => void
|
|
159
|
+
feedGuard: (content: unknown[]) => void
|
|
160
|
+
guardAbort: AbortController
|
|
161
|
+
reason: () => string | undefined
|
|
162
|
+
} {
|
|
163
|
+
const toolNames = new Map<string, string>()
|
|
164
|
+
const guardAbort = new AbortController()
|
|
165
|
+
let guardReason: string | undefined
|
|
166
|
+
|
|
167
|
+
const limits = opts.guardLimits
|
|
168
|
+
const driver = limits
|
|
169
|
+
? createGuardDriver({
|
|
170
|
+
guard: new ProgressGuard(limits, opts.expectsEdits ?? true),
|
|
171
|
+
probe: opts.workspaceProbe,
|
|
172
|
+
log: opts.log,
|
|
173
|
+
onAbort: (reason) => {
|
|
174
|
+
guardReason = reason
|
|
175
|
+
guardAbort.abort()
|
|
176
|
+
},
|
|
177
|
+
})
|
|
178
|
+
: undefined
|
|
179
|
+
|
|
180
|
+
const feedGuard = (content: unknown[]): void => {
|
|
181
|
+
if (!driver || driver.aborted()) return
|
|
182
|
+
for (const block of content) {
|
|
183
|
+
if (!isRecord(block) || block.type !== 'tool_result') continue
|
|
184
|
+
const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined
|
|
185
|
+
const name = id ? toolNames.get(id) : undefined
|
|
186
|
+
if (id) toolNames.delete(id)
|
|
187
|
+
if (!name) continue
|
|
188
|
+
driver.observeSignal({ name, isError: block.is_error === true })
|
|
189
|
+
if (driver.aborted()) return
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
rememberTool: (id, name) => toolNames.set(id, name),
|
|
195
|
+
feedGuard,
|
|
196
|
+
guardAbort,
|
|
197
|
+
reason: () => guardReason,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
202
|
+
return typeof value === 'object' && value !== null
|
|
203
|
+
}
|
package/src/harness-server.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { parseAgentJob, parseInlineJob } from './job.js'
|
|
|
5
5
|
import { handleAgent } from './agent.js'
|
|
6
6
|
import { handleInline } from './inline.js'
|
|
7
7
|
import { redactSecrets } from './git.js'
|
|
8
|
+
import { readDockerStatus } from './docker-status.js'
|
|
8
9
|
import { JobRegistry, loadRunnerLimits, type JobResultBase, type RunOptions } from './runner.js'
|
|
9
10
|
import { log } from './logger.js'
|
|
10
11
|
import { HARNESS_VERSION } from './version.js'
|
|
@@ -128,10 +129,22 @@ const server = createServer((req, res) => {
|
|
|
128
129
|
// fail loudly early (see version.ts). Unauthenticated like the rest of /health — the
|
|
129
130
|
// version is not a secret. An old image predating this field simply omits it, which the
|
|
130
131
|
// backend treats as a stale signal.
|
|
132
|
+
//
|
|
133
|
+
// `docker` is this container's own verdict on its daemon (see docker-status.ts). It rides
|
|
134
|
+
// /health because that is where an operator and a boot-time probe already look, and because
|
|
135
|
+
// the alternative was every agent discovering an absent daemon for itself, one failed
|
|
136
|
+
// compose command at a time. Reported, never enforced here: what REFUSES on it is the
|
|
137
|
+
// stand-up in agent.ts, which is the only place that knows a job wanted a daemon.
|
|
138
|
+
//
|
|
139
|
+
// Deliberately the BOOT record, not a live probe. /health is polled, and a probe per poll
|
|
140
|
+
// would spawn a process per poll to answer a question this endpoint is not the one to act
|
|
141
|
+
// on; the stand-up re-confirms a recorded absence at the moment it matters
|
|
142
|
+
// (`resolveDockerVerdict`), so a stale negative here never becomes a stale refusal there.
|
|
131
143
|
return send(res, 200, {
|
|
132
144
|
status: 'ok',
|
|
133
145
|
...(HARNESS_VERSION ? { version: HARNESS_VERSION } : {}),
|
|
134
146
|
capabilities: HARNESS_BODY_CAPABILITIES,
|
|
147
|
+
docker: await readDockerStatus(),
|
|
135
148
|
})
|
|
136
149
|
}
|
|
137
150
|
// All non-health endpoints are gated by the optional shared secret.
|