@alecsibilia/luca 14.0.0-alpha.19 → 14.0.0-alpha.20

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 (68) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/board/package.json +1 -1
  3. package/board/server/board-vocabulary-review.ts +5 -0
  4. package/board/server/luca-version.ts +1 -1
  5. package/board/server/record-texts.ts +4 -0
  6. package/board/server/reduce-board-create.ts +1 -0
  7. package/board/server/reduce-board-needs-you.ts +11 -5
  8. package/board/server/reduce-board-reasons.ts +1 -1
  9. package/board/shared/board-state.ts +9 -2
  10. package/board/shared/reply-actions.ts +8 -2
  11. package/engine/agents/agent-launcher.ts +7 -2
  12. package/engine/agents/claude-launcher.ts +1 -0
  13. package/engine/agents/claude-messages.ts +2 -0
  14. package/engine/agents/common-instructions.ts +24 -1
  15. package/engine/agents/open-session.ts +1 -0
  16. package/engine/agents/role-instructions.ts +16 -3
  17. package/engine/agents/scripted-launcher.ts +4 -0
  18. package/engine/core/agent-guards-helpers.ts +8 -1
  19. package/engine/core/agent-turn.ts +52 -11
  20. package/engine/core/decide-final-fix.ts +9 -3
  21. package/engine/core/decide-final-review.ts +27 -6
  22. package/engine/core/decide-run-budget.ts +61 -12
  23. package/engine/core/decide-stuck.ts +52 -6
  24. package/engine/core/engine-chores.ts +24 -7
  25. package/engine/core/execute-final-review.ts +1 -0
  26. package/engine/core/execute-repo-wait.ts +28 -12
  27. package/engine/core/execute-stuck.ts +3 -0
  28. package/engine/core/execute.ts +1 -1
  29. package/engine/core/final-review-action.ts +2 -1
  30. package/engine/core/final-review-text.ts +32 -4
  31. package/engine/core/pull-request-text.ts +63 -6
  32. package/engine/core/repo-wait-text.ts +18 -5
  33. package/engine/core/run-stuck-text.ts +1 -1
  34. package/engine/core/stuck-text.ts +4 -0
  35. package/engine/gates/shell-check.ts +5 -1
  36. package/engine/gates/test-runner.ts +1 -1
  37. package/engine/guards/after-turn-check.ts +31 -5
  38. package/engine/guards/worktree-git.ts +208 -0
  39. package/engine/guards/worktree-snapshot.ts +412 -0
  40. package/engine/guards/worktree-state.ts +28 -825
  41. package/engine/guards/worktree-undo.ts +260 -0
  42. package/engine/intake/criteria-checks.ts +97 -0
  43. package/engine/intake/intake-checks.ts +10 -0
  44. package/engine/journal/agent-entries.ts +13 -3
  45. package/engine/journal/agent-turn-replay.ts +13 -1
  46. package/engine/journal/cross-ticket-replay.ts +47 -2
  47. package/engine/journal/final-review-entries.ts +10 -4
  48. package/engine/journal/final-review-replay.ts +22 -3
  49. package/engine/journal/final-review-round.ts +61 -1
  50. package/engine/journal/final-review-state.ts +15 -0
  51. package/engine/journal/journal-entry-schema.ts +2 -0
  52. package/engine/journal/journal-kinds.ts +1 -0
  53. package/engine/journal/journal-record-schema.ts +2 -0
  54. package/engine/journal/replay.ts +20 -5
  55. package/engine/journal/reply-replay.ts +12 -6
  56. package/engine/journal/repo-wait-entries.ts +11 -0
  57. package/engine/journal/run-state.ts +13 -5
  58. package/engine/journal/stuck-entries.ts +10 -3
  59. package/engine/journal/ticket-progress.ts +6 -0
  60. package/engine/journal/ticket-replay.ts +12 -1
  61. package/engine/shell/run-command.ts +146 -18
  62. package/engine/testing/agent-turn-fixtures.ts +8 -0
  63. package/engine/testing/final-review-fixtures.ts +11 -0
  64. package/engine/testing/final-review-steps.ts +143 -0
  65. package/engine/testing/practice-repo.ts +6 -4
  66. package/engine/testing/run-budget-fixtures.ts +165 -0
  67. package/package.json +1 -1
  68. package/skills/luca-unstick/SKILL.md +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @alecsibilia/luca
