@alecsibilia/luca 14.0.0-alpha.30 → 14.0.0-alpha.32

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/board/package.json +1 -1
  3. package/board/server/board-vocabulary-engine.ts +15 -0
  4. package/board/server/board-vocabulary.ts +2 -0
  5. package/board/server/describe-record.ts +6 -1
  6. package/board/server/journal-rebuild-helpers.ts +6 -1
  7. package/board/server/luca-version.ts +1 -1
  8. package/board/server/reduce-board-create.ts +1 -0
  9. package/board/server/reduce-board-final-review.ts +3 -2
  10. package/board/server/reduce-board-needs-you.ts +108 -36
  11. package/board/server/reduce-board-update.ts +42 -0
  12. package/board/shared/board-state.ts +7 -0
  13. package/engine/cli/config-reload-text.ts +3 -1
  14. package/engine/config/config-fields.ts +10 -0
  15. package/engine/config/config-reload.ts +18 -0
  16. package/engine/config/engine-config-schema.ts +7 -0
  17. package/engine/config/engine-config.ts +1 -0
  18. package/engine/core/auto-retry.ts +206 -0
  19. package/engine/core/decide-build-view.ts +7 -0
  20. package/engine/core/decide-run-budget.ts +6 -1
  21. package/engine/core/decide-stuck.ts +22 -1
  22. package/engine/core/execute-action.ts +1 -0
  23. package/engine/core/execute-build.ts +1 -0
  24. package/engine/core/execute-stuck.ts +21 -1
  25. package/engine/core/execute.ts +7 -1
  26. package/engine/core/run-ended.ts +88 -0
  27. package/engine/journal/final-review-replay.ts +6 -0
  28. package/engine/journal/final-review-state.ts +3 -0
  29. package/engine/journal/journal-entry-schema.ts +4 -0
  30. package/engine/journal/journal-kinds.ts +2 -0
  31. package/engine/journal/journal-record-schema.ts +4 -0
  32. package/engine/journal/journal-record.ts +2 -0
  33. package/engine/journal/replay.ts +6 -0
  34. package/engine/journal/reply-replay.ts +44 -0
  35. package/engine/journal/run-entries.ts +30 -0
  36. package/engine/journal/run-state.ts +6 -0
  37. package/engine/journal/stuck-entries.ts +24 -0
  38. package/engine/journal/ticket-progress.ts +7 -0
  39. package/engine/journal/ticket-replay.ts +7 -1
  40. package/engine/journal/ticket-result-replay.ts +13 -3
  41. package/engine/journal/ticket-retry-replay.ts +1 -0
  42. package/engine/testing/auto-retry-fixtures.ts +226 -0
  43. package/engine/testing/run-fixtures.ts +6 -0
  44. package/engine/tracker/tracker.ts +9 -3
  45. package/package.json +1 -1
  46. package/skills/luca-retro/SKILL.md +1 -1
  47. package/skills/luca-unstick/scripts/stuck-lines.ts +234 -0
  48. package/skills/luca-unstick/scripts/stuck-records.ts +494 -0
  49. package/skills/luca-unstick/scripts/stuck-summary.ts +49 -564
  50. package/skills/luca-watch/SKILL.md +131 -0
  51. package/skills/luca-watch/scripts/wait-for-change.ts +378 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @alecsibilia/luca
2
2
 
3
+ ## 14.0.0-alpha.32
4
+
5
+ No changes in this release.
6
+
7
+ ## 14.0.0-alpha.31
8
+
9
+ No changes in this release.
10
+
3
11
  ## 14.0.0-alpha.30
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.30",
3
+ "version": "14.0.0-alpha.32",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -75,6 +75,21 @@ export const ENGINE_VOCABULARY = {
75
75
  reason: z.string(),
76
76
  detail: z.string().catch(''),
77
77
  }),
