@alecsibilia/luca 14.0.0-alpha.2 → 14.0.0-alpha.21

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 (321) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +7 -2
  3. package/board/client/board-look.ts +31 -1
  4. package/board/client/board-panel-parts.tsx +150 -0
  5. package/board/client/board-panel-stages.ts +99 -0
  6. package/board/client/board-panel-styles.ts +223 -0
  7. package/board/client/board-panel.tsx +77 -1275
  8. package/board/client/engine-life.ts +48 -0
  9. package/board/client/final-review-stack.tsx +144 -0
  10. package/board/client/heartbeat.tsx +61 -0
  11. package/board/client/helper-lines.tsx +69 -0
  12. package/board/client/live-feed.tsx +86 -0
  13. package/board/client/live-line.ts +73 -0
  14. package/board/client/model-line.tsx +178 -0
  15. package/board/client/needs-you-section.tsx +133 -0
  16. package/board/client/one-line.tsx +193 -0
  17. package/board/client/repo-wait-banner.tsx +69 -0
  18. package/board/client/run-footer.tsx +68 -0
  19. package/board/client/run-header.tsx +189 -0
  20. package/board/client/run-picker.tsx +98 -0
  21. package/board/client/status-bar.tsx +131 -0
  22. package/board/client/step-dots.tsx +127 -0
  23. package/board/client/stuck-actions.tsx +269 -0
  24. package/board/client/ticket-card-view.tsx +238 -0
  25. package/board/client/ticket-detail.tsx +87 -0
  26. package/board/client/ticket-stack.tsx +119 -0
  27. package/board/client/ticket-wait.ts +188 -0
  28. package/board/client/timeline-rows.tsx +41 -14
  29. package/board/client/usage-line.tsx +51 -0
  30. package/board/client/wait-line.tsx +39 -0
  31. package/board/index.client.tsx +1 -1
  32. package/board/index.server.ts +7 -1
  33. package/board/package.json +1 -1
  34. package/board/server/board-panel-state-helpers.ts +67 -0
  35. package/board/server/board-server-check.ts +294 -0
  36. package/board/server/board-server-engine.ts +129 -0
  37. package/board/server/board-server-events.ts +134 -0
  38. package/board/server/board-server-read.ts +190 -0
  39. package/board/server/board-server-resume.ts +164 -0
  40. package/board/server/board-server-start.ts +174 -0
  41. package/board/server/board-server-store.ts +281 -0
  42. package/board/server/board-server-types.ts +114 -0
  43. package/board/server/board-server-util.ts +4 -0
  44. package/board/server/board-server.ts +52 -943
  45. package/board/server/board-vocabulary-engine.ts +144 -0
  46. package/board/server/board-vocabulary-review.ts +115 -0
  47. package/board/server/board-vocabulary-run.ts +145 -0
  48. package/board/server/board-vocabulary-shapes.ts +153 -0
  49. package/board/server/board-vocabulary.ts +20 -375
  50. package/board/server/current-step-panel-helpers.ts +108 -0
  51. package/board/server/describe-record.ts +403 -0
  52. package/board/server/engine-launch.ts +20 -3
  53. package/board/server/engine-restart-helpers.ts +105 -0
  54. package/board/server/engine-watch.ts +45 -5
  55. package/board/server/journal-rebuild-helpers.ts +169 -0
  56. package/board/server/live-file.ts +130 -0
  57. package/board/server/luca-version.ts +1 -1
  58. package/board/server/make-rows.ts +14 -629
  59. package/board/server/record-texts.ts +350 -0
  60. package/board/server/reduce-board-agent-start.ts +224 -0
  61. package/board/server/reduce-board-agents.ts +160 -0
  62. package/board/server/reduce-board-counters.ts +138 -0
  63. package/board/server/reduce-board-create.ts +148 -0
  64. package/board/server/reduce-board-final-review.ts +174 -0
  65. package/board/server/reduce-board-needs-you.ts +283 -0
  66. package/board/server/reduce-board-reasons.ts +59 -0
  67. package/board/server/reduce-board-run.ts +255 -0
  68. package/board/server/reduce-board-settle.ts +88 -0
  69. package/board/server/reduce-board-steps.ts +226 -0
  70. package/board/server/reduce-board-text.ts +139 -0
  71. package/board/server/reduce-board-ticket-checks.ts +168 -0
  72. package/board/server/reduce-board-ticket-join.ts +144 -0
  73. package/board/server/reduce-board-ticket-start.ts +132 -0
  74. package/board/server/reduce-board-update.ts +117 -0
  75. package/board/server/reduce-board-usage.ts +247 -0
  76. package/board/server/reduce-board.ts +100 -1917
  77. package/board/server/reply-post.ts +407 -0
  78. package/board/server/run-registry.ts +8 -4
  79. package/board/server/ticket-stages-panel-helpers.ts +296 -0
  80. package/board/shared/board-rows.ts +9 -0
  81. package/board/shared/board-rpc.ts +42 -0
  82. package/board/shared/board-state-counts.ts +77 -0
  83. package/board/shared/board-state-live.ts +99 -0
  84. package/board/shared/board-state-review.ts +59 -0
  85. package/board/shared/board-state-run.ts +236 -0
  86. package/board/shared/board-state-ticket.ts +257 -0
  87. package/board/shared/board-state-usage.ts +107 -0
  88. package/board/shared/board-state.ts +97 -567
  89. package/board/shared/reply-actions.ts +207 -0
  90. package/engine/agents/agent-launcher.ts +27 -4
  91. package/engine/agents/claude-launcher-helpers.ts +233 -0
  92. package/engine/agents/claude-launcher.ts +28 -669
  93. package/engine/agents/claude-messages.ts +212 -0
  94. package/engine/agents/claude-options.ts +43 -8
  95. package/engine/agents/common-instructions.ts +138 -0
  96. package/engine/agents/input-channel.ts +36 -0
  97. package/engine/agents/launch-paths.ts +29 -0
  98. package/engine/agents/launch-session.ts +227 -0
  99. package/engine/agents/learner-instructions.ts +31 -0
  100. package/engine/agents/open-session.ts +210 -0
  101. package/engine/agents/prompt-sections.ts +95 -0
  102. package/engine/agents/rejoin-prompts.ts +135 -0
  103. package/engine/agents/reviewer-instructions.ts +99 -0
  104. package/engine/agents/role-instructions.ts +31 -244
  105. package/engine/agents/role-prompts.ts +39 -109
  106. package/engine/agents/role-results.ts +106 -7
  107. package/engine/agents/scripted-launcher.ts +21 -6
  108. package/engine/agents/session-pool.ts +124 -0
  109. package/engine/agents/turn-progress.ts +283 -0
  110. package/engine/agents/writer-instructions.ts +151 -0
  111. package/engine/cli/computer-adapters-real.ts +15 -3
  112. package/engine/cli/computer-adapters.ts +2 -0
  113. package/engine/cli/computer-checks.ts +87 -4
  114. package/engine/cli/config-reload-text.ts +39 -0
  115. package/engine/cli/decision-model-setup.ts +70 -0
  116. package/engine/cli/doctor-command.ts +1 -0
  117. package/engine/cli/doctor.ts +21 -3
  118. package/engine/cli/going-runs.ts +192 -36
  119. package/engine/cli/init-command.ts +1 -0
  120. package/engine/cli/init.ts +23 -1
  121. package/engine/cli/live-runs.ts +73 -0
  122. package/engine/cli/luca-run.ts +42 -7
  123. package/engine/cli/luca-skills.ts +183 -0
  124. package/engine/cli/luca.ts +8 -1
  125. package/engine/cli/models-command.ts +119 -0
  126. package/engine/cli/read-run.ts +81 -0
  127. package/engine/cli/run-args.ts +33 -6
  128. package/engine/cli/run-demo.ts +143 -0
  129. package/engine/cli/run-end.ts +184 -0
  130. package/engine/cli/run-modes-helpers.ts +81 -0
  131. package/engine/cli/run-modes.ts +62 -439
  132. package/engine/cli/setup.ts +5 -48
  133. package/engine/cli/unfinished-runs.ts +122 -0
  134. package/engine/cli/upgrade-command.ts +212 -30
  135. package/engine/cli/upgrade-finish.ts +149 -0
  136. package/engine/cli/upgrade.ts +112 -71
  137. package/engine/config/config-fields.ts +127 -0
  138. package/engine/config/config-reload.ts +250 -0
  139. package/engine/config/engine-config-schema.ts +235 -0
  140. package/engine/config/engine-config.ts +72 -142
  141. package/engine/config/test-commands.ts +102 -0
  142. package/engine/core/agent-guards-helpers.ts +84 -0
  143. package/engine/core/agent-turn.ts +395 -0
  144. package/engine/core/already-done.ts +44 -1
  145. package/engine/core/build-action.ts +214 -0
  146. package/engine/core/build-context.ts +135 -0
  147. package/engine/core/build-launch.ts +259 -0
  148. package/engine/core/commit-in.ts +282 -0
  149. package/engine/core/decide-build-early.ts +108 -0
  150. package/engine/core/decide-build-ending.ts +145 -0
  151. package/engine/core/decide-build-helpers.ts +87 -0
  152. package/engine/core/decide-build-tickets.ts +207 -0
  153. package/engine/core/decide-build-view.ts +103 -0
  154. package/engine/core/decide-build.ts +101 -1368
  155. package/engine/core/decide-final-fix.ts +203 -0
  156. package/engine/core/decide-final-review.ts +55 -310
  157. package/engine/core/decide-red-green.ts +398 -0
  158. package/engine/core/decide-rejoin.ts +259 -0
  159. package/engine/core/decide-repo-wait.ts +129 -0
  160. package/engine/core/decide-run-budget.ts +86 -12
  161. package/engine/core/decide-stuck-helpers.ts +127 -0
  162. package/engine/core/decide-stuck.ts +60 -8
  163. package/engine/core/decide-ticket-review.ts +95 -0
  164. package/engine/core/decide-ticket.ts +267 -0
  165. package/engine/core/decide-waits.ts +47 -0
  166. package/engine/core/decide.ts +74 -23
  167. package/engine/core/engine-chores.ts +163 -0
  168. package/engine/core/environment-rerun.ts +83 -0
  169. package/engine/core/evidence-text.ts +84 -0
  170. package/engine/core/execute-action.ts +303 -0
  171. package/engine/core/execute-already-done-check.ts +133 -0
  172. package/engine/core/execute-build.ts +68 -944
  173. package/engine/core/execute-changeset.ts +5 -7
  174. package/engine/core/execute-final-review.ts +125 -13
  175. package/engine/core/execute-gates.ts +182 -0
  176. package/engine/core/execute-intake.ts +130 -0
  177. package/engine/core/execute-join.ts +276 -0
  178. package/engine/core/execute-memory.ts +12 -0
  179. package/engine/core/execute-pull-request.ts +84 -0
  180. package/engine/core/execute-red-check.ts +219 -0
  181. package/engine/core/execute-repo-wait.ts +129 -0
  182. package/engine/core/execute-stuck.ts +25 -10
  183. package/engine/core/execute-waits.ts +158 -0
  184. package/engine/core/execute-with-jev.ts +118 -0
  185. package/engine/core/execute.ts +124 -831
  186. package/engine/core/final-failure-cap.ts +38 -0
  187. package/engine/core/final-review-action.ts +125 -0
  188. package/engine/core/final-review-text.ts +63 -17
  189. package/engine/core/final-stuck-text.ts +86 -0
  190. package/engine/core/fix-loop-text.ts +49 -2
  191. package/engine/core/left-out.ts +120 -0
  192. package/engine/core/live-file.ts +372 -0
  193. package/engine/core/loop-caps.ts +25 -2
  194. package/engine/core/not-in-run.ts +7 -3
  195. package/engine/core/pull-request-text.ts +255 -6
  196. package/engine/core/repo-wait-text.ts +81 -0
  197. package/engine/core/review-text.ts +55 -1
  198. package/engine/core/run-crash-recovery-helpers.ts +16 -0
  199. package/engine/core/run-session-close-helpers.ts +96 -0
  200. package/engine/core/run-start.ts +148 -0
  201. package/engine/core/run-stuck-text.ts +86 -0
  202. package/engine/core/schedule-rules.ts +193 -0
  203. package/engine/core/stuck-detail-text.ts +89 -0
  204. package/engine/core/stuck-format.ts +22 -0
  205. package/engine/core/stuck-text.ts +29 -332
  206. package/engine/core/ticket-stuck-text.ts +192 -0
  207. package/engine/core/worktree-changes.ts +106 -0
  208. package/engine/gates/already-done-check.ts +120 -0
  209. package/engine/gates/feature-map.ts +96 -0
  210. package/engine/gates/gate-runner.ts +108 -108
  211. package/engine/gates/gate-schemas.ts +61 -2
  212. package/engine/gates/leftover-scan.ts +154 -31
  213. package/engine/gates/new-tests.ts +45 -0
  214. package/engine/gates/own-files-check.ts +98 -0
  215. package/engine/gates/prepare-check.ts +60 -0
  216. package/engine/gates/prepare-slots.ts +99 -0
  217. package/engine/gates/red-check.ts +184 -16
  218. package/engine/gates/shell-check.ts +64 -0
  219. package/engine/gates/slot-waits.ts +55 -0
  220. package/engine/gates/slow-test-runner.ts +59 -0
  221. package/engine/gates/test-checks.ts +161 -0
  222. package/engine/gates/test-runner.ts +164 -5
  223. package/engine/gates/test-slots.ts +48 -0
  224. package/engine/git/git-adapter.ts +55 -396
  225. package/engine/git/git-commits.ts +132 -0
  226. package/engine/git/git-join.ts +139 -0
  227. package/engine/git/git-read.ts +123 -0
  228. package/engine/git/git-run.ts +32 -0
  229. package/engine/git/git-worktrees.ts +143 -0
  230. package/engine/git/outside-links.ts +55 -0
  231. package/engine/guards/after-turn-check.ts +31 -5
  232. package/engine/guards/guard-hook.ts +60 -12
  233. package/engine/guards/guard-roles.ts +68 -0
  234. package/engine/guards/helper-rules.ts +92 -0
  235. package/engine/guards/permission-rules.ts +161 -0
  236. package/engine/guards/role-rules-helpers.ts +48 -0
  237. package/engine/guards/role-rules.ts +34 -606
  238. package/engine/guards/shell-rules.ts +344 -0
  239. package/engine/guards/worktree-git.ts +208 -0
  240. package/engine/guards/worktree-snapshot.ts +412 -0
  241. package/engine/guards/worktree-state.ts +28 -825
  242. package/engine/guards/worktree-undo.ts +260 -0
  243. package/engine/guards/write-rules.ts +149 -0
  244. package/engine/index.ts +30 -9
  245. package/engine/intake/criteria-checks.ts +114 -0
  246. package/engine/intake/intake-checks.ts +164 -30
  247. package/engine/intake/intake-schemas.ts +37 -2
  248. package/engine/jev/clef-client.ts +259 -0
  249. package/engine/jev/decision-model-credentials.ts +212 -0
  250. package/engine/jev/jev-jobs.ts +144 -60
  251. package/engine/jev/jev-schemas.ts +86 -5
  252. package/engine/jev/jev-shadow-helpers.ts +218 -0
  253. package/engine/jev/jev-shadow.ts +109 -64
  254. package/engine/jev/model-pick.ts +275 -0
  255. package/engine/journal/agent-entries.ts +167 -0
  256. package/engine/journal/agent-message-entries.ts +72 -0
  257. package/engine/journal/agent-turn-replay.ts +203 -0
  258. package/engine/journal/already-done-entries.ts +55 -0
  259. package/engine/journal/commit-entries.ts +56 -0
  260. package/engine/journal/cross-ticket-replay.ts +222 -0
  261. package/engine/journal/decision-model-entries.ts +86 -0
  262. package/engine/journal/entry-fields.ts +10 -0
  263. package/engine/journal/final-review-entries.ts +128 -0
  264. package/engine/journal/final-review-replay.ts +324 -0
  265. package/engine/journal/final-review-round.ts +320 -0
  266. package/engine/journal/final-review-state.ts +180 -0
  267. package/engine/journal/gate-entries.ts +81 -0
  268. package/engine/journal/intake-entries.ts +87 -0
  269. package/engine/journal/join-entries.ts +126 -0
  270. package/engine/journal/journal-entry-schema.ts +218 -0
  271. package/engine/journal/journal-kinds.ts +97 -0
  272. package/engine/journal/journal-record-schema.ts +218 -0
  273. package/engine/journal/journal-record.ts +43 -1400
  274. package/engine/journal/journal.ts +6 -1
  275. package/engine/journal/limit-entries.ts +90 -0
  276. package/engine/journal/memory-entries.ts +124 -0
  277. package/engine/journal/memory-replay.ts +157 -0
  278. package/engine/journal/plan-replay.ts +139 -0
  279. package/engine/journal/pull-request-entries.ts +41 -0
  280. package/engine/journal/replay.ts +157 -2144
  281. package/engine/journal/reply-replay.ts +144 -0
  282. package/engine/journal/repo-wait-entries.ts +53 -0
  283. package/engine/journal/run-entries.ts +172 -0
  284. package/engine/journal/run-state.ts +264 -0
  285. package/engine/journal/shared-failure-replay.ts +133 -0
  286. package/engine/journal/stuck-entries.ts +193 -0
  287. package/engine/journal/ticket-progress.ts +392 -0
  288. package/engine/journal/ticket-replay.ts +411 -0
  289. package/engine/journal/ticket-result-replay.ts +364 -0
  290. package/engine/journal/ticket-retry-replay.ts +142 -0
  291. package/engine/journal/worktree-entries.ts +122 -0
  292. package/engine/models/model-score.ts +512 -0
  293. package/engine/shell/run-command.ts +187 -18
  294. package/engine/testing/agent-turn-fixtures.ts +287 -0
  295. package/engine/testing/already-done-fixtures.ts +97 -0
  296. package/engine/testing/build-fixtures.ts +68 -973
  297. package/engine/testing/checkouts-fixtures.ts +48 -0
  298. package/engine/testing/final-review-fixtures.ts +62 -1
  299. package/engine/testing/final-review-steps.ts +143 -0
  300. package/engine/testing/limit-fixtures.ts +85 -0
  301. package/engine/testing/live-file-fixtures.ts +57 -0
  302. package/engine/testing/practice-repo.ts +10 -4
  303. package/engine/testing/practice-run.ts +1 -1
  304. package/engine/testing/reply-fixtures.ts +88 -0
  305. package/engine/testing/run-budget-fixtures.ts +165 -0
  306. package/engine/testing/run-fixtures.ts +219 -0
  307. package/engine/testing/sdk-play.ts +177 -0
  308. package/engine/testing/setup-repo.ts +194 -0
  309. package/engine/testing/step-record-fixtures.ts +55 -0
  310. package/engine/testing/ticket-step-fixtures.ts +272 -0
  311. package/engine/testing/wait-scenarios.ts +186 -0
  312. package/engine/tracker/gh-retry.ts +217 -0
  313. package/engine/tracker/github-tracker.ts +310 -90
  314. package/engine/tracker/in-memory-tracker.ts +8 -4
  315. package/engine/tracker/tracker.ts +131 -6
  316. package/package.json +2 -1
  317. package/skills/luca-retro/SKILL.md +74 -0
  318. package/skills/luca-retro/scripts/retro-summary.ts +1356 -0
  319. package/skills/luca-unstick/SKILL.md +153 -0
  320. package/skills/luca-unstick/scripts/stuck-summary.ts +1063 -0
  321. package/engine/jev/jev-client.ts +0 -173
