@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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # @alecsibilia/luca
2
2
 
3
+ ## 14.0.0-alpha.9
4
+
5
+ No changes in this release.
6
+
7
+ ## 14.0.0-alpha.8
8
+
9
+ No changes in this release.
10
+
3
11
  ## 14.0.0-alpha.7
4
12
 
5
13
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luca/board",
3
- "version": "14.0.0-alpha.7",
3
+ "version": "14.0.0-alpha.9",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -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.7"
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 CONTEXT.md: ticket, blocked, gate, red check,
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. A clash or failed
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. A
1260
- * stuck ticket ends the run: no ticket starts or moves on, and the worktrees
1261
- * of pushed tickets are removed. Once every ticket pushed, the final review
1262
- * looks at the whole run branch (`decideFinalReview`) before the PR opens; a
1263
- * stuck final review ends the run with the run branch's worktree kept. In a
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
- (number) => progress(number).approved_seq
1465
+ [
1466
+ (number) => progress(number).joined === null,
1467
+ (number) => progress(number).approved_seq,
1468
+ ]
1461
1469
  )
1462
- const joiner = queue[0] ?? null
1463
- // A stuck ticket's join is undone before any new ticket starts on it.
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 (joined?.ok === true && progress.pushed === null) {
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. The undone join's install is still in the run
955
- * branch's `node_modules`, so when its commits changed dependency files the
956
- * lockfile's install runs there again.
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 last = joined?.ok ? joined.shas.at(-1) : undefined
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
- last === undefined
1024
+ undone.length === 0
973
1025
  ? []
974
1026
  : await git.filesBetween({
975
1027
  cwd: runBranch.path,
976
1028
  from: `${first_sha}^`,
977
- to: last,
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`. Safe to run twice.
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
- }) => Promise<{ undone: string[] }>
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 before = `${first_sha}^`
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,
@@ -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, or a step crashes
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.7",
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