@cat-factory/executor-harness 1.132.3 → 1.134.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +47 -0
  2. package/dist/agent-env.d.ts +17 -0
  3. package/dist/agent-env.js +47 -0
  4. package/dist/agent-runner.d.ts +11 -2
  5. package/dist/agent-runner.js +3 -48
  6. package/dist/agent.d.ts +0 -11
  7. package/dist/agent.js +7 -132
  8. package/dist/captured-command.d.ts +1 -1
  9. package/dist/captured-command.js +3 -2
  10. package/dist/coding-agent.d.ts +35 -0
  11. package/dist/coding-agent.js +213 -41
  12. package/dist/docker-status.d.ts +89 -0
  13. package/dist/docker-status.js +147 -0
  14. package/dist/frontend-infra.js +4 -3
  15. package/dist/git.d.ts +48 -5
  16. package/dist/git.js +93 -26
  17. package/dist/guard-driver.d.ts +71 -0
  18. package/dist/guard-driver.js +171 -0
  19. package/dist/harness-server.js +13 -0
  20. package/dist/infra-standup.d.ts +69 -0
  21. package/dist/infra-standup.js +182 -0
  22. package/dist/job.d.ts +10 -0
  23. package/dist/multi-repo-coding.d.ts +17 -0
  24. package/dist/multi-repo-coding.js +55 -8
  25. package/dist/pi-workspace.d.ts +11 -0
  26. package/dist/pi-workspace.js +47 -0
  27. package/dist/pi.d.ts +8 -0
  28. package/dist/pi.js +16 -9
  29. package/dist/progress-guard.d.ts +56 -10
  30. package/dist/progress-guard.js +84 -22
  31. package/dist/runner.d.ts +1 -1
  32. package/dist/salvage.d.ts +180 -0
  33. package/dist/salvage.js +289 -0
  34. package/dist/workspace-probe.d.ts +85 -0
  35. package/dist/workspace-probe.js +124 -0
  36. package/package.json +4 -4
  37. package/src/agent-env.ts +49 -0
  38. package/src/agent-runner.ts +14 -53
  39. package/src/agent.ts +7 -158
  40. package/src/captured-command.ts +3 -2
  41. package/src/coding-agent.ts +252 -44
  42. package/src/docker-status.ts +201 -0
  43. package/src/frontend-infra.ts +4 -3
  44. package/src/git.ts +104 -26
  45. package/src/guard-driver.ts +203 -0
  46. package/src/harness-server.ts +13 -0
  47. package/src/infra-standup.ts +218 -0
  48. package/src/job.ts +10 -0
  49. package/src/multi-repo-coding.ts +59 -8
  50. package/src/pi-workspace.ts +72 -0
  51. package/src/pi.ts +27 -12
  52. package/src/progress-guard.ts +110 -34
  53. package/src/runner.ts +1 -1
  54. package/src/salvage.ts +407 -0
  55. package/src/workspace-probe.ts +155 -0
