@mclawnet/swarm 0.1.13 → 0.1.14

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 (320) hide show
  1. package/dist/__tests__/always-on-activity-reader.test.d.ts +2 -0
  2. package/dist/__tests__/always-on-activity-reader.test.d.ts.map +1 -0
  3. package/dist/__tests__/always-on-activity-reader.test.js +193 -0
  4. package/dist/__tests__/always-on-activity-reader.test.js.map +1 -0
  5. package/dist/__tests__/always-on-config.test.d.ts +2 -0
  6. package/dist/__tests__/always-on-config.test.d.ts.map +1 -0
  7. package/dist/__tests__/always-on-config.test.js +285 -0
  8. package/dist/__tests__/always-on-config.test.js.map +1 -0
  9. package/dist/__tests__/always-on-manager.test.d.ts +2 -0
  10. package/dist/__tests__/always-on-manager.test.d.ts.map +1 -0
  11. package/dist/__tests__/always-on-manager.test.js +797 -0
  12. package/dist/__tests__/always-on-manager.test.js.map +1 -0
  13. package/dist/__tests__/always-on-parity.test.d.ts +2 -0
  14. package/dist/__tests__/always-on-parity.test.d.ts.map +1 -0
  15. package/dist/__tests__/always-on-parity.test.js +20 -0
  16. package/dist/__tests__/always-on-parity.test.js.map +1 -0
  17. package/dist/__tests__/cascade-picker.test.d.ts +2 -0
  18. package/dist/__tests__/cascade-picker.test.d.ts.map +1 -0
  19. package/dist/__tests__/cascade-picker.test.js +122 -0
  20. package/dist/__tests__/cascade-picker.test.js.map +1 -0
  21. package/dist/__tests__/coordinator-shipment.test.d.ts +2 -0
  22. package/dist/__tests__/coordinator-shipment.test.d.ts.map +1 -0
  23. package/dist/__tests__/coordinator-shipment.test.js +280 -0
  24. package/dist/__tests__/coordinator-shipment.test.js.map +1 -0
  25. package/dist/__tests__/coordinator-workspace-recover.test.d.ts +2 -0
  26. package/dist/__tests__/coordinator-workspace-recover.test.d.ts.map +1 -0
  27. package/dist/__tests__/coordinator-workspace-recover.test.js +140 -0
  28. package/dist/__tests__/coordinator-workspace-recover.test.js.map +1 -0
  29. package/dist/__tests__/coordinator-workspace.test.d.ts +2 -0
  30. package/dist/__tests__/coordinator-workspace.test.d.ts.map +1 -0
  31. package/dist/__tests__/coordinator-workspace.test.js +135 -0
  32. package/dist/__tests__/coordinator-workspace.test.js.map +1 -0
  33. package/dist/__tests__/default-runner-epipe.test.d.ts +2 -0
  34. package/dist/__tests__/default-runner-epipe.test.d.ts.map +1 -0
  35. package/dist/__tests__/default-runner-epipe.test.js +43 -0
  36. package/dist/__tests__/default-runner-epipe.test.js.map +1 -0
  37. package/dist/__tests__/discovery-scheduler.test.d.ts +2 -0
  38. package/dist/__tests__/discovery-scheduler.test.d.ts.map +1 -0
  39. package/dist/__tests__/discovery-scheduler.test.js +367 -0
  40. package/dist/__tests__/discovery-scheduler.test.js.map +1 -0
  41. package/dist/__tests__/env-forward-e2e.test.d.ts +2 -0
  42. package/dist/__tests__/env-forward-e2e.test.d.ts.map +1 -0
  43. package/dist/__tests__/env-forward-e2e.test.js +57 -0
  44. package/dist/__tests__/env-forward-e2e.test.js.map +1 -0
  45. package/dist/__tests__/gh-pr-creator.test.d.ts +2 -0
  46. package/dist/__tests__/gh-pr-creator.test.d.ts.map +1 -0
  47. package/dist/__tests__/gh-pr-creator.test.js +107 -0
  48. package/dist/__tests__/gh-pr-creator.test.js.map +1 -0
  49. package/dist/__tests__/git-worktree-provider.test.d.ts +2 -0
  50. package/dist/__tests__/git-worktree-provider.test.d.ts.map +1 -0
  51. package/dist/__tests__/git-worktree-provider.test.js +98 -0
  52. package/dist/__tests__/git-worktree-provider.test.js.map +1 -0
  53. package/dist/__tests__/gitignore-check.test.d.ts +2 -0
  54. package/dist/__tests__/gitignore-check.test.d.ts.map +1 -0
  55. package/dist/__tests__/gitignore-check.test.js +39 -0
  56. package/dist/__tests__/gitignore-check.test.js.map +1 -0
  57. package/dist/__tests__/idea-research-source.test.d.ts +2 -0
  58. package/dist/__tests__/idea-research-source.test.d.ts.map +1 -0
  59. package/dist/__tests__/idea-research-source.test.js +425 -0
  60. package/dist/__tests__/idea-research-source.test.js.map +1 -0
  61. package/dist/__tests__/idea-todo-source.test.d.ts +2 -0
  62. package/dist/__tests__/idea-todo-source.test.d.ts.map +1 -0
  63. package/dist/__tests__/idea-todo-source.test.js +258 -0
  64. package/dist/__tests__/idea-todo-source.test.js.map +1 -0
  65. package/dist/__tests__/introspection-dedupe.test.d.ts +2 -0
  66. package/dist/__tests__/introspection-dedupe.test.d.ts.map +1 -0
  67. package/dist/__tests__/introspection-dedupe.test.js +484 -0
  68. package/dist/__tests__/introspection-dedupe.test.js.map +1 -0
  69. package/dist/__tests__/introspection-source.test.d.ts +2 -0
  70. package/dist/__tests__/introspection-source.test.d.ts.map +1 -0
  71. package/dist/__tests__/introspection-source.test.js +1051 -0
  72. package/dist/__tests__/introspection-source.test.js.map +1 -0
  73. package/dist/__tests__/migration-roles.test.js +1 -22
  74. package/dist/__tests__/migration-roles.test.js.map +1 -1
  75. package/dist/__tests__/reconcile-researching.test.d.ts +2 -0
  76. package/dist/__tests__/reconcile-researching.test.d.ts.map +1 -0
  77. package/dist/__tests__/reconcile-researching.test.js +224 -0
  78. package/dist/__tests__/reconcile-researching.test.js.map +1 -0
  79. package/dist/__tests__/role-loader-preamble-all.test.js +3 -1
  80. package/dist/__tests__/role-loader-preamble-all.test.js.map +1 -1
  81. package/dist/__tests__/role-loader.test.js +95 -0
  82. package/dist/__tests__/role-loader.test.js.map +1 -1
  83. package/dist/__tests__/role-prompt-no-legacy-protocol.test.js +3 -1
  84. package/dist/__tests__/role-prompt-no-legacy-protocol.test.js.map +1 -1
  85. package/dist/__tests__/secret-scrub.test.d.ts +2 -0
  86. package/dist/__tests__/secret-scrub.test.d.ts.map +1 -0
  87. package/dist/__tests__/secret-scrub.test.js +55 -0
  88. package/dist/__tests__/secret-scrub.test.js.map +1 -0
  89. package/dist/__tests__/shipment-actions.test.d.ts +2 -0
  90. package/dist/__tests__/shipment-actions.test.d.ts.map +1 -0
  91. package/dist/__tests__/shipment-actions.test.js +378 -0
  92. package/dist/__tests__/shipment-actions.test.js.map +1 -0
  93. package/dist/__tests__/shipment-persistence.test.d.ts +2 -0
  94. package/dist/__tests__/shipment-persistence.test.d.ts.map +1 -0
  95. package/dist/__tests__/shipment-persistence.test.js +120 -0
  96. package/dist/__tests__/shipment-persistence.test.js.map +1 -0
  97. package/dist/__tests__/shipment-pipeline.test.d.ts +2 -0
  98. package/dist/__tests__/shipment-pipeline.test.d.ts.map +1 -0
  99. package/dist/__tests__/shipment-pipeline.test.js +392 -0
  100. package/dist/__tests__/shipment-pipeline.test.js.map +1 -0
  101. package/dist/__tests__/shipment-report.test.d.ts +2 -0
  102. package/dist/__tests__/shipment-report.test.d.ts.map +1 -0
  103. package/dist/__tests__/shipment-report.test.js +78 -0
  104. package/dist/__tests__/shipment-report.test.js.map +1 -0
  105. package/dist/__tests__/shipment-stdin-integration.test.d.ts +2 -0
  106. package/dist/__tests__/shipment-stdin-integration.test.d.ts.map +1 -0
  107. package/dist/__tests__/shipment-stdin-integration.test.js +49 -0
  108. package/dist/__tests__/shipment-stdin-integration.test.js.map +1 -0
  109. package/dist/__tests__/shipment-type-parity.test.d.ts +2 -0
  110. package/dist/__tests__/shipment-type-parity.test.d.ts.map +1 -0
  111. package/dist/__tests__/shipment-type-parity.test.js +10 -0
  112. package/dist/__tests__/shipment-type-parity.test.js.map +1 -0
  113. package/dist/__tests__/snapshot-copy-provider.test.d.ts +2 -0
  114. package/dist/__tests__/snapshot-copy-provider.test.d.ts.map +1 -0
  115. package/dist/__tests__/snapshot-copy-provider.test.js +88 -0
  116. package/dist/__tests__/snapshot-copy-provider.test.js.map +1 -0
  117. package/dist/__tests__/swarm-coordinator-backend.test.js +153 -0
  118. package/dist/__tests__/swarm-coordinator-backend.test.js.map +1 -1
  119. package/dist/__tests__/swarm-coordinator-complete-intercept.test.d.ts +2 -0
  120. package/dist/__tests__/swarm-coordinator-complete-intercept.test.d.ts.map +1 -0
  121. package/dist/__tests__/swarm-coordinator-complete-intercept.test.js +111 -0
  122. package/dist/__tests__/swarm-coordinator-complete-intercept.test.js.map +1 -0
  123. package/dist/__tests__/task-store-source.test.d.ts +2 -0
  124. package/dist/__tests__/task-store-source.test.d.ts.map +1 -0
  125. package/dist/__tests__/task-store-source.test.js +56 -0
  126. package/dist/__tests__/task-store-source.test.js.map +1 -0
  127. package/dist/__tests__/transport-detect.test.d.ts +2 -0
  128. package/dist/__tests__/transport-detect.test.d.ts.map +1 -0
  129. package/dist/__tests__/transport-detect.test.js +92 -0
  130. package/dist/__tests__/transport-detect.test.js.map +1 -0
  131. package/dist/__tests__/workcycle-runner-cascade.test.d.ts +2 -0
  132. package/dist/__tests__/workcycle-runner-cascade.test.d.ts.map +1 -0
  133. package/dist/__tests__/workcycle-runner-cascade.test.js +203 -0
  134. package/dist/__tests__/workcycle-runner-cascade.test.js.map +1 -0
  135. package/dist/__tests__/workcycle-runner.test.d.ts +2 -0
  136. package/dist/__tests__/workcycle-runner.test.d.ts.map +1 -0
  137. package/dist/__tests__/workcycle-runner.test.js +369 -0
  138. package/dist/__tests__/workcycle-runner.test.js.map +1 -0
  139. package/dist/__tests__/workspace-diff.test.d.ts +2 -0
  140. package/dist/__tests__/workspace-diff.test.d.ts.map +1 -0
  141. package/dist/__tests__/workspace-diff.test.js +62 -0
  142. package/dist/__tests__/workspace-diff.test.js.map +1 -0
  143. package/dist/__tests__/workspace-manager.test.d.ts +2 -0
  144. package/dist/__tests__/workspace-manager.test.d.ts.map +1 -0
  145. package/dist/__tests__/workspace-manager.test.js +120 -0
  146. package/dist/__tests__/workspace-manager.test.js.map +1 -0
  147. package/dist/__tests__/workspace-types.test.d.ts +2 -0
  148. package/dist/__tests__/workspace-types.test.d.ts.map +1 -0
  149. package/dist/__tests__/workspace-types.test.js +37 -0
  150. package/dist/__tests__/workspace-types.test.js.map +1 -0
  151. package/dist/__tests__/worktree-gc.test.d.ts +2 -0
  152. package/dist/__tests__/worktree-gc.test.d.ts.map +1 -0
  153. package/dist/__tests__/worktree-gc.test.js +183 -0
  154. package/dist/__tests__/worktree-gc.test.js.map +1 -0
  155. package/dist/always-on/activity-reader.d.ts +27 -0
  156. package/dist/always-on/activity-reader.d.ts.map +1 -0
  157. package/dist/always-on/activity-reader.js +95 -0
  158. package/dist/always-on/activity-reader.js.map +1 -0
  159. package/dist/always-on/always-on-manager.d.ts +170 -0
  160. package/dist/always-on/always-on-manager.d.ts.map +1 -0
  161. package/dist/always-on/always-on-manager.js +538 -0
  162. package/dist/always-on/always-on-manager.js.map +1 -0
  163. package/dist/always-on/config.d.ts +141 -0
  164. package/dist/always-on/config.d.ts.map +1 -0
  165. package/dist/always-on/config.js +324 -0
  166. package/dist/always-on/config.js.map +1 -0
  167. package/dist/always-on/discovery-scheduler.d.ts +60 -0
  168. package/dist/always-on/discovery-scheduler.d.ts.map +1 -0
  169. package/dist/always-on/discovery-scheduler.js +287 -0
  170. package/dist/always-on/discovery-scheduler.js.map +1 -0
  171. package/dist/always-on/ideas-client.d.ts +23 -0
  172. package/dist/always-on/ideas-client.d.ts.map +1 -0
  173. package/dist/always-on/ideas-client.js +13 -0
  174. package/dist/always-on/ideas-client.js.map +1 -0
  175. package/dist/always-on/reconcile-researching.d.ts +42 -0
  176. package/dist/always-on/reconcile-researching.d.ts.map +1 -0
  177. package/dist/always-on/reconcile-researching.js +133 -0
  178. package/dist/always-on/reconcile-researching.js.map +1 -0
  179. package/dist/always-on/task-sources/cascade-picker.d.ts +42 -0
  180. package/dist/always-on/task-sources/cascade-picker.d.ts.map +1 -0
  181. package/dist/always-on/task-sources/cascade-picker.js +65 -0
  182. package/dist/always-on/task-sources/cascade-picker.js.map +1 -0
  183. package/dist/always-on/task-sources/idea-dedupe.d.ts +62 -0
  184. package/dist/always-on/task-sources/idea-dedupe.d.ts.map +1 -0
  185. package/dist/always-on/task-sources/idea-dedupe.js +130 -0
  186. package/dist/always-on/task-sources/idea-dedupe.js.map +1 -0
  187. package/dist/always-on/task-sources/idea-research-source.d.ts +46 -0
  188. package/dist/always-on/task-sources/idea-research-source.d.ts.map +1 -0
  189. package/dist/always-on/task-sources/idea-research-source.js +308 -0
  190. package/dist/always-on/task-sources/idea-research-source.js.map +1 -0
  191. package/dist/always-on/task-sources/idea-sort.d.ts +3 -0
  192. package/dist/always-on/task-sources/idea-sort.d.ts.map +1 -0
  193. package/dist/always-on/task-sources/idea-sort.js +25 -0
  194. package/dist/always-on/task-sources/idea-sort.js.map +1 -0
  195. package/dist/always-on/task-sources/idea-todo-source.d.ts +48 -0
  196. package/dist/always-on/task-sources/idea-todo-source.d.ts.map +1 -0
  197. package/dist/always-on/task-sources/idea-todo-source.js +226 -0
  198. package/dist/always-on/task-sources/idea-todo-source.js.map +1 -0
  199. package/dist/always-on/task-sources/introspection-source.d.ts +101 -0
  200. package/dist/always-on/task-sources/introspection-source.d.ts.map +1 -0
  201. package/dist/always-on/task-sources/introspection-source.js +695 -0
  202. package/dist/always-on/task-sources/introspection-source.js.map +1 -0
  203. package/dist/always-on/task-sources/task-store-source.d.ts +15 -0
  204. package/dist/always-on/task-sources/task-store-source.d.ts.map +1 -0
  205. package/dist/always-on/task-sources/task-store-source.js +59 -0
  206. package/dist/always-on/task-sources/task-store-source.js.map +1 -0
  207. package/dist/always-on/task-sources/types.d.ts +108 -0
  208. package/dist/always-on/task-sources/types.d.ts.map +1 -0
  209. package/dist/always-on/task-sources/types.js +13 -0
  210. package/dist/always-on/task-sources/types.js.map +1 -0
  211. package/dist/always-on/types.d.ts +76 -0
  212. package/dist/always-on/types.d.ts.map +1 -0
  213. package/dist/always-on/types.js +17 -0
  214. package/dist/always-on/types.js.map +1 -0
  215. package/dist/always-on/workcycle-runner.d.ts +115 -0
  216. package/dist/always-on/workcycle-runner.d.ts.map +1 -0
  217. package/dist/always-on/workcycle-runner.js +285 -0
  218. package/dist/always-on/workcycle-runner.js.map +1 -0
  219. package/dist/always-on/worktree-gc.d.ts +41 -0
  220. package/dist/always-on/worktree-gc.d.ts.map +1 -0
  221. package/dist/always-on/worktree-gc.js +167 -0
  222. package/dist/always-on/worktree-gc.js.map +1 -0
  223. package/dist/index.d.ts +26 -2
  224. package/dist/index.d.ts.map +1 -1
  225. package/dist/index.js +24 -1
  226. package/dist/index.js.map +1 -1
  227. package/dist/persistence.d.ts +37 -1
  228. package/dist/persistence.d.ts.map +1 -1
  229. package/dist/persistence.js +48 -0
  230. package/dist/persistence.js.map +1 -1
  231. package/dist/retrospective.d.ts.map +1 -1
  232. package/dist/retrospective.js +6 -0
  233. package/dist/retrospective.js.map +1 -1
  234. package/dist/roles/role-loader.d.ts +1 -1
  235. package/dist/roles/role-loader.d.ts.map +1 -1
  236. package/dist/roles/role-loader.js +18 -0
  237. package/dist/roles/role-loader.js.map +1 -1
  238. package/dist/roles/types.d.ts +12 -0
  239. package/dist/roles/types.d.ts.map +1 -1
  240. package/dist/shipment/gh-pr-creator.d.ts +28 -0
  241. package/dist/shipment/gh-pr-creator.d.ts.map +1 -0
  242. package/dist/shipment/gh-pr-creator.js +80 -0
  243. package/dist/shipment/gh-pr-creator.js.map +1 -0
  244. package/dist/shipment/report.d.ts +27 -0
  245. package/dist/shipment/report.d.ts.map +1 -0
  246. package/dist/shipment/report.js +41 -0
  247. package/dist/shipment/report.js.map +1 -0
  248. package/dist/shipment/secret-scrub.d.ts +12 -0
  249. package/dist/shipment/secret-scrub.d.ts.map +1 -0
  250. package/dist/shipment/secret-scrub.js +30 -0
  251. package/dist/shipment/secret-scrub.js.map +1 -0
  252. package/dist/shipment/shipment-actions.d.ts +85 -0
  253. package/dist/shipment/shipment-actions.d.ts.map +1 -0
  254. package/dist/shipment/shipment-actions.js +190 -0
  255. package/dist/shipment/shipment-actions.js.map +1 -0
  256. package/dist/shipment/shipment-pipeline.d.ts +48 -0
  257. package/dist/shipment/shipment-pipeline.d.ts.map +1 -0
  258. package/dist/shipment/shipment-pipeline.js +256 -0
  259. package/dist/shipment/shipment-pipeline.js.map +1 -0
  260. package/dist/shipment/transport-detect.d.ts +16 -0
  261. package/dist/shipment/transport-detect.d.ts.map +1 -0
  262. package/dist/shipment/transport-detect.js +54 -0
  263. package/dist/shipment/transport-detect.js.map +1 -0
  264. package/dist/shipment/workspace-diff.d.ts +39 -0
  265. package/dist/shipment/workspace-diff.d.ts.map +1 -0
  266. package/dist/shipment/workspace-diff.js +64 -0
  267. package/dist/shipment/workspace-diff.js.map +1 -0
  268. package/dist/swarm-coordinator.d.ts +20 -1
  269. package/dist/swarm-coordinator.d.ts.map +1 -1
  270. package/dist/swarm-coordinator.js +193 -10
  271. package/dist/swarm-coordinator.js.map +1 -1
  272. package/dist/types.d.ts +62 -0
  273. package/dist/types.d.ts.map +1 -1
  274. package/dist/workspace/git-worktree-provider.d.ts +11 -0
  275. package/dist/workspace/git-worktree-provider.d.ts.map +1 -0
  276. package/dist/workspace/git-worktree-provider.js +123 -0
  277. package/dist/workspace/git-worktree-provider.js.map +1 -0
  278. package/dist/workspace/gitignore-check.d.ts +10 -0
  279. package/dist/workspace/gitignore-check.d.ts.map +1 -0
  280. package/dist/workspace/gitignore-check.js +25 -0
  281. package/dist/workspace/gitignore-check.js.map +1 -0
  282. package/dist/workspace/index.d.ts +5 -0
  283. package/dist/workspace/index.d.ts.map +1 -0
  284. package/dist/workspace/index.js +5 -0
  285. package/dist/workspace/index.js.map +1 -0
  286. package/dist/workspace/snapshot-copy-provider.d.ts +11 -0
  287. package/dist/workspace/snapshot-copy-provider.d.ts.map +1 -0
  288. package/dist/workspace/snapshot-copy-provider.js +66 -0
  289. package/dist/workspace/snapshot-copy-provider.js.map +1 -0
  290. package/dist/workspace/types.d.ts +36 -0
  291. package/dist/workspace/types.d.ts.map +1 -0
  292. package/dist/workspace/types.js +2 -0
  293. package/dist/workspace/types.js.map +1 -0
  294. package/dist/workspace/workspace-manager.d.ts +30 -0
  295. package/dist/workspace/workspace-manager.d.ts.map +1 -0
  296. package/dist/workspace/workspace-manager.js +104 -0
  297. package/dist/workspace/workspace-manager.js.map +1 -0
  298. package/package.json +4 -4
  299. package/roles/queen.md +1 -0
  300. package/templates/introspection.md +64 -0
  301. package/templates/research-only.md +58 -0
  302. package/roles/preset-analyst-simons.md +0 -39
  303. package/roles/preset-architect-knuth.md +0 -39
  304. package/roles/preset-designer-norman.md +0 -39
  305. package/roles/preset-designer.md +0 -39
  306. package/roles/preset-dev-carmack.md +0 -39
  307. package/roles/preset-dev-gosling.md +0 -39
  308. package/roles/preset-developer.md +0 -52
  309. package/roles/preset-manager-grove.md +0 -39
  310. package/roles/preset-manager-musk.md +0 -39
  311. package/roles/preset-pm.md +0 -78
  312. package/roles/preset-researcher-feynman.md +0 -39
  313. package/roles/preset-reviewer.md +0 -46
  314. package/roles/preset-strategist-buffett.md +0 -39
  315. package/roles/preset-strategist-munger.md +0 -39
  316. package/roles/preset-strategist-sunzi.md +0 -39
  317. package/roles/preset-tester-beck.md +0 -40
  318. package/roles/preset-tester.md +0 -47
  319. package/roles/preset-writer-orwell.md +0 -39
  320. package/roles/preset-writer.md +0 -39
