@alecsibilia/luca 14.0.0-alpha.5 → 14.0.0-alpha.7

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.7
4
+
5
+ No changes in this release.
6
+
7
+ ## 14.0.0-alpha.6
8
+
9
+ No changes in this release.
10
+
3
11
  ## 14.0.0-alpha.5
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.5",
3
+ "version": "14.0.0-alpha.7",
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(
@@ -279,7 +288,10 @@ export const BOARD_VOCABULARY = {
279
288
  reason: z.string(),
280
289
  detail: z.string().catch(''),
281
290
  }),
282
- /** The whole run is stuck (#435): `run_budget`, it used up its budget. */
291
+ /**
292
+ * The whole run is stuck (#435): `run_budget`, it used up its budget,
293
+ * or `pull_request_failed`, its PR didn't open (#508).
294
+ */
283
295
  run_stuck: z.looseObject({
284
296
  reason: z.string(),
285
297
  detail: z.string().catch(''),
@@ -445,6 +457,8 @@ const entry = <Kind extends BoardKind>({ kind }: { kind: Kind }) =>
445
457
  const BoardEntrySchema = z.discriminatedUnion('kind', [
446
458
  entry({ kind: 'run_started' }),
447
459
  entry({ kind: 'engine_resumed' }),
460
+ entry({ kind: 'config_reloaded' }),
461
+ entry({ kind: 'config_reload_refused' }),
448
462
  entry({ kind: 'intake_read' }),
449
463
  entry({ kind: 'intake_refused' }),
450
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.5"
2
+ export const LUCA_VERSION: string = "14.0.0-alpha.7"
@@ -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('; ')}`,
@@ -408,6 +408,8 @@ const resolveNeedsYou = ({
408
408
  const RUN_STUCK_REASONS: Record<string, string> = {
409
409
  run_budget:
410
410
  'The run used up its run budget of tokens. `retry` adds one more full budget.',
411
+ pull_request_failed:
412
+ "The run's pull request didn't open. `retry` tries again, reusing a PR already open from the run branch.",
411
413
  }
412
414
 
413
415
  /** A run stuck reason code in words. */
@@ -861,6 +863,10 @@ const applyKind = ({
861
863
  }),
862
864
  },
863
865
  }
866
+ // A resume's config reload (#516) is only a chat row.
867
+ case 'config_reloaded':
868
+ case 'config_reload_refused':
869
+ return state
864
870
  case 'intake_read':
865
871
  return {
866
872
  ...state,
@@ -190,6 +190,28 @@ export const prepareSection = ({ command }: { command: string }): string =>
190
190
  "If the engine's prepare command fails after your turn, its output comes back to you like a failed check.",
191
191
  ].join('\n\n')
192
192
 
193
+ /**
194
+ * The section a test-writer or implementer (a ticket's or the final
195
+ * review's fixers) gets in a repo with changesets: the engine writes the
196
+ * run's one changeset, so an agent's own is extra.
197
+ */
198
+ export const CHANGESETS_SECTION = [
199
+ '## The engine writes the changeset',
200
+ "Don't write changesets, even if the repo's rules ask for one: the engine writes the run's one changeset when it opens the PR. " +
201
+ 'A new file you add in `.changeset/` is removed before the commit. ' +
202
+ 'A finding that only asks for a changeset is answered by this: answer it "wont_fix" and say the engine adds it.',
203
+ ].join('\n\n')
204
+
205
+ /**
206
+ * The section the rules lens gets in a repo with changesets, so a rule like
207
+ * "every PR needs a changeset" raises no finding.
208
+ */
209
+ export const CHANGESETS_LENS_SECTION = [
210
+ '## The engine writes the changeset',
211
+ "The engine adds the run's one changeset when it opens the PR, so a rule that asks for a changeset is met. " +
212
+ 'Raise no finding that only asks for a changeset.',
213
+ ].join('\n\n')
214
+
193
215
  /** What each lens judges the whole run branch on, in one paragraph. */
194
216
  const LENS_TASK_TEXT =
195
217
  'You are one lens of the final review: a fresh, read-only reviewer of the WHOLE run branch, every ticket of the spec together. Write nothing.'
@@ -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
+ )
@@ -47,6 +47,8 @@ import {
47
47
  import { badTestDetail, retrySection, setupChangeDetail } from './stuck-text'
48
48
 
49
49
  import {
50
+ CHANGESETS_LENS_SECTION,
51
+ CHANGESETS_SECTION,
50
52
  prepareSection,
51
53
  rejoinSection,
52
54
  rolePrompt,
@@ -1303,34 +1305,47 @@ export const decideBuild = ({
1303
1305
  }): BuildAction[] => {
1304
1306
  const steps = buildSteps({ state, spec_number })
1305
1307
  const command = state.config?.prepare
1306
- return command === undefined
1307
- ? steps
1308
- : steps.map((step) => withPrepareSection({ step, command }))
1308
+ const prepared =
1309
+ command === undefined
1310
+ ? steps
1311
+ : steps.map((step) =>
1312
+ withWriterSection({
1313
+ step,
1314
+ section: prepareSection({ command }),
1315
+ })
1316
+ )
1317
+ return state.changesets ? prepared.map(withChangesetsSections) : prepared
1309
1318
  }
1310
1319
 
1311
1320
  /**
1312
1321
  * A fresh test-writer's or implementer's launch (a ticket's or the final
1313
- * review's fixers), with the repo's prepare command named in its prompt.
1314
- * Any other step as it is.
1322
+ * review's fixers), with `section` added to its prompt, such as the repo's
1323
+ * prepare command. Any other step as it is.
1315
1324
  */
1316
- const withPrepareSection = ({
1325
+ const withWriterSection = ({
1317
1326
  step,
1318
- command,
1327
+ section,
1319
1328
  }: {
1320
1329
  step: BuildAction
1321
- command: string
1330
+ section: string
1322
1331
  }): BuildAction => {
1323
1332
  const writesCode =
1324
1333
  (step.type === 'launch_agent' || step.type === 'launch_final_fixer') &&
1325
1334
  (step.role === 'test-writer' || step.role === 'implementer')
1326
1335
  return writesCode
1327
- ? {
1328
- ...step,
1329
- prompt: `${step.prompt}\n\n${prepareSection({ command })}`,
1330
- }
1336
+ ? { ...step, prompt: `${step.prompt}\n\n${section}` }
1331
1337
  : step
1332
1338
  }
1333
1339
 
1340
+ /**
1341
+ * In a repo with changesets, the writers are told not to write one and the
1342
+ * rules lens that the engine adds the run's one at PR time (#507).
1343
+ */
1344
+ const withChangesetsSections = (step: BuildAction): BuildAction =>
1345
+ step.type === 'launch_lens' && step.lens === 'rules'
1346
+ ? { ...step, prompt: `${step.prompt}\n\n${CHANGESETS_LENS_SECTION}` }
1347
+ : withWriterSection({ step, section: CHANGESETS_SECTION })
1348
+
1334
1349
  /** `decideBuild`'s steps, before the prepare command joins any prompt. */
1335
1350
  const buildSteps = ({
1336
1351
  state,
@@ -1,5 +1,9 @@
1
1
  import { pendingReplies, waitForReply, type StuckAction } from './decide-stuck'
2
- import { runBudgetDetail, runStuckComment } from './stuck-text'
2
+ import {
3
+ pullRequestStuckComment,
4
+ runBudgetDetail,
5
+ runStuckComment,
6
+ } from './stuck-text'
3
7
 
4
8
  import type { JournalRecord, RunStuckReason } from '../journal/journal-record'
5
9
  import type { RunState } from '../journal/replay'
@@ -19,7 +23,9 @@ export type RunBudgetAction =
19
23
  * reason "run budget": that is its only step, and nothing new starts. The
20
24
  * spec issue is told, then the run waits for the owner: a bare `retry`
21
25
  * adds one more full budget (replay does that), and `stop` ends the run.
22
- * The owner's other replies wait until the run carries on. `null` when
26
+ * The owner's other replies wait until the run carries on. A run whose PR
27
+ * didn't open (#508) is stuck the same way, told why, and a `retry` tries
28
+ * the PR again, adding no budget. `null` when
23
29
  * the budget has nothing to say: the run is under it, stopping, or its PR
24
30
  * is open.
25
31
  *
@@ -56,7 +62,10 @@ export const decideRunBudget = ({
56
62
  {
57
63
  type: 'report_run_stuck',
58
64
  spec_number,
59
- body: runStuckComment({ detail: stuck.detail, budget }),
65
+ body:
66
+ stuck.reason === 'pull_request_failed'
67
+ ? pullRequestStuckComment({ detail: stuck.detail })
68
+ : runStuckComment({ detail: stuck.detail, budget }),
60
69
  },
61
70
  ]
62
71
  }