package/dist/git.d.ts CHANGED
@@ -149,9 +149,45 @@ export declare function commitTrackedEdits(dir: string, message: string, signal?
149
149
  * --exclude-standard`). The harness deliberately never blanket-stages new files (the
150
150
  * agent owns commit selection), so this is exactly what {@link commitTrackedEdits}
151
151
  * does NOT capture — a NEW file the agent created but forgot to commit. The caller
152
- * surfaces it as a warning so that silent loss is at least observable in the logs.
152
+ * surfaces it as a warning, and the salvage commits it, so that loss is at least observable.
153
+ *
154
+ * `-z` for the reason given on {@link splitNulPaths}: the default output C-QUOTES any path git
155
+ * considers unusual, and a quoted path is not the name of a file. The salvage stages exactly
156
+ * what this returns, so a single accented filename would make its one `git add` exit 128 and
157
+ * discard the whole all-or-nothing salvage.
153
158
  */
154
159
  export declare function listUntrackedFiles(dir: string, signal?: AbortSignal): Promise<string[]>;
160
+ /**
161
+ * The raw `git status --porcelain -z --untracked-files=all` output for `dir` — every path git
162
+ * considers changed, with untracked files enumerated INDIVIDUALLY rather than collapsed to their
163
+ * directory.
164
+ *
165
+ * The raw string, not a parsed list, because its two consumers want different things from it and
166
+ * the parse ({@link changedPathsFromPorcelain}) is pure and shared: the workspace probe wants "is
167
+ * anything here at all", the salvage wants the paths themselves. Gitignored paths are absent by
168
+ * construction, which is what keeps a dependency install from reading as agent progress.
169
+ *
170
+ * NOTHING IS STAGED, unlike {@link hasAgentChanges}: this runs mid-flight, while the agent is
171
+ * still working, so a `git add -A` here would silently stage files the agent had not chosen and
172
+ * change what a later `commitTrackedEdits` captures.
173
+ */
174
+ export declare function workingTreeStatus(dir: string, signal?: AbortSignal): Promise<string>;
175
+ /**
176
+ * Stage exactly `paths` and commit them with `message`, returning the new commit's sha (or null
177
+ * when git found nothing to commit — a path that vanished between listing and staging).
178
+ *
179
+ * Three separate things stop a path being read as something other than a path. `--` terminates
180
+ * the options, so one beginning with `-` cannot be read as a flag. Each path is a separate argv
181
+ * entry, so no shell ever sees them. And each is prefixed `:(literal)`, which is what `--` does
182
+ * NOT cover: everything after `--` is a PATHSPEC, not a filename, so its leading `:` is read as
183
+ * pathspec magic and its wildcards are matched as a glob. An agent-authored `:notes.txt` makes a
184
+ * bare `git add -- :notes.txt` exit 128 on `did not match any files` — and since this stages every
185
+ * path in ONE command, that one name discards the whole all-or-nothing salvage, exactly as an
186
+ * unquoted accented name did. `:(literal)` matches the entry as itself and nothing else.
187
+ *
188
+ * The caller has already decided WHICH paths belong; this only commits them.
189
+ */
190
+ export declare function commitPaths(dir: string, paths: string[], message: string, signal?: AbortSignal): Promise<string | null>;
155
191
  /**
156
192
  * The untracked, non-ignored paths in the working tree with whole untracked DIRECTORIES
157
193
  * collapsed to a single `dir/` entry (`--directory`), rather than every file beneath them.
@@ -233,10 +269,17 @@ export declare function changedFilesSinceBase(dir: string, baseBranch: string, g
233
269
  */
234
270
  export declare function hasDiffAgainstBase(dir: string, baseBranch: string, signal?: AbortSignal): Promise<boolean>;
235
271
  /**
236
- * Parse the paths out of `git status --porcelain` (v1) output. Each line is
237
- * `XY <path>`, or `XY <old> -> <new>` for a rename/copy (we keep the new path);
238
- * git quotes paths with special characters, which we unquote. Blank lines are
239
- * skipped. Pure so the no-op detection can be tested without spawning git.
272
+ * Parse the paths out of `git status --porcelain -z` (v1) output.
273
+ *
274
+ * `-z` rather than the default, for the reason given on {@link splitNulPaths}: the default
275
+ * C-QUOTES any path git considers unusual, so `caf\u00e9.ts` arrives as the seven-character
276
+ * literal `"caf\303\251.ts"` and every consumer that then touches the file misses it. In `-z`
277
+ * every field is a real path and nothing is escaped.
278
+ *
279
+ * Each NUL-terminated field is `XY <path>`. A rename or copy (`R`/`C` in either status column)
280
+ * spends a SECOND field on its original path, which is consumed and dropped: we keep the new
281
+ * path, the one that now exists in the tree. Pure so the no-op detection and the workspace
282
+ * probe's sentinel rule can be tested without spawning git.
240
283
  */
241
284
  export declare function changedPathsFromPorcelain(status: string): string[];
242
285
  /**
package/dist/git.js CHANGED
@@ -470,14 +470,77 @@ export async function commitTrackedEdits(dir, message, signal) {
470
470
  * --exclude-standard`). The harness deliberately never blanket-stages new files (the
471
471
  * agent owns commit selection), so this is exactly what {@link commitTrackedEdits}
472
472
  * does NOT capture — a NEW file the agent created but forgot to commit. The caller
473
- * surfaces it as a warning so that silent loss is at least observable in the logs.
473
+ * surfaces it as a warning, and the salvage commits it, so that loss is at least observable.
474
+ *
475
+ * `-z` for the reason given on {@link splitNulPaths}: the default output C-QUOTES any path git
476
+ * considers unusual, and a quoted path is not the name of a file. The salvage stages exactly
477
+ * what this returns, so a single accented filename would make its one `git add` exit 128 and
478
+ * discard the whole all-or-nothing salvage.
474
479
  */