2
2
 
3
+ ## 14.0.0-alpha.20
4
+
5
+ No changes in this release.
6
+
3
7
  ## 14.0.0-alpha.19
4
8
 
5
9
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luca/board",
3
- "version": "14.0.0-alpha.19",
3
+ "version": "14.0.0-alpha.20",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -14,6 +14,11 @@ export const REVIEW_VOCABULARY = {
14
14
  /** `ship` is only for a stuck final review. */
15
15
  word: z.enum(['retry', 'skip', 'stop', 'ship']),
16
16
  ticket: z.number().int().nullable().optional(),
17
+ /**
18
+ * The tokens a `retry` of the run stuck on its budget added (#543);
19
+ * absent before top-ups, when it added one whole budget.
20
+ */
21
+ top_up: z.number().int().positive().optional().catch(undefined),
17
22
  }),
18
23
  /** A reply the engine couldn't use; it answered why on the spec issue. */
19
24
  reply_ignored: z.looseObject({
@@ -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.19"
2
+ export const LUCA_VERSION: string = "14.0.0-alpha.20"
@@ -53,6 +53,10 @@ export const ignoredText = ({
53
53
  return 'nothing is stuck right now'
54
54
  case 'ship_needs_final_review':
55
55
  return '`ship` is only for a stuck final review'
56
+ case 'amount_only_for_run_budget':
57
+ return 'an amount is only for a run stuck on its budget'
58
+ case 'bad_amount':
59
+ return 'the amount is not a whole number above zero'
56
60
  default:
57
61
  return reason.replaceAll('_', ' ')
58
62
  }
@@ -75,6 +75,7 @@ export const createBoardState = ({
75
75
  run_tokens: 0,
76
76
  run_budget_tokens: DEFAULT_RUN_BUDGET_TOKENS,
77
77
  run_budget_each: DEFAULT_RUN_BUDGET_TOKENS,
78
+ run_stuck_reason: null,
78
79
  confidence_bar: DEFAULT_CONFIDENCE_BAR,
79
80
  limit_wait: null,
80
81
  usage_line_wait: null,
@@ -151,14 +151,20 @@ const replyReceived = ({
151
151
  key: ticketKey({ ticket }),
152
152
  })
153
153
  }
154
- // A bare `retry` while the run is stuck on its budget adds one
155
- // more full budget; the final review is left as it is.
154
+ // A bare `retry` while the run is stuck on its budget adds its
155
+ // top-up (one whole budget for a retry journaled with none,
156
+ // #543); stuck for another reason, no budget. The final review
157
+ // is left as it is.
156
158
  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
157
163
  return resolveNeedsYou({
158
164
  state: {
159
165
  ...state,
160
- run_budget_tokens:
161
- state.run_budget_tokens + state.run_budget_each,
166
+ run_budget_tokens: state.run_budget_tokens + top_up,
167
+ run_stuck_reason: null,
162
168
  },
163
169
  key: RUN_KEY,
164
170
  })
@@ -245,7 +251,7 @@ export const applyNeedsYouKind = ({
245
251
  }
246
252
  case 'run_stuck':
247
253
  return addNeedsYou({
248
- state,
254
+ state: { ...state, run_stuck_reason: record.content.reason },
249
255
  item: {
250
256
  key: RUN_KEY,
251
257
  ticket: null,
@@ -49,7 +49,7 @@ export const finalReasonText = ({ reason }: { reason: string }): string =>
49
49
  /** Why the whole run is stuck (`run_stuck`'s reason), in words. */
50
50
  const RUN_STUCK_REASONS: Record<string, string> = {
51
51
  run_budget:
52
- 'The run used up its run budget of tokens. `retry` adds one more full budget.',
52
+ 'The run used up its run budget of tokens. `retry` adds half a budget more, and `retry +3M` the amount named.',
53
53
  pull_request_failed:
54
54
  "The run's pull request didn't open. `retry` tries again, reusing a PR already open from the run branch.",
55
55
  }
@@ -74,12 +74,19 @@ export const BoardStateSchema = z.object({
74
74
  run_tokens: z.number().min(0).default(0),
75
75
  /**
76
76
  * The tokens the run may use before it is stuck (#435): one full run
77
- * budget, plus one more for each `retry` the owner gave it. Defaulted,
78
- * so an older board state still parses.
77
+ * budget, plus the top-up of each `retry` the owner gave the run stuck
78
+ * on its budget (#543; one whole budget for a retry journaled with no
79
+ * top-up). Defaulted, so an older board state still parses.
79
80
  */
80
81
  run_budget_tokens: z.number().min(0).default(DEFAULT_RUN_BUDGET_TOKENS),
81
82
  /** One full run budget: the engine config's, else the default. */
82
83
  run_budget_each: z.number().min(0).default(DEFAULT_RUN_BUDGET_TOKENS),
84
+ /**
85
+ * Why the whole run is stuck (`run_stuck`'s reason), until the owner's
86
+ * reply; `null` while it isn't. Defaulted, so an older board state
87
+ * still parses.
88
+ */
89
+ run_stuck_reason: z.string().nullable().default(null),
83
90
  /**
84
91
  * The engine config's `models.confidence_bar` (#630): the decision
85
92
  * model is sharp at or over it. Defaulted, so an older board state
@@ -54,14 +54,20 @@ export type ReplyParts = {
54
54
 
55
55
  const REPLY_PATTERN = /^(retry|skip|stop|ship)(?: #(\d+))?$/
56
56
 
57
+ /** `retry` with an amount for the run stuck on its budget (#543). */
58
+ const AMOUNT_REPLY_PATTERN = /^retry \+\S+$/
59
+
57
60
  /**
58
- * Splits one of `allowedReplies` into its word and ticket, to match it
59
- * with the engine's `reply_received`. `null` for anything else.
61
+ * Splits one of `allowedReplies`, or a `retry +<amount>` the owner typed,
62
+ * into its word and ticket, to match it with the engine's `reply_received`.
63
+ * `null` for anything else.
60
64
  *
61
65
  * @example
62
66
  * replyParts({ reply: 'retry #12' }) // { word: 'retry', ticket: 12 }
67
+ * replyParts({ reply: 'retry +3M' }) // { word: 'retry', ticket: null }
63
68
  */
64
69
  export const replyParts = ({ reply }: { reply: string }): ReplyParts | null => {
70
+ if (AMOUNT_REPLY_PATTERN.test(reply)) return { word: 'retry', ticket: null }
65
71
  const match = REPLY_PATTERN.exec(reply)
66
72
  const word = z.enum(['retry', 'skip', 'stop', 'ship']).safeParse(match?.[1])
67
73
  if (!match || !word.success) return null
@@ -100,7 +100,10 @@ export type LauncherFailure = z.infer<typeof LauncherFailureSchema>
100
100
  * the same way; a success with no structured output is a failed try.
101
101
  * `session_id` names the session the turn ran in, so the engine can send that
102
102
  * same agent a follow-up in a fix loop. `session` is the launcher's summary
103
- * of the model session, for the journal.
103
+ * of the model session, for the journal. `session_gone` marks a follow-up
104
+ * that found its session closed or unknown (#521): no agent ran, and the
105
+ * engine hands the follow-up's message to a fresh agent. `out_of_turns`
106
+ * marks a turn that reached its role's turn limit (#543).
104
107
  */
105
108
  export type AgentTurn =
106
109
  | {
@@ -115,6 +118,8 @@ export type AgentTurn =
115
118
  error: string
116
119
  session_id?: string
117
120
  session?: AgentSession
121
+ session_gone?: boolean
122
+ out_of_turns?: boolean
118
123
  }
119
124
 
120
125
  /**
@@ -179,7 +184,7 @@ export type AgentLauncher = {
179
184
  * as a failed gate's output in a fix loop, and returns the session's new
180
185
  * result. The agent keeps everything it knew from its earlier turns, the
181
186
  * guards it was launched with, and its model. A session the launcher doesn't know,
182
- * or one that has closed, is an engine failure.
187
+ * or one that has closed, is an engine failure with `session_gone`.
183
188
  *
184
189
  * @example
185
190
  * const turn = await launcher.followUp({ session_id, role: 'implementer', ticket: 11, message: 'lint failed: ...', cwd, config })
@@ -107,6 +107,7 @@ export const createClaudeLauncher = ({
107
107
  ok: false,
108
108
  failure: 'engine',
109
109
  session_id,
110
+ session_gone: true,
110
111
  error: `No open agent session ${session_id}: it closed or never started.`,
111
112
  }
112
113
  }
@@ -103,6 +103,8 @@ export type Failed = {
103
103
  ok: false
104
104
  failure: 'agent' | 'engine' | 'stop' | 'plan'
105
105
  error: string
106
+ /** The turn reached its role's turn limit (#543). */
107
+ out_of_turns?: boolean
106
108
  }
107
109
 
108
110
  export type Outcome = { ok: true; structured_output: unknown } | Failed
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * The instruction sections more than one role gets: the shell rules, agent
3
- * messages, helpers, the rules for every agent, and fixing review findings.
3
+ * messages, helpers, the sandbox, the repo's rule files, the rules for every
4
+ * agent, and fixing review findings.
4
5
  */
5
6
  import type { AgentRole } from './role-results'
6
7
 
@@ -72,6 +73,28 @@ export const COMMON = `## Rules for every agent
72
73
  - "run_notes": at most 3 short facts about this repo that would help later agents in this run. Can be empty.
73
74
  - Finish by giving your structured result. A turn without one counts as failed.`
74
75
 
76
+ /** What every role's sandbox blocks, and that the gates run outside it (#540). */
77
+ export const SANDBOX = `## Your sandbox
78
+ - The engine runs the gates (tests, types, lint) outside your sandbox and gives you their results.
79
+ - The sandbox blocks the network and local ports, writes outside your worktree, git internals, \`node_modules\`, lockfiles, test setup files, test files for roles that may not edit them, and secret files.
80
+ - A command that fails only inside your sandbox isn't yours to fix or work around. Rely on the engine's gate results, and say so.`
81
+
82
+ /**
83
+ * The writers' list of the repo's rule files (#646), named, not pasted in,
84
+ * or nothing when the repo names none.
85
+ */
86
+ export const repoRules = ({ config }: { config: EngineConfig }): string[] =>
87
+ config.rule_files.length === 0
88
+ ? []
89
+ : [
90
+ [
91
+ '## Repo rules',
92
+ "This repo's rules are in these files:",
93
+ ...config.rule_files.map((path) => `- \`${path}\``),
94
+ 'Read them before you start, and follow them.',
95
+ ].join('\n'),
96
+ ]
97
+
75
98
  export const REVIEW_FIXER = `## Fixing review findings
76
99
  Sometimes you are sent a ticket review's findings to fix, after the ticket's work is committed, or the final review's findings to fix on the whole run branch, after every ticket joined it. Then:
77
100
  - Fix each finding you were sent, keeping to your role's rules.
@@ -161,6 +161,7 @@ export const watch = ({
161
161
  ok: false,
162
162
  failure: 'agent',
163
163
  error: `The agent's turn ended with ${done.subtype}: ${detail}`,
164
+ ...(done.subtype === 'error_max_turns' ? { out_of_turns: true } : {}),
164
165
  }
165
166
  }
166
167
 
@@ -2,7 +2,14 @@
2
2
  * Each role's instructions, put together from the role's task section and
3
3
  * the shared sections.
4
4
  */
5
- import { COMMON, HELPERS, MESSAGES, shellRules } from './common-instructions'
5
+ import {
6
+ COMMON,
7
+ HELPERS,
8
+ MESSAGES,
9
+ repoRules,
10
+ SANDBOX,
11
+ shellRules,
12
+ } from './common-instructions'
6
13
  import { LEARNER, LEARNER_RULES } from './learner-instructions'
7
14
  import { LENS, REVIEWER } from './reviewer-instructions'
8
15
  import { lensOf, type AgentRole } from './role-results'
@@ -21,7 +28,9 @@ import { canMessage } from '../messages/agent-messages'
21
28
  * reviewers aren't. Each final review lens gets its own focus; the
22
29
  * integration lens also gives the PR's Summary picture and Merge Danger
23
30
  * (#513). The learner (#370) gets its own task and rules: no shell,
24
- * nothing written. Every role is told about its read-only helpers (#627).
31
+ * nothing written. Every role is told about its read-only helpers (#627),
32
+ * and what its sandbox blocks (#540). The writers, as final review fixers
33
+ * too, are told where the repo's rule files are (#646).
25
34
  *
26
35
  * @example
27
36
  * const append = roleInstructions({ role: 'implementer', may_edit_tests: false, config })
@@ -43,7 +52,7 @@ export const roleInstructions = ({
43
52
  }): string => {
44
53
  // The learner has no shell and no agent messages, and its rules differ.
45
54
  if (role === 'learner')
46
- return [LEARNER, HELPERS, LEARNER_RULES].join('\n\n')
55
+ return [LEARNER, HELPERS, SANDBOX, LEARNER_RULES].join('\n\n')
47
56
  const lens = lensOf({ role })
48
57
  const task =
49
58
  lens !== null
@@ -55,9 +64,13 @@ export const roleInstructions = ({
55
64
  : REVIEWER
56
65
  return [
57
66
  task,
67
+ ...(role === 'test-writer' || role === 'implementer'
68
+ ? repoRules({ config })
69
+ : []),
58
70
  shellRules({ role, config }),
59
71
  ...((messaging ?? canMessage({ role })) ? [MESSAGES] : []),
60
72
  HELPERS,
73
+ SANDBOX,
61
74
  COMMON,
62
75
  ].join('\n\n')
63
76
  }
@@ -50,6 +50,8 @@ export type ScriptedTurn = {
50
50
  result?: unknown
51
51
  failure?: LauncherFailure
52
52
  error?: string
53
+ /** With `failure`: the turn reached its role's turn limit (#543). */
54
+ out_of_turns?: boolean
53
55
  /** A session summary for the engine to journal. */
54
56
  session?: AgentSession
55
57
  }
@@ -169,6 +171,7 @@ export const createScriptedLauncher = ({
169
171
  failure: turn.failure,
170
172
  session_id,
171
173
  error: turn.error ?? `The scripted ${role} failed.`,
174
+ ...(turn.out_of_turns === true ? { out_of_turns: true } : {}),
172
175
  ...session,
173
176
  }
174
177
  }
@@ -223,6 +226,7 @@ export const createScriptedLauncher = ({
223
226
  ok: false,
224
227
  failure: 'engine',
225
228
  session_id,
229
+ session_gone: true,
226
230
  error: `No scripted session ${session_id}.`,
227
231
  }
228
232
  }
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
 
5
5
  import { afterAll, beforeAll } from 'bun:test'
6
+ import omit from 'lodash/omit'
6
7
 
7
8
  import type { ScriptedTurn } from '../agents/scripted-launcher'
8
9
  import type { JournalRecord } from '../journal/journal-record'
@@ -18,9 +19,15 @@ import { createPracticeRepo } from '../testing/practice-repo'
18
19
  export type Practice = Awaited<ReturnType<typeof createPracticeRepo>>
19
20
  export type Ran = Awaited<ReturnType<Practice['run']>>
20
21
 
22
+ /**
23
+ * Each failed turn's content, without `session_gone` (#521) or `out_of_turns`
24
+ * (#543): no guard test follows up a gone session or reaches a turn limit.
25
+ */
21
26
  export const failures = (records: JournalRecord[]) =>
22
27
  records.flatMap((record) =>
23
- record.kind === 'agent_failed' ? [record.content] : []
28
+ record.kind === 'agent_failed'
29
+ ? [omit(record.content, 'session_gone', 'out_of_turns')]
30
+ : []
24
31
  )
25
32
 
26
33
  export const commits = (records: JournalRecord[]) =>
@@ -31,6 +31,8 @@ const failTurn = ({
31
31
  error,
32
32
  failure,
33
33
  session_id,
34
+ session_gone,
35
+ out_of_turns,
34
36
  }: {
35
37
  context: BuildContext
36
38
  ticket: number | null
@@ -38,12 +40,21 @@ const failTurn = ({
38
40
  error: string
39
41
  failure: AgentFailure
40
42
  session_id: string | null
43
+ session_gone: boolean
44
+ out_of_turns: boolean
41
45
  }) => {
42
46
  context.journal.append({
43
47
  kind: 'agent_failed',
44
48
  ticket,
45
49
  role,
46
- content: { role, error, failure, session_id },
50
+ content: {
51
+ role,
52
+ error,
53
+ failure,
54
+ session_id,
55
+ ...(session_gone ? { session_gone } : {}),
56
+ ...(out_of_turns ? { out_of_turns } : {}),
57
+ },
47
58
  })
48
59
  }
49
60
 
@@ -144,27 +155,54 @@ export const runTurn = async ({
144
155
  throw new Error(`Run stopped: ${turn.error}`)
145
156
  }
146
157
  const session_id = turn.session_id ?? null
147
- const fail = (failure: AgentFailure, error: string) => {
148
- failTurn({ context, ticket, role, error, failure, session_id })
158
+ const fail = ({
159
+ failure,
160
+ error,
161
+ session_gone = false,
162
+ out_of_turns = false,
163
+ }: {
164
+ failure: AgentFailure
165
+ error: string
166
+ session_gone?: boolean
167
+ out_of_turns?: boolean
168
+ }) => {
169
+ failTurn({
170
+ context,
171
+ ticket,
172
+ role,
173
+ error,
174
+ failure,
175
+ session_id,
176
+ session_gone,
177
+ out_of_turns,
178
+ })
149
179
  return null
150
180
  }
151
181
  if (violations.length > 0) {
152
- return fail(
153
- 'guard',
154
- describeViolations({ role: guard_role, violations })
155
- )
182
+ return fail({
183
+ failure: 'guard',
184
+ error: describeViolations({ role: guard_role, violations }),
185
+ })
156
186
  }
157
187
  if (!turn.ok) {
158
- return fail(turn.failure === 'engine' ? 'engine' : 'agent', turn.error)
188
+ return fail({
189
+ failure: turn.failure === 'engine' ? 'engine' : 'agent',
190
+ error: turn.error,
191
+ session_gone: turn.session_gone === true,
192
+ out_of_turns: turn.out_of_turns === true,
193
+ })
159
194
  }
160
195
  if (
161
196
  turn.structured_output === undefined ||
162
197
  turn.structured_output === null
163
198
  ) {
164
- return fail('result', `The ${role} finished with no structured output.`)
199
+ return fail({
200
+ failure: 'result',
201
+ error: `The ${role} finished with no structured output.`,
202
+ })
165
203
  }
166
204
  const checked = parseRoleResult({ role, output: turn.structured_output })
167
- if (!checked.ok) return fail('result', checked.error)
205
+ if (!checked.ok) return fail({ failure: 'result', error: checked.error })
168
206
  // The chores the writer asked for (#618), before any check or commit.
169
207
  const problems = await doChores({
170
208
  context,
@@ -174,7 +212,10 @@ export const runTurn = async ({
174
212
  guard: { role: guard_role, may_edit_tests },
175
213
  })
176
214
  if (problems.length > 0) {
177
- return fail('chore', problems.map((line) => `- ${line}`).join('\n'))
215
+ return fail({
216
+ failure: 'chore',
217
+ error: problems.map((line) => `- ${line}`).join('\n'),
218
+ })
178
219
  }
179
220
  context.journal.append({
180
221
  kind: 'agent_finished',
@@ -112,7 +112,8 @@ export const fixStep = (args: DecideArgs): FinalReviewAction | null => {
112
112
  const { review, run_branch, snapshot } = args
113
113
  const { fix } = review
114
114
  if (fix === null) return null
115
- if (fix.round > MAX_FIX_ROUNDS) {
115
+ // The fresh pass's one fix round runs past the cap (#548).
116
+ if (fix.round > MAX_FIX_ROUNDS && !review.fresh) {
116
117
  return stuck({
117
118
  reason: 'changes_requested',
118
119
  detail: `The final review still asks for changes after ${MAX_FIX_ROUNDS} fix rounds:\n${openFinalFindingsText({ findings: fix.findings })}`,
@@ -164,14 +165,19 @@ export const fixStep = (args: DecideArgs): FinalReviewAction | null => {
164
165
  })
165
166
  }
166
167
  const session_id = review.sessions.implementer
168
+ const message = gateFixMessage({ gates })
167
169
  if (session_id === undefined) {
168
- return launchFixer({ ...args, role: 'implementer' })
170
+ return launchFixer({
171
+ ...args,
172
+ role: 'implementer',
173
+ follow_up: message,
174
+ })
169
175
  }
170
176
  return {
171
177
  type: 'follow_up_final_fixer',
172
178
  role: 'implementer',
173
179
  session_id,
174
- message: gateFixMessage({ gates }),
180
+ message,
175
181
  }
176
182
  }
177
183
  if (review.commit === null) {
@@ -61,9 +61,14 @@ const lensStep = ({
61
61
  * tests once every lens finished, and settles only once they ran; failing
62
62
  * ones open the fix round (or join it). A fix round with no finding, opened only
63
63
  * by them, is just its gates (which run the slow tests again) with their
64
- * fix loop, the commit, and the push, and the review passes there.
64
+ * fix loop, the commit, and the push, with no lens re-review.
65
65
  * A review still asking for changes after `MAX_FIX_ROUNDS` fix
66
66
  * rounds is stuck, and so is a failed fix loop, a bad test, or a leftover.
67
+ * Once a fix commit was pushed and no blocking finding is left, the fresh
68
+ * pass (#548) has all five lenses read the whole branch again, without
69
+ * earlier findings; its blocking findings get one fix round (past the round
70
+ * cap, too) and one re-review, and what is still open then tops the PR
71
+ * instead of getting the review stuck.
67
72
  * Failed tries are capped per role like a ticket's. A stuck final review
68
73
  * waits for a reply; `ship` opens the PR anyway. A final review step cut
69
74
  * off by a crash `MAX_CRASHES` times in a row is stuck too.
@@ -117,15 +122,31 @@ export const decideFinalReview = (args: DecideArgs): FinalReviewDecision => {
117
122
  if (review.slow_due && review.slow_tests === null) {
118
123
  return working([{ type: 'run_final_slow_tests' }])
119
124
  }
120
- if (review.fix === null) return working([{ type: 'pass_final_review' }])
125
+ // Clean after a fix commit: every lens reads the whole branch once more,
126
+ // fresh, before the PR opens (#548). Once per final review; with no fix
127
+ // commit, round 1 was already a full fresh read.
128
+ const ending: FinalReviewAction =
129
+ review.fix_pushed && !review.fresh_started
130
+ ? {
131
+ type: 'start_final_review',
132
+ round: review.round + 1,
133
+ from_sha: run_branch.base_sha,
134
+ lenses: [...LENS_NAMES],
135
+ fresh: true,
136
+ }
137
+ : { type: 'pass_final_review' }
138
+ if (review.fix === null) return working([ending])
139
+ // The fresh pass's fix was re-checked: what is still open tops the PR,
140
+ // and the final review never gets stuck on it (#548).
141
+ if (review.fresh_started && !review.fresh) {
142
+ return working([{ type: 'pass_final_review' }])
143
+ }
121
144
  const step = fixStep(args)
122
145
  if (step !== null) return working([step])
123
146
  // A fix round opened only by failing slow tests ends at its push: its
124
147
  // gates ran the slow tests on what was pushed, and no lens had a finding
125
- // to look at again (#563).
126
- if (review.fix.findings.length === 0) {
127
- return working([{ type: 'pass_final_review' }])
128
- }
148
+ // to look at again (#563), so the fresh pass comes next.
149
+ if (review.fix.findings.length === 0) return working([ending])
129
150
  // The fixes are pushed: the lenses with blocking findings look again.
130
151
  return working([
131
152
  {
@@ -1,12 +1,22 @@
1
- import { pendingReplies, waitForReply, type StuckAction } from './decide-stuck'
1
+ import {
2
+ amountTokens,
3
+ pendingReplies,
4
+ waitForReply,
5
+ type StuckAction,
6
+ } from './decide-stuck'
2
7
  import { repoBusyStuckComment } from './repo-wait-text'
3
8
  import {
4
9
  pullRequestStuckComment,
10
+ replyAnswer,
5
11
  runBudgetDetail,
6
12
  runStuckComment,
7
13
  } from './stuck-text'
8
14
 
9
- import type { JournalRecord, RunStuckReason } from '../journal/journal-record'
15
+ import type {
16
+ JournalRecord,
17
+ ReplyProblem,
18
+ RunStuckReason,
19
+ } from '../journal/journal-record'
10
20
  import type { RunState } from '../journal/replay'
11
21
  import { runBudgetOf, runTokensIn } from '../limits/run-budget'
12
22
 
@@ -38,13 +48,16 @@ export type RunBudgetAction =
38
48
  /**
39
49
  * The run budget half of the decision step. Pure. Once the run's tokens
40
50
  * (`runTokensIn`) reach its budget (the config's `run_budget_tokens`, else
41
- * the default, once more for each `retry`), the run is stuck with the
42
- * reason "run budget": that is its only step, and nothing new starts. The
43
- * spec issue is told, then the run waits for the owner: a bare `retry`
44
- * adds one more full budget (replay does that), and `stop` ends the run.
45
- * The owner's other replies wait until the run carries on. A run whose PR
46
- * didn't open (#508) is stuck the same way, told why, and a `retry` tries
47
- * the PR again, adding no budget. `null` when
51
+ * the default, plus every top-up a `retry` granted), the run is stuck with
52
+ * the reason "run budget": that is its only step, and nothing new starts.
53
+ * The spec issue is told, then the run waits for the owner: a bare `retry`
54
+ * tops the budget up by half a budget, rounded down, and `retry +3M` by the
55
+ * amount named (#543; replay adds the top-up); `stop` ends the run. A zero
56
+ * or unreadable amount is sent back (`bad_amount`). The owner's other
57
+ * replies wait until the run carries on. A run whose PR didn't open (#508)
58
+ * or whose repo wait ran out is stuck the same way, told why, and a bare
59
+ * `retry` tries again, adding no budget; an amount there is sent back
60
+ * (`amount_only_for_run_budget`). `null` when
48
61
  * the budget has nothing to say: the run is under it, stopping, or its PR
49
62
  * is open.
50
63
  *
@@ -62,11 +75,11 @@ export const decideRunBudget = ({
62
75
  spec_number: number
63
76
  }): (RunBudgetAction | StuckAction)[] | null => {
64
77
  if (state.stop !== null || state.pull_request !== null) return null
65
- const { stuck, report, retries } = state.run_budget
78
+ const { stuck, report, top_ups } = state.run_budget
66
79
  const budget = runBudgetOf({ config: state.config })
67
80
  if (stuck === null) {
68
81
  const tokens = runTokensIn({ records })
69
- const allowed = budget * (1 + retries)
82
+ const allowed = budget + top_ups
70
83
  if (tokens < allowed) return null
71
84
  return [
72
85
  {
@@ -90,12 +103,48 @@ export const decideRunBudget = ({
90
103
  word === 'stop' || (word === 'retry' && ticket === null)
91
104
  )
92
105
  if (reply === undefined) return [waitForReply({ state, spec_number })]
106
+ const ignore = (reason: ReplyProblem): StuckAction[] => [
107
+ {
108
+ type: 'ignore_reply',
109
+ comment_id: reply.comment_id,
110
+ spec_number,
111
+ reason,
112
+ answer: replyAnswer({ problem: reason, named: null, stuck: [] }),
113
+ },
114
+ ]
115
+ if (reply.word === 'stop') {
116
+ return [
117
+ {
118
+ type: 'take_reply',
119
+ comment_id: reply.comment_id,
120
+ word: 'stop',
121
+ ticket: null,
122
+ },
123
+ ]
124
+ }
125
+ if (stuck.reason !== 'run_budget') {
126
+ if (reply.amount !== null) return ignore('amount_only_for_run_budget')
127
+ return [
128
+ {
129
+ type: 'take_reply',
130
+ comment_id: reply.comment_id,
131
+ word: 'retry',
132
+ ticket: null,
133
+ },
134
+ ]
135
+ }
136
+ const top_up =
137
+ reply.amount === null
138
+ ? Math.floor(budget / 2)
139
+ : amountTokens({ amount: reply.amount })
140
+ if (top_up === null) return ignore('bad_amount')
93
141
  return [
94
142
  {
95
143
  type: 'take_reply',
96
144
  comment_id: reply.comment_id,
97
- word: reply.word === 'stop' ? 'stop' : 'retry',
145
+ word: 'retry',
98
146
  ticket: null,
147
+ top_up,
99
148
  },
100
149
  ]
101
150
  }