@@ -0,0 +1,129 @@
1
+ import { decideRunBudget, type RunBudgetAction } from './decide-run-budget'
2
+ import type { StuckAction } from './decide-stuck'
3
+
4
+ import type { GoingRun } from '../cli/going-runs'
5
+ import type { JournalRecord } from '../journal/journal-record'
6
+ import type { RunState } from '../journal/replay'
7
+
8
+ /** How long a repo wait lasts before the run is stuck (`repo_busy`). */
9
+ export const REPO_WAIT_MAX_MS = 24 * 60 * 60 * 1000
10
+
11
+ /** How long each nap of a repo wait is, between looks at the going runs. */
12
+ export const REPO_WAIT_NAP_MS = 60_000
13
+
14
+ /** An earlier run on the repo that a repo wait waits for. */
15
+ export type RepoWaitRun = { run_id: string; spec: number | null }
16
+
17
+ /** The run's steps for the repo wait (#615), before intake. */
18
+ export type RepoWaitAction =
19
+ /** Tell the spec issue which runs the run waits for, and journal it. */
20
+ | { type: 'start_repo_wait'; spec_number: number; runs: RepoWaitRun[] }
21
+ /**
22
+ * Nap and look again until no earlier run is going, then journal the
23
+ * wait's end; at `until`, the run is stuck as `repo_busy`.
24
+ */
25
+ | { type: 'wait_for_repo'; spec_number: number; until: string }
26
+
27
+ /**
28
+ * The going runs a repo wait waits for: those on the same repo as this
29
+ * run, started earlier by `run_started` time, then by run id, so two runs
30
+ * started together never wait for each other. A run with no repo (an older
31
+ * journal) or no start time never counts, and neither does this run. Pure.
32
+ *
33
+ * @example
34
+ * earlierRuns({ runs: going.runs, run_id: 'run-b', records })
35
+ * // [{ run_id: 'run-a', spec: 7 }]
36
+ */
37
+ export const earlierRuns = ({
38
+ runs,
39
+ run_id,
40
+ records,
41
+ }: {
42
+ runs: Pick<GoingRun, 'run_id' | 'spec' | 'repo' | 'started_at'>[]
43
+ /** This run's id. */
44
+ run_id: string
45
+ /** This run's journal. */
46
+ records: JournalRecord[]
47
+ }): RepoWaitRun[] => {
48
+ const start = records.find((record) => record.kind === 'run_started')
49
+ if (start?.kind !== 'run_started' || start.content.repo === null) return []
50
+ const { repo } = start.content
51
+ const own = Date.parse(start.time)
52
+ return runs
53
+ .filter((run) => {
54
+ if (run.repo !== repo || run.run_id === run_id) return false
55
+ if (run.started_at === null) return false
56
+ const at = Date.parse(run.started_at)
57
+ return at < own || (at === own && run.run_id < run_id)
58
+ })
59
+ .map(({ run_id: id, spec }) => ({ run_id: id, spec }))
60
+ }
61
+
62
+ /**
63
+ * Whether the run has yet to look for earlier runs on its repo: intake
64
+ * hasn't read the tickets, and no repo wait began or was skipped. Pure, and
65
+ * cheap: it reads only the record kinds.
66
+ */
67
+ export const repoWaitUnchecked = ({
68
+ records,
69
+ }: {
70
+ records: JournalRecord[]
71
+ }): boolean =>
72
+ records.length > 0 &&
73
+ !records.some(
74
+ ({ kind }) =>
75
+ kind === 'intake_read' ||
76
+ kind === 'repo_wait_started' ||
77
+ kind === 'repo_wait_skipped'
78
+ )
79
+
80
+ /**
81
+ * The repo wait half of the decision step, before intake (#615). Pure. A
82
+ * run with `earlier_runs` going on its repo waits for them: it tells the
83
+ * spec issue once, then naps until none is going. A run that waited 24
84
+ * hours is stuck (`repo_busy`) until the owner's `retry` (another 24
85
+ * hours) or `stop`. A skipped or ended wait, and a run with no earlier
86
+ * runs (or none given), go on to intake: `null`.
87
+ */
88
+ export const decideRepoWait = ({
89
+ state,
90
+ records,
91
+ spec_number,
92
+ earlier_runs,
93
+ }: {
94
+ state: RunState
95
+ records: JournalRecord[]
96
+ spec_number: number
97
+ /** The earlier runs going on the repo; left out, there is no wait. */
98
+ earlier_runs: RepoWaitRun[] | undefined
99
+ }):
100
+ | (
101
+ | RepoWaitAction
102
+ | RunBudgetAction
103
+ | StuckAction
104
+ | { type: 'done'; outcome: 'stopped_by_user' }
105
+ )[]
106
+ | null => {
107
+ if (state.phase !== 'started') return null
108
+ if (state.stop !== null) {
109
+ return [{ type: 'done', outcome: 'stopped_by_user' }]
110
+ }
111
+ if (state.run_budget.stuck !== null) {
112
+ return decideRunBudget({ state, records, spec_number })
113
+ }
114
+ const { status, since } = state.repo_wait
115
+ if (status === 'waiting' && since !== null) {
116
+ return [
117
+ {
118
+ type: 'wait_for_repo',
119
+ spec_number,
120
+ until: new Date(
121
+ Date.parse(since) + REPO_WAIT_MAX_MS
122
+ ).toISOString(),
123
+ },
124
+ ]
125
+ }
126
+ if (status !== 'none' || earlier_runs === undefined) return null
127
+ if (earlier_runs.length === 0) return null
128
+ return [{ type: 'start_repo_wait', spec_number, runs: earlier_runs }]
129
+ }
@@ -1,10 +1,43 @@
1
- import { pendingReplies, waitForReply, type StuckAction } from './decide-stuck'
2
- import { runBudgetDetail, runStuckComment } from './stuck-text'
1
+ import {
2
+ amountTokens,
3
+ pendingReplies,
4
+ waitForReply,
5
+ type StuckAction,
6
+ } from './decide-stuck'
7
+ import { repoBusyStuckComment } from './repo-wait-text'
8
+ import {
9
+ pullRequestStuckComment,
10
+ replyAnswer,
11
+ runBudgetDetail,
12
+ runStuckComment,
13
+ } from './stuck-text'
3
14
 