475
480
  export async function listUntrackedFiles(dir, signal) {
476
- const out = await git(['ls-files', '--others', '--exclude-standard'], { cwd: dir, signal });
477
- return out
478
- .split('\n')
479
- .map((line) => line.replace(/\r$/, '').trim())
480
- .filter((path) => path !== '');
481
+ return splitNulPaths(await git(['ls-files', '--others', '--exclude-standard', '-z'], { cwd: dir, signal }));
482
+ }
483
+ /**
484
+ * Split git's NUL-delimited path output into real, unescaped paths.
485
+ *
486
+ * Every path-listing git command here passes `-z`, and this is why: without it git renders a
487
+ * path containing a non-ASCII byte, a quote, a backslash or a newline as a C-QUOTED STRING
488
+ * (`"caf\303\251.ts"`), quotes and octal escapes included. That string is not a filename — feed
489
+ * it back to `git add` and the command exits 128 with `pathspec ... did not match any files`, and
490
+ * `stat` on it reports nothing. `-z` turns the quoting off entirely (a NUL cannot occur in a path,
491
+ * so no escaping is needed) and is the ONLY setting that is correct for every path: `core.quotePath
492
+ * =false` covers the non-ASCII case alone and still quotes the other three.
493
+ *
494
+ * A trailing NUL leaves an empty final field, which is dropped along with any other blank.
495
+ */
496
+ function splitNulPaths(out) {
497
+ return out.split('\0').filter((path) => path !== '');
498
+ }
499
+ /**
500
+ * The raw `git status --porcelain -z --untracked-files=all` output for `dir` — every path git
501
+ * considers changed, with untracked files enumerated INDIVIDUALLY rather than collapsed to their
502
+ * directory.
503
+ *
504
+ * The raw string, not a parsed list, because its two consumers want different things from it and
505
+ * the parse ({@link changedPathsFromPorcelain}) is pure and shared: the workspace probe wants "is
506
+ * anything here at all", the salvage wants the paths themselves. Gitignored paths are absent by
507
+ * construction, which is what keeps a dependency install from reading as agent progress.
508
+ *
509
+ * NOTHING IS STAGED, unlike {@link hasAgentChanges}: this runs mid-flight, while the agent is
510
+ * still working, so a `git add -A` here would silently stage files the agent had not chosen and
511
+ * change what a later `commitTrackedEdits` captures.
512
+ */
513
+ export async function workingTreeStatus(dir, signal) {
514
+ return git(['status', '--porcelain', '-z', '--untracked-files=all'], { cwd: dir, signal });
515
+ }
516
+ /**
517
+ * Stage exactly `paths` and commit them with `message`, returning the new commit's sha (or null
518
+ * when git found nothing to commit — a path that vanished between listing and staging).
519
+ *
520
+ * Three separate things stop a path being read as something other than a path. `--` terminates
521
+ * the options, so one beginning with `-` cannot be read as a flag. Each path is a separate argv
522
+ * entry, so no shell ever sees them. And each is prefixed `:(literal)`, which is what `--` does
523
+ * NOT cover: everything after `--` is a PATHSPEC, not a filename, so its leading `:` is read as
524
+ * pathspec magic and its wildcards are matched as a glob. An agent-authored `:notes.txt` makes a
525
+ * bare `git add -- :notes.txt` exit 128 on `did not match any files` — and since this stages every
526
+ * path in ONE command, that one name discards the whole all-or-nothing salvage, exactly as an
527
+ * unquoted accented name did. `:(literal)` matches the entry as itself and nothing else.
528
+ *
529
+ * The caller has already decided WHICH paths belong; this only commits them.
530
+ */
531
+ export async function commitPaths(dir, paths, message, signal) {
532
+ if (paths.length === 0)
533
+ return null;
534
+ await git(['add', '--', ...paths.map(literalPathspec)], { cwd: dir, signal });
535
+ const staged = await git(['diff', '--cached', '--name-only'], { cwd: dir, signal });
536
+ if (staged.trim() === '')
537
+ return null;
538
+ await git(['commit', '-m', message], { cwd: dir, signal });
539
+ return headCommit(dir, signal);
540
+ }
541
+ /** One path as a pathspec that matches only itself — see {@link commitPaths} for why. */
542
+ function literalPathspec(path) {
543
+ return `:(literal)${path}`;
481
544
  }
