@dsdenes/devboard 0.1.560 → 0.1.563

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 (323) hide show
  1. package/AGENTS.md +59 -0
  2. package/README.md +2497 -26
  3. package/bin/devboard.mjs +352 -118
  4. package/client/App.svelte +523 -0
  5. package/client/FeedbackOverlay.svelte +1125 -0
  6. package/client/FlowMode.svelte +1015 -0
  7. package/client/app.css +3922 -0
  8. package/client/env.d.ts +23 -0
  9. package/client/lib/commands.ts +836 -0
  10. package/client/lib/components/ActivityBar.svelte +295 -0
  11. package/client/lib/components/AdrTable.svelte +241 -0
  12. package/client/lib/components/CommandPalette.svelte +198 -0
  13. package/client/lib/components/ConceptKind.svelte +66 -0
  14. package/client/lib/components/ConfirmDialog.svelte +62 -0
  15. package/client/lib/components/DataTable.svelte +243 -0
  16. package/client/lib/components/DetailSplit.svelte +68 -0
  17. package/client/lib/components/DiscoverOverview.svelte +898 -0
  18. package/client/lib/components/DiscoverWorkspace.svelte +437 -0
  19. package/client/lib/components/DraftsPage.svelte +186 -0
  20. package/client/lib/components/GoalWorkspace.svelte +1042 -0
  21. package/client/lib/components/IssuesAgentFocus.svelte +117 -0
  22. package/client/lib/components/IssuesAgentLog.svelte +50 -0
  23. package/client/lib/components/IssuesQuestion.svelte +95 -0
  24. package/client/lib/components/IssuesWorkspace.svelte +577 -0
  25. package/client/lib/components/MermaidDiagram.svelte +89 -0
  26. package/client/lib/components/MonacoEditor.svelte +190 -0
  27. package/client/lib/components/NotificationRegion.svelte +195 -0
  28. package/client/lib/components/PaneHandle.svelte +25 -0
  29. package/client/lib/components/ScoutIssues.svelte +215 -0
  30. package/client/lib/components/ScoutWorkspace.svelte +180 -0
  31. package/client/lib/components/Sidebar.svelte +622 -0
  32. package/client/lib/components/StatusBar.svelte +118 -0
  33. package/client/lib/components/Terminal.svelte +193 -0
  34. package/client/lib/components/WatchdogWorkspace.svelte +537 -0
  35. package/client/lib/components/ui/button.svelte +56 -0
  36. package/client/lib/components/ui/icon-button.svelte +56 -0
  37. package/client/lib/components/ui/input.svelte +19 -0
  38. package/client/lib/components/ui/page-header.svelte +120 -0
  39. package/client/lib/components/ui/stage-track.svelte +141 -0
  40. package/client/lib/components/watchdog-indicator.svelte +189 -0
  41. package/client/lib/connection.svelte.ts +210 -0
  42. package/client/lib/dependency-diagram.test.ts +90 -0
  43. package/client/lib/dependency-diagram.ts +75 -0
  44. package/client/lib/discover-format.test.ts +265 -0
  45. package/client/lib/discover-format.ts +394 -0
  46. package/client/lib/discover.svelte.ts +412 -0
  47. package/client/lib/draft-format.test.ts +48 -0
  48. package/client/lib/draft-format.ts +13 -0
  49. package/client/lib/flow-save.test.ts +148 -0
  50. package/client/lib/flow.svelte.ts +445 -0
  51. package/client/lib/goal-reconnect.test.ts +282 -0
  52. package/client/lib/goal.svelte.ts +652 -0
  53. package/client/lib/issues.svelte.ts +439 -0
  54. package/client/lib/layout.svelte.ts +234 -0
  55. package/client/lib/notification-issue.test.ts +53 -0
  56. package/client/lib/notification-issue.ts +27 -0
  57. package/client/lib/notifications.svelte.ts +62 -0
  58. package/client/lib/poll-status.test.ts +141 -0
  59. package/client/lib/poll-status.ts +88 -0
  60. package/client/lib/record-status.test.ts +19 -0
  61. package/client/lib/record-status.ts +25 -0
  62. package/client/lib/scout.svelte.ts +164 -0
  63. package/client/lib/skill-document.test.ts +77 -0
  64. package/client/lib/skill-document.ts +57 -0
  65. package/client/lib/status-tone.test.ts +40 -0
  66. package/client/lib/status-tone.ts +33 -0
  67. package/client/lib/types.ts +578 -0
  68. package/client/lib/utils.ts +6 -0
  69. package/client/lib/viewport.svelte.ts +20 -0
  70. package/client/lib/watchdog.svelte.ts +69 -0
  71. package/client/main.ts +7 -0
  72. package/dist/apple-touch-icon.png +0 -0
  73. package/dist/assets/abnfDiagram-VCTEODGH-CmBSMArZ.js +1 -0
  74. package/dist/assets/arc-BZgCSX2H.js +1 -0
  75. package/dist/assets/architecture-7GRP2DOG-CF9MHlfo.js +1 -0
  76. package/dist/assets/architectureDiagram-5GKGNRK7-DzWB6xiQ.js +36 -0
  77. package/dist/assets/array-BifhSqXX.js +1 -0
  78. package/dist/assets/blockDiagram-I7D4REHJ-BtmGJ0hn.js +129 -0
  79. package/dist/assets/c4Diagram-7LVT6UL2-D5CHrrRX.js +38 -0
  80. package/dist/assets/channel-CgM4lcUW.js +1 -0
  81. package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  82. package/dist/assets/chunk-4HAMMTFA-DeIufjFl.js +62 -0
  83. package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  84. package/dist/assets/chunk-75Z2AOVW-DbtzyKUg.js +2 -0
  85. package/dist/assets/chunk-DU6HZSFF-C0fdbx3K.js +127 -0
  86. package/dist/assets/chunk-F27PBJKO-HObL2IvF.js +1 -0
  87. package/dist/assets/chunk-FOHPRMQF-DD4dKubD.js +161 -0
  88. package/dist/assets/chunk-GMAD6QVW-fg8wvtPY.js +72 -0
  89. package/dist/assets/chunk-GVQU2GXP-C0TaHOrD.js +1 -0
  90. package/dist/assets/chunk-IMKFNOWR-BVoIdx20.js +231 -0
  91. package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  92. package/dist/assets/chunk-L3NEJ4N5-B_kmqfk5.js +1 -0
  93. package/dist/assets/chunk-OSK3NFVY-DO0vhxHH.js +10 -0
  94. package/dist/assets/chunk-P2QGCYS3-CjljLvFj.js +1 -0
  95. package/dist/assets/chunk-POPQ4Y6H-BU0C7tw6.js +1 -0
  96. package/dist/assets/chunk-PWAF6VOD-Dn0OjBt0.js +1 -0
  97. package/dist/assets/chunk-SHT3W25Y-CMCZE2xx.js +168 -0
  98. package/dist/assets/chunk-SVP7TREG-BIRmRy_W.js +88 -0
  99. package/dist/assets/chunk-TICWLB2K-C1bc2CDE.js +206 -0
  100. package/dist/assets/chunk-XXDRQBXY-Cc38QUmR.js +1 -0
  101. package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  102. package/dist/assets/classDiagram-ZZMXUADV-BDh5w6Nc.js +1 -0
  103. package/dist/assets/classDiagram-v2-VYDZK3BY-BDh5w6Nc.js +1 -0
  104. package/dist/assets/cose-bilkent-JH36ORCC-B7pU7qhM.js +1 -0
  105. package/dist/assets/cynefin-OW5HDTMX-76dswSqS.js +1 -0
  106. package/dist/assets/cynefinDiagram-5FMLGOSQ-2kS4q01d.js +62 -0
  107. package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  108. package/dist/assets/dagre-D7JM2Yhv.js +1 -0
  109. package/dist/assets/dagre-GXQ25YYZ-B5Y21TQM.js +4 -0
  110. package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  111. package/dist/assets/diagram-S7CK7UJ4-CY6td7Et.js +30 -0
  112. package/dist/assets/diagram-UQ7AKVKN-DmDqnGZf.js +41 -0
  113. package/dist/assets/diagram-VSXAHHWV-KGLWOOcE.js +3 -0
  114. package/dist/assets/diagram-VX7I27RA-D5syUraJ.js +24 -0
  115. package/dist/assets/diagram-Z3DM3KII-Dtrbs1Om.js +24 -0
  116. package/dist/assets/dist-Dce-2YbA.js +1 -0
  117. package/dist/assets/ebnfDiagram-PWID7BFC-CQpJLtU3.js +1 -0
  118. package/dist/assets/editor.worker-BmdWdrHZ.js +33 -0
  119. package/dist/assets/erDiagram-RLTQ6QDP-cMTzKLu_.js +99 -0
  120. package/dist/assets/eventmodeling-NTZA5JFV-CMzLdNaL.js +1 -0
  121. package/dist/assets/flowDiagram-HODETNUW-BMUajB-G.js +1 -0
  122. package/dist/assets/ganttDiagram-EL5Y4UJY-DseBS5Fs.js +292 -0
  123. package/dist/assets/gitGraph-4MIJSDKK-CUQZRpxJ.js +1 -0
  124. package/dist/assets/gitGraphDiagram-WWUBYQGX-CfMfsJ3b.js +106 -0
  125. package/dist/assets/graphlib-DS17s2tU.js +1 -0
  126. package/dist/assets/index-ByQ-XX38.css +1 -0
  127. package/dist/assets/index-CIf5TpfY.js +214 -0
  128. package/dist/assets/info-A6RAGUB7-D3nAKZ8M.js +1 -0
  129. package/dist/assets/infoDiagram-27XIBGKW-zu1O0WOy.js +2 -0
  130. package/dist/assets/init-C-OQMol4.js +1 -0
  131. package/dist/assets/ishikawaDiagram-5VMMS53U-DKC7BO9p.js +70 -0
  132. package/dist/assets/journeyDiagram-3NMN7TZE-Ld5mXse7.js +139 -0
  133. package/dist/assets/kanban-definition-UXKFOSKX-CH7_QlHL.js +89 -0
  134. package/dist/assets/katex-DJhrJSbw.js +257 -0
  135. package/dist/assets/line-N9XiCREw.js +1 -0
  136. package/dist/assets/linear-C9hnqmJv.js +1 -0
  137. package/dist/assets/mermaid-parser.core-DgPwV5Bh.js +7 -0
  138. package/dist/assets/mermaid.core-BKqynXLR.js +44 -0
  139. package/dist/assets/mindmap-definition-YA3MSWOX-DkiL-wiW.js +96 -0
  140. package/dist/assets/monaco-DjpsE8to.css +1 -0
  141. package/dist/assets/monaco-DqzJaNIe.js +908 -0
  142. package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  143. package/dist/assets/packet-AYTQ26CC-DuMTvpEJ.js +1 -0
  144. package/dist/assets/path-fybaL0A-.js +1 -0
  145. package/dist/assets/pegDiagram-XKGWAZYB-DvMl_xSe.js +1 -0
  146. package/dist/assets/pie-WAS4IAKB-Da0xPaD8.js +1 -0
  147. package/dist/assets/pieDiagram-E7YTZNPT-amANNRJY.js +39 -0
  148. package/dist/assets/quadrantDiagram-AXDQQJYC-BrgvDMnk.js +7 -0
  149. package/dist/assets/radar-RG4KPBEZ-Dz3Ih5-N.js +1 -0
  150. package/dist/assets/railroad-74A4TZTK-CKMW-IV2.js +1 -0
  151. package/dist/assets/railroad-abnf-HS5TGJTU-ChqmSWSF.js +1 -0
  152. package/dist/assets/railroad-ebnf-LZEXJU2U-V3R4fFdb.js +1 -0
  153. package/dist/assets/railroad-peg-WCYAUIDC-B-RuhUQW.js +1 -0
  154. package/dist/assets/railroadDiagram-O6MQD6OU-FjG-6OLu.js +1 -0
  155. package/dist/assets/requirementDiagram-BXWQKSXE-Cnj8RfE4.js +84 -0
  156. package/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  157. package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  158. package/dist/assets/sankeyDiagram-P5KCCOFB-DMu1fnEh.js +40 -0
  159. package/dist/assets/sequenceDiagram-WJ2MYXX4-CtCtaRpW.js +162 -0
  160. package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  161. package/dist/assets/src-CGp3bfio.js +1 -0
  162. package/dist/assets/stateDiagram-D77RDMKH-xcJbLNhz.js +1 -0
  163. package/dist/assets/stateDiagram-v2-MP3YSRHH-DYHrQmJJ.js +1 -0
  164. package/dist/assets/swimlanes-42K2YHIH-BwLln_ga.js +1 -0
  165. package/dist/assets/swimlanesDiagram-VR7AAH4N-BcDfUQ0A.js +8 -0
  166. package/dist/assets/timeline-definition-24CTP7MA-ucNGsUUz.js +120 -0
  167. package/dist/assets/treeView-Q6P3EWNA-3uOomRue.js +1 -0
  168. package/dist/assets/treemap-WGGIJYW6-iGK7vwID.js +1 -0
  169. package/dist/assets/vennDiagram-4TSXK5OY-B9LgGe16.js +34 -0
  170. package/dist/assets/wardley-WFR3VGLG-FupMOf6k.js +1 -0
  171. package/dist/assets/wardleyDiagram-VM6X3IG4-CGZdEXM2.js +78 -0
  172. package/dist/assets/xterm-BrP-ENHg.css +1 -0
  173. package/dist/assets/xterm-C8zLTNGk.js +16 -0
  174. package/dist/assets/xychartDiagram-S5SC5T6Z-DafZrSBJ.js +7 -0
  175. package/dist/favicon.ico +0 -0
  176. package/dist/favicon.svg +6 -0
  177. package/dist/index.html +34 -0
  178. package/extensions/implement-goal-issue.ts +368 -0
  179. package/extensions/implement-next-epic.ts +835 -0
  180. package/index.html +28 -0
  181. package/install.sh +111 -0
  182. package/issues/control.ts +91 -0
  183. package/issues/guards.ts +12 -0
  184. package/issues/ledger.ts +122 -0
  185. package/issues/legacy.ts +68 -0
  186. package/issues/lock.ts +305 -0
  187. package/issues/paths.ts +76 -0
  188. package/issues/preflight.ts +189 -0
  189. package/issues/repository.ts +203 -0
  190. package/issues/scout/actions.ts +255 -0
  191. package/issues/scout/control.ts +80 -0
  192. package/issues/scout/github.ts +358 -0
  193. package/issues/scout/paths.ts +129 -0
  194. package/issues/scout/preflight.ts +277 -0
  195. package/issues/scout/recommendation.ts +400 -0
  196. package/issues/scout/scan.ts +143 -0
  197. package/issues/scout/state.ts +448 -0
  198. package/issues/scout/worker.ts +1282 -0
  199. package/issues/state.ts +584 -0
  200. package/issues/tracker.ts +271 -0
  201. package/issues/worker.ts +1065 -0
  202. package/lib/dependencies.ts +61 -0
  203. package/lib/forge-auth.ts +953 -0
  204. package/lib/forge.ts +141 -0
  205. package/lib/gitea.ts +729 -0
  206. package/lib/github-release.ts +318 -0
  207. package/lib/inventory.test.ts +79 -0
  208. package/lib/inventory.ts +53 -0
  209. package/lib/release.test.ts +269 -0
  210. package/lib/release.ts +801 -0
  211. package/omp/commands/adr-resolve.md +8 -0
  212. package/omp/commands/discover.md +6 -0
  213. package/omp/commands/epic-next.md +8 -0
  214. package/omp/commands/flow-change.md +8 -0
  215. package/omp/commands/flow-goal.md +8 -0
  216. package/omp/commands/flow-implement.md +8 -0
  217. package/omp/commands/flow-widget.md +8 -0
  218. package/omp/commands/goal-draft.md +8 -0
  219. package/omp/commands/goal-outcome.md +8 -0
  220. package/omp/commands/implement-issue.md +8 -0
  221. package/omp/commands/prepare-skills.md +6 -0
  222. package/omp/commands/scout-recommendations.md +8 -0
  223. package/omp/commands/setup-devboard.md +24 -0
  224. package/omp/config.yml +12 -0
  225. package/omp/extensions/implement-goal-issue.ts +2 -0
  226. package/omp/extensions/implement-next-epic.ts +2 -0
  227. package/omp/mcp.json +9 -0
  228. package/package.json +86 -7
  229. package/prompts/adr-resolve.md +130 -0
  230. package/prompts/discover.md +541 -0
  231. package/prompts/epic-next.md +533 -0
  232. package/prompts/flow-change.md +76 -0
  233. package/prompts/flow-goal.md +126 -0
  234. package/prompts/flow-implement.md +103 -0
  235. package/prompts/flow-widget.md +54 -0
  236. package/prompts/goal-draft.md +147 -0
  237. package/prompts/goal-outcome.md +87 -0
  238. package/prompts/implement-issue.md +66 -0
  239. package/prompts/prepare-skills.md +142 -0
  240. package/prompts/scout-recommendations.md +143 -0
  241. package/public/apple-touch-icon.png +0 -0
  242. package/public/favicon.ico +0 -0
  243. package/public/favicon.svg +6 -0
  244. package/server/acp.ts +540 -0
  245. package/server/activity.test.ts +96 -0
  246. package/server/activity.ts +48 -0
  247. package/server/app.test.ts +882 -0
  248. package/server/app.ts +6179 -0
  249. package/server/ask.test.ts +543 -0
  250. package/server/ask.ts +589 -0
  251. package/server/dependencies.test.ts +118 -0
  252. package/server/discover-remote.test.ts +1602 -0
  253. package/server/discover-sheet.test.ts +176 -0
  254. package/server/discover.test.ts +1510 -0
  255. package/server/discover.ts +3198 -0
  256. package/server/feedback.test.ts +458 -0
  257. package/server/feedback.ts +369 -0
  258. package/server/fixtures/adr-source.ts +71 -0
  259. package/server/fixtures/hang-download.ts +14 -0
  260. package/server/fixtures/stub-agent.ts +1870 -0
  261. package/server/fixtures/stub-forge.ts +141 -0
  262. package/server/fixtures/stub-gh.ts +57 -0
  263. package/server/flow-pr.test.ts +976 -0
  264. package/server/flow.test.ts +1632 -0
  265. package/server/flow.ts +2655 -0
  266. package/server/forge-pr.ts +387 -0
  267. package/server/goal-creation.test.ts +957 -0
  268. package/server/goal-draft.test.ts +348 -0
  269. package/server/goal-drafts.ts +449 -0
  270. package/server/goal-proposal.test.ts +479 -0
  271. package/server/goal-proposal.ts +232 -0
  272. package/server/goal-revision.test.ts +613 -0
  273. package/server/goal-slug.test.ts +56 -0
  274. package/server/goal-slug.ts +96 -0
  275. package/server/goal.ts +20 -0
  276. package/server/index.ts +147 -0
  277. package/server/issue-close.test.ts +100 -0
  278. package/server/issue-close.ts +178 -0
  279. package/server/issues.test.ts +167 -0
  280. package/server/issues.ts +479 -0
  281. package/server/journal.test.ts +350 -0
  282. package/server/journal.ts +329 -0
  283. package/server/lifecycle-pause.test.ts +1795 -0
  284. package/server/lifecycle.test.ts +1879 -0
  285. package/server/parallel-lifecycle.test.ts +1029 -0
  286. package/server/persistence-restart.test.ts +723 -0
  287. package/server/persistence.test.ts +1935 -0
  288. package/server/records.test.ts +180 -0
  289. package/server/records.ts +99 -0
  290. package/server/reset-implementation.test.ts +997 -0
  291. package/server/run.test.ts +611 -0
  292. package/server/scout.test.ts +877 -0
  293. package/server/scout.ts +459 -0
  294. package/server/skills-preparation.test.ts +1073 -0
  295. package/server/state.ts +249 -0
  296. package/server/tracker.ts +369 -0
  297. package/server/updates.test.ts +444 -0
  298. package/server/updates.ts +198 -0
  299. package/server/version.test.ts +140 -0
  300. package/server/version.ts +151 -0
  301. package/server/watchdog.ts +389 -0
  302. package/svelte.config.js +5 -0
  303. package/templates/host-AGENTS.md +235 -0
  304. package/tsconfig.json +21 -0
  305. package/vite.config.ts +21 -0
  306. package/watchdog/cleanup-main.ts +98 -0
  307. package/watchdog/cleanup.ts +301 -0
  308. package/watchdog/collect.ts +311 -0
  309. package/watchdog/coordinator.ts +397 -0
  310. package/watchdog/delivery.ts +107 -0
  311. package/watchdog/discovery.ts +209 -0
  312. package/watchdog/findings.ts +211 -0
  313. package/watchdog/github.ts +189 -0
  314. package/watchdog/history.ts +138 -0
  315. package/watchdog/install.ts +103 -0
  316. package/watchdog/ledger.ts +188 -0
  317. package/watchdog/lock.ts +63 -0
  318. package/watchdog/main.ts +106 -0
  319. package/watchdog/status.ts +71 -0
  320. package/watchdog/systemd.ts +96 -0
  321. package/watchdog/uninstall.ts +30 -0
  322. package/watchdog/units/devboard-watchdog.service +9 -0
  323. package/watchdog/units/devboard-watchdog.timer +10 -0