4
- import type { JournalRecord, RunStuckReason } from '../journal/journal-record'
15
+ import type {
16
+ JournalRecord,
17
+ ReplyProblem,
18
+ RunStuckReason,
19
+ } from '../journal/journal-record'
5
20
  import type { RunState } from '../journal/replay'
6
21
  import { runBudgetOf, runTokensIn } from '../limits/run-budget'
7
22
 
23
+ /** The spec issue's comment for the stuck run, by why it is stuck. */
24
+ const stuckBody = ({
25
+ stuck,
26
+ budget,
27
+ }: {
28
+ stuck: { reason: RunStuckReason; detail: string }
29
+ budget: number
30
+ }): string => {
31
+ switch (stuck.reason) {
32
+ case 'pull_request_failed':
33
+ return pullRequestStuckComment({ detail: stuck.detail })
34
+ case 'repo_busy':
35
+ return repoBusyStuckComment({ detail: stuck.detail })
36
+ case 'run_budget':
37
+ return runStuckComment({ detail: stuck.detail, budget })
38
+ }
39
+ }
40
+
8
41
  /** The run's steps with its budget of tokens (#435). */
9
42
  export type RunBudgetAction =
10
43
  /** The run used up its budget: journal it as stuck. */
@@ -15,11 +48,16 @@ export type RunBudgetAction =
15
48
  /**
16
49
  * The run budget half of the decision step. Pure. Once the run's tokens
17
50
  * (`runTokensIn`) reach its budget (the config's `run_budget_tokens`, else
18
- * the default, once more for each `retry`), the run is stuck with the
19
- * reason "run budget": that is its only step, and nothing new starts. The
20
- * spec issue is told, then the run waits for the owner: a bare `retry`
21
- * 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
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
23
61
  * the budget has nothing to say: the run is under it, stopping, or its PR
24
62
  * is open.
25
63
  *
@@ -37,11 +75,11 @@ export const decideRunBudget = ({
37
75
  spec_number: number
38
76
  }): (RunBudgetAction | StuckAction)[] | null => {
39
77
  if (state.stop !== null || state.pull_request !== null) return null
40
- const { stuck, report, retries } = state.run_budget
78
+ const { stuck, report, top_ups } = state.run_budget
41
79
  const budget = runBudgetOf({ config: state.config })
42
80
  if (stuck === null) {
43
81
  const tokens = runTokensIn({ records })
44
- const allowed = budget * (1 + retries)
82
+ const allowed = budget + top_ups
45
83
  if (tokens < allowed) return null
46
84
  return [
47
85
  {
@@ -56,7 +94,7 @@ export const decideRunBudget = ({
56
94
  {
57
95
  type: 'report_run_stuck',
58
96
  spec_number,
59
- body: runStuckComment({ detail: stuck.detail, budget }),
97
+ body: stuckBody({ stuck, budget }),
60
98
  },
61
99
  ]
62
100
  }
@@ -65,12 +103,48 @@ export const decideRunBudget = ({
65
103
  word === 'stop' || (word === 'retry' && ticket === null)
66
104
  )
67
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')
68
141
  return [
69
142
  {
70
143
  type: 'take_reply',
71
144
  comment_id: reply.comment_id,
72
- word: reply.word === 'stop' ? 'stop' : 'retry',
145
+ word: 'retry',
73
146
  ticket: null,
147
+ top_up,
74
148
  },
75
149
  ]
76
150
  }
@@ -0,0 +1,127 @@
1
+ import { decideSteps } from './decide'
2
+ import { MAX_FIX_ROUNDS } from './decide-build'
3
+
4
+ import type { TicketSnapshot } from '../intake/intake-schemas'
5
+ import type { JournalEntry } from '../journal/journal-record'
6
+ import {
7
+ agentStarted,
8
+ baselineTests,
9
+ gatesRun,
10
+ implemented,
11
+ intakePassed,
12
+ practiceTicket,
13
+ SESSIONS,
14
+ ticketBuilt,
15
+ ticketStuck,
16
+ worktreeReset,
17
+ ticketWorktreeCreated,
18
+ withInstalls,
19
+ } from '../testing/build-fixtures'
20
+ import { recordsFrom } from '../testing/intake-fixtures'
21
+
22
+ /**
23
+ * Fixtures shared by the decide-stuck tests (#366): the tickets, the helpers
24
+ * that decide the next steps, and journals of a ticket stuck for good.
25
+ */
26
+
27
+ export const SUM = practiceTicket({ number: 11, title: 'Add sum' })
28
+ export const PRODUCT = practiceTicket({ number: 12, title: 'Add product' })
29
+ export const AVERAGE = practiceTicket({
30
+ number: 13,
31
+ title: 'Add average',
32
+ blockers: [11],
33
+ })
34
+
35
+ export const stepsAfter = ({
36
+ tickets,
37
+ entries,
38
+ }: {
39
+ tickets: TicketSnapshot[]
40
+ entries: JournalEntry[]
41
+ }) =>
42
+ decideSteps({
43
+ records: recordsFrom({
44
+ entries: [
45
+ ...intakePassed({ tickets }),
46
+ ...withInstalls({ entries }),
47
+ ],
48
+ }),
49
+ })
50
+
51
+ export const twoSteps = (entries: JournalEntry[]) =>
52
+ stepsAfter({ tickets: [SUM, PRODUCT], entries })
53
+
54
+ export const oneSteps = (entries: JournalEntry[]) =>
55
+ stepsAfter({ tickets: [SUM], entries })
56
+
57
+ /** #11 failed its gates after every fix round. */
58
+ export const gatesFailedForGood = (): JournalEntry[] => [
59
+ ticketWorktreeCreated({ ticket: 11 }),
60
+ baselineTests({ ticket: 11 }),
61
+ ...ticketBuilt({ ticket: 11 }).slice(2, 6),
62
+ implemented({ ticket: 11 }),
63
+ gatesRun({ ticket: 11, target: 'ticket', ok: false }),
64
+ ...Array.from({ length: MAX_FIX_ROUNDS }, () => [
65
+ agentStarted({
66
+ ticket: 11,
67
+ role: 'implementer',
68
+ follow_up_of: SESSIONS.implementer,
69
+ }),
70
+ implemented({ ticket: 11 }),
71
+ gatesRun({ ticket: 11, target: 'ticket', ok: false }),
72
+ ]).flat(),
73
+ ticketStuck({
74
+ ticket: 11,
75
+ reason: 'gates_failed',
76
+ detail: 'The gates still fail after 3 fix rounds:\ntest: 1 fail',
77
+ }),
78
+ ]
79
+
80
+ /**
81
+ * #11's one mapped test passed with no expect() twice (#581): the engine sent
82
+ * it back once, and the second time the ticket is stuck as `bad_test`.
83
+ */
84
+ export const emptyTestForGood = (): JournalEntry[] => {
85
+ const emptyGates = gatesRun({
86
+ ticket: 11,
87
+ target: 'ticket',
88
+ ok: true,
89
+ cases: [
90
+ {
91
+ file: 'src/sum.test.ts',
92
+ full_name: 'sum adds two numbers',
93
+ status: 'passed',
94
+ assertions: 0,
95
+ },
96
+ ],
97
+ })
98
+ return [
99
+ ticketWorktreeCreated({ ticket: 11 }),
100
+ baselineTests({ ticket: 11 }),
101
+ ...ticketBuilt({ ticket: 11 }).slice(2, 6),
102
+ implemented({ ticket: 11 }),
103
+ emptyGates,
104
+ worktreeReset({
105
+ ticket: 11,
106
+ bad_test: {
107
+ file: 'src/sum.test.ts',
108
+ name: 'sum adds two numbers',
109
+ reason: 'It passes but runs no expect(), so it checks nothing (#581).',
110
+ },
111
+ }),
112
+ ...ticketBuilt({ ticket: 11 }).slice(2, 6),
113
+ implemented({ ticket: 11, session_id: 'impl-2' }),
114
+ emptyGates,
115
+ ticketStuck({
116
+ ticket: 11,
117
+ reason: 'bad_test',
118
+ detail: 'A mapped test still checks nothing after a fresh test-writer.',
119
+ }),
120
+ ]
121
+ }
122
+
123
+ /** #12 has its worktree and baseline: its test-writer is next. */
124
+ export const productStarted = (): JournalEntry[] => [
125
+ ticketWorktreeCreated({ ticket: 12 }),
126
+ baselineTests({ ticket: 12 }),
127
+ ]
@@ -32,12 +32,16 @@ export type StuckAction =
32
32
  * work waits for a reply; other tickets keep building meanwhile.