482
545
  /**
483
546
  * The untracked, non-ignored paths in the working tree with whole untracked DIRECTORIES
@@ -489,11 +552,7 @@ export async function listUntrackedFiles(dir, signal) {
489
552
  * and enumerating them would cost a multi-megabyte listing to learn a single name.
490
553
  */
491
554
  export async function listUntrackedPaths(dir, signal) {
492
- const out = await git(['ls-files', '--others', '--exclude-standard', '--directory', '--no-empty-directory'], { cwd: dir, signal });
493
- return out
494
- .split('\n')
495
- .map((line) => line.replace(/\r$/, '').trim())
496
- .filter((path) => path !== '');
555
+ return splitNulPaths(await git(['ls-files', '--others', '--exclude-standard', '--directory', '--no-empty-directory', '-z'], { cwd: dir, signal }));
497
556
  }
498
557
  /**
499
558
  * Locally exclude `pattern` from this checkout via `.git/info/exclude` — a per-clone
@@ -628,24 +687,32 @@ export async function hasDiffAgainstBase(dir, baseBranch, signal) {
628
687
  }
629
688
  }
630
689
  /**
631
- * Parse the paths out of `git status --porcelain` (v1) output. Each line is
632
- * `XY <path>`, or `XY <old> -> <new>` for a rename/copy (we keep the new path);
633
- * git quotes paths with special characters, which we unquote. Blank lines are
634
- * skipped. Pure so the no-op detection can be tested without spawning git.
690
+ * Parse the paths out of `git status --porcelain -z` (v1) output.
691
+ *
692
+ * `-z` rather than the default, for the reason given on {@link splitNulPaths}: the default
693
+ * C-QUOTES any path git considers unusual, so `caf\u00e9.ts` arrives as the seven-character
694
+ * literal `"caf\303\251.ts"` and every consumer that then touches the file misses it. In `-z`
695
+ * every field is a real path and nothing is escaped.
696
+ *
697
+ * Each NUL-terminated field is `XY <path>`. A rename or copy (`R`/`C` in either status column)
698
+ * spends a SECOND field on its original path, which is consumed and dropped: we keep the new
699
+ * path, the one that now exists in the tree. Pure so the no-op detection and the workspace
700
+ * probe's sentinel rule can be tested without spawning git.
635
701
  */
