@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
@@ -1,3 +1,10 @@
1
+ /** A process the command started that still ran after a timeout's kill. */
2
+ export type Survivor = {
3
+ pid: number
4
+ /** Its command line, from `ps`. */
5
+ command: string
6
+ }
7
+
1
8
  /** What a finished command left behind. */
2
9
  export type CommandResult = {
3
10
  /** `null` when the command was killed. */
@@ -5,6 +12,8 @@ export type CommandResult = {
5
12
  stdout: string
6
13
  stderr: string
7
14
  timed_out: boolean
15
+ /** Set on a timeout only: what still ran after the kill (#571). */
16
+ survivors?: Survivor[]
8
17
  }
9
18
 
10
19
  /** Commands the engine runs give up after this long unless told otherwise. */
@@ -12,8 +21,9 @@ export const DEFAULT_COMMAND_TIMEOUT_MS = 300_000
12
21
 
13
22
  /**
14
23
  * After a timed-out command is killed, how long the engine still waits for
15
- * its output. A child that left the command's process group can hold the
16
- * output pipe open; past this, the engine stops reading and moves on.
24
+ * its output when the caller doesn't say (`pipe_grace_ms`). A child that left
25
+ * the command's process group can hold the output pipe open; past this, the
26
+ * engine stops reading and moves on.
17
27
  */
18
28
  const PIPE_GRACE_MS = 2_000
19
29
 
@@ -39,6 +49,110 @@ const signalGroup = ({
39
49
  }
40
50
  }
41
51
 
52
+ /** One line of `ps`: a process, its parent, and its process group. */
53
+ type ProcessEntry = Survivor & { ppid: number; pgid: number }
54
+
55
+ /**
56
+ * Every process now running, from `ps`. Empty when `ps` fails: then the
57
+ * engine can only kill the command's own group.
58
+ */
59
+ const listProcessTable = async (): Promise<ProcessEntry[]> => {
60
+ try {
61
+ const ps = Bun.spawn(
62
+ ['ps', '-axww', '-o', 'pid=,ppid=,pgid=,command='],
63
+ {
64
+ stdin: 'ignore',
65
+ stdout: 'pipe',
66
+ stderr: 'ignore',
67
+ }
68
+ )
69
+ const [text, code] = await Promise.all([
70
+ new Response(ps.stdout).text(),
71
+ ps.exited,
72
+ ])
73
+ if (code !== 0) return []
74
+ return text.split('\n').flatMap((line) => {
75
+ const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s(.*)$/.exec(line)
76
+ if (match === null) return []
77
+ const [, pid, ppid, pgid, command] = match
78
+ return [
79
+ {
80
+ pid: Number(pid),
81
+ ppid: Number(ppid),
82
+ pgid: Number(pgid),
83
+ command: (command ?? '').trim(),
84
+ },
85
+ ]
86
+ })
87
+ } catch {
88
+ return []
89
+ }
90
+ }
91
+
92
+ /** Every process below `pid`, found by parent process id. */
93
+ const descendantsOf = async (pid: number): Promise<ProcessEntry[]> => {
94
+ const table = await listProcessTable()
95
+ const found: ProcessEntry[] = []
96
+ const parents = new Set([pid])
97
+ for (let grew = true; grew; ) {
98
+ grew = false
99
+ for (const entry of table) {
100
+ if (!parents.has(entry.ppid) || parents.has(entry.pid)) continue
101
+ parents.add(entry.pid)
102
+ found.push(entry)
103
+ grew = true
104
+ }
105
+ }
106
+ return found
107
+ }
108
+
109
+ /** Whether a process with this id still runs. */
110
+ const isRunning = (pid: number): boolean => {
111
+ try {
112
+ process.kill(pid, 0)
113
+ return true
114
+ } catch {
115
+ return false
116
+ }
117
+ }
118
+
119
+ /** How long the engine waits for killed processes to go before naming them. */
120
+ const SURVIVOR_WAIT_MS = 500
121
+
122
+ /** Of `processes`, those still running after up to `SURVIVOR_WAIT_MS`. */
123
+ const survivorsOf = async (processes: ProcessEntry[]): Promise<Survivor[]> => {
124
+ const end = Date.now() + SURVIVOR_WAIT_MS
125
+ let alive = processes.filter(({ pid }) => isRunning(pid))
126
+ while (alive.length > 0 && Date.now() < end) {
127
+ await Bun.sleep(25)
128
+ alive = alive.filter(({ pid }) => isRunning(pid))
129
+ }
130
+ return alive.map(({ pid, command }) => ({ pid, command }))
131
+ }
132
+
133
+ /**
134
+ * Kills a timed-out command and everything it started (#571). It first walks
135
+ * the process tree from the command, while the tree is still whole, then
136
+ * kills the command's group and the group of every process below it, so a
137
+ * child that started a group of its own goes too. Returns what it found.
138
+ */
139
+ const killTree = async (pid: number): Promise<ProcessEntry[]> => {
140
+ const descendants = await descendantsOf(pid)
141
+ signalGroup({ pid, signal: 'SIGKILL' })
142
+ for (const entry of descendants) {
143
+ // Never the engine's own group.
144
+ if (entry.pgid > 1 && entry.pgid !== process.pid) {
145
+ signalGroup({ pid: entry.pgid, signal: 'SIGKILL' })
146
+ }
147
+ try {
148
+ process.kill(entry.pid, 'SIGKILL')
149
+ } catch {
150
+ // Gone already.
151
+ }
152
+ }
153
+ return descendants
154
+ }
155
+
42
156
  /** Sends `signal` to every command still running. */
43
157
  const signalLiveGroups = (signal: NodeJS.Signals): void => {
44
158
  for (const pid of liveGroups) signalGroup({ pid, signal })
@@ -97,10 +211,11 @@ const collectText = (
97
211
  * Runs a command and collects its output. Never throws on a non-zero exit.
98
212
  *
99
213
  * The command runs in its own process group. One that runs past
100
- * `timeout_ms` is killed with everything it started (the whole group) and
101
- * marked `timed_out`, with `exit_code: null`. The engine then waits at most
102
- * a moment more for its output, so a child that left the group can't hold
103
- * the engine.
214
+ * `timeout_ms` is killed with everything it started (its group, and the
215
+ * group of every process below it) and marked `timed_out`, with
216
+ * `exit_code: null` and the `survivors` the kill missed. The engine then
217
+ * waits at most a moment more for its output (`pipe_grace_ms`, default
218
+ * `PIPE_GRACE_MS`), so a child that escaped the kill can't hold the engine.
104
219
  *
105
220
  * @example
106
221
  * const result = await runCommand({ cmd: ['git', 'status'], cwd })
@@ -111,6 +226,7 @@ export const runCommand = async ({
111
226
  cwd,
112
227
  timeout_ms,
113
228
  env,
229
+ pipe_grace_ms,
114
230
  }: {
115
231
  cmd: string[]
116
232
  cwd: string
@@ -118,6 +234,11 @@ export const runCommand = async ({
118
234
  timeout_ms?: number
119
235
  /** Variables set on top of the engine's own environment. */
120
236
  env?: Record<string, string>
237
+ /**
238
+ * How long to keep reading output after a timeout kill; defaults to
239
+ * `PIPE_GRACE_MS`. Tests of that wait pass a short one (#580).
240
+ */
241
+ pipe_grace_ms?: number
121
242
  }): Promise<CommandResult> => {
122
243
  stopCommandsOnExit()
123
244
  const proc = Bun.spawn({
@@ -133,16 +254,17 @@ export const runCommand = async ({
133
254
  liveGroups.add(proc.pid)
134
255
  const stdout = collectText(proc.stdout)
135
256
  const stderr = collectText(proc.stderr)
136
- let timedOut = false
257
+ let killed: Promise<ProcessEntry[]> | undefined
137
258
  let grace: ReturnType<typeof setTimeout> | undefined
138
259
  const timer = setTimeout(() => {
139
- timedOut = true
140
- signalGroup({ pid: proc.pid, signal: 'SIGKILL' })
141
- proc.kill('SIGKILL')
142
- grace = setTimeout(() => {
143
- stdout.stop()
144
- stderr.stop()
145
- }, PIPE_GRACE_MS)
260
+ killed = killTree(proc.pid).then((descendants) => {
261
+ proc.kill('SIGKILL')
262
+ grace = setTimeout(() => {
263
+ stdout.stop()
264
+ stderr.stop()
265
+ }, pipe_grace_ms ?? PIPE_GRACE_MS)
266
+ return descendants
267
+ })
146
268
  }, timeout_ms ?? DEFAULT_COMMAND_TIMEOUT_MS)
147
269
  const [out, err, exitCode] = await Promise.all([
148
270
  stdout.text,
@@ -150,14 +272,18 @@ export const runCommand = async ({
150
272
  proc.exited,
151
273
  ])
152
274
  clearTimeout(timer)
275
+ const descendants = await killed
153
276
  clearTimeout(grace)
154
277
  liveGroups.delete(proc.pid)
155
- return {
156
- exit_code: timedOut ? null : exitCode,
278
+ const result = {
279
+ exit_code: killed === undefined ? exitCode : null,
157
280
  stdout: out,
158
281
  stderr: err,
159
- timed_out: timedOut,
282
+ timed_out: killed !== undefined,
160
283
  }
284
+ return descendants === undefined
285
+ ? result
286
+ : { ...result, survivors: await survivorsOf(descendants) }
161
287
  }
162
288
 
163
289
  /** Runs a command line from the engine config through `sh -c`. */
@@ -165,12 +291,55 @@ export const runShell = ({
165
291
  command,
166
292
  cwd,
167
293
  timeout_ms,
294
+ pipe_grace_ms,
168
295
  }: {
169
296
  command: string
170
297
  cwd: string
171
298
  timeout_ms?: number
299
+ /** See `runCommand`. */
300
+ pipe_grace_ms?: number
172
301
  }): Promise<CommandResult> =>
173
- runCommand({ cmd: ['sh', '-c', command], cwd, timeout_ms })
302
+ runCommand({
303
+ cmd: ['sh', '-c', command],
304
+ cwd,
305
+ timeout_ms,
306
+ pipe_grace_ms,
307
+ })
308
+
309
+ /** A time limit in words: `30 minutes`, `1 minute`, `0.3 seconds`. */
310
+ export const durationText = (ms: number): string => {
311
+ const [amount, unit] =
312
+ ms >= 60_000 ? [ms / 60_000, 'minute'] : [ms / 1000, 'second']
313
+ const rounded = Math.round(amount * 10) / 10
314
+ return `${rounded} ${unit}${rounded === 1 ? '' : 's'}`
315
+ }
316
+
317
+ /**
318
+ * The line a timed-out command's output starts with: how long it had, that
319
+ * the engine stopped it, then `hint`, if any. With `survivors`, it names
320
+ * each one instead of claiming everything was stopped (#571).
321
+ *
322
+ * @example
323
+ * timedOutLine({ timeout_ms: 300_000, hint: 'To give it longer, raise `test_timeout_ms`.' })
324
+ * // 'Timed out after 5 minutes, so the engine stopped it and everything it started. To give it longer, raise `test_timeout_ms`.\n'
325
+ */
326
+ export const timedOutLine = ({
327
+ timeout_ms,
328
+ hint,
329
+ survivors,
330
+ }: {
331
+ timeout_ms: number
332
+ /** How to give the command longer. */
333
+ hint?: string
334
+ /** What still ran after the kill. */
335
+ survivors?: Survivor[]
336
+ }): string => {
337
+ const stopped =
338
+ survivors === undefined || survivors.length === 0
339
+ ? 'so the engine stopped it and everything it started.'
340
+ : `so the engine stopped it. These were still running after the kill: ${survivors.map(({ pid, command }) => `pid ${pid} (${command})`).join(', ')}.`
341
+ return `Timed out after ${durationText(timeout_ms)}, ${stopped}${hint === undefined ? '' : ` ${hint}`}\n`
342
+ }
174
343
 
175
344
  /** Keeps the start and end of long output, for the journal and prompts. */
176
345
  export const clipOutput = ({
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Journal entry builders for agent turns: a turn started, its result for
3
+ * each role (tests written, implemented, reviewed), a failed turn, and the
4
+ * launcher's summary of a turn.
5
+ */
6
+ import type {
7
+ AgentRole,
8
+ CriterionTests,
9
+ Finding,
10
+ FindingResponse,
11
+ FindingRuling,
12
+ } from '../agents/role-results'
13
+ import type { JournalEntry } from '../journal/journal-record'
14
+
15
+ /** The session id fixtures give each role's turns unless told otherwise. */
16
+ export const SESSIONS = {
17
+ 'test-writer': 'tw-1',
18
+ implementer: 'impl-1',
19
+ 'ticket-reviewer': 'rev-1',
20
+ 'architecture-lens': 'architecture-1',
21
+ 'simplification-lens': 'simplification-1',
22
+ 'security-lens': 'security-1',
23
+ 'integration-lens': 'integration-1',
24
+ 'rules-lens': 'rules-1',
25
+ learner: 'learner-1',
26
+ } as const satisfies Record<AgentRole, string>
27
+
28
+ /** An agent turn started: a fresh launch, or a follow-up to `follow_up_of`. */
29
+ export const agentStarted = ({
30
+ ticket,
31
+ role,
32
+ prompt,
33
+ follow_up_of,
34
+ }: {
35
+ /** `null` for the final review. */
36
+ ticket: number | null
37
+ role: AgentRole
38
+ prompt?: string
39
+ follow_up_of?: string
40
+ }): JournalEntry => ({
41
+ kind: 'agent_started',
42
+ ticket,
43
+ role,
44
+ content: {
45
+ role,
46
+ prompt: prompt ?? 'p',
47
+ follow_up_of: follow_up_of ?? null,
48
+ },
49
+ })
50
+
51
+ export const testsWritten = ({
52
+ ticket,
53
+ session_id,
54
+ assumptions,
55
+ finding_responses,
56
+ run_notes,
57
+ criteria,
58
+ }: {
59
+ /** `null` for the final review. */
60
+ ticket: number | null
61
+ /** Defaults to `SESSIONS['test-writer']`. */
62
+ session_id?: string
63
+ /** Defaults to one test, `sum adds two numbers`, for AC1. */
64
+ criteria?: CriterionTests[]
65
+ /** Defaults to one assumption. */
66
+ assumptions?: string[]
67
+ /** A review fixer's answer to each finding it got. */
68
+ finding_responses?: FindingResponse[]
69
+ /** Defaults to none. */
70
+ run_notes?: string[]
71
+ }): JournalEntry => ({
72
+ kind: 'agent_finished',
73
+ ticket,
74
+ role: 'test-writer',
75
+ content: {
76
+ role: 'test-writer',
77
+ session_id: session_id ?? SESSIONS['test-writer'],
78
+ result: {
79
+ outcome: 'tests_written',
80
+ criteria: criteria ?? [
81
+ {
82
+ criterion_id: 'AC1',
83
+ tests: [
84
+ {
85
+ file: 'src/sum.test.ts',
86
+ name: 'sum adds two numbers',
87
+ },
88
+ ],
89
+ },
90
+ ],
91
+ summary: 'One test for AC1.',
92
+ assumptions: assumptions ?? ['Numbers are integers.'],
93
+ run_notes: run_notes ?? [],
94
+ finding_responses: finding_responses ?? [],
95
+ },
96
+ },
97
+ })
98
+
99
+ /** A test-writer that found nothing new to test. */
100
+ export const nothingNewToTest = ({
101
+ ticket,
102
+ }: {
103
+ ticket: number
104
+ }): JournalEntry => ({
105
+ kind: 'agent_finished',
106
+ ticket,
107
+ role: 'test-writer',
108
+ content: {
109
+ role: 'test-writer',
110
+ session_id: SESSIONS['test-writer'],
111
+ result: { outcome: 'nothing_new_to_test' },
112
+ },
113
+ })
114
+
115
+ export const implemented = ({
116
+ ticket,
117
+ outcome,
118
+ session_id,
119
+ reason,
120
+ assumptions,
121
+ finding_responses,
122
+ run_notes,
123
+ }: {
124
+ /** `null` for the final review. */
125
+ ticket: number | null
126
+ outcome?: 'done' | 'bad_test'
127
+ /** Defaults to `SESSIONS.implementer`. */
128
+ session_id?: string
129
+ /** The bad test's reason. Defaults to "Wrong sum." */
130
+ reason?: string
131
+ assumptions?: string[]
132
+ finding_responses?: FindingResponse[]
133
+ /** Defaults to none. */
134
+ run_notes?: string[]
135
+ }): JournalEntry => ({
136
+ kind: 'agent_finished',
137
+ ticket,
138
+ role: 'implementer',
139
+ content: {
140
+ role: 'implementer',
141
+ session_id: session_id ?? SESSIONS.implementer,
142
+ result: {
143
+ outcome: outcome ?? 'done',
144
+ bad_test:
145
+ outcome === 'bad_test'
146
+ ? {
147
+ file: 'src/sum.test.ts',
148
+ name: 'sum adds two numbers',
149
+ reason: reason ?? 'Wrong sum.',
150
+ }
151
+ : null,
152
+ summary: 'Added sum.',
153
+ assumptions: assumptions ?? [],
154
+ run_notes: run_notes ?? [],
155
+ finding_responses: finding_responses ?? [],
156
+ },
157
+ },
158
+ })
159
+
160
+ /** One reviewer finding; a code should-fix in `src/sum.ts` unless told otherwise. */
161
+ export const finding = ({
162
+ id,
163
+ severity,
164
+ kind,
165
+ title,
166
+ file,
167
+ }: {
168
+ id: string
169
+ severity?: 'blocker' | 'should_fix' | 'nit'
170
+ kind?: 'code' | 'test'
171
+ title?: string
172
+ file?: string | null
173
+ }): Finding => ({
174
+ id,
175
+ severity: severity ?? 'should_fix',
176
+ kind: kind ?? 'code',
177
+ file: file === undefined ? 'src/sum.ts' : file,
178
+ title: title ?? `Finding ${id}`,
179
+ detail: `Detail of ${id}.`,
180
+ })
181
+
182
+ /**
183
+ * A ticket reviewer's result. The verdict follows the findings: changes are
184
+ * requested when any is a blocker or should-fix.
185
+ */
186
+ export const reviewed = ({
187
+ ticket,
188
+ findings,
189
+ rulings,
190
+ session_id,
191
+ }: {
192
+ ticket: number
193
+ findings?: Finding[]
194
+ rulings?: FindingRuling[]
195
+ /** Defaults to `SESSIONS['ticket-reviewer']`. */
196
+ session_id?: string
197
+ }): JournalEntry => {
198
+ const list = findings ?? []
199
+ const blocking = list.some(({ severity }) => severity !== 'nit')
200
+ return {
201
+ kind: 'agent_finished',
202
+ ticket,
203
+ role: 'ticket-reviewer',
204
+ content: {
205
+ role: 'ticket-reviewer',
206
+ session_id: session_id ?? SESSIONS['ticket-reviewer'],
207
+ result: {
208
+ verdict: blocking ? 'changes_requested' : 'approve',
209
+ findings: list,
210
+ rulings: rulings ?? [],
211
+ summary: 'Looks right.',
212
+ assumptions: [],
213
+ },
214
+ },
215
+ }
216
+ }
217
+
218
+ /** An agent turn failed, in the session its role's fixtures use unless told otherwise. */
219
+ export const agentFailed = ({
220
+ ticket,
221
+ role,
222
+ failure,
223
+ error,
224
+ session_id,
225
+ session_gone,
226
+ out_of_turns,
227
+ }: {
228
+ /** `null` for the final review. */
229
+ ticket: number | null
230
+ role: AgentRole
231
+ failure: 'agent' | 'result' | 'guard' | 'engine'
232
+ /** Defaults to "It broke." */
233
+ error?: string
234
+ /** Defaults to `SESSIONS[role]`; `null` for a turn with no session. */
235
+ session_id?: string | null
236
+ /** A follow-up found its session closed or gone (#521). Left out by default. */
237
+ session_gone?: boolean
238
+ /** The turn reached its role's turn limit (#543). Left out by default. */
239
+ out_of_turns?: boolean
240
+ }): JournalEntry => ({
241
+ kind: 'agent_failed',
242
+ ticket,
243
+ role,
244
+ content: {
245
+ role,
246
+ failure,
247
+ error: error ?? 'It broke.',
248
+ session_id: session_id === undefined ? SESSIONS[role] : session_id,
249
+ ...(session_gone === undefined ? {} : { session_gone }),
250
+ ...(out_of_turns === undefined ? {} : { out_of_turns }),
251
+ },
252
+ })
253
+
254
+ /** The launcher's summary of one agent turn, with these readings and tokens. */
255
+ export const agentSession = ({
256
+ ticket,
257
+ role,
258
+ rate_limit_events,
259
+ billing_error,
260
+ output_tokens,
261
+ }: {
262
+ /** `null` for the final review. */
263
+ ticket: number | null
264
+ role: AgentRole
265
+ rate_limit_events?: Record<string, unknown>[]
266
+ billing_error?: boolean
267
+ /** Defaults to 100; input tokens are always 10. */
268
+ output_tokens?: number
269
+ }): JournalEntry => ({
270
+ kind: 'agent_session',
271
+ ticket,
272
+ role,
273
+ content: {
274
+ role,
275
+ session: {
276
+ session_id: SESSIONS[role],
277
+ usage: {
278
+ input_tokens: 10,
279
+ output_tokens: output_tokens ?? 100,
280
+ cache_read_input_tokens: 0,
281
+ cache_creation_input_tokens: 0,
282
+ },
283
+ rate_limit_events: rate_limit_events ?? [],
284
+ billing_error: billing_error ?? false,
285
+ },
286
+ },
287
+ })
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Journal entry builders for a ticket whose work is already on the base
3
+ * branch: the test-writer's evidence, the engine's check of it, and the
4
+ * ticket noted and closed.
5
+ */
6
+ import { SESSIONS } from './agent-turn-fixtures'
7
+
8
+ import type { JournalEntry } from '../journal/journal-record'
9
+
10
+ /** The commit fixtures say did an already-done ticket's work. */
11
+ export const DONE_SHA = '3559c25f5a1b2c3d4e5f60718293a4b5c6d7e8f9'
12
+
13
+ /**
14
+ * A test-writer that found the ticket's work already on the base branch:
15
+ * done by `DONE_SHA` unless told otherwise, with one existing test for AC1.
16
+ */
17
+ export const alreadyDone = ({
18
+ ticket,
19
+ shas,
20
+ }: {
21
+ ticket: number
22
+ /** Defaults to `[DONE_SHA]`. */
23
+ shas?: string[]
24
+ }): JournalEntry => ({
25
+ kind: 'agent_finished',
26
+ ticket,
27
+ role: 'test-writer',
28
+ content: {
29
+ role: 'test-writer',
30
+ session_id: SESSIONS['test-writer'],
31
+ result: {
32
+ outcome: 'already_done',
33
+ done_by: (shas ?? [DONE_SHA]).map((sha) => ({
34
+ sha,
35
+ title: 'feat: add sum',
36
+ })),
37
+ criteria: [
38
+ {
39
+ criterion_id: 'AC1',
40
+ tests: [
41
+ {
42
+ file: 'src/sum.test.ts',
43
+ name: 'sum adds two numbers',
44
+ },
45
+ ],
46
+ },
47
+ ],
48
+ summary: 'The sum is already on main, with its test.',
49
+ },
50
+ },
51
+ })
52
+
53
+ /**
54
+ * The engine checked a test-writer's "already done" evidence (#495): it
55
+ * passed, or, with `problems`, it did not.
56
+ */
57
+ export const alreadyDoneChecked = ({
58
+ ticket,
59
+ problems,
60
+ }: {
61
+ ticket: number
62
+ /** What did not check out. Left out, none: the check passed. */
63
+ problems?: string[]
64
+ }): JournalEntry => ({
65
+ kind: 'already_done_checked',
66
+ ticket,
67
+ role: null,
68
+ content: {
69
+ ok: (problems ?? []).length === 0,
70
+ problems: problems ?? [],
71
+ base_sha: 'b0',
72
+ tests: null,
73
+ },
74
+ })
75
+
76
+ /** The engine noted that a ticket's work is already done, on the spec issue. */
77
+ export const ticketAlreadyDone = ({
78
+ ticket,
79
+ shas,
80
+ }: {
81
+ ticket: number
82
+ /** Defaults to `[DONE_SHA]`. */
83
+ shas?: string[]
84
+ }): JournalEntry => ({
85
+ kind: 'ticket_already_done',
86
+ ticket,
87
+ role: null,
88
+ content: { shas: shas ?? [DONE_SHA], comment_id: 300 + ticket },
89
+ })
90
+
91
+ /** The engine closed an already-done ticket, with a comment on it. */
92
+ export const ticketClosed = ({ ticket }: { ticket: number }): JournalEntry => ({
93
+ kind: 'ticket_closed',
94
+ ticket,
95
+ role: null,
96
+ content: { comment_id: 400 + ticket },
97
+ })