33
33
  */
34
34
  | { type: 'wait_for_reply'; spec_number: number; since_id: number }
35
- /** The owner's comment is a reply: journal it. */
35
+ /**
36
+ * The owner's comment is a reply: journal it. A `retry` of the run stuck
37
+ * on its budget carries the tokens it adds (`top_up`, #543).
38
+ */
36
39
  | {
37
40
  type: 'take_reply'
38
41
  comment_id: number
39
42
  word: ReplyWord
40
43
  ticket: number | null
44
+ top_up?: number
41
45
  }
42
46
  /** The owner's comment looks like a reply but can't be used: say why. */
43
47
  | {
@@ -70,24 +74,64 @@ export type StuckAction =
70
74
  body: string
71
75
  }
72
76
 
73
- /** A reply word, and the ticket it names if any, from a comment's text. */
74
- type ParsedReply = { word: ReplyWord | 'ship'; ticket: number | null }
77
+ /**
78
+ * A reply word, the ticket it names if any, and the text after `retry +`
79
+ * (`amount`, #543) if any, from a comment's text.
80
+ */
81
+ type ParsedReply = {
82
+ word: ReplyWord | 'ship'
83
+ ticket: number | null
84
+ amount: string | null
85
+ }
75
86
 
76
87
  const REPLY_PATTERN = /^(retry|skip|stop|ship)(?:\s+#?(\d+))?[\s.!]*$/i
77
88
 
89
+ const AMOUNT_REPLY_PATTERN = /^retry\s+\+(\S*?)[\s.!]*$/i
90
+
78
91
  /**
79
92
  * Reads a one-word reply: `retry`, `skip`, `stop`, or `ship`, optionally
80
- * naming a ticket (`retry #12`, `skip 12`). Anything else is not a reply.
93
+ * naming a ticket (`retry #12`, `skip 12`), or `retry` with an amount
94
+ * (`retry +3M`). Anything else is not a reply.
81
95
  *
82
96
  * @example
83
- * parseReply({ body: 'Retry #12' }) // { word: 'retry', ticket: 12 }
97
+ * parseReply({ body: 'Retry #12' }) // { word: 'retry', ticket: 12, amount: null }
98
+ * parseReply({ body: 'retry +3M' }) // { word: 'retry', ticket: null, amount: '3M' }
84
99
  * parseReply({ body: 'Looking at it.' }) // null
85
100
  */
86
101
  export const parseReply = ({ body }: { body: string }): ParsedReply | null => {
102
+ const amount = AMOUNT_REPLY_PATTERN.exec(body.trim())
103
+ if (amount !== null) {
104
+ return { word: 'retry', ticket: null, amount: amount[1] ?? '' }
105
+ }
87
106
  const match = REPLY_PATTERN.exec(body.trim())
88
107
  if (match === null) return null
89
108
  const word = match[1]?.toLowerCase() as ParsedReply['word']
90
- return { word, ticket: match[2] === undefined ? null : Number(match[2]) }
109
+ return {
110
+ word,
111
+ ticket: match[2] === undefined ? null : Number(match[2]),
112
+ amount: null,
113
+ }
114
+ }
115
+
116
+ const AMOUNT_PATTERN = /^(\d+)([km]?)$/i
117
+
118
+ const AMOUNT_SCALES: Record<string, number> = { '': 1, k: 1_000, m: 1_000_000 }
119
+
120
+ /**
121
+ * The tokens an amount after `retry +` names: a whole number, optionally
122
+ * followed by `k` or `M` in any case. `null` for zero, or for anything that
123
+ * can't be read.
124
+ *
125
+ * @example
126
+ * amountTokens({ amount: '500k' }) // 500_000
127
+ * amountTokens({ amount: '1.5M' }) // null
128
+ */
129
+ export const amountTokens = ({ amount }: { amount: string }): number | null => {
130
+ const match = AMOUNT_PATTERN.exec(amount)
131
+ if (match === null) return null
132
+ const scale = AMOUNT_SCALES[(match[2] ?? '').toLowerCase()] ?? 1
133
+ const tokens = Number(match[1]) * scale
134
+ return tokens > 0 && Number.isSafeInteger(tokens) ? tokens : null
91
135
  }
92
136
 
93
137
  /**
@@ -137,7 +181,9 @@ const awaitingReply = (progress: TicketProgress): boolean =>
137
181
  /**
138
182
  * The next steps of one stuck ticket: undo a join its stuck left on the run
139
183
  * 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.
184
+ * on the owner's reply. Nothing while it waits for one. A ticket stuck
185
+ * because its undo was refused (`undo_refused`, #519) is never undone again
186
+ * before a reply: it is told at once, with its join left where it is.
141
187
  */
142
188
  export const stuckTicketSteps = ({
143
189
  spec_number,
@@ -151,7 +197,11 @@ export const stuckTicketSteps = ({
151
197
  const { stuck, joined } = progress
152
198
  if (stuck === null) return []
153
199
  const number = ticket.number
154
- if (joined?.ok === true && progress.pushed === null) {
200
+ if (
201
+ joined?.ok === true &&
202
+ progress.pushed === null &&
203
+ stuck.reason !== 'undo_refused'
204
+ ) {
155
205
  const first_sha = joined.shas[0]
156
206
  if (first_sha !== undefined) {
157
207
  return [{ type: 'undo_join', ticket: number, first_sha }]
@@ -315,6 +365,8 @@ export const replySteps = ({
315
365
  },
316
366
  ]
317
367
  }
368
+ // Only the run stuck on its budget takes an amount (decideRunBudget).
369
+ if (next.amount !== null) return ignore('amount_only_for_run_budget')
318
370
  if (next.ticket !== null && !waiting.includes(next.ticket)) {
319
371
  return ignore('not_stuck')
320
372
  }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * A ticket's review step: a fresh reviewer after the green commit, and the
3
+ * review's fix rounds, each re-reviewed.
4
+ */
5
+
6
+ import { stuck, type BuildAction, type StepArgs } from './build-action'
7
+ import { launch } from './build-launch'
8
+ import { commitStep, gateStep } from './decide-red-green'
9
+ import { MAX_FIX_ROUNDS } from './loop-caps'
10
+ import { openFindingsText, reviewFixMessage } from './review-text'
11
+ import { badTestDetail } from './stuck-text'
12
+
13
+ import { handoffRole } from '../journal/replay'
14
+
15
+ /**
16
+ * The ticket review and its fix loop, after the green commit. A fresh
17
+ * reviewer checks the committed diff. Blockers and should-fixes open a fix
18
+ * round: test findings go to a fresh test-writer first, then code findings
19
+ * to the same implementer session (a fresh one if it is gone), then the
20
+ * findings a fixer answered `other_role` to a fresh agent of the other
21
+ * writer role (#616). The fixes
22
+ * pass the gates, get their own commit, and a fresh reviewer checks only
23
+ * the new changes. A review that still asks for changes after
24
+ * `MAX_FIX_ROUNDS` fix rounds is stuck. `null` once a review approves.
25
+ */
26
+ export const reviewStep = ({
27
+ snapshot,
28
+ ticket,
29
+ progress,
30
+ run_notes,
31
+ }: StepArgs): BuildAction | null => {
32
+ const number = ticket.number
33
+ const reviewer = () =>
34
+ launch({
35
+ role: 'ticket-reviewer',
36
+ snapshot,
37
+ ticket,
38
+ progress,
39
+ run_notes,
40
+ })
41
+ if (progress.review === null) return reviewer()
42
+ const fix = progress.review_fix
43
+ if (fix === null) return null
44
+ if (fix.round > MAX_FIX_ROUNDS) {
45
+ return stuck({
46
+ ticket: number,
47
+ reason: 'changes_requested',
48
+ detail: `The ticket review still asks for changes after ${MAX_FIX_ROUNDS} fix rounds:\n${openFindingsText({ findings: fix.findings })}`,
49
+ })
50
+ }
51
+ if (!fix.tests_answered) {
52
+ return launch({
53
+ role: 'test-writer',
54
+ snapshot,
55
+ ticket,
56
+ progress,
57
+ run_notes,
58
+ })
59
+ }
60
+ if (!fix.code_answered) {
61
+ const session_id = progress.sessions.implementer
62
+ if (session_id === undefined) {
63
+ return launch({
64
+ role: 'implementer',
65
+ snapshot,
66
+ ticket,
67
+ progress,
68
+ run_notes,
69
+ })
70
+ }
71
+ return {
72
+ type: 'follow_up_agent',
73
+ ticket: number,
74
+ role: 'implementer',
75
+ session_id,
76
+ message: reviewFixMessage({ fix }),
77
+ }
78
+ }
79
+ if (fix.bad_test !== null) {
80
+ return stuck({
81
+ ticket: number,
82
+ reason: 'bad_test',
83
+ detail: `While fixing review findings, the implementer sent a test back as bad: ${badTestDetail({ bad_test: fix.bad_test })}`,
84
+ })
85
+ }
86
+ const handoff = handoffRole({ fix })
87
+ if (handoff !== null) {
88
+ return launch({ role: handoff, snapshot, ticket, progress, run_notes })
89
+ }
90
+ return (
91
+ gateStep({ snapshot, ticket, progress, run_notes }) ??
92
+ commitStep({ stage: 'fix', ticket, progress }) ??
93
+ reviewer()
94
+ )
95
+ }