@alecsibilia/luca 14.0.0-alpha.6 → 14.0.0-alpha.8

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.8
4
+
5
+ No changes in this release.
6
+
7
+ ## 14.0.0-alpha.7
8
+
9
+ No changes in this release.
10
+
3
11
  ## 14.0.0-alpha.6
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.6",
3
+ "version": "14.0.0-alpha.8",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -149,6 +149,15 @@ export const BOARD_VOCABULARY = {
149
149
  }),
150
150
  /** The engine started again on the run's journal, on Luca's `luca_version`. */
151
151
  engine_resumed: z.looseObject({ luca_version: z.string() }),
152
+ /**
153
+ * A resume took the repo's new build fields (#516); the board reads
154
+ * only which fields changed.
155
+ */
156
+ config_reloaded: z.looseObject({
157
+ changes: z.array(z.looseObject({ field: z.string() })),
158
+ }),
159
+ /** A resume kept the run's config, as the repo's couldn't be used. */
160
+ config_reload_refused: z.looseObject({ reason: z.string() }),
152
161
  intake_read: z.looseObject({ spec: SpecIssueSchema }),
153
162
  intake_refused: z.looseObject({
154
163
  problems: z.array(
@@ -448,6 +457,8 @@ const entry = <Kind extends BoardKind>({ kind }: { kind: Kind }) =>
448
457
  const BoardEntrySchema = z.discriminatedUnion('kind', [
449
458
  entry({ kind: 'run_started' }),
450
459
  entry({ kind: 'engine_resumed' }),
460
+ entry({ kind: 'config_reloaded' }),
461
+ entry({ kind: 'config_reload_refused' }),
451
462
  entry({ kind: 'intake_read' }),
452
463
  entry({ kind: 'intake_refused' }),
453
464
  entry({ kind: 'nothing_to_do' }),
@@ -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.6"
2
+ export const LUCA_VERSION: string = "14.0.0-alpha.8"
@@ -252,6 +252,16 @@ export const describeRecord = ({
252
252
  after.run.version_note !== before.run.version_note
253
253
  ? event({ text: after.run.version_note, tone: 'warning' })
254
254
  : null
255
+ case 'config_reloaded':
256
+ return event({
257
+ text: `Config reloaded: ${record.content.changes.map(({ field }) => field).join(', ')} changed.`,
258
+ tone: 'info',
259
+ })
260
+ case 'config_reload_refused':
261
+ return event({
262
+ text: `Config not reloaded, so the run keeps its own: ${record.content.reason.split('\n')[0] ?? ''}`,
263
+ tone: 'warning',
264
+ })
255
265
  case 'intake_refused':
256
266
  return event({
257
267
  text: `Intake refused the run. ${after.run.refusal.join('; ')}`,
@@ -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
  /**
@@ -863,6 +865,10 @@ const applyKind = ({
863
865
  }),
864
866
  },
865
867
  }
868
+ // A resume's config reload (#516) is only a chat row.
869
+ case 'config_reloaded':
870
+ case 'config_reload_refused':
871
+ return state
866
872
  case 'intake_read':
867
873
  return {
868
874
  ...state,
@@ -13,6 +13,7 @@ import { loadEngineConfig } from '../config/engine-config'
13
13
  import { lucaVersion } from '../config/luca-version'
14
14
  import { decide, type EngineAction } from '../core/decide'
15
15
  import {
16
+ recordConfigReload,
16
17
  recordResume,
17
18
  runEngine,
18
19
  startRun,
@@ -210,13 +211,46 @@ const driveRun = async ({
210
211
  return end
211
212
  }
212
213
 
214
+ /** A value of a reloaded config field in a log line; `null` is "none". */
215
+ const fieldValue = (value: string | number | null): string =>
216
+ value === null ? 'none' : String(value)
217
+
218
+ /**
219
+ * The log line for a resume's `config_reloaded` or `config_reload_refused`
220
+ * (#516).
221
+ *
222
+ * @example
223
+ * configReloadText({ reload }) // 'config reloaded: prepare changed (make rom -> make rom-cached)'
224
+ */
225
+ export const configReloadText = ({
226
+ reload,
227
+ }: {
228
+ reload: JournalRecord
229
+ }): string => {
230
+ switch (reload.kind) {
231
+ case 'config_reloaded':
232
+ return `config reloaded: ${reload.content.changes
233
+ .map(
234
+ ({ field, from, to }) =>
235
+ `${field} changed (${fieldValue(from)} -> ${fieldValue(to)})`
236
+ )
237
+ .join(', ')}`
238
+ case 'config_reload_refused':
239
+ return `kept the run's config, as the repo's can't be used: ${reload.content.reason}`
240
+ default:
241
+ return reload.kind
242
+ }
243
+ }
244
+
213
245
  /**
214
246
  * A real run of `spec_number` on `repo`: loads the repo's engine config,
215
247
  * opens (or resumes) the run's journal at `<runs_dir>/<run_id>`, and runs the
216
248
  * engine with the board kept in step, the agents from `launcher`, and Jev
217
249
  * in shadow mode if given. With `memory`, a new run turns memory on (#370),
218
250
  * with the engine config's `muninn.vault` as the project vault (none:
219
- * `default` only); a resumed run keeps what its journal says. Never throws;
251
+ * `default` only); a resumed run keeps what its journal says. A new run
252
+ * stops on a bad config; a resume takes the config's build fields again
253
+ * and keeps its own on a bad one (`recordConfigReload`, #516). Never throws;
220
254
  * the launcher's sessions and the memory client are closed and the board
221
255
  * always gets the run's end.
222
256
  *
@@ -268,7 +302,9 @@ export const runSpec = async ({
268
302
  log(`[luca-run] spec #${spec_number} in ${repo}, run ${run_id}`)
269
303
  log(`[luca-run] journal: ${journal.file}`)
270
304
  const loaded = await loadEngineConfig({ repo_root: repo })
271
- if (!loaded.ok) {
305
+ const fresh = isEmpty(journal.read())
306
+ // A new run needs a good config; a resume keeps its own (#516).
307
+ if (fresh && !loaded.ok) {
272
308
  log(`[luca-run] stopped: ${loaded.error}`)
273
309
  await launcher.closeAll?.()
274
310
  await memory?.client.close().catch(() => undefined)
@@ -276,7 +312,7 @@ export const runSpec = async ({
276
312
  await board?.end({ records: journal.read(), ...end })
277
313
  return end
278
314
  }
279
- if (isEmpty(journal.read())) {
315
+ if (fresh && loaded.ok) {
280
316
  startRun({
281
317
  journal,
282
318
  spec_number,
@@ -292,6 +328,8 @@ export const runSpec = async ({
292
328
  } else {
293
329
  log('[luca-run] resuming the run from its journal')
294
330
  recordResume({ journal, luca_version })
331
+ const reload = recordConfigReload({ journal, loaded })
332
+ if (reload !== null) log(`[luca-run] ${configReloadText({ reload })}`)
295
333
  }
296
334
  return driveRun({
297
335
  journal,
@@ -0,0 +1,160 @@
1
+ import omit from 'lodash/omit'
2
+ import reduce from 'lodash/reduce'
3
+ import { z } from 'zod'
4
+
5
+ import type { EngineConfig } from './engine-config'
6
+
7
+ /**
8
+ * The config fields a resumed run takes from the repo's
9
+ * `.luca/config.json` again (#516). They say how the engine builds a
10
+ * checkout before its tests, not what the tests are, so a run can change
11
+ * them halfway without making its earlier results wrong: a repo that swaps
12
+ * a slow `prepare` for a fast cached one gets it on the next resume.
13
+ *
14
+ * - `prepare`: the command run before each test run in a checkout.
15
+ * - `prepare_timeout_ms`: how long that command may run.
16
+ * - `prepare_concurrency`: how many prepare runs may go at once. The
17
+ * prepare slots take the limit on each call (`prepareCheck`), so a new
18
+ * one counts from the next prepare run.
19
+ */
20
+ export const RELOADED_FIELDS = [
21
+ 'prepare',
22
+ 'prepare_timeout_ms',
23
+ 'prepare_concurrency',
24
+ ] as const
25
+
26
+ /**
27
+ * The config fields a run keeps from its `run_started`, whatever the file
28
+ * says on a resume:
29
+ *
30
+ * - `checks`: the baseline, the red check, and the gates compare test
31
+ * results across the run. A ticket takes another's baseline by commit
32
+ * alone (`sharedBaselineStep` in `core/decide-build.ts`), and the red
33
+ * check holds every old passing test of the baseline to the new run by
34
+ * file and name (`checkRed` in `gates/red-check.ts`). A baseline taken
35
+ * with old commands is not comparable with a run of new ones. Intake also
36
+ * judged the run's tickets against these commands (a `bun` test command
37
+ * for the red check).
38
+ * - `test_file_patterns`: which files are tests decides the red check, the
39
+ * role rules, and which commits were the test-writer's.
40
+ * - `test_setup_files`: part of what the tests are, as `checks` is.
41
+ * - `rule_files`: the rules lens judges against them; tickets already
42
+ * reviewed were judged on the old ones.
43
+ * - `muninn`: the run's memory vault is fixed at start (`run_started.memory`).
44
+ * - `run_budget_tokens`: the budget the run's tokens are counted against,
45
+ * and the board's, from `run_started`; a raise is a `retry` reply.
46
+ */
47
+ export const FROZEN_FIELDS = [
48
+ 'checks',
49
+ 'test_file_patterns',
50
+ 'test_setup_files',
51
+ 'rule_files',
52
+ 'muninn',
53
+ 'run_budget_tokens',
54
+ ] as const
55
+
56
+ const positiveInt = z.number().int().positive().nullable()
57
+
58
+ /**
59
+ * One reloaded field that changed: its value in the run before (`from`)
60
+ * and in the file now (`to`). `null` is a field left out, so a field added
61
+ * or removed is a change too.
62
+ */
63
+ export const ConfigChangeSchema = z.discriminatedUnion('field', [
64
+ z.object({
65
+ field: z.literal('prepare'),
66
+ from: z.string().min(1).nullable(),
67
+ to: z.string().min(1).nullable(),
68
+ }),
69
+ z.object({
70
+ field: z.literal('prepare_timeout_ms'),
71
+ from: positiveInt,
72
+ to: positiveInt,
73
+ }),
74
+ z.object({
75
+ field: z.literal('prepare_concurrency'),
76
+ from: positiveInt,
77
+ to: positiveInt,
78
+ }),
79
+ ])
80
+
81
+ export type ConfigChange = z.infer<typeof ConfigChangeSchema>
82
+
83
+ /**
84
+ * The reloaded fields whose value differs between the run's config
85
+ * (`current`) and the one just read (`next`), old to new, in
86
+ * `RELOADED_FIELDS` order. Frozen fields are never changes. Pure.
87
+ *
88
+ * @example
89
+ * configChanges({ current: { ...config, prepare: 'make' }, next: { ...config, prepare: 'make fast' } })
90
+ * // [{ field: 'prepare', from: 'make', to: 'make fast' }]
91
+ */
92
+ export const configChanges = ({
93
+ current,
94
+ next,
95
+ }: {
96
+ current: EngineConfig
97
+ next: EngineConfig
98
+ }): ConfigChange[] => {
99
+ const all: ConfigChange[] = [
100
+ {
101
+ field: 'prepare',
102
+ from: current.prepare ?? null,
103
+ to: next.prepare ?? null,
104
+ },
105
+ {
106
+ field: 'prepare_timeout_ms',
107
+ from: current.prepare_timeout_ms ?? null,
108
+ to: next.prepare_timeout_ms ?? null,
109
+ },
110
+ {
111
+ field: 'prepare_concurrency',
112
+ from: current.prepare_concurrency ?? null,
113
+ to: next.prepare_concurrency ?? null,
114
+ },
115
+ ]
116
+ return all.filter(({ from, to }) => from !== to)
117
+ }
118
+
119
+ const applyChange = ({
120
+ config,
121
+ change,
122
+ }: {
123
+ config: EngineConfig
124
+ change: ConfigChange
125
+ }): EngineConfig => {
126
+ switch (change.field) {
127
+ case 'prepare':
128
+ return change.to === null
129
+ ? omit(config, 'prepare')
130
+ : { ...config, prepare: change.to }
131
+ case 'prepare_timeout_ms':
132
+ return change.to === null
133
+ ? omit(config, 'prepare_timeout_ms')
134
+ : { ...config, prepare_timeout_ms: change.to }
135
+ case 'prepare_concurrency':
136
+ return change.to === null
137
+ ? omit(config, 'prepare_concurrency')
138
+ : { ...config, prepare_concurrency: change.to }
139
+ }
140
+ }
141
+
142
+ /**
143
+ * `config` with each change's new value: set, or left out for `null`. Pure.
144
+ *
145
+ * @example
146
+ * withConfigChanges({ config, changes: [{ field: 'prepare', from: 'make', to: null }] })
147
+ * // config without prepare
148
+ */
149
+ export const withConfigChanges = ({
150
+ config,
151
+ changes,
152
+ }: {
153
+ config: EngineConfig
154
+ changes: ConfigChange[]
155
+ }): EngineConfig =>
156
+ reduce(
157
+ changes,
158
+ (next, change) => applyChange({ config: next, change }),
159
+ config
160
+ )
@@ -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':
@@ -2,6 +2,7 @@ import { basename, dirname } from 'node:path'
2
2
 
3
3
  import has from 'lodash/has'
4
4
  import partition from 'lodash/partition'
5
+ import values from 'lodash/values'
5
6
 
6
7
  import { decideSteps, type EngineAction } from './decide'
7
8
  import { isFinalReviewAction } from './decide-final-review'
@@ -29,9 +30,13 @@ import {
29
30
  } from './session-close'
30
31
 
31
32
  import type { BoardSync } from '../board/board-sync'
32
- import type { EngineConfig } from '../config/engine-config'
33
+ import { configChanges } from '../config/config-reload'
34
+ import type {
35
+ EngineConfig,
36
+ LoadEngineConfigResult,
37
+ } from '../config/engine-config'
33
38
  import { lucaVersion } from '../config/luca-version'
34
- import { outsideBlockerNumbers } from '../intake/intake-checks'
39
+ import { configProblems, outsideBlockerNumbers } from '../intake/intake-checks'
35
40
  import type { IntakeProblem } from '../intake/intake-schemas'
36
41
  import { jevAsksAfter, jevAsksBefore } from '../jev/jev-jobs'
37
42
  import { askJevInShadow, type JevShadow } from '../jev/jev-shadow'
@@ -140,6 +145,62 @@ export const recordResume = ({
140
145
  content: { luca_version },
141
146
  })
142
147
 
148
+ /**
149
+ * On a resume, takes the repo's config again (#516), just read as
150
+ * `loaded`, and journals what came of it, right after `recordResume`:
151
+ *
152
+ * - a `config_reloaded` with the build fields (`RELOADED_FIELDS`) that
153
+ * changed, old to new, which replay puts on the run's config;
154
+ * - nothing, when none changed (frozen fields never count, see
155
+ * `FROZEN_FIELDS`);
156
+ * - a `config_reload_refused` with the reason, when the file is missing,
157
+ * not JSON, not a valid config, or one intake would refuse
158
+ * (`configProblems`, on the run's tickets). The run keeps its config.
159
+ *
160
+ * Never throws for a bad file: the run always goes on. Returns the record
161
+ * it appended, or `null`. A journal with no config yet (not a run) gets
162
+ * nothing; the decision step says what is wrong with it.
163
+ *
164
+ * @example
165
+ * recordResume({ journal })
166
+ * const reload = recordConfigReload({ journal, loaded: await loadEngineConfig({ repo_root }) })
167
+ */
168
+ export const recordConfigReload = ({
169
+ journal,
170
+ loaded,
171
+ }: {
172
+ journal: Journal
173
+ loaded: LoadEngineConfigResult
174
+ }): JournalRecord | null => {
175
+ const state = replayRun({ records: journal.read() })
176
+ if (state.config === null) return null
177
+ const refuse = (reason: string): JournalRecord =>
178
+ journal.append({
179
+ kind: 'config_reload_refused',
180
+ ticket: null,
181
+ role: null,
182
+ content: { reason },
183
+ })
184
+ if (!loaded.ok) return refuse(loaded.error)
185
+ const problems = configProblems({
186
+ config: loaded.config,
187
+ tickets: values(state.snapshot?.tickets ?? {}),
188
+ })
189
+ if (problems.length > 0) return refuse(problems.join('\n'))
190
+ const changes = configChanges({
191
+ current: state.config,
192
+ next: loaded.config,
193
+ })
194
+ return changes.length === 0
195
+ ? null
196
+ : journal.append({
197
+ kind: 'config_reloaded',
198
+ ticket: null,
199
+ role: null,
200
+ content: { changes },
201
+ })
202
+ }
203
+
143
204
  /** The comment a bad spec or ticket gets when intake refuses the run. */
144
205
  export const refusalComment = ({
145
206
  spec_number,
@@ -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-'))
@@ -226,6 +226,43 @@ const groupFindings = ({
226
226
  }))
227
227
  }
228
228
 
229
+ /**
230
+ * Intake's checks on the engine config, as messages for a person: a test
231
+ * command, and one with bun's per-test results unless every ticket in
232
+ * `tickets` is a refactor ticket (the red check reads per-test results,
233
+ * which only bun's give today). Pure. Empty when the config is fine.
234
+ *
235
+ * Intake runs it on the open tickets agents build; a resume runs it on the
236
+ * repo's config file before taking its new build fields (#516).
237
+ *
238
+ * @example
239
+ * configProblems({ config: { ...config, checks: {} }, tickets: [] })
240
+ * // ['The engine config (.luca/config.json) has no test command at checks.test.']
241
+ */
242
+ export const configProblems = ({
243
+ config,
244
+ tickets,
245
+ }: {
246
+ config: EngineConfig
247
+ tickets: { labels: string[] }[]
248
+ }): string[] => {
249
+ const tests = testCommands({ config })
250
+ if (tests.length === 0) {
251
+ return [
252
+ `The engine config (${ENGINE_CONFIG_FILE}) has no test command at checks.test.`,
253
+ ]
254
+ }
255
+ if (
256
+ !tests.some(({ results }) => results === 'bun') &&
257
+ tickets.some((ticket) => !ticket.labels.includes(REFACTOR_LABEL))
258
+ ) {
259
+ return [
260
+ `The engine config (${ENGINE_CONFIG_FILE}) has no test command with bun results at checks.test, so the red check can't prove new tests fail first. Add a \`bun test\` command, or label every open ticket \`${REFACTOR_LABEL}\`.`,
261
+ ]
262
+ }
263
+ return []
264
+ }
265
+
229
266
  /**
230
267
  * Intake's checks: is the spec, and every open ticket in it, ready to build?
231
268
  *
@@ -261,21 +298,8 @@ export const checkIntake = ({
261
298
  const onSpec = (message: string) =>
262
299
  findings.push({ ticket: spec.number, message })
263
300
 
264
- const tests = testCommands({ config })
265
- if (tests.length === 0) {
266
- findings.push({
267
- ticket: null,
268
- message: `The engine config (${ENGINE_CONFIG_FILE}) has no test command at checks.test.`,
269
- })
270
- } else if (
271
- !tests.some(({ results }) => results === 'bun') &&
272
- forAgents.some((ticket) => !ticket.labels.includes(REFACTOR_LABEL))
273
- ) {
274
- // The red check reads per-test results, which only bun's give today.
275
- findings.push({
276
- ticket: null,
277
- message: `The engine config (${ENGINE_CONFIG_FILE}) has no test command with bun results at checks.test, so the red check can't prove new tests fail first. Add a \`bun test\` command, or label every open ticket \`${REFACTOR_LABEL}\`.`,
278
- })
301
+ for (const message of configProblems({ config, tickets: forAgents })) {
302
+ findings.push({ ticket: null, message })
279
303
  }
280
304
  if (spec.state !== 'open') onSpec('The spec is closed.')
281
305
  if (section({ body: spec.body, heading: 'Testing Decisions' }) === '') {
@@ -7,6 +7,7 @@ import {
7
7
  LensNameSchema,
8
8
  RoleResultSchema,
9
9
  } from '../agents/role-results'
10
+ import { ConfigChangeSchema } from '../config/config-reload'
10
11
  import { EngineConfigSchema } from '../config/engine-config'
11
12
  import {
12
13
  GateCheckSchema,
@@ -92,6 +93,29 @@ const EngineResumedEntrySchema = z.object({
92
93
  content: z.object({ luca_version: z.string().min(1) }),
93
94
  })
94
95
 
96
+ /**
97
+ * A resume read the repo's `.luca/config.json` again (#516), and the
98
+ * build fields it reloads (`RELOADED_FIELDS`) changed: each one, old to
99
+ * new. Journaled right after the resume's `engine_resumed`, only when
100
+ * something changed. Replay puts the new values on the run's config.
101
+ */
102
+ const ConfigReloadedEntrySchema = z.object({
103
+ ...ENTRY_FIELDS,
104
+ kind: z.literal('config_reloaded'),
105
+ content: z.object({ changes: z.array(ConfigChangeSchema).min(1) }),
106
+ })
107
+
108
+ /**
109
+ * A resume could not take the repo's config (#516): it is missing, not
110
+ * JSON, not a valid config, or one intake would refuse. The run keeps the
111
+ * config it had and goes on. `reason` says what is wrong.
112
+ */
113
+ const ConfigReloadRefusedEntrySchema = z.object({
114
+ ...ENTRY_FIELDS,
115
+ kind: z.literal('config_reload_refused'),
116
+ content: z.object({ reason: z.string().min(1) }),
117
+ })
118
+
95
119
  const IntakeReadEntrySchema = z.object({
96
120
  ...ENTRY_FIELDS,
97
121
  kind: z.literal('intake_read'),
@@ -660,7 +684,9 @@ export type RejoinCause = z.infer<typeof RejoinCauseSchema>
660
684
  * and `code` are the files that clashed (conflict markers left in them),
661
685
  * split by the config's test file patterns. `undone` are the run branch
662
686
  * commits the engine undid first (a join whose gates failed), and
663
- * `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).
664
690
  */
665
691
  const TicketRebasedEntrySchema = z.object({
666
692
  ...ENTRY_FIELDS,
@@ -727,6 +753,8 @@ const RunBranchPushedEntrySchema = z.object({
727
753
  /**
728
754
  * Why a ticket (or the final review) is stuck. A failed red check or gate is only stuck once its
729
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).
730
758
  */
731
759
  export const StuckReasonSchema = z.enum([
732
760
  'agent_failed',
@@ -742,6 +770,7 @@ export const StuckReasonSchema = z.enum([
742
770
  'prepare_failed',
743
771
  'setup_change_needed',
744
772
  'crashed',
773
+ 'undo_refused',
745
774
  ])
746
775
 
747
776
  export type StuckReason = z.infer<typeof StuckReasonSchema>
@@ -932,7 +961,11 @@ const FinalReviewRetriedEntrySchema = z.object({
932
961
 
933
962
  /**
934
963
  * A stuck ticket's join was undone on the run branch (its commits were
935
- * 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.
936
969
  */
937
970
  const JoinUndoneEntrySchema = z.object({
938
971
  ...ENTRY_FIELDS,
@@ -1364,6 +1397,8 @@ export const JournalEntrySchema = z.discriminatedUnion('kind', [
1364
1397
  RunResumedEntrySchema,
1365
1398
  JoinStartedEntrySchema,
1366
1399
  EngineResumedEntrySchema,
1400
+ ConfigReloadedEntrySchema,
1401
+ ConfigReloadRefusedEntrySchema,
1367
1402
  ])
1368
1403
 
1369
1404
  /** A journal entry as callers write it; schema defaults fill the rest. */
@@ -1445,6 +1480,8 @@ export const JournalRecordSchema = z.discriminatedUnion('kind', [
1445
1480
  RunResumedEntrySchema.extend(STAMP_FIELDS),
1446
1481
  JoinStartedEntrySchema.extend(STAMP_FIELDS),
1447
1482
  EngineResumedEntrySchema.extend(STAMP_FIELDS),
1483
+ ConfigReloadedEntrySchema.extend(STAMP_FIELDS),
1484
+ ConfigReloadRefusedEntrySchema.extend(STAMP_FIELDS),
1448
1485
  ])
1449
1486
 
1450
1487
  export type JournalRecord = z.infer<typeof JournalRecordSchema>
@@ -1525,6 +1562,8 @@ export const JournalKindSchema = z.enum([
1525
1562
  'run_resumed',
1526
1563
  'join_started',
1527
1564
  'engine_resumed',
1565
+ 'config_reloaded',
1566
+ 'config_reload_refused',
1528
1567
  ])
1529
1568
 
1530
1569
  export type JournalKind = z.infer<typeof JournalKindSchema>
@@ -29,6 +29,7 @@ import {
29
29
  type TestWriterResult,
30
30
  type TicketReviewResult,
31
31
  } from '../agents/role-results'
32
+ import { withConfigChanges } from '../config/config-reload'
32
33
  import type { EngineConfig } from '../config/engine-config'
33
34
  import type {
34
35
  GateCheck,
@@ -1014,6 +1015,21 @@ const applyRecord = ({
1014
1015
  // Luca's version on a resume changes no step: only the board reads it.
1015
1016
  case 'engine_resumed':
1016
1017
  return next
1018
+ // A resume's new build fields (#516): every later step uses them.
1019
+ case 'config_reloaded':
1020
+ return {
1021
+ ...next,
1022
+ config:
1023
+ state.config === null
1024
+ ? null
1025
+ : withConfigChanges({
1026
+ config: state.config,
1027
+ changes: record.content.changes,
1028
+ }),
1029
+ }
1030
+ // The run kept its config; only the board and the log read why.
1031
+ case 'config_reload_refused':
1032
+ return next
1017
1033
  case 'comment_read':
1018
1034
  return { ...next, comments: [...state.comments, record.content] }
1019
1035
  case 'run_stuck':
@@ -1192,6 +1208,18 @@ const applyRecord = ({
1192
1208
  state: { ...next, run_notes: notesAfter({ state, record }) },
1193
1209
  record,
1194
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
+ })
1195
1223
  // Always the final review's (see finalRecordOf above).
1196
1224
  case 'final_review_started':
1197
1225
  case 'lens_started':
@@ -1687,6 +1715,8 @@ type TicketRecord = Exclude<
1687
1715
  | 'step_ended'
1688
1716
  | 'run_resumed'
1689
1717
  | 'engine_resumed'
1718
+ | 'config_reloaded'
1719
+ | 'config_reload_refused'
1690
1720
  | 'join_started'
1691
1721
  | 'memory_write_started'
1692
1722
  | 'memory_write_done'
@@ -2260,8 +2290,8 @@ const retriedChange = ({
2260
2290
  * - in a review fix round, the round starts over as round 1 with fresh
2261
2291
  * fixers, so the user's changes are gated, committed, and re-reviewed;
2262
2292
  * - a leftover scan, a failed install, a failed prepare command at the
2263
- * baseline, a reviewer's failed tries, a failed join, or a step crashes
2264
- * 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.
2265
2295
  */
2266
2296
  const resumedProgress = ({
2267
2297
  progress,
@@ -2302,6 +2332,7 @@ const resumedProgress = ({
2302
2332
  reason === 'prepare_failed' ||
2303
2333
  reason === 'join_failed' ||
2304
2334
  reason === 'join_gates_failed' ||
2335
+ reason === 'undo_refused' ||
2305
2336
  reason === 'crashed' ||
2306
2337
  reviewerFailed
2307
2338
  ) {
@@ -2546,6 +2577,41 @@ const closedSessionAfter = ({
2546
2577
  }
2547
2578
  }
2548
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
+
2549
2615
  const applyTicketRecord = ({
2550
2616
  state,
2551
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.6",
4
+ "version": "14.0.0-alpha.8",
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