@cat-factory/executor-harness 1.74.0 → 1.76.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 +21 -8
- package/dist/agent-runner.js +167 -55
- package/dist/agent.js +63 -5
- package/dist/captured-command.js +16 -0
- package/dist/coding-agent.js +44 -2
- package/dist/dependency-install.js +245 -0
- package/dist/git.js +45 -0
- package/dist/job.js +4 -1
- package/dist/process-exit.js +18 -0
- package/dist/runner.js +91 -23
- package/dist/validation-checks.js +6 -2
- package/package.json +4 -4
- package/src/agent-runner.ts +203 -58
- package/src/agent.ts +73 -5
- package/src/captured-command.ts +17 -0
- package/src/coding-agent.ts +57 -2
- package/src/dependency-install.ts +333 -0
- package/src/git.ts +52 -0
- package/src/job.ts +17 -0
- package/src/process-exit.ts +19 -0
- package/src/runner.ts +124 -37
- package/src/validation-checks.ts +6 -2
package/src/agent-runner.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from './agent-capabilities.js'
|
|
25
25
|
import { ProgressGuard, type ProgressGuardLimits } from './progress-guard.js'
|
|
26
26
|
import { killChildProcess, spawnDetached } from './process.js'
|
|
27
|
+
import { describeProcessExit } from './process-exit.js'
|
|
27
28
|
import { redact, registerKnownSecrets, secretsToRedact } from './redact.js'
|
|
28
29
|
import { createSliceTracker, startSubagentWatcher } from './subagents.js'
|
|
29
30
|
import {
|
|
@@ -246,7 +247,7 @@ function streamCli(
|
|
|
246
247
|
opts.signal?.removeEventListener('abort', onAbort)
|
|
247
248
|
reject(err)
|
|
248
249
|
})
|
|
249
|
-
child.on('close', (code) => {
|
|
250
|
+
child.on('close', (code, signal) => {
|
|
250
251
|
opts.signal?.removeEventListener('abort', onAbort)
|
|
251
252
|
const stderrTail = redact(stderr, secrets).slice(-700)
|
|
252
253
|
if (lineBuffer.trim()) processLine(lineBuffer.trim(), true)
|
|
@@ -258,7 +259,7 @@ function streamCli(
|
|
|
258
259
|
return
|
|
259
260
|
}
|
|
260
261
|
if (code !== 0) {
|
|
261
|
-
reject(new
|
|
262
|
+
reject(new CliExitFailure({ command, exitCode: code, signal, stderrTail }))
|
|
262
263
|
return
|
|
263
264
|
}
|
|
264
265
|
resolve({ stderrTail })
|
|
@@ -266,6 +267,100 @@ function streamCli(
|
|
|
266
267
|
})
|
|
267
268
|
}
|
|
268
269
|
|
|
270
|
+
/**
|
|
271
|
+
* A CLI subprocess that ended badly — it exited non-zero, or a signal killed it.
|
|
272
|
+
*
|
|
273
|
+
* Its own class (rather than a formatted string) because the message is not final at throw time:
|
|
274
|
+
* the caller folds in the CLI's terminal report before it surfaces (see {@link withAgentReport}),
|
|
275
|
+
* and rebuilding from parts beats patching a rendered sentence. Distinct from the watchdog-abort
|
|
276
|
+
* rejection above, which owns its own diagnostic and must keep it.
|
|
277
|
+
*/
|
|
278
|
+
class CliExitFailure extends Error {
|
|
279
|
+
readonly parts: CliExit
|
|
280
|
+
/**
|
|
281
|
+
* Also exposed flat, matching the watchdog-abort rejection's shape: the guard-trip branch
|
|
282
|
+
* reads `stderrTail` off whatever it caught to append to its own replacement message.
|
|
283
|
+
*/
|
|
284
|
+
readonly stderrTail: string
|
|
285
|
+
constructor(parts: CliExit, report = '') {
|
|
286
|
+
super(cliExitMessage(parts, report))
|
|
287
|
+
this.name = 'CliExitFailure'
|
|
288
|
+
this.parts = parts
|
|
289
|
+
this.stderrTail = parts.stderrTail
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
interface CliExit {
|
|
294
|
+
command: string
|
|
295
|
+
/** The exit code, or `null` when a signal killed the CLI instead. */
|
|
296
|
+
exitCode: number | null
|
|
297
|
+
signal: NodeJS.Signals | null
|
|
298
|
+
stderrTail: string
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* One message shape for a CLI that ended badly, with or without a report to add.
|
|
303
|
+
*
|
|
304
|
+
* How it ended is rendered through {@link describeProcessExit}, the shared vocabulary every
|
|
305
|
+
* process-reporting transport uses, so an externally-killed container job (an OOM kill, a
|
|
306
|
+
* `docker stop` racing teardown) reads differently from the CLI's own failure exit.
|
|
307
|
+
*/
|
|
308
|
+
function cliExitMessage(exit: CliExit, report: string): string {
|
|
309
|
+
const how = describeProcessExit(exit.exitCode, exit.signal)
|
|
310
|
+
const suffix = report ? ` Agent's last report: ${report}` : ''
|
|
311
|
+
return `${exit.command} ${how}: ${exit.stderrTail || '(no stderr output)'}${suffix}`
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Re-throw a bad CLI exit with the CLI's own account of how the run ended folded in.
|
|
316
|
+
*
|
|
317
|
+
* Both agent CLIs report a terminal failure on STDOUT, inside their event stream (Claude Code's
|
|
318
|
+
* `result` event, Codex's last agent message) — never on stderr. So a run the upstream API kept
|
|
319
|
+
* refusing exits non-zero with an EMPTY stderr tail, and the harness surfaces `claude exited with
|
|
320
|
+
* code 1:` and nothing else, while the reason it collected sits in a local variable only the
|
|
321
|
+
* SUCCESS path returns. That failure is indistinguishable from a crash, and the operator has no
|
|
322
|
+
* next step. Nothing else in the run records it: the CLI's session transcript dies with the
|
|
323
|
+
* per-run config home, and a local-mode container is removed the moment the job settles.
|
|
324
|
+
*
|
|
325
|
+
* Anything that is not a bad-exit rejection passes through untouched — a watchdog abort and a
|
|
326
|
+
* tripped progress guard carry more specific diagnostics already.
|
|
327
|
+
*/
|
|
328
|
+
function withAgentReport(err: unknown, report: string, secrets: string[]): unknown {
|
|
329
|
+
if (!(err instanceof CliExitFailure)) return err
|
|
330
|
+
const folded = capReport(redact(report, secrets).trim())
|
|
331
|
+
return folded ? new CliExitFailure(err.parts, folded) : err
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** How much of the agent's terminal report the failure message carries. */
|
|
335
|
+
const MAX_AGENT_REPORT_CHARS = 700
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Bound the agent's terminal report, keeping its HEAD — the opposite bias from the stderr tail
|
|
339
|
+
* beside it, and deliberately so. A stderr tail is a log: the cause is whatever it ended on. A
|
|
340
|
+
* report is a written statement, and its opening is where the answer lives — the failure
|
|
341
|
+
* `subtype` {@link claudeResultReport} prepends, or the first line of Codex's last agent message.
|
|
342
|
+
* Tail-slicing it drops exactly the classification the fold exists to surface.
|
|
343
|
+
*
|
|
344
|
+
* A cut is marked, because a report that merely stops reads like an agent that trailed off.
|
|
345
|
+
*/
|
|
346
|
+
function capReport(report: string): string {
|
|
347
|
+
if (report.length <= MAX_AGENT_REPORT_CHARS) return report
|
|
348
|
+
return `${report.slice(0, MAX_AGENT_REPORT_CHARS)}… (report truncated)`
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The CLI's own account of how the run ended, read off its terminal `result` event: the failure
|
|
353
|
+
* `subtype` it names (`error_during_execution`, `error_max_turns`, …) joined to whatever text it
|
|
354
|
+
* printed. A headless `-p` run reports an upstream API refusal HERE — on stdout, as JSON — and
|
|
355
|
+
* nowhere else, so this is what a bad exit has to carry. A clean result yields just its text.
|
|
356
|
+
*/
|
|
357
|
+
function claudeResultReport(event: Record<string, unknown>): string {
|
|
358
|
+
const subtype = typeof event.subtype === 'string' ? event.subtype : ''
|
|
359
|
+
const text = typeof event.result === 'string' ? event.result.trim() : ''
|
|
360
|
+
const failed = event.is_error === true || (subtype !== '' && subtype !== 'success')
|
|
361
|
+
return failed ? [subtype || 'error', text].filter(Boolean).join(': ') : text
|
|
362
|
+
}
|
|
363
|
+
|
|
269
364
|
/**
|
|
270
365
|
* Fold a composed system prompt into the task prompt so the role + best-practice context
|
|
271
366
|
* rides stdin as a single user turn. Used by the Codex runner (no system-prompt flag) and
|
|
@@ -389,6 +484,8 @@ async function setUpClaudeMcp(
|
|
|
389
484
|
export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRunOutcome> {
|
|
390
485
|
const stats: PiRunStats = { toolCalls: 0, assistantChars: 0 }
|
|
391
486
|
let summary = ''
|
|
487
|
+
/** The CLI's own account of how the run ended — see {@link claudeResultReport}. */
|
|
488
|
+
let terminalReport = ''
|
|
392
489
|
let usage: { inputTokens: number; outputTokens: number } | undefined
|
|
393
490
|
|
|
394
491
|
// Decide how the composed system prompt is carried up front, so the telemetry seed below
|
|
@@ -537,54 +634,12 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
537
634
|
} else if (type === 'result') {
|
|
538
635
|
if (typeof event.result === 'string') summary = event.result
|
|
539
636
|
usage = claudeUsage(event.usage) ?? usage
|
|
637
|
+
terminalReport = claudeResultReport(event) || terminalReport
|
|
540
638
|
}
|
|
541
639
|
}
|
|
542
640
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
// Claude Code persists user config/credentials under its config dir; point that at an
|
|
546
|
-
// isolated, per-run temp dir OUTSIDE the cloned checkout (`opts.cwd`). Otherwise the
|
|
547
|
-
// agents that finish with `git add -A` (blueprint/requirements/bootstrap) could stage a
|
|
548
|
-
// stray `.claude/` directory — and any cached credential in it — into the pushed branch.
|
|
549
|
-
// Mirrors the Codex CODEX_HOME isolation below; removed in `finally`.
|
|
550
|
-
if (!opts.ambientAuth && !opts.subscriptionToken) {
|
|
551
|
-
throw new Error('claude-code harness requires a subscription token (or ambientAuth)')
|
|
552
|
-
}
|
|
553
|
-
const configHome = opts.ambientAuth ? undefined : await mkdtemp(join(tmpdir(), 'cf-claude-'))
|
|
554
|
-
|
|
555
|
-
// The config dir is brand-new every run, so Claude Code would otherwise treat this
|
|
556
|
-
// as a first launch and BLOCK on the interactive onboarding / "trust this folder" /
|
|
557
|
-
// bypass-permissions acknowledgement prompts — which never get answered headlessly,
|
|
558
|
-
// hanging the job until the watchdog kills it. Pre-seed the config that marks those
|
|
559
|
-
// as already accepted so `-p` starts straight into the run. Best-effort: written
|
|
560
|
-
// before the CLI starts; unknown keys are harmless if a CLI version ignores them.
|
|
561
|
-
// (Ambient mode skips this — the developer's own config is already onboarded.)
|
|
562
|
-
// ADR 0026 D4: assert the pinned onboarding keys landed and log them with the CLI
|
|
563
|
-
// version, so a future first-run gate this set doesn't cover (which looks identical to
|
|
564
|
-
// a healthy-but-quiet subagent start) is diffable when the cold-start watchdog fires.
|
|
565
|
-
if (configHome) {
|
|
566
|
-
await writeOnboardingPreseed(configHome)
|
|
567
|
-
await assertOnboardingKeysCurrent(configHome, process.env.CLAUDE_CLI_VERSION, opts.log)
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
// Skills: install each as a native skill under the config dir's `skills/<name>/` so the CLI
|
|
571
|
-
// discovers and can invoke it. ONLY into the isolated per-run config home — never the
|
|
572
|
-
// developer's own `~/.claude` (ambient/native mode), where it would persist in their personal
|
|
573
|
-
// setup after the run and two concurrent jobs carrying same-named skills would clobber each
|
|
574
|
-
// other. An ambient run reads the skills from the checkout instead (`.cat-context/skill/<name>/`,
|
|
575
|
-
// materialised by the caller). Best-effort: a write failure must not wedge the run — the prompt
|
|
576
|
-
// still names the skills.
|
|
577
|
-
if (configHome) {
|
|
578
|
-
for (const skill of opts.skills ?? []) {
|
|
579
|
-
await writeNativeSkill(join(configHome, 'skills'), skill).catch(() => {})
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
// Tool servers (MCP): the CLI is pointed at a per-run config rather than discovering an ambient
|
|
584
|
-
// one. See `setUpClaudeMcp` for why that matters and what has to be cleaned up afterwards.
|
|
585
|
-
const mcp = await setUpClaudeMcp(opts.mcpServers, configHome)
|
|
586
|
-
|
|
587
|
-
const env = buildClaudeEnv(opts, configHome)
|
|
641
|
+
const home = await openClaudeRunHome(opts)
|
|
642
|
+
const { configHome } = home
|
|
588
643
|
|
|
589
644
|
// ADR 0026 D3 (path corrected by ADR 0027 Defect A): while the run is live, tail the CLI's
|
|
590
645
|
// subagent `*.jsonl` transcripts so a parallel-subagent review keeps the inactivity
|
|
@@ -627,13 +682,13 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
627
682
|
'bypassPermissions',
|
|
628
683
|
'--model',
|
|
629
684
|
opts.model,
|
|
630
|
-
...
|
|
685
|
+
...home.mcpArgs,
|
|
631
686
|
...appendArgs,
|
|
632
687
|
],
|
|
633
688
|
},
|
|
634
689
|
prompt,
|
|
635
690
|
{ ...opts, signal: runSignal },
|
|
636
|
-
env,
|
|
691
|
+
home.env,
|
|
637
692
|
opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [],
|
|
638
693
|
onEvent,
|
|
639
694
|
)
|
|
@@ -659,19 +714,105 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
659
714
|
telemetry.flush()
|
|
660
715
|
publisher.flush()
|
|
661
716
|
// A tripped no-progress guard aborted the CLI; streamCli rejects with its generic abort
|
|
662
|
-
// message, so replace it with the guard's actionable diagnostic — carrying the stderr tail
|
|
663
|
-
//
|
|
664
|
-
// killed.
|
|
717
|
+
// message, so replace it with the guard's actionable diagnostic — carrying the stderr tail it
|
|
718
|
+
// attached, since that is usually the only evidence of what the CLI was doing when it was
|
|
719
|
+
// killed. The leading clauses are byte-for-byte the shape `runPi` fails with; a terminal
|
|
720
|
+
// report is appended after them when the CLI managed to emit one before it was killed, which
|
|
721
|
+
// is uncommon but is the same evidence a bad exit now carries — a guard trip is no reason to
|
|
722
|
+
// discard it.
|
|
665
723
|
if (guardReason) {
|
|
666
724
|
const tail = (err as { stderrTail?: string } | undefined)?.stderrTail
|
|
667
|
-
|
|
725
|
+
const report = capReport(redact(terminalReport, secrets).trim())
|
|
726
|
+
throw new Error(
|
|
727
|
+
[
|
|
728
|
+
guardReason,
|
|
729
|
+
tail ? `Agent stderr: ${tail}` : '',
|
|
730
|
+
report ? `Agent's last report: ${report}` : '',
|
|
731
|
+
]
|
|
732
|
+
.filter(Boolean)
|
|
733
|
+
.join(' '),
|
|
734
|
+
)
|
|
668
735
|
}
|
|
669
|
-
throw err
|
|
736
|
+
throw withAgentReport(err, terminalReport, secrets)
|
|
670
737
|
} finally {
|
|
671
738
|
await subagents?.stop()
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
739
|
+
await home.dispose()
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* The isolated, per-run home the `claude` CLI runs against: a temp config dir OUTSIDE the cloned
|
|
745
|
+
* checkout, pre-seeded past the first-launch prompts, carrying the run's native skills and MCP
|
|
746
|
+
* config, plus the child env pointing the CLI at it. {@link ClaudeRunHome.dispose} is the other
|
|
747
|
+
* half of the same concern — the leased credential must never outlive the run — so acquisition
|
|
748
|
+
* and teardown are defined together rather than split across a `finally` forty lines away.
|
|
749
|
+
*
|
|
750
|
+
* Ambient (native) mode has NO home: the developer's installed CLI uses its own `~/.claude`
|
|
751
|
+
* login, so nothing is created, nothing is pre-seeded, and `dispose` only clears the MCP config.
|
|
752
|
+
*/
|
|
753
|
+
interface ClaudeRunHome {
|
|
754
|
+
/** The per-run config dir; `undefined` in ambient mode (the developer's own login is used). */
|
|
755
|
+
configHome: string | undefined
|
|
756
|
+
/** The CLI argv selecting the run's tool servers; empty when it has none. */
|
|
757
|
+
mcpArgs: string[]
|
|
758
|
+
/** The child-process env (see {@link buildClaudeEnv}). */
|
|
759
|
+
env: Record<string, string>
|
|
760
|
+
dispose: () => Promise<void>
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
async function openClaudeRunHome(opts: SubscriptionRunOptions): Promise<ClaudeRunHome> {
|
|
764
|
+
// Native (ambient) mode: run the developer's installed `claude` with its OWN login —
|
|
765
|
+
// no isolated config home, no injected credential, no onboarding pre-seed. Otherwise,
|
|
766
|
+
// Claude Code persists user config/credentials under its config dir; point that at an
|
|
767
|
+
// isolated, per-run temp dir OUTSIDE the cloned checkout (`opts.cwd`). Otherwise the
|
|
768
|
+
// agents that finish with `git add -A` (blueprint/requirements/bootstrap) could stage a
|
|
769
|
+
// stray `.claude/` directory — and any cached credential in it — into the pushed branch.
|
|
770
|
+
// Mirrors the Codex CODEX_HOME isolation below; removed by `dispose`.
|
|
771
|
+
if (!opts.ambientAuth && !opts.subscriptionToken) {
|
|
772
|
+
throw new Error('claude-code harness requires a subscription token (or ambientAuth)')
|
|
773
|
+
}
|
|
774
|
+
const configHome = opts.ambientAuth ? undefined : await mkdtemp(join(tmpdir(), 'cf-claude-'))
|
|
775
|
+
|
|
776
|
+
// The config dir is brand-new every run, so Claude Code would otherwise treat this
|
|
777
|
+
// as a first launch and BLOCK on the interactive onboarding / "trust this folder" /
|
|
778
|
+
// bypass-permissions acknowledgement prompts — which never get answered headlessly,
|
|
779
|
+
// hanging the job until the watchdog kills it. Pre-seed the config that marks those
|
|
780
|
+
// as already accepted so `-p` starts straight into the run. Best-effort: written
|
|
781
|
+
// before the CLI starts; unknown keys are harmless if a CLI version ignores them.
|
|
782
|
+
// (Ambient mode skips this — the developer's own config is already onboarded.)
|
|
783
|
+
// ADR 0026 D4: assert the pinned onboarding keys landed and log them with the CLI
|
|
784
|
+
// version, so a future first-run gate this set doesn't cover (which looks identical to
|
|
785
|
+
// a healthy-but-quiet subagent start) is diffable when the cold-start watchdog fires.
|
|
786
|
+
if (configHome) {
|
|
787
|
+
await writeOnboardingPreseed(configHome)
|
|
788
|
+
await assertOnboardingKeysCurrent(configHome, process.env.CLAUDE_CLI_VERSION, opts.log)
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Skills: install each as a native skill under the config dir's `skills/<name>/` so the CLI
|
|
792
|
+
// discovers and can invoke it. ONLY into the isolated per-run config home — never the
|
|
793
|
+
// developer's own `~/.claude` (ambient/native mode), where it would persist in their personal
|
|
794
|
+
// setup after the run and two concurrent jobs carrying same-named skills would clobber each
|
|
795
|
+
// other. An ambient run reads the skills from the checkout instead (`.cat-context/skill/<name>/`,
|
|
796
|
+
// materialised by the caller). Best-effort: a write failure must not wedge the run — the prompt
|
|
797
|
+
// still names the skills.
|
|
798
|
+
if (configHome) {
|
|
799
|
+
for (const skill of opts.skills ?? []) {
|
|
800
|
+
await writeNativeSkill(join(configHome, 'skills'), skill).catch(() => {})
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// Tool servers (MCP): the CLI is pointed at a per-run config rather than discovering an ambient
|
|
805
|
+
// one. See `setUpClaudeMcp` for why that matters and what has to be cleaned up afterwards.
|
|
806
|
+
const mcp = await setUpClaudeMcp(opts.mcpServers, configHome)
|
|
807
|
+
|
|
808
|
+
return {
|
|
809
|
+
configHome,
|
|
810
|
+
mcpArgs: mcp.args,
|
|
811
|
+
env: buildClaudeEnv(opts, configHome),
|
|
812
|
+
dispose: async () => {
|
|
813
|
+
// The ambient-mode MCP config dir (credential-bearing) never outlives the run.
|
|
814
|
+
await mcp.cleanup()
|
|
815
|
+
if (!configHome) return
|
|
675
816
|
// Lift the CLI session transcripts (`projects/`) out for short-lived retention BEFORE the
|
|
676
817
|
// home is deleted — the credential lives at the home root, never in `projects/`, so this
|
|
677
818
|
// keeps the debugging artifact without leaking the token. Best-effort; never throws.
|
|
@@ -681,7 +822,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
681
822
|
})
|
|
682
823
|
// Never leave the config dir (and any cached credential) on disk past the run.
|
|
683
824
|
await rm(configHome, { recursive: true, force: true }).catch(() => {})
|
|
684
|
-
}
|
|
825
|
+
},
|
|
685
826
|
}
|
|
686
827
|
}
|
|
687
828
|
|
|
@@ -975,6 +1116,10 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
|
|
|
975
1116
|
...(usage ? { usage } : {}),
|
|
976
1117
|
...(calls.length ? { callMetrics: calls } : {}),
|
|
977
1118
|
}
|
|
1119
|
+
} catch (err) {
|
|
1120
|
+
// Codex surfaces its terminal failure the same way Claude Code does — in the stdout event
|
|
1121
|
+
// stream, not on stderr — so a bad exit carries the last thing the agent said.
|
|
1122
|
+
throw withAgentReport(err, summary, secrets)
|
|
978
1123
|
} finally {
|
|
979
1124
|
if (codexHome) {
|
|
980
1125
|
// Lift the CLI session transcripts (`sessions/`) out for short-lived retention BEFORE the
|
package/src/agent.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
runMultiRepoCoding,
|
|
37
37
|
} from './coding-agent.js'
|
|
38
38
|
import { validationFailureMessage } from './validation-checks.js'
|
|
39
|
+
import { prepopulateDependencies, withDependencyNote } from './dependency-install.js'
|
|
39
40
|
import { agentCapabilities, mergeEffort } from './agent-shared.js'
|
|
40
41
|
import { runBootstrap } from './bootstrap-mode.js'
|
|
41
42
|
import {
|
|
@@ -541,6 +542,25 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
|
|
|
541
542
|
logger.info('agent(explore): PR head fetch', { number: job.reviewPrNumber, fetched })
|
|
542
543
|
}
|
|
543
544
|
|
|
545
|
+
// DEPENDENCY PREPOPULATION, before the agent's first turn. An EXPLORE run is the case this
|
|
546
|
+
// exists for: a reviewer or architect reading a fresh clone can see that a library is
|
|
547
|
+
// depended upon but not what it actually exposes, so it reasons about the manifest instead
|
|
548
|
+
// of the code. Best-effort — the outcome is stated in the prompt either way and never fails
|
|
549
|
+
// the run. Runs in `workDir` so a monorepo service installs from its own subtree.
|
|
550
|
+
//
|
|
551
|
+
// BEFORE the stand-up below, deliberately. The frontend stand-up runs the service's own
|
|
552
|
+
// install and then SERVES what it built: installing after it would pay for a second install
|
|
553
|
+
// and, worse, rewrite the `node_modules` the running app resolves out of. Prepopulation is
|
|
554
|
+
// setup for everything that follows, so it goes first.
|
|
555
|
+
const dependencyNote = await prepopulateDependencies({
|
|
556
|
+
spec: job.dependencyInstall,
|
|
557
|
+
installDir: workDir,
|
|
558
|
+
repoDir: dir,
|
|
559
|
+
agentDir: workDir,
|
|
560
|
+
logger,
|
|
561
|
+
opts,
|
|
562
|
+
})
|
|
563
|
+
|
|
544
564
|
// Optional infra stand-up (the tester): bring the service's docker-compose
|
|
545
565
|
// dependencies up at the repo root for the duration of the run, tearing them down in
|
|
546
566
|
// the `finally`. A stand-up failure is non-fatal — it's surfaced to the agent as a
|
|
@@ -553,9 +573,10 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
|
|
|
553
573
|
// failure) is flagged as a concern; a frontend serve URL points the UI tester at the
|
|
554
574
|
// app it just built + served (the backend env resolution already reached the harness).
|
|
555
575
|
const infraNotes = managed ? buildInfraNotes(managed) : []
|
|
556
|
-
const userPrompt =
|
|
557
|
-
? `${job.userPrompt}\n\nNote: ${infraNotes.join(' ')}`
|
|
558
|
-
|
|
576
|
+
const userPrompt = withDependencyNote(
|
|
577
|
+
infraNotes.length ? `${job.userPrompt}\n\nNote: ${infraNotes.join(' ')}` : job.userPrompt,
|
|
578
|
+
dependencyNote,
|
|
579
|
+
)
|
|
559
580
|
// The stand-up record (success or failure, with its captured logs) rides back on EVERY
|
|
560
581
|
// result branch — the backend surfaces it on the Tester step regardless of whether the
|
|
561
582
|
// agent then produced a usable report.
|
|
@@ -809,13 +830,34 @@ async function runMultiRepoExplore(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
809
830
|
}
|
|
810
831
|
}
|
|
811
832
|
|
|
833
|
+
// DEPENDENCY PREPOPULATION for the PRIMARY leg. The install is declared on ONE service frame
|
|
834
|
+
// (the primary repo's), so it is run in that leg's checkout — never fanned out across the
|
|
835
|
+
// peers, whose services declare their own configs the dispatch never resolved. The agent runs
|
|
836
|
+
// at the workspace ROOT and reads across every sibling, which is exactly why this matters
|
|
837
|
+
// here: a cross-repo investigator reasoning about a manifest instead of the packages is the
|
|
838
|
+
// complaint that motivated the feature. Same treatment as the reference branches above.
|
|
839
|
+
//
|
|
840
|
+
// The note names the sibling directory rather than saying "this checkout": the agent's cwd is
|
|
841
|
+
// the workspace root, which has no dependency tree of its own.
|
|
842
|
+
const primaryLeg = legs[0]
|
|
843
|
+
const dependencyNote = primaryLeg
|
|
844
|
+
? await prepopulateDependencies({
|
|
845
|
+
spec: job.dependencyInstall,
|
|
846
|
+
installDir: join(root, primaryLeg.dirName),
|
|
847
|
+
repoDir: join(root, primaryLeg.dirName),
|
|
848
|
+
agentDir: root,
|
|
849
|
+
logger,
|
|
850
|
+
opts,
|
|
851
|
+
})
|
|
852
|
+
: undefined
|
|
853
|
+
|
|
812
854
|
opts.onPhase?.('agent')
|
|
813
855
|
logger.info('multi-repo-explore: running agent', { repos: legs.map((l) => l.dirName) })
|
|
814
856
|
const run = await runAgentInWorkspace(
|
|
815
857
|
{
|
|
816
858
|
dir: root,
|
|
817
859
|
systemPrompt: job.systemPrompt,
|
|
818
|
-
userPrompt: job.userPrompt,
|
|
860
|
+
userPrompt: withDependencyNote(job.userPrompt, dependencyNote),
|
|
819
861
|
model: job.model,
|
|
820
862
|
harness: job.harness,
|
|
821
863
|
subscriptionToken: job.subscriptionToken,
|
|
@@ -972,6 +1014,10 @@ function buildSingleRepoCodingSpec(
|
|
|
972
1014
|
// (see docs/initiatives/bugfix-reproduction-proof.md). Forwarded straight off the job body —
|
|
973
1015
|
// like the checks above, the loop is generic machinery keyed on the data, not the agent kind.
|
|
974
1016
|
...(job.reproduction ? { reproduction: job.reproduction } : {}),
|
|
1017
|
+
// Dependency prepopulation: the service's install, run against the checkout BEFORE the
|
|
1018
|
+
// agent's first turn (see docs/initiatives/agent-dependency-prepopulation.md). Forwarded
|
|
1019
|
+
// straight off the job body like the two phases above — generic machinery keyed on the data.
|
|
1020
|
+
...(job.dependencyInstall ? { dependencyInstall: job.dependencyInstall } : {}),
|
|
975
1021
|
}
|
|
976
1022
|
}
|
|
977
1023
|
|
|
@@ -1204,10 +1250,32 @@ async function runConflictResolution(job: AgentJob, opts: RunOptions): Promise<A
|
|
|
1204
1250
|
// there were conflicts), so it would drift onto the original feature task. Lead with the
|
|
1205
1251
|
// conflict; keep the task only as trailing reference.
|
|
1206
1252
|
const conflicted = await unmergedPaths(dir, signal)
|
|
1253
|
+
|
|
1254
|
+
// DEPENDENCY PREPOPULATION, before this mode's agent turn. Resolving a conflict is a READING
|
|
1255
|
+
// task before it is a writing one — the agent has to understand what both sides do — so it
|
|
1256
|
+
// needs the dependency tree as much as any other kind. Placed AFTER the clean-merge branches
|
|
1257
|
+
// above so a conflict-free run (the common case) never pays for an install it has no agent to
|
|
1258
|
+
// hand the tree to; and the artifact exclusion inside matters here more than anywhere, because
|
|
1259
|
+
// this flow finishes its merge commit with a whole-tree `git add -A`.
|
|
1260
|
+
const workDir = await deriveWorkDir(dir, job.repo.serviceDirectory)
|
|
1261
|
+
const dependencyNote = await prepopulateDependencies({
|
|
1262
|
+
spec: job.dependencyInstall,
|
|
1263
|
+
installDir: workDir,
|
|
1264
|
+
repoDir: dir,
|
|
1265
|
+
// The agent resolves at the repo ROOT (git's conflict state is repo-wide), so a monorepo
|
|
1266
|
+
// service's install ran somewhere the agent is not standing and the note has to say where.
|
|
1267
|
+
agentDir: dir,
|
|
1268
|
+
logger,
|
|
1269
|
+
opts,
|
|
1270
|
+
})
|
|
1271
|
+
|
|
1207
1272
|
opts.onPhase?.('agent')
|
|
1208
1273
|
logger.info('agent(conflict): resolving conflicts with agent', { conflicted })
|
|
1209
1274
|
const diff = await conflictDiff(dir, conflicted, signal)
|
|
1210
|
-
const userPrompt =
|
|
1275
|
+
const userPrompt = withDependencyNote(
|
|
1276
|
+
buildConflictPrompt(mergeBase, job.branch, conflicted, diff, job.userPrompt),
|
|
1277
|
+
dependencyNote,
|
|
1278
|
+
)
|
|
1211
1279
|
|
|
1212
1280
|
const { summary, stats, stderrTail, usage, callMetrics, effortReport } =
|
|
1213
1281
|
await runAgentInWorkspace(
|
package/src/captured-command.ts
CHANGED
|
@@ -136,6 +136,23 @@ export async function runCapturedCommand(args: {
|
|
|
136
136
|
})
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Wrap captured command output in a fenced block that the output itself cannot break out of.
|
|
141
|
+
*
|
|
142
|
+
* Every consumer of a captured tail embeds it in markdown a MODEL then reads — a repair prompt,
|
|
143
|
+
* the dependency-install note — and a package manager legitimately prints backticks (a linter
|
|
144
|
+
* quoting a template literal, a test echoing a fenced snippet from a fixture). A fixed three-tick
|
|
145
|
+
* fence closes on the first such run, and everything after it reads as prose: the remaining
|
|
146
|
+
* output, and worse, the INSTRUCTIONS that follow the block. Sizing the fence one tick longer than
|
|
147
|
+
* the longest run in the body is what CommonMark specifies for exactly this, so the block always
|
|
148
|
+
* spans the whole tail.
|
|
149
|
+
*/
|
|
150
|
+
export function fencedOutput(text: string): string {
|
|
151
|
+
const longestRun = Math.max(0, ...[...text.matchAll(/`+/g)].map((m) => m[0].length))
|
|
152
|
+
const fence = '`'.repeat(Math.max(3, longestRun + 1))
|
|
153
|
+
return `${fence}\n${text}\n${fence}`
|
|
154
|
+
}
|
|
155
|
+
|
|
139
156
|
/** Bound an already-scrubbed output tail to what a REPORT carries, saying what it dropped. */
|
|
140
157
|
export function boundTail(scrubbed: string, maxChars: number): string {
|
|
141
158
|
if (scrubbed.length <= maxChars) return scrubbed
|
package/src/coding-agent.ts
CHANGED
|
@@ -58,6 +58,11 @@ import {
|
|
|
58
58
|
type ReproductionReport,
|
|
59
59
|
type ReproductionSpec,
|
|
60
60
|
} from './reproduction-proof.js'
|
|
61
|
+
import {
|
|
62
|
+
prepopulateDependencies,
|
|
63
|
+
withDependencyNote,
|
|
64
|
+
type DependencyInstallSpec,
|
|
65
|
+
} from './dependency-install.js'
|
|
61
66
|
|
|
62
67
|
// The shared skeleton for the container coding agents that clone a repo, run Pi
|
|
63
68
|
// against it and push the result on a branch. The implementation (`/run`) and
|
|
@@ -130,6 +135,13 @@ export interface CodingAgentSpec extends HarnessAuthFields {
|
|
|
130
135
|
* `docs/initiatives/pre-pr-validation.md`.
|
|
131
136
|
*/
|
|
132
137
|
validationChecks?: ValidationChecksSpec
|
|
138
|
+
/**
|
|
139
|
+
* DEPENDENCY PREPOPULATION: the service's install command, run against the checkout BEFORE the
|
|
140
|
+
* agent's first turn so it works against a tree whose dependencies are present. Best-effort —
|
|
141
|
+
* a failure becomes a note in the agent's prompt, never a failed run. Absent ⇒ no install
|
|
142
|
+
* phase. See `docs/initiatives/agent-dependency-prepopulation.md`.
|
|
143
|
+
*/
|
|
144
|
+
dependencyInstall?: DependencyInstallSpec
|
|
133
145
|
/**
|
|
134
146
|
* BUGFIX REPRODUCTION PROOF: the run's declared reproduction command + test files. When set, the
|
|
135
147
|
* harness runs that command against the pre-fix tree AND the tree the PR will open from, feeding
|
|
@@ -337,9 +349,29 @@ export async function runCodingAgent(
|
|
|
337
349
|
followUpTick.unref?.()
|
|
338
350
|
}
|
|
339
351
|
|
|
352
|
+
// DEPENDENCY PREPOPULATION: install the service's dependencies into the checkout BEFORE the
|
|
353
|
+
// agent's first turn, so it reads real packages instead of inferring capabilities from a
|
|
354
|
+
// manifest. Runs in `workDir` (a monorepo service installs from its own subtree, exactly
|
|
355
|
+
// where its manifest and lockfile live), and its outcome is STATED to the agent either way —
|
|
356
|
+
// a silent absence of dependencies reads to an agent as "this environment is offline".
|
|
357
|
+
// Best-effort by construction: a failed install never fails the run. Keyed purely off the
|
|
358
|
+
// job body (no agent-kind switch); absent ⇒ this is a no-op.
|
|
359
|
+
const dependencyNote = await prepopulateDependencies({
|
|
360
|
+
spec: spec.dependencyInstall,
|
|
361
|
+
installDir: workDir,
|
|
362
|
+
repoDir: dir,
|
|
363
|
+
agentDir: workDir,
|
|
364
|
+
logger,
|
|
365
|
+
opts,
|
|
366
|
+
})
|
|
367
|
+
|
|
340
368
|
// One agent pass over this checkout, parameterised only by the prompt — so the pre-PR
|
|
341
369
|
// validation loop below can re-run the agent with a repair instruction without
|
|
342
370
|
// re-deriving (or drifting from) the dispatch's own settings.
|
|
371
|
+
//
|
|
372
|
+
// The dependency note rides EVERY pass, not just the first: a repair round starts a fresh
|
|
373
|
+
// agent, and one that is not told the tree is already installed spends the round it was
|
|
374
|
+
// given to fix something reinstalling it instead.
|
|
343
375
|
const runAgentPass = (
|
|
344
376
|
userPrompt: string,
|
|
345
377
|
): Promise<Awaited<ReturnType<typeof runAgentInWorkspace>>> =>
|
|
@@ -347,7 +379,7 @@ export async function runCodingAgent(
|
|
|
347
379
|
{
|
|
348
380
|
dir: workDir,
|
|
349
381
|
systemPrompt: spec.systemPrompt,
|
|
350
|
-
userPrompt,
|
|
382
|
+
userPrompt: withDependencyNote(userPrompt, dependencyNote),
|
|
351
383
|
model: spec.model,
|
|
352
384
|
harness: spec.harness,
|
|
353
385
|
subscriptionToken: spec.subscriptionToken,
|
|
@@ -1038,6 +1070,29 @@ export async function runMultiRepoCoding(
|
|
|
1038
1070
|
// reference branches. Mutates each leg's `dir`/`resumed`/`baseSha` in place.
|
|
1039
1071
|
await prepareMultiRepoCheckouts(root, legs, job, logger, opts)
|
|
1040
1072
|
|
|
1073
|
+
// DEPENDENCY PREPOPULATION for the PRIMARY leg, exactly as the read-only multi-repo fan-out
|
|
1074
|
+
// does it. The install is declared on ONE service frame (the primary repo's), so it runs in
|
|
1075
|
+
// that leg's checkout and is never fanned out across peers, whose own frames declare configs
|
|
1076
|
+
// this dispatch never resolved — running a `pnpm install` inside a Go checkout is not a
|
|
1077
|
+
// degraded outcome, it is a wrong one. A cross-repo implementer needs its dependencies for
|
|
1078
|
+
// the same reason a cross-repo investigator does; the note names the sibling directory
|
|
1079
|
+
// because the agent itself stands at the workspace root.
|
|
1080
|
+
//
|
|
1081
|
+
// At the leg's checkout ROOT, not a `serviceDirectory` subtree: this layout applies no
|
|
1082
|
+
// service-directory scoping anywhere (the agent runs at the root and the prompt explains the
|
|
1083
|
+
// sibling checkouts), and a root install is the one that resolves a monorepo workspace whole.
|
|
1084
|
+
const primaryLeg = legs.find((leg) => leg.primary)
|
|
1085
|
+
const dependencyNote = primaryLeg
|
|
1086
|
+
? await prepopulateDependencies({
|
|
1087
|
+
spec: job.dependencyInstall,
|
|
1088
|
+
installDir: primaryLeg.dir,
|
|
1089
|
+
repoDir: primaryLeg.dir,
|
|
1090
|
+
agentDir: root,
|
|
1091
|
+
logger,
|
|
1092
|
+
opts,
|
|
1093
|
+
})
|
|
1094
|
+
: undefined
|
|
1095
|
+
|
|
1041
1096
|
// Run the agent ONCE with its cwd at the workspace root, so it sees every sibling checkout
|
|
1042
1097
|
// and can change them coherently. No monorepo/service-directory scoping — the multi-repo
|
|
1043
1098
|
// note + the backend system-prompt section explain the layout.
|
|
@@ -1048,7 +1103,7 @@ export async function runMultiRepoCoding(
|
|
|
1048
1103
|
{
|
|
1049
1104
|
dir: root,
|
|
1050
1105
|
systemPrompt: job.systemPrompt,
|
|
1051
|
-
userPrompt: job.userPrompt,
|
|
1106
|
+
userPrompt: withDependencyNote(job.userPrompt, dependencyNote),
|
|
1052
1107
|
model: job.model,
|
|
1053
1108
|
harness: job.harness,
|
|
1054
1109
|
subscriptionToken: job.subscriptionToken,
|