636
702
  export function changedPathsFromPorcelain(status) {
703
+ const fields = status.split('\0');
637
704
  const paths = [];
638
- for (const raw of status.split('\n')) {
639
- const line = raw.replace(/\r$/, '');
640
- if (line.trim() === '')
705
+ for (let index = 0; index < fields.length; index++) {
706
+ const entry = fields[index] ?? '';
707
+ if (entry === '')
708
+ continue;
709
+ // `XY ` then the path. A field too short to hold both is not a status entry (a stray
710
+ // trailing fragment), so there is no path in it to keep.
711
+ if (entry.length <= 3)
641
712
  continue;
642
- let path = line.slice(3);
643
- const arrow = path.indexOf(' -> ');
644
- if (arrow !== -1)
645
- path = path.slice(arrow + 4);
646
- path = path.trim().replace(/^"(.*)"$/, '$1');
647
- if (path)
648
- paths.push(path);
713
+ if (entry[0] === 'R' || entry[0] === 'C' || entry[1] === 'R' || entry[1] === 'C')
714
+ index++;
715
+ paths.push(entry.slice(3));
649
716
  }
650
717
  return paths;
651
718
  }
@@ -658,7 +725,7 @@ export function changedPathsFromPorcelain(status) {
658
725
  */
659
726
  export async function hasAgentChanges(dir, signal) {
660
727
  await git(['add', '-A'], { cwd: dir, signal });
661
- const status = await git(['status', '--porcelain'], { cwd: dir, signal });
728
+ const status = await git(['status', '--porcelain', '-z'], { cwd: dir, signal });
662
729
  return changedPathsFromPorcelain(status).length > 0;
663
730
  }
664
731
  /** The commit SHA at `dir`'s HEAD — captured right after clone as the base tip. */
@@ -0,0 +1,71 @@
1
+ import { ProgressGuard, type ProgressGuardLimits } from './progress-guard.js';
2
+ import type { WorkspaceProbe } from './workspace-probe.js';
3
+ import { type Logger } from './logger.js';
4
+ /** One run's guard plus the async settlement of the bound the stream alone cannot decide. */
5
+ export interface GuardDriver {
6
+ /** Feed one tool-call signal (name + error flag) — the claude-code runner's shape. */
7
+ observeSignal: (tool: {
8
+ name: string;
9
+ isError: boolean;
10
+ }) => void;
11
+ /** Feed one parsed Pi `--mode json` event; a non-tool-call event is a no-op. */
12
+ observeEvent: (event: Record<string, unknown>) => void;
13
+ /** Whether the guard has decided to kill this run. */
14
+ aborted: () => boolean;
15
+ }
16
+ /**
17
+ * Drive one run's {@link ProgressGuard}, resolving the one verdict the stream cannot settle.
18
+ *
19
+ * An `abort` verdict fires {@link onAbort} immediately: every streak bound (consecutive errors /
20
+ * web calls / MCP calls / non-action calls) reads only the stream, so the stream is all the
21
+ * evidence there is.
22
+ *
23
+ * A `needs-workspace-evidence` verdict — the no-edit bound — starts a probe of the working tree
24
+ * and acts on its answer:
25
+ *
26
+ * - MUTATED: the run has changed the repository, whichever tool it used. The bound is satisfied
27
+ * permanently, exactly as a recognised edit-tool call satisfies it, so no second probe is ever
28
+ * made and the run continues.
29
+ * - CLEAN: abort, with the evidence in the message.
30
+ * - THREW: inconclusive, which is neither a pass nor a fail. The bound is re-armed (it can trip
31
+ * again after another `maxToolCallsWithoutEdit` action calls) and the cause is warned. Failing
32
+ * open is deliberate: killing a productive run is the expensive error, and the streak bounds,
33
+ * the inactivity watchdog and the wall-clock cap all still hold the run.
34
+ *
35
+ * With no probe wired the bound falls back to its old tool-name-only judgement, so a caller with
36
+ * no checkout to probe is no worse off than before.
37
+ */
38
+ export declare function createGuardDriver(deps: {
39
+ guard: ProgressGuard;
40
+ probe?: WorkspaceProbe | undefined;
41
+ /** Kill the run with this diagnostic. Called at most once. */
42
+ onAbort: (reason: string) => void;
43
+ log?: Logger | undefined;
44
+ }): GuardDriver;
45
+ /** What {@link createClaudeProgressGuard} needs off the run options, and nothing more. */
46
+ export interface ClaudeGuardOptions {
47
+ guardLimits?: ProgressGuardLimits | undefined;
48
+ expectsEdits?: boolean | undefined;
49
+ workspaceProbe?: WorkspaceProbe | undefined;
50
+ log?: Logger | undefined;
51
+ }
52
+ /**
53
+ * No-progress guard on the claude-code CLI's own tool stream — the claude-code analogue of runPi's
54
+ * guard, which cannot see the CLI's internal turns. The caller remembers each `tool_use` id's name
55
+ * off the assistant turn (`rememberTool`) and hands the following user turn's content to
56
+ * `feedGuard`, which pairs each `tool_result`'s `is_error` with that name.
57
+ *
58
+ * The first abort trips it: the diagnostic is recorded (readable via `reason()`, which the catch
59
+ * surfaces over the generic abort message) and `guardAbort` fires, folded into streamCli's signal
60
+ * so a tripped guard kills the CLI the same way the external watchdog does. Disabled when the
61
+ * caller supplies no limits (only the external watchdog then bounds the run).
62
+ *
63
+ * Lives here rather than in `agent-runner.ts` because the async half of it — the workspace probe
64
+ * behind the no-edit bound — is the same collaborator the Pi runner drives.
65
+ */
66
+ export declare function createClaudeProgressGuard(opts: ClaudeGuardOptions): {
67
+ rememberTool: (id: string, name: string) => void;
68
+ feedGuard: (content: unknown[]) => void;
69
+ guardAbort: AbortController;
70
+ reason: () => string | undefined;
71
+ };
@@ -0,0 +1,171 @@
1
+ import { ProgressGuard } from './progress-guard.js';
2
+ import { log as defaultLog } from './logger.js';
3
+ // The bridge between the SYNCHRONOUS {@link ProgressGuard} and the ASYNCHRONOUS evidence one of
4
+ // its bounds needs. Both runners feed the guard from a sync stream handler (`pi.ts`'s JSONL line
5
+ // reader, `agent-runner.ts`'s tool_result pairing) and neither can await inside one, so the driver
6
+ // owns the probe's lifetime instead. Shared rather than copied per runner: a decision this one
7
+ // ("has the run stopped making progress") must come out the same way on both, which is why
8
+ // `ProgressGuard` itself was extracted in the first place.
9
+ /** The whole cause chain of a thrown value, one line, so a probe failure names what actually broke. */
10
+ function describeCause(error) {
11
+ const parts = [];
12
+ let current = error;
13
+ for (let depth = 0; depth < 8 && current !== undefined && current !== null; depth++) {
14
+ parts.push(current instanceof Error ? current.message : String(current));
15
+ current = current instanceof Error ? current.cause : undefined;
16
+ }
17
+ return parts.filter((part) => part !== '').join(': ');
18
+ }
19
+ /**
20
+ * Drive one run's {@link ProgressGuard}, resolving the one verdict the stream cannot settle.
21
+ *
22
+ * An `abort` verdict fires {@link onAbort} immediately: every streak bound (consecutive errors /
23
+ * web calls / MCP calls / non-action calls) reads only the stream, so the stream is all the
24
+ * evidence there is.
25
+ *
26
+ * A `needs-workspace-evidence` verdict — the no-edit bound — starts a probe of the working tree
27
+ * and acts on its answer:
28
+ *
29
+ * - MUTATED: the run has changed the repository, whichever tool it used. The bound is satisfied
30
+ * permanently, exactly as a recognised edit-tool call satisfies it, so no second probe is ever
31
+ * made and the run continues.
32
+ * - CLEAN: abort, with the evidence in the message.
33
+ * - THREW: inconclusive, which is neither a pass nor a fail. The bound is re-armed (it can trip
34
+ * again after another `maxToolCallsWithoutEdit` action calls) and the cause is warned. Failing
35
+ * open is deliberate: killing a productive run is the expensive error, and the streak bounds,
36
+ * the inactivity watchdog and the wall-clock cap all still hold the run.
37
+ *
38
+ * With no probe wired the bound falls back to its old tool-name-only judgement, so a caller with
39
+ * no checkout to probe is no worse off than before.
40
+ */
41
+ export function createGuardDriver(deps) {
42
+ const logger = deps.log ?? defaultLog;
43
+ let aborted = false;
44
+ let probing = false;
45
+ const abort = (reason) => {
46
+ if (aborted)
47
+ return;
48
+ aborted = true;
49
+ deps.onAbort(reason);
50
+ };
51
+ const settleFromWorkspace = (provisional) => {
52
+ const probe = deps.probe;
53
+ if (!probe) {
54
+ // No checkout to probe: the tool-name reading is the only evidence there is, so act on it
55
+ // rather than leaving the bound permanently unenforceable.
56
+ abort(`${provisional} Aborting before it burns the whole run.`);
57
+ return;
58
+ }
59
+ probing = true;
60
+ void probe()
61
+ .then((evidence) => {
62
+ if (aborted)
63
+ return;
64
+ if (evidence.mutated) {
65
+ deps.guard.noteWorkspaceMutation();
66
+ logger.info('progress-guard: working tree shows the run IS changing the repository', {
67
+ headSha: evidence.headSha,
68
+ headMoved: evidence.headMoved,
69
+ dirtyPathCount: evidence.dirtyPathCount,
70
+ });
71
+ return;
72
+ }
73
+ abort(`${provisional} The working tree agrees: at ${evidence.headSha} there is nothing ` +
74
+ `uncommitted and HEAD has not moved since this pass began, so the repository is ` +
75
+ `unchanged. Aborting before it burns the whole run.`);
76
+ })
77
+ .catch((error) => {
78
+ if (aborted)
79
+ return;
80
+ deps.guard.rearmNoEditBound();
81
+ logger.warn('progress-guard: workspace probe failed; treating it as inconclusive', {
82
+ error: describeCause(error),
83
+ });
84
+ })
85
+ .finally(() => {
86
+ probing = false;
87
+ });
88
+ };
89
+ const act = (verdict) => {
90
+ if (aborted || !verdict)
91
+ return;
92
+ if (verdict.kind === 'needs-workspace-evidence') {
93
+ // A probe already in flight owns the current question. The guard itself suppresses a second
94
+ // `needs-workspace-evidence` until one is answered; this is the belt to that braces.
95
+ if (!probing)
96
+ settleFromWorkspace(verdict.reason);
97
+ return;
98
+ }
99
+ abort(verdict.reason);
100
+ };
101
+ return {
102
+ observeSignal: (tool) => {
103
+ if (aborted)
104
+ return;
105
+ act(deps.guard.observeSignal(tool));
106
+ },
107
+ observeEvent: (event) => {
108
+ if (aborted)
109
+ return;
110
+ act(deps.guard.observe(event));
111
+ },
112
+ aborted: () => aborted,
113
+ };
114
+ }
115
+ /**
116
+ * No-progress guard on the claude-code CLI's own tool stream — the claude-code analogue of runPi's
117
+ * guard, which cannot see the CLI's internal turns. The caller remembers each `tool_use` id's name
118
+ * off the assistant turn (`rememberTool`) and hands the following user turn's content to
119
+ * `feedGuard`, which pairs each `tool_result`'s `is_error` with that name.
120
+ *
121
+ * The first abort trips it: the diagnostic is recorded (readable via `reason()`, which the catch
122
+ * surfaces over the generic abort message) and `guardAbort` fires, folded into streamCli's signal
123
+ * so a tripped guard kills the CLI the same way the external watchdog does. Disabled when the
124
+ * caller supplies no limits (only the external watchdog then bounds the run).
125
+ *
126
+ * Lives here rather than in `agent-runner.ts` because the async half of it — the workspace probe
127
+ * behind the no-edit bound — is the same collaborator the Pi runner drives.
128
+ */
129
+ export function createClaudeProgressGuard(opts) {
130
+ const toolNames = new Map();
131
+ const guardAbort = new AbortController();
132
+ let guardReason;
133
+ const limits = opts.guardLimits;
134
+ const driver = limits
135
+ ? createGuardDriver({
136
+ guard: new ProgressGuard(limits, opts.expectsEdits ?? true),
137
+ probe: opts.workspaceProbe,
138
+ log: opts.log,
139
+ onAbort: (reason) => {
140
+ guardReason = reason;
141
+ guardAbort.abort();
142
+ },
143
+ })
144
+ : undefined;
145
+ const feedGuard = (content) => {
146
+ if (!driver || driver.aborted())
147
+ return;
148
+ for (const block of content) {
149
+ if (!isRecord(block) || block.type !== 'tool_result')
150
+ continue;
151
+ const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined;
152
+ const name = id ? toolNames.get(id) : undefined;
153
+ if (id)
154
+ toolNames.delete(id);
155
+ if (!name)
156
+ continue;
157
+ driver.observeSignal({ name, isError: block.is_error === true });
158
+ if (driver.aborted())
159
+ return;
160
+ }
161
+ };
162
+ return {
163
+ rememberTool: (id, name) => toolNames.set(id, name),
164
+ feedGuard,
165
+ guardAbort,
166
+ reason: () => guardReason,
167
+ };
168
+ }
169
+ function isRecord(value) {
170
+ return typeof value === 'object' && value !== null;
171
+ }
@@ -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 } from './runner.js';
9
10
  import { log } from './logger.js';
10
11
  import { HARNESS_VERSION } from './version.js';
@@ -109,10 +110,22 @@ const server = createServer((req, res) => {
109
110
  // fail loudly early (see version.ts). Unauthenticated like the rest of /health — the
110
111
  // version is not a secret. An old image predating this field simply omits it, which the
111
112
  // backend treats as a stale signal.
113
+ //
114
+ // `docker` is this container's own verdict on its daemon (see docker-status.ts). It rides
115
+ // /health because that is where an operator and a boot-time probe already look, and because
116
+ // the alternative was every agent discovering an absent daemon for itself, one failed
117
+ // compose command at a time. Reported, never enforced here: what REFUSES on it is the
118
+ // stand-up in agent.ts, which is the only place that knows a job wanted a daemon.
119
+ //
120
+ // Deliberately the BOOT record, not a live probe. /health is polled, and a probe per poll
121
+ // would spawn a process per poll to answer a question this endpoint is not the one to act
122
+ // on; the stand-up re-confirms a recorded absence at the moment it matters
123
+ // (`resolveDockerVerdict`), so a stale negative here never becomes a stale refusal there.
112
124
  return send(res, 200, {
113
125
  status: 'ok',
114
126
  ...(HARNESS_VERSION ? { version: HARNESS_VERSION } : {}),
115
127
  capabilities: HARNESS_BODY_CAPABILITIES,
128
+ docker: await readDockerStatus(),
116
129
  });
117
130
  }
118
131
  // All non-health endpoints are gated by the optional shared secret.
@@ -0,0 +1,69 @@
1
+ import type { AgentInfraSpec, InfraSetupRecord, ServiceInfraSpec } from './job.js';
2
+ import { type DockerProbe } from './docker-status.js';
3
+ import type { RunOptions } from './runner.js';
4
+ import type { Logger } from './logger.js';
5
+ /**
6
+ * Bring the service's docker-compose dependencies up (local infra only). Best-effort:
7
+ * runs `docker compose -f <path> up -d --wait` in the checkout. A compose failure is logged
8
+ * and surfaced to the agent (as a prompt note) rather than failing the job — the agent can
9
+ * still run unit-level tests and report what it could. A no-op for ephemeral / no-infra /
10
+ * no-compose-path runs.
11
+ *
12
+ * A CONFIRMED absence of a Docker daemon short-circuits it: the container's own probe
13
+ * ({@link readDockerStatus}, recorded by `entrypoint.sh`) already knows there is nothing to
14
+ * talk to, so running compose against it would only turn a fact this container holds into a
15
+ * connection error the agent has to interpret. The record then carries `dockerAvailable: false`
16
+ * and the stated cause, which is what makes the Tester step say why it ran no infra instead of
17
+ * looking like a Tester that simply chose not to. Anything OTHER than a confirmed absence
18
+ * attempts as before (`DockerStatus.available` in docker-status.ts states why "undecided" is its
19
+ * own value).
20
+ *
21
+ * "Confirmed", not merely recorded: {@link resolveDockerVerdict} re-checks a recorded absence
22
+ * against a live daemon first, so a warm-pool container whose sidecar came up late is not
23
+ * latched into refusing infra that works. `probe` is that check, injected so the unit suite can
24
+ * state both answers on a machine that has its own daemon either way.
25
+ *
26
+ * Whether it succeeds or fails, the (redacted, bounded) command output is captured into a
27
+ * {@link InfraSetupRecord} returned alongside the prompt `note`, so the backend can surface
28
+ * the in-container dependency stand-up logs on the Tester step — the failure-class artifact
29
+ * the orchestrator-side provisioning logs can't see.
30
+ *
31
+ * Exported for the unit suite (like {@link buildInfraNotes}): the refusal branch is a decision
32
+ * this container makes about itself, and the acceptance suite can only exercise it on a machine
33
+ * where the daemon genuinely fails.
34
+ */
35
+ export declare function standUpInfra(dir: string, infra: ServiceInfraSpec, signal: AbortSignal | undefined, logger: Logger, probe?: DockerProbe): Promise<{
36
+ started: boolean;
37
+ note?: string;
38
+ record?: InfraSetupRecord;
39
+ }>;
40
+ /**
41
+ * Stand the run's infra up and return a single cleanup handle, dispatching on the spec's
42
+ * `kind`: the frontend UI-test flow (`kind: 'frontend'`) builds/serves the app + WireMock as
43
+ * processes (torn down by killing them); the default backend-service flow stands the
44
+ * docker-compose stack up (torn down with `docker compose down`). Unifying the two here keeps
45
+ * `runExploreMode` free of the branch and guarantees the matching teardown runs in its finally.
46
+ *
47
+ * `dir` is the clone ROOT; `workDir` is the service subtree (equal to `dir` when the run is not
48
+ * monorepo-scoped). The docker-compose stand-up runs at the root (its `composePath` is
49
+ * repo-relative), but the FRONTEND stand-up runs in `workDir`: a monorepo frontend's
50
+ * `package.json` / `outputDir` / `mocks/` all live under the service subtree, so installing,
51
+ * building, serving and seeding WireMock from the root would target the wrong directory.
52
+ */
53
+ export declare function manageInfra(dir: string, workDir: string, infra: AgentInfraSpec, opts: RunOptions, logger: Logger): Promise<{
54
+ note?: string;
55
+ serveUrl?: string;
56
+ record?: InfraSetupRecord;
57
+ cleanup: () => Promise<void>;
58
+ }>;
59
+ /**
60
+ * Build the dynamic infra notes appended to the agent's user prompt from a stand-up outcome.
61
+ * A stand-up problem (a failed build / compose) is flagged as a concern to test around; a
62
+ * frontend serve URL points the UI tester at the app that was just built + served and pre-empts
63
+ * a live-backend CORS failure being mis-reported as an app defect. Pure (no IO) so the exact
64
+ * wording + ordering is unit-tested; returns the notes in order (problem first, serve URL next).
65
+ */
66
+ export declare function buildInfraNotes(managed: {
67
+ note?: string;
68
+ serveUrl?: string;
69
+ }): string[];