@cat-factory/executor-harness 1.34.4 → 1.34.10
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.js +240 -110
- package/dist/coding-agent.js +242 -2
- package/dist/job.js +44 -0
- package/dist/pi-workspace.js +1 -0
- package/dist/pi.js +27 -3
- package/package.json +3 -3
- package/src/agent.ts +296 -118
- package/src/coding-agent.ts +289 -1
- package/src/job.ts +77 -0
- package/src/pi-workspace.ts +7 -0
- package/src/pi.ts +30 -3
package/src/coding-agent.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdir } from 'node:fs/promises'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import type { HarnessAuthFields, RepoSpec } from './job.js'
|
|
3
|
+
import type { AgentJob, AgentResult, HarnessAuthFields, PeerRepoSpec, RepoSpec } from './job.js'
|
|
4
4
|
import {
|
|
5
5
|
branchAheadOfBase,
|
|
6
6
|
branchHasCommitsSince,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
excludeFromGit,
|
|
12
12
|
headCommit,
|
|
13
13
|
listUntrackedFiles,
|
|
14
|
+
openPullRequest,
|
|
14
15
|
prepareExistingCheckout,
|
|
15
16
|
pushBranch,
|
|
16
17
|
refreshFromBaseIfClean,
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
agentNeverActed,
|
|
24
25
|
agentOutputTail,
|
|
25
26
|
runAgentInWorkspace,
|
|
27
|
+
withWorkspace,
|
|
26
28
|
} from './pi-workspace.js'
|
|
27
29
|
import type { ProgressGuardLimits } from './pi.js'
|
|
28
30
|
import type { RunOptions } from './runner.js'
|
|
@@ -399,6 +401,292 @@ export async function runCodingAgent(
|
|
|
399
401
|
)
|
|
400
402
|
}
|
|
401
403
|
|
|
404
|
+
/** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
|
|
405
|
+
export function safeDirSegment(value: string): string {
|
|
406
|
+
return value.replace(/[^A-Za-z0-9._-]/g, '-') || '_'
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* A sibling-directory allocator for a multi-repo run: returns the checkout directory name for a
|
|
411
|
+
* repo under the workspace root. Deterministic (`owner__name`) and collision-free by construction
|
|
412
|
+
* — the checkout set is deduped by `owner/name` upstream and GitHub owners contain no `_`, so the
|
|
413
|
+
* `owner__name` join is unique per repo without a stateful collision dance. Kept as a factory so
|
|
414
|
+
* the coding + read-only explore fan-outs share ONE scheme, and it MUST stay byte-identical to the
|
|
415
|
+
* backend's `siblingCheckoutDir` / `renderMultiRepoWorkspaceSection` in `@cat-factory/server`
|
|
416
|
+
* (jobBody.ts), which names this exact directory in the agent's prompt — the two are computed
|
|
417
|
+
* independently, so a divergent rule would point the agent at a directory that does not exist.
|
|
418
|
+
*/
|
|
419
|
+
export function makeDirClaimer(): (repo: Pick<RepoSpec, 'name' | 'owner'>) => string {
|
|
420
|
+
return (repo) => `${safeDirSegment(repo.owner)}__${safeDirSegment(repo.name)}`
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** One repository participating in a multi-repo run: where to clone it + what to do after. */
|
|
424
|
+
interface RepoLeg {
|
|
425
|
+
repo: RepoSpec
|
|
426
|
+
/** Sibling directory name under the workspace root. */
|
|
427
|
+
dirName: string
|
|
428
|
+
/** Absolute checkout directory (filled during the clone phase). */
|
|
429
|
+
dir: string
|
|
430
|
+
/** Branch to clone (the repo's base). */
|
|
431
|
+
cloneBranch: string
|
|
432
|
+
/** Branch to create off the clone and push the work to (the shared `cat-factory/<block>`). */
|
|
433
|
+
workBranch: string
|
|
434
|
+
ghToken: string
|
|
435
|
+
pr?: { title: string; body: string }
|
|
436
|
+
frameId?: string
|
|
437
|
+
primary: boolean
|
|
438
|
+
/** The branch tip before the run — work iff the branch advances past it. */
|
|
439
|
+
baseSha: string
|
|
440
|
+
/** Whether an existing remote work branch was resumed (already carries prior work). */
|
|
441
|
+
resumed: boolean
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Multi-repo coding (service-connections phase 3): clone the primary repo AND every connected
|
|
446
|
+
* peer repo as SIBLING checkouts under one workspace root, run the agent ONCE with its cwd at
|
|
447
|
+
* that root (so it makes the cross-service change coherently across all of them), then commit +
|
|
448
|
+
* push each repo that actually changed and open one PR per dirty repo. The task's own-service PR
|
|
449
|
+
* is reported as `prUrl`/`branch`; the peer PRs as `peerPullRequests`.
|
|
450
|
+
*
|
|
451
|
+
* Deliberately simpler than the single-repo {@link runCodingAgent} for the first cut: NO mid-run
|
|
452
|
+
* checkpoint pushes (an evicted multi-repo run re-clones on retry — the deterministic work branch
|
|
453
|
+
* still lets it resume any commits it managed to push at the end), NO warm-pool persistent
|
|
454
|
+
* checkout (always ephemeral), and NO follow-up sentinel streaming. It reuses the SAME dir-scoped
|
|
455
|
+
* git helpers, so the per-repo clone/commit/push/PR mechanics match the single-repo path exactly.
|
|
456
|
+
*/
|
|
457
|
+
export async function runMultiRepoCoding(
|
|
458
|
+
job: AgentJob,
|
|
459
|
+
opts: RunOptions = {},
|
|
460
|
+
): Promise<AgentResult> {
|
|
461
|
+
const { signal } = opts
|
|
462
|
+
const logger = (opts.log ?? log).child({ kind: 'multi-repo', jobId: job.jobId })
|
|
463
|
+
const peers: PeerRepoSpec[] = job.peerRepos ?? []
|
|
464
|
+
const primaryWorkBranch = job.pushBranch ?? job.newBranch ?? job.branch
|
|
465
|
+
|
|
466
|
+
// Assign the sibling directory per repo via the shared deterministic allocator (`owner__name`,
|
|
467
|
+
// matching the backend prompt's `siblingCheckoutDir`), shared with the read-only explore fan-out.
|
|
468
|
+
const claimDir = makeDirClaimer()
|
|
469
|
+
const legs: RepoLeg[] = [
|
|
470
|
+
{
|
|
471
|
+
repo: job.repo,
|
|
472
|
+
dirName: claimDir(job.repo),
|
|
473
|
+
dir: '',
|
|
474
|
+
cloneBranch: job.branch,
|
|
475
|
+
workBranch: primaryWorkBranch,
|
|
476
|
+
ghToken: job.ghToken,
|
|
477
|
+
...(job.pr ? { pr: job.pr } : {}),
|
|
478
|
+
primary: true,
|
|
479
|
+
baseSha: '',
|
|
480
|
+
resumed: false,
|
|
481
|
+
},
|
|
482
|
+
...peers.map(
|
|
483
|
+
(peer): RepoLeg => ({
|
|
484
|
+
repo: peer.repo,
|
|
485
|
+
dirName: claimDir(peer.repo),
|
|
486
|
+
dir: '',
|
|
487
|
+
cloneBranch: peer.repo.baseBranch,
|
|
488
|
+
// Coding peers always carry `newBranch` (the backend sets the shared work branch);
|
|
489
|
+
// fall back to the primary's for the type (read-only peers never reach this path).
|
|
490
|
+
workBranch: peer.newBranch ?? primaryWorkBranch,
|
|
491
|
+
ghToken: peer.ghToken ?? job.ghToken,
|
|
492
|
+
...(peer.pr ? { pr: peer.pr } : {}),
|
|
493
|
+
...(peer.frameId ? { frameId: peer.frameId } : {}),
|
|
494
|
+
primary: false,
|
|
495
|
+
baseSha: '',
|
|
496
|
+
resumed: false,
|
|
497
|
+
}),
|
|
498
|
+
),
|
|
499
|
+
]
|
|
500
|
+
|
|
501
|
+
return withWorkspace('multi', async (root) => {
|
|
502
|
+
// Clone phase: every repo into its sibling dir under the workspace root. Resume an
|
|
503
|
+
// existing remote work branch (an evicted retry) rather than branching off base again.
|
|
504
|
+
opts.onPhase?.('clone')
|
|
505
|
+
for (const leg of legs) {
|
|
506
|
+
const dir = join(root, leg.dirName)
|
|
507
|
+
await mkdir(dir, { recursive: true })
|
|
508
|
+
leg.resumed = await remoteBranchExists(leg.repo.cloneUrl, leg.workBranch, leg.ghToken, signal)
|
|
509
|
+
if (leg.resumed) {
|
|
510
|
+
logger.info('multi-repo: resuming existing branch', {
|
|
511
|
+
repo: leg.dirName,
|
|
512
|
+
branch: leg.workBranch,
|
|
513
|
+
})
|
|
514
|
+
await cloneExistingBranch({
|
|
515
|
+
cloneUrl: leg.repo.cloneUrl,
|
|
516
|
+
branch: leg.workBranch,
|
|
517
|
+
ghToken: leg.ghToken,
|
|
518
|
+
dir,
|
|
519
|
+
signal,
|
|
520
|
+
})
|
|
521
|
+
} else {
|
|
522
|
+
logger.info('multi-repo: cloning', { repo: leg.dirName, cloneBranch: leg.cloneBranch })
|
|
523
|
+
await cloneRepo({
|
|
524
|
+
repo: { ...leg.repo, baseBranch: leg.cloneBranch },
|
|
525
|
+
ghToken: leg.ghToken,
|
|
526
|
+
dir,
|
|
527
|
+
signal,
|
|
528
|
+
})
|
|
529
|
+
await createBranch(dir, leg.workBranch, signal)
|
|
530
|
+
}
|
|
531
|
+
leg.dir = dir
|
|
532
|
+
// The branch tip before the agent runs. Captured BEFORE the resume base refresh below so
|
|
533
|
+
// that refresh's merge commit counts as advancement and is pushed (as in the single-repo
|
|
534
|
+
// path). A fresh leg produced work iff its branch advances past this; a resumed leg already
|
|
535
|
+
// carries prior work.
|
|
536
|
+
leg.baseSha = await headCommit(dir, signal)
|
|
537
|
+
// A resumed branch was cut from an OLDER base; merge the latest base in when the two merge
|
|
538
|
+
// cleanly so the agent works against current base and the peer/own PRs stay current. On a
|
|
539
|
+
// conflict this is a best-effort no-op (the merge gate handles a conflicting PR downstream),
|
|
540
|
+
// mirroring the single-repo {@link runCodingAgent} resume refresh.
|
|
541
|
+
if (leg.resumed) {
|
|
542
|
+
const refreshed = await refreshFromBaseIfClean(
|
|
543
|
+
dir,
|
|
544
|
+
leg.cloneBranch,
|
|
545
|
+
leg.ghToken,
|
|
546
|
+
signal,
|
|
547
|
+
).catch(() => false)
|
|
548
|
+
if (!refreshed) {
|
|
549
|
+
logger.info('multi-repo: resume base refresh skipped (conflict or error)', {
|
|
550
|
+
repo: leg.dirName,
|
|
551
|
+
base: leg.cloneBranch,
|
|
552
|
+
})
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Run the agent ONCE with its cwd at the workspace root, so it sees every sibling checkout
|
|
558
|
+
// and can change them coherently. No monorepo/service-directory scoping — the multi-repo
|
|
559
|
+
// note + the backend system-prompt section explain the layout.
|
|
560
|
+
opts.onPhase?.('agent')
|
|
561
|
+
logger.info('multi-repo: running agent', { repos: legs.map((l) => l.dirName) })
|
|
562
|
+
const { summary, stats, stderrTail, usage, callMetrics } = await runAgentInWorkspace(
|
|
563
|
+
{
|
|
564
|
+
dir: root,
|
|
565
|
+
systemPrompt: job.systemPrompt,
|
|
566
|
+
userPrompt: job.userPrompt,
|
|
567
|
+
model: job.model,
|
|
568
|
+
harness: job.harness,
|
|
569
|
+
subscriptionToken: job.subscriptionToken,
|
|
570
|
+
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
571
|
+
ambientAuth: job.ambientAuth,
|
|
572
|
+
proxyBaseUrl: job.proxyBaseUrl,
|
|
573
|
+
sessionToken: job.sessionToken,
|
|
574
|
+
webToolsGuidance: job.webToolsGuidance,
|
|
575
|
+
webSearchProxy: job.webSearch,
|
|
576
|
+
guardLimits: job.guardLimits,
|
|
577
|
+
...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
|
|
578
|
+
multiRepo: true,
|
|
579
|
+
},
|
|
580
|
+
opts,
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
// Push phase: commit forgotten tracked edits, then push + open a PR for each repo the run
|
|
584
|
+
// actually changed. A repo the agent left untouched is skipped (no branch, no PR).
|
|
585
|
+
opts.onPhase?.('push')
|
|
586
|
+
let primaryPushed = false
|
|
587
|
+
let primaryPrUrl: string | undefined
|
|
588
|
+
const peerPullRequests: NonNullable<AgentResult['peerPullRequests']> = []
|
|
589
|
+
for (const leg of legs) {
|
|
590
|
+
await commitTrackedEdits(
|
|
591
|
+
leg.dir,
|
|
592
|
+
job.commitMessage ?? leg.pr?.title ?? 'Agent changes',
|
|
593
|
+
signal,
|
|
594
|
+
)
|
|
595
|
+
const advanced = await branchHasCommitsSince(leg.dir, leg.baseSha, signal)
|
|
596
|
+
let hasWork = advanced || leg.resumed
|
|
597
|
+
if (leg.resumed && !advanced) {
|
|
598
|
+
const ahead = await branchAheadOfBase(leg.dir, leg.repo.baseBranch, leg.ghToken, signal)
|
|
599
|
+
if (ahead === false) hasWork = false
|
|
600
|
+
}
|
|
601
|
+
const leftover = await listUntrackedFiles(leg.dir, signal)
|
|
602
|
+
if (leftover.length > 0) {
|
|
603
|
+
logger.warn('multi-repo: uncommitted new files left behind (not pushed)', {
|
|
604
|
+
repo: leg.dirName,
|
|
605
|
+
count: leftover.length,
|
|
606
|
+
files: leftover.slice(0, 20),
|
|
607
|
+
})
|
|
608
|
+
}
|
|
609
|
+
if (!hasWork) {
|
|
610
|
+
logger.info('multi-repo: no changes for repo', { repo: leg.dirName })
|
|
611
|
+
continue
|
|
612
|
+
}
|
|
613
|
+
await pushBranch(leg.dir, leg.workBranch, leg.ghToken, signal)
|
|
614
|
+
let prUrl: string | null = null
|
|
615
|
+
if (leg.pr) {
|
|
616
|
+
prUrl = await openPullRequest({
|
|
617
|
+
owner: leg.repo.owner,
|
|
618
|
+
name: leg.repo.name,
|
|
619
|
+
ghToken: leg.ghToken,
|
|
620
|
+
head: leg.workBranch,
|
|
621
|
+
base: leg.repo.baseBranch,
|
|
622
|
+
pr: leg.pr,
|
|
623
|
+
apiBase: job.githubApiBase,
|
|
624
|
+
cloneUrl: leg.repo.cloneUrl,
|
|
625
|
+
...(leg.repo.provider ? { provider: leg.repo.provider } : {}),
|
|
626
|
+
signal,
|
|
627
|
+
})
|
|
628
|
+
}
|
|
629
|
+
if (leg.primary) {
|
|
630
|
+
primaryPushed = true
|
|
631
|
+
if (prUrl) primaryPrUrl = prUrl
|
|
632
|
+
} else if (prUrl) {
|
|
633
|
+
peerPullRequests.push({
|
|
634
|
+
repo: `${leg.repo.owner}/${leg.repo.name}`,
|
|
635
|
+
...(leg.frameId ? { frameId: leg.frameId } : {}),
|
|
636
|
+
prUrl,
|
|
637
|
+
branch: leg.workBranch,
|
|
638
|
+
})
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const anyWork = primaryPushed || peerPullRequests.length > 0
|
|
643
|
+
if (!anyWork) {
|
|
644
|
+
// Nothing changed in ANY repo. For the implementer this is a failure (as in the
|
|
645
|
+
// single-repo path); a caller that tolerates a no-op (never the implementer today)
|
|
646
|
+
// gets a clean non-event.
|
|
647
|
+
if (job.noChangesIsError === false) {
|
|
648
|
+
return {
|
|
649
|
+
pushed: false,
|
|
650
|
+
branch: primaryWorkBranch,
|
|
651
|
+
summary,
|
|
652
|
+
stats,
|
|
653
|
+
...(usage ? { usage } : {}),
|
|
654
|
+
...(callMetrics ? { callMetrics } : {}),
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return {
|
|
658
|
+
pushed: false,
|
|
659
|
+
branch: primaryWorkBranch,
|
|
660
|
+
summary,
|
|
661
|
+
stats,
|
|
662
|
+
error: noChangesReason(
|
|
663
|
+
'the agent produced no file changes in any repository',
|
|
664
|
+
stats,
|
|
665
|
+
stderrTail,
|
|
666
|
+
),
|
|
667
|
+
failureCause: 'no-changes',
|
|
668
|
+
...(usage ? { usage } : {}),
|
|
669
|
+
...(callMetrics ? { callMetrics } : {}),
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
logger.info('multi-repo: complete', {
|
|
673
|
+
primaryPushed,
|
|
674
|
+
primaryPrUrl: primaryPrUrl ?? null,
|
|
675
|
+
peers: peerPullRequests.length,
|
|
676
|
+
})
|
|
677
|
+
return {
|
|
678
|
+
pushed: primaryPushed,
|
|
679
|
+
...(primaryPrUrl ? { prUrl: primaryPrUrl } : {}),
|
|
680
|
+
branch: primaryWorkBranch,
|
|
681
|
+
...(peerPullRequests.length ? { peerPullRequests } : {}),
|
|
682
|
+
summary,
|
|
683
|
+
stats,
|
|
684
|
+
...(usage ? { usage } : {}),
|
|
685
|
+
...(callMetrics ? { callMetrics } : {}),
|
|
686
|
+
}
|
|
687
|
+
})
|
|
688
|
+
}
|
|
689
|
+
|
|
402
690
|
/**
|
|
403
691
|
* The "no changes" reason both coding agents report: a caller-supplied lead phrase
|
|
404
692
|
* plus the shared "never acted" cause and a credential-scrubbed tail of Pi's stderr.
|
package/src/job.ts
CHANGED
|
@@ -61,6 +61,29 @@ export interface PrSpec {
|
|
|
61
61
|
body: string
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* A connected service's repo to check out as a SIBLING alongside the primary during a
|
|
66
|
+
* multi-repo coding run (service-connections phase 3). The agent clones every peer repo
|
|
67
|
+
* into a sibling directory under the workspace root, makes the coherent cross-service
|
|
68
|
+
* change, and the harness opens ONE PR per peer repo it actually changed. The clone URL is
|
|
69
|
+
* host-allowlisted exactly like the primary `repo.cloneUrl`.
|
|
70
|
+
*/
|
|
71
|
+
export interface PeerRepoSpec {
|
|
72
|
+
repo: RepoSpec
|
|
73
|
+
/** The involved service frame this repo resolved from, echoed back on the peer PR. */
|
|
74
|
+
frameId?: string
|
|
75
|
+
/**
|
|
76
|
+
* The work branch to create off the peer's base and push (the shared `cat-factory/<block>`).
|
|
77
|
+
* Present for a COING fan-out (coder / ci-fixer). Absent for a READ-ONLY explore fan-out
|
|
78
|
+
* (the bug-investigator), which only clones the peer to read it and never pushes.
|
|
79
|
+
*/
|
|
80
|
+
newBranch?: string
|
|
81
|
+
/** Open a PR/MR in this peer when set AND the run changed the peer (skipped for a clean repo). */
|
|
82
|
+
pr?: PrSpec
|
|
83
|
+
/** Per-repo GitHub token; defaults to the job's `ghToken` (one installation per workspace today). */
|
|
84
|
+
ghToken?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
64
87
|
function str(value: unknown, path: string): string {
|
|
65
88
|
if (typeof value !== 'string' || value.length === 0) {
|
|
66
89
|
throw new Error(`Invalid job: '${path}' must be a non-empty string`)
|
|
@@ -179,6 +202,38 @@ function parseRepoSpec(repo: Record<string, unknown>): RepoSpec {
|
|
|
179
202
|
return spec
|
|
180
203
|
}
|
|
181
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Parse the optional multi-repo peer list (service-connections phase 3). Each entry carries a
|
|
207
|
+
* full {@link RepoSpec} (validated + sanitised like the primary), the work branch to push, and
|
|
208
|
+
* an optional PR + per-repo token. A malformed list throws; an absent one yields `[]`.
|
|
209
|
+
*/
|
|
210
|
+
function parsePeerRepos(value: unknown): PeerRepoSpec[] {
|
|
211
|
+
if (value === undefined || value === null) return []
|
|
212
|
+
if (!Array.isArray(value)) throw new Error("Invalid job: 'peerRepos' must be an array")
|
|
213
|
+
return value.map((entry, i) => {
|
|
214
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
215
|
+
throw new Error(`Invalid job: 'peerRepos[${i}]' must be an object`)
|
|
216
|
+
}
|
|
217
|
+
const e = entry as Record<string, unknown>
|
|
218
|
+
const spec: PeerRepoSpec = {
|
|
219
|
+
repo: parseRepoSpec((e.repo ?? {}) as Record<string, unknown>),
|
|
220
|
+
}
|
|
221
|
+
// `newBranch` is required for a coding fan-out (it pushes to it) but ABSENT for a
|
|
222
|
+
// read-only explore fan-out (bug-investigator) — validate it only when present.
|
|
223
|
+
if (e.newBranch !== undefined) spec.newBranch = str(e.newBranch, `peerRepos[${i}].newBranch`)
|
|
224
|
+
if (typeof e.frameId === 'string' && e.frameId) spec.frameId = e.frameId
|
|
225
|
+
if (typeof e.ghToken === 'string' && e.ghToken) spec.ghToken = e.ghToken
|
|
226
|
+
if (typeof e.pr === 'object' && e.pr !== null) {
|
|
227
|
+
const p = e.pr as Record<string, unknown>
|
|
228
|
+
spec.pr = {
|
|
229
|
+
title: str(p.title, `peerRepos[${i}].pr.title`),
|
|
230
|
+
body: typeof p.body === 'string' ? p.body : '',
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return spec
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
182
237
|
/** Parse the optional `repo.provider` discriminator (defaults to undefined ⇒ host inference). */
|
|
183
238
|
function parseVcsProvider(value: unknown): 'github' | 'gitlab' | undefined {
|
|
184
239
|
if (value === undefined || value === null) return undefined
|
|
@@ -529,6 +584,13 @@ export interface AgentJob extends HarnessAuthFields {
|
|
|
529
584
|
commitMessage?: string
|
|
530
585
|
/** Coding mode: open this PR when the run pushed changes. Absent ⇒ push only, no PR. */
|
|
531
586
|
pr?: PrSpec
|
|
587
|
+
/**
|
|
588
|
+
* Coding mode (implementer): connected services' repos to clone as SIBLINGS for a MULTI-REPO
|
|
589
|
+
* change (service-connections phase 3). When present, the agent works with its cwd at the
|
|
590
|
+
* workspace ROOT (all repos are sibling checkouts under it), and the harness opens one PR per
|
|
591
|
+
* peer repo it actually changed — in addition to the primary. Absent ⇒ single-repo run.
|
|
592
|
+
*/
|
|
593
|
+
peerRepos?: PeerRepoSpec[]
|
|
532
594
|
/**
|
|
533
595
|
* Coding mode: whether a no-op run (nothing changed) is a failure. The implementer
|
|
534
596
|
* fails on a no-op; the in-place fixers (ci-fix / fix-tests) treat it as a non-fatal
|
|
@@ -617,6 +679,13 @@ export interface AgentResult {
|
|
|
617
679
|
pushed?: boolean
|
|
618
680
|
prUrl?: string
|
|
619
681
|
branch?: string
|
|
682
|
+
/**
|
|
683
|
+
* Coding mode (multi-repo): the PRs opened in the connected services' PEER repos, one per
|
|
684
|
+
* repo the run actually changed (service-connections phase 3). Beside the own-service
|
|
685
|
+
* `prUrl`/`branch`; the backend lifts these onto the block's `peerPullRequests`. Absent for
|
|
686
|
+
* a single-repo run.
|
|
687
|
+
*/
|
|
688
|
+
peerPullRequests?: { repo: string; frameId?: string; prUrl: string; branch: string }[]
|
|
620
689
|
/** Coding mode (bootstrap): the default branch the bootstrapped contents were pushed to. */
|
|
621
690
|
defaultBranch?: string
|
|
622
691
|
error?: string
|
|
@@ -856,6 +925,7 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
856
925
|
})()
|
|
857
926
|
: undefined
|
|
858
927
|
const infra = parseAgentInfraSpec(o.infra)
|
|
928
|
+
const peerRepos = parsePeerRepos(o.peerRepos)
|
|
859
929
|
const bootstrap = parseAgentBootstrapSpec(o.bootstrap)
|
|
860
930
|
const contextFiles = parseContextFiles(o.contextFiles)
|
|
861
931
|
const packageRegistries = parsePackageRegistries(o.packageRegistries)
|
|
@@ -886,6 +956,7 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
886
956
|
? { commitMessage: o.commitMessage }
|
|
887
957
|
: {}),
|
|
888
958
|
...(pr ? { pr } : {}),
|
|
959
|
+
...(peerRepos.length ? { peerRepos } : {}),
|
|
889
960
|
...(o.noChangesIsError === false ? { noChangesIsError: false } : {}),
|
|
890
961
|
...(o.persistentCheckout === true ? { persistentCheckout: true } : {}),
|
|
891
962
|
...(o.streamFollowUps === true ? { streamFollowUps: true } : {}),
|
|
@@ -896,5 +967,11 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
896
967
|
// Bootstrap pushes the result to a SEPARATE target repo, so its clone URL must be an
|
|
897
968
|
// allowed GitHub host too (the installation token is sent to it on the force-push).
|
|
898
969
|
if (job.bootstrap) assertAllowedHost(job.bootstrap.target.cloneUrl, 'bootstrap.target.cloneUrl')
|
|
970
|
+
// Each peer repo's clone URL receives the installation token on clone/push, so it must be
|
|
971
|
+
// an allowed GitHub host too — a body-supplied peer pointing at an attacker host would
|
|
972
|
+
// exfiltrate the token exactly like a rogue primary clone URL.
|
|
973
|
+
for (const [i, peer] of (job.peerRepos ?? []).entries()) {
|
|
974
|
+
assertAllowedHost(peer.repo.cloneUrl, `peerRepos[${i}].repo.cloneUrl`)
|
|
975
|
+
}
|
|
899
976
|
return job
|
|
900
977
|
}
|
package/src/pi-workspace.ts
CHANGED
|
@@ -209,6 +209,12 @@ export interface AgentRunSpec {
|
|
|
209
209
|
* is present directly in the container env (the self-hosted runner-pool path).
|
|
210
210
|
*/
|
|
211
211
|
webSearchProxy?: boolean
|
|
212
|
+
/**
|
|
213
|
+
* Multi-repo run (service-connections phase 3): the cwd (`dir`) is the WORKSPACE ROOT with
|
|
214
|
+
* every involved repo checked out as a sibling under it. Suppresses the single-repo monorepo
|
|
215
|
+
* note in AGENTS.md and adds the multi-repo mechanics note instead. Absent ⇒ single-repo.
|
|
216
|
+
*/
|
|
217
|
+
multiRepo?: boolean
|
|
212
218
|
}
|
|
213
219
|
|
|
214
220
|
/**
|
|
@@ -274,6 +280,7 @@ export async function runAgentInWorkspace(
|
|
|
274
280
|
guidance: spec.webToolsGuidance,
|
|
275
281
|
serviceDirectory: spec.serviceDirectory,
|
|
276
282
|
contextFiles,
|
|
283
|
+
...(spec.multiRepo ? { multiRepo: true } : {}),
|
|
277
284
|
})
|
|
278
285
|
await writePiModelsConfig({ model: spec.model, proxyBaseUrl })
|
|
279
286
|
const { signal, onActivity, onProgress, onSpan } = opts
|
package/src/pi.ts
CHANGED
|
@@ -141,6 +141,7 @@ export async function writeAgentsContext(
|
|
|
141
141
|
guidance?: string
|
|
142
142
|
serviceDirectory?: string
|
|
143
143
|
contextFiles?: ContextFileInfo[]
|
|
144
|
+
multiRepo?: boolean
|
|
144
145
|
} = {},
|
|
145
146
|
): Promise<void> {
|
|
146
147
|
const dir = join(homedir(), '.pi', 'agent')
|
|
@@ -152,18 +153,44 @@ export async function writeAgentsContext(
|
|
|
152
153
|
const webTools = opts.webSearch ? (opts.guidance ?? WEB_TOOLS_GUIDANCE) : ''
|
|
153
154
|
// Tell the agent it's in a monorepo and which subtree is its service, so it scopes
|
|
154
155
|
// its work (and its build/test commands) there. Only present when the dispatcher
|
|
155
|
-
// resolved a monorepo service directory; the agent's cwd already points at it.
|
|
156
|
-
|
|
156
|
+
// resolved a monorepo service directory; the agent's cwd already points at it. A
|
|
157
|
+
// MULTI-REPO run runs at the workspace root (cwd spans sibling checkouts), so the
|
|
158
|
+
// monorepo note is suppressed there — the multi-repo mechanics note replaces it.
|
|
159
|
+
const monorepo =
|
|
160
|
+
opts.serviceDirectory && !opts.multiRepo ? monorepoGuidance(opts.serviceDirectory) : ''
|
|
161
|
+
// Multi-repo mechanics note (service-connections phase 3): the concrete repo→role mapping
|
|
162
|
+
// is in the backend-composed system prompt above; this explains the shared MECHANICS (cwd
|
|
163
|
+
// is the workspace root, repos are sibling checkouts, one PR per dirty repo).
|
|
164
|
+
const multiRepo = opts.multiRepo ? MULTI_REPO_GUIDANCE : ''
|
|
157
165
|
// Point the agent at any linked context the backend materialised into the checkout
|
|
158
166
|
// (requirements / RFCs / PRDs / tracker issues) so it reads them on demand.
|
|
159
167
|
const context = contextGuidance(opts.contextFiles ?? [])
|
|
160
168
|
await writeFile(
|
|
161
169
|
join(dir, 'AGENTS.md'),
|
|
162
|
-
`${systemPrompt}${BLUEPRINT_GUIDANCE}${SPEC_GUIDANCE}${TODO_GUIDANCE}${monorepo}${webTools}${context}`,
|
|
170
|
+
`${systemPrompt}${BLUEPRINT_GUIDANCE}${SPEC_GUIDANCE}${TODO_GUIDANCE}${monorepo}${multiRepo}${webTools}${context}`,
|
|
163
171
|
'utf8',
|
|
164
172
|
)
|
|
165
173
|
}
|
|
166
174
|
|
|
175
|
+
/** The MULTI-REPO mechanics note appended to AGENTS.md when a run spans sibling checkouts. */
|
|
176
|
+
const MULTI_REPO_GUIDANCE = `
|
|
177
|
+
|
|
178
|
+
## Multi-repo workspace (work across sibling checkouts)
|
|
179
|
+
|
|
180
|
+
This task spans MORE THAN ONE repository. Your working directory is the WORKSPACE ROOT, and
|
|
181
|
+
each involved repository is checked out as a sibling directory directly under it. The workspace
|
|
182
|
+
root itself is NOT a git repository — run git INSIDE each repository's directory. The system
|
|
183
|
+
prompt above lists which repository is which and each one's role. Make the cross-service
|
|
184
|
+
change coherently across every repository the task requires — a provider's API and its
|
|
185
|
+
consumer's call site belong in the SAME piece of work. Run each repository's own build/test
|
|
186
|
+
commands inside that repository's directory.
|
|
187
|
+
|
|
188
|
+
Commit your own work inside each repository you change (\`cd\` into it, stage the files that
|
|
189
|
+
belong — INCLUDING any new files you added — and commit). The platform will NOT add untracked
|
|
190
|
+
files for you, so anything you leave uncommitted and untracked is lost. Each repository you
|
|
191
|
+
change is opened as a SEPARATE pull request; leave a repository untouched if the task does not
|
|
192
|
+
require changing it.`
|
|
193
|
+
|
|
167
194
|
/** Directory in the checkout where linked-context files are materialised (see CONTEXT_DIR in agents). */
|
|
168
195
|
export const CONTEXT_DIR = '.cat-context'
|
|
169
196
|
|