@cat-factory/executor-harness 1.137.0 → 1.139.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/dist/agent-runner.js +17 -2
- package/dist/agent.js +6 -3
- package/dist/claude-cli.d.ts +17 -0
- package/dist/claude-cli.js +8 -4
- package/dist/coding-agent.d.ts +8 -0
- package/dist/coding-agent.js +157 -61
- package/dist/git.d.ts +7 -0
- package/dist/git.js +14 -3
- package/dist/multi-repo-coding.js +1 -11
- package/dist/salvage.d.ts +44 -1
- package/dist/salvage.js +81 -4
- package/package.json +2 -2
- package/src/agent-runner.ts +19 -2
- package/src/agent.ts +6 -2
- package/src/claude-cli.ts +20 -4
- package/src/coding-agent.ts +204 -69
- package/src/git.ts +14 -3
- package/src/multi-repo-coding.ts +1 -15
- package/src/salvage.ts +87 -2
package/src/git.ts
CHANGED
|
@@ -665,6 +665,13 @@ export async function workingTreeStatus(dir: string, signal?: AbortSignal): Prom
|
|
|
665
665
|
* path in ONE command, that one name discards the whole all-or-nothing salvage, exactly as an
|
|
666
666
|
* unquoted accented name did. `:(literal)` matches the entry as itself and nothing else.
|
|
667
667
|
*
|
|
668
|
+
* The commit is SCOPED to those same pathspecs, and so is the staged-anything check above it. A
|
|
669
|
+
* bare `git commit` takes whatever the index holds, which is not the same set: an agent killed
|
|
670
|
+
* mid-flight leaves its own `git add` staged, and that content would then land under a message
|
|
671
|
+
* naming only the paths passed here, counted by a caller that had never looked at it. The commit
|
|
672
|
+
* and the claim made about it have to describe ONE set of files. Content the agent staged and this
|
|
673
|
+
* call did not name stays staged for whoever commits it next.
|
|
674
|
+
*
|
|
668
675
|
* The caller has already decided WHICH paths belong; this only commits them.
|
|
669
676
|
*/
|
|
670
677
|
export async function commitPaths(
|
|
@@ -674,10 +681,14 @@ export async function commitPaths(
|
|
|
674
681
|
signal?: AbortSignal,
|
|
675
682
|
): Promise<string | null> {
|
|
676
683
|
if (paths.length === 0) return null
|
|
677
|
-
|
|
678
|
-
|
|
684
|
+
const pathspecs = paths.map(literalPathspec)
|
|
685
|
+
await git(['add', '--', ...pathspecs], { cwd: dir, signal })
|
|
686
|
+
const staged = await git(['diff', '--cached', '--name-only', '--', ...pathspecs], {
|
|
687
|
+
cwd: dir,
|
|
688
|
+
signal,
|
|
689
|
+
})
|
|
679
690
|
if (staged.trim() === '') return null
|
|
680
|
-
await git(['commit', '-m', message], { cwd: dir, signal })
|
|
691
|
+
await git(['commit', '-m', message, '--', ...pathspecs], { cwd: dir, signal })
|
|
681
692
|
return headCommit(dir, signal)
|
|
682
693
|
}
|
|
683
694
|
|
package/src/multi-repo-coding.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
refreshFromBaseIfClean,
|
|
17
17
|
remoteBranchExists,
|
|
18
18
|
} from './git.js'
|
|
19
|
-
import {
|
|
19
|
+
import { salvageUntrackedWork, withSalvageOnlyNote } from './salvage.js'
|
|
20
20
|
import { openPullRequest } from './vcs-api.js'
|
|
21
21
|
import { applyPrDescription, PR_DESCRIPTION_FILE, readPrDescription } from './pr-description.js'
|
|
22
22
|
import { runAgentInWorkspace, withWorkspace } from './pi-workspace.js'
|
|
@@ -403,20 +403,6 @@ export function probeDirsForLegs(legs: readonly { dir: string; readOnly?: boolea
|
|
|
403
403
|
return legs.filter((leg) => !leg.readOnly).map((leg) => leg.dir)
|
|
404
404
|
}
|
|
405
405
|
|
|
406
|
-
/**
|
|
407
|
-
* Put {@link salvageOnlyNotice} at the top of a pull request that is nothing but a salvage.
|
|
408
|
-
*
|
|
409
|
-
* Only the BODY is marked. A title carrying it would follow the PR into every list and
|
|
410
|
-
* notification a maintainer sees, which is a lot of noise for a caveat that belongs beside the
|
|
411
|
-
* diff, and the salvage commit's own message elaborates on it there.
|
|
412
|
-
*/
|
|
413
|
-
function withSalvageOnlyNote(
|
|
414
|
-
pr: { title: string; body: string },
|
|
415
|
-
salvageOnly: boolean,
|
|
416
|
-
): { title: string; body: string } {
|
|
417
|
-
return salvageOnly ? { ...pr, body: `${salvageOnlyNotice()}\n\n${pr.body}` } : pr
|
|
418
|
-
}
|
|
419
|
-
|
|
420
406
|
/**
|
|
421
407
|
* Push phase for {@link runMultiRepoCoding}: commit forgotten tracked edits, then push + open a PR
|
|
422
408
|
* for each repo the run actually changed (a repo the agent left untouched is skipped — no branch,
|
package/src/salvage.ts
CHANGED
|
@@ -269,6 +269,63 @@ export async function salvageUntrackedWork(args: {
|
|
|
269
269
|
}
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Fold a later salvage pass onto an earlier one, so a run that salvaged TWICE reports what both
|
|
274
|
+
* passes did rather than only the last.
|
|
275
|
+
*
|
|
276
|
+
* A coding run salvages at each point where the next thing to happen reads COMMITS rather than the
|
|
277
|
+
* working tree: once before the pre-PR gates (which is what lets them run at all on a run whose
|
|
278
|
+
* only product is untracked files), and again at the settle, because a gate's repair round runs
|
|
279
|
+
* the agent afresh and can leave new files of its own. On almost every run the second pass is
|
|
280
|
+
* `none` (the first one already committed everything), so this is a cheap way to keep ONE honest
|
|
281
|
+
* report instead of two half-truths.
|
|
282
|
+
*
|
|
283
|
+
* Counts are summed only when BOTH passes committed, because only then are the two sets disjoint.
|
|
284
|
+
* A pass that refused or failed sees the same files again on the next pass, so summing there would
|
|
285
|
+
* double-count the one loss; instead the more significant status wins outright, `failed` over
|
|
286
|
+
* `refused` over `committed`, on the rule that a pass which could NOT keep its files is the fact a
|
|
287
|
+
* human has to act on and must not be hidden by a later pass that found nothing left to do. Ties
|
|
288
|
+
* take the later pass, whose numbers are the current ones.
|
|
289
|
+
*
|
|
290
|
+
* `withheld` is always the union: a credential-bearing file either pass declined to commit has to
|
|
291
|
+
* be named whatever else happened, since naming it is what lets someone rotate what it held.
|
|
292
|
+
*/
|
|
293
|
+
export function foldSalvageReports(previous: SalvageReport, next: SalvageReport): SalvageReport {
|
|
294
|
+
if (next.status === 'none') return withWithheld(previous, next)
|
|
295
|
+
if (previous.status === 'none') return withWithheld(next, previous)
|
|
296
|
+
if (previous.status === 'committed' && next.status === 'committed') {
|
|
297
|
+
return withWithheld(
|
|
298
|
+
{
|
|
299
|
+
status: 'committed',
|
|
300
|
+
files: [...previous.files, ...next.files].slice(0, REPORTED_PATHS),
|
|
301
|
+
fileCount: previous.fileCount + next.fileCount,
|
|
302
|
+
totalBytes: previous.totalBytes + next.totalBytes,
|
|
303
|
+
...((next.commitSha ?? previous.commitSha)
|
|
304
|
+
? { commitSha: next.commitSha ?? previous.commitSha }
|
|
305
|
+
: {}),
|
|
306
|
+
},
|
|
307
|
+
previous,
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
return SALVAGE_STATUS_RANK[previous.status] > SALVAGE_STATUS_RANK[next.status]
|
|
311
|
+
? withWithheld(previous, next)
|
|
312
|
+
: withWithheld(next, previous)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Which status a fold keeps when the two passes disagree; see {@link foldSalvageReports}. */
|
|
316
|
+
const SALVAGE_STATUS_RANK: Record<SalvageReport['status'], number> = {
|
|
317
|
+
none: 0,
|
|
318
|
+
committed: 1,
|
|
319
|
+
refused: 2,
|
|
320
|
+
failed: 3,
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** `kept` with `other`'s withheld paths merged in, de-duplicated and order-preserving. */
|
|
324
|
+
function withWithheld(kept: SalvageReport, other: SalvageReport): SalvageReport {
|
|
325
|
+
const union = [...new Set([...(kept.withheld ?? []), ...(other.withheld ?? [])])]
|
|
326
|
+
return union.length > 0 ? { ...kept, withheld: union } : kept
|
|
327
|
+
}
|
|
328
|
+
|
|
272
329
|
/**
|
|
273
330
|
* How the run that left these files behind ended. It decides what the commit message SAYS, which
|
|
274
331
|
* is the whole point of marking a salvage: a commit arriving on a branch with no explanation is
|
|
@@ -322,6 +379,22 @@ export function salvageOnlyNotice(): string {
|
|
|
322
379
|
)
|
|
323
380
|
}
|
|
324
381
|
|
|
382
|
+
/**
|
|
383
|
+
* Put {@link salvageOnlyNotice} at the top of a pull request that is nothing but a salvage.
|
|
384
|
+
*
|
|
385
|
+
* Only the BODY is marked. A title carrying it would follow the PR into every list and
|
|
386
|
+
* notification a maintainer sees, which is a lot of noise for a caveat that belongs beside the
|
|
387
|
+
* diff, and the salvage commit's own message elaborates on it there.
|
|
388
|
+
*
|
|
389
|
+
* Lives here rather than beside either caller because BOTH open pull requests off a salvage-only
|
|
390
|
+
* branch: the multi-repo push phase, per leg, and the single-repo one. It was the multi-repo path
|
|
391
|
+
* alone for a while, which meant the same branch shape opened an unmarked PR depending only on how
|
|
392
|
+
* many repositories the run happened to clone.
|
|
393
|
+
*/
|
|
394
|
+
export function withSalvageOnlyNote<T extends { body: string }>(pr: T, salvageOnly: boolean): T {
|
|
395
|
+
return salvageOnly ? { ...pr, body: `${salvageOnlyNotice()}\n\n${pr.body}` } : pr
|
|
396
|
+
}
|
|
397
|
+
|
|
325
398
|
/** Total size of `paths` under `dir`; a file that cannot be stat'd counts as zero rather than failing. */
|
|
326
399
|
async function measure(dir: string, paths: string[]): Promise<number> {
|
|
327
400
|
const sizes = await Promise.all(
|
|
@@ -351,14 +424,21 @@ export interface SalvageDelivery {
|
|
|
351
424
|
* reports, so the person reading "the run was killed" is told in the same breath what became of
|
|
352
425
|
* its work: on the branch and reviewed by nobody, still in the container, or never committed.
|
|
353
426
|
*
|
|
427
|
+
* `occasion` is the SAME fact {@link salvageCommitMessage} is given, and for the same reason: how
|
|
428
|
+
* the run ended is what decides how much to trust the files. A run that was killed left them
|
|
429
|
+
* mid-thought; a run that settled simply never added them, and telling a human a clean run "was
|
|
430
|
+
* aborted" describes a failure that did not happen. The two texts had drifted precisely here,
|
|
431
|
+
* which is why the occasion is now a parameter of both rather than a constant inside one.
|
|
432
|
+
*
|
|
354
433
|
* `delivery` is supplied by whoever pushed. Absent means the caller is on a path where the
|
|
355
434
|
* ordinary push follows (the settle path), so there is nothing extra to say.
|
|
356
435
|
*/
|
|
357
436
|
export function describeSalvage(
|
|
358
437
|
report: SalvageReport,
|
|
438
|
+
occasion: SalvageOccasion,
|
|
359
439
|
delivery?: SalvageDelivery,
|
|
360
440
|
): string | undefined {
|
|
361
|
-
const parts = [describeOutcome(report, delivery), describeWithheld(report)].filter(
|
|
441
|
+
const parts = [describeOutcome(report, occasion, delivery), describeWithheld(report)].filter(
|
|
362
442
|
(part): part is string => part !== undefined,
|
|
363
443
|
)
|
|
364
444
|
return parts.length > 0 ? parts.join(' ') : undefined
|
|
@@ -367,6 +447,7 @@ export function describeSalvage(
|
|
|
367
447
|
/** The fate of the files the salvage DID try to keep. */
|
|
368
448
|
function describeOutcome(
|
|
369
449
|
report: SalvageReport,
|
|
450
|
+
occasion: SalvageOccasion,
|
|
370
451
|
delivery: SalvageDelivery | undefined,
|
|
371
452
|
): string | undefined {
|
|
372
453
|
switch (report.status) {
|
|
@@ -378,9 +459,13 @@ function describeOutcome(
|
|
|
378
459
|
? `commit ${report.commitSha ?? 'unknown'}, which could NOT be pushed ` +
|
|
379
460
|
`(${delivery.reason ?? 'the push failed'}) and so is lost with the container`
|
|
380
461
|
: `commit ${report.commitSha ?? 'unknown'}`
|
|
462
|
+
const why =
|
|
463
|
+
occasion.kind === 'aborted'
|
|
464
|
+
? 'this run was aborted'
|
|
465
|
+
: 'the agent finished without committing them'
|
|
381
466
|
return (
|
|
382
467
|
`${report.fileCount} uncommitted new file(s) the agent left behind were salvaged into ` +
|
|
383
|
-
`${landed};
|
|
468
|
+
`${landed}; ${why}, so review them before trusting them.`
|
|
384
469
|
)
|
|
385
470
|
}
|
|
386
471
|
case 'refused':
|