@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.
- package/README.md +47 -0
- package/dist/agent-env.d.ts +17 -0
- package/dist/agent-env.js +47 -0
- package/dist/agent-runner.d.ts +11 -2
- package/dist/agent-runner.js +3 -48
- package/dist/agent.d.ts +0 -11
- package/dist/agent.js +7 -132
- package/dist/captured-command.d.ts +1 -1
- package/dist/captured-command.js +3 -2
- 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 +55 -8
- package/dist/pi-workspace.d.ts +11 -0
- package/dist/pi-workspace.js +47 -0
- 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-env.ts +49 -0
- package/src/agent-runner.ts +14 -53
- package/src/agent.ts +7 -158
- package/src/captured-command.ts +3 -2
- 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 +59 -8
- package/src/pi-workspace.ts +72 -0
- 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
package/dist/coding-agent.js
CHANGED
|
@@ -11,6 +11,7 @@ import { runValidationLoop, } from './validation-checks.js';
|
|
|
11
11
|
import { runReproductionLoop, } from './reproduction-proof.js';
|
|
12
12
|
import { prepopulateDependencies, withDependencyNote, } from './dependency-install.js';
|
|
13
13
|
import { resolvePrTemplateNote, withPrTemplateNote, } from './pr-template.js';
|
|
14
|
+
import { describeSalvage, salvageUntrackedWork, } from './salvage.js';
|
|
14
15
|
/**
|
|
15
16
|
* How often the harness checkpoints the agent's work mid-run by pushing the branch.
|
|
16
17
|
* A per-run container can be evicted at any moment; pushing the agent's commits
|
|
@@ -88,11 +89,16 @@ function createWorkBranchPusher(args) {
|
|
|
88
89
|
// force push against. Starts unset even on a RESUMED branch: the tip we merely cloned is an
|
|
89
90
|
// earlier run's work, so a rewrite of it is refused (and re-driven) rather than forced away.
|
|
90
91
|
let publishedSha;
|
|
91
|
-
|
|
92
|
+
// `override` replaces the RUN's signal for this one push. Every ordinary push rides the run's
|
|
93
|
+
// signal, so a watchdog kill stops it. The rescue push cannot: the run's signal is ABORTED on
|
|
94
|
+
// exactly the paths that need a rescue, and an aborted signal makes `execFile` reject before it
|
|
95
|
+
// spawns, so a rescue on it is a guaranteed no-op. See {@link withSalvagedWork}.
|
|
96
|
+
const pushWorkOnce = (override) => {
|
|
92
97
|
if (pushInFlight)
|
|
93
98
|
return pushInFlight;
|
|
99
|
+
const pushSignal = override ?? signal;
|
|
94
100
|
pushInFlight = (async () => {
|
|
95
|
-
if (!(await unpublishedWorkBranchTip({ dir, baseSha, publishedSha, signal })))
|
|
101
|
+
if (!(await unpublishedWorkBranchTip({ dir, baseSha, publishedSha, signal: pushSignal })))
|
|
96
102
|
return;
|
|
97
103
|
// The rule the lease is entitled to lives beside the push ({@link workBranchLease}); the
|
|
98
104
|
// warn is here, because a withheld lease is how a rewrite this pass cannot claim fails the
|
|
@@ -102,14 +108,14 @@ function createWorkBranchPusher(args) {
|
|
|
102
108
|
branch: spec.pushBranch,
|
|
103
109
|
baseSha,
|
|
104
110
|
publishedSha,
|
|
105
|
-
signal,
|
|
111
|
+
signal: pushSignal,
|
|
106
112
|
onWithheld: (probe) => logger.warn('coding-agent: push lease withheld, the branch dropped its pre-run tip', {
|
|
107
113
|
baseSha,
|
|
108
114
|
publishedSha,
|
|
109
115
|
probe,
|
|
110
116
|
}),
|
|
111
117
|
});
|
|
112
|
-
publishedSha = await pushBranch(dir, spec.pushBranch, spec.ghToken,
|
|
118
|
+
publishedSha = await pushBranch(dir, spec.pushBranch, spec.ghToken, pushSignal, lease);
|
|
113
119
|
})().finally(() => {
|
|
114
120
|
pushInFlight = null;
|
|
115
121
|
});
|
|
@@ -140,6 +146,39 @@ function createWorkBranchPusher(args) {
|
|
|
140
146
|
checkpoint.unref?.();
|
|
141
147
|
return { pushWorkOnce, inFlightPush, checkpoint };
|
|
142
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Exclude the harness's own sentinel files from this checkout's git, and start tailing the
|
|
151
|
+
* follow-up one when the run streams follow-ups.
|
|
152
|
+
*
|
|
153
|
+
* Each sentinel is a file the PLATFORM writes into the agent's cwd (its effort self-assessment,
|
|
154
|
+
* its PR briefing, its follow-up items), so a `git add -A` by the agent would commit the
|
|
155
|
+
* platform's own bookkeeping into a customer's pull request. The exclude goes in
|
|
156
|
+
* `.git/info/exclude`, which is per-clone and never lands in the repo. `readEffortReport` also
|
|
157
|
+
* removes its file after the run, but that cannot un-stage a mid-run commit; only the exclude
|
|
158
|
+
* prevents one. A bare filename pattern matches at any depth, so a monorepo `workDir` is covered.
|
|
159
|
+
*
|
|
160
|
+
* The caller owns the returned interval's lifetime (it clears `followUpTick`). Extracted from
|
|
161
|
+
* {@link runCodingAgent} for the per-function line budget.
|
|
162
|
+
*/
|
|
163
|
+
async function armCheckoutSentinels(args) {
|
|
164
|
+
const { dir, workDir, spec, logger, opts } = args;
|
|
165
|
+
const { signal } = opts;
|
|
166
|
+
await excludeFromGit(dir, EFFORT_REPORT_FILE, signal);
|
|
167
|
+
await excludeFromGit(dir, PR_DESCRIPTION_FILE, signal);
|
|
168
|
+
// The follow-up sentinel lives in the agent's working directory (its cwd), where the prompt
|
|
169
|
+
// tells it to write; the other two are read from both the checkout root and the cwd.
|
|
170
|
+
const followUpTailer = spec.streamFollowUps && opts.onFollowUp
|
|
171
|
+
? new FollowUpTailer(join(workDir, FOLLOW_UPS_FILENAME), opts.onFollowUp, logger)
|
|
172
|
+
: undefined;
|
|
173
|
+
if (!followUpTailer)
|
|
174
|
+
return { followUpTailer: undefined, followUpTick: undefined };
|
|
175
|
+
await excludeFromGit(dir, FOLLOW_UPS_FILENAME, signal);
|
|
176
|
+
const followUpTick = setInterval(() => {
|
|
177
|
+
void followUpTailer.poll();
|
|
178
|
+
}, followUpPollIntervalMs());
|
|
179
|
+
followUpTick.unref?.();
|
|
180
|
+
return { followUpTailer, followUpTick };
|
|
181
|
+
}
|
|
143
182
|
export async function runCodingAgent(spec, opts = {}) {
|
|
144
183
|
const { signal } = opts;
|
|
145
184
|
// The registry already binds jobId/repo/branch; add the coding kind + the push branch
|
|
@@ -169,31 +208,16 @@ export async function runCodingAgent(spec, opts = {}) {
|
|
|
169
208
|
const workDir = serviceDirectory ? join(dir, serviceDirectory) : dir;
|
|
170
209
|
if (serviceDirectory)
|
|
171
210
|
await mkdir(workDir, { recursive: true });
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
// Follow-up companion: tail the Coder's sentinel file and stream new items out on the
|
|
183
|
-
// job view. Locally exclude it from git first so the agent's own `git add` can never
|
|
184
|
-
// stage it and it never surfaces as an untracked leftover or in the PR. The sentinel
|
|
185
|
-
// lives in the agent's working directory (its cwd), where the prompt tells it to write.
|
|
186
|
-
const followUpTailer = spec.streamFollowUps && opts.onFollowUp
|
|
187
|
-
? new FollowUpTailer(join(workDir, FOLLOW_UPS_FILENAME), opts.onFollowUp, logger)
|
|
188
|
-
: undefined;
|
|
189
|
-
let followUpTick;
|
|
190
|
-
if (followUpTailer) {
|
|
191
|
-
await excludeFromGit(dir, FOLLOW_UPS_FILENAME, signal);
|
|
192
|
-
followUpTick = setInterval(() => {
|
|
193
|
-
void followUpTailer.poll();
|
|
194
|
-
}, followUpPollIntervalMs());
|
|
195
|
-
followUpTick.unref?.();
|
|
196
|
-
}
|
|
211
|
+
// The harness's own side-channel files in this checkout: excluded from git so the agent's
|
|
212
|
+
// `git add` can never stage one into the PR, and the follow-up one tailed while the agent
|
|
213
|
+
// works. See {@link armCheckoutSentinels}.
|
|
214
|
+
const { followUpTailer, followUpTick } = await armCheckoutSentinels({
|
|
215
|
+
dir,
|
|
216
|
+
workDir,
|
|
217
|
+
spec,
|
|
218
|
+
logger,
|
|
219
|
+
opts,
|
|
220
|
+
});
|
|
197
221
|
// DEPENDENCY PREPOPULATION: install the service's dependencies into the checkout BEFORE the
|
|
198
222
|
// agent's first turn, so it reads real packages instead of inferring capabilities from a
|
|
199
223
|
// manifest. Runs in `workDir` (a monorepo service installs from its own subtree, exactly
|
|
@@ -350,6 +374,24 @@ export async function runCodingAgent(spec, opts = {}) {
|
|
|
350
374
|
prTemplate,
|
|
351
375
|
});
|
|
352
376
|
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
// The run was killed mid-flight: the progress guard tripped, a watchdog fired, or the
|
|
379
|
+
// container is going away. Everything the agent had not committed dies with the checkout,
|
|
380
|
+
// and on a greenfield task that is all of it. Salvage it onto the work branch and push,
|
|
381
|
+
// so a retry resumes on top of the work instead of starting over.
|
|
382
|
+
//
|
|
383
|
+
// Best-effort and non-masking: the ORIGINAL failure is what the run reports, so a salvage
|
|
384
|
+
// that itself fails may not replace it. What the salvage found is joined onto that
|
|
385
|
+
// failure's message instead, because "the run was aborted" and "its work is on the branch,
|
|
386
|
+
// reviewed by nobody" are one fact a person needs together.
|
|
387
|
+
//
|
|
388
|
+
// The checkpoint is stopped HERE rather than only in the `finally` below: it fires
|
|
389
|
+
// `pushWorkOnce`, which coalesces, so a checkpoint starting behind the rescue would be
|
|
390
|
+
// handed the rescue's push and a rescue starting behind a checkpoint would be handed a
|
|
391
|
+
// push made BEFORE the salvage commit existed — reporting as pushed a commit that is not.
|
|
392
|
+
clearInterval(checkpoint);
|
|
393
|
+
throw await withSalvagedWork(error, { dir, logger, pushWorkOnce, inFlightPush });
|
|
394
|
+
}
|
|
353
395
|
finally {
|
|
354
396
|
// Safety net for the throw path (the happy path already cleared these above).
|
|
355
397
|
clearInterval(checkpoint);
|
|
@@ -359,6 +401,126 @@ export async function runCodingAgent(spec, opts = {}) {
|
|
|
359
401
|
return outcome;
|
|
360
402
|
});
|
|
361
403
|
}
|
|
404
|
+
/**
|
|
405
|
+
* Prefix a run's summary with the salvage note, when there is one worth a human's attention.
|
|
406
|
+
*
|
|
407
|
+
* The test is the same in all three cases: did the agent produce something the push does NOT
|
|
408
|
+
* carry, which nothing else on a passing run would say. A refused or failed salvage is that, and
|
|
409
|
+
* so is a withheld secret-bearing file — the run looks clean and the file is not on the branch.
|
|
410
|
+
* A salvage that simply worked needs no note here: its files ARE in the push, and the commit
|
|
411
|
+
* message on the branch says where they came from.
|
|
412
|
+
*/
|
|
413
|
+
function withSalvageNote(summary, salvage) {
|
|
414
|
+
const missedWork = salvage.status === 'refused' || salvage.status === 'failed';
|
|
415
|
+
if (!missedWork && (salvage.withheld?.length ?? 0) === 0)
|
|
416
|
+
return summary;
|
|
417
|
+
const note = describeSalvage(salvage);
|
|
418
|
+
return note ? `${note}\n\n${summary}` : summary;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* How long the rescue of an aborted run's work gets, on its own clock.
|
|
422
|
+
*
|
|
423
|
+
* Bounded because the run's own bounds no longer apply: the rescue deliberately runs OFF the run's
|
|
424
|
+
* signal (see {@link rescueSignal}), so without this a wedged git command would hold the container
|
|
425
|
+
* open until the platform reclaims it. Generous enough for a status, an add, a commit and a push
|
|
426
|
+
* over a slow network, and each git command inside it still carries its own tighter ceiling.
|
|
427
|
+
* Overridable via env for tests.
|
|
428
|
+
*/
|
|
429
|
+
function salvageRescueMs() {
|
|
430
|
+
const n = Number(process.env.JOB_SALVAGE_RESCUE_MS);
|
|
431
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 120_000;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* The signal the rescue runs on: a FRESH one, never the run's.
|
|
435
|
+
*
|
|
436
|
+
* The rescue exists for the runs whose lifetime is already over — a watchdog fired, the guard
|
|
437
|
+
* tripped, the container is being evicted — and on those paths `opts.signal` is ABORTED. Node's
|
|
438
|
+
* `execFile` rejects on an already-aborted signal before it spawns anything, so passing it here
|
|
439
|
+
* makes every git call in the salvage and its push fail instantly: the rescue would be a
|
|
440
|
+
* guaranteed no-op in precisely the cases it was written for. Its own timeout is what bounds it
|
|
441
|
+
* instead.
|
|
442
|
+
*/
|
|
443
|
+
function rescueSignal() {
|
|
444
|
+
return AbortSignal.timeout(salvageRescueMs());
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Salvage what an aborted run left uncommitted, push it, and return the error to rethrow with the
|
|
448
|
+
* salvage stated on it.
|
|
449
|
+
*
|
|
450
|
+
* Returns rather than throws so the caller's `throw` stays visible at the call site, and so this
|
|
451
|
+
* can never REPLACE the failure being reported: a salvage that throws is swallowed, because the
|
|
452
|
+
* reason the run died is strictly more useful than the reason its rescue did.
|
|
453
|
+
*
|
|
454
|
+
* The push is what makes the salvage worth anything — the commit lives in a container that is
|
|
455
|
+
* about to be reclaimed — and it is reported HONESTLY: a commit that could not be pushed is lost
|
|
456
|
+
* exactly as the uncommitted files would have been, so the note says so rather than naming a sha
|
|
457
|
+
* nobody will ever be able to fetch.
|
|
458
|
+
*
|
|
459
|
+
* Two things have to happen before the salvage, and the caller has already stopped the checkpoint
|
|
460
|
+
* interval for the first. The second is here: any push the checkpoint had IN FLIGHT is drained,
|
|
461
|
+
* because `pushWorkOnce` coalesces onto it and would otherwise hand the rescue a push that was
|
|
462
|
+
* made before the salvage commit existed.
|
|
463
|
+
*
|
|
464
|
+
* Exported for its test: every collaborator it needs is a parameter, so the ordering and the
|
|
465
|
+
* signal it pushes on can be asserted against a real repository without a container.
|
|
466
|
+
*/
|
|
467
|
+
export async function withSalvagedWork(error, args) {
|
|
468
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
469
|
+
const signal = rescueSignal();
|
|
470
|
+
await drainInFlightPush(args.inFlightPush, args.logger);
|
|
471
|
+
const note = await salvageUntrackedWork({
|
|
472
|
+
dir: args.dir,
|
|
473
|
+
occasion: { kind: 'aborted', cause },
|
|
474
|
+
logger: args.logger,
|
|
475
|
+
signal,
|
|
476
|
+
})
|
|
477
|
+
.then(async (report) => {
|
|
478
|
+
if (report.status !== 'committed')
|
|
479
|
+
return describeSalvage(report);
|
|
480
|
+
return describeSalvage(report, await deliverSalvage(args, signal));
|
|
481
|
+
})
|
|
482
|
+
.catch((salvageError) => {
|
|
483
|
+
args.logger.error('coding-agent: salvage of an aborted run failed', {
|
|
484
|
+
reason: salvageError instanceof Error ? salvageError.message : String(salvageError),
|
|
485
|
+
});
|
|
486
|
+
return undefined;
|
|
487
|
+
});
|
|
488
|
+
if (!note)
|
|
489
|
+
return error;
|
|
490
|
+
if (error instanceof Error) {
|
|
491
|
+
error.message = `${error.message} ${note}`;
|
|
492
|
+
return error;
|
|
493
|
+
}
|
|
494
|
+
return new Error(`${cause} ${note}`);
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Wait out the checkpoint push already running, so the rescue's own push is not coalesced onto it.
|
|
498
|
+
*
|
|
499
|
+
* Its outcome is irrelevant and never propagates: it was a best-effort checkpoint of work that
|
|
500
|
+
* predates the salvage, and the rescue is about to push again anyway.
|
|
501
|
+
*/
|
|
502
|
+
async function drainInFlightPush(inFlightPush, logger) {
|
|
503
|
+
const pending = inFlightPush();
|
|
504
|
+
if (!pending)
|
|
505
|
+
return;
|
|
506
|
+
await pending.catch((error) => {
|
|
507
|
+
logger.warn('coding-agent: the checkpoint push in flight at the abort did not land', {
|
|
508
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
/** Push the salvage commit, reporting whether it actually landed rather than assuming it did. */
|
|
513
|
+
async function deliverSalvage(args, signal) {
|
|
514
|
+
try {
|
|
515
|
+
await args.pushWorkOnce(signal);
|
|
516
|
+
return { pushed: true };
|
|
517
|
+
}
|
|
518
|
+
catch (error) {
|
|
519
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
520
|
+
args.logger.error('coding-agent: the salvage commit could not be pushed', { reason });
|
|
521
|
+
return { pushed: false, reason };
|
|
522
|
+
}
|
|
523
|
+
}
|
|
362
524
|
/**
|
|
363
525
|
* Clone (or RESUME an existing branch) into `dir`, fetch any read-only reference branches, and
|
|
364
526
|
* capture the pre-run branch tip. Extracted from {@link runCodingAgent} so its body stays small;
|
|
@@ -471,7 +633,7 @@ async function prepareCodingCheckout(dir, spec, logger, opts) {
|
|
|
471
633
|
async function finalizeCodingRun(args) {
|
|
472
634
|
const { validationReport, reproductionReport, dir, spec, logger, opts, baseSha, resumed, workDir, checkpoint, followUpTick, followUpTailer, pushWorkOnce, inFlightPush, agentRun, prTemplate, } = args;
|
|
473
635
|
const { signal } = opts;
|
|
474
|
-
const {
|
|
636
|
+
const { stats, stderrTail, usage, callMetrics, effortReport } = agentRun;
|
|
475
637
|
let outcome;
|
|
476
638
|
// Stop tailing the follow-up sentinel and flush any items written after the last
|
|
477
639
|
// tick, so a fast final burst still reaches the job view before the run is recorded.
|
|
@@ -500,17 +662,25 @@ async function finalizeCodingRun(args) {
|
|
|
500
662
|
const inflight = inFlightPush();
|
|
501
663
|
if (inflight)
|
|
502
664
|
await inflight.catch(() => { });
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
}
|
|
513
|
-
|
|
665
|
+
// Recover the untracked, non-ignored files the agent left behind. `commitTrackedEdits` above
|
|
666
|
+
// only captures edits to ALREADY tracked files, so a NEW file the agent created and forgot to
|
|
667
|
+
// commit used to be listed, warned about and dropped — and on a greenfield task EVERY file is
|
|
668
|
+
// new, which made that warning the whole deliverable going in the bin. Observable is not
|
|
669
|
+
// recovered, so commit them. Guardrails (a dependency/build deny-list, a file-count and byte
|
|
670
|
+
// bound, an all-or-nothing refusal over it) live in `salvage.ts`; this path is coding mode by
|
|
671
|
+
// construction, which is the other rule it must obey.
|
|
672
|
+
const salvage = await salvageUntrackedWork({
|
|
673
|
+
dir,
|
|
674
|
+
occasion: { kind: 'settled' },
|
|
675
|
+
logger,
|
|
676
|
+
...(signal ? { signal } : {}),
|
|
677
|
+
});
|
|
678
|
+
// A salvage that COMMITTED needs no announcement: its files are in the push and its commit
|
|
679
|
+
// message says where they came from. A refused or failed one means work the agent produced is
|
|
680
|
+
// NOT in the pull request, on a run that otherwise reads as a clean pass — so say it in the
|
|
681
|
+
// summary, which is the harness's own account of the run and already reaches the step a human
|
|
682
|
+
// reads. The agent's text follows it, unchanged.
|
|
683
|
+
const summary = withSalvageNote(agentRun.summary, salvage);
|
|
514
684
|
// A fresh run produced work iff the branch advanced past its pre-run tip. A RESUMED
|
|
515
685
|
// run already carries prior work — UNLESS that branch turns out to have nothing ahead
|
|
516
686
|
// of the PR base (e.g. its earlier PR was merged with a merge commit, leaving the
|
|
@@ -540,6 +710,7 @@ async function finalizeCodingRun(args) {
|
|
|
540
710
|
...(usage ? { usage } : {}),
|
|
541
711
|
...(callMetrics ? { callMetrics } : {}),
|
|
542
712
|
...(effortReport ? { effortReport } : {}),
|
|
713
|
+
...(salvage.status === 'none' ? {} : { salvage }),
|
|
543
714
|
};
|
|
544
715
|
}
|
|
545
716
|
else {
|
|
@@ -556,6 +727,7 @@ async function finalizeCodingRun(args) {
|
|
|
556
727
|
...(callMetrics ? { callMetrics } : {}),
|
|
557
728
|
...(effortReport ? { effortReport } : {}),
|
|
558
729
|
...(prDescription ? { prDescription } : {}),
|
|
730
|
+
...(salvage.status === 'none' ? {} : { salvage }),
|
|
559
731
|
};
|
|
560
732
|
}
|
|
561
733
|
// Ralph loop: run the programmatic completion command against the pushed/committed
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where `entrypoint.sh` records its verdict. The two halves of one contract: change this and the
|
|
3
|
+
* `DOCKER_STATUS_FILE` default in `entrypoint.sh` together. `HARNESS_DOCKER_STATUS_FILE`
|
|
4
|
+
* overrides both (the acceptance suite and the unit tests point them at a temp file).
|
|
5
|
+
*/
|
|
6
|
+
export declare const DOCKER_STATUS_FILE = "/tmp/harness-docker-status.json";
|
|
7
|
+
/** Which daemon the verdict is about. Closed vocabulary, written by `entrypoint.sh`. */
|
|
8
|
+
export type DockerSource =
|
|
9
|
+
/** The rootless daemon this container starts for itself. */
|
|
10
|
+
'rootless'
|
|
11
|
+
/** A sidecar/external daemon a self-hosted pool wired in via `DOCKER_HOST`. */
|
|
12
|
+
| 'external'
|
|
13
|
+
/** No daemon in this image at all (no `dockerd` on PATH). */
|
|
14
|
+
| 'none'
|
|
15
|
+
/**
|
|
16
|
+
* Nothing recorded a verdict. NOT a failure: the native host-process transport
|
|
17
|
+
* (`LOCAL_NATIVE_AGENTS`) runs this harness with no entrypoint at all, on a developer's
|
|
18
|
+
* machine where Docker usually works fine.
|
|
19
|
+
*/
|
|
20
|
+
| 'unreported';
|
|
21
|
+
/**
|
|
22
|
+
* The container's Docker verdict.
|
|
23
|
+
*
|
|
24
|
+
* `available` is THREE-valued on purpose. `undefined` means "not decided" — the entrypoint's
|
|
25
|
+
* bounded wait is still running, or nothing recorded anything (native mode) — and a caller must
|
|
26
|
+
* treat it as it behaved before this existed: attempt, and report what happened. `false` is a
|
|
27
|
+
* DECIDED absence and is the only value anything refuses on.
|
|
28
|
+
*/
|
|
29
|
+
export interface DockerStatus {
|
|
30
|
+
available: boolean | undefined;
|
|
31
|
+
source: DockerSource;
|
|
32
|
+
/** Why, in the entrypoint's own closed vocabulary (`serving`/`failed`/`missing`/…). */
|
|
33
|
+
reason: string;
|
|
34
|
+
/** A human detail for the failing cases: the dockerd log tail, or what was unreachable. */
|
|
35
|
+
detail?: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Read the recorded verdict, or {@link UNREPORTED} when there is none.
|
|
39
|
+
*
|
|
40
|
+
* Defensive by design: the file crosses a shell→Node boundary, so an unreadable, truncated or
|
|
41
|
+
* malformed one answers "not decided" rather than throwing. That is the same disposition as an
|
|
42
|
+
* absent file, and it is the safe one — a parse bug here must not turn into a Tester that refuses
|
|
43
|
+
* to stand its dependencies up.
|
|
44
|
+
*/
|
|
45
|
+
export declare function readDockerStatus(path?: string): Promise<DockerStatus>;
|
|
46
|
+
/**
|
|
47
|
+
* The sentence a Tester (and the human reading its step) gets instead of a compose error, when
|
|
48
|
+
* the daemon is decidedly absent. It names the cause the agent could not have discovered and the
|
|
49
|
+
* consequence, because the agent's next move differs: with no daemon there is nothing to retry,
|
|
50
|
+
* and the useful run is the one that tests what it can and flags the dependency gap.
|
|
51
|
+
*
|
|
52
|
+
* TOTAL over {@link DockerSource}, deliberately. `unreported` is not a hypothetical arm: the
|
|
53
|
+
* reader above preserves a recorded `available: false` while degrading a source word this build
|
|
54
|
+
* does not know, so an absence whose source is `unreported` is exactly what a status file written
|
|
55
|
+
* by a NEWER entrypoint produces here. A ternary chain ending in the rootless arm answered that
|
|
56
|
+
* case by naming a daemon nobody said anything about: a guess, in the one sentence whose entire
|
|
57
|
+
* job is to tell a human which thing to go and fix. The `never` arm keeps the compile-time half:
|
|
58
|
+
* adding a source without a sentence stops building.
|
|
59
|
+
*/
|
|
60
|
+
export declare function describeDockerAbsence(status: DockerStatus): string;
|
|
61
|
+
/** Whether a daemon is answering RIGHT NOW. Injected so the unit suite can state either answer. */
|
|
62
|
+
export type DockerProbe = () => Promise<boolean>;
|
|
63
|
+
/**
|
|
64
|
+
* The default {@link DockerProbe}: `docker version` talks to the SERVER, unlike the client-only
|
|
65
|
+
* `docker --version`, which answers happily with no daemon at all.
|
|
66
|
+
*/
|
|
67
|
+
export declare const probeDockerServing: DockerProbe;
|
|
68
|
+
/** What a stand-up is entitled to conclude about the daemon at the moment it is about to run. */
|
|
69
|
+
export interface DockerVerdict {
|
|
70
|
+
/** Three-valued exactly as {@link DockerStatus.available}, and read the same way. */
|
|
71
|
+
available: boolean | undefined;
|
|
72
|
+
/** Set only for a CONFIRMED absence: the sentence to refuse with. Absent means proceed. */
|
|
73
|
+
refusal?: string;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Resolve what to do now, from what boot recorded plus what a daemon says today.
|
|
77
|
+
*
|
|
78
|
+
* `entrypoint.sh` probes once, at boot, within a bounded wait. A container outlives that: a warm
|
|
79
|
+
* pool serves many jobs from one, and a sidecar daemon that took longer than the wait allows is
|
|
80
|
+
* serving perfectly well by the second job. Refusing off the recorded verdict alone latches that
|
|
81
|
+
* container into refusing local infra that in fact works, for its whole life, with a stale
|
|
82
|
+
* sentence explaining why. So a recorded absence is a HYPOTHESIS here, and the live probe settles
|
|
83
|
+
* it; the recorded verdict is still what supplies the cause and the daemon's own log tail, which
|
|
84
|
+
* no probe can reconstruct.
|
|
85
|
+
*
|
|
86
|
+
* Only a recorded `false` is re-confirmed. "Not decided" keeps attempting exactly as before: the
|
|
87
|
+
* point of the third value is that nothing turns it into a refusal, and a probe here would.
|
|
88
|
+
*/
|
|
89
|
+
export declare function resolveDockerVerdict(status: DockerStatus, probe?: DockerProbe): Promise<DockerVerdict>;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
// What this container knows about its own Docker daemon, as recorded by `entrypoint.sh`.
|
|
5
|
+
//
|
|
6
|
+
// The Tester's local-mode infra stand-up (`docker compose up --wait`) is the only thing in the
|
|
7
|
+
// harness that needs a daemon, and for months there was none: the image installed
|
|
8
|
+
// `docker-ce-rootless-extras` (the wrappers that START a daemon) but never `docker-ce` (the
|
|
9
|
+
// daemon), and the entrypoint backgrounded the start in a subshell where its exit status was
|
|
10
|
+
// unobservable. Every local-infra Tester run degraded to a no-infra run, and the only trace was
|
|
11
|
+
// a compose error in a prompt note. This module is the answer that was missing: the entrypoint
|
|
12
|
+
// probes the daemon once and records the verdict, and everything that would otherwise ASSUME a
|
|
13
|
+
// daemon reads it instead.
|
|
14
|
+
//
|
|
15
|
+
// The recorded verdict describes BOOT, and a container outlives its boot, so nothing refuses on
|
|
16
|
+
// it unconfirmed: `resolveDockerVerdict` re-checks a recorded absence against a live daemon and
|
|
17
|
+
// keeps the record for what only the record holds, the cause and the daemon's own log tail.
|
|
18
|
+
//
|
|
19
|
+
// The three-valued shape is deliberate and is the point (CLAUDE.md, "Degrade loudly"): a daemon
|
|
20
|
+
// that FAILED and a daemon nobody asked about are different facts with different correct
|
|
21
|
+
// reactions, and collapsing them would either refuse stand-ups that work or silently attempt
|
|
22
|
+
// ones that cannot. Only a DECIDED `false` refuses.
|
|
23
|
+
/**
|
|
24
|
+
* Where `entrypoint.sh` records its verdict. The two halves of one contract: change this and the
|
|
25
|
+
* `DOCKER_STATUS_FILE` default in `entrypoint.sh` together. `HARNESS_DOCKER_STATUS_FILE`
|
|
26
|
+
* overrides both (the acceptance suite and the unit tests point them at a temp file).
|
|
27
|
+
*/
|
|
28
|
+
export const DOCKER_STATUS_FILE = '/tmp/harness-docker-status.json';
|
|
29
|
+
/** The verdict when nothing recorded one (see {@link DockerSource} `unreported`). */
|
|
30
|
+
const UNREPORTED = {
|
|
31
|
+
available: undefined,
|
|
32
|
+
source: 'unreported',
|
|
33
|
+
reason: 'no docker status was recorded for this harness process',
|
|
34
|
+
};
|
|
35
|
+
const SOURCES = ['rootless', 'external', 'none', 'unreported'];
|
|
36
|
+
/**
|
|
37
|
+
* Read the recorded verdict, or {@link UNREPORTED} when there is none.
|
|
38
|
+
*
|
|
39
|
+
* Defensive by design: the file crosses a shell→Node boundary, so an unreadable, truncated or
|
|
40
|
+
* malformed one answers "not decided" rather than throwing. That is the same disposition as an
|
|
41
|
+
* absent file, and it is the safe one — a parse bug here must not turn into a Tester that refuses
|
|
42
|
+
* to stand its dependencies up.
|
|
43
|
+
*/
|
|
44
|
+
export async function readDockerStatus(path = process.env.HARNESS_DOCKER_STATUS_FILE?.trim() || DOCKER_STATUS_FILE) {
|
|
45
|
+
let raw;
|
|
46
|
+
try {
|
|
47
|
+
raw = await readFile(path, 'utf8');
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return UNREPORTED;
|
|
51
|
+
}
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(raw);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return UNREPORTED;
|
|
58
|
+
}
|
|
59
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
60
|
+
return UNREPORTED;
|
|
61
|
+
const record = parsed;
|
|
62
|
+
const source = SOURCES.includes(record.source)
|
|
63
|
+
? record.source
|
|
64
|
+
: 'unreported';
|
|
65
|
+
const detail = typeof record.detail === 'string' && record.detail ? record.detail : undefined;
|
|
66
|
+
return {
|
|
67
|
+
available: typeof record.available === 'boolean' ? record.available : undefined,
|
|
68
|
+
source,
|
|
69
|
+
reason: typeof record.reason === 'string' && record.reason ? record.reason : UNREPORTED.reason,
|
|
70
|
+
...(detail ? { detail } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The sentence a Tester (and the human reading its step) gets instead of a compose error, when
|
|
75
|
+
* the daemon is decidedly absent. It names the cause the agent could not have discovered and the
|
|
76
|
+
* consequence, because the agent's next move differs: with no daemon there is nothing to retry,
|
|
77
|
+
* and the useful run is the one that tests what it can and flags the dependency gap.
|
|
78
|
+
*
|
|
79
|
+
* TOTAL over {@link DockerSource}, deliberately. `unreported` is not a hypothetical arm: the
|
|
80
|
+
* reader above preserves a recorded `available: false` while degrading a source word this build
|
|
81
|
+
* does not know, so an absence whose source is `unreported` is exactly what a status file written
|
|
82
|
+
* by a NEWER entrypoint produces here. A ternary chain ending in the rootless arm answered that
|
|
83
|
+
* case by naming a daemon nobody said anything about: a guess, in the one sentence whose entire
|
|
84
|
+
* job is to tell a human which thing to go and fix. The `never` arm keeps the compile-time half:
|
|
85
|
+
* adding a source without a sentence stops building.
|
|
86
|
+
*/
|
|
87
|
+
export function describeDockerAbsence(status) {
|
|
88
|
+
return status.detail
|
|
89
|
+
? `${absenceCause(status.source)} (${status.detail})`
|
|
90
|
+
: absenceCause(status.source);
|
|
91
|
+
}
|
|
92
|
+
function absenceCause(source) {
|
|
93
|
+
switch (source) {
|
|
94
|
+
case 'none':
|
|
95
|
+
return 'this executor image ships no Docker daemon';
|
|
96
|
+
case 'external':
|
|
97
|
+
return 'the external Docker daemon this container was pointed at is unreachable';
|
|
98
|
+
case 'rootless':
|
|
99
|
+
return 'this container could not start its rootless Docker daemon';
|
|
100
|
+
case 'unreported':
|
|
101
|
+
return 'no Docker daemon answered in this container, and the recorded verdict did not say which one was tried';
|
|
102
|
+
default:
|
|
103
|
+
return unnamedSource(source);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function unnamedSource(source) {
|
|
107
|
+
return `no Docker daemon answered in this container (unrecognised source ${JSON.stringify(source)})`;
|
|
108
|
+
}
|
|
109
|
+
/** A live probe may not outlast the thing it is guarding; a hung socket is an absent daemon here. */
|
|
110
|
+
const PROBE_TIMEOUT_MS = 10_000;
|
|
111
|
+
const execFileAsync = promisify(execFile);
|
|
112
|
+
/**
|
|
113
|
+
* The default {@link DockerProbe}: `docker version` talks to the SERVER, unlike the client-only
|
|
114
|
+
* `docker --version`, which answers happily with no daemon at all.
|
|
115
|
+
*/
|
|
116
|
+
export const probeDockerServing = async () => {
|
|
117
|
+
try {
|
|
118
|
+
await execFileAsync('docker', ['version', '--format', '{{.Server.Version}}'], {
|
|
119
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
120
|
+
});
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Resolve what to do now, from what boot recorded plus what a daemon says today.
|
|
129
|
+
*
|
|
130
|
+
* `entrypoint.sh` probes once, at boot, within a bounded wait. A container outlives that: a warm
|
|
131
|
+
* pool serves many jobs from one, and a sidecar daemon that took longer than the wait allows is
|
|
132
|
+
* serving perfectly well by the second job. Refusing off the recorded verdict alone latches that
|
|
133
|
+
* container into refusing local infra that in fact works, for its whole life, with a stale
|
|
134
|
+
* sentence explaining why. So a recorded absence is a HYPOTHESIS here, and the live probe settles
|
|
135
|
+
* it; the recorded verdict is still what supplies the cause and the daemon's own log tail, which
|
|
136
|
+
* no probe can reconstruct.
|
|
137
|
+
*
|
|
138
|
+
* Only a recorded `false` is re-confirmed. "Not decided" keeps attempting exactly as before: the
|
|
139
|
+
* point of the third value is that nothing turns it into a refusal, and a probe here would.
|
|
140
|
+
*/
|
|
141
|
+
export async function resolveDockerVerdict(status, probe = probeDockerServing) {
|
|
142
|
+
if (status.available !== false)
|
|
143
|
+
return { available: status.available };
|
|
144
|
+
if (await probe())
|
|
145
|
+
return { available: true };
|
|
146
|
+
return { available: false, refusal: describeDockerAbsence(status) };
|
|
147
|
+
}
|
package/dist/frontend-infra.js
CHANGED
|
@@ -3,6 +3,7 @@ import { promisify } from 'node:util';
|
|
|
3
3
|
import { writeFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { killChildProcess } from './process.js';
|
|
6
|
+
import { agentChildEnv } from './agent-env.js';
|
|
6
7
|
import { pathExists } from './fs-utils.js';
|
|
7
8
|
import { captureRedactedOutput, redactSecrets } from './redact.js';
|
|
8
9
|
import { log } from './logger.js';
|
|
@@ -109,7 +110,7 @@ export async function standUpFrontend(dir, infra, run, logger = log) {
|
|
|
109
110
|
signal,
|
|
110
111
|
timeout: 8 * 60_000,
|
|
111
112
|
maxBuffer: 16 * 1024 * 1024,
|
|
112
|
-
env:
|
|
113
|
+
env: agentChildEnv(jobEnv),
|
|
113
114
|
});
|
|
114
115
|
pushOutput(installed.stdout, installed.stderr);
|
|
115
116
|
// 2) Build (build-time env injected here; runtime injection writes a shim after).
|
|
@@ -121,7 +122,7 @@ export async function standUpFrontend(dir, infra, run, logger = log) {
|
|
|
121
122
|
signal,
|
|
122
123
|
timeout: 12 * 60_000,
|
|
123
124
|
maxBuffer: 16 * 1024 * 1024,
|
|
124
|
-
env:
|
|
125
|
+
env: agentChildEnv(jobEnv, buildEnv),
|
|
125
126
|
});
|
|
126
127
|
pushOutput(built.stdout, built.stderr);
|
|
127
128
|
// Runtime injection: write a `window.env` shim into the build output the app can load
|
|
@@ -258,7 +259,7 @@ function startServe(dir, infra, servePort, outputDir, logger) {
|
|
|
258
259
|
// Reserved names were already filtered from `infra.env` at parse; PORT wins last so
|
|
259
260
|
// the health-check's port is authoritative even if a binding tried to set it.
|
|
260
261
|
// (Spreading an undefined `infra.env` is a no-op, so no `?? {}` fallback is needed.)
|
|
261
|
-
env:
|
|
262
|
+
env: agentChildEnv(infra.env, { PORT: String(servePort) }),
|
|
262
263
|
}), 'serve', logger);
|
|
263
264
|
}
|
|
264
265
|
logger.info('agent(frontend): serving static output', { outputDir, servePort });
|