@alecsibilia/luca 14.0.0-alpha.5 → 14.0.0-alpha.6
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/CHANGELOG.md +4 -0
- package/board/package.json +1 -1
- package/board/server/board-vocabulary.ts +4 -1
- package/board/server/luca-version.ts +1 -1
- package/board/server/reduce-board.ts +2 -0
- package/engine/agents/role-prompts.ts +22 -0
- package/engine/core/decide-build.ts +27 -12
- package/engine/core/decide-run-budget.ts +12 -3
- package/engine/core/execute-build.ts +95 -41
- package/engine/core/execute-changeset.ts +6 -8
- package/engine/core/execute-final-review.ts +86 -10
- package/engine/core/execute-pull-request.ts +84 -0
- package/engine/core/execute-stuck.ts +7 -9
- package/engine/core/execute.ts +27 -2
- package/engine/core/final-review-text.ts +13 -4
- package/engine/core/pull-request-text.ts +140 -0
- package/engine/core/stuck-text.ts +29 -0
- package/engine/gates/leftover-scan.ts +58 -4
- package/engine/git/git-adapter.ts +29 -0
- package/engine/journal/journal-record.ts +37 -5
- package/engine/journal/replay.ts +27 -10
- package/engine/tracker/github-tracker.ts +19 -9
- package/engine/tracker/in-memory-tracker.ts +3 -2
- package/engine/tracker/tracker.ts +37 -4
- package/package.json +1 -1
- package/skills/luca-unstick/SKILL.md +1 -0
package/CHANGELOG.md
CHANGED
package/board/package.json
CHANGED
|
@@ -279,7 +279,10 @@ export const BOARD_VOCABULARY = {
|
|
|
279
279
|
reason: z.string(),
|
|
280
280
|
detail: z.string().catch(''),
|
|
281
281
|
}),
|
|
282
|
-
/**
|
|
282
|
+
/**
|
|
283
|
+
* The whole run is stuck (#435): `run_budget`, it used up its budget,
|
|
284
|
+
* or `pull_request_failed`, its PR didn't open (#508).
|
|
285
|
+
*/
|
|
283
286
|
run_stuck: z.looseObject({
|
|
284
287
|
reason: z.string(),
|
|
285
288
|
detail: z.string().catch(''),
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The Luca version of this board, stamped when the package was packed. */
|
|
2
|
-
export const LUCA_VERSION: string = "14.0.0-alpha.
|
|
2
|
+
export const LUCA_VERSION: string = "14.0.0-alpha.6"
|
|
@@ -408,6 +408,8 @@ const resolveNeedsYou = ({
|
|
|
408
408
|
const RUN_STUCK_REASONS: Record<string, string> = {
|
|
409
409
|
run_budget:
|
|
410
410
|
'The run used up its run budget of tokens. `retry` adds one more full budget.',
|
|
411
|
+
pull_request_failed:
|
|
412
|
+
"The run's pull request didn't open. `retry` tries again, reusing a PR already open from the run branch.",
|
|
411
413
|
}
|
|
412
414
|
|
|
413
415
|
/** A run stuck reason code in words. */
|
|
@@ -190,6 +190,28 @@ export const prepareSection = ({ command }: { command: string }): string =>
|
|
|
190
190
|
"If the engine's prepare command fails after your turn, its output comes back to you like a failed check.",
|
|
191
191
|
].join('\n\n')
|
|
192
192
|
|
|
193
|
+
/**
|
|
194
|
+
* The section a test-writer or implementer (a ticket's or the final
|
|
195
|
+
* review's fixers) gets in a repo with changesets: the engine writes the
|
|
196
|
+
* run's one changeset, so an agent's own is extra.
|
|
197
|
+
*/
|
|
198
|
+
export const CHANGESETS_SECTION = [
|
|
199
|
+
'## The engine writes the changeset',
|
|
200
|
+
"Don't write changesets, even if the repo's rules ask for one: the engine writes the run's one changeset when it opens the PR. " +
|
|
201
|
+
'A new file you add in `.changeset/` is removed before the commit. ' +
|
|
202
|
+
'A finding that only asks for a changeset is answered by this: answer it "wont_fix" and say the engine adds it.',
|
|
203
|
+
].join('\n\n')
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The section the rules lens gets in a repo with changesets, so a rule like
|
|
207
|
+
* "every PR needs a changeset" raises no finding.
|
|
208
|
+
*/
|
|
209
|
+
export const CHANGESETS_LENS_SECTION = [
|
|
210
|
+
'## The engine writes the changeset',
|
|
211
|
+
"The engine adds the run's one changeset when it opens the PR, so a rule that asks for a changeset is met. " +
|
|
212
|
+
'Raise no finding that only asks for a changeset.',
|
|
213
|
+
].join('\n\n')
|
|
214
|
+
|
|
193
215
|
/** What each lens judges the whole run branch on, in one paragraph. */
|
|
194
216
|
const LENS_TASK_TEXT =
|
|
195
217
|
'You are one lens of the final review: a fresh, read-only reviewer of the WHOLE run branch, every ticket of the spec together. Write nothing.'
|
|
@@ -47,6 +47,8 @@ import {
|
|
|
47
47
|
import { badTestDetail, retrySection, setupChangeDetail } from './stuck-text'
|
|
48
48
|
|
|
49
49
|
import {
|
|
50
|
+
CHANGESETS_LENS_SECTION,
|
|
51
|
+
CHANGESETS_SECTION,
|
|
50
52
|
prepareSection,
|
|
51
53
|
rejoinSection,
|
|
52
54
|
rolePrompt,
|
|
@@ -1303,34 +1305,47 @@ export const decideBuild = ({
|
|
|
1303
1305
|
}): BuildAction[] => {
|
|
1304
1306
|
const steps = buildSteps({ state, spec_number })
|
|
1305
1307
|
const command = state.config?.prepare
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1308
|
+
const prepared =
|
|
1309
|
+
command === undefined
|
|
1310
|
+
? steps
|
|
1311
|
+
: steps.map((step) =>
|
|
1312
|
+
withWriterSection({
|
|
1313
|
+
step,
|
|
1314
|
+
section: prepareSection({ command }),
|
|
1315
|
+
})
|
|
1316
|
+
)
|
|
1317
|
+
return state.changesets ? prepared.map(withChangesetsSections) : prepared
|
|
1309
1318
|
}
|
|
1310
1319
|
|
|
1311
1320
|
/**
|
|
1312
1321
|
* A fresh test-writer's or implementer's launch (a ticket's or the final
|
|
1313
|
-
* review's fixers), with
|
|
1314
|
-
* Any other step as it is.
|
|
1322
|
+
* review's fixers), with `section` added to its prompt, such as the repo's
|
|
1323
|
+
* prepare command. Any other step as it is.
|
|
1315
1324
|
*/
|
|
1316
|
-
const
|
|
1325
|
+
const withWriterSection = ({
|
|
1317
1326
|
step,
|
|
1318
|
-
|
|
1327
|
+
section,
|
|
1319
1328
|
}: {
|
|
1320
1329
|
step: BuildAction
|
|
1321
|
-
|
|
1330
|
+
section: string
|
|
1322
1331
|
}): BuildAction => {
|
|
1323
1332
|
const writesCode =
|
|
1324
1333
|
(step.type === 'launch_agent' || step.type === 'launch_final_fixer') &&
|
|
1325
1334
|
(step.role === 'test-writer' || step.role === 'implementer')
|
|
1326
1335
|
return writesCode
|
|
1327
|
-
? {
|
|
1328
|
-
...step,
|
|
1329
|
-
prompt: `${step.prompt}\n\n${prepareSection({ command })}`,
|
|
1330
|
-
}
|
|
1336
|
+
? { ...step, prompt: `${step.prompt}\n\n${section}` }
|
|
1331
1337
|
: step
|
|
1332
1338
|
}
|
|
1333
1339
|
|
|
1340
|
+
/**
|
|
1341
|
+
* In a repo with changesets, the writers are told not to write one and the
|
|
1342
|
+
* rules lens that the engine adds the run's one at PR time (#507).
|
|
1343
|
+
*/
|
|
1344
|
+
const withChangesetsSections = (step: BuildAction): BuildAction =>
|
|
1345
|
+
step.type === 'launch_lens' && step.lens === 'rules'
|
|
1346
|
+
? { ...step, prompt: `${step.prompt}\n\n${CHANGESETS_LENS_SECTION}` }
|
|
1347
|
+
: withWriterSection({ step, section: CHANGESETS_SECTION })
|
|
1348
|
+
|
|
1334
1349
|
/** `decideBuild`'s steps, before the prepare command joins any prompt. */
|
|
1335
1350
|
const buildSteps = ({
|
|
1336
1351
|
state,
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { pendingReplies, waitForReply, type StuckAction } from './decide-stuck'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
pullRequestStuckComment,
|
|
4
|
+
runBudgetDetail,
|
|
5
|
+
runStuckComment,
|
|
6
|
+
} from './stuck-text'
|
|
3
7
|
|
|
4
8
|
import type { JournalRecord, RunStuckReason } from '../journal/journal-record'
|
|
5
9
|
import type { RunState } from '../journal/replay'
|
|
@@ -19,7 +23,9 @@ export type RunBudgetAction =
|
|
|
19
23
|
* reason "run budget": that is its only step, and nothing new starts. The
|
|
20
24
|
* spec issue is told, then the run waits for the owner: a bare `retry`
|
|
21
25
|
* adds one more full budget (replay does that), and `stop` ends the run.
|
|
22
|
-
* The owner's other replies wait until the run carries on.
|
|
26
|
+
* The owner's other replies wait until the run carries on. A run whose PR
|
|
27
|
+
* didn't open (#508) is stuck the same way, told why, and a `retry` tries
|
|
28
|
+
* the PR again, adding no budget. `null` when
|
|
23
29
|
* the budget has nothing to say: the run is under it, stopping, or its PR
|
|
24
30
|
* is open.
|
|
25
31
|
*
|
|
@@ -56,7 +62,10 @@ export const decideRunBudget = ({
|
|
|
56
62
|
{
|
|
57
63
|
type: 'report_run_stuck',
|
|
58
64
|
spec_number,
|
|
59
|
-
body:
|
|
65
|
+
body:
|
|
66
|
+
stuck.reason === 'pull_request_failed'
|
|
67
|
+
? pullRequestStuckComment({ detail: stuck.detail })
|
|
68
|
+
: runStuckComment({ detail: stuck.detail, budget }),
|
|
60
69
|
},
|
|
61
70
|
]
|
|
62
71
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
-
import { mkdir } from 'node:fs/promises'
|
|
2
|
+
import { mkdir, rm } from 'node:fs/promises'
|
|
3
3
|
import { basename, dirname, join } from 'node:path'
|
|
4
4
|
|
|
5
5
|
import difference from 'lodash/difference'
|
|
@@ -11,8 +11,6 @@ import { CHANGESET_CONFIG } from './changeset'
|
|
|
11
11
|
import { mayEditTests, type BuildAction } from './decide-build'
|
|
12
12
|
import type { FinalReviewAction } from './decide-final-review'
|
|
13
13
|
import { retryTicket } from './execute-stuck'
|
|
14
|
-
import { openTicketsNotInRun } from './not-in-run'
|
|
15
|
-
import { withNotInRun } from './pull-request-text'
|
|
16
14
|
import { closeSessions, openSessionsIn } from './session-close'
|
|
17
15
|
|
|
18
16
|
import type { AgentLauncher, AgentTurn } from '../agents/agent-launcher'
|
|
@@ -25,7 +23,13 @@ import { bunTestCommands, type EngineConfig } from '../config/engine-config'
|
|
|
25
23
|
import { checkAlreadyDone } from '../gates/already-done-check'
|
|
26
24
|
import { prepareCheck, runGates, shellCheck } from '../gates/gate-runner'
|
|
27
25
|
import { endedText } from '../gates/gate-schemas'
|
|
28
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
importStem,
|
|
28
|
+
isAgentChangeset,
|
|
29
|
+
newCodeFiles,
|
|
30
|
+
scanLeftovers,
|
|
31
|
+
scratchNamedFiles,
|
|
32
|
+
} from '../gates/leftover-scan'
|
|
29
33
|
import {
|
|
30
34
|
dependenciesChanged,
|
|
31
35
|
installCommand,
|
|
@@ -450,6 +454,65 @@ const isUsed = async ({
|
|
|
450
454
|
)
|
|
451
455
|
}
|
|
452
456
|
|
|
457
|
+
/**
|
|
458
|
+
* Whether a file other than itself and the tests names this scratch-named
|
|
459
|
+
* file by its basename, as a TOC, `package.json`, or a config does: a
|
|
460
|
+
* tracked file, or one this change adds (a new addon's TOC names its new
|
|
461
|
+
* modules). A mention in a test doesn't make a file the repo's.
|
|
462
|
+
*/
|
|
463
|
+
const isNamed = async ({
|
|
464
|
+
context,
|
|
465
|
+
cwd,
|
|
466
|
+
path,
|
|
467
|
+
added_texts,
|
|
468
|
+
}: {
|
|
469
|
+
context: BuildContext
|
|
470
|
+
cwd: string
|
|
471
|
+
path: string
|
|
472
|
+
added_texts: Record<string, string>
|
|
473
|
+
}): Promise<boolean> => {
|
|
474
|
+
const name = basename(path)
|
|
475
|
+
const tracked = await context.git.filesMentioning({ cwd, text: name })
|
|
476
|
+
const added = Object.entries(added_texts)
|
|
477
|
+
.filter(([, text]) => text.includes(name))
|
|
478
|
+
.map(([file]) => file)
|
|
479
|
+
const files = [...tracked, ...added].filter((file) => file !== path)
|
|
480
|
+
const tests = testFilesAmong({
|
|
481
|
+
files,
|
|
482
|
+
test_file_patterns: context.config.test_file_patterns,
|
|
483
|
+
})
|
|
484
|
+
return files.some((file) => !tests.includes(file))
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* In a repo with changesets, removes the changesets an agent wrote (see
|
|
489
|
+
* `isAgentChangeset`) from the worktree and journals `changeset_dropped`:
|
|
490
|
+
* the engine writes the run's one itself. Returns the other changes.
|
|
491
|
+
*/
|
|
492
|
+
export const dropAgentChangesets = async ({
|
|
493
|
+
context,
|
|
494
|
+
cwd,
|
|
495
|
+
ticket,
|
|
496
|
+
changes,
|
|
497
|
+
}: {
|
|
498
|
+
context: BuildContext
|
|
499
|
+
cwd: string
|
|
500
|
+
ticket: number | null
|
|
501
|
+
changes: FileChange[]
|
|
502
|
+
}): Promise<FileChange[]> => {
|
|
503
|
+
if (!context.state.changesets) return changes
|
|
504
|
+
const paths = changes.filter(isAgentChangeset).map(({ path }) => path)
|
|
505
|
+
if (paths.length === 0) return changes
|
|
506
|
+
for (const path of paths) await rm(join(cwd, path), { force: true })
|
|
507
|
+
context.journal.append({
|
|
508
|
+
kind: 'changeset_dropped',
|
|
509
|
+
ticket,
|
|
510
|
+
role: null,
|
|
511
|
+
content: { paths },
|
|
512
|
+
})
|
|
513
|
+
return changes.filter(({ path }) => !paths.includes(path))
|
|
514
|
+
}
|
|
515
|
+
|
|
453
516
|
/**
|
|
454
517
|
* The commit a crash left behind at `cwd`: HEAD carries `message` and no
|
|
455
518
|
* record names it yet, so the step's first try made it and died before
|
|
@@ -483,7 +546,8 @@ const leftCommit = async ({
|
|
|
483
546
|
* The leftover scan, then an engine commit of everything in `cwd`, both
|
|
484
547
|
* journaled. What the prepare command made (`prepare_made`) and untracked
|
|
485
548
|
* links pointing outside the checkout (see `leaveOutIn`) are neither
|
|
486
|
-
* scanned nor committed.
|
|
549
|
+
* scanned nor committed. An agent's changeset is dropped before the scan
|
|
550
|
+
* (see `dropAgentChangesets`). A hit blocks the commit. A redo after a crash that
|
|
487
551
|
* already committed (nothing left to commit, and HEAD is an unjournaled
|
|
488
552
|
* commit with this message) journals that commit instead of making
|
|
489
553
|
* another; its scan, of a clean worktree, finds nothing, as the first try's
|
|
@@ -514,7 +578,12 @@ export const commitIn = async ({
|
|
|
514
578
|
mention_text: string
|
|
515
579
|
}) => {
|
|
516
580
|
const leave_out = await leaveOutIn({ context, cwd, ticket })
|
|
517
|
-
const changes = await
|
|
581
|
+
const changes = await dropAgentChangesets({
|
|
582
|
+
context,
|
|
583
|
+
cwd,
|
|
584
|
+
ticket,
|
|
585
|
+
changes: await ticketChanges({ context, cwd, leave_out }),
|
|
586
|
+
})
|
|
518
587
|
const test_files = testFilesAmong({
|
|
519
588
|
files: changes.map(({ path }) => path),
|
|
520
589
|
test_file_patterns: context.config.test_file_patterns,
|
|
@@ -528,7 +597,19 @@ export const commitIn = async ({
|
|
|
528
597
|
for (const path of newCodeFiles({ changes, test_files })) {
|
|
529
598
|
used_code[path] = await isUsed({ context, cwd, path, added_texts })
|
|
530
599
|
}
|
|
531
|
-
const
|
|
600
|
+
const named: string[] = []
|
|
601
|
+
for (const path of scratchNamedFiles({ changes })) {
|
|
602
|
+
if (await isNamed({ context, cwd, path, added_texts })) {
|
|
603
|
+
named.push(path)
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
const hits = scanLeftovers({
|
|
607
|
+
changes,
|
|
608
|
+
test_files,
|
|
609
|
+
mention_text,
|
|
610
|
+
used_code,
|
|
611
|
+
named,
|
|
612
|
+
})
|
|
532
613
|
context.journal.append({
|
|
533
614
|
kind: 'leftover_scan',
|
|
534
615
|
ticket,
|
|
@@ -1151,12 +1232,15 @@ export const executeBuildAction = async ({
|
|
|
1151
1232
|
}: BuildDeps & {
|
|
1152
1233
|
/**
|
|
1153
1234
|
* The final review's steps go to `executeFinalReviewAction`, the
|
|
1154
|
-
* changeset to `writeChangeset`,
|
|
1155
|
-
* `executeAlreadyDoneAction`.
|
|
1235
|
+
* changeset to `writeChangeset`, the PR to `openPullRequest`, and an
|
|
1236
|
+
* already-done ticket's to `executeAlreadyDoneAction`.
|
|
1156
1237
|
*/
|
|
1157
1238
|
action: Exclude<
|
|
1158
1239
|
BuildAction,
|
|
1159
|
-
|
|
1240
|
+
| FinalReviewAction
|
|
1241
|
+
| AlreadyDoneAction
|
|
1242
|
+
| { type: 'write_changeset' }
|
|
1243
|
+
| { type: 'open_pull_request' }
|
|
1160
1244
|
>
|
|
1161
1245
|
journal: Journal
|
|
1162
1246
|
tracker: Tracker
|
|
@@ -1398,7 +1482,7 @@ export const executeBuildAction = async ({
|
|
|
1398
1482
|
case 'report_final_review_stuck':
|
|
1399
1483
|
case 'ship_final_review':
|
|
1400
1484
|
case 'retry_final_review':
|
|
1401
|
-
//
|
|
1485
|
+
// Stuck work's steps; `executeAction` carries them out.
|
|
1402
1486
|
throw new Error(`${action.type} is not a git or agent step.`)
|
|
1403
1487
|
case 'mark_stuck':
|
|
1404
1488
|
// A join crashes cut off too often leaves no half of it behind.
|
|
@@ -1418,36 +1502,6 @@ export const executeBuildAction = async ({
|
|
|
1418
1502
|
content: { reason: action.reason, detail: action.detail },
|
|
1419
1503
|
})
|
|
1420
1504
|
return
|
|
1421
|
-
case 'open_pull_request': {
|
|
1422
|
-
const { head, base, title, body } = action
|
|
1423
|
-
// A redo adopts the PR its first try opened before the crash.
|
|
1424
|
-
const adopted = context.step.redo
|
|
1425
|
-
? await tracker.findOpenPullRequest({ head })
|
|
1426
|
-
: null
|
|
1427
|
-
// The spec's open tickets not in the run are named in the PR.
|
|
1428
|
-
const full = withNotInRun({
|
|
1429
|
-
body,
|
|
1430
|
-
open: await openTicketsNotInRun({
|
|
1431
|
-
tracker,
|
|
1432
|
-
state: context.state,
|
|
1433
|
-
}),
|
|
1434
|
-
})
|
|
1435
|
-
const opened =
|
|
1436
|
-
adopted ??
|
|
1437
|
-
(await tracker.openPullRequest({
|
|
1438
|
-
head,
|
|
1439
|
-
base,
|
|
1440
|
-
title,
|
|
1441
|
-
body: full,
|
|
1442
|
-
}))
|
|
1443
|
-
journal.append({
|
|
1444
|
-
kind: 'pull_request_opened',
|
|
1445
|
-
ticket: null,
|
|
1446
|
-
role: null,
|
|
1447
|
-
content: { ...opened, head, base, title, body: full },
|
|
1448
|
-
})
|
|
1449
|
-
return
|
|
1450
|
-
}
|
|
1451
1505
|
case 'done':
|
|
1452
1506
|
case 'invalid_journal':
|
|
1453
1507
|
return
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
type WorkspacePackage,
|
|
13
13
|
} from './changeset'
|
|
14
14
|
import type { BuildAction } from './decide-build'
|
|
15
|
-
import {
|
|
15
|
+
import { need, type BuildContext } from './execute-build'
|
|
16
16
|
|
|
17
17
|
/** The part of the changesets config the engine reads: what it ignores. */
|
|
18
18
|
const ChangesetConfigSchema = z
|
|
@@ -83,8 +83,9 @@ export const workspacePackages = async ({
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
/**
|
|
86
|
-
* Writes the run's changeset on the run branch, commits it
|
|
87
|
-
*
|
|
86
|
+
* Writes the run's changeset on the run branch, commits it (only it: other
|
|
87
|
+
* uncommitted edits stay out, #509), and pushes the run branch, then
|
|
88
|
+
* journals `changeset_written`. It names every workspace
|
|
88
89
|
* package with files changed since the run branch's base, minus the ones
|
|
89
90
|
* the config ignores. A redo after a crash finds its own file already
|
|
90
91
|
* committed and commits nothing twice. A `release:none` spec (#476), or a
|
|
@@ -145,11 +146,8 @@ export const writeChangeset = async ({
|
|
|
145
146
|
await Bun.write(join(cwd, path), changesetText({ packages, bump, summary }))
|
|
146
147
|
const changes = await git.changes({ cwd })
|
|
147
148
|
if (changes.some((change) => change.path === path)) {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
message,
|
|
151
|
-
leave_out: await leaveOutIn({ context, cwd, ticket: null }),
|
|
152
|
-
})
|
|
149
|
+
// Only the changeset: any other edit left in the worktree stays out.
|
|
150
|
+
await git.commitPaths({ cwd, message, paths: [path] })
|
|
153
151
|
}
|
|
154
152
|
await git.push({ cwd, branch })
|
|
155
153
|
journal.append({
|
|
@@ -4,15 +4,22 @@ import { isAbsolute, join } from 'node:path'
|
|
|
4
4
|
import type { FinalReviewAction } from './decide-final-review'
|
|
5
5
|
import {
|
|
6
6
|
commitIn,
|
|
7
|
+
dropAgentChangesets,
|
|
7
8
|
gatesIn,
|
|
9
|
+
leaveOutIn,
|
|
8
10
|
need,
|
|
9
11
|
runTurn,
|
|
10
12
|
type BuildContext,
|
|
11
13
|
} from './execute-build'
|
|
12
14
|
import { closeSessions, openSessionsIn } from './session-close'
|
|
13
15
|
|
|
16
|
+
import type { EngineCommit } from '../git/git-adapter'
|
|
14
17
|
import type { Journal } from '../journal/journal'
|
|
15
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
replayRun,
|
|
20
|
+
type FinalReviewState,
|
|
21
|
+
type RuleFile,
|
|
22
|
+
} from '../journal/replay'
|
|
16
23
|
|
|
17
24
|
/**
|
|
18
25
|
* Carries out the final review's steps on the run branch's worktree: its
|
|
@@ -293,7 +300,8 @@ export const executeFinalReviewAction = async ({
|
|
|
293
300
|
|
|
294
301
|
/**
|
|
295
302
|
* The seam for replies (#366): when the person replies `ship` to a stuck
|
|
296
|
-
* final review, the reply reader calls this
|
|
303
|
+
* final review, the reply reader calls this (the engine's ship step,
|
|
304
|
+
* `shipFinalReviewIn`, after committing any edits left over). It journals
|
|
297
305
|
* `final_review_shipped`, and the next `runEngine` on the journal opens the
|
|
298
306
|
* PR with the findings still open listed at the top of its description.
|
|
299
307
|
* A final review that isn't stuck (or was already shipped) gets nothing.
|
|
@@ -304,21 +312,89 @@ export const executeFinalReviewAction = async ({
|
|
|
304
312
|
*/
|
|
305
313
|
export const shipFinalReview = ({
|
|
306
314
|
journal,
|
|
315
|
+
commit,
|
|
307
316
|
}: {
|
|
308
317
|
journal: Journal
|
|
318
|
+
/** The commit of the edits left uncommitted at ship time, if any. */
|
|
319
|
+
commit?: EngineCommit & { message: string }
|
|
309
320
|
}): { ok: true } | { ok: false; reason: string } => {
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
}
|
|
314
|
-
if (final_review.shipped) {
|
|
315
|
-
return { ok: false, reason: 'The final review was already shipped.' }
|
|
316
|
-
}
|
|
321
|
+
const refused = shipRefusal({
|
|
322
|
+
final_review: replayRun({ records: journal.read() }).final_review,
|
|
323
|
+
})
|
|
324
|
+
if (refused !== null) return { ok: false, reason: refused }
|
|
317
325
|
journal.append({
|
|
318
326
|
kind: 'final_review_shipped',
|
|
319
327
|
ticket: null,
|
|
320
328
|
role: null,
|
|
321
|
-
content: {},
|
|
329
|
+
content: commit === undefined ? {} : { commit },
|
|
322
330
|
})
|
|
323
331
|
return { ok: true }
|
|
324
332
|
}
|
|
333
|
+
|
|
334
|
+
/** Why a final review can't be shipped, or `null` when it can. */
|
|
335
|
+
const shipRefusal = ({
|
|
336
|
+
final_review,
|
|
337
|
+
}: {
|
|
338
|
+
final_review: FinalReviewState
|
|
339
|
+
}): string | null => {
|
|
340
|
+
if (final_review.stuck === null) return 'The final review is not stuck.'
|
|
341
|
+
if (final_review.shipped) return 'The final review was already shipped.'
|
|
342
|
+
return null
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The commit message for the edits left uncommitted in the run branch's
|
|
347
|
+
* worktree when a stuck final review is shipped (#509).
|
|
348
|
+
*
|
|
349
|
+
* @example
|
|
350
|
+
* shippedEditsMessage({ round: 4 }) // 'fix: final review round 4, shipped with open findings'
|
|
351
|
+
*/
|
|
352
|
+
export const shippedEditsMessage = ({ round }: { round: number }): string =>
|
|
353
|
+
`fix: final review round ${round}, shipped with open findings`
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Carries out a `ship` reply to the stuck final review (#509): edits left
|
|
357
|
+
* uncommitted in the run branch's worktree (a fixer's, or the owner's) get
|
|
358
|
+
* their own commit (`shippedEditsMessage`), which is pushed, then
|
|
359
|
+
* `shipFinalReview` journals it with the ship, so the PR lists its files as
|
|
360
|
+
* not reviewed. What prepare made stays out, and a changeset a fixer wrote
|
|
361
|
+
* is dropped as at any engine commit (`dropAgentChangesets`), so the PR has
|
|
362
|
+
* only the engine's. With no edits, no commit. A redo after a crash that
|
|
363
|
+
* already committed adopts that commit.
|
|
364
|
+
*/
|
|
365
|
+
export const shipFinalReviewIn = async ({
|
|
366
|
+
context,
|
|
367
|
+
}: {
|
|
368
|
+
context: BuildContext
|
|
369
|
+
}): Promise<void> => {
|
|
370
|
+
const { journal, git, state } = context
|
|
371
|
+
const refused = shipRefusal({ final_review: state.final_review })
|
|
372
|
+
if (refused !== null) throw new Error(refused)
|
|
373
|
+
const { path: cwd, branch } = need({
|
|
374
|
+
value: state.run_branch,
|
|
375
|
+
what: 'run branch',
|
|
376
|
+
})
|
|
377
|
+
const message = shippedEditsMessage({ round: state.final_review.round })
|
|
378
|
+
const leave_out = await leaveOutIn({ context, cwd, ticket: null })
|
|
379
|
+
const changes = await dropAgentChangesets({
|
|
380
|
+
context,
|
|
381
|
+
cwd,
|
|
382
|
+
ticket: null,
|
|
383
|
+
changes: (await git.changes({ cwd })).filter(
|
|
384
|
+
({ path }) => !leave_out.includes(path)
|
|
385
|
+
),
|
|
386
|
+
})
|
|
387
|
+
let commit: (EngineCommit & { message: string }) | undefined
|
|
388
|
+
if (changes.length > 0) {
|
|
389
|
+
commit = {
|
|
390
|
+
...(await git.commitAll({ cwd, message, leave_out })),
|
|
391
|
+
message,
|
|
392
|
+
}
|
|
393
|
+
} else if (context.step.redo) {
|
|
394
|
+
const last = await git.lastCommit({ cwd })
|
|
395
|
+
if (last.message === message) commit = last
|
|
396
|
+
}
|
|
397
|
+
if (commit !== undefined) await git.push({ cwd, branch })
|
|
398
|
+
const shipped = shipFinalReview({ journal, commit })
|
|
399
|
+
if (!shipped.ok) throw new Error(shipped.reason)
|
|
400
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import type { BuildAction } from './decide-build'
|
|
4
|
+
import type { BuildContext } from './execute-build'
|
|
5
|
+
import { openTicketsNotInRun } from './not-in-run'
|
|
6
|
+
import {
|
|
7
|
+
fitPullRequestBody,
|
|
8
|
+
PULL_REQUEST_FILE,
|
|
9
|
+
withNotInRun,
|
|
10
|
+
} from './pull-request-text'
|
|
11
|
+
|
|
12
|
+
import { postCommentOnce } from '../tracker/post-comment-once'
|
|
13
|
+
import { isTrackerFailure, type OpenedPullRequest } from '../tracker/tracker'
|
|
14
|
+
|
|
15
|
+
const errorText = (error: unknown): string =>
|
|
16
|
+
error instanceof Error ? error.message : String(error)
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Opens the run's one pull request, then journals `pull_request_opened`
|
|
20
|
+
* (#508). Its full text (with the spec's open tickets not in the run) is
|
|
21
|
+
* saved first to `pull-request.md` in the run folder, so it's never lost.
|
|
22
|
+
* An open PR from the run branch is reused, never opened twice: the one a
|
|
23
|
+
* try opened before a crash or a failure, or one the owner opened by hand.
|
|
24
|
+
* The body sent keeps under `PULL_REQUEST_BUDGET`, and what doesn't fit
|
|
25
|
+
* is posted as PR comments after it (`fitPullRequestBody`), each once. A
|
|
26
|
+
* failed tracker call (`isTrackerFailure`: finding or opening the PR, such
|
|
27
|
+
* as a failed `gh pr create`) journals the run stuck (`pull_request_failed`)
|
|
28
|
+
* with its error, for the owner's `retry`; any other error is a crash.
|
|
29
|
+
*/
|
|
30
|
+
export const openPullRequest = async ({
|
|
31
|
+
context,
|
|
32
|
+
action,
|
|
33
|
+
}: {
|
|
34
|
+
context: BuildContext
|
|
35
|
+
action: Extract<BuildAction, { type: 'open_pull_request' }>
|
|
36
|
+
}): Promise<void> => {
|
|
37
|
+
const { journal, tracker, state, run_dir, step } = context
|
|
38
|
+
const { head, base, title, body } = action
|
|
39
|
+
// The spec's open tickets not in the run are named in the PR.
|
|
40
|
+
const full = withNotInRun({
|
|
41
|
+
body,
|
|
42
|
+
open: await openTicketsNotInRun({ tracker, state }),
|
|
43
|
+
})
|
|
44
|
+
await Bun.write(join(run_dir, PULL_REQUEST_FILE), full)
|
|
45
|
+
const fitted = fitPullRequestBody({ body: full })
|
|
46
|
+
let opened: OpenedPullRequest
|
|
47
|
+
try {
|
|
48
|
+
opened =
|
|
49
|
+
(await tracker.findOpenPullRequest({ head })) ??
|
|
50
|
+
(await tracker.openPullRequest({
|
|
51
|
+
head,
|
|
52
|
+
base,
|
|
53
|
+
title,
|
|
54
|
+
body: fitted.body,
|
|
55
|
+
}))
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (!isTrackerFailure(error)) throw error
|
|
58
|
+
journal.append({
|
|
59
|
+
kind: 'run_stuck',
|
|
60
|
+
ticket: null,
|
|
61
|
+
role: null,
|
|
62
|
+
content: {
|
|
63
|
+
reason: 'pull_request_failed',
|
|
64
|
+
detail: errorText(error),
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
for (const [n, comment] of fitted.comments.entries()) {
|
|
70
|
+
await postCommentOnce({
|
|
71
|
+
tracker,
|
|
72
|
+
number: opened.number,
|
|
73
|
+
body: comment,
|
|
74
|
+
step,
|
|
75
|
+
n,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
journal.append({
|
|
79
|
+
kind: 'pull_request_opened',
|
|
80
|
+
ticket: null,
|
|
81
|
+
role: null,
|
|
82
|
+
content: { ...opened, head, base, title, body: full },
|
|
83
|
+
})
|
|
84
|
+
}
|