@@ -0,0 +1,1935 @@
1
+ import { pendingRecord } from "./fixtures/adr-source";
2
+ import { afterAll, describe, expect, test } from "bun:test";
3
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
4
+ import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { basename, dirname, join, resolve } from "node:path";
7
+ import type { Server } from "bun";
8
+ import { agentPrompt } from "./acp";
9
+ import { createApp } from "./app";
10
+ import {
11
+ ACTIVITY_MAX_BYTES,
12
+ ACTIVITY_MAX_ENTRIES,
13
+ ACTIVITY_TRIMMED_MARKER,
14
+ STATE_MAX_BYTES,
15
+ appendActivity,
16
+ readStateFile,
17
+ stateFile,
18
+ trimActivity,
19
+ writeStateFile,
20
+ } from "./state";
21
+ import type { EpicLookup, OpenEpic } from "./tracker";
22
+
23
+ const temporaryDirectories: string[] = [];
24
+ const STUB_AGENT = join(import.meta.dir, "fixtures", "stub-agent.ts");
25
+ const MANAGED_SKILL = [
26
+ "---",
27
+ "name: behavior-first-testing",
28
+ "description: Choose tests by observable behavior.",
29
+ "metadata:",
30
+ ' devboard-managed: "true"',
31
+ "---",
32
+ "",
33
+ "# Behavior-first testing",
34
+ "",
35
+ "- Prefer observable behavior over implementation details.",
36
+ "",
37
+ ].join("\n");
38
+ const PENDING_ONE = pendingRecord("0001-first.md", "First decision", "First.", "Choose the first.");
39
+ const PENDING_TWO = pendingRecord("0002-second.md", "Second decision", "Second.", "Choose the second.");
40
+
41
+ async function temporaryDirectory(): Promise<string> {
42
+ const directory = await mkdtemp(join(tmpdir(), "devboard-persistence-"));
43
+ temporaryDirectories.push(directory);
44
+ return directory;
45
+ }
46
+
47
+ afterAll(async () => {
48
+ await Promise.all(
49
+ temporaryDirectories.map((directory) => rm(directory, { recursive: true, force: true })),
50
+ );
51
+ });
52
+
53
+ interface Fixture {
54
+ root: string;
55
+ adrDir: string;
56
+ staticDir: string;
57
+ goalFile: string;
58
+ stateDir: string;
59
+ logPath: string;
60
+ }
61
+
62
+ interface FixtureOptions {
63
+ adrs?: Record<string, string>;
64
+ goal?: string | null;
65
+ git?: boolean;
66
+ skill?: boolean;
67
+ }
68
+
69
+ async function createFixture(options: FixtureOptions = {}): Promise<Fixture> {
70
+ const root = await temporaryDirectory();
71
+ const adrDir = join(root, "adrs");
72
+ const staticDir = join(root, "static");
73
+ const goalFile = join(root, "GOAL.md");
74
+ await mkdir(adrDir, { recursive: true });
75
+ await mkdir(staticDir, { recursive: true });
76
+ await writeFile(join(staticDir, "index.html"), "<!doctype html><title>ADR overview</title>");
77
+ if (options.goal !== null) {
78
+ await writeFile(goalFile, options.goal ?? "# Goal\n\nShip the lifecycle.\n");
79
+ }
80
+ for (const [file, source] of Object.entries(options.adrs ?? {})) {
81
+ await writeFile(join(adrDir, file), source);
82
+ }
83
+ if (options.skill !== false) {
84
+ await mkdir(join(root, ".omp", "skills", "behavior-first-testing"), { recursive: true });
85
+ await writeFile(
86
+ join(root, ".omp", "skills", "behavior-first-testing", "SKILL.md"),
87
+ MANAGED_SKILL,
88
+ );
89
+ }
90
+ if (options.git === true) {
91
+ await writeFile(join(root, ".gitignore"), "state/\n");
92
+ git(root, ["init", "-q", "-b", "main"]);
93
+ git(root, ["config", "user.name", "Devboard Test"]);
94
+ git(root, ["config", "user.email", "devboard@example.invalid"]);
95
+ git(root, ["config", "commit.gpgsign", "false"]);
96
+ git(root, ["add", "-A"]);
97
+ git(root, ["commit", "-q", "-m", "initial"]);
98
+ }
99
+ return {
100
+ root,
101
+ adrDir,
102
+ staticDir,
103
+ goalFile,
104
+ stateDir: join(root, "state"),
105
+ logPath: join(root, "agent.log"),
106
+ };
107
+ }
108
+
109
+ function git(directory: string, args: string[]): { code: number; stdout: string; stderr: string } {
110
+ const result = Bun.spawnSync({
111
+ cmd: ["git", "-C", directory, ...args],
112
+ env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_TERMINAL_PROMPT: "0" },
113
+ stdout: "pipe",
114
+ stderr: "pipe",
115
+ });
116
+ const output = {
117
+ code: result.exitCode ?? -1,
118
+ stdout: result.stdout.toString().trim(),
119
+ stderr: result.stderr.toString().trim(),
120
+ };
121
+ if (output.code !== 0) {
122
+ throw new Error(`git ${args.join(" ")} failed: ${output.stderr || output.stdout}`);
123
+ }
124
+ return output;
125
+ }
126
+
127
+ interface HostOptions {
128
+ agentArguments?: string;
129
+ tracker?: EpicLookup;
130
+ stateDir?: string;
131
+ laneBackoffMs?: number;
132
+ mainPollMs?: number;
133
+ laneRetentionMs?: number;
134
+ restart?: () => void;
135
+ }
136
+
137
+ function startHost(fixture: Fixture, options: HostOptions = {}): Server<unknown> {
138
+ return createApp({
139
+ adrDir: fixture.adrDir,
140
+ staticDir: fixture.staticDir,
141
+ hostname: "127.0.0.1",
142
+ port: 0,
143
+ repoDir: fixture.root,
144
+ stateDir: options.stateDir ?? fixture.stateDir,
145
+ goalFile: fixture.goalFile,
146
+ agentCommand: `bun ${STUB_AGENT} --log=${fixture.logPath} ${options.agentArguments ?? ""}`.trim(),
147
+ tracker: options.tracker ?? (async () => undefined),
148
+ trackerSettle: { attempts: 1 },
149
+ laneBackoffMs: options.laneBackoffMs,
150
+ mainPollMs: options.mainPollMs,
151
+ laneRetentionMs: options.laneRetentionMs,
152
+ ...(options.restart === undefined ? {} : { restart: options.restart }),
153
+ });
154
+ }
155
+
156
+ interface TestClient {
157
+ socket: WebSocket;
158
+ events: Record<string, unknown>[];
159
+ send(command: unknown): void;
160
+ waitFor(
161
+ predicate: (message: Record<string, unknown>) => boolean,
162
+ message?: string,
163
+ timeout?: number,
164
+ ): Promise<Record<string, unknown>>;
165
+ close(): void;
166
+ }
167
+
168
+ function connect(url: string): Promise<TestClient> {
169
+ const { promise, resolve: settle, reject } = Promise.withResolvers<TestClient>();
170
+ const socket = new WebSocket(`ws://${url}/ws`);
171
+ const events: Record<string, unknown>[] = [];
172
+ const buffered: Record<string, unknown>[] = [];
173
+ const waiters: {
174
+ predicate: (message: Record<string, unknown>) => boolean;
175
+ resolve: (message: Record<string, unknown>) => void;
176
+ }[] = [];
177
+
178
+ socket.onopen = () => {
179
+ settle({
180
+ socket,
181
+ events,
182
+ send(command) {
183
+ socket.send(JSON.stringify(command));
184
+ },
185
+ waitFor(
186
+ predicate,
187
+ message = `the expected event did not arrive; saw ${events
188
+ .slice(-6)
189
+ .map((event) => String(event.type))
190
+ .join(",")}`,
191
+ timeout = 10000,
192
+ ) {
193
+ const index = buffered.findIndex(predicate);
194
+ if (index >= 0) {
195
+ const found = buffered.splice(index, 1)[0];
196
+ if (found !== undefined) return Promise.resolve(found);
197
+ }
198
+ {
199
+ const { promise: waiting, resolve: ready } =
200
+ Promise.withResolvers<Record<string, unknown>>();
201
+ const entry = { predicate, resolve: ready };
202
+ waiters.push(entry);
203
+ setTimeout(() => {
204
+ const at = waiters.indexOf(entry);
205
+ if (at >= 0) {
206
+ waiters.splice(at, 1);
207
+ ready(Promise.reject(new Error(message)) as never);
208
+ }
209
+ }, timeout);
210
+ return waiting;
211
+ }
212
+ },
213
+ close() {
214
+ socket.close();
215
+ },
216
+ });
217
+ };
218
+
219
+ socket.onmessage = (event) => {
220
+ let message: Record<string, unknown>;
221
+ try {
222
+ message = JSON.parse(String(event.data)) as Record<string, unknown>;
223
+ } catch {
224
+ return;
225
+ }
226
+ events.push(message);
227
+ const index = waiters.findIndex((waiter) => waiter.predicate(message));
228
+ if (index >= 0) {
229
+ const waiter = waiters.splice(index, 1)[0];
230
+ if (waiter !== undefined) waiter.resolve(message);
231
+ } else {
232
+ buffered.push(message);
233
+ }
234
+ };
235
+
236
+ socket.onerror = () => reject(new Error("websocket failed"));
237
+ return promise;
238
+ }
239
+
240
+ function logLines(logPath: string): string[] {
241
+ if (!existsSync(logPath)) return [];
242
+ return readFileSync(logPath, "utf8")
243
+ .split("\n")
244
+ .filter((line) => line.length > 0);
245
+ }
246
+
247
+ function prompts(logPath: string): string[] {
248
+ return logLines(logPath)
249
+ .filter((line) => line.startsWith("prompt "))
250
+ .map((line) => line.slice("prompt ".length));
251
+ }
252
+
253
+ function spawnPids(logPath: string): number[] {
254
+ return logLines(logPath)
255
+ .filter((line) => line.startsWith("spawn "))
256
+ .map((line) => Number(line.slice("spawn ".length)));
257
+ }
258
+
259
+ function alive(pid: number): boolean {
260
+ try {
261
+ process.kill(pid, 0);
262
+ return true;
263
+ } catch {
264
+ return false;
265
+ }
266
+ }
267
+
268
+ async function waitUntil(
269
+ predicate: () => boolean | Promise<boolean>,
270
+ message: string,
271
+ timeout = 10000,
272
+ ): Promise<void> {
273
+ const deadline = Date.now() + timeout;
274
+ while (Date.now() < deadline) {
275
+ if (await predicate()) return;
276
+ await Bun.sleep(25);
277
+ }
278
+ if (!(await predicate())) throw new Error(message);
279
+ }
280
+
281
+ function storedPath(fixture: Fixture): string {
282
+ return stateFile(fixture.stateDir, resolve(fixture.root));
283
+ }
284
+
285
+ function readStored(fixture: Fixture): Record<string, unknown> {
286
+ const load = readStateFile(storedPath(fixture));
287
+ if (load.kind !== "loaded") throw new Error(`the state file is ${load.kind}`);
288
+ return load.value as Record<string, unknown>;
289
+ }
290
+
291
+ function storedState(overrides: Record<string, unknown> = {}): Record<string, unknown> {
292
+ const lifecycle = { stage: "goal", pass: 1, work: null, paused: false };
293
+ return {
294
+ lifecycle,
295
+ lastLifecycle: lifecycle,
296
+ lifecycleRunning: false,
297
+ lifecyclePausedForManualRun: false,
298
+ laneSequence: 0,
299
+ lanes: [],
300
+ epicStates: [],
301
+ parkedEpics: [],
302
+ knownEpics: [],
303
+ generationQueued: false,
304
+ generationAttempted: false,
305
+ manualSequencing: false,
306
+ manualRun: null,
307
+ goalCreation: null,
308
+ goalRevision: null,
309
+ skillsPreparation: null,
310
+ skillsPreparationPending: null,
311
+ resetActive: false,
312
+ restartWhenIdle: false,
313
+ children: [],
314
+ ...overrides,
315
+ };
316
+ }
317
+
318
+ function writeStored(fixture: Fixture, overrides: Record<string, unknown> = {}): void {
319
+ writeStateFile(
320
+ storedPath(fixture),
321
+ `${JSON.stringify({ version: 1, state: storedState(overrides) })}\n`,
322
+ );
323
+ }
324
+
325
+ function laneEntry(overrides: Record<string, unknown> = {}): Record<string, unknown> {
326
+ return {
327
+ id: "lane-1",
328
+ kind: "implementation",
329
+ epic: { number: 77, title: "Restored", url: "https://fixture.test/issues/77" },
330
+ record: null,
331
+ attempt: 1,
332
+ status: "running",
333
+ activity: ["Working on epic 77"],
334
+ question: null,
335
+ blocked: false,
336
+ failure: null,
337
+ retainUntil: null,
338
+ ...overrides,
339
+ };
340
+ }
341
+
342
+ describe("state location", () => {
343
+ test("keys the state file by a 24-hex prefix of the resolved repository", () => {
344
+ const first = stateFile("/state", "/repo/one");
345
+ const second = stateFile("/state", "/repo/two");
346
+ expect(first).toMatch(/^\/state\/state\/[0-9a-f]{24}\.json$/);
347
+ expect(second).toMatch(/^\/state\/state\/[0-9a-f]{24}\.json$/);
348
+ expect(first).not.toBe(second);
349
+ expect(stateFile("/state", "/repo/one")).toBe(first);
350
+ });
351
+
352
+ test(
353
+ "keeps two repositories sharing one state directory apart",
354
+ async () => {
355
+ const shared = await temporaryDirectory();
356
+ const first = await createFixture({ git: true });
357
+ const second = await createFixture({ git: true });
358
+ const epic: OpenEpic = {
359
+ number: 11,
360
+ title: "Only the first",
361
+ url: "https://fixture.test/issues/11",
362
+ };
363
+ const firstHost = startHost(first, {
364
+ stateDir: shared,
365
+ agentArguments: "--delay-ms=5000",
366
+ tracker: async () => epic,
367
+ });
368
+ const firstClient = await connect(`127.0.0.1:${firstHost.port}`);
369
+ const secondHost = startHost(second, {
370
+ stateDir: shared,
371
+ agentArguments: "--no-close",
372
+ });
373
+ const secondClient = await connect(`127.0.0.1:${secondHost.port}`);
374
+
375
+ try {
376
+ firstClient.send({ type: "startLifecycle" });
377
+ await firstClient.waitFor(
378
+ (message) => message.type === "lifecycleState" && message.stage === "implementation",
379
+ );
380
+ await waitUntil(
381
+ () => existsSync(stateFile(shared, resolve(first.root))),
382
+ "the first repository's state was not written",
383
+ );
384
+ expect(existsSync(stateFile(shared, resolve(second.root)))).toBe(false);
385
+
386
+ secondClient.send({ type: "listRecords" });
387
+ await secondClient.waitFor((message) => message.type === "records");
388
+ expect(secondClient.events.filter((message) => message.type === "lifecycleState")).toEqual(
389
+ [],
390
+ );
391
+ expect(secondClient.events.filter((message) => message.type === "laneState")).toEqual([]);
392
+
393
+ const stored = readStateFile(stateFile(shared, resolve(first.root)));
394
+ expect(stored.kind).toBe("loaded");
395
+ const state = (stored as { value: Record<string, unknown> }).value;
396
+ expect(state.lifecycleRunning).toBe(true);
397
+ expect(state.knownEpics).toEqual([epic]);
398
+ } finally {
399
+ firstClient.close();
400
+ secondClient.close();
401
+ firstHost.stop(true);
402
+ secondHost.stop(true);
403
+ }
404
+ },
405
+ 30000,
406
+ );
407
+ });
408
+
409
+ describe("durable writes", () => {
410
+ test(
411
+ "writes the state before the event that announces it is broadcast",
412
+ async () => {
413
+ const fixture = await createFixture({ git: true });
414
+ let lookups = 0;
415
+ const host = startHost(fixture, {
416
+ tracker: async () => {
417
+ lookups += 1;
418
+ return lookups === 1
419
+ ? { number: 21, title: "Announced", url: "https://fixture.test/issues/21" }
420
+ : undefined;
421
+ },
422
+ });
423
+ const client = await connect(`127.0.0.1:${host.port}`);
424
+
425
+ try {
426
+ client.send({ type: "startLifecycle" });
427
+ const announced = await client.waitFor(
428
+ (message) => message.type === "lifecycleState" && message.pass === 2,
429
+ );
430
+ // The event has travelled to the client; the state behind it is already
431
+ // on disk.
432
+ const state = readStored(fixture);
433
+ expect(state.lifecycle).toMatchObject({
434
+ stage: announced.stage,
435
+ pass: announced.pass,
436
+ work: announced.work,
437
+ paused: announced.paused,
438
+ });
439
+ } finally {
440
+ client.close();
441
+ host.stop(true);
442
+ }
443
+ },
444
+ 30000,
445
+ );
446
+
447
+ test("replaces the state file atomically and keeps the last complete state", async () => {
448
+ const directory = await temporaryDirectory();
449
+ const path = join(directory, "state", "abc.json");
450
+ writeStateFile(path, '{"version":1,"state":{"lifecycle":{"stage":"goal"}}}\n');
451
+ expect(readStateFile(path).kind).toBe("loaded");
452
+ expect(readdirSync(join(directory, "state"))).toEqual(["abc.json"]);
453
+
454
+ // A destination whose parent cannot be a directory fails before the
455
+ // rename, so the previous complete state stays readable.
456
+ const blocked = join(directory, "blocked");
457
+ writeFileSync(blocked, "not a directory");
458
+ const target = join(blocked, "state", "abc.json");
459
+ expect(() => writeStateFile(target, "second")).toThrow();
460
+ writeStateFile(path, '{"version":1,"state":{"lifecycle":{"stage":"epic"}}}\n');
461
+ const load = readStateFile(path);
462
+ expect(load.kind).toBe("loaded");
463
+ expect((load as { value: { lifecycle: { stage: string } } }).value.lifecycle.stage).toBe("epic");
464
+ });
465
+
466
+ test(
467
+ "keeps the state file parseable across SIGKILLs during writes",
468
+ async () => {
469
+ const directory = await temporaryDirectory();
470
+ const statePath = join(directory, "state", "key.json");
471
+ const script = join(directory, "writer.ts");
472
+ await writeFile(
473
+ script,
474
+ [
475
+ `import { writeStateFile } from ${JSON.stringify(join(import.meta.dir, "state.ts"))};`,
476
+ `const path = ${JSON.stringify(statePath)};`,
477
+ 'const activity = Array.from({ length: 400 }, (_, index) =>',
478
+ ' `line ${index} `.repeat(40),',
479
+ ');',
480
+ 'const payload = JSON.stringify({',
481
+ ' version: 1,',
482
+ ' state: {',
483
+ ' lifecycle: { stage: "epic", pass: 2, work: null, paused: false },',
484
+ ' activity,',
485
+ ' },',
486
+ '});',
487
+ "for (;;) writeStateFile(path, payload);",
488
+ ].join("\n"),
489
+ );
490
+
491
+ for (let attempt = 0; attempt < 6; attempt += 1) {
492
+ const child = Bun.spawn({
493
+ cmd: [process.execPath, script],
494
+ stdout: "ignore",
495
+ stderr: "ignore",
496
+ });
497
+ await Bun.sleep(40 + attempt * 15);
498
+ child.kill("SIGKILL");
499
+ await child.exited;
500
+ const load = readStateFile(statePath);
501
+ expect(load.kind).toBe("loaded");
502
+ if (load.kind === "loaded") {
503
+ expect((load.value as { lifecycle: { pass: number } }).lifecycle.pass).toBe(2);
504
+ }
505
+ }
506
+ // A killed writer leaves a temporary file behind at most; the next write
507
+ // still replaces the state atomically.
508
+ writeStateFile(
509
+ statePath,
510
+ '{"version":1,"state":{"lifecycle":{"stage":"goal","pass":1,"work":null,"paused":false}}}\n',
511
+ );
512
+ expect(readStateFile(statePath).kind).toBe("loaded");
513
+ },
514
+ 30000,
515
+ );
516
+ });
517
+
518
+ describe("unusable state", () => {
519
+ const cases: { label: string; write: (path: string) => void; reason: string }[] = [
520
+ {
521
+ label: "truncated JSON",
522
+ write: (path) => writeFileSync(path, '{"version":1,"state":{"lifecycle":'),
523
+ reason: "not valid JSON",
524
+ },
525
+ {
526
+ label: "unsupported version",
527
+ write: (path) => writeFileSync(path, '{"version":9,"state":{}}'),
528
+ reason: "unsupported version 9",
529
+ },
530
+ {
531
+ label: "the wrong shape",
532
+ write: (path) => writeFileSync(path, '{"version":1,"state":{"lifecycle":null}}'),
533
+ reason: "not a dashboard state",
534
+ },
535
+ ];
536
+
537
+ for (const entry of cases) {
538
+ test(
539
+ `starts from the initial state after ${entry.label}`,
540
+ async () => {
541
+ const fixture = await createFixture({ git: true });
542
+ const path = storedPath(fixture);
543
+ await mkdir(join(fixture.stateDir, "state"), { recursive: true });
544
+ entry.write(path);
545
+ const before = readFileSync(path, "utf8");
546
+
547
+ const host = startHost(fixture);
548
+ const client = await connect(`127.0.0.1:${host.port}`);
549
+ try {
550
+ const error = await client.waitFor((message) => message.type === "error");
551
+ expect(String(error.message)).toContain(entry.reason);
552
+ expect(
553
+ client.events.filter((message) => message.type === "error").length,
554
+ ).toBe(1);
555
+ client.send({ type: "getProject" });
556
+ await client.waitFor((message) => message.type === "project");
557
+ expect(client.events.filter((message) => message.type === "error").length).toBe(1);
558
+
559
+ const preserved = readdirSync(join(fixture.stateDir, "state")).filter((name) =>
560
+ name.includes(".damaged-"),
561
+ );
562
+ expect(preserved.length).toBe(1);
563
+ expect(readFileSync(join(fixture.stateDir, "state", String(preserved[0])), "utf8")).toBe(
564
+ before,
565
+ );
566
+ } finally {
567
+ client.close();
568
+ host.stop(true);
569
+ }
570
+ },
571
+ 20000,
572
+ );
573
+ }
574
+ });
575
+
576
+ describe("bounded activity", () => {
577
+ test("keeps the newest entries behind one marker when a store exceeds its bounds", () => {
578
+ const lines = Array.from({ length: ACTIVITY_MAX_ENTRIES + 25 }, (_, index) => `line ${index}`);
579
+ trimActivity(lines);
580
+ expect(lines.length).toBe(ACTIVITY_MAX_ENTRIES);
581
+ expect(lines[0]).toBe(ACTIVITY_TRIMMED_MARKER);
582
+ expect(lines[lines.length - 1]).toBe(`line ${ACTIVITY_MAX_ENTRIES + 24}`);
583
+ expect(lines[1]).toBe("line 26");
584
+
585
+ const oversized = ["x".repeat(ACTIVITY_MAX_BYTES + 1)];
586
+ trimActivity(oversized);
587
+ expect(oversized[0]).toBe(ACTIVITY_TRIMMED_MARKER);
588
+ expect(oversized.length).toBe(2);
589
+ expect(oversized[1]?.length).toBe(ACTIVITY_MAX_BYTES + 1);
590
+
591
+ const appended: string[] = [];
592
+ appendActivity(appended, "first", false);
593
+ appendActivity(appended, " and more", true);
594
+ expect(appended).toEqual(["first and more"]);
595
+ });
596
+
597
+ test(
598
+ "keeps a fully populated state under the serialized bound",
599
+ async () => {
600
+ const fixture = await createFixture({ git: true });
601
+ const entry = "a".repeat(Math.floor(ACTIVITY_MAX_BYTES / (ACTIVITY_MAX_ENTRIES - 2)));
602
+ const activity = [ACTIVITY_TRIMMED_MARKER];
603
+ for (let index = 0; index < ACTIVITY_MAX_ENTRIES - 1; index += 1) activity.push(entry);
604
+
605
+ const epics = Array.from({ length: 20 }, (_, index) => ({
606
+ number: index + 1,
607
+ title: `Epic ${index + 1}`,
608
+ url: "",
609
+ }));
610
+ writeStored(fixture, {
611
+ lifecycle: { stage: "implementation", pass: 3, work: null, paused: false },
612
+ lastLifecycle: { stage: "implementation", pass: 3, work: null, paused: false },
613
+ lifecycleRunning: true,
614
+ laneSequence: 20,
615
+ lanes: epics.map((epic) =>
616
+ laneEntry({
617
+ id: `lane-${epic.number}`,
618
+ epic,
619
+ activity: [...activity],
620
+ status: "running",
621
+ }),
622
+ ),
623
+ knownEpics: epics,
624
+ epicStates: epics.map((epic) => ({
625
+ id: String(epic.number),
626
+ epic,
627
+ attempts: 1,
628
+ status: "running",
629
+ mainAtFailure: null,
630
+ })),
631
+ });
632
+ expect((await stat(storedPath(fixture))).size).toBeLessThan(STATE_MAX_BYTES);
633
+
634
+ // Restoring 20 concurrent lanes must keep the state inside the same
635
+ // bound as those lanes append to it.
636
+ const host = startHost(fixture, { agentArguments: "--delay-ms=5000" });
637
+ try {
638
+ await waitUntil(
639
+ () => spawnPids(fixture.logPath).length === 20,
640
+ "not every restored lane was re-spawned",
641
+ 60000,
642
+ );
643
+ const bytes = (await stat(storedPath(fixture))).size;
644
+ expect(bytes).toBeLessThan(STATE_MAX_BYTES);
645
+ const restored = readStored(fixture).lanes as { activity: string[] }[];
646
+ expect(restored.length).toBe(20);
647
+ for (const lane of restored) {
648
+ expect(lane.activity.length).toBeLessThanOrEqual(ACTIVITY_MAX_ENTRIES);
649
+ expect(lane.activity[0]).toBe(ACTIVITY_TRIMMED_MARKER);
650
+ const total = lane.activity.reduce((sum, line) => sum + Buffer.byteLength(line), 0);
651
+ expect(total).toBeLessThanOrEqual(ACTIVITY_MAX_BYTES);
652
+ }
653
+ } finally {
654
+ host.stop(true);
655
+ }
656
+ },
657
+ 90000,
658
+ );
659
+ });
660
+
661
+ describe("lifecycle restore", () => {
662
+ test(
663
+ "restores a running lifecycle, its lanes, activity, and question",
664
+ async () => {
665
+ const fixture = await createFixture({ git: true });
666
+ const epic: OpenEpic = {
667
+ number: 41,
668
+ title: "Restored lane",
669
+ url: "https://fixture.test/issues/41",
670
+ };
671
+ const first = startHost(fixture, {
672
+ agentArguments: "--delay-ms=300 --questions=1 --no-close",
673
+ tracker: async () => epic,
674
+ laneBackoffMs: 60_000,
675
+ });
676
+ const client = await connect(`127.0.0.1:${first.port}`);
677
+
678
+ let laneId = "";
679
+ let questionId: unknown;
680
+ try {
681
+ client.send({ type: "startLifecycle" });
682
+ const question = await client.waitFor(
683
+ (message) => message.type === "question" && typeof message.lane === "string",
684
+ );
685
+ laneId = String(question.lane);
686
+ questionId = question.id;
687
+ const snapshot = await client.waitFor(
688
+ (message) =>
689
+ message.type === "laneState" &&
690
+ Array.isArray(message.lanes) &&
691
+ (message.lanes as { id: string; question: unknown }[]).some(
692
+ (lane) => lane.id === laneId && lane.question !== null,
693
+ ),
694
+ );
695
+ const recorded = (snapshot.lanes as { id: string; activity: string[] }[]).find(
696
+ (lane) => lane.id === laneId,
697
+ );
698
+ expect(recorded?.activity).toEqual(["Working on epic 41"]);
699
+ expect(readStored(fixture).lifecycleRunning).toBe(true);
700
+ expect(prompts(fixture.logPath)).toEqual(["/implement-next-epic 41"]);
701
+ client.close();
702
+ } finally {
703
+ client.close();
704
+ first.stop(true);
705
+ }
706
+
707
+ const second = startHost(fixture, {
708
+ agentArguments: "--delay-ms=5000 --questions=1 --no-close",
709
+ tracker: async () => epic,
710
+ laneBackoffMs: 60_000,
711
+ });
712
+ const resumed = await connect(`127.0.0.1:${second.port}`);
713
+ try {
714
+ expect(await resumed.waitFor((message) => message.type === "lifecycleState")).toEqual({
715
+ type: "lifecycleState",
716
+ stage: "implementation",
717
+ pass: 1,
718
+ work: { kind: "epic", id: "41", url: "https://fixture.test/issues/41" },
719
+ paused: false,
720
+ });
721
+ const snapshot = await resumed.waitFor((message) => message.type === "laneState");
722
+ const lanes = snapshot.lanes as {
723
+ id: string;
724
+ kind: string;
725
+ attempt: number;
726
+ status: string;
727
+ activity: string[];
728
+ question: { id: unknown } | null;
729
+ }[];
730
+ expect(lanes.length).toBe(1);
731
+ expect(lanes[0]?.id).toBe(laneId);
732
+ expect(lanes[0]?.kind).toBe("implementation");
733
+ expect(lanes[0]?.attempt).toBe(1);
734
+ expect(lanes[0]?.status).toBe("running");
735
+ expect(lanes[0]?.activity).toEqual(["Working on epic 41"]);
736
+ expect(lanes[0]?.question?.id).toBe(questionId);
737
+ expect(snapshot.openEpics).toBe(1);
738
+
739
+ await waitUntil(
740
+ () => prompts(fixture.logPath).length === 2,
741
+ "the replacement did not re-spawn the restored lane",
742
+ );
743
+ expect(prompts(fixture.logPath)).toEqual([
744
+ "/implement-next-epic 41",
745
+ "/implement-next-epic 41",
746
+ ]);
747
+ const pids = spawnPids(fixture.logPath);
748
+ expect(pids.length).toBe(2);
749
+ expect(alive(pids[0] ?? 0)).toBe(false);
750
+ expect(alive(pids[1] ?? 0)).toBe(true);
751
+
752
+ // The restored question is refused until the replacement re-issues one.
753
+ expect(lanes[0]?.question).not.toBeNull();
754
+ resumed.send({ type: "answer", lane: laneId, id: questionId, value: "Beta" });
755
+ const refusal = await resumed.waitFor((message) => message.type === "error");
756
+ expect(refusal.message).toBe("The question is no longer pending.");
757
+
758
+ const reissued = await resumed.waitFor(
759
+ (message) => message.type === "question" && message.lane === laneId,
760
+ );
761
+ resumed.send({ type: "answer", lane: laneId, id: reissued.id, value: "Beta" });
762
+ await waitUntil(
763
+ () => logLines(fixture.logPath).includes("answer Beta"),
764
+ "the replacement question was not answerable",
765
+ );
766
+ } finally {
767
+ resumed.close();
768
+ second.stop(true);
769
+ }
770
+ },
771
+ 40000,
772
+ );
773
+
774
+ test(
775
+ "restores a paused lifecycle without spawning and resumes on resumeLifecycle",
776
+ async () => {
777
+ const fixture = await createFixture({ git: true });
778
+ const epic: OpenEpic = {
779
+ number: 52,
780
+ title: "Paused lane",
781
+ url: "https://fixture.test/issues/52",
782
+ };
783
+ const first = startHost(fixture, {
784
+ agentArguments: "--delay-ms=5000 --no-close",
785
+ tracker: async () => epic,
786
+ });
787
+ const client = await connect(`127.0.0.1:${first.port}`);
788
+ try {
789
+ client.send({ type: "startLifecycle" });
790
+ await client.waitFor(
791
+ (message) => message.type === "lifecycleState" && message.stage === "implementation",
792
+ );
793
+ await client.waitFor(
794
+ (message) => message.type === "activity" && message.text === "Working on epic 52",
795
+ );
796
+ client.send({ type: "pauseLifecycle" });
797
+ await client.waitFor(
798
+ (message) => message.type === "lifecycleState" && message.paused === true,
799
+ );
800
+ await waitUntil(
801
+ () =>
802
+ (client.events.filter((message) => message.type === "laneState").at(-1) as
803
+ | { lanes: { status: string }[] }
804
+ | undefined)?.lanes[0]?.status === "paused",
805
+ "the lane did not report paused",
806
+ );
807
+ expect(prompts(fixture.logPath)).toEqual(["/implement-next-epic 52"]);
808
+ } finally {
809
+ client.close();
810
+ first.stop(true);
811
+ }
812
+
813
+ const second = startHost(fixture, {
814
+ agentArguments: "--delay-ms=5000 --no-close",
815
+ tracker: async () => epic,
816
+ });
817
+ const resumed = await connect(`127.0.0.1:${second.port}`);
818
+ try {
819
+ expect(await resumed.waitFor((message) => message.type === "lifecycleState")).toEqual({
820
+ type: "lifecycleState",
821
+ stage: "implementation",
822
+ pass: 1,
823
+ work: { kind: "epic", id: "52", url: "https://fixture.test/issues/52" },
824
+ paused: true,
825
+ });
826
+ const snapshot = await resumed.waitFor((message) => message.type === "laneState");
827
+ expect((snapshot.lanes as { status: string }[])[0]?.status).toBe("paused");
828
+
829
+ // No stage transition, tracker read, retry, generation pass, or peer
830
+ // happens while the restored run stays paused.
831
+ await Bun.sleep(400);
832
+ expect(prompts(fixture.logPath)).toEqual(["/implement-next-epic 52"]);
833
+ expect(spawnPids(fixture.logPath).length).toBe(1);
834
+ expect(resumed.events.filter((message) => message.type === "runStarted")).toEqual([]);
835
+
836
+ resumed.send({ type: "resumeLifecycle" });
837
+ const resumedState = await resumed.waitFor(
838
+ (message) => message.type === "lifecycleState" && message.paused === false,
839
+ );
840
+ expect(resumedState.stage).toBe("implementation");
841
+ await waitUntil(
842
+ () => prompts(fixture.logPath).length === 2,
843
+ "resume did not re-spawn the interrupted lane",
844
+ );
845
+ expect(prompts(fixture.logPath)).toEqual([
846
+ "/implement-next-epic 52",
847
+ "/implement-next-epic 52",
848
+ ]);
849
+ const laneSnapshot = await resumed.waitFor(
850
+ (message) =>
851
+ message.type === "laneState" &&
852
+ (message.lanes as { status: string }[])[0]?.status === "running",
853
+ );
854
+ expect(laneSnapshot.lanes as unknown).toBeDefined();
855
+ } finally {
856
+ resumed.close();
857
+ second.stop(true);
858
+ }
859
+ },
860
+ 40000,
861
+ );
862
+
863
+ test(
864
+ "replays a finished lifecycle with its final pass and retained lanes",
865
+ async () => {
866
+ const fixture = await createFixture({ git: true, adrs: {} });
867
+ const env = { agentArguments: "--delay-ms=50" };
868
+ const first = startHost(fixture, env);
869
+ const client = await connect(`127.0.0.1:${first.port}`);
870
+ try {
871
+ client.send({ type: "startLifecycle" });
872
+ expect(
873
+ await client.waitFor(
874
+ (message) => message.type === "lifecycleState" && message.stage === "finished",
875
+ ),
876
+ ).toEqual({
877
+ type: "lifecycleState",
878
+ stage: "finished",
879
+ pass: 1,
880
+ work: null,
881
+ paused: false,
882
+ });
883
+ } finally {
884
+ client.close();
885
+ first.stop(true);
886
+ }
887
+
888
+ const second = startHost(fixture, env);
889
+ const resumed = await connect(`127.0.0.1:${second.port}`);
890
+ try {
891
+ resumed.send({ type: "getProject" });
892
+ await resumed.waitFor((message) => message.type === "project");
893
+ const stateIndex = resumed.events.findIndex(
894
+ (message) => message.type === "lifecycleState",
895
+ );
896
+ const replyIndex = resumed.events.findIndex((message) => message.type === "project");
897
+ expect(stateIndex).toBeGreaterThanOrEqual(0);
898
+ expect(stateIndex).toBeLessThan(replyIndex);
899
+ expect(resumed.events[stateIndex]).toEqual({
900
+ type: "lifecycleState",
901
+ stage: "finished",
902
+ pass: 1,
903
+ work: null,
904
+ paused: false,
905
+ });
906
+ } finally {
907
+ resumed.close();
908
+ second.stop(true);
909
+ }
910
+ },
911
+ 30000,
912
+ );
913
+
914
+ test(
915
+ "replays the idle goal stage with the reached pass after stopLifecycle",
916
+ async () => {
917
+ const fixture = await createFixture({ git: true });
918
+ const epic: OpenEpic = {
919
+ number: 61,
920
+ title: "Idle pass",
921
+ url: "https://fixture.test/issues/61",
922
+ };
923
+ let lookups = 0;
924
+ const first = startHost(fixture, {
925
+ tracker: async () => {
926
+ lookups += 1;
927
+ return lookups === 1 ? epic : undefined;
928
+ },
929
+ });
930
+ const client = await connect(`127.0.0.1:${first.port}`);
931
+ try {
932
+ client.send({ type: "startLifecycle" });
933
+ await client.waitFor(
934
+ (message) => message.type === "lifecycleState" && message.pass === 2,
935
+ );
936
+ client.send({ type: "stopLifecycle" });
937
+ await client.waitFor(
938
+ (message) =>
939
+ message.type === "lifecycleState" && message.stage === "goal" && message.pass === 2,
940
+ );
941
+ } finally {
942
+ client.close();
943
+ first.stop(true);
944
+ }
945
+
946
+ const second = startHost(fixture);
947
+ const resumed = await connect(`127.0.0.1:${second.port}`);
948
+ try {
949
+ expect(await resumed.waitFor((message) => message.type === "lifecycleState")).toEqual({
950
+ type: "lifecycleState",
951
+ stage: "goal",
952
+ pass: 2,
953
+ work: null,
954
+ paused: false,
955
+ });
956
+ const snapshot = await resumed.waitFor((message) => message.type === "laneState");
957
+ // The retained tabs are part of the restored state; the run itself is idle.
958
+ const statuses = (snapshot.lanes as { status: string }[]).map((lane) => lane.status);
959
+ expect(statuses.length).toBeGreaterThan(0);
960
+ expect(statuses.every((status) => status === "closed")).toBe(true);
961
+ } finally {
962
+ resumed.close();
963
+ second.stop(true);
964
+ }
965
+ },
966
+ 30000,
967
+ );
968
+ });
969
+
970
+ describe("lifecycle replay boundaries", () => {
971
+ test(
972
+ "sends no lifecycleState for a repository whose lifecycle never ran",
973
+ async () => {
974
+ const fixture = await createFixture({
975
+ skill: false,
976
+ adrs: { "0001-first.md": PENDING_ONE },
977
+ });
978
+ const args = "--delay-ms=20 --questions=0";
979
+ const first = startHost(fixture, { agentArguments: args });
980
+ const client = await connect(`127.0.0.1:${first.port}`);
981
+ try {
982
+ client.send({ type: "startRun" });
983
+ await client.waitFor((message) => message.type === "runStopped");
984
+ expect(client.events.filter((message) => message.type === "lifecycleState")).toEqual([]);
985
+ } finally {
986
+ client.close();
987
+ first.stop(true);
988
+ }
989
+
990
+ const second = startHost(fixture, { agentArguments: args });
991
+ const resumed = await connect(`127.0.0.1:${second.port}`);
992
+ try {
993
+ resumed.send({ type: "getProject" });
994
+ await resumed.waitFor((message) => message.type === "project");
995
+ expect(resumed.events.filter((message) => message.type === "lifecycleState")).toEqual([]);
996
+ expect(readStored(fixture).lastLifecycle).toBeNull();
997
+ } finally {
998
+ resumed.close();
999
+ second.stop(true);
1000
+ }
1001
+ },
1002
+ 20000,
1003
+ );
1004
+
1005
+ test(
1006
+ "replays the idle goal state of an aborted run",
1007
+ async () => {
1008
+ const fixture = await createFixture({ git: true });
1009
+ const first = startHost(fixture, {
1010
+ tracker: async () => {
1011
+ throw new Error("tracker unavailable");
1012
+ },
1013
+ });
1014
+ const client = await connect(`127.0.0.1:${first.port}`);
1015
+ try {
1016
+ client.send({ type: "startLifecycle" });
1017
+ expect(await client.waitFor((message) => message.type === "error")).toMatchObject({
1018
+ message: "tracker unavailable",
1019
+ });
1020
+ expect(
1021
+ await client.waitFor(
1022
+ (message) =>
1023
+ message.type === "lifecycleState" && message.stage === "goal" && message.pass === 1,
1024
+ ),
1025
+ ).toMatchObject({ stage: "goal", pass: 1, work: null, paused: false });
1026
+ } finally {
1027
+ client.close();
1028
+ first.stop(true);
1029
+ }
1030
+
1031
+ const second = startHost(fixture);
1032
+ const resumed = await connect(`127.0.0.1:${second.port}`);
1033
+ try {
1034
+ expect(await resumed.waitFor((message) => message.type === "lifecycleState")).toEqual({
1035
+ type: "lifecycleState",
1036
+ stage: "goal",
1037
+ pass: 1,
1038
+ work: null,
1039
+ paused: false,
1040
+ });
1041
+ } finally {
1042
+ resumed.close();
1043
+ second.stop(true);
1044
+ }
1045
+ },
1046
+ 20000,
1047
+ );
1048
+ });
1049
+
1050
+ describe("epic bookkeeping and generation", () => {
1051
+ test(
1052
+ "restores a retrying epic and retries it after the backoff",
1053
+ async () => {
1054
+ const fixture = await createFixture({ git: true });
1055
+ const epic: OpenEpic = {
1056
+ number: 71,
1057
+ title: "Retrying epic",
1058
+ url: "https://fixture.test/issues/71",
1059
+ };
1060
+ const run = () =>
1061
+ startHost(fixture, {
1062
+ agentArguments: "--delay-ms=20",
1063
+ tracker: async () => epic,
1064
+ laneBackoffMs: 100,
1065
+ mainPollMs: 100,
1066
+ });
1067
+ const first = run();
1068
+ const client = await connect(`127.0.0.1:${first.port}`);
1069
+ try {
1070
+ client.send({ type: "startLifecycle" });
1071
+ const snapshot = await client.waitFor(
1072
+ (message) =>
1073
+ message.type === "laneState" &&
1074
+ (message.lanes as { status: string }[])[0]?.status === "retrying",
1075
+ );
1076
+ expect((snapshot.lanes as { activity: string[] }[])[0]?.activity).toEqual([
1077
+ "Working on epic 71",
1078
+ ]);
1079
+ const state = readStored(fixture);
1080
+ const stored = state.epicStates as { status: string; attempts: number }[];
1081
+ expect(stored[0]?.status).toBe("retrying");
1082
+ expect(stored[0]?.attempts).toBe(1);
1083
+ } finally {
1084
+ client.close();
1085
+ first.stop(true);
1086
+ }
1087
+
1088
+ const second = run();
1089
+ const resumed = await connect(`127.0.0.1:${second.port}`);
1090
+ try {
1091
+ const snapshot = await resumed.waitFor((message) => message.type === "laneState");
1092
+ expect((snapshot.lanes as { status: string }[])[0]?.status).toBe("retrying");
1093
+ await waitUntil(
1094
+ () => prompts(fixture.logPath).length === 2,
1095
+ "the restored retrying epic did not run again",
1096
+ );
1097
+ expect(prompts(fixture.logPath)).toEqual([
1098
+ "/implement-next-epic 71",
1099
+ "/implement-next-epic 71",
1100
+ ]);
1101
+ const retried = await resumed.waitFor(
1102
+ (message) =>
1103
+ message.type === "laneState" &&
1104
+ (message.lanes as { attempt: number; status: string }[]).some(
1105
+ (lane) => lane.attempt === 2,
1106
+ ),
1107
+ );
1108
+ expect(retried.lanes as unknown).toBeDefined();
1109
+ } finally {
1110
+ resumed.close();
1111
+ second.stop(true);
1112
+ }
1113
+ },
1114
+ 40000,
1115
+ );
1116
+
1117
+ test(
1118
+ "restores a parked epic and waits for main to advance",
1119
+ async () => {
1120
+ const fixture = await createFixture({ git: true });
1121
+ const parked: OpenEpic = {
1122
+ number: 81,
1123
+ title: "Parked epic",
1124
+ url: "https://fixture.test/issues/81",
1125
+ };
1126
+ const running: OpenEpic = {
1127
+ number: 82,
1128
+ title: "Running epic",
1129
+ url: "https://fixture.test/issues/82",
1130
+ };
1131
+ const run = () =>
1132
+ startHost(fixture, {
1133
+ // Epic 81 parks; epic 82 keeps the run alive past the kill.
1134
+ agentArguments: "--blocker-epic=81 --lane-delay=82:6000 --no-close",
1135
+ tracker: async () => [parked, running],
1136
+ laneBackoffMs: 100,
1137
+ mainPollMs: 100,
1138
+ });
1139
+ const first = run();
1140
+ const client = await connect(`127.0.0.1:${first.port}`);
1141
+ try {
1142
+ client.send({ type: "startLifecycle" });
1143
+ await client.waitFor(
1144
+ (message) =>
1145
+ message.type === "laneState" &&
1146
+ (message.lanes as { status: string }[]).some((lane) => lane.status === "parked"),
1147
+ );
1148
+ await client.waitFor(
1149
+ (message) =>
1150
+ message.type === "laneState" &&
1151
+ (message.lanes as { status: string }[]).some((lane) => lane.status === "running"),
1152
+ );
1153
+ const state = readStored(fixture);
1154
+ expect(state.parkedEpics).toEqual(["81"]);
1155
+ expect(state.lifecycleRunning).toBe(true);
1156
+ const parkedState = (state.epicStates as {
1157
+ id: string;
1158
+ status: string;
1159
+ mainAtFailure: string | null;
1160
+ }[]).find((entry) => entry.id === "81");
1161
+ expect(parkedState?.status).toBe("parked");
1162
+ expect(parkedState?.mainAtFailure).toMatch(/^[0-9a-f]{40}$/);
1163
+ } finally {
1164
+ client.close();
1165
+ first.stop(true);
1166
+ }
1167
+
1168
+ const second = run();
1169
+ const resumed = await connect(`127.0.0.1:${second.port}`);
1170
+ try {
1171
+ const snapshot = await resumed.waitFor(
1172
+ (message) =>
1173
+ message.type === "laneState" &&
1174
+ (message.lanes as { status: string }[]).length >= 2,
1175
+ );
1176
+ const statuses = (snapshot.lanes as { status: string }[]).map((lane) => lane.status);
1177
+ expect(statuses).toContain("parked");
1178
+ // While main stays where it was, the parked epic starts no peer again.
1179
+ const parkedPrompts = () =>
1180
+ prompts(fixture.logPath).filter((prompt) => prompt === "/implement-next-epic 81").length;
1181
+ await Bun.sleep(500);
1182
+ expect(parkedPrompts()).toBe(1);
1183
+
1184
+ await writeFile(join(fixture.root, "advance.txt"), "advance\n");
1185
+ git(fixture.root, ["add", "advance.txt"]);
1186
+ git(fixture.root, ["commit", "-q", "-m", "advance main"]);
1187
+ await waitUntil(
1188
+ () => parkedPrompts() === 2,
1189
+ "the parked epic did not run after main advanced",
1190
+ );
1191
+ } finally {
1192
+ resumed.close();
1193
+ second.stop(true);
1194
+ }
1195
+ },
1196
+ 40000,
1197
+ );
1198
+
1199
+ test(
1200
+ "resumes an in-flight generation lane with its recorded activity",
1201
+ async () => {
1202
+ const fixture = await createFixture({ git: true });
1203
+ const first = startHost(fixture, { agentArguments: "--delay-ms=2000" });
1204
+ const client = await connect(`127.0.0.1:${first.port}`);
1205
+ let laneId = "";
1206
+ try {
1207
+ client.send({ type: "startLifecycle" });
1208
+ const snapshot = await client.waitFor(
1209
+ (message) =>
1210
+ message.type === "laneState" &&
1211
+ (message.lanes as { kind: string }[])[0]?.kind === "generation",
1212
+ );
1213
+ const lane = (snapshot.lanes as { id: string; kind: string; status: string }[])[0];
1214
+ expect(lane?.kind).toBe("generation");
1215
+ expect(lane?.status).toBe("running");
1216
+ laneId = String(lane?.id);
1217
+ await client.waitFor(
1218
+ (message) => message.type === "activity" && message.text === "Choosing the next epics",
1219
+ );
1220
+ } finally {
1221
+ client.close();
1222
+ first.stop(true);
1223
+ }
1224
+
1225
+ const second = startHost(fixture, { agentArguments: "--delay-ms=100 --questions=0" });
1226
+ const resumed = await connect(`127.0.0.1:${second.port}`);
1227
+ try {
1228
+ const snapshot = await resumed.waitFor((message) => message.type === "laneState");
1229
+ const lane = (snapshot.lanes as { id: string; kind: string; activity: string[] }[])[0];
1230
+ expect(lane?.id).toBe(laneId);
1231
+ expect(lane?.kind).toBe("generation");
1232
+ expect(lane?.activity.slice(0, 1)).toEqual(["Choosing the next epics"]);
1233
+ await waitUntil(
1234
+ () => prompts(fixture.logPath).filter((prompt) => prompt === "/epic-next").length === 2,
1235
+ "the restored generation lane did not re-spawn",
1236
+ );
1237
+ expect(
1238
+ await resumed.waitFor(
1239
+ (message) => message.type === "lifecycleState" && message.stage === "finished",
1240
+ ),
1241
+ ).toMatchObject({ stage: "finished" });
1242
+ } finally {
1243
+ resumed.close();
1244
+ second.stop(true);
1245
+ }
1246
+ },
1247
+ 40000,
1248
+ );
1249
+
1250
+ test(
1251
+ "runs a queued generation exactly once after the restored pass",
1252
+ async () => {
1253
+ const fixture = await createFixture({ git: true });
1254
+ writeStored(fixture, {
1255
+ lifecycle: { stage: "epic", pass: 1, work: null, paused: false },
1256
+ lastLifecycle: { stage: "epic", pass: 1, work: null, paused: false },
1257
+ lifecycleRunning: true,
1258
+ generationQueued: true,
1259
+ generationAttempted: true,
1260
+ laneSequence: 1,
1261
+ lanes: [
1262
+ laneEntry({
1263
+ id: "lane-1",
1264
+ kind: "generation",
1265
+ epic: null,
1266
+ activity: ["Choosing the next epics"],
1267
+ }),
1268
+ ],
1269
+ });
1270
+
1271
+ const host = startHost(fixture, { agentArguments: "--delay-ms=20" });
1272
+ const client = await connect(`127.0.0.1:${host.port}`);
1273
+ try {
1274
+ expect(
1275
+ await client.waitFor(
1276
+ (message) => message.type === "lifecycleState" && message.stage === "finished",
1277
+ ),
1278
+ ).toMatchObject({ stage: "finished" });
1279
+ await Bun.sleep(300);
1280
+ expect(prompts(fixture.logPath)).toEqual(["/epic-next", "/epic-next"]);
1281
+ // The queued pass is a new lane above the restored sequence.
1282
+ const ids = new Set<string>();
1283
+ for (const event of client.events) {
1284
+ if (event.type !== "laneState") continue;
1285
+ for (const lane of event.lanes as { id: string }[]) ids.add(lane.id);
1286
+ }
1287
+ expect([...ids].sort()).toEqual(["lane-1", "lane-2"]);
1288
+ } finally {
1289
+ client.close();
1290
+ host.stop(true);
1291
+ }
1292
+ },
1293
+ 30000,
1294
+ );
1295
+
1296
+ test(
1297
+ "honors restored lane retention and drops an expired tab",
1298
+ async () => {
1299
+ const fixture = await createFixture({ git: true });
1300
+ writeStored(fixture, {
1301
+ lifecycle: { stage: "finished", pass: 4, work: null, paused: false },
1302
+ lastLifecycle: { stage: "finished", pass: 4, work: null, paused: false },
1303
+ lanes: [
1304
+ laneEntry({
1305
+ status: "closed",
1306
+ activity: ["Finished"],
1307
+ retainUntil: Date.now() + 1500,
1308
+ }),
1309
+ ],
1310
+ });
1311
+ const host = startHost(fixture);
1312
+ const client = await connect(`127.0.0.1:${host.port}`);
1313
+ try {
1314
+ expect(await client.waitFor((message) => message.type === "lifecycleState")).toEqual({
1315
+ type: "lifecycleState",
1316
+ stage: "finished",
1317
+ pass: 4,
1318
+ work: null,
1319
+ paused: false,
1320
+ });
1321
+ const snapshot = await client.waitFor((message) => message.type === "laneState");
1322
+ expect((snapshot.lanes as { id: string }[])[0]?.id).toBe("lane-1");
1323
+ const emptied = await client.waitFor(
1324
+ (message) => message.type === "laneState" && (message.lanes as unknown[]).length === 0,
1325
+ );
1326
+ expect(emptied.lanes).toEqual([]);
1327
+ } finally {
1328
+ client.close();
1329
+ host.stop(true);
1330
+ }
1331
+
1332
+ const expiring = await createFixture({ git: true });
1333
+ writeStored(expiring, {
1334
+ lifecycle: { stage: "finished", pass: 2, work: null, paused: false },
1335
+ lastLifecycle: { stage: "finished", pass: 2, work: null, paused: false },
1336
+ lanes: [
1337
+ laneEntry({
1338
+ status: "closed",
1339
+ activity: ["Finished earlier"],
1340
+ retainUntil: Date.now() - 1000,
1341
+ }),
1342
+ ],
1343
+ });
1344
+ const third = startHost(expiring);
1345
+ const late = await connect(`127.0.0.1:${third.port}`);
1346
+ try {
1347
+ expect(await late.waitFor((message) => message.type === "lifecycleState")).toEqual({
1348
+ type: "lifecycleState",
1349
+ stage: "finished",
1350
+ pass: 2,
1351
+ work: null,
1352
+ paused: false,
1353
+ });
1354
+ const snapshot = await late.waitFor((message) => message.type === "laneState");
1355
+ expect(snapshot.lanes).toEqual([]);
1356
+ } finally {
1357
+ late.close();
1358
+ third.stop(true);
1359
+ }
1360
+ },
1361
+ 30000,
1362
+ );
1363
+ });
1364
+
1365
+ describe("ADR lane restore", () => {
1366
+ test(
1367
+ "re-spawns a restored ADR lane with the same record prompt",
1368
+ async () => {
1369
+ const fixture = await createFixture({ git: true, adrs: { "0001-first.md": PENDING_ONE } });
1370
+ const lifecycle = {
1371
+ stage: "adr",
1372
+ pass: 1,
1373
+ work: { kind: "adr", id: "0001-first.md" },
1374
+ paused: false,
1375
+ };
1376
+ writeStored(fixture, {
1377
+ lifecycle,
1378
+ lastLifecycle: lifecycle,
1379
+ lifecycleRunning: true,
1380
+ laneSequence: 1,
1381
+ lanes: [
1382
+ laneEntry({
1383
+ kind: "adr",
1384
+ epic: null,
1385
+ record: { file: "0001-first.md", title: "First decision" },
1386
+ activity: ["Working on /adr-resolve"],
1387
+ }),
1388
+ ],
1389
+ });
1390
+
1391
+ const host = startHost(fixture, { agentArguments: "--delay-ms=3000 --questions=0" });
1392
+ const client = await connect(`127.0.0.1:${host.port}`);
1393
+ try {
1394
+ expect(await client.waitFor((message) => message.type === "lifecycleState")).toEqual({
1395
+ type: "lifecycleState",
1396
+ ...lifecycle,
1397
+ });
1398
+ const snapshot = await client.waitFor((message) => message.type === "laneState");
1399
+ const lane = (snapshot.lanes as {
1400
+ kind: string;
1401
+ record: { file: string };
1402
+ status: string;
1403
+ activity: string[];
1404
+ }[])[0];
1405
+ expect(lane?.kind).toBe("adr");
1406
+ expect(lane?.record.file).toBe("0001-first.md");
1407
+ expect(lane?.status).toBe("running");
1408
+ expect(lane?.activity).toEqual(["Working on /adr-resolve"]);
1409
+
1410
+ await waitUntil(
1411
+ () => prompts(fixture.logPath).length === 1,
1412
+ "the restored ADR lane did not re-spawn",
1413
+ );
1414
+ expect(prompts(fixture.logPath)).toEqual([
1415
+ agentPrompt(fixture.adrDir, "0001-first.md").split("\n")[0] ?? "",
1416
+ ]);
1417
+ } finally {
1418
+ client.close();
1419
+ host.stop(true);
1420
+ }
1421
+ },
1422
+ 30000,
1423
+ );
1424
+ });
1425
+
1426
+ describe("foreground restore", () => {
1427
+ test(
1428
+ "continues a restored manual ADR sequence from its own record",
1429
+ async () => {
1430
+ const fixture = await createFixture({
1431
+ skill: false,
1432
+ adrs: { "0001-first.md": PENDING_ONE, "0002-second.md": PENDING_TWO },
1433
+ });
1434
+ const first = startHost(fixture, { agentArguments: "--delay-ms=3000 --questions=0" });
1435
+ const client = await connect(`127.0.0.1:${first.port}`);
1436
+ let recorded = "";
1437
+ try {
1438
+ client.send({ type: "startRun" });
1439
+ const started = await client.waitFor((message) => message.type === "runStarted");
1440
+ expect(started).toMatchObject({ file: "0001-first.md", manual: true });
1441
+ const activity = await client.waitFor(
1442
+ (message) => message.type === "activity" && typeof message.lane !== "string",
1443
+ );
1444
+ recorded = String(activity.text);
1445
+ expect(recorded).toBe("Inspecting 0001-first.md");
1446
+ expect(readStored(fixture).manualRun).toMatchObject({ file: "0001-first.md" });
1447
+ } finally {
1448
+ client.close();
1449
+ first.stop(true);
1450
+ }
1451
+
1452
+ const second = startHost(fixture, { agentArguments: "--delay-ms=100 --questions=0" });
1453
+ const resumed = await connect(`127.0.0.1:${second.port}`);
1454
+ try {
1455
+ const started = await resumed.waitFor((message) => message.type === "runStarted");
1456
+ expect(started).toMatchObject({
1457
+ file: "0001-first.md",
1458
+ title: "First decision",
1459
+ action: "resolving",
1460
+ manual: true,
1461
+ });
1462
+ const replay = await resumed.waitFor((message) => message.type === "activity");
1463
+ expect(replay).toEqual({ type: "activity", text: recorded });
1464
+ const next = await resumed.waitFor(
1465
+ (message) => message.type === "runStarted" && message.file === "0002-second.md",
1466
+ );
1467
+ expect(next.manual).toBe(true);
1468
+ await waitUntil(
1469
+ () =>
1470
+ logLines(fixture.logPath).filter((line) => line.startsWith("resolved ")).length >= 2,
1471
+ "the restored sequence did not continue",
1472
+ );
1473
+ expect(
1474
+ prompts(fixture.logPath).filter((prompt) => prompt.startsWith("/adr-resolve")).length,
1475
+ ).toBe(3);
1476
+ expect(resumed.events.filter((message) => message.type === "error")).toEqual([]);
1477
+ } finally {
1478
+ resumed.close();
1479
+ second.stop(true);
1480
+ }
1481
+ },
1482
+ 40000,
1483
+ );
1484
+
1485
+ test(
1486
+ "restores goal creation with its original prompt and activity",
1487
+ async () => {
1488
+ const fixture = await createFixture({ goal: null, skill: false });
1489
+ const args = (delay: number) =>
1490
+ `--create-goal --goal=${fixture.goalFile} --delay-ms=${delay}`;
1491
+ const first = startHost(fixture, { agentArguments: args(3000) });
1492
+ const client = await connect(`127.0.0.1:${first.port}`);
1493
+ try {
1494
+ client.send({ type: "createGoal" });
1495
+ await client.waitFor((message) => message.type === "goalCreationStarted");
1496
+ const activity = await client.waitFor((message) => message.type === "activity");
1497
+ expect(activity.text).toBe("Creating the goal");
1498
+ expect(readStored(fixture).goalCreation).not.toBeNull();
1499
+ } finally {
1500
+ client.close();
1501
+ first.stop(true);
1502
+ }
1503
+
1504
+ const second = startHost(fixture, { agentArguments: args(2000) });
1505
+ const resumed = await connect(`127.0.0.1:${second.port}`);
1506
+ try {
1507
+ const started = await resumed.waitFor((message) => message.type === "goalCreationStarted");
1508
+ expect(started).toBeDefined();
1509
+ expect(await resumed.waitFor((message) => message.type === "activity")).toEqual({
1510
+ type: "activity",
1511
+ text: "Creating the goal",
1512
+ });
1513
+ // The replacement peer asks its own question; the restored prefix is
1514
+ // kept and the new line appends.
1515
+ const question = await resumed.waitFor((message) => message.type === "question");
1516
+ resumed.send({ type: "answer", id: question.id, value: "Alpha" });
1517
+ const stopped = await resumed.waitFor(
1518
+ (message) => message.type === "goalCreationStopped",
1519
+ "the restored creation did not finish",
1520
+ 20000,
1521
+ );
1522
+ expect(stopped).toMatchObject({ completed: true });
1523
+ expect(String(stopped.text)).toContain("# Created goal");
1524
+ expect(prompts(fixture.logPath).filter((prompt) => prompt === "/goal-outcome").length).toBe(
1525
+ 2,
1526
+ );
1527
+ } finally {
1528
+ resumed.close();
1529
+ second.stop(true);
1530
+ }
1531
+ },
1532
+ 30000,
1533
+ );
1534
+
1535
+ test(
1536
+ "restores goal revision with the same guidance",
1537
+ async () => {
1538
+ const fixture = await createFixture({ skill: false });
1539
+ const args = (delay: number) =>
1540
+ `--revise-goal --goal=${fixture.goalFile} --delay-ms=${delay}`;
1541
+ const first = startHost(fixture, { agentArguments: args(3000) });
1542
+ const client = await connect(`127.0.0.1:${first.port}`);
1543
+ try {
1544
+ client.send({ type: "reviseGoal", request: "Tighten the outcome" });
1545
+ await client.waitFor((message) => message.type === "goalRevisionStarted");
1546
+ await client.waitFor((message) => message.type === "activity");
1547
+ } finally {
1548
+ client.close();
1549
+ first.stop(true);
1550
+ }
1551
+
1552
+ const second = startHost(fixture, { agentArguments: args(2000) });
1553
+ const resumed = await connect(`127.0.0.1:${second.port}`);
1554
+ try {
1555
+ await resumed.waitFor((message) => message.type === "goalRevisionStarted");
1556
+ expect(await resumed.waitFor((message) => message.type === "activity")).toEqual({
1557
+ type: "activity",
1558
+ text: "Revising the goal",
1559
+ });
1560
+ const question = await resumed.waitFor((message) => message.type === "question");
1561
+ resumed.send({ type: "answer", id: question.id, value: "Alpha" });
1562
+ expect(
1563
+ await resumed.waitFor(
1564
+ (message) => message.type === "goalRevisionStopped",
1565
+ "the restored revision did not finish",
1566
+ 20000,
1567
+ ),
1568
+ ).toMatchObject({ completed: true });
1569
+ const requests = logLines(fixture.logPath)
1570
+ .filter((line) => line.startsWith("request "))
1571
+ .map((line) => line.slice("request ".length));
1572
+ expect(requests.length).toBe(2);
1573
+ expect(requests[0]).toBe(requests[1]);
1574
+ const raw = readFileSync(fixture.logPath, "utf8");
1575
+ expect(raw.split("Guidance: Tighten the outcome").length - 1).toBe(2);
1576
+ } finally {
1577
+ resumed.close();
1578
+ second.stop(true);
1579
+ }
1580
+ },
1581
+ 30000,
1582
+ );
1583
+
1584
+ test(
1585
+ "restores skills preparation and rolls back to the pre-run bytes on failure",
1586
+ async () => {
1587
+ const fixture = await createFixture({ skill: false });
1588
+ await mkdir(join(fixture.root, ".omp", "skills", "behavior-first-testing"), {
1589
+ recursive: true,
1590
+ });
1591
+ await writeFile(
1592
+ join(fixture.root, ".omp", "skills", "behavior-first-testing", "SKILL.md"),
1593
+ MANAGED_SKILL,
1594
+ );
1595
+ const args = (delay: number) =>
1596
+ `--prepare-skills --prepare-skills-error --delay-ms=${delay}`;
1597
+ const first = startHost(fixture, { agentArguments: args(3000) });
1598
+ const client = await connect(`127.0.0.1:${first.port}`);
1599
+ try {
1600
+ client.send({ type: "prepareSkills" });
1601
+ await client.waitFor((message) => message.type === "skillsPreparationStarted");
1602
+ await client.waitFor(
1603
+ (message) =>
1604
+ message.type === "activity" &&
1605
+ message.text ===
1606
+ "Researching public standards, architecture patterns, and testing practice",
1607
+ );
1608
+ const state = readStored(fixture);
1609
+ const pending = state.skillsPreparation as { snapshot: { files: [string, string][] } };
1610
+ expect(pending.snapshot.files.length).toBe(1);
1611
+ } finally {
1612
+ client.close();
1613
+ first.stop(true);
1614
+ }
1615
+
1616
+ const second = startHost(fixture, { agentArguments: args(2000) });
1617
+ const resumed = await connect(`127.0.0.1:${second.port}`);
1618
+ try {
1619
+ await resumed.waitFor((message) => message.type === "skillsPreparationStarted");
1620
+ expect(await resumed.waitFor((message) => message.type === "activity")).toEqual({
1621
+ type: "activity",
1622
+ text: "Researching public standards, architecture patterns, and testing practice",
1623
+ });
1624
+ expect(
1625
+ await resumed.waitFor((message) => message.type === "skillsPreparationStopped"),
1626
+ ).toMatchObject({ completed: false });
1627
+ expect(
1628
+ await readFile(
1629
+ join(fixture.root, ".omp", "skills", "behavior-first-testing", "SKILL.md"),
1630
+ "utf8",
1631
+ ),
1632
+ ).toBe(MANAGED_SKILL);
1633
+ expect(prompts(fixture.logPath).filter((prompt) => prompt === "/prepare-skills").length).toBe(
1634
+ 2,
1635
+ );
1636
+ } finally {
1637
+ resumed.close();
1638
+ second.stop(true);
1639
+ }
1640
+ },
1641
+ 30000,
1642
+ );
1643
+
1644
+ test(
1645
+ "restores a pending skills preparation from its stored snapshot",
1646
+ async () => {
1647
+ const fixture = await createFixture({ skill: false });
1648
+ const houseStyle = [
1649
+ "---",
1650
+ "name: house-style",
1651
+ "description: Follow the house style.",
1652
+ "---",
1653
+ "",
1654
+ "# House style",
1655
+ "",
1656
+ "- Prefer short functions.",
1657
+ "",
1658
+ ].join("\n");
1659
+ const files: [string, string][] = [
1660
+ ["house-style/SKILL.md", Buffer.from(houseStyle).toString("base64")],
1661
+ ["behavior-first-testing/SKILL.md", Buffer.from(MANAGED_SKILL).toString("base64")],
1662
+ ];
1663
+ writeStored(fixture, {
1664
+ skillsPreparationPending: {
1665
+ activity: [],
1666
+ question: null,
1667
+ snapshot: { exists: true, files },
1668
+ unmanagedNames: ["house-style"],
1669
+ },
1670
+ });
1671
+ // The killed process had already replaced the managed set.
1672
+ await mkdir(join(fixture.root, ".omp", "skills"), { recursive: true });
1673
+
1674
+ const host = startHost(fixture, { agentArguments: "--prepare-skills --delay-ms=100" });
1675
+ const client = await connect(`127.0.0.1:${host.port}`);
1676
+ try {
1677
+ await client.waitFor((message) => message.type === "skillsPreparationStarted");
1678
+ const stopped = await client.waitFor(
1679
+ (message) => message.type === "skillsPreparationStopped",
1680
+ );
1681
+ expect(stopped).toMatchObject({ completed: true });
1682
+ // The unmanaged skill was restored before the resumed run checked it.
1683
+ expect(await readFile(join(fixture.root, ".omp", "skills", "house-style", "SKILL.md"), "utf8")).toBe(
1684
+ houseStyle,
1685
+ );
1686
+ expect(prompts(fixture.logPath)).toEqual(["/prepare-skills"]);
1687
+ } finally {
1688
+ client.close();
1689
+ host.stop(true);
1690
+ }
1691
+ },
1692
+ 30000,
1693
+ );
1694
+ });
1695
+
1696
+ describe("reset and restart", () => {
1697
+ test(
1698
+ "never re-runs an interrupted reset and reports it as failed",
1699
+ async () => {
1700
+ const fixture = await createFixture({ git: true, adrs: { "0001-first.md": PENDING_ONE } });
1701
+ const head = git(fixture.root, ["rev-parse", "HEAD"]).stdout;
1702
+ writeStored(fixture, {
1703
+ lifecycle: { stage: "implementation", pass: 2, work: null, paused: false },
1704
+ lastLifecycle: { stage: "implementation", pass: 2, work: null, paused: false },
1705
+ lifecycleRunning: true,
1706
+ laneSequence: 1,
1707
+ lanes: [laneEntry({})],
1708
+ knownEpics: [laneEntry().epic],
1709
+ epicStates: [
1710
+ {
1711
+ id: "77",
1712
+ epic: laneEntry().epic,
1713
+ attempts: 1,
1714
+ status: "running",
1715
+ mainAtFailure: null,
1716
+ },
1717
+ ],
1718
+ resetActive: true,
1719
+ });
1720
+
1721
+ const host = startHost(fixture, { agentArguments: "--delay-ms=100" });
1722
+ const client = await connect(`127.0.0.1:${host.port}`);
1723
+ try {
1724
+ const error = await client.waitFor((message) => message.type === "error");
1725
+ expect(String(error.message)).toContain("implementation reset was interrupted");
1726
+ const stopped = await client.waitFor(
1727
+ (message) => message.type === "implementationResetStopped",
1728
+ );
1729
+ expect(stopped).toMatchObject({ completed: false });
1730
+ expect(client.events.filter((message) => message.type === "error").length).toBe(1);
1731
+ expect(client.events.filter((message) => message.type === "runStarted")).toEqual([]);
1732
+ expect(existsSync(fixture.logPath)).toBe(false);
1733
+ expect(git(fixture.root, ["rev-parse", "HEAD"]).stdout).toBe(head);
1734
+ expect(git(fixture.root, ["status", "--porcelain"]).stdout).toBe("");
1735
+ expect(existsSync(join(fixture.adrDir, "0001-first.md"))).toBe(true);
1736
+ } finally {
1737
+ client.close();
1738
+ host.stop(true);
1739
+ }
1740
+ },
1741
+ 30000,
1742
+ );
1743
+
1744
+ test(
1745
+ "restores neither the old lanes nor the old pass after a successful reset",
1746
+ async () => {
1747
+ const fixture = await createFixture({ git: true, adrs: { "0001-first.md": PENDING_ONE } });
1748
+ const remote = join(dirname(fixture.root), `${basename(fixture.root)}-origin.git`);
1749
+ Bun.spawnSync({ cmd: ["git", "init", "-q", "--bare", remote] });
1750
+ git(fixture.root, ["remote", "add", "origin", remote]);
1751
+ git(fixture.root, ["push", "-q", "origin", "main"]);
1752
+
1753
+ writeStored(fixture, {
1754
+ lifecycle: { stage: "epic", pass: 3, work: null, paused: false },
1755
+ lastLifecycle: { stage: "finished", pass: 3, work: null, paused: false },
1756
+ laneSequence: 1,
1757
+ lanes: [laneEntry({ status: "closed", retainUntil: Date.now() + 60_000 })],
1758
+ });
1759
+
1760
+ const host = startHost(fixture);
1761
+ const client = await connect(`127.0.0.1:${host.port}`);
1762
+ try {
1763
+ client.send({ type: "resetImplementation" });
1764
+ const stopped = await client.waitFor(
1765
+ (message) => message.type === "implementationResetStopped",
1766
+ "the reset did not finish",
1767
+ 20000,
1768
+ );
1769
+ expect(stopped.completed).toBe(true);
1770
+ const state = readStored(fixture);
1771
+ expect(state.lifecycle).toEqual({ stage: "goal", pass: 1, work: null, paused: false });
1772
+ expect(state.lanes).toEqual([]);
1773
+ } finally {
1774
+ client.close();
1775
+ host.stop(true);
1776
+ }
1777
+
1778
+ const second = startHost(fixture);
1779
+ const resumed = await connect(`127.0.0.1:${second.port}`);
1780
+ try {
1781
+ expect(await resumed.waitFor((message) => message.type === "lifecycleState")).toEqual({
1782
+ type: "lifecycleState",
1783
+ stage: "goal",
1784
+ pass: 1,
1785
+ work: null,
1786
+ paused: false,
1787
+ });
1788
+ const snapshot = await resumed.waitFor((message) => message.type === "laneState");
1789
+ expect(snapshot.lanes).toEqual([]);
1790
+ } finally {
1791
+ resumed.close();
1792
+ second.stop(true);
1793
+ }
1794
+ },
1795
+ 40000,
1796
+ );
1797
+
1798
+ test(
1799
+ "honors a restored when-idle restart exactly once",
1800
+ async () => {
1801
+ const fixture = await createFixture({
1802
+ skill: false,
1803
+ adrs: { "0001-first.md": PENDING_ONE, "0002-second.md": PENDING_TWO },
1804
+ });
1805
+ const first = startHost(fixture, {
1806
+ agentArguments: "--delay-ms=200 --questions=0",
1807
+ restart: () => {},
1808
+ });
1809
+ const client = await connect(`127.0.0.1:${first.port}`);
1810
+ try {
1811
+ client.send({ type: "startRun" });
1812
+ await client.waitFor((message) => message.type === "runStarted");
1813
+ client.send({ type: "restartServer", whenIdle: true });
1814
+ await client.waitFor((message) => message.type === "upgradePending");
1815
+ expect(readStored(fixture).restartWhenIdle).toBe(true);
1816
+ } finally {
1817
+ client.close();
1818
+ first.stop(true);
1819
+ }
1820
+
1821
+ let calls = 0;
1822
+ let flagAtRestart: unknown = "unset";
1823
+ const second = startHost(fixture, {
1824
+ agentArguments: "--delay-ms=100 --questions=0",
1825
+ restart: () => {
1826
+ calls += 1;
1827
+ const raw = readFileSync(storedPath(fixture), "utf8");
1828
+ flagAtRestart = (JSON.parse(raw) as { state: { restartWhenIdle: boolean } }).state
1829
+ .restartWhenIdle;
1830
+ },
1831
+ });
1832
+ try {
1833
+ await waitUntil(() => calls === 1, "the restored when-idle restart was not honored", 20000);
1834
+ expect(calls).toBe(1);
1835
+ expect(flagAtRestart).toBe(false);
1836
+ expect(readStored(fixture).restartWhenIdle).toBe(false);
1837
+ } finally {
1838
+ second.stop(true);
1839
+ }
1840
+
1841
+ let laterCalls = 0;
1842
+ const third = startHost(fixture, {
1843
+ agentArguments: "--delay-ms=100 --questions=0",
1844
+ restart: () => {
1845
+ laterCalls += 1;
1846
+ },
1847
+ });
1848
+ const idle = await connect(`127.0.0.1:${third.port}`);
1849
+ try {
1850
+ await Bun.sleep(800);
1851
+ expect(laterCalls).toBe(0);
1852
+ expect(idle.events.filter((message) => message.type === "serverRestarting")).toEqual([]);
1853
+ } finally {
1854
+ idle.close();
1855
+ third.stop(true);
1856
+ }
1857
+ },
1858
+ 40000,
1859
+ );
1860
+
1861
+ test(
1862
+ "reports the unavailable restart for a restored pending flag and clears it",
1863
+ async () => {
1864
+ const fixture = await createFixture({ skill: false });
1865
+ writeStored(fixture, { restartWhenIdle: true });
1866
+ const host = startHost(fixture);
1867
+ const client = await connect(`127.0.0.1:${host.port}`);
1868
+ try {
1869
+ expect(await client.waitFor((message) => message.type === "error")).toEqual({
1870
+ type: "error",
1871
+ message: "Restarting the server is not available.",
1872
+ });
1873
+ await waitUntil(
1874
+ () => readStored(fixture).restartWhenIdle === false,
1875
+ "the pending restart flag was not cleared",
1876
+ );
1877
+ } finally {
1878
+ client.close();
1879
+ host.stop(true);
1880
+ }
1881
+ },
1882
+ 20000,
1883
+ );
1884
+ });
1885
+
1886
+ describe("state write failures", () => {
1887
+ test(
1888
+ "keeps serving from memory with one journal error when the state directory is read-only",
1889
+ async () => {
1890
+ const fixture = await createFixture({ git: true });
1891
+ await mkdir(join(fixture.stateDir, "state"), { recursive: true });
1892
+ await Bun.write(join(fixture.stateDir, "state", ".keep"), "");
1893
+ await chmod(join(fixture.stateDir, "state"), 0o500);
1894
+
1895
+ const epic: OpenEpic = {
1896
+ number: 91,
1897
+ title: "Read only",
1898
+ url: "https://fixture.test/issues/91",
1899
+ };
1900
+ const host = startHost(fixture, {
1901
+ agentArguments: "--delay-ms=5000 --no-close",
1902
+ tracker: async () => epic,
1903
+ });
1904
+ const client = await connect(`127.0.0.1:${host.port}`);
1905
+ try {
1906
+ client.send({ type: "startLifecycle" });
1907
+ await client.waitFor(
1908
+ (message) => message.type === "lifecycleState" && message.stage === "implementation",
1909
+ );
1910
+ client.send({ type: "stopLifecycle" });
1911
+ await client.waitFor(
1912
+ (message) => message.type === "lifecycleState" && message.stage === "goal",
1913
+ );
1914
+ client.send({ type: "getProject" });
1915
+ expect(await client.waitFor((message) => message.type === "project")).toBeDefined();
1916
+
1917
+ const journalDir = join(fixture.stateDir, "journal");
1918
+ const records = readdirSync(journalDir)
1919
+ .filter((name) => name.endsWith(".jsonl"))
1920
+ .map((name) => readFileSync(join(journalDir, name), "utf8"))
1921
+ .join("");
1922
+ const failures = records
1923
+ .split("\n")
1924
+ .filter((line) => line.includes("state: could not write"));
1925
+ expect(failures.length).toBe(1);
1926
+ expect(client.events.filter((message) => message.type === "error")).toEqual([]);
1927
+ } finally {
1928
+ client.close();
1929
+ host.stop(true);
1930
+ await chmod(join(fixture.stateDir, "state"), 0o700);
1931
+ }
1932
+ },
1933
+ 30000,
1934
+ );
1935
+ });