@alecsibilia/luca 14.0.0-alpha.7 → 14.0.0-alpha.9
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 +8 -0
- package/board/package.json +1 -1
- package/board/server/luca-version.ts +1 -1
- package/board/server/reduce-board.ts +2 -0
- package/board/shared/board-state.ts +1 -1
- package/engine/core/decide-build.ts +18 -9
- package/engine/core/decide-stuck.ts +8 -2
- package/engine/core/execute-build.ts +74 -18
- package/engine/core/stuck-text.ts +4 -0
- package/engine/git/git-adapter.ts +49 -6
- package/engine/journal/journal-record.ts +11 -2
- package/engine/journal/replay.ts +50 -2
- package/package.json +1 -1
- package/skills/luca-unstick/SKILL.md +1 -0
package/CHANGELOG.md
CHANGED
package/board/package.json
CHANGED
|
@@ -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.9"
|
|
@@ -61,6 +61,8 @@ const STUCK_REASONS: Record<string, string> = {
|
|
|
61
61
|
setup_change_needed:
|
|
62
62
|
'An agent needs a test setup file changed; only you may change one.',
|
|
63
63
|
crashed: 'The engine crashed in the same step again and again.',
|
|
64
|
+
undo_refused:
|
|
65
|
+
'Undoing its join would have rewound the run branch over other work.',
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
/**
|
|
@@ -3,7 +3,7 @@ import { z } from 'zod'
|
|
|
3
3
|
/**
|
|
4
4
|
* The board's view of one run, as the plugin's server keeps it and the side
|
|
5
5
|
* panel reads it. Shared by both runtimes, so it holds only Zod schemas and
|
|
6
|
-
* plain values. Words follow
|
|
6
|
+
* plain values. Words follow GLOSSARY.md: ticket, blocked, gate, red check,
|
|
7
7
|
* fix loop, ticket review, final review, lens, finding, stuck, skipped ticket,
|
|
8
8
|
* limit wait, shadow mode.
|
|
9
9
|
*/
|
|
@@ -1254,13 +1254,16 @@ const notRemoved = ({
|
|
|
1254
1254
|
* the run branch's tip, then its install) once every ticket it waits on has
|
|
1255
1255
|
* pushed, and while no ticket waits to join, so it never builds on joined
|
|
1256
1256
|
* commits whose gates have not passed yet. Approved tickets join one at a
|
|
1257
|
-
* time, in the order their reviews finally approved them
|
|
1257
|
+
* time, in the order their reviews finally approved them, except that one
|
|
1258
|
+
* already joining finishes first. A clash or failed
|
|
1258
1259
|
* gates after joining puts the ticket's change back on top of the run
|
|
1259
|
-
* branch to be fixed there (up to `MAX_REJOINS` times), then re-reviewed.
|
|
1260
|
-
* stuck ticket
|
|
1261
|
-
*
|
|
1262
|
-
*
|
|
1263
|
-
*
|
|
1260
|
+
* branch to be fixed there (up to `MAX_REJOINS` times), then re-reviewed.
|
|
1261
|
+
* While a stuck ticket's join is still on the run branch, no ticket starts
|
|
1262
|
+
* or joins until it is undone (#519), so no join lands on top of a join
|
|
1263
|
+
* that is going away. A stuck ticket ends the run: no ticket starts or
|
|
1264
|
+
* moves on, and the worktrees of pushed tickets are removed. Once every
|
|
1265
|
+
* ticket pushed, the final review looks at the whole run branch
|
|
1266
|
+
* (`decideFinalReview`) before the PR opens; a stuck final review ends the run with the run branch's worktree kept. In a
|
|
1264
1267
|
* repo with changesets, the run's changeset is written just before the PR
|
|
1265
1268
|
* (`write_changeset`), once. Once
|
|
1266
1269
|
* the PR is open, every worktree is removed. Branches and the journal stay.
|
|
@@ -1450,6 +1453,8 @@ const buildSteps = ({
|
|
|
1450
1453
|
}
|
|
1451
1454
|
}
|
|
1452
1455
|
|
|
1456
|
+
// A ticket already on the run branch finishes its join first (#519): a
|
|
1457
|
+
// retried ticket approved earlier never joins on top of it.
|
|
1453
1458
|
const queue = sortBy(
|
|
1454
1459
|
numbers.filter(
|
|
1455
1460
|
(number) =>
|
|
@@ -1457,16 +1462,20 @@ const buildSteps = ({
|
|
|
1457
1462
|
!isStuck(number) &&
|
|
1458
1463
|
!skipped(number)
|
|
1459
1464
|
),
|
|
1460
|
-
|
|
1465
|
+
[
|
|
1466
|
+
(number) => progress(number).joined === null,
|
|
1467
|
+
(number) => progress(number).approved_seq,
|
|
1468
|
+
]
|
|
1461
1469
|
)
|
|
1462
|
-
|
|
1463
|
-
//
|
|
1470
|
+
// A stuck ticket's join is undone before any new ticket starts on it
|
|
1471
|
+
// or joins on top of it (#519): a join on top would be undone with it.
|
|
1464
1472
|
const stuckJoinOnRunBranch = numbers.some(
|
|
1465
1473
|
(number) =>
|
|
1466
1474
|
isStuck(number) &&
|
|
1467
1475
|
progress(number).joined?.ok === true &&
|
|
1468
1476
|
progress(number).pushed === null
|
|
1469
1477
|
)
|
|
1478
|
+
const joiner = stuckJoinOnRunBranch ? null : (queue[0] ?? null)
|
|
1470
1479
|
|
|
1471
1480
|
const steps = numbers.flatMap((number): BuildAction[] => {
|
|
1472
1481
|
const ticket = snapshot.tickets[number]
|
|
@@ -137,7 +137,9 @@ const awaitingReply = (progress: TicketProgress): boolean =>
|
|
|
137
137
|
/**
|
|
138
138
|
* The next steps of one stuck ticket: undo a join its stuck left on the run
|
|
139
139
|
* branch (so no other ticket builds on it), tell the spec issue, then act
|
|
140
|
-
* on the owner's reply. Nothing while it waits for one.
|
|
140
|
+
* on the owner's reply. Nothing while it waits for one. A ticket stuck
|
|
141
|
+
* because its undo was refused (`undo_refused`, #519) is never undone again
|
|
142
|
+
* before a reply: it is told at once, with its join left where it is.
|
|
141
143
|
*/
|
|
142
144
|
export const stuckTicketSteps = ({
|
|
143
145
|
spec_number,
|
|
@@ -151,7 +153,11 @@ export const stuckTicketSteps = ({
|
|
|
151
153
|
const { stuck, joined } = progress
|
|
152
154
|
if (stuck === null) return []
|
|
153
155
|
const number = ticket.number
|
|
154
|
-
if (
|
|
156
|
+
if (
|
|
157
|
+
joined?.ok === true &&
|
|
158
|
+
progress.pushed === null &&
|
|
159
|
+
stuck.reason !== 'undo_refused'
|
|
160
|
+
) {
|
|
155
161
|
const first_sha = joined.shas[0]
|
|
156
162
|
if (first_sha !== undefined) {
|
|
157
163
|
return [{ type: 'undo_join', ticket: number, first_sha }]
|
|
@@ -949,11 +949,37 @@ const undoCutOffJoin = async ({
|
|
|
949
949
|
await context.git.resetWorktree({ cwd: runBranch.path, to: sha })
|
|
950
950
|
}
|
|
951
951
|
|
|
952
|
+
/**
|
|
953
|
+
* The run branch commits an undo of `ticket`'s join may take (#519): its
|
|
954
|
+
* own joined commits, and those of any other ticket that joined and has not
|
|
955
|
+
* pushed (a later join on top goes with it, and replay clears that join
|
|
956
|
+
* too). A pushed ticket's commits never.
|
|
957
|
+
*/
|
|
958
|
+
const undoableCommits = ({
|
|
959
|
+
state,
|
|
960
|
+
ticket,
|
|
961
|
+
}: {
|
|
962
|
+
state: RunState
|
|
963
|
+
ticket: number
|
|
964
|
+
}): string[] =>
|
|
965
|
+
uniq(
|
|
966
|
+
Object.entries(state.tickets).flatMap(([number, progress]) => {
|
|
967
|
+
const { joined } = progress
|
|
968
|
+
if (!joined?.ok) return []
|
|
969
|
+
const own = Number(number) === ticket
|
|
970
|
+
return own || progress.pushed === null ? joined.shas : []
|
|
971
|
+
})
|
|
972
|
+
)
|
|
973
|
+
|
|
952
974
|
/**
|
|
953
975
|
* Undoes a ticket's join on the run branch, back to before `first_sha`, and
|
|
954
|
-
* returns the commits undone.
|
|
955
|
-
*
|
|
956
|
-
*
|
|
976
|
+
* returns the commits undone. It never rewinds over other work (#519): it
|
|
977
|
+
* may only undo the ticket's own join and later joins not pushed yet
|
|
978
|
+
* (`undoableCommits`). When git refuses, the run branch is left alone, the
|
|
979
|
+
* ticket is stuck (`undo_refused`) with why, and it returns `null`, so the
|
|
980
|
+
* caller does nothing more. The undone joins' install is still in the run
|
|
981
|
+
* branch's `node_modules`, so when their commits changed dependency files
|
|
982
|
+
* the lockfile's install runs there again.
|
|
957
983
|
*/
|
|
958
984
|
const undoJoin = async ({
|
|
959
985
|
context,
|
|
@@ -963,20 +989,45 @@ const undoJoin = async ({
|
|
|
963
989
|
context: BuildContext
|
|
964
990
|
ticket: number
|
|
965
991
|
first_sha: string
|
|
966
|
-
}): Promise<string[]> => {
|
|
967
|
-
const { git, state, journal } = context
|
|
992
|
+
}): Promise<string[] | null> => {
|
|
993
|
+
const { git, state, journal, launcher } = context
|
|
968
994
|
const runBranch = need({ value: state.run_branch, what: 'run branch' })
|
|
969
995
|
const joined = state.tickets[ticket]?.joined
|
|
970
|
-
const
|
|
996
|
+
const tip = await git.head({ cwd: runBranch.path })
|
|
997
|
+
const undo = await git.undoReplay({
|
|
998
|
+
cwd: runBranch.path,
|
|
999
|
+
first_sha,
|
|
1000
|
+
only: undoableCommits({ state, ticket }),
|
|
1001
|
+
})
|
|
1002
|
+
if (!undo.ok) {
|
|
1003
|
+
// A stuck ticket's agents take no more follow-ups.
|
|
1004
|
+
await closeSessions({
|
|
1005
|
+
journal,
|
|
1006
|
+
launcher,
|
|
1007
|
+
sessions: openSessionsIn({ records: journal.read() }).filter(
|
|
1008
|
+
(session) => session.ticket === ticket
|
|
1009
|
+
),
|
|
1010
|
+
})
|
|
1011
|
+
journal.append({
|
|
1012
|
+
kind: 'ticket_stuck',
|
|
1013
|
+
ticket,
|
|
1014
|
+
role: null,
|
|
1015
|
+
content: {
|
|
1016
|
+
reason: 'undo_refused',
|
|
1017
|
+
detail: `The engine went to undo this ticket's join on the run branch (from ${first_sha.slice(0, 7)}), but that would have rewound the run branch over other work, so the run branch was left alone:\n${undo.error}`,
|
|
1018
|
+
},
|
|
1019
|
+
})
|
|
1020
|
+
return null
|
|
1021
|
+
}
|
|
1022
|
+
const { undone } = undo
|
|
971
1023
|
const undoneFiles =
|
|
972
|
-
|
|
1024
|
+
undone.length === 0
|
|
973
1025
|
? []
|
|
974
1026
|
: await git.filesBetween({
|
|
975
1027
|
cwd: runBranch.path,
|
|
976
1028
|
from: `${first_sha}^`,
|
|
977
|
-
to:
|
|
1029
|
+
to: tip,
|
|
978
1030
|
})
|
|
979
|
-
const { undone } = await git.undoReplay({ cwd: runBranch.path, first_sha })
|
|
980
1031
|
// A redo whose first try undid the join before a crash finds nothing
|
|
981
1032
|
// left to undo: the commits it undid are the journaled join's.
|
|
982
1033
|
const named =
|
|
@@ -999,7 +1050,8 @@ const undoJoin = async ({
|
|
|
999
1050
|
* join first if asked (its gates failed), then resets the ticket's worktree
|
|
1000
1051
|
* to the run branch's tip and applies the ticket's whole diff there,
|
|
1001
1052
|
* uncommitted, with conflict markers in the files that clash. Journals the
|
|
1002
|
-
* clashed files split into tests and code.
|
|
1053
|
+
* clashed files split into tests and code. A refused undo (#519) leaves the
|
|
1054
|
+
* ticket stuck, and nothing is rebased.
|
|
1003
1055
|
*/
|
|
1004
1056
|
const rebaseTicket = async ({
|
|
1005
1057
|
context,
|
|
@@ -1025,6 +1077,8 @@ const rebaseTicket = async ({
|
|
|
1025
1077
|
ticket: action.ticket,
|
|
1026
1078
|
first_sha: action.undo_first_sha,
|
|
1027
1079
|
})
|
|
1080
|
+
// A refused undo left the run branch alone and the ticket stuck.
|
|
1081
|
+
if (undone === null) return
|
|
1028
1082
|
const onto = await git.head({ cwd: runBranch.path })
|
|
1029
1083
|
const moved_files = await git.filesBetween({
|
|
1030
1084
|
cwd: worktree.path,
|
|
@@ -1458,20 +1512,22 @@ export const executeBuildAction = async ({
|
|
|
1458
1512
|
})
|
|
1459
1513
|
return
|
|
1460
1514
|
}
|
|
1461
|
-
case 'undo_join':
|
|
1515
|
+
case 'undo_join': {
|
|
1516
|
+
const shas = await undoJoin({
|
|
1517
|
+
context,
|
|
1518
|
+
ticket: action.ticket,
|
|
1519
|
+
first_sha: action.first_sha,
|
|
1520
|
+
})
|
|
1521
|
+
// A refused undo left the run branch alone and the ticket stuck.
|
|
1522
|
+
if (shas === null) return
|
|
1462
1523
|
journal.append({
|
|
1463
1524
|
kind: 'join_undone',
|
|
1464
1525
|
ticket: action.ticket,
|
|
1465
1526
|
role: null,
|
|
1466
|
-
content: {
|
|
1467
|
-
shas: await undoJoin({
|
|
1468
|
-
context,
|
|
1469
|
-
ticket: action.ticket,
|
|
1470
|
-
first_sha: action.first_sha,
|
|
1471
|
-
}),
|
|
1472
|
-
},
|
|
1527
|
+
content: { shas },
|
|
1473
1528
|
})
|
|
1474
1529
|
return
|
|
1530
|
+
}
|
|
1475
1531
|
case 'retry_ticket':
|
|
1476
1532
|
return retryTicket({ context, action })
|
|
1477
1533
|
case 'report_stuck':
|
|
@@ -37,6 +37,8 @@ const REASON_LINES: Record<StuckReason, string> = {
|
|
|
37
37
|
setup_change_needed:
|
|
38
38
|
'An agent needs a test setup file changed, and only you may change one.',
|
|
39
39
|
crashed: 'The engine crashed in the same step, again and again.',
|
|
40
|
+
undo_refused:
|
|
41
|
+
'Undoing its join would have rewound the run branch over other work, so the run branch was left alone.',
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
/** What the user could do, per reason; `n` is the ticket. */
|
|
@@ -75,6 +77,8 @@ const suggestion = ({
|
|
|
75
77
|
return `Make that change yourself in the worktree, then reply ${retry}.`
|
|
76
78
|
case 'crashed':
|
|
77
79
|
return `Read the engine's log for why it crashed, fix what trips it, then reply ${retry} to take the step again with a fresh agent.`
|
|
80
|
+
case 'undo_refused':
|
|
81
|
+
return `Put the run branch back so this ticket's join is on top (or take its join off by hand), then reply ${retry}; or reply \`skip #${n}\`.`
|
|
78
82
|
}
|
|
79
83
|
}
|
|
80
84
|
|
|
@@ -134,14 +134,21 @@ export type GitAdapter = {
|
|
|
134
134
|
}) => Promise<{ ok: true; shas: string[] } | { ok: false; error: string }>
|
|
135
135
|
/**
|
|
136
136
|
* Undoes a replay on the branch checked out at `cwd`: resets it hard to
|
|
137
|
-
* the commit before `first_sha`.
|
|
137
|
+
* the commit before `first_sha`. It never rewinds over other work
|
|
138
|
+
* (#519), so it refuses, with the branch left as it is, when
|
|
139
|
+
* `first_sha` is no commit (or has no parent), is not on the branch, or
|
|
140
|
+
* when any commit from `first_sha` up to the branch's tip is not in
|
|
141
|
+
* `only`. Safe to run twice: with the branch already at the commit
|
|
142
|
+
* before `first_sha`, it undoes nothing.
|
|
138
143
|
*
|
|
139
|
-
* @returns The commits it undid, oldest first.
|
|
144
|
+
* @returns The commits it undid, oldest first, or why it refused.
|
|
140
145
|
*/
|
|
141
146
|
undoReplay: (args: {
|
|
142
147
|
cwd: string
|
|
143
148
|
first_sha: string
|
|
144
|
-
|
|
149
|
+
/** The commits it may undo: any other on top of `first_sha` refuses. */
|
|
150
|
+
only: string[]
|
|
151
|
+
}) => Promise<{ ok: true; undone: string[] } | { ok: false; error: string }>
|
|
145
152
|
/**
|
|
146
153
|
* Moves a worktree's change onto another commit: resets the worktree at
|
|
147
154
|
* `cwd` hard to `onto`, then applies the whole diff from `from` to `to`
|
|
@@ -535,16 +542,52 @@ export const createGitAdapter = ({
|
|
|
535
542
|
),
|
|
536
543
|
}
|
|
537
544
|
},
|
|
538
|
-
undoReplay: async ({ cwd, first_sha }) => {
|
|
539
|
-
const
|
|
545
|
+
undoReplay: async ({ cwd, first_sha, only }) => {
|
|
546
|
+
const short = first_sha.slice(0, 7)
|
|
547
|
+
const parent = await gitRun({
|
|
548
|
+
cwd,
|
|
549
|
+
args: [
|
|
550
|
+
'rev-parse',
|
|
551
|
+
'--verify',
|
|
552
|
+
'--quiet',
|
|
553
|
+
`${first_sha}^{commit}^`,
|
|
554
|
+
],
|
|
555
|
+
})
|
|
556
|
+
if (parent.exit_code !== 0) {
|
|
557
|
+
return {
|
|
558
|
+
ok: false,
|
|
559
|
+
error: `Commit ${short} is not in the repo (or has no parent), so there is no join to undo.`,
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
const before = parent.stdout.trim()
|
|
563
|
+
const tip = await head({ cwd })
|
|
564
|
+
const ancestor = await gitRun({
|
|
565
|
+
cwd,
|
|
566
|
+
args: ['merge-base', '--is-ancestor', first_sha, 'HEAD'],
|
|
567
|
+
})
|
|
568
|
+
if (ancestor.exit_code !== 0) {
|
|
569
|
+
// Already undone, such as a redo after a crash.
|
|
570
|
+
if (tip === before) return { ok: true, undone: [] }
|
|
571
|
+
return {
|
|
572
|
+
ok: false,
|
|
573
|
+
error: `Commit ${short} is not on the branch, so undoing it (a reset to its parent) would rewind the branch over other work.`,
|
|
574
|
+
}
|
|
575
|
+
}
|
|
540
576
|
const undone = lines(
|
|
541
577
|
await gitOk({
|
|
542
578
|
cwd,
|
|
543
579
|
args: ['rev-list', '--reverse', `${before}..HEAD`],
|
|
544
580
|
})
|
|
545
581
|
)
|
|
582
|
+
const others = undone.filter((sha) => !only.includes(sha))
|
|
583
|
+
if (others.length > 0) {
|
|
584
|
+
return {
|
|
585
|
+
ok: false,
|
|
586
|
+
error: `Undoing ${short} would also undo other work on top of it: ${others.map((sha) => sha.slice(0, 7)).join(', ')}.`,
|
|
587
|
+
}
|
|
588
|
+
}
|
|
546
589
|
await gitOk({ cwd, args: ['reset', '--quiet', '--hard', before] })
|
|
547
|
-
return { undone }
|
|
590
|
+
return { ok: true, undone }
|
|
548
591
|
},
|
|
549
592
|
rebaseWorktree: async ({ cwd, from, to, onto }) => {
|
|
550
593
|
const folder = await mkdtemp(join(tmpdir(), 'luca-rebase-'))
|
|
@@ -684,7 +684,9 @@ export type RejoinCause = z.infer<typeof RejoinCauseSchema>
|
|
|
684
684
|
* and `code` are the files that clashed (conflict markers left in them),
|
|
685
685
|
* split by the config's test file patterns. `undone` are the run branch
|
|
686
686
|
* commits the engine undid first (a join whose gates failed), and
|
|
687
|
-
* `reinstall` says whether the worktree needs its install again.
|
|
687
|
+
* `reinstall` says whether the worktree needs its install again. As with
|
|
688
|
+
* `join_undone`, replay clears the join of any other ticket whose joined
|
|
689
|
+
* commits are among `undone` (#519).
|
|
688
690
|
*/
|
|
689
691
|
const TicketRebasedEntrySchema = z.object({
|
|
690
692
|
...ENTRY_FIELDS,
|
|
@@ -751,6 +753,8 @@ const RunBranchPushedEntrySchema = z.object({
|
|
|
751
753
|
/**
|
|
752
754
|
* Why a ticket (or the final review) is stuck. A failed red check or gate is only stuck once its
|
|
753
755
|
* fix loop reaches its cap, and a bad test only on its second bounce.
|
|
756
|
+
* `undo_refused`: undoing its join would have rewound the run branch over
|
|
757
|
+
* other work, so the engine left the run branch alone (#519).
|
|
754
758
|
*/
|
|
755
759
|
export const StuckReasonSchema = z.enum([
|
|
756
760
|
'agent_failed',
|
|
@@ -766,6 +770,7 @@ export const StuckReasonSchema = z.enum([
|
|
|
766
770
|
'prepare_failed',
|
|
767
771
|
'setup_change_needed',
|
|
768
772
|
'crashed',
|
|
773
|
+
'undo_refused',
|
|
769
774
|
])
|
|
770
775
|
|
|
771
776
|
export type StuckReason = z.infer<typeof StuckReasonSchema>
|
|
@@ -956,7 +961,11 @@ const FinalReviewRetriedEntrySchema = z.object({
|
|
|
956
961
|
|
|
957
962
|
/**
|
|
958
963
|
* A stuck ticket's join was undone on the run branch (its commits were
|
|
959
|
-
* never pushed), so no other ticket builds on them.
|
|
964
|
+
* never pushed), so no other ticket builds on them. `shas` are every commit
|
|
965
|
+
* the undo took off the run branch, oldest first: the ticket's own join and
|
|
966
|
+
* any later, unpushed joins on top of it (#519). Replay clears the join of
|
|
967
|
+
* every ticket whose joined commits are among them, not only this one's, so
|
|
968
|
+
* those tickets join again on the run branch as it is now.
|
|
960
969
|
*/
|
|
961
970
|
const JoinUndoneEntrySchema = z.object({
|
|
962
971
|
...ENTRY_FIELDS,
|
package/engine/journal/replay.ts
CHANGED
|
@@ -1208,6 +1208,18 @@ const applyRecord = ({
|
|
|
1208
1208
|
state: { ...next, run_notes: notesAfter({ state, record }) },
|
|
1209
1209
|
record,
|
|
1210
1210
|
})
|
|
1211
|
+
case 'join_undone':
|
|
1212
|
+
return undoneJoinsAfter({
|
|
1213
|
+
state: applyTicketRecord({ state: next, record }),
|
|
1214
|
+
ticket: record.ticket,
|
|
1215
|
+
shas: record.content.shas,
|
|
1216
|
+
})
|
|
1217
|
+
case 'ticket_rebased':
|
|
1218
|
+
return undoneJoinsAfter({
|
|
1219
|
+
state: applyTicketRecord({ state: next, record }),
|
|
1220
|
+
ticket: record.ticket,
|
|
1221
|
+
shas: record.content.undone,
|
|
1222
|
+
})
|
|
1211
1223
|
// Always the final review's (see finalRecordOf above).
|
|
1212
1224
|
case 'final_review_started':
|
|
1213
1225
|
case 'lens_started':
|
|
@@ -2278,8 +2290,8 @@ const retriedChange = ({
|
|
|
2278
2290
|
* - in a review fix round, the round starts over as round 1 with fresh
|
|
2279
2291
|
* fixers, so the user's changes are gated, committed, and re-reviewed;
|
|
2280
2292
|
* - a leftover scan, a failed install, a failed prepare command at the
|
|
2281
|
-
* baseline, a reviewer's failed tries, a failed join,
|
|
2282
|
-
* cut off just run again.
|
|
2293
|
+
* baseline, a reviewer's failed tries, a failed join, a refused undo of
|
|
2294
|
+
* its join (#519), or a step crashes cut off just run again.
|
|
2283
2295
|
*/
|
|
2284
2296
|
const resumedProgress = ({
|
|
2285
2297
|
progress,
|
|
@@ -2320,6 +2332,7 @@ const resumedProgress = ({
|
|
|
2320
2332
|
reason === 'prepare_failed' ||
|
|
2321
2333
|
reason === 'join_failed' ||
|
|
2322
2334
|
reason === 'join_gates_failed' ||
|
|
2335
|
+
reason === 'undo_refused' ||
|
|
2323
2336
|
reason === 'crashed' ||
|
|
2324
2337
|
reviewerFailed
|
|
2325
2338
|
) {
|
|
@@ -2564,6 +2577,41 @@ const closedSessionAfter = ({
|
|
|
2564
2577
|
}
|
|
2565
2578
|
}
|
|
2566
2579
|
|
|
2580
|
+
/**
|
|
2581
|
+
* The other tickets whose joins an undo took off the run branch (#519): an
|
|
2582
|
+
* undo resets the run branch to before the undone ticket's join, so any
|
|
2583
|
+
* join on top of it goes too. Each such ticket's join and join gates are
|
|
2584
|
+
* cleared, as its own undo would, so it joins again on the run branch as it
|
|
2585
|
+
* is now, and a stuck one is never undone a second time. Its push, stuck,
|
|
2586
|
+
* and reply are kept.
|
|
2587
|
+
*/
|
|
2588
|
+
const undoneJoinsAfter = ({
|
|
2589
|
+
state,
|
|
2590
|
+
ticket,
|
|
2591
|
+
shas,
|
|
2592
|
+
}: {
|
|
2593
|
+
state: RunState
|
|
2594
|
+
/** The ticket whose join was undone. */
|
|
2595
|
+
ticket: number | null
|
|
2596
|
+
/** Every commit the undo took off the run branch. */
|
|
2597
|
+
shas: string[]
|
|
2598
|
+
}): RunState => {
|
|
2599
|
+
if (shas.length === 0) return state
|
|
2600
|
+
return {
|
|
2601
|
+
...state,
|
|
2602
|
+
tickets: mapValues(state.tickets, (progress, number) => {
|
|
2603
|
+
const { joined } = progress
|
|
2604
|
+
const taken =
|
|
2605
|
+
Number(number) !== ticket &&
|
|
2606
|
+
joined?.ok === true &&
|
|
2607
|
+
joined.shas.some((sha) => shas.includes(sha))
|
|
2608
|
+
return taken
|
|
2609
|
+
? { ...progress, joined: null, join_gates: null }
|
|
2610
|
+
: progress
|
|
2611
|
+
}),
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2567
2615
|
const applyTicketRecord = ({
|
|
2568
2616
|
state,
|
|
2569
2617
|
record,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alecsibilia/luca",
|
|
3
3
|
"description": "Luca: turns a spec on GitHub Issues into a reviewed pull request, run from Paseo with Claude Code",
|
|
4
|
-
"version": "14.0.0-alpha.
|
|
4
|
+
"version": "14.0.0-alpha.9",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Alec Sibilia <sibilia.alec@gmail.com>",
|
|
7
7
|
"homepage": "https://github.com/asibilia/luca-framework#readme",
|
|
@@ -72,6 +72,7 @@ Find the stuck reason: `ticket_stuck` (a ticket), `final_review_stuck` (the fina
|
|
|
72
72
|
| `leftovers_found` | The leftover scan found files that must not be committed (a report file, a nested git repo). | Delete or move those files out of the worktree (they're not the ticket's work), then `retry #n`. |
|
|
73
73
|
| `setup_change_needed` | An agent needs a test setup file changed, and only a person may do that. | Read what it asks for. If it's right, make that change yourself in the worktree (ask the owner first: this one isn't a test file), then `retry #n`. |
|
|
74
74
|
| `crashed` | The engine crashed in the same step, again and again. | That's an engine bug, not the ticket (see below). |
|
|
75
|
+
| `undo_refused` | The ticket's join is still on the run branch, but undoing it would have rewound the run branch over other work (its join isn't on the branch, or other commits sit on top of it), so Luca left the run branch alone. Nothing new starts or joins until it's sorted (#519). | Look at the run branch's log next to the joins in the journal. Put the run branch back so the ticket's join is on top, or take its join off by hand (ask the owner first), then `retry #n`. Or `skip #n`. |
|
|
75
76
|
| `run_budget` | The whole run used up its budget of tokens. | Look at what used them (a ticket stuck in fix loops?). A bare `retry` gives one more full budget; `stop` ends the run. |
|
|
76
77
|
| `pull_request_failed` | Every ticket is done, but `gh pr create` failed, so the run's PR didn't open. The run branch is pushed, and the PR's full text is in the run folder's `pull-request.md`. | Read `gh`'s error. A GitHub or sign-in hiccup: a bare `retry` opens the PR. If you open it by hand from the run branch first, `retry` reuses it and never opens a second one. |
|
|
77
78
|
|