@cat-factory/executor-harness 1.66.0 → 1.70.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 +39 -1
- package/dist/agent-capabilities.js +354 -0
- package/dist/agent-runner.js +72 -11
- package/dist/agent-shared.js +23 -0
- package/dist/agent.js +14 -139
- package/dist/bootstrap-mode.js +142 -0
- package/dist/coding-agent.js +87 -67
- package/dist/job.js +8 -75
- package/dist/pi-workspace.js +25 -16
- package/dist/pi.js +81 -16
- package/dist/runner.js +8 -0
- package/dist/structured-output.js +13 -2
- package/package.json +4 -4
- package/src/agent-capabilities.ts +414 -0
- package/src/agent-runner.ts +97 -26
- package/src/agent-shared.ts +34 -0
- package/src/agent.ts +13 -165
- package/src/bootstrap-mode.ts +175 -0
- package/src/coding-agent.ts +106 -71
- package/src/job.ts +53 -93
- package/src/pi-workspace.ts +46 -21
- package/src/pi.ts +95 -16
- package/src/runner.ts +17 -0
- package/src/structured-output.ts +24 -2
package/src/agent.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
|
-
import { mkdir, mkdtemp,
|
|
3
|
+
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
|
|
4
4
|
import { execFile } from 'node:child_process'
|
|
5
5
|
import { promisify } from 'node:util'
|
|
6
6
|
import type {
|
|
@@ -20,17 +20,14 @@ import {
|
|
|
20
20
|
conflictDiff,
|
|
21
21
|
fetchPullRequestHead,
|
|
22
22
|
fetchReferenceBranches,
|
|
23
|
-
hasAgentChanges,
|
|
24
23
|
headCommit,
|
|
25
24
|
mergeBranch,
|
|
26
25
|
prepareExistingCheckout,
|
|
27
26
|
pushBranch,
|
|
28
|
-
reinitAndPush,
|
|
29
27
|
unmergedPaths,
|
|
30
28
|
} from './git.js'
|
|
31
29
|
import { inferVcsProvider, openPullRequest } from './vcs-api.js'
|
|
32
30
|
import type { PiRunStats, RunDiagnostics } from './pi.js'
|
|
33
|
-
import type { EffortReport } from './effort.js'
|
|
34
31
|
import { applyPrDescription } from './pr-description.js'
|
|
35
32
|
import {
|
|
36
33
|
makeDirClaimer,
|
|
@@ -39,6 +36,8 @@ import {
|
|
|
39
36
|
runMultiRepoCoding,
|
|
40
37
|
} from './coding-agent.js'
|
|
41
38
|
import { validationFailureMessage } from './validation-checks.js'
|
|
39
|
+
import { agentCapabilities, mergeEffort } from './agent-shared.js'
|
|
40
|
+
import { runBootstrap } from './bootstrap-mode.js'
|
|
42
41
|
import {
|
|
43
42
|
acquireRepoCheckout,
|
|
44
43
|
agentNeverActed,
|
|
@@ -256,6 +255,7 @@ async function resolveReplyCustom(
|
|
|
256
255
|
subscriptionToken: job.subscriptionToken,
|
|
257
256
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
258
257
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
258
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
259
259
|
sessionToken: job.sessionToken,
|
|
260
260
|
model: job.model,
|
|
261
261
|
jobId: job.jobId,
|
|
@@ -313,15 +313,6 @@ async function cloneServiceCheckout(
|
|
|
313
313
|
return deriveWorkDir(dir, job.repo.serviceDirectory)
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
-
/**
|
|
317
|
-
* Fold an agent's effort self-assessment (lifted from its sentinel file by `runAgentInWorkspace`)
|
|
318
|
-
* onto its final result. Every container mode routes its result through this so the report reaches
|
|
319
|
-
* the backend uniformly. A run that wrote no report passes through unchanged.
|
|
320
|
-
*/
|
|
321
|
-
function mergeEffort(result: AgentResult, effortReport: EffortReport | undefined): AgentResult {
|
|
322
|
-
return effortReport ? { ...result, effortReport } : result
|
|
323
|
-
}
|
|
324
|
-
|
|
325
316
|
/** Run one generic agent job end to end, dispatching on `mode`. */
|
|
326
317
|
export async function handleAgent(job: AgentJob, opts: RunOptions = {}): Promise<AgentResult> {
|
|
327
318
|
// An `ambientAuth` job runs in the SHARED native host process on the developer's own HOME
|
|
@@ -599,6 +590,7 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
|
|
|
599
590
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
600
591
|
ambientAuth: job.ambientAuth,
|
|
601
592
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
593
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
602
594
|
sessionToken: job.sessionToken,
|
|
603
595
|
serviceDirectory,
|
|
604
596
|
// Read-only: it inspects and reports, making no edits — so the no-progress
|
|
@@ -608,6 +600,7 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
|
|
|
608
600
|
webSearchProxy: job.webSearch,
|
|
609
601
|
contextFiles: job.contextFiles,
|
|
610
602
|
guardLimits: job.guardLimits,
|
|
603
|
+
...agentCapabilities(job),
|
|
611
604
|
},
|
|
612
605
|
agentOpts,
|
|
613
606
|
)
|
|
@@ -829,6 +822,7 @@ async function runMultiRepoExplore(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
829
822
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
830
823
|
ambientAuth: job.ambientAuth,
|
|
831
824
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
825
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
832
826
|
sessionToken: job.sessionToken,
|
|
833
827
|
// Read-only: no edits expected, so the no-progress guard's no-edit bound must not fire.
|
|
834
828
|
expectsEdits: false,
|
|
@@ -836,6 +830,7 @@ async function runMultiRepoExplore(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
836
830
|
webSearchProxy: job.webSearch,
|
|
837
831
|
...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
|
|
838
832
|
guardLimits: job.guardLimits,
|
|
833
|
+
...agentCapabilities(job),
|
|
839
834
|
multiRepo: true,
|
|
840
835
|
},
|
|
841
836
|
opts,
|
|
@@ -946,6 +941,7 @@ function buildSingleRepoCodingSpec(
|
|
|
946
941
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
947
942
|
ambientAuth: job.ambientAuth,
|
|
948
943
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
944
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
949
945
|
sessionToken: job.sessionToken,
|
|
950
946
|
commitMessage: job.commitMessage ?? job.pr?.title ?? 'Agent changes',
|
|
951
947
|
webToolsGuidance: job.webToolsGuidance,
|
|
@@ -954,8 +950,8 @@ function buildSingleRepoCodingSpec(
|
|
|
954
950
|
...(job.persistentCheckout ? { persistentCheckout: true } : {}),
|
|
955
951
|
...(job.streamFollowUps ? { streamFollowUps: true } : {}),
|
|
956
952
|
...(job.referenceBranches?.length ? { referenceBranches: job.referenceBranches } : {}),
|
|
957
|
-
//
|
|
958
|
-
...(job
|
|
953
|
+
// Skills + tool servers: installed/wired harness-aware by runAgentInWorkspace.
|
|
954
|
+
...agentCapabilities(job),
|
|
959
955
|
// Ralph loop: run the completion command after the agent commits and report its verdict.
|
|
960
956
|
...(job.validation
|
|
961
957
|
? {
|
|
@@ -1225,9 +1221,11 @@ async function runConflictResolution(job: AgentJob, opts: RunOptions): Promise<A
|
|
|
1225
1221
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
1226
1222
|
ambientAuth: job.ambientAuth,
|
|
1227
1223
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
1224
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
1228
1225
|
sessionToken: job.sessionToken,
|
|
1229
1226
|
contextFiles: job.contextFiles,
|
|
1230
1227
|
guardLimits: job.guardLimits,
|
|
1228
|
+
...agentCapabilities(job),
|
|
1231
1229
|
},
|
|
1232
1230
|
opts,
|
|
1233
1231
|
)
|
|
@@ -1328,156 +1326,6 @@ function unresolvedReason(
|
|
|
1328
1326
|
)
|
|
1329
1327
|
}
|
|
1330
1328
|
|
|
1331
|
-
/**
|
|
1332
|
-
* Repo-bootstrap coding flow (the bootstrapper): with a reference architecture, clone it →
|
|
1333
|
-
* the agent adapts it in place per the instructions; without one (`fromScratch`), start from
|
|
1334
|
-
* an empty directory → the agent scaffolds the new service. Either way the result's history
|
|
1335
|
-
* is reset to a single commit and force-pushed to the SEPARATE, pre-created target repo's
|
|
1336
|
-
* default branch. Diverges from the ordinary coding flow in pushing to a different repo with
|
|
1337
|
-
* a reinitialised history rather than a work branch + PR on the cloned repo.
|
|
1338
|
-
*/
|
|
1339
|
-
async function runBootstrap(job: AgentJob, opts: RunOptions): Promise<AgentResult> {
|
|
1340
|
-
const { signal } = opts
|
|
1341
|
-
const boot = job.bootstrap!
|
|
1342
|
-
const fromScratch = boot.fromScratch === true
|
|
1343
|
-
const logger = (opts.log ?? log).child({ target: `${boot.target.owner}/${boot.target.name}` })
|
|
1344
|
-
return withWorkspace('boot', async (dir) => {
|
|
1345
|
-
if (!fromScratch) {
|
|
1346
|
-
opts.onPhase?.('clone')
|
|
1347
|
-
logger.info('agent(bootstrap): cloning reference architecture', {
|
|
1348
|
-
reference: `${job.repo.owner}/${job.repo.name}`,
|
|
1349
|
-
})
|
|
1350
|
-
await cloneRepo({
|
|
1351
|
-
repo: { ...job.repo, baseBranch: job.branch },
|
|
1352
|
-
ghToken: job.ghToken,
|
|
1353
|
-
dir,
|
|
1354
|
-
signal,
|
|
1355
|
-
})
|
|
1356
|
-
} else {
|
|
1357
|
-
logger.info('agent(bootstrap): scaffolding from scratch (no reference)')
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
opts.onPhase?.('agent')
|
|
1361
|
-
logger.info('agent(bootstrap): running agent')
|
|
1362
|
-
const { summary, stats, stderrTail, usage, callMetrics, effortReport } =
|
|
1363
|
-
await runAgentInWorkspace(
|
|
1364
|
-
{
|
|
1365
|
-
dir,
|
|
1366
|
-
systemPrompt: job.systemPrompt,
|
|
1367
|
-
userPrompt: job.userPrompt,
|
|
1368
|
-
model: job.model,
|
|
1369
|
-
harness: job.harness,
|
|
1370
|
-
subscriptionToken: job.subscriptionToken,
|
|
1371
|
-
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
1372
|
-
ambientAuth: job.ambientAuth,
|
|
1373
|
-
proxyBaseUrl: job.proxyBaseUrl,
|
|
1374
|
-
sessionToken: job.sessionToken,
|
|
1375
|
-
guardLimits: job.guardLimits,
|
|
1376
|
-
},
|
|
1377
|
-
opts,
|
|
1378
|
-
)
|
|
1379
|
-
|
|
1380
|
-
// Guard against a no-op run: Pi can exit cleanly having done nothing (e.g. it never
|
|
1381
|
-
// reached the model), and a force-push would then publish an empty tree — leaving the
|
|
1382
|
-
// run "succeeded" but the repo bare. Fail with a structured error (carrying what the
|
|
1383
|
-
// agent did) instead of pushing nothing.
|
|
1384
|
-
if (!(await producedRepoContent(dir, !fromScratch, signal))) {
|
|
1385
|
-
const error = bootstrapNoOpReason(!fromScratch, stats, summary, stderrTail)
|
|
1386
|
-
logger.error('agent(bootstrap): agent produced no content, refusing to push', { ...stats })
|
|
1387
|
-
return mergeEffort(
|
|
1388
|
-
{
|
|
1389
|
-
summary,
|
|
1390
|
-
stats,
|
|
1391
|
-
error,
|
|
1392
|
-
failureCause: 'agent',
|
|
1393
|
-
...(usage ? { usage } : {}),
|
|
1394
|
-
...(callMetrics ? { callMetrics } : {}),
|
|
1395
|
-
},
|
|
1396
|
-
effortReport,
|
|
1397
|
-
)
|
|
1398
|
-
}
|
|
1399
|
-
|
|
1400
|
-
opts.onPhase?.('push')
|
|
1401
|
-
logger.info('agent(bootstrap): pushing bootstrapped contents', { ...stats })
|
|
1402
|
-
// Bootstrap always resets history to one commit + force-pushes (the fresh history
|
|
1403
|
-
// shares no ancestor with whatever boilerplate the new repo was created with).
|
|
1404
|
-
await reinitAndPush({
|
|
1405
|
-
dir,
|
|
1406
|
-
target: boot.target,
|
|
1407
|
-
ghToken: job.ghToken,
|
|
1408
|
-
message: fromScratch
|
|
1409
|
-
? 'Bootstrap new repository'
|
|
1410
|
-
: `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
|
|
1411
|
-
})
|
|
1412
|
-
logger.info('agent(bootstrap): complete', { defaultBranch: boot.target.defaultBranch })
|
|
1413
|
-
return mergeEffort(
|
|
1414
|
-
{
|
|
1415
|
-
defaultBranch: boot.target.defaultBranch,
|
|
1416
|
-
summary,
|
|
1417
|
-
stats,
|
|
1418
|
-
...(usage ? { usage } : {}),
|
|
1419
|
-
...(callMetrics ? { callMetrics } : {}),
|
|
1420
|
-
},
|
|
1421
|
-
effortReport,
|
|
1422
|
-
)
|
|
1423
|
-
})
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
|
-
/**
|
|
1427
|
-
* Whether the bootstrapper actually produced repository content, so a no-op run (the agent
|
|
1428
|
-
* never reached the model / never wrote anything) is failed rather than force-pushed as an
|
|
1429
|
-
* empty repo. With a reference architecture, "produced content" means the agent changed the
|
|
1430
|
-
* clone; scaffolding from scratch, it means at least one file now exists in the working
|
|
1431
|
-
* directory. (The harness writes its prompt context to Pi's global `~/.pi/agent/AGENTS.md`,
|
|
1432
|
-
* never into `dir`, so nothing here needs to be filtered out as harness boilerplate.)
|
|
1433
|
-
*/
|
|
1434
|
-
export async function producedRepoContent(
|
|
1435
|
-
dir: string,
|
|
1436
|
-
hasReference: boolean,
|
|
1437
|
-
signal?: AbortSignal,
|
|
1438
|
-
): Promise<boolean> {
|
|
1439
|
-
if (hasReference) return hasAgentChanges(dir, signal)
|
|
1440
|
-
return containsAnyFile(dir)
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
/**
|
|
1444
|
-
* Whether `dir` contains at least one regular file anywhere in its tree, walking
|
|
1445
|
-
* depth-first and stopping at the FIRST file found — so the cost is bounded by how
|
|
1446
|
-
* quickly a file turns up (a scaffold almost always writes a root-level file), not by
|
|
1447
|
-
* the size of the produced tree (a full recursive `readdir` would materialise every
|
|
1448
|
-
* entry before the check).
|
|
1449
|
-
*/
|
|
1450
|
-
async function containsAnyFile(dir: string): Promise<boolean> {
|
|
1451
|
-
const handle = await opendir(dir)
|
|
1452
|
-
try {
|
|
1453
|
-
for await (const entry of handle) {
|
|
1454
|
-
if (entry.isFile()) return true
|
|
1455
|
-
if (entry.isDirectory() && (await containsAnyFile(join(dir, entry.name)))) return true
|
|
1456
|
-
}
|
|
1457
|
-
} catch {
|
|
1458
|
-
// A directory that vanished mid-walk has nothing to contribute.
|
|
1459
|
-
}
|
|
1460
|
-
return false
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
/** Human-readable bootstrap no-op reason, embedding what the agent did so the cause is visible. */
|
|
1464
|
-
function bootstrapNoOpReason(
|
|
1465
|
-
hasReference: boolean,
|
|
1466
|
-
stats: PiRunStats,
|
|
1467
|
-
summary: string,
|
|
1468
|
-
stderrTail: string | undefined,
|
|
1469
|
-
): string {
|
|
1470
|
-
const what = hasReference
|
|
1471
|
-
? 'made no changes to the reference architecture'
|
|
1472
|
-
: 'scaffolded no files'
|
|
1473
|
-
const cause = agentNeverActed(stats) ? NEVER_ACTED_CAUSE : ''
|
|
1474
|
-
return (
|
|
1475
|
-
`the bootstrapper agent ${what} ` +
|
|
1476
|
-
`(tool calls: ${stats.toolCalls}, assistant output: ${stats.assistantChars} chars).${cause}` +
|
|
1477
|
-
agentOutputTail(stderrTail, summary)
|
|
1478
|
-
)
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
1329
|
/** Human-readable reason a read-only run produced no usable output. */
|
|
1482
1330
|
function noOutputReason(stats: PiRunStats, stderrTail: string | undefined): string {
|
|
1483
1331
|
const cause = agentNeverActed(stats)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { opendir } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { AgentJob, AgentResult } from './job.js'
|
|
4
|
+
import type { PiRunStats } from './pi.js'
|
|
5
|
+
import type { RunOptions } from './runner.js'
|
|
6
|
+
import {
|
|
7
|
+
NEVER_ACTED_CAUSE,
|
|
8
|
+
agentNeverActed,
|
|
9
|
+
agentOutputTail,
|
|
10
|
+
runAgentInWorkspace,
|
|
11
|
+
withWorkspace,
|
|
12
|
+
} from './pi-workspace.js'
|
|
13
|
+
import { cloneRepo, hasAgentChanges, reinitAndPush } from './git.js'
|
|
14
|
+
import { log } from './logger.js'
|
|
15
|
+
import { agentCapabilities, mergeEffort } from './agent-shared.js'
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// The repo-BOOTSTRAP mode: adapt a reference architecture (or scaffold from scratch) into a
|
|
19
|
+
// pre-created empty repo and force-push it as a single commit. Extracted from `agent.ts` as a
|
|
20
|
+
// cohesive collaborator — it is a whole MODE with its own push semantics (a separate target repo
|
|
21
|
+
// and a reinitialised history, not a work branch + PR), and it shares only the small agent-run
|
|
22
|
+
// helpers in `agent-shared.ts` with the coding/explore flows.
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Repo-bootstrap coding flow (the bootstrapper): with a reference architecture, clone it →
|
|
27
|
+
* the agent adapts it in place per the instructions; without one (`fromScratch`), start from
|
|
28
|
+
* an empty directory → the agent scaffolds the new service. Either way the result's history
|
|
29
|
+
* is reset to a single commit and force-pushed to the SEPARATE, pre-created target repo's
|
|
30
|
+
* default branch. Diverges from the ordinary coding flow in pushing to a different repo with
|
|
31
|
+
* a reinitialised history rather than a work branch + PR on the cloned repo.
|
|
32
|
+
*/
|
|
33
|
+
export async function runBootstrap(job: AgentJob, opts: RunOptions): Promise<AgentResult> {
|
|
34
|
+
const { signal } = opts
|
|
35
|
+
const boot = job.bootstrap!
|
|
36
|
+
const fromScratch = boot.fromScratch === true
|
|
37
|
+
const logger = (opts.log ?? log).child({ target: `${boot.target.owner}/${boot.target.name}` })
|
|
38
|
+
return withWorkspace('boot', async (dir) => {
|
|
39
|
+
if (!fromScratch) {
|
|
40
|
+
opts.onPhase?.('clone')
|
|
41
|
+
logger.info('agent(bootstrap): cloning reference architecture', {
|
|
42
|
+
reference: `${job.repo.owner}/${job.repo.name}`,
|
|
43
|
+
})
|
|
44
|
+
await cloneRepo({
|
|
45
|
+
repo: { ...job.repo, baseBranch: job.branch },
|
|
46
|
+
ghToken: job.ghToken,
|
|
47
|
+
dir,
|
|
48
|
+
signal,
|
|
49
|
+
})
|
|
50
|
+
} else {
|
|
51
|
+
logger.info('agent(bootstrap): scaffolding from scratch (no reference)')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
opts.onPhase?.('agent')
|
|
55
|
+
logger.info('agent(bootstrap): running agent')
|
|
56
|
+
const { summary, stats, stderrTail, usage, callMetrics, effortReport } =
|
|
57
|
+
await runAgentInWorkspace(
|
|
58
|
+
{
|
|
59
|
+
dir,
|
|
60
|
+
systemPrompt: job.systemPrompt,
|
|
61
|
+
userPrompt: job.userPrompt,
|
|
62
|
+
model: job.model,
|
|
63
|
+
harness: job.harness,
|
|
64
|
+
subscriptionToken: job.subscriptionToken,
|
|
65
|
+
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
66
|
+
ambientAuth: job.ambientAuth,
|
|
67
|
+
proxyBaseUrl: job.proxyBaseUrl,
|
|
68
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
69
|
+
sessionToken: job.sessionToken,
|
|
70
|
+
guardLimits: job.guardLimits,
|
|
71
|
+
...agentCapabilities(job),
|
|
72
|
+
},
|
|
73
|
+
opts,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
// Guard against a no-op run: Pi can exit cleanly having done nothing (e.g. it never
|
|
77
|
+
// reached the model), and a force-push would then publish an empty tree — leaving the
|
|
78
|
+
// run "succeeded" but the repo bare. Fail with a structured error (carrying what the
|
|
79
|
+
// agent did) instead of pushing nothing.
|
|
80
|
+
if (!(await producedRepoContent(dir, !fromScratch, signal))) {
|
|
81
|
+
const error = bootstrapNoOpReason(!fromScratch, stats, summary, stderrTail)
|
|
82
|
+
logger.error('agent(bootstrap): agent produced no content, refusing to push', { ...stats })
|
|
83
|
+
return mergeEffort(
|
|
84
|
+
{
|
|
85
|
+
summary,
|
|
86
|
+
stats,
|
|
87
|
+
error,
|
|
88
|
+
failureCause: 'agent',
|
|
89
|
+
...(usage ? { usage } : {}),
|
|
90
|
+
...(callMetrics ? { callMetrics } : {}),
|
|
91
|
+
},
|
|
92
|
+
effortReport,
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
opts.onPhase?.('push')
|
|
97
|
+
logger.info('agent(bootstrap): pushing bootstrapped contents', { ...stats })
|
|
98
|
+
// Bootstrap always resets history to one commit + force-pushes (the fresh history
|
|
99
|
+
// shares no ancestor with whatever boilerplate the new repo was created with).
|
|
100
|
+
await reinitAndPush({
|
|
101
|
+
dir,
|
|
102
|
+
target: boot.target,
|
|
103
|
+
ghToken: job.ghToken,
|
|
104
|
+
message: fromScratch
|
|
105
|
+
? 'Bootstrap new repository'
|
|
106
|
+
: `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
|
|
107
|
+
})
|
|
108
|
+
logger.info('agent(bootstrap): complete', { defaultBranch: boot.target.defaultBranch })
|
|
109
|
+
return mergeEffort(
|
|
110
|
+
{
|
|
111
|
+
defaultBranch: boot.target.defaultBranch,
|
|
112
|
+
summary,
|
|
113
|
+
stats,
|
|
114
|
+
...(usage ? { usage } : {}),
|
|
115
|
+
...(callMetrics ? { callMetrics } : {}),
|
|
116
|
+
},
|
|
117
|
+
effortReport,
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Whether the bootstrapper actually produced repository content, so a no-op run (the agent
|
|
124
|
+
* never reached the model / never wrote anything) is failed rather than force-pushed as an
|
|
125
|
+
* empty repo. With a reference architecture, "produced content" means the agent changed the
|
|
126
|
+
* clone; scaffolding from scratch, it means at least one file now exists in the working
|
|
127
|
+
* directory. (The harness writes its prompt context to Pi's global `~/.pi/agent/AGENTS.md`,
|
|
128
|
+
* never into `dir`, so nothing here needs to be filtered out as harness boilerplate.)
|
|
129
|
+
*/
|
|
130
|
+
export async function producedRepoContent(
|
|
131
|
+
dir: string,
|
|
132
|
+
hasReference: boolean,
|
|
133
|
+
signal?: AbortSignal,
|
|
134
|
+
): Promise<boolean> {
|
|
135
|
+
if (hasReference) return hasAgentChanges(dir, signal)
|
|
136
|
+
return containsAnyFile(dir)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Whether `dir` contains at least one regular file anywhere in its tree, walking
|
|
141
|
+
* depth-first and stopping at the FIRST file found — so the cost is bounded by how
|
|
142
|
+
* quickly a file turns up (a scaffold almost always writes a root-level file), not by
|
|
143
|
+
* the size of the produced tree (a full recursive `readdir` would materialise every
|
|
144
|
+
* entry before the check).
|
|
145
|
+
*/
|
|
146
|
+
async function containsAnyFile(dir: string): Promise<boolean> {
|
|
147
|
+
const handle = await opendir(dir)
|
|
148
|
+
try {
|
|
149
|
+
for await (const entry of handle) {
|
|
150
|
+
if (entry.isFile()) return true
|
|
151
|
+
if (entry.isDirectory() && (await containsAnyFile(join(dir, entry.name)))) return true
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
// A directory that vanished mid-walk has nothing to contribute.
|
|
155
|
+
}
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Human-readable bootstrap no-op reason, embedding what the agent did so the cause is visible. */
|
|
160
|
+
function bootstrapNoOpReason(
|
|
161
|
+
hasReference: boolean,
|
|
162
|
+
stats: PiRunStats,
|
|
163
|
+
summary: string,
|
|
164
|
+
stderrTail: string | undefined,
|
|
165
|
+
): string {
|
|
166
|
+
const what = hasReference
|
|
167
|
+
? 'made no changes to the reference architecture'
|
|
168
|
+
: 'scaffolded no files'
|
|
169
|
+
const cause = agentNeverActed(stats) ? NEVER_ACTED_CAUSE : ''
|
|
170
|
+
return (
|
|
171
|
+
`the bootstrapper agent ${what} ` +
|
|
172
|
+
`(tool calls: ${stats.toolCalls}, assistant output: ${stats.assistantChars} chars).${cause}` +
|
|
173
|
+
agentOutputTail(stderrTail, summary)
|
|
174
|
+
)
|
|
175
|
+
}
|