@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,267 @@
1
+ /**
2
+ * One ticket's next step once it has its worktree: a failed turn, its
3
+ * install and prepare, its shared baseline, then its tests, code, review
4
+ * and join.
5
+ */
6
+
7
+ import {
8
+ isRefactorTicket,
9
+ stuck,
10
+ type BuildAction,
11
+ type StepArgs,
12
+ } from './build-action'
13
+ import { launch } from './build-launch'
14
+ import { codeStep, testStep } from './decide-red-green'
15
+ import { joinStep, rejoinStep } from './decide-rejoin'
16
+ import { reviewStep } from './decide-ticket-review'
17
+ import { failedTryMessage } from './fix-loop-text'
18
+ import { MAX_ENGINE_FAILURES, MAX_FIX_ROUNDS } from './loop-caps'
19
+ import { setupChangeDetail } from './stuck-text'
20
+
21
+ import { endedText } from '../gates/gate-schemas'
22
+ import type {
23
+ ReplayedInstall,
24
+ ReplayedWorktree,
25
+ TicketProgress,
26
+ } from '../journal/replay'
27
+
28
+ /** Why an install failed, for the stuck detail, or `null` if it passed. */
29
+ export const installFailure = ({
30
+ install,
31
+ where,
32
+ }: {
33
+ install: ReplayedInstall
34
+ where: string
35
+ }): string | null => {
36
+ const { check } = install
37
+ if (check === null || check.ok) return null
38
+ return `\`${check.command}\` failed in ${where} (${endedText(check)}). Agents never run the install, so fix the manifest or lockfile on the base branch and retry.\n${check.output}`
39
+ }
40
+
41
+ /**
42
+ * The next step after a failed agent turn. An engine failure starts a fresh
43
+ * agent of the same role without using up a try, until
44
+ * `MAX_ENGINE_FAILURES` in a row. Any other failure uses up one of the
45
+ * role's tries on the ticket; with tries left, a test-writer or implementer
46
+ * gets a follow-up in the session that failed (a fresh launch with the same
47
+ * message if it has none, such as after a crash), and a reviewer a fresh
48
+ * launch. The last try's failure is stuck.
49
+ */
50
+ const failedTurnStep = ({
51
+ snapshot,
52
+ ticket,
53
+ progress,
54
+ run_notes,
55
+ failed,
56
+ }: StepArgs & {
57
+ failed: NonNullable<TicketProgress['agent_failure']>
58
+ }): BuildAction => {
59
+ const { role, error, failure, session_id } = failed
60
+ if (failure === 'engine') {
61
+ if (progress.engine_failures >= MAX_ENGINE_FAILURES) {
62
+ return stuck({
63
+ ticket: ticket.number,
64
+ reason: 'agent_failed',
65
+ detail: `The engine failed to run the ${role} ${progress.engine_failures} times in a row: ${error}`,
66
+ })
67
+ }
68
+ return launch({ role, snapshot, ticket, progress, run_notes })
69
+ }
70
+ const tries = progress.failed_tries[role] ?? 0
71
+ if (tries >= MAX_FIX_ROUNDS) {
72
+ return stuck({
73
+ ticket: ticket.number,
74
+ reason: 'agent_failed',
75
+ detail: `The ${role} failed ${tries} tries; the last one: ${error}`,
76
+ })
77
+ }
78
+ if (role === 'ticket-reviewer') {
79
+ return launch({ role, snapshot, ticket, progress, run_notes })
80
+ }
81
+ const message = failedTryMessage({ failure, error })
82
+ if (session_id === null) {
83
+ return launch({
84
+ role,
85
+ snapshot,
86
+ ticket,
87
+ progress,
88
+ run_notes,
89
+ follow_up: message,
90
+ })
91
+ }
92
+ return {
93
+ type: 'follow_up_agent',
94
+ ticket: ticket.number,
95
+ role,
96
+ session_id,
97
+ message,
98
+ }
99
+ }
100
+
101
+ type SharedBaselineArgs = {
102
+ number: number
103
+ /** The run's tickets, in order. */
104
+ numbers: number[]
105
+ progress: (number: number) => TicketProgress
106
+ /** Whether a ticket is neither pushed, skipped, nor stuck. */
107
+ can_move: (number: number) => boolean
108
+ }
109
+
110
+ /**
111
+ * The ticket that runs the baseline tests a ticket with no baseline shares:
112
+ * the first ticket still able to move whose worktree starts from the same
113
+ * run-branch commit and has no baseline yet. `null` with no worktree.
114
+ */
115
+ export const baselineRunner = ({
116
+ number,
117
+ numbers,
118
+ progress,
119
+ can_move,
120
+ }: SharedBaselineArgs): number | null => {
121
+ const base_sha = progress(number).worktree?.base_sha
122
+ if (base_sha === undefined) return null
123
+ return (
124
+ numbers.find(
125
+ (other) =>
126
+ can_move(other) &&
127
+ progress(other).baseline === null &&
128
+ progress(other).worktree?.base_sha === base_sha
129
+ ) ?? null
130
+ )
131
+ }
132
+
133
+ /**
134
+ * How a ticket with no baseline gets one. Tickets whose worktrees start from
135
+ * the same run-branch commit share one baseline test run: a ticket reuses a
136
+ * baseline taken at its commit, or else the first such ticket still able to
137
+ * move runs the tests (`baselineRunner`) and the rest wait for it. `null`:
138
+ * it waits.
139
+ */
140
+ export const sharedBaselineStep = (
141
+ args: SharedBaselineArgs
142
+ ): BuildAction | null => {
143
+ const { number, numbers, progress } = args
144
+ const base_sha = progress(number).worktree?.base_sha
145
+ if (base_sha === undefined) return null
146
+ const lender = numbers.find(
147
+ (other) =>
148
+ other !== number &&
149
+ progress(other).baseline !== null &&
150
+ progress(other).baseline_sha === base_sha
151
+ )
152
+ if (lender !== undefined) {
153
+ return {
154
+ type: 'reuse_baseline_tests',
155
+ ticket: number,
156
+ from_ticket: lender,
157
+ base_sha,
158
+ }
159
+ }
160
+ return baselineRunner(args) === number
161
+ ? { type: 'run_baseline_tests', ticket: number }
162
+ : null
163
+ }
164
+
165
+ /**
166
+ * The next step for one ticket that has its worktree, or `null` when it has
167
+ * nothing to do now: it pushed, or its review approved and it waits its turn
168
+ * to join.
169
+ */
170
+ export const nextTicketStep = ({
171
+ snapshot,
172
+ ticket,
173
+ progress,
174
+ run_notes,
175
+ run_branch,
176
+ joiner,
177
+ setup_files,
178
+ baseline_step,
179
+ base_branch,
180
+ }: StepArgs & {
181
+ run_branch: ReplayedWorktree
182
+ /** The branch the run started from. */
183
+ base_branch: string
184
+ /** Whether the ticket is first in the join queue. */
185
+ joiner: boolean
186
+ /** The config's test setup files. */
187
+ setup_files: string[]
188
+ /** How it gets its baseline, if it has none (`sharedBaselineStep`). */
189
+ baseline_step: BuildAction | null
190
+ }): BuildAction | null => {
191
+ const number = ticket.number
192
+ if (progress.agent_failure !== null) {
193
+ return failedTurnStep({
194
+ snapshot,
195
+ ticket,
196
+ progress,
197
+ run_notes,
198
+ failed: progress.agent_failure,
199
+ })
200
+ }
201
+ if (progress.setup_change !== null) {
202
+ return stuck({
203
+ ticket: number,
204
+ reason: 'setup_change_needed',
205
+ detail: setupChangeDetail({
206
+ ...progress.setup_change,
207
+ setup_files,
208
+ }),
209
+ })
210
+ }
211
+ if (progress.install === null) {
212
+ return {
213
+ type: 'install_dependencies',
214
+ target: 'ticket',
215
+ ticket: number,
216
+ }
217
+ }
218
+ const ticketFailure = installFailure({
219
+ install: progress.install,
220
+ where: `the worktree of #${number}`,
221
+ })
222
+ if (ticketFailure !== null) {
223
+ return stuck({
224
+ ticket: number,
225
+ reason: 'install_failed',
226
+ detail: ticketFailure,
227
+ })
228
+ }
229
+ const { prepare } = progress
230
+ if (prepare !== null && !prepare.ok) {
231
+ return stuck({
232
+ ticket: number,
233
+ reason: 'prepare_failed',
234
+ detail: `\`${prepare.command}\` failed in the worktree of #${number} (${endedText(prepare)}) before any agent worked on it. Agents never run the prepare command, so fix what it needs (a missing tool, say) and retry.\n${prepare.output}`,
235
+ })
236
+ }
237
+ if (progress.baseline === null) return baseline_step
238
+ if (progress.commits.green === null) {
239
+ const rejoin = rejoinStep({
240
+ snapshot,
241
+ ticket,
242
+ progress,
243
+ run_notes,
244
+ })
245
+ if (rejoin !== null) return rejoin
246
+ const tests = isRefactorTicket({ ticket })
247
+ ? null
248
+ : testStep({
249
+ snapshot,
250
+ ticket,
251
+ progress,
252
+ run_notes,
253
+ base_branch,
254
+ })
255
+ if (tests !== null) return tests
256
+ const code = codeStep({ snapshot, ticket, progress, run_notes })
257
+ if (code !== null) return code
258
+ }
259
+ const review = reviewStep({
260
+ snapshot,
261
+ ticket,
262
+ progress,
263
+ run_notes,
264
+ })
265
+ if (review !== null) return review
266
+ return joiner ? joinStep({ ticket, progress, run_branch }) : null
267
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The waits between steps to journal (#559): a ticket the engine holds on
3
+ * purpose, for another ticket's shared baseline tests, the join queue, or
4
+ * the start queue, gets one `ticket_waiting` when its wait starts. Pure.
5
+ */
6
+ import type { TicketWait } from './decide-build-tickets'
7
+
8
+ import type { JournalRecord } from '../journal/journal-record'
9
+
10
+ /**
11
+ * Whether `wait` is journaled already: its ticket's `ticket_waiting` since
12
+ * its last `step_started` waits for the same thing on the same ticket.
13
+ */
14
+ const journaled = ({
15
+ wait,
16
+ records,
17
+ }: {
18
+ wait: TicketWait
19
+ records: JournalRecord[]
20
+ }): boolean => {
21
+ const latest = records.findLast(
22
+ (record) =>
23
+ record.ticket === wait.ticket &&
24
+ (record.kind === 'step_started' || record.kind === 'ticket_waiting')
25
+ )
26
+ return (
27
+ latest?.kind === 'ticket_waiting' &&
28
+ latest.content.for === wait.for &&
29
+ latest.content.on_ticket === wait.on_ticket
30
+ )
31
+ }
32
+
33
+ /**
34
+ * The build decision's waits (`decideBuildPlan`) that started since the
35
+ * journal last said so, in ticket order.
36
+ *
37
+ * @example
38
+ * startedWaits({ waits, records: journal.read() })
39
+ * // [{ ticket: 12, for: 'baseline', on_ticket: 11 }]
40
+ */
41
+ export const startedWaits = ({
42
+ waits,
43
+ records,
44
+ }: {
45
+ waits: TicketWait[]
46
+ records: JournalRecord[]
47
+ }): TicketWait[] => waits.filter((wait) => !journaled({ wait, records }))
@@ -1,10 +1,17 @@
1
- import { decideBuild, type BuildAction } from './decide-build'
1
+ import { decideBuildPlan, type BuildAction } from './decide-build'
2
+ import type { TicketWait } from './decide-build-tickets'
2
3
  import { decideCrashes, type CrashAction } from './decide-crashes'
3
4
  import { decideMemory, type MemoryAction } from './decide-memory'
4
5
  import { decidePlan, type PlanAction } from './decide-plan'
6
+ import {
7
+ decideRepoWait,
8
+ type RepoWaitAction,
9
+ type RepoWaitRun,
10
+ } from './decide-repo-wait'
5
11
  import { decideRunBudget, type RunBudgetAction } from './decide-run-budget'
6
12
  import { decideUsage, type UsageAction } from './decide-usage'
7
13
  import { decideUsageLine, type UsageLineAction } from './decide-usage-line'
14
+ import { startedWaits } from './decide-waits'
8
15
 
9
16
  import { checkIntake } from '../intake/intake-checks'
10
17
  import type { IntakeProblem, IntakeSnapshot } from '../intake/intake-schemas'
@@ -16,6 +23,8 @@ import type { UsageLines } from '../limits/usage-line'
16
23
  export type EngineAction =
17
24
  /** The journal cannot be a run (for example it has no `run_started`). */
18
25
  | { type: 'invalid_journal'; reason: string }
26
+ /** Wait for earlier runs on the same repo, before intake (#615). */
27
+ | RepoWaitAction
19
28
  /** Read the spec, its sub-tickets, and outside blockers from the tracker. */
20
29
  | { type: 'read_intake'; spec_number: number }
21
30
  /** Tell each bad ticket what is missing, then end the run. */
@@ -50,8 +59,10 @@ export type EngineAction =
50
59
  * It never reads the tracker, the disk, or the clock.
51
60
  *
52
61
  * Crashes come first: a run-level step cut off by a crash `MAX_CRASHES`
53
- * times in a row stops the run for good (`decide-crashes.ts`). Then the
54
- * plan: a limit wait or a billing stop is the only action,
62
+ * times in a row stops the run for good (`decide-crashes.ts`). Before
63
+ * intake, a run with `earlier_runs` going on its repo waits for them
64
+ * (`decide-repo-wait.ts`). After intake, the
65
+ * plan:a limit wait or a billing stop is the only action,
55
66
  * for the whole run, however many tickets are in flight (the scheduler lets
56
67
  * them settle first, then every cut-off step is taken again after the
57
68
  * wait). Then the run budget: a run whose tokens reached it is stuck, and
@@ -66,17 +77,44 @@ export type EngineAction =
66
77
  * const actions = decideSteps({ records: journal.read() })
67
78
  * // [{ type: 'launch_agent', ticket: 11, ... }, { type: 'run_red_check', ticket: 12, ... }]
68
79
  */
69
- export const decideSteps = ({
70
- records,
71
- usage_lines,
72
- shared_readings,
73
- }: {
80
+ export const decideSteps = (args: DecideInput): EngineAction[] =>
81
+ decideNext(args).actions
82
+
83
+ /** What `decideSteps` and `decideNext` decide from. */
84
+ type DecideInput = {
74
85
  records: JournalRecord[]
75
86
  /** The `luca-board` usage lines. Left out, there is no usage line. */
76
87
  usage_lines?: UsageLines
77
88
  /** The newest raw readings every run shared, each with its `arrived_at`. */
78
89
  shared_readings?: unknown[]
79
- }): EngineAction[] => {
90
+ /**
91
+ * The earlier runs going on the run's repo (`earlierRuns`), read before
92
+ * intake. Left out, there is no repo wait to start.
93
+ */
94
+ earlier_runs?: RepoWaitRun[]
95
+ }
96
+
97
+ /** The decision with no ticket waiting between steps. */
98
+ const alone = (actions: EngineAction[]) => ({ actions, waits: [] })
99
+
100
+ /**
101
+ * `decideSteps`'s actions, and beside them the waits between steps to
102
+ * journal now (#559): each ticket the same build decision held on purpose,
103
+ * for another ticket's shared baseline tests, the join queue or the start
104
+ * queue, whose wait the journal doesn't say yet. None when the build
105
+ * doesn't run: a crash stop, a repo wait, intake, a plan limit, the run
106
+ * budget, or a pause at the usage line.
107
+ *
108
+ * @example
109
+ * decideNext({ records: journal.read() })
110
+ * // { actions: [{ type: 'run_baseline_tests', ticket: 11 }], waits: [{ ticket: 12, for: 'baseline', on_ticket: 11 }] }
111
+ */
112
+ export const decideNext = ({
113
+ records,
114
+ usage_lines,
115
+ shared_readings,
116
+ earlier_runs,
117
+ }: DecideInput): { actions: EngineAction[]; waits: TicketWait[] } => {
80
118
  const state = replayRun({ records })
81
119
  const { phase, spec_number } = state
82
120
  const crash = phase === 'new' ? null : decideCrashes({ state })
@@ -86,10 +124,15 @@ export const decideSteps = ({
86
124
  crash.type === 'done' && phase === 'intake_passed'
87
125
  ? decideUsage({ records, state, ending: true })
88
126
  : null
89
- return [usage ?? crash]
127
+ return alone([usage ?? crash])
90
128
  }
129
+ const repoWait =
130
+ spec_number === null
131
+ ? null
132
+ : decideRepoWait({ state, records, spec_number, earlier_runs })
133
+ if (repoWait !== null) return alone(repoWait)
91
134
  if (phase !== 'intake_passed' || spec_number === null) {
92
- return [decideIntake({ state })]
135
+ return alone([decideIntake({ state })])
93
136
  }
94
137
  const plan = decidePlan({ state, spec_number })
95
138
  if (plan !== null) {
@@ -98,16 +141,12 @@ export const decideSteps = ({
98
141
  plan.type === 'done'
99
142
  ? decideUsage({ records, state, ending: true })
100
143
  : null
101
- return [usage ?? plan]
144
+ return alone([usage ?? plan])
102
145
  }
103
146
  const budget = decideRunBudget({ state, records, spec_number })
104
- if (budget !== null) return budget
105
- const build = decideMemory({
106
- state,
107
- records,
108
- spec_number,
109
- build: decideBuild({ state, spec_number }),
110
- })
147
+ if (budget !== null) return alone(budget)
148
+ const { steps, waits } = decideBuildPlan({ state, spec_number })
149
+ const build = decideMemory({ state, records, spec_number, build: steps })
111
150
  const pause = decideUsageLine({
112
151
  state,
113
152
  records,
@@ -116,14 +155,18 @@ export const decideSteps = ({
116
155
  usage_lines,
117
156
  shared_readings: shared_readings ?? [],
118
157
  })
119
- if (pause !== null) return [pause]
158
+ if (pause !== null) return alone([pause])
159
+ const started = startedWaits({ waits, records })
120
160
  // A run whose every ticket was already done ends with nothing to do.
121
161
  const ending =
122
162
  build.length === 1 &&
123
163
  (build[0]?.type === 'done' || build[0]?.type === 'finish_nothing_to_do')
124
164
  const usage = decideUsage({ records, state, ending })
125
- if (usage === null) return build
126
- return ending ? [usage] : [usage, ...build]
165
+ if (usage === null) return { actions: build, waits: started }
166
+ return {
167
+ actions: ending ? [usage] : [usage, ...build],
168
+ waits: started,
169
+ }
127
170
  }
128
171
 
129
172
  /**
@@ -138,12 +181,19 @@ export const decide = ({
138
181
  records,
139
182
  usage_lines,
140
183
  shared_readings,
184
+ earlier_runs,
141
185
  }: {
142
186
  records: JournalRecord[]
143
187
  usage_lines?: UsageLines
144
188
  shared_readings?: unknown[]
189
+ earlier_runs?: RepoWaitRun[]
145
190
  }): EngineAction => {
146
- const [first] = decideSteps({ records, usage_lines, shared_readings })
191
+ const [first] = decideSteps({
192
+ records,
193
+ usage_lines,
194
+ shared_readings,
195
+ earlier_runs,
196
+ })
147
197
  return (
148
198
  first ?? {
149
199
  type: 'invalid_journal',
@@ -196,6 +246,7 @@ const decideIntake = ({ state }: { state: RunState }): EngineAction => {
196
246
  type: 'finish_nothing_to_do',
197
247
  closed_tickets: result.closed_tickets,
198
248
  already_done: [],
249
+ left_out: result.left_out,
199
250
  }
200
251
  }
201
252
  // A snapshot cut short by a crash is taken again in full; replay
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The owner chores the engine does for a writer after its turn (#618): the
3
+ * file modes it asked for, and the repo's generators it named. Agents can
4
+ * do neither themselves.
5
+ */
6
+ import { chmod, lstat, realpath } from 'node:fs/promises'
7
+ import { isAbsolute, join, relative, sep } from 'node:path'
8
+
9
+ import type { BuildContext } from './build-context'
10
+
11
+ import type { FileMode, RoleResult } from '../agents/role-results'
12
+ import { testTimeoutOf } from '../config/engine-config'
13
+ import { shellCheck } from '../gates/gate-runner'
14
+ import { endedText, type GateCheck } from '../gates/gate-schemas'
15
+ import { TEST_TIMEOUT_HINT } from '../gates/test-runner'
16
+ import type { GuardArgs } from '../guards/guard-roles'
17
+ import { mayWrite } from '../guards/write-rules'
18
+
19
+ /** The chores a result asks for, or `null` for a role that asks none. */
20
+ const choresOf = ({
21
+ result,
22
+ }: {
23
+ result: RoleResult
24
+ }): { file_modes: FileMode[]; run_generators: string[] } | null =>
25
+ result.role === 'test-writer' || result.role === 'implementer'
26
+ ? result.result
27
+ : null
28
+
29
+ /**
30
+ * Sets one file's mode, or says why not: a path the role may not write, no
31
+ * regular file there, or a real path (through a symlinked folder) that
32
+ * leaves the worktree or that the role may not write (#624).
33
+ */
34
+ const applyMode = async ({
35
+ cwd,
36
+ mode,
37
+ guard,
38
+ context,
39
+ }: {
40
+ cwd: string
41
+ mode: FileMode
42
+ guard: GuardArgs
43
+ context: BuildContext
44
+ }): Promise<string | null> => {
45
+ const { path, executable } = mode
46
+ if (!mayWrite({ ...guard, path, config: context.config })) {
47
+ return `file_modes: ${path} is a path your role may not write, so its mode was not changed.`
48
+ }
49
+ const full = join(cwd, path)
50
+ const found = await lstat(full).catch(() => null)
51
+ const real = await realpath(full).catch(() => null)
52
+ if (found === null || !found.isFile() || real === null) {
53
+ return `file_modes: ${path} is not a file in the worktree, so no mode was set.`
54
+ }
55
+ // A symlinked folder on the way must not lead the mode elsewhere.
56
+ const inside = relative(await realpath(cwd), real)
57
+ if (
58
+ inside === '..' ||
59
+ inside.startsWith(`..${sep}`) ||
60
+ isAbsolute(inside)
61
+ ) {
62
+ return `file_modes: ${path} is really ${real}, outside the worktree, so its mode was not changed.`
63
+ }
64
+ if (!mayWrite({ ...guard, path: inside, config: context.config })) {
65
+ return `file_modes: ${path} is really ${inside}, a path your role may not write, so its mode was not changed.`
66
+ }
67
+ const mode_bits = executable ? found.mode | 0o111 : found.mode & ~0o111
68
+ await chmod(real, mode_bits & 0o7777)
69
+ return null
70
+ }
71
+
72
+ /** Runs one named generator, as a `generate` check, or says it has none. */
73
+ const runGenerator = async ({
74
+ cwd,
75
+ name,
76
+ context,
77
+ }: {
78
+ cwd: string
79
+ name: string
80
+ context: BuildContext
81
+ }): Promise<{ check: GateCheck | null; problem: string | null }> => {
82
+ const generators = context.config.generators ?? {}
83
+ // Own names only: `constructor` and friends are inherited, not named.
84
+ const command = Object.hasOwn(generators, name)
85
+ ? generators[name]
86
+ : undefined
87
+ if (command === undefined) {
88
+ const known = Object.keys(generators).map((other) => `"${other}"`)
89
+ return {
90
+ check: null,
91
+ problem: `run_generators: "${name}" is not a generator this repo names (${known.length === 0 ? 'it names none' : `it names ${known.join(', ')}`}).`,
92
+ }
93
+ }
94
+ const check = await shellCheck({
95
+ name: 'generate',
96
+ command,
97
+ cwd,
98
+ timeout_ms: testTimeoutOf({ config: context.config }),
99
+ timeout_hint: TEST_TIMEOUT_HINT,
100
+ })
101
+ return {
102
+ check,
103
+ problem: check.ok
104
+ ? null
105
+ : `run_generators: the generator "${name}" (\`${command}\`) failed (${endedText(check)}):\n${check.output}`,
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Does the chores a writer's result asks for, in its worktree: each file
111
+ * mode, on a path the role may write (others are refused and left as they
112
+ * are), then each named generator, in order, as a `generate` check with the
113
+ * test time limit. What they change is committed with the rest of the
114
+ * turn's work. Journals `chores_done` when the result asked for any.
115
+ *
116
+ * @returns The problems, for the role's fix loop: a refused path, an
117
+ * unknown generator, or a failed one. Empty when every chore was done.
118
+ *
119
+ * @example
120
+ * const problems = await doChores({ context, cwd, result, guard })
121
+ */
122
+ export const doChores = async ({
123
+ context,
124
+ cwd,
125
+ ticket,
126
+ result,
127
+ guard,
128
+ }: {
129
+ context: BuildContext
130
+ cwd: string
131
+ /** `null` in the final review. */
132
+ ticket: number | null
133
+ result: RoleResult
134
+ guard: GuardArgs
135
+ }): Promise<string[]> => {
136
+ const chores = choresOf({ result })
137
+ if (
138
+ chores === null ||
139
+ (chores.file_modes.length === 0 && chores.run_generators.length === 0)
140
+ ) {
141
+ return []
142
+ }
143
+ const problems: string[] = []
144
+ const file_modes: FileMode[] = []
145
+ for (const mode of chores.file_modes) {
146
+ const problem = await applyMode({ cwd, mode, guard, context })
147
+ if (problem === null) file_modes.push(mode)
148
+ else problems.push(problem)
149
+ }
150
+ const checks: GateCheck[] = []
151
+ for (const name of chores.run_generators) {
152
+ const ran = await runGenerator({ cwd, name, context })
153
+ if (ran.check !== null) checks.push(ran.check)
154
+ if (ran.problem !== null) problems.push(ran.problem)
155
+ }
156
+ context.journal.append({
157
+ kind: 'chores_done',
158
+ ticket,
159
+ role: result.role,
160
+ content: { role: result.role, file_modes, checks, problems },
161
+ })
162
+ return problems
163
+ }