78
+ /**
79
+ * The engine told the owner a ticket (`ticket` set), the run, or the
80
+ * final review is stuck (#705): its Needs-you item shows from here.
81
+ */
82
+ stuck_reported: z.looseObject({}),
83
+ /**
84
+ * The engine retried a stuck ticket, the final review, or the run by
85
+ * itself (#705): `item` is `ticket`, `final_review`, or `run`, and
86
+ * `body` its comment on the spec issue, word for word.
87
+ */
88
+ auto_retried: z.looseObject({
89
+ item: z.string(),
90
+ reason: z.string().catch(''),
91
+ body: z.string().catch(''),
92
+ }),
78
93
  pull_request_opened: z.looseObject({
79
94
  number: z.number().int(),
80
95
  url: z.string(),
@@ -65,6 +65,8 @@ const BoardEntrySchema = z.discriminatedUnion('kind', [
65
65
  entry({ kind: 'run_branch_pushed' }),
66
66
  entry({ kind: 'ticket_stuck' }),
67
67
  entry({ kind: 'run_stuck' }),
68
+ entry({ kind: 'stuck_reported' }),
69
+ entry({ kind: 'auto_retried' }),
68
70
  entry({ kind: 'pull_request_opened' }),
69
71
  entry({ kind: 'worktrees_removed' }),
70
72
  entry({ kind: 'jev_asked' }),
@@ -278,6 +278,9 @@ export const describeRecord = ({
278
278
  text: `The run is stuck. ${runReasonText({ reason: record.content.reason })}`,
279
279
  tone: 'danger',
280
280
  })
281
+ // The engine's own retry (#705): its comment on the spec issue.
282
+ case 'auto_retried':
283
+ return event({ text: record.content.body, tone: 'info' })
281
284
  case 'pull_request_opened':
282
285
  return event({
283
286
  text: `Pull request #${record.content.number} opened: ${record.content.url}`,
@@ -377,11 +380,13 @@ export const describeRecord = ({
377
380
  tone: 'info',
378
381
  })
379
382
  // The repo wait shows as a banner on the panel (#632), the network
380
- // wait as the run's status (#701).
383
+ // wait as the run's status (#701), and a stuck report as the stuck
384
+ // row it brings (#705).
381
385
  case 'repo_wait_started':
382
386
  case 'repo_wait_ended':
383
387
  case 'network_wait_started':
384
388
  case 'network_wait_ended':
389
+ case 'stuck_reported':
385
390
  case 'intake_read':
386
391
  case 'ticket_snapshot':
387
392
  case 'baseline_tests':
@@ -9,6 +9,7 @@ import {
9
9
  pullRequestOpened,
10
10
  runStarted,
11
11
  stamp,
12
+ stuckReported,
12
13
  ticketStuck,
13
14
  ticketWorktreeCreated,
14
15
  wholeTicket,
@@ -133,11 +134,15 @@ export const finishedRun = (): Entry[] => [
133
134
  }),
134
135
  ]
135
136
 
136
- /** Spec 10 with ticket 13 stuck and nothing else running. */
137
+ /**
138
+ * Spec 10 with ticket 13 stuck, told to the owner (so it needs you, #705),
139
+ * and nothing else running.
140
+ */
137
141
  export const stuckRun = (): Entry[] => [
138
142
  ...intakeOfThree(),
139
143
  ticketWorktreeCreated({ ticket: 13 }),
140
144
  ticketStuck({ ticket: 13, reason: 'red_check_failed', detail: 'x' }),
145
+ stuckReported({ ticket: 13 }),
141
146
  ]
142
147
 
143
148
  export const nothingToDoRun = (): Entry[] => [
@@ -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.30"
2
+ export const LUCA_VERSION: string = "14.0.0-alpha.32"
@@ -83,6 +83,7 @@ export const createBoardState = ({
83
83
  network_waits: [],
84
84
  run_plan_used: [],
85
85
  needs_you: [],
86
+ unreported: [],
86
87
  tickets: [],
87
88
  final_review: {
88
89
  state: 'waiting',
@@ -7,7 +7,7 @@ import { finalReasonText } from './reduce-board-reasons'
7
7
  import { clip } from './reduce-board-text'
8
8
  import {
9
9
  DETAIL_MAX,
10
- addNeedsYou,
10
+ addUnreported,
11
11
  resolveNeedsYou,
12
12
  updateFinal,
13
13
  withTried,
@@ -125,8 +125,9 @@ export const applyFinalReviewKind = ({
125
125
  }),
126
126
  }),
127
127
  })
128
+ // Needs you once the engine tells the owner (#705).
128
129
  case 'final_review_stuck':
129
- return addNeedsYou({
130
+ return addUnreported({
130
131
  state: updateFinal({
131
132
  state,
132
133
  update: (review) => ({ ...review, state: 'stuck' }),
@@ -1,6 +1,7 @@
1
1
  /**
2
- * What needs a person: a stuck ticket or run, a skipped or retried ticket,
3
- * and the replies that resolve them.
2
+ * What needs a person: a stuck ticket or run (once the engine told the
3
+ * owner, #705), a skipped or retried ticket, and the replies and the
4
+ * engine's own retries that resolve them.
4
5
  */
5
6
  import type { BoardRecord } from './board-vocabulary'
6
7
  import {
@@ -12,6 +13,8 @@ import { clip } from './reduce-board-text'
12
13
  import {
13
14
  DETAIL_MAX,
14
15
  addNeedsYou,
16
+ addUnreported,
17
+ reportNeedsYou,
15
18
  resolveNeedsYou,
16
19
  updateFinal,
17
20
  updateTicket,
@@ -126,6 +129,55 @@ const ticketRetried = ({
126
129
  }
127
130
  }
128
131
 
132
+ /**
133
+ * A retry of a ticket, the run, or the final review, the owner's or the
134
+ * engine's own (#705): it carries on, and its item is resolved.
135
+ */
136
+ const retriedTicket = ({
137
+ state,
138
+ ticket,
139
+ }: {
140
+ state: BoardState
141
+ ticket: number
142
+ }): BoardState =>
143
+ resolveNeedsYou({
144
+ state: updateTicket({
145
+ state,
146
+ number: ticket,
147
+ update: (card) => ({
148
+ ...card,
149
+ stage: 'building',
150
+ activity: 'retrying',
151
+ }),
152
+ }),
153
+ key: ticketKey({ ticket }),
154
+ })
155
+
156
+ const retriedRun = ({
157
+ state,
158
+ top_up,
159
+ }: {
160
+ state: BoardState
161
+ top_up: number
162
+ }): BoardState =>
163
+ resolveNeedsYou({
164
+ state: {
165
+ ...state,
166
+ run_budget_tokens: state.run_budget_tokens + top_up,
167
+ run_stuck_reason: null,
168
+ },
169
+ key: RUN_KEY,
170
+ })
171
+
172
+ const retriedFinal = ({ state }: { state: BoardState }): BoardState =>
173
+ resolveNeedsYou({
174
+ state: updateFinal({
175
+ state,
176
+ update: (review) => ({ ...review, state: 'reviewing' }),
177
+ }),
178
+ key: FINAL_KEY,
179
+ })
180
+
129
181
  const replyReceived = ({
130
182
  state,
131
183
  record,
@@ -137,45 +189,21 @@ const replyReceived = ({
137
189
  const ticket = record.content.ticket ?? record.ticket
138
190
  switch (word) {
139
191
  case 'retry':
140
- if (ticket !== null) {
141
- return resolveNeedsYou({
142
- state: updateTicket({
143
- state,
144
- number: ticket,
145
- update: (card) => ({
146
- ...card,
147
- stage: 'building',
148
- activity: 'retrying',
149
- }),
150
- }),
151
- key: ticketKey({ ticket }),
152
- })
153
- }
192
+ if (ticket !== null) return retriedTicket({ state, ticket })
154
193
  // A bare `retry` while the run is stuck on its budget adds its
155
194
  // top-up (one whole budget for a retry journaled with none,
156
195
  // #543); stuck for another reason, no budget. The final review
157
196
  // is left as it is.
158
197
  if (state.needs_you.some((item) => item.key === RUN_KEY)) {
159
- const top_up =
160
- state.run_stuck_reason === 'run_budget'
161
- ? (record.content.top_up ?? state.run_budget_each)
162
- : 0
163
- return resolveNeedsYou({
164
- state: {
165
- ...state,
166
- run_budget_tokens: state.run_budget_tokens + top_up,
167
- run_stuck_reason: null,
168
- },
169
- key: RUN_KEY,
198
+ return retriedRun({
199
+ state,
200
+ top_up:
201
+ state.run_stuck_reason === 'run_budget'
202
+ ? (record.content.top_up ?? state.run_budget_each)
203
+ : 0,
170
204
  })
171
205
  }
172
- return resolveNeedsYou({
173
- state: updateFinal({
174
- state,
175
- update: (review) => ({ ...review, state: 'reviewing' }),
176
- }),
177
- key: FINAL_KEY,
178
- })
206
+ return retriedFinal({ state })
179
207
  case 'skip':
180
208
  return skipTicket({ state, ticket, because: null })
181
209
  case 'ship':
@@ -191,10 +219,39 @@ const replyReceived = ({
191
219
  }
192
220
  }
193
221
 
222
+ /**
223
+ * The engine retried a stuck item by itself (#705): nothing to reply to,
224
+ * so its item is resolved, told or not, and it carries on as after the
225
+ * owner's `retry`.
226
+ */
227
+ const autoRetried = ({
228
+ state,
229
+ record,
230
+ }: {
231
+ state: BoardState
232
+ record: Extract<BoardRecord, { kind: 'auto_retried' }>
233
+ }): BoardState => {
234
+ const { ticket } = record
235
+ switch (record.content.item) {
236
+ case 'ticket':
237
+ return ticket === null ? state : retriedTicket({ state, ticket })
238
+ // The engine retries the run only after `pull_request_failed`: no
239
+ // budget to add.
240
+ case 'run':
241
+ return retriedRun({ state, top_up: 0 })
242
+ case 'final_review':
243
+ return retriedFinal({ state })
244
+ default:
245
+ return state
246
+ }
247
+ }
248
+
194
249
  /** The record kinds of what needs a person. */
195
250
  export const NEEDS_YOU_KINDS = [
196
251
  'ticket_stuck',
197
252
  'run_stuck',
253
+ 'stuck_reported',
254
+ 'auto_retried',
198
255
  'reply_received',
199
256
  'ticket_skipped',
200
257
  'ticket_retried',
@@ -229,7 +286,8 @@ export const applyNeedsYouKind = ({
229
286
  activity: 'stuck',
230
287
  }),
231
288
  })
232
- return addNeedsYou({
289
+ // Needs you once the engine tells the owner (#705).
290
+ return addUnreported({
233
291
  state: stuck,
234
292
  item: {
235
293
  key: ticketKey({ ticket }),
@@ -250,7 +308,7 @@ export const applyNeedsYouKind = ({
250
308
  })
251
309
  }
252
310
  case 'run_stuck':
253
- return addNeedsYou({
311
+ return addUnreported({
254
312
  state: { ...state, run_stuck_reason: record.content.reason },
255
313
  item: {
256
314
  key: RUN_KEY,
@@ -266,6 +324,20 @@ export const applyNeedsYouKind = ({
266
324
  since: record.time,
267
325
  },
268
326
  })
327
+ // A report with no ticket is the run's while it waits to be told,
328
+ // else the final review's.
329
+ case 'stuck_reported':
330
+ return reportNeedsYou({
331
+ state,
332
+ key:
333
+ ticket !== null
334
+ ? ticketKey({ ticket })
335
+ : state.unreported.some((item) => item.key === RUN_KEY)
336
+ ? RUN_KEY
337
+ : FINAL_KEY,
338
+ })
339
+ case 'auto_retried':
340
+ return autoRetried({ state, record })
269
341
  case 'reply_received':
270
342
  return replyReceived({ state, record })
271
343
  case 'ticket_skipped':
@@ -77,6 +77,44 @@ export const addNeedsYou = ({
77
77
  ],
78
78
  })
79
79
 
80
+ /**
81
+ * A stuck item the engine hasn't told the owner of yet (#705): it waits in
82
+ * `unreported` until `stuck_reported` (`reportNeedsYou`).
83
+ */
84
+ export const addUnreported = ({
85
+ state,
86
+ item,
87
+ }: {
88
+ state: BoardState
89
+ item: NeedsYou
90
+ }): BoardState => ({
91
+ ...state,
92
+ unreported: [
93
+ ...state.unreported.filter((entry) => entry.key !== item.key),
94
+ item,
95
+ ],
96
+ })
97
+
98
+ /** The engine told the owner: the item `key` waiting to be told needs you now. */
99
+ export const reportNeedsYou = ({
100
+ state,
101
+ key,
102
+ }: {
103
+ state: BoardState
104
+ key: string
105
+ }): BoardState => {
106
+ const item = state.unreported.find((entry) => entry.key === key)
107
+ if (item === undefined) return state
108
+ return addNeedsYou({
109
+ state: {
110
+ ...state,
111
+ unreported: state.unreported.filter((entry) => entry.key !== key),
112
+ },
113
+ item,
114
+ })
115
+ }
116
+
117
+ /** Resolves item `key`, told or not yet told, or every item for `null`. */
80
118
  export const resolveNeedsYou = ({
81
119
  state,
82
120
  key,
@@ -89,6 +127,10 @@ export const resolveNeedsYou = ({
89
127
  key === null
90
128
  ? []
91
129
  : state.needs_you.filter((entry) => entry.key !== key),
130
+ unreported:
131
+ key === null
132
+ ? []
133
+ : state.unreported.filter((entry) => entry.key !== key),
92
134
  })
93
135
 
94
136
  /** The final review with one more "tried" line. */
@@ -144,6 +144,13 @@ export const BoardStateSchema = z.object({
144
144
  /** How much of the plan the whole run used, per window. */
145
145
  run_plan_used: z.array(PlanUsedSchema),
146
146
  needs_you: z.array(NeedsYouSchema),
147
+ /**
148
+ * A stuck ticket's or run's item before the engine told the owner
149
+ * (#705): it moves to `needs_you` on the engine's `stuck_reported`, and
150
+ * is dropped when the engine retries the item by itself. Defaulted, so
151
+ * a board state from before it still parses.
152
+ */
153
+ unreported: z.array(NeedsYouSchema).default([]),
147
154
  tickets: z.array(TicketCardSchema),
148
155
  final_review: FinalReviewSchema,
149
156
  jev: JevCountsSchema,
@@ -5,7 +5,9 @@
5
5
  import type { JournalRecord } from '../journal/journal-record'
6
6
 
7
7
  /** A value of a reloaded config field in a log line; `null` is "none". */
8
- const fieldValue = (value: string | number | string[] | null): string => {
8
+ const fieldValue = (
9
+ value: string | number | boolean | string[] | null
10
+ ): string => {
9
11
  if (value === null) return 'none'
10
12
  if (Array.isArray(value)) return `[${value.join(', ')}]`
11
13
  return String(value)
@@ -94,6 +94,16 @@ export const decisionModelOf = ({
94
94
  export const modelsOf = ({ config }: { config: EngineConfig }): ModelsConfig =>
95
95
  config.models ?? ModelsConfigSchema.parse({})
96
96
 
97
+ /**
98
+ * Whether the engine retries a routine stuck item once by itself (#705):
99
+ * the config's `auto_retry`, else `true`.
100
+ *
101
+ * @example
102
+ * autoRetryOf({ config: { ...config, auto_retry: false } }) // false
103
+ */
104
+ export const autoRetryOf = ({ config }: { config: EngineConfig }): boolean =>
105
+ config.auto_retry ?? true
106
+
97
107
  /**
98
108
  * The feature map's path (#583), in the form a worktree-relative path takes
99
109
  * everywhere else (`normalize`, no leading `./`), or `null` with no map.
@@ -3,6 +3,7 @@ import omit from 'lodash/omit'
3
3
  import reduce from 'lodash/reduce'
4
4
  import { z } from 'zod'
5
5
 
6
+ import { autoRetryOf } from './config-fields'
6
7
  import type { EngineConfig } from './engine-config'
7
8
 
8
9
  /**
@@ -28,6 +29,9 @@ import type { EngineConfig } from './engine-config'
28
29
  * - `leftover_allow`: more new markdown the leftover scan lets through
29
30
  * (#520). Each scan reads it then, so a run stuck on a leftover can be
30
31
  * unstuck by allowing the file and resuming.
32
+ * - `auto_retry`: whether the engine retries a routine stuck item once by
33
+ * itself (#705). Only an item stuck after the resume is retried, so
34
+ * turning it on leaves an item already told to the owner waiting.
31
35
  */
32
36
  export const RELOADED_FIELDS = [
33
37
  'prepare',
@@ -37,6 +41,7 @@ export const RELOADED_FIELDS = [
37
41
  'test_timeout_ms',
38
42
  'test_case_timeout_ms',
39
43
  'leftover_allow',
44
+ 'auto_retry',
40
45
  ] as const
41
46
 
42
47
  /**
@@ -130,6 +135,12 @@ export const ConfigChangeSchema = z.discriminatedUnion('field', [
130
135
  from: patterns,
131
136
  to: patterns,
132
137
  }),
138
+ // Never left out: a config without it reads as `true`.
139
+ z.object({
140
+ field: z.literal('auto_retry'),
141
+ from: z.boolean(),
142
+ to: z.boolean(),
143
+ }),
133
144
  ])
134
145
 
135
146
  export type ConfigChange = z.infer<typeof ConfigChangeSchema>
@@ -186,6 +197,11 @@ export const configChanges = ({
186
197
  from: current.leftover_allow ?? null,
187
198
  to: next.leftover_allow ?? null,
188
199
  },
200
+ {
201
+ field: 'auto_retry',
202
+ from: autoRetryOf({ config: current }),
203
+ to: autoRetryOf({ config: next }),
204
+ },
189
205
  ]
190
206
  return all.filter(({ from, to }) => !isEqual(from, to))
191
207
  }
@@ -226,6 +242,8 @@ const applyChange = ({
226
242
  return change.to === null
227
243
  ? omit(config, 'leftover_allow')
228
244
  : { ...config, leftover_allow: change.to }
245
+ case 'auto_retry':
246
+ return { ...config, auto_retry: change.to }
229
247
  }
230
248
  }
231
249
 
@@ -344,6 +344,13 @@ export const EngineConfigSchema = z.object({
344
344
  * (`DEFAULT_RUN_BUDGET_TOKENS`).
345
345
  */
346
346
  run_budget_tokens: z.number().int().positive().optional(),
347
+ /**
348
+ * Whether the engine retries a routine stuck item once by itself
349
+ * (#705), with a comment on the spec issue, before it asks the owner.
350
+ * Left out, `true`; a config written before it may lack it, and reads
351
+ * as `true` too (`autoRetryOf`).
352
+ */
353
+ auto_retry: z.boolean().default(true).optional(),
347
354
  })
348
355
 
349
356
  export type EngineConfig = z.infer<typeof EngineConfigSchema>
@@ -17,6 +17,7 @@ import {
17
17
  } from './engine-config-schema'
18
18
 
19
19
  export {
20
+ autoRetryOf,
20
21
  DEFAULT_PREPARE_CONCURRENCY,
21
22
  DEFAULT_PREPARE_TIMEOUT_MS,
22
23
  decisionModelOf,