@@ -0,0 +1,797 @@
1
+ // AlwaysOnManager tests (M7.3).
2
+ //
3
+ // Covers the per-project scheduler lifecycle, recordActivity gate, ticksToday
4
+ // persistence across "restart" (drop + new instance), pause gate, and
5
+ // always-on.log rotation. The runnerFactory option lets us inject a fake
6
+ // WorkCycleRunner so we never need a real SwarmCoordinator here.
7
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
8
+ import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync, statSync, existsSync, } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { projectRoot } from "@mclawnet/task";
12
+ import { AlwaysOnManager } from "../always-on/always-on-manager.js";
13
+ import { loadAlwaysOnConfig, saveAlwaysOnConfig, } from "../always-on/config.js";
14
+ let home;
15
+ let workDir;
16
+ function localYMD(d) {
17
+ const y = d.getFullYear();
18
+ const m = String(d.getMonth() + 1).padStart(2, "0");
19
+ const day = String(d.getDate()).padStart(2, "0");
20
+ return `${y}-${m}-${day}`;
21
+ }
22
+ beforeEach(() => {
23
+ home = mkdtempSync(join(tmpdir(), "always-on-mgr-home-"));
24
+ workDir = mkdtempSync(join(tmpdir(), "always-on-mgr-wd-"));
25
+ mkdirSync(projectRoot(workDir, home), { recursive: true });
26
+ });
27
+ afterEach(() => {
28
+ rmSync(home, { recursive: true, force: true });
29
+ rmSync(workDir, { recursive: true, force: true });
30
+ });
31
+ /**
32
+ * Build a fake WorkCycleRunner that records calls and resolves quickly.
33
+ * We never need the real SwarmCoordinator for these tests.
34
+ */
35
+ function makeFakeRunner() {
36
+ const counter = { runCalls: 0 };
37
+ const runner = {
38
+ run: async (input) => {
39
+ counter.runCalls += 1;
40
+ return {
41
+ cycleId: `cycle-${counter.runCalls}`,
42
+ swarmId: `m7-cycle-${counter.runCalls}`,
43
+ taskRef: { id: "t1", title: "test" },
44
+ status: "completed",
45
+ startedAt: new Date().toISOString(),
46
+ endedAt: new Date().toISOString(),
47
+ };
48
+ },
49
+ };
50
+ return { runner, get runCalls() { return counter.runCalls; } };
51
+ }
52
+ /** Build a manager with default options + injected fake runner. */
53
+ function makeManager(opts) {
54
+ const fake = makeFakeRunner();
55
+ const mgr = new AlwaysOnManager({
56
+ home,
57
+ swarmCoordinator: {},
58
+ pickNextTask: opts.pickNextTask ?? (async () => null),
59
+ now: opts.now,
60
+ userActiveWindowMs: opts.userActiveWindowMs,
61
+ logRotateBytes: opts.logRotateBytes,
62
+ runnerFactory: () => fake.runner,
63
+ });
64
+ return { mgr, runCalls: () => fake.runCalls };
65
+ }
66
+ describe("AlwaysOnManager — lifecycle", () => {
67
+ it("start() with mode=off creates an entry but leaves the scheduler stopped", async () => {
68
+ saveAlwaysOnConfig(workDir, home, { mode: "off", dailyBudget: 3 });
69
+ const { mgr } = makeManager({});
70
+ await mgr.start(workDir);
71
+ const snap = mgr.snapshot(workDir);
72
+ expect(snap).not.toBeNull();
73
+ expect(snap.mode).toBe("off");
74
+ expect(snap.nextTickAt).toBeNull(); // stopped → no next tick
75
+ await mgr.disposeAll();
76
+ });
77
+ it("start() with mode=active arms the scheduler timer", async () => {
78
+ saveAlwaysOnConfig(workDir, home, { mode: "active", dailyBudget: 3 });
79
+ const { mgr } = makeManager({});
80
+ await mgr.start(workDir);
81
+ const snap = mgr.snapshot(workDir);
82
+ expect(snap.mode).toBe("active");
83
+ expect(snap.nextTickAt).not.toBeNull(); // scheduler armed
84
+ await mgr.disposeAll();
85
+ });
86
+ it("stop() drops the entry; snapshot returns null", async () => {
87
+ saveAlwaysOnConfig(workDir, home, { mode: "active", dailyBudget: 3 });
88
+ const { mgr } = makeManager({});
89
+ await mgr.start(workDir);
90
+ await mgr.stop(workDir);
91
+ expect(mgr.snapshot(workDir)).toBeNull();
92
+ });
93
+ it("start() is idempotent — second call rebuilds without leaking timers", async () => {
94
+ saveAlwaysOnConfig(workDir, home, { mode: "active", dailyBudget: 3 });
95
+ const { mgr } = makeManager({});
96
+ await mgr.start(workDir);
97
+ await mgr.start(workDir);
98
+ const snap = mgr.snapshot(workDir);
99
+ expect(snap.mode).toBe("active");
100
+ await mgr.disposeAll();
101
+ });
102
+ });
103
+ describe("AlwaysOnManager — applyConfig + refreshFromDisk", () => {
104
+ it("applyConfig persists merged config and updates live scheduler mode", async () => {
105
+ saveAlwaysOnConfig(workDir, home, { mode: "off", dailyBudget: 3 });
106
+ const { mgr } = makeManager({});
107
+ await mgr.start(workDir);
108
+ const r = await mgr.applyConfig(workDir, { mode: "active" });
109
+ expect(r.config.mode).toBe("active");
110
+ expect(loadAlwaysOnConfig(workDir, home).mode).toBe("active");
111
+ expect(mgr.snapshot(workDir).mode).toBe("active");
112
+ await mgr.disposeAll();
113
+ });
114
+ it("applyConfig auto-boots when the project has never been started", async () => {
115
+ saveAlwaysOnConfig(workDir, home, { mode: "off", dailyBudget: 3 });
116
+ const { mgr } = makeManager({});
117
+ // Note: no start() before applyConfig.
118
+ const r = await mgr.applyConfig(workDir, { mode: "active" });
119
+ expect(r.config.mode).toBe("active");
120
+ expect(mgr.snapshot(workDir)).not.toBeNull();
121
+ await mgr.disposeAll();
122
+ });
123
+ it("applyConfig handles pausedUntil:null to clear pause", async () => {
124
+ saveAlwaysOnConfig(workDir, home, {
125
+ mode: "active",
126
+ dailyBudget: 3,
127
+ pausedUntil: "2030-01-01T00:00:00Z",
128
+ });
129
+ const { mgr } = makeManager({});
130
+ await mgr.start(workDir);
131
+ const r = await mgr.applyConfig(workDir, { pausedUntil: null });
132
+ expect(r.config.pausedUntil).toBeUndefined();
133
+ await mgr.disposeAll();
134
+ });
135
+ it("refreshFromDisk re-syncs scheduler after external config write", async () => {
136
+ saveAlwaysOnConfig(workDir, home, { mode: "off", dailyBudget: 3 });
137
+ const { mgr } = makeManager({});
138
+ await mgr.start(workDir);
139
+ // Simulate the M7.2 handler having merged + saved already.
140
+ saveAlwaysOnConfig(workDir, home, { mode: "aggressive", dailyBudget: 5 });
141
+ await mgr.refreshFromDisk(workDir);
142
+ expect(mgr.snapshot(workDir).mode).toBe("aggressive");
143
+ await mgr.disposeAll();
144
+ });
145
+ });
146
+ describe("AlwaysOnManager — userActive gate", () => {
147
+ it("I3: recordActivity within window → next tick is gated by userActive", async () => {
148
+ saveAlwaysOnConfig(workDir, home, { mode: "active", dailyBudget: 3 });
149
+ const baseTime = Date.UTC(2026, 5, 5, 12, 0, 0);
150
+ let nowMs = baseTime;
151
+ const { mgr } = makeManager({
152
+ now: () => new Date(nowMs),
153
+ userActiveWindowMs: 30 * 60 * 1000,
154
+ });
155
+ await mgr.start(workDir);
156
+ // Activity 5 min ago — well within the 30 min window.
157
+ nowMs += 5 * 60 * 1000;
158
+ mgr.recordActivity();
159
+ // Drive a tick directly via the scheduler — the userActive gate should
160
+ // short-circuit before onFire is reached.
161
+ const entry = mgr.entries.get(workDir);
162
+ await entry.scheduler.tickNow();
163
+ const last = mgr.snapshot(workDir).recentTicks.at(-1);
164
+ expect(last.decision).toBe("skipped");
165
+ const gateNames = last.gateBreakdown.map((g) => g.gate);
166
+ expect(gateNames).toContain("userActive");
167
+ const userActiveEntry = last.gateBreakdown.find((g) => g.gate === "userActive");
168
+ expect(userActiveEntry.decision.kind).toBe("skip");
169
+ await mgr.disposeAll();
170
+ });
171
+ it("I3: recordActivity outside the window → next tick fires", async () => {
172
+ saveAlwaysOnConfig(workDir, home, { mode: "active", dailyBudget: 3 });
173
+ const baseTime = Date.UTC(2026, 5, 5, 12, 0, 0);
174
+ let nowMs = baseTime;
175
+ let runCalls = 0;
176
+ const fakeRunner = {
177
+ run: async () => {
178
+ runCalls += 1;
179
+ return {
180
+ cycleId: `c${runCalls}`, swarmId: `s${runCalls}`,
181
+ taskRef: { id: "t1", title: "t" },
182
+ status: "completed",
183
+ startedAt: new Date().toISOString(),
184
+ endedAt: new Date().toISOString(),
185
+ };
186
+ },
187
+ };
188
+ const mgr = new AlwaysOnManager({
189
+ home,
190
+ swarmCoordinator: {},
191
+ pickNextTask: async () => null,
192
+ now: () => new Date(nowMs),
193
+ userActiveWindowMs: 0, // disable the gate entirely
194
+ runnerFactory: () => fakeRunner,
195
+ });
196
+ await mgr.start(workDir);
197
+ mgr.recordActivity();
198
+ nowMs += 60_000;
199
+ const entry = mgr.entries.get(workDir);
200
+ await entry.scheduler.tickNow();
201
+ expect(runCalls).toBe(1);
202
+ expect(mgr.snapshot(workDir).recentTicks.at(-1).decision).toBe("fired");
203
+ await mgr.disposeAll();
204
+ });
205
+ it("S1: recordActivity is per-project; activity on A does not gate B", async () => {
206
+ const workDirB = mkdtempSync(join(tmpdir(), "always-on-mgr-wd-b-"));
207
+ mkdirSync(projectRoot(workDirB, home), { recursive: true });
208
+ try {
209
+ saveAlwaysOnConfig(workDir, home, { mode: "active", dailyBudget: 3 });
210
+ saveAlwaysOnConfig(workDirB, home, { mode: "active", dailyBudget: 3 });
211
+ const baseTime = Date.UTC(2026, 5, 5, 12, 0, 0);
212
+ let nowMs = baseTime;
213
+ const { mgr } = makeManager({
214
+ now: () => new Date(nowMs),
215
+ userActiveWindowMs: 30 * 60 * 1000,
216
+ });
217
+ await mgr.start(workDir);
218
+ await mgr.start(workDirB);
219
+ // Activity only on project A.
220
+ nowMs += 60_000;
221
+ mgr.recordActivity(workDir);
222
+ const aEntry = mgr.entries.get(workDir);
223
+ const bEntry = mgr.entries.get(workDirB);
224
+ await aEntry.scheduler.tickNow();
225
+ await bEntry.scheduler.tickNow();
226
+ const aLast = mgr.snapshot(workDir).recentTicks.at(-1);
227
+ const bLast = mgr.snapshot(workDirB).recentTicks.at(-1);
228
+ expect(aLast.decision).toBe("skipped");
229
+ // B should NOT see userActive skip — no activity recorded for B.
230
+ const bUserActive = bLast.gateBreakdown.find((g) => g.gate === "userActive");
231
+ expect(bUserActive?.decision.kind).not.toBe("skip");
232
+ await mgr.disposeAll();
233
+ }
234
+ finally {
235
+ rmSync(workDirB, { recursive: true, force: true });
236
+ }
237
+ });
238
+ it("I3: pausedUntil in the future → tick is skipped at the pausedUntil gate", async () => {
239
+ const baseTime = Date.UTC(2026, 5, 5, 12, 0, 0);
240
+ let nowMs = baseTime;
241
+ const future = new Date(nowMs + 60 * 60 * 1000).toISOString();
242
+ saveAlwaysOnConfig(workDir, home, {
243
+ mode: "active",
244
+ dailyBudget: 3,
245
+ pausedUntil: future,
246
+ });
247
+ const { mgr } = makeManager({ now: () => new Date(nowMs) });
248
+ await mgr.start(workDir);
249
+ const entry = mgr.entries.get(workDir);
250
+ await entry.scheduler.tickNow();
251
+ const last = mgr.snapshot(workDir).recentTicks.at(-1);
252
+ expect(last.decision).toBe("skipped");
253
+ const pausedEntry = last.gateBreakdown.find((g) => g.gate === "pausedUntil");
254
+ expect(pausedEntry.decision.kind).toBe("skip");
255
+ await mgr.disposeAll();
256
+ });
257
+ it("I3: pausedUntil in the past → falls through, tick can fire", async () => {
258
+ const baseTime = Date.UTC(2026, 5, 5, 12, 0, 0);
259
+ let nowMs = baseTime;
260
+ const past = new Date(nowMs - 60 * 60 * 1000).toISOString();
261
+ saveAlwaysOnConfig(workDir, home, {
262
+ mode: "active",
263
+ dailyBudget: 3,
264
+ pausedUntil: past,
265
+ });
266
+ const { mgr } = makeManager({ now: () => new Date(nowMs) });
267
+ await mgr.start(workDir);
268
+ const entry = mgr.entries.get(workDir);
269
+ await entry.scheduler.tickNow();
270
+ const last = mgr.snapshot(workDir).recentTicks.at(-1);
271
+ // pausedUntil is past → fires (runner returns completed via fake).
272
+ expect(last.decision).toBe("fired");
273
+ await mgr.disposeAll();
274
+ });
275
+ });
276
+ describe("AlwaysOnManager — ticksToday persistence across restart", () => {
277
+ it("restoreState on start when ticksTodayDate matches today", async () => {
278
+ const today = localYMD(new Date());
279
+ saveAlwaysOnConfig(workDir, home, {
280
+ mode: "active",
281
+ dailyBudget: 3,
282
+ ticksToday: 2,
283
+ ticksTodayDate: today,
284
+ lastFireAt: new Date().toISOString(),
285
+ });
286
+ const { mgr } = makeManager({});
287
+ await mgr.start(workDir);
288
+ const snap = mgr.snapshot(workDir);
289
+ expect(snap.ticksToday).toBe(2);
290
+ expect(snap.lastFireAt).not.toBeNull();
291
+ await mgr.disposeAll();
292
+ });
293
+ it("resets ticksToday when persisted date is older than today", async () => {
294
+ saveAlwaysOnConfig(workDir, home, {
295
+ mode: "active",
296
+ dailyBudget: 3,
297
+ ticksToday: 3,
298
+ ticksTodayDate: "2020-01-01",
299
+ lastFireAt: "2020-01-01T12:00:00.000Z",
300
+ });
301
+ const { mgr } = makeManager({});
302
+ await mgr.start(workDir);
303
+ const snap = mgr.snapshot(workDir);
304
+ expect(snap.ticksToday).toBe(0); // reset
305
+ // lastFireAt is preserved for display (yesterday-at-noon UI hint).
306
+ expect(snap.lastFireAt).not.toBeNull();
307
+ await mgr.disposeAll();
308
+ });
309
+ it("survives a 'restart' (dispose + new manager + start)", async () => {
310
+ const today = localYMD(new Date());
311
+ saveAlwaysOnConfig(workDir, home, {
312
+ mode: "active",
313
+ dailyBudget: 3,
314
+ ticksToday: 1,
315
+ ticksTodayDate: today,
316
+ lastFireAt: new Date().toISOString(),
317
+ });
318
+ const { mgr: mgr1 } = makeManager({});
319
+ await mgr1.start(workDir);
320
+ expect(mgr1.snapshot(workDir).ticksToday).toBe(1);
321
+ await mgr1.disposeAll();
322
+ // Pretend we crashed and rebooted with a brand-new manager instance.
323
+ const { mgr: mgr2 } = makeManager({});
324
+ await mgr2.start(workDir);
325
+ expect(mgr2.snapshot(workDir).ticksToday).toBe(1);
326
+ await mgr2.disposeAll();
327
+ });
328
+ });
329
+ describe("AlwaysOnManager — always-on.log", () => {
330
+ function logPath() {
331
+ return join(projectRoot(workDir, home), "always-on.log");
332
+ }
333
+ it("writes one jsonl line per tick (skipped recorded too)", async () => {
334
+ // Mode=active + tiny tickInterval — but DiscoveryScheduler doesn't expose
335
+ // tickInterval through AlwaysOnManager. Use the pausedUntil gate to
336
+ // force a skip on each tick via tickNow.
337
+ const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
338
+ saveAlwaysOnConfig(workDir, home, {
339
+ mode: "active",
340
+ dailyBudget: 3,
341
+ pausedUntil: future,
342
+ });
343
+ const { mgr } = makeManager({});
344
+ await mgr.start(workDir);
345
+ // We rely on tickNow via the scheduler — manager doesn't expose tickNow,
346
+ // so reach in through a typed any cast. Tests are allowed this.
347
+ const entry = mgr.entries.get(workDir);
348
+ await entry.scheduler.tickNow();
349
+ await entry.scheduler.tickNow();
350
+ expect(existsSync(logPath())).toBe(true);
351
+ const lines = readFileSync(logPath(), "utf-8").trim().split("\n");
352
+ expect(lines).toHaveLength(2);
353
+ const parsed = JSON.parse(lines[0]);
354
+ expect(parsed.decision).toBe("skipped");
355
+ expect(parsed.gateBreakdown.at(-1).gate).toBe("pausedUntil");
356
+ await mgr.disposeAll();
357
+ });
358
+ it("rotates the log when it exceeds the byte cap", async () => {
359
+ const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
360
+ saveAlwaysOnConfig(workDir, home, {
361
+ mode: "active",
362
+ dailyBudget: 3,
363
+ pausedUntil: future,
364
+ });
365
+ // Tiny cap so one tick is enough to trigger rotation.
366
+ const { mgr } = makeManager({ logRotateBytes: 50 });
367
+ await mgr.start(workDir);
368
+ const entry = mgr.entries.get(workDir);
369
+ // First tick → writes line (file < cap → no rotation pre-write).
370
+ await entry.scheduler.tickNow();
371
+ // Second tick → file > cap → rotation kicks in BEFORE write.
372
+ await entry.scheduler.tickNow();
373
+ // Third tick → also rotates again (line2 might already be > cap).
374
+ await entry.scheduler.tickNow();
375
+ expect(existsSync(logPath() + ".1")).toBe(true);
376
+ const sizeAfter = statSync(logPath()).size;
377
+ // After rotation, the live file shouldn't be massive (cap ≈ 50 bytes).
378
+ expect(sizeAfter).toBeLessThan(1024);
379
+ await mgr.disposeAll();
380
+ });
381
+ it("B5: fired tick log line includes cycle meta (cycleId/swarmId/source/status)", async () => {
382
+ // Boot in active mode without pausedUntil so the gate cascade fires onFire.
383
+ saveAlwaysOnConfig(workDir, home, { mode: "active", dailyBudget: 5 });
384
+ const fakeRunner = {
385
+ run: async () => ({
386
+ cycleId: "cycle-b5",
387
+ swarmId: "swarm-b5",
388
+ taskRef: {
389
+ id: "t1",
390
+ title: "demo",
391
+ sourceKind: "idea-todo",
392
+ },
393
+ status: "completed",
394
+ startedAt: new Date().toISOString(),
395
+ endedAt: new Date().toISOString(),
396
+ }),
397
+ };
398
+ const mgr = new AlwaysOnManager({
399
+ home,
400
+ swarmCoordinator: {},
401
+ pickNextTask: async () => null, // unused — runnerFactory supplies the fake
402
+ runnerFactory: () => fakeRunner,
403
+ });
404
+ await mgr.start(workDir);
405
+ const entry = mgr.entries.get(workDir);
406
+ await entry.scheduler.tickNow();
407
+ const lines = readFileSync(logPath(), "utf-8").trim().split("\n");
408
+ expect(lines).toHaveLength(1);
409
+ const parsed = JSON.parse(lines[0]);
410
+ expect(parsed.decision).toBe("fired");
411
+ expect(parsed.cycle).toEqual({
412
+ cycleId: "cycle-b5",
413
+ cycleStatus: "completed",
414
+ swarmId: "swarm-b5",
415
+ source: "idea-todo",
416
+ });
417
+ // PR-B Sub-PR-B4: GC ran (no .worktrees/ in this tmp workDir → empty
418
+ // result {0,0,0}, but field IS present on fired ticks). PR-B follow-up
419
+ // dropped totalBytesBefore/After (see worktree-gc.ts doc comment).
420
+ expect(parsed.gc).toEqual({
421
+ scanned: 0,
422
+ removed: 0,
423
+ retained: 0,
424
+ });
425
+ await mgr.disposeAll();
426
+ });
427
+ it("B5: skipped tick log line has no cycle meta (onFire never ran)", async () => {
428
+ // pausedUntil future → gate cascade skips, onFire is not invoked.
429
+ const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
430
+ saveAlwaysOnConfig(workDir, home, {
431
+ mode: "active",
432
+ dailyBudget: 3,
433
+ pausedUntil: future,
434
+ });
435
+ const { mgr } = makeManager({});
436
+ await mgr.start(workDir);
437
+ const entry = mgr.entries.get(workDir);
438
+ await entry.scheduler.tickNow();
439
+ const parsed = JSON.parse(readFileSync(logPath(), "utf-8").trim());
440
+ expect(parsed.decision).toBe("skipped");
441
+ expect(parsed.cycle).toBeUndefined();
442
+ // PR-B Sub-PR-B4: skipped ticks don't run GC either — no `gc` field.
443
+ expect(parsed.gc).toBeUndefined();
444
+ await mgr.disposeAll();
445
+ });
446
+ });
447
+ describe("AlwaysOnManager — snapshot wire shape", () => {
448
+ it("serialises Date fields to ISO strings", async () => {
449
+ const today = localYMD(new Date());
450
+ saveAlwaysOnConfig(workDir, home, {
451
+ mode: "active",
452
+ dailyBudget: 3,
453
+ ticksToday: 1,
454
+ ticksTodayDate: today,
455
+ lastFireAt: "2026-06-05T10:00:00.000Z",
456
+ });
457
+ const { mgr } = makeManager({});
458
+ await mgr.start(workDir);
459
+ const snap = mgr.snapshot(workDir);
460
+ expect(typeof snap.lastFireAt).toBe("string");
461
+ expect(snap.lastFireAt).toBe("2026-06-05T10:00:00.000Z");
462
+ expect(typeof snap.nextTickAt).toBe("string"); // armed
463
+ expect(Array.isArray(snap.recentTicks)).toBe(true);
464
+ await mgr.disposeAll();
465
+ });
466
+ });
467
+ describe("AlwaysOnManager — disposeAll", () => {
468
+ it("stops every entry", async () => {
469
+ saveAlwaysOnConfig(workDir, home, { mode: "active", dailyBudget: 3 });
470
+ const { mgr } = makeManager({});
471
+ await mgr.start(workDir);
472
+ await mgr.disposeAll();
473
+ expect(mgr.snapshot(workDir)).toBeNull();
474
+ });
475
+ });
476
+ describe("AlwaysOnManager — corrupt config (I4)", () => {
477
+ it("start() boots with defaults when always-on.json is malformed", async () => {
478
+ // Write garbage JSON — pre-hotfix the boot scanner couldn't tell this
479
+ // apart from "never touched" and silently dropped the project from the
480
+ // always-on rotation.
481
+ writeFileSync(join(projectRoot(workDir, home), "always-on.json"), "{not valid json");
482
+ // loadAlwaysOnConfig should still return defaults (no throw).
483
+ const cfg = loadAlwaysOnConfig(workDir, home);
484
+ expect(cfg.mode).toBe("off");
485
+ const { mgr } = makeManager({});
486
+ await mgr.start(workDir);
487
+ // Manager boots (snapshot is non-null) — corrupt config no longer eats
488
+ // the project silently.
489
+ expect(mgr.snapshot(workDir)).not.toBeNull();
490
+ await mgr.disposeAll();
491
+ });
492
+ });
493
+ describe("loadAlwaysOnConfig — M7.3 runtime fields", () => {
494
+ it("restores ticksToday + ticksTodayDate + lastFireAt when valid", () => {
495
+ writeFileSync(join(projectRoot(workDir, home), "always-on.json"), JSON.stringify({
496
+ mode: "active",
497
+ dailyBudget: 3,
498
+ ticksToday: 2,
499
+ ticksTodayDate: "2026-06-05",
500
+ lastFireAt: "2026-06-05T12:00:00.000Z",
501
+ }));
502
+ const cfg = loadAlwaysOnConfig(workDir, home);
503
+ expect(cfg.ticksToday).toBe(2);
504
+ expect(cfg.ticksTodayDate).toBe("2026-06-05");
505
+ expect(cfg.lastFireAt).toBe("2026-06-05T12:00:00.000Z");
506
+ });
507
+ it("drops bad runtime fields silently (negative ticksToday, malformed date)", () => {
508
+ writeFileSync(join(projectRoot(workDir, home), "always-on.json"), JSON.stringify({
509
+ mode: "active",
510
+ dailyBudget: 3,
511
+ ticksToday: -1,
512
+ ticksTodayDate: "yesterday",
513
+ lastFireAt: 12345,
514
+ }));
515
+ const cfg = loadAlwaysOnConfig(workDir, home);
516
+ expect(cfg.ticksToday).toBeUndefined();
517
+ expect(cfg.ticksTodayDate).toBeUndefined();
518
+ expect(cfg.lastFireAt).toBeUndefined();
519
+ });
520
+ });
521
+ describe("saveAlwaysOnConfig — M7.3 runtime fields", () => {
522
+ it("round-trips ticksToday/ticksTodayDate/lastFireAt", () => {
523
+ saveAlwaysOnConfig(workDir, home, {
524
+ mode: "active",
525
+ dailyBudget: 5,
526
+ ticksToday: 3,
527
+ ticksTodayDate: "2026-06-05",
528
+ lastFireAt: "2026-06-05T10:00:00.000Z",
529
+ });
530
+ const reloaded = loadAlwaysOnConfig(workDir, home);
531
+ expect(reloaded.ticksToday).toBe(3);
532
+ expect(reloaded.ticksTodayDate).toBe("2026-06-05");
533
+ expect(reloaded.lastFireAt).toBe("2026-06-05T10:00:00.000Z");
534
+ });
535
+ it("omits runtime fields from disk when not supplied", () => {
536
+ saveAlwaysOnConfig(workDir, home, { mode: "off", dailyBudget: 3 });
537
+ const raw = JSON.parse(readFileSync(join(projectRoot(workDir, home), "always-on.json"), "utf-8"));
538
+ expect("ticksToday" in raw).toBe(false);
539
+ expect("ticksTodayDate" in raw).toBe(false);
540
+ expect("lastFireAt" in raw).toBe(false);
541
+ });
542
+ });
543
+ // ── M8 hotfix C1 — taskSources toggle gates the cascade ────────────────
544
+ describe("AlwaysOnManager — C1 taskSources filter", () => {
545
+ it("disables ideaResearch source per-project on the very next pick after PATCH", async () => {
546
+ // Build a cascade where idea-research is enabled by default but the
547
+ // user has flipped it off in always-on.json. The manager builds a
548
+ // CascadePicker with a filter that re-reads each project's
549
+ // always-on.json on every pick, so we should observe the disabled
550
+ // source NEVER getting called.
551
+ const researchPick = vi.fn(async () => null);
552
+ const taskStorePick = vi.fn(async () => null);
553
+ const sources = [
554
+ { name: "task-store", kind: "task-store", pick: taskStorePick },
555
+ { name: "idea-research", kind: "idea-research", pick: researchPick },
556
+ ];
557
+ saveAlwaysOnConfig(workDir, home, {
558
+ mode: "active",
559
+ dailyBudget: 3,
560
+ taskSources: {
561
+ taskStore: true,
562
+ ideaTodo: true,
563
+ ideaResearch: false,
564
+ introspection: false,
565
+ },
566
+ });
567
+ const mgr = new AlwaysOnManager({
568
+ home,
569
+ swarmCoordinator: {},
570
+ taskSources: sources,
571
+ runnerFactory: () => ({
572
+ run: async () => ({
573
+ cycleId: "c",
574
+ swarmId: "s",
575
+ taskRef: null,
576
+ status: "skipped",
577
+ startedAt: "",
578
+ endedAt: "",
579
+ }),
580
+ }),
581
+ });
582
+ await mgr.start(workDir);
583
+ // Drive the picker directly. The manager exposes pickNextTask via the
584
+ // runner constructor closure; we exercise it through the cascade by
585
+ // calling the picker bound to the entry.
586
+ const internal = mgr;
587
+ await internal.pickNextTask(workDir);
588
+ expect(taskStorePick).toHaveBeenCalledTimes(1);
589
+ expect(researchPick).not.toHaveBeenCalled();
590
+ // Now the user toggles ideaResearch on — applyConfig persists it.
591
+ await mgr.applyConfig(workDir, {
592
+ taskSources: { ideaResearch: true },
593
+ });
594
+ // Next pick MUST consult ideaResearch (no manager rebuild, no restart).
595
+ await internal.pickNextTask(workDir);
596
+ expect(researchPick).toHaveBeenCalledTimes(1);
597
+ await mgr.disposeAll();
598
+ });
599
+ it("refreshFromDisk does not need to rebuild the cascade — filter reads disk fresh per pick", async () => {
600
+ // Write a config with introspection ON but the manager doesn't see
601
+ // the file yet. refreshFromDisk is a no-op for filter behaviour
602
+ // because the filter never caches; we just verify the next pick
603
+ // observes the disk change.
604
+ const introspectionPick = vi.fn(async () => null);
605
+ const sources = [
606
+ { name: "queen-introspection", kind: "queen-introspection", pick: introspectionPick },
607
+ ];
608
+ saveAlwaysOnConfig(workDir, home, {
609
+ mode: "active",
610
+ dailyBudget: 3,
611
+ taskSources: { introspection: false },
612
+ });
613
+ const mgr = new AlwaysOnManager({
614
+ home,
615
+ swarmCoordinator: {},
616
+ taskSources: sources,
617
+ runnerFactory: () => ({
618
+ run: async () => ({
619
+ cycleId: "c",
620
+ swarmId: "s",
621
+ taskRef: null,
622
+ status: "skipped",
623
+ startedAt: "",
624
+ endedAt: "",
625
+ }),
626
+ }),
627
+ });
628
+ await mgr.start(workDir);
629
+ const internal = mgr;
630
+ await internal.pickNextTask(workDir);
631
+ expect(introspectionPick).not.toHaveBeenCalled();
632
+ // External edit (simulating UI write through M7.2 handler) + refresh.
633
+ const current = loadAlwaysOnConfig(workDir, home);
634
+ saveAlwaysOnConfig(workDir, home, {
635
+ ...current,
636
+ taskSources: { introspection: true },
637
+ });
638
+ await mgr.refreshFromDisk(workDir);
639
+ await internal.pickNextTask(workDir);
640
+ expect(introspectionPick).toHaveBeenCalledTimes(1);
641
+ await mgr.disposeAll();
642
+ });
643
+ });
644
+ // ── R3 I-B: drainPending bypasses the cascade-picker toggle ────────────
645
+ describe("AlwaysOnManager — R3 I-B drainPending bypasses toggle filter", () => {
646
+ it("toggle flipped OFF mid-flight — next tick still reaps pending via drainPending (cascade pick is skipped)", async () => {
647
+ // Scenario: user enables introspection, IntrospectionSource spawns a
648
+ // fire-and-forget swarm (leaves pendingIntrospectionSwarmId behind).
649
+ // Before that swarm reaches terminal state, the user toggles
650
+ // introspection OFF. The cascade picker filter now skips
651
+ // IntrospectionSource.pick() entirely — pre-R3 that meant the source's
652
+ // own Step-0 drain never fired and the pending breadcrumb persisted
653
+ // forever. The drainPending hook fixes this: it runs every tick before
654
+ // the cascade, regardless of toggle state.
655
+ saveAlwaysOnConfig(workDir, home, {
656
+ mode: "active",
657
+ dailyBudget: 3,
658
+ taskSources: { introspection: false }, // toggled OFF
659
+ pendingIntrospectionSwarmId: "stale-swarm-id",
660
+ });
661
+ const drainCalls = [];
662
+ const pickCalls = [];
663
+ const introspectionSource = {
664
+ name: "queen-introspection",
665
+ kind: "queen-introspection",
666
+ async pick(wd) {
667
+ pickCalls.push(wd);
668
+ return null;
669
+ },
670
+ async drainPending(wd) {
671
+ drainCalls.push(wd);
672
+ // Simulate the source ingesting the pending swarm: clear breadcrumb
673
+ // and call coord.complete/fail.
674
+ const cur = loadAlwaysOnConfig(wd, home);
675
+ saveAlwaysOnConfig(wd, home, {
676
+ ...cur,
677
+ pendingIntrospectionSwarmId: undefined,
678
+ });
679
+ },
680
+ };
681
+ const mgr = new AlwaysOnManager({
682
+ home,
683
+ swarmCoordinator: {},
684
+ taskSources: [introspectionSource],
685
+ runnerFactory: () => ({
686
+ run: async () => ({
687
+ cycleId: "c",
688
+ swarmId: "s",
689
+ taskRef: null,
690
+ status: "skipped",
691
+ startedAt: "",
692
+ endedAt: "",
693
+ }),
694
+ }),
695
+ });
696
+ await mgr.start(workDir);
697
+ // Drive a single tick — drainPending fires before the cascade picker.
698
+ const entry = mgr.entries.get(workDir);
699
+ await entry.scheduler.tickNow();
700
+ // Drain ran exactly once (pre-cascade); cascade pick was filtered out
701
+ // because the toggle is OFF.
702
+ expect(drainCalls).toEqual([workDir]);
703
+ expect(pickCalls).toEqual([]);
704
+ // Breadcrumb was cleared by the drain — proving the toggle-off path
705
+ // still reaped the in-flight swarm.
706
+ expect(loadAlwaysOnConfig(workDir, home).pendingIntrospectionSwarmId).toBeUndefined();
707
+ await mgr.disposeAll();
708
+ });
709
+ it("drainAllSources is public + idempotent — second call is a no-op once pending is cleared", async () => {
710
+ saveAlwaysOnConfig(workDir, home, {
711
+ mode: "active",
712
+ dailyBudget: 3,
713
+ taskSources: { introspection: true },
714
+ pendingIntrospectionSwarmId: "stale-swarm-id",
715
+ });
716
+ let drainCount = 0;
717
+ const introspectionSource = {
718
+ name: "queen-introspection",
719
+ kind: "queen-introspection",
720
+ async pick() {
721
+ return null;
722
+ },
723
+ async drainPending(wd) {
724
+ drainCount += 1;
725
+ const cur = loadAlwaysOnConfig(wd, home);
726
+ if (cur.pendingIntrospectionSwarmId) {
727
+ saveAlwaysOnConfig(wd, home, {
728
+ ...cur,
729
+ pendingIntrospectionSwarmId: undefined,
730
+ });
731
+ }
732
+ },
733
+ };
734
+ const mgr = new AlwaysOnManager({
735
+ home,
736
+ swarmCoordinator: {},
737
+ taskSources: [introspectionSource],
738
+ runnerFactory: () => ({
739
+ run: async () => ({
740
+ cycleId: "c",
741
+ swarmId: "s",
742
+ taskRef: null,
743
+ status: "skipped",
744
+ startedAt: "",
745
+ endedAt: "",
746
+ }),
747
+ }),
748
+ });
749
+ await mgr.start(workDir);
750
+ await mgr.drainAllSources(workDir);
751
+ await mgr.drainAllSources(workDir);
752
+ expect(drainCount).toBe(2); // both calls invoked the hook
753
+ // But pending was cleared after the first one, so the second is a no-op
754
+ // from the source's POV (still reaches the source, just nothing to do).
755
+ expect(loadAlwaysOnConfig(workDir, home).pendingIntrospectionSwarmId).toBeUndefined();
756
+ await mgr.disposeAll();
757
+ });
758
+ it("drainAllSources swallows source-level errors — one source's bug does not break the tick", async () => {
759
+ saveAlwaysOnConfig(workDir, home, {
760
+ mode: "active",
761
+ dailyBudget: 3,
762
+ taskSources: { introspection: true },
763
+ });
764
+ const badSource = {
765
+ name: "queen-introspection",
766
+ kind: "queen-introspection",
767
+ async pick() {
768
+ return null;
769
+ },
770
+ async drainPending() {
771
+ throw new Error("source blew up during drain");
772
+ },
773
+ };
774
+ const mgr = new AlwaysOnManager({
775
+ home,
776
+ swarmCoordinator: {},
777
+ taskSources: [badSource],
778
+ runnerFactory: () => ({
779
+ run: async () => ({
780
+ cycleId: "c",
781
+ swarmId: "s",
782
+ taskRef: null,
783
+ status: "skipped",
784
+ startedAt: "",
785
+ endedAt: "",
786
+ }),
787
+ }),
788
+ });
789
+ await mgr.start(workDir);
790
+ // Must NOT throw.
791
+ await expect(mgr.drainAllSources(workDir)).resolves.toBeUndefined();
792
+ await mgr.disposeAll();
793
+ });
794
+ });
795
+ // suppress unused-import warnings while keeping the import for type-only usage
796
+ void vi;
797
+ //# sourceMappingURL=always-on-manager.test.js.map