elanous 0.2.27 → 0.2.28

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 (285) hide show
  1. package/apps/pwa/out/404/index.html +1 -1
  2. package/apps/pwa/out/404.html +1 -1
  3. package/apps/pwa/out/_next/static/chunks/{1454-a9c338f5bb556f42.js → 1454-29d1d773d5996a49.js} +1 -1
  4. package/apps/pwa/out/_next/static/chunks/{599-a914cc0468f56143.js → 599-56f2c0a5cc4a7295.js} +1 -1
  5. package/apps/pwa/out/_next/static/chunks/app/approvals/page-5ab1e47978577c5e.js +1 -0
  6. package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-07110da72c45e68f.js → page-24453f6a302816b4.js} +1 -1
  7. package/apps/pwa/out/_next/static/chunks/app/board/{page-d2d76d4a7eae91bd.js → page-6c131d30bb1cf871.js} +1 -1
  8. package/apps/pwa/out/_next/static/chunks/app/botlab/page-fa46d4588e8442c2.js +1 -0
  9. package/apps/pwa/out/_next/static/chunks/app/bots/{page-e5a9302c105725a3.js → page-dc0772b11861af51.js} +1 -1
  10. package/apps/pwa/out/_next/static/chunks/app/ceo/page-66ad46ed0279feb9.js +1 -0
  11. package/apps/pwa/out/_next/static/chunks/app/chat/page-5159dc8752c6cd29.js +1 -0
  12. package/apps/pwa/out/_next/static/chunks/app/consult/page-c5a2478861c08b87.js +1 -0
  13. package/apps/pwa/out/_next/static/chunks/app/control/page-f722a9dec084e08b.js +1 -0
  14. package/apps/pwa/out/_next/static/chunks/app/decisions/{page-6a16a48d1aa6e91f.js → page-256ffd365d32e100.js} +1 -1
  15. package/apps/pwa/out/_next/static/chunks/app/design-check/page-e0de78ae7b99f01c.js +1 -0
  16. package/apps/pwa/out/_next/static/chunks/app/editor/page-cfbdc44b708d9801.js +1 -0
  17. package/apps/pwa/out/_next/static/chunks/app/exec/{page-39f82acf619cca23.js → page-4c741eb760f2f77c.js} +1 -1
  18. package/apps/pwa/out/_next/static/chunks/app/field/{page-7c2c8f81b185f9c0.js → page-e9ba98f5f1440a21.js} +1 -1
  19. package/apps/pwa/out/_next/static/chunks/app/inside/{page-ae7aff07e32676bb.js → page-dafc901bb1b72b5f.js} +1 -1
  20. package/apps/pwa/out/_next/static/chunks/app/intake/{page-482c60c1bc540d0a.js → page-2f6f303fa114ec24.js} +1 -1
  21. package/apps/pwa/out/_next/static/chunks/app/{layout-7cb996c1aa08b6f5.js → layout-1634081c5272006c.js} +1 -1
  22. package/apps/pwa/out/_next/static/chunks/app/live/{page-623e08fe4477d606.js → page-ced0bcfc6542b3fc.js} +1 -1
  23. package/apps/pwa/out/_next/static/chunks/app/live-run/{page-db460cd8aecb8490.js → page-bb1f05cd51c38944.js} +1 -1
  24. package/apps/pwa/out/_next/static/chunks/app/loops/{page-ff27902d0b11ef4f.js → page-646b50f63b6fe732.js} +1 -1
  25. package/apps/pwa/out/_next/static/chunks/app/market/page-db069b48a59c8c00.js +1 -0
  26. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/{page-1917ea87ea71ab6b.js → page-f736324afa2117c1.js} +1 -1
  27. package/apps/pwa/out/_next/static/chunks/app/morning/{page-25a302273b054fd7.js → page-6f131b51193a36f9.js} +1 -1
  28. package/apps/pwa/out/_next/static/chunks/app/{not-found-670510eda8b9443f.js → not-found-435f5e2faf02bc01.js} +1 -1
  29. package/apps/pwa/out/_next/static/chunks/app/observatory/page-497e0eedf20e7890.js +1 -0
  30. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-a8d28fdf995e16ea.js +1 -0
  31. package/apps/pwa/out/_next/static/chunks/app/ops/release/{page-4bb7556e2e97a750.js → page-8966452228753c5e.js} +1 -1
  32. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-fd5d18656cb432b6.js +1 -0
  33. package/apps/pwa/out/_next/static/chunks/app/outputs/page-5663779cb1a37609.js +1 -0
  34. package/apps/pwa/out/_next/static/chunks/app/{page-4eb96fc7241c2456.js → page-66a67eb19ebd976b.js} +1 -1
  35. package/apps/pwa/out/_next/static/chunks/app/reflection/page-3b492aa78cf3c4a6.js +1 -0
  36. package/apps/pwa/out/_next/static/chunks/app/scheduler/{page-0d64ff212dc55d03.js → page-c71267e7681da982.js} +1 -1
  37. package/apps/pwa/out/_next/static/chunks/app/sessions/{page-7f096248993fc2d4.js → page-a21d1e1797172692.js} +1 -1
  38. package/apps/pwa/out/_next/static/chunks/app/settings/devices/{page-e47fc72049149a38.js → page-cb249e81ed339318.js} +1 -1
  39. package/apps/pwa/out/_next/static/chunks/app/settings/{page-f7d808e31e005895.js → page-781cfc5114e097da.js} +1 -1
  40. package/apps/pwa/out/_next/static/chunks/app/setup/done/{page-5c2b24b5496dad73.js → page-478157214db6a023.js} +1 -1
  41. package/apps/pwa/out/_next/static/chunks/app/setup/{page-76cdb531668b5191.js → page-86141f12cf090b7f.js} +1 -1
  42. package/apps/pwa/out/_next/static/chunks/app/share/page-7256c73bb1c36751.js +1 -0
  43. package/apps/pwa/out/_next/static/chunks/app/showroom/page-5c738ca7adef3de5.js +1 -0
  44. package/apps/pwa/out/_next/static/chunks/app/tasks/page-0138c8091ec17224.js +1 -0
  45. package/apps/pwa/out/_next/static/chunks/app/term/page-cc2f24f16d85e735.js +1 -0
  46. package/apps/pwa/out/_next/static/chunks/app/today/{page-dab163b338e3b966.js → page-979d318a534e92da.js} +1 -1
  47. package/apps/pwa/out/_next/static/chunks/app/trace/page-182d394f7e6e096b.js +1 -0
  48. package/apps/pwa/out/_next/static/chunks/app/vault/{page-0260ffc7eda50db1.js → page-21050928a697ed7a.js} +1 -1
  49. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-44b9d807005a9030.js +1 -0
  50. package/apps/pwa/out/_next/static/chunks/app/workflows/{page-99c4e6c7345a9156.js → page-601fb77340e63c9a.js} +1 -1
  51. package/apps/pwa/out/_next/static/chunks/app/workspace/page-75d3fb5d47af0edf.js +1 -0
  52. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-11bf9e5725a72c22.js +1 -0
  53. package/apps/pwa/out/approvals/index.html +1 -1
  54. package/apps/pwa/out/approvals/index.txt +13 -13
  55. package/apps/pwa/out/autopilot/index.html +1 -1
  56. package/apps/pwa/out/autopilot/index.txt +13 -13
  57. package/apps/pwa/out/board/index.html +1 -1
  58. package/apps/pwa/out/board/index.txt +13 -13
  59. package/apps/pwa/out/botlab/index.html +1 -1
  60. package/apps/pwa/out/botlab/index.txt +13 -13
  61. package/apps/pwa/out/bots/index.html +1 -1
  62. package/apps/pwa/out/bots/index.txt +13 -13
  63. package/apps/pwa/out/ceo/index.html +1 -1
  64. package/apps/pwa/out/ceo/index.txt +13 -13
  65. package/apps/pwa/out/chat/index.html +1 -1
  66. package/apps/pwa/out/chat/index.txt +13 -13
  67. package/apps/pwa/out/consult/index.html +1 -1
  68. package/apps/pwa/out/consult/index.txt +13 -13
  69. package/apps/pwa/out/control/index.html +1 -1
  70. package/apps/pwa/out/control/index.txt +13 -13
  71. package/apps/pwa/out/decisions/index.html +1 -1
  72. package/apps/pwa/out/decisions/index.txt +13 -13
  73. package/apps/pwa/out/design-check/index.html +1 -1
  74. package/apps/pwa/out/design-check/index.txt +13 -13
  75. package/apps/pwa/out/editor/index.html +1 -1
  76. package/apps/pwa/out/editor/index.txt +13 -13
  77. package/apps/pwa/out/exec/index.html +1 -1
  78. package/apps/pwa/out/exec/index.txt +13 -13
  79. package/apps/pwa/out/field/index.html +1 -1
  80. package/apps/pwa/out/field/index.txt +13 -13
  81. package/apps/pwa/out/healthz +1 -1
  82. package/apps/pwa/out/index.html +1 -1
  83. package/apps/pwa/out/index.txt +13 -13
  84. package/apps/pwa/out/inside/index.html +1 -1
  85. package/apps/pwa/out/inside/index.txt +13 -13
  86. package/apps/pwa/out/intake/index.html +1 -1
  87. package/apps/pwa/out/intake/index.txt +13 -13
  88. package/apps/pwa/out/live/index.html +1 -1
  89. package/apps/pwa/out/live/index.txt +13 -13
  90. package/apps/pwa/out/live-run/index.html +1 -1
  91. package/apps/pwa/out/live-run/index.txt +13 -13
  92. package/apps/pwa/out/loops/index.html +1 -1
  93. package/apps/pwa/out/loops/index.txt +13 -13
  94. package/apps/pwa/out/market/index.html +1 -1
  95. package/apps/pwa/out/market/index.txt +13 -13
  96. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  97. package/apps/pwa/out/missions/placeholder/index.txt +13 -13
  98. package/apps/pwa/out/morning/index.html +1 -1
  99. package/apps/pwa/out/morning/index.txt +13 -13
  100. package/apps/pwa/out/observatory/index.html +1 -1
  101. package/apps/pwa/out/observatory/index.txt +13 -13
  102. package/apps/pwa/out/ops/checklist/index.html +1 -1
  103. package/apps/pwa/out/ops/checklist/index.txt +13 -13
  104. package/apps/pwa/out/ops/release/index.html +1 -1
  105. package/apps/pwa/out/ops/release/index.txt +13 -13
  106. package/apps/pwa/out/ops/seats/index.html +1 -1
  107. package/apps/pwa/out/ops/seats/index.txt +13 -13
  108. package/apps/pwa/out/outputs/index.html +1 -1
  109. package/apps/pwa/out/outputs/index.txt +13 -13
  110. package/apps/pwa/out/reflection/index.html +1 -1
  111. package/apps/pwa/out/reflection/index.txt +14 -14
  112. package/apps/pwa/out/scheduler/index.html +1 -1
  113. package/apps/pwa/out/scheduler/index.txt +13 -13
  114. package/apps/pwa/out/sessions/index.html +1 -1
  115. package/apps/pwa/out/sessions/index.txt +13 -13
  116. package/apps/pwa/out/settings/devices/index.html +1 -1
  117. package/apps/pwa/out/settings/devices/index.txt +13 -13
  118. package/apps/pwa/out/settings/index.html +1 -1
  119. package/apps/pwa/out/settings/index.txt +13 -13
  120. package/apps/pwa/out/setup/done/index.html +1 -1
  121. package/apps/pwa/out/setup/done/index.txt +13 -13
  122. package/apps/pwa/out/setup/index.html +1 -1
  123. package/apps/pwa/out/setup/index.txt +13 -13
  124. package/apps/pwa/out/share/index.html +1 -1
  125. package/apps/pwa/out/share/index.txt +13 -13
  126. package/apps/pwa/out/showroom/index.html +1 -1
  127. package/apps/pwa/out/showroom/index.txt +13 -13
  128. package/apps/pwa/out/tasks/index.html +1 -1
  129. package/apps/pwa/out/tasks/index.txt +13 -13
  130. package/apps/pwa/out/term/index.html +1 -1
  131. package/apps/pwa/out/term/index.txt +13 -13
  132. package/apps/pwa/out/today/index.html +1 -1
  133. package/apps/pwa/out/today/index.txt +13 -13
  134. package/apps/pwa/out/trace/index.html +1 -1
  135. package/apps/pwa/out/trace/index.txt +13 -13
  136. package/apps/pwa/out/vault/index.html +1 -1
  137. package/apps/pwa/out/vault/index.txt +13 -13
  138. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  139. package/apps/pwa/out/workflows/chat-ui/index.txt +13 -13
  140. package/apps/pwa/out/workflows/index.html +1 -1
  141. package/apps/pwa/out/workflows/index.txt +13 -13
  142. package/apps/pwa/out/workspace/index.html +1 -1
  143. package/apps/pwa/out/workspace/index.txt +13 -13
  144. package/apps/pwa/out/worktrees/index.html +1 -1
  145. package/apps/pwa/out/worktrees/index.txt +13 -13
  146. package/graphs/implement-loop.yaml +38 -1
  147. package/graphs/steward/recipes.yaml +6 -0
  148. package/graphs/steward/release-day.yaml +23 -0
  149. package/graphs/steward/steward.yaml +9 -1
  150. package/graphs/work/code-pr.yaml +23 -0
  151. package/package.json +1 -1
  152. package/scripts/browser-observe-cycle.ts +76 -2
  153. package/scripts/ci-typecheck-changed.ts +50 -17
  154. package/scripts/detect-test-interference.ts +31 -1
  155. package/scripts/release-loop/docs-node.ts +51 -22
  156. package/scripts/release-loop/gate-node.ts +37 -4
  157. package/scripts/release-loop/node-verdict.ts +3 -1
  158. package/scripts/release-loop/publish-node.ts +3 -0
  159. package/scripts/release-loop/tui-node-checks.ts +5 -2
  160. package/scripts/skills/public-check.ts +5 -2
  161. package/scripts/test-deterministic.ts +520 -37
  162. package/scripts/ux-sim.ts +7 -1
  163. package/scripts/verify-kpack6-live.ts +230 -0
  164. package/src/acp/codex-plugins.ts +17 -0
  165. package/src/agent/task-notification.ts +2 -1
  166. package/src/agent-mission/in-agent-plugin.ts +8 -3
  167. package/src/agent-substrate/self-review-cli.ts +34 -10
  168. package/src/briefing/brief-items.ts +151 -35
  169. package/src/budget/llm-cost.ts +25 -2
  170. package/src/budget/usage-outcomes.ts +32 -0
  171. package/src/claims/claims-ledger.ts +23 -0
  172. package/src/claims/claims-render.ts +22 -2
  173. package/src/cli/brief-cli.ts +131 -10
  174. package/src/cli/claims-cli.ts +24 -4
  175. package/src/cli/msg-cli.ts +165 -9
  176. package/src/cli/plugin-cli.ts +11 -3
  177. package/src/cli/pr-cli.ts +80 -18
  178. package/src/cli/pr-land-refusal.ts +20 -0
  179. package/src/cli/release-cli.ts +2 -0
  180. package/src/cli/rfc-cli.ts +24 -0
  181. package/src/cli/tailscale-serve.ts +5 -8
  182. package/src/cli/tasks-cli.ts +393 -26
  183. package/src/cli/usage-cli.ts +9 -4
  184. package/src/dashboard/first-screen-band.ts +63 -13
  185. package/src/dashboard/index.ts +8 -3
  186. package/src/dashboard/skill-detection-runtime.ts +24 -4
  187. package/src/domains/morning-report.ts +3 -1
  188. package/src/github/pr-labels.ts +10 -4
  189. package/src/graph-runner/runner.ts +101 -14
  190. package/src/harness/harness-clean.ts +21 -3
  191. package/src/harness/harness-cli-command.ts +22 -6
  192. package/src/harness/harness-pod-dispatch.ts +11 -4
  193. package/src/hq/seats.ts +17 -3
  194. package/src/index.ts +34 -5
  195. package/src/intelligence-map/model-bench-scores.ts +102 -0
  196. package/src/loops/orchestrator/inbox.ts +73 -0
  197. package/src/loops/orchestrator/resync-shadow.ts +100 -12
  198. package/src/loops/orchestrator/tick.ts +73 -2
  199. package/src/maturity/feature-maturity.ts +1 -1
  200. package/src/msg/msg-sse-server.ts +141 -0
  201. package/src/msg/msg-store.ts +84 -14
  202. package/src/patents/ledger.ts +128 -0
  203. package/src/plugins/install/market-fetch.ts +32 -1
  204. package/src/pod-lease/admission.ts +116 -41
  205. package/src/release-loop/checklist.ts +11 -0
  206. package/src/release-loop/merged-pr-checklist.ts +24 -5
  207. package/src/release-loop/rubric.ts +70 -0
  208. package/src/rfc-registry/registry.ts +88 -0
  209. package/src/seat-dispatch/seat-request-ledger.ts +4 -0
  210. package/src/seat-loop/knowhow-lint.ts +233 -0
  211. package/src/seat-loop/seat-pack.ts +16 -4
  212. package/src/seat-loop/traffic-signal.ts +7 -0
  213. package/src/self-dev/ask-launch-flow.ts +48 -8
  214. package/src/self-dev/ask-path-candidates.ts +69 -14
  215. package/src/self-dev/decompose-proposal.ts +43 -1
  216. package/src/self-dev/graph-journey-nodes.ts +2 -0
  217. package/src/self-dev/launch-preflight-text.ts +5 -1
  218. package/src/self-dev/launch-preflight.ts +143 -10
  219. package/src/self-implement/gate-cli.ts +6 -2
  220. package/src/self-implement/goal-author.ts +2 -1
  221. package/src/self-implement/goal-new-paths.ts +4 -5
  222. package/src/self-implement/graph-authority.ts +17 -3
  223. package/src/self-implement/graph-yaml.ts +15 -7
  224. package/src/self-implement/headless-elanous-driver.ts +16 -1
  225. package/src/self-implement/host-regate.ts +44 -9
  226. package/src/self-implement/orchestrator.ts +3 -10
  227. package/src/self-implement/rework-policy.ts +80 -2
  228. package/src/self-implement/speed-claims.ts +145 -6
  229. package/src/skills/tools/persistent-grounding.ts +59 -4
  230. package/src/skills/tools/shell.ts +79 -1
  231. package/src/steward/rejudge-tick.ts +12 -1
  232. package/src/steward/release-day.ts +158 -0
  233. package/src/steward/resync-tick.ts +123 -0
  234. package/src/task-agent/actions.ts +1 -0
  235. package/src/task-agent/adopt-pr.ts +140 -0
  236. package/src/task-agent/board.ts +11 -1
  237. package/src/task-agent/card-evidence.ts +37 -5
  238. package/src/task-agent/cover.ts +84 -17
  239. package/src/task-agent/failure-notify.ts +61 -0
  240. package/src/task-agent/green-sweep.ts +170 -0
  241. package/src/task-agent/land-worktree.ts +52 -5
  242. package/src/task-agent/live-move-history.ts +1 -0
  243. package/src/task-agent/live-moves.ts +43 -12
  244. package/src/task-agent/parent-host.ts +1 -1
  245. package/src/task-agent/postland-card.ts +139 -0
  246. package/src/task-agent/rejudge-on-head.ts +106 -7
  247. package/src/task-agent/remote-rejudge-bind.ts +62 -0
  248. package/src/task-agent/repair-branch-cas.ts +182 -0
  249. package/src/task-agent/repair-cycle.ts +275 -0
  250. package/src/task-agent/repair-next-move.ts +31 -0
  251. package/src/task-agent/repair-review.ts +24 -0
  252. package/src/task-agent/repair-worker.ts +87 -0
  253. package/src/task-agent/review-result-capture.ts +2 -1
  254. package/src/task-agent/shadow.ts +13 -7
  255. package/src/task-agent/task-hand.ts +115 -6
  256. package/src/task-orchestrator/surfaces/pod-repair-commit.ts +96 -0
  257. package/src/task-orchestrator/surfaces/self-implement-pod.ts +70 -12
  258. package/src/telegram.ts +30 -1
  259. package/src/user-config.ts +8 -1
  260. package/src/ux-sim/fault-sqlite-busy.ts +50 -0
  261. package/src/version/packed-revision.json +1 -1
  262. package/apps/pwa/out/_next/static/chunks/app/approvals/page-a1ce306364f08da9.js +0 -1
  263. package/apps/pwa/out/_next/static/chunks/app/botlab/page-30007380f2e2a3c1.js +0 -1
  264. package/apps/pwa/out/_next/static/chunks/app/ceo/page-b93c04b5311c97ec.js +0 -1
  265. package/apps/pwa/out/_next/static/chunks/app/chat/page-718a49e86bf25f9c.js +0 -1
  266. package/apps/pwa/out/_next/static/chunks/app/consult/page-20662900d6b4b455.js +0 -1
  267. package/apps/pwa/out/_next/static/chunks/app/control/page-f707a509515ac50c.js +0 -1
  268. package/apps/pwa/out/_next/static/chunks/app/design-check/page-fcb06e305f515527.js +0 -1
  269. package/apps/pwa/out/_next/static/chunks/app/editor/page-6acf5ec4e6c6d1af.js +0 -1
  270. package/apps/pwa/out/_next/static/chunks/app/market/page-8397a5c6ca569bf4.js +0 -1
  271. package/apps/pwa/out/_next/static/chunks/app/observatory/page-d73c3f1e00d93986.js +0 -1
  272. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-65caacf54389081e.js +0 -1
  273. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-77e04b4f8762c666.js +0 -1
  274. package/apps/pwa/out/_next/static/chunks/app/outputs/page-15c0a4095baddfba.js +0 -1
  275. package/apps/pwa/out/_next/static/chunks/app/reflection/page-63e272a4cd3b6634.js +0 -1
  276. package/apps/pwa/out/_next/static/chunks/app/share/page-e2b63345bba1a11f.js +0 -1
  277. package/apps/pwa/out/_next/static/chunks/app/showroom/page-d376e0c83edc041a.js +0 -1
  278. package/apps/pwa/out/_next/static/chunks/app/tasks/page-d6db55fe2c04d128.js +0 -1
  279. package/apps/pwa/out/_next/static/chunks/app/term/page-87b88f551f421d8b.js +0 -1
  280. package/apps/pwa/out/_next/static/chunks/app/trace/page-08019efef111ed2e.js +0 -1
  281. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-980ce6fe36ad95eb.js +0 -1
  282. package/apps/pwa/out/_next/static/chunks/app/workspace/page-0ab42e0f05823013.js +0 -1
  283. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-ad9220422d20ba33.js +0 -1
  284. /package/apps/pwa/out/_next/static/{Ev2jivDMTSoEc-5rIXdcy → y1ltt7oCZxVpC6zVPhZ2O}/_buildManifest.js +0 -0
  285. /package/apps/pwa/out/_next/static/{Ev2jivDMTSoEc-5rIXdcy → y1ltt7oCZxVpC6zVPhZ2O}/_ssgManifest.js +0 -0
@@ -5,6 +5,9 @@ import { tmpdir } from 'node:os';
5
5
  import { dirname, isAbsolute, join, resolve } from 'node:path';
6
6
  import { debug } from '../../debug/log.js';
7
7
  import { effectiveInstanceRoot } from '../../instance/resolve.js';
8
+ import { PR_LABEL_NAMES } from '../../github/pr-labels.js';
9
+ import { listRunLedgers } from '../../self-implement/run-ledger.js';
10
+ import { queryRunningRuns } from '../../self-implement/running-runs.js';
8
11
  import {
9
12
  defaultGitMergeSeam, defaultLlmResolve, mergeMainWithLlmResolve,
10
13
  type LlmMergeOutcome, type MergeGitSeam,
@@ -16,6 +19,8 @@ interface ResyncShadowEntry {
16
19
  at: string;
17
20
  pr: number;
18
21
  head: string;
22
+ beforeHead?: string;
23
+ afterHead?: string;
19
24
  mode: ResyncShadowMode;
20
25
  pushed: boolean;
21
26
  labelRemoved: boolean;
@@ -27,7 +32,7 @@ interface ResyncShadowEntry {
27
32
  }
28
33
  export interface ResyncShadowSummary { candidates: number; tried: number; saved: number; savedRatio: number | null }
29
34
 
30
- type OpenPr = { number: number; headRefName: string; mergeable: string; isDraft: boolean; labels: { name: string }[] };
35
+ type OpenPr = { number: number; headRefName: string; headRefOid?: string; mergeable: string; isDraft: boolean; labels: { name: string }[] };
31
36
  export interface ResyncShadowDeps {
32
37
  repoRoot?: string;
33
38
  instanceRoot?: string;
@@ -37,6 +42,63 @@ export interface ResyncShadowDeps {
37
42
  gitSeam?: MergeGitSeam;
38
43
  runTests?: (cwd: string, files: string[]) => { ok: boolean; failedTests?: string[]; noTests?: boolean };
39
44
  now?: () => Date;
45
+ /** Run ownership is checked before touching a branch; null means the observation failed. */
46
+ activePrNumbers?: (candidatePrs: readonly number[]) => ReadonlySet<number> | null;
47
+ }
48
+
49
+ function activePrNumbers(candidatePrs: readonly OpenPr[], repoRoot: string, instanceRoot: string): ReadonlySet<number> | null {
50
+ const owners = new Map<number, { runId: string; branch: string; headCommit: string; terminal: boolean }[]>();
51
+ const branchRuns = new Map<string, Set<string>>();
52
+ const incompleteOwnerPrs = new Set<number>();
53
+ const candidateSet = new Set(candidatePrs.map(pr => pr.number));
54
+ try {
55
+ for (const match of listRunLedgers().matches) {
56
+ const latestStatus = match.entries.filter(entry => entry.event === 'run-status').at(-1)?.data.runStatus;
57
+ const terminal = latestStatus === 'completed' || latestStatus === 'failed' || latestStatus === 'cancelled';
58
+ for (const entry of match.entries) {
59
+ // A competing run can own the branch even when its PR number or worktree event is missing.
60
+ if ((entry.event === 'worktree' || entry.event === 'pr-opened') && typeof entry.data.branch === 'string') {
61
+ const ids = branchRuns.get(entry.data.branch) ?? new Set<string>();
62
+ ids.add(match.runId);
63
+ branchRuns.set(entry.data.branch, ids);
64
+ }
65
+ const number = entry.event === 'pr-opened' ? entry.data.number : undefined;
66
+ if (typeof number !== 'number' || !candidateSet.has(number)) continue;
67
+ const matches = owners.get(number) ?? [];
68
+ if (typeof entry.data.branch !== 'string' || typeof entry.data.headCommit !== 'string')
69
+ incompleteOwnerPrs.add(number);
70
+ else matches.push({ runId: match.runId, branch: entry.data.branch, headCommit: entry.data.headCommit, terminal });
71
+ owners.set(number, matches);
72
+ }
73
+ }
74
+ const runIds = [...new Set([...owners.values()].flatMap(rows => rows.map(row => row.runId)))];
75
+ if (runIds.length === 0) return new Set(candidatePrs.map(pr => pr.number));
76
+ const observed = queryRunningRuns({ runIds, caller: 'smart-merge.resync' });
77
+ if (observed.completeness !== 'complete') return null;
78
+ const active = new Set(observed.entries.filter(row => row.status === 'running' || row.status === 'probable-running'
79
+ || row.status === 'unknown' || row.status === 'ended-unclosed').map(row => row.runId));
80
+ const priorPath = join(instanceRoot, 'orchestrator', 'resync-shadow.jsonl');
81
+ const prior = existsSync(priorPath) ? readFileSync(priorPath, 'utf8').split('\n').filter(Boolean).map(line => JSON.parse(line) as ResyncShadowEntry) : [];
82
+ const excluded = new Set<number>();
83
+ for (const pr of candidatePrs) {
84
+ const matches = owners.get(pr.number) ?? [];
85
+ if (incompleteOwnerPrs.has(pr.number) || matches.length !== 1 || !/^[a-f0-9]{40}$/i.test(pr.headRefOid ?? '')) { excluded.add(pr.number); continue; }
86
+ const owner = matches[0]!;
87
+ if (owner.branch !== pr.headRefName || !owner.terminal || !/^[a-f0-9]{40}$/i.test(owner.headCommit)
88
+ || active.has(owner.runId) || [...(branchRuns.get(pr.headRefName) ?? [])].some(id => id !== owner.runId)) {
89
+ excluded.add(pr.number); continue;
90
+ }
91
+ const remote = command('git', ['ls-remote', 'origin', `refs/heads/${pr.headRefName}`], repoRoot).trim().split(/\s/)[0];
92
+ if (remote !== pr.headRefOid) { excluded.add(pr.number); continue; }
93
+ let proven = owner.headCommit;
94
+ for (const row of prior.filter(row => row.pr === pr.number && row.head === pr.headRefName && row.pushed)) {
95
+ if (row.beforeHead !== proven || !/^[a-f0-9]{40}$/i.test(row.afterHead ?? '')) { proven = ''; break; }
96
+ proven = row.afterHead!;
97
+ }
98
+ if (proven !== remote) excluded.add(pr.number);
99
+ }
100
+ return excluded;
101
+ } catch { return null; }
40
102
  }
41
103
 
42
104
  function command(file: string, args: string[], cwd: string, timeout = 60_000): string {
@@ -51,6 +113,15 @@ function changedTests(gh: (args: string[]) => string, number: number): string[]
51
113
  path.endsWith('.test.ts') && !isAbsolute(path) && path !== '' && !path.split('/').some(part => part === '..' || part === '.' || part === '')))];
52
114
  }
53
115
 
116
+ function listOpenPrs(gh: (args: string[]) => string): OpenPr[] {
117
+ const observed: unknown = JSON.parse(gh(['pr', 'list', '--state', 'open', '--json', 'number,headRefName,headRefOid,mergeable,isDraft,labels', '--limit', '1000']));
118
+ if (!Array.isArray(observed) || observed.length >= 1000 || observed.some(pr => !pr || !Number.isSafeInteger(pr.number)
119
+ || typeof pr.headRefName !== 'string' || typeof pr.mergeable !== 'string' || typeof pr.isDraft !== 'boolean'
120
+ || !Array.isArray(pr.labels) || pr.labels.some((label: unknown) => !label || typeof label !== 'object' || typeof (label as { name?: unknown }).name !== 'string')))
121
+ throw new Error('incomplete or invalid PR observation');
122
+ return observed as OpenPr[];
123
+ }
124
+
54
125
  // A PR test must not inherit credentials, reach the network or write outside its own worktree.
55
126
  // If the kernel does not provide the required isolation, fail closed before starting bun.
56
127
  const TEST_SANDBOX = `import ctypes, os, sys
@@ -145,16 +216,16 @@ export async function runResyncShadow(max = 3, deps: ResyncShadowDeps = {}, mode
145
216
  if (mode !== 'shadow' && mode !== 'live') throw new Error('mode must be shadow or live');
146
217
  const repoRoot = deps.repoRoot ?? resolve(import.meta.dir, '../../..');
147
218
  const gh = deps.runGh ?? ((args: string[]) => command('gh', args, repoRoot));
148
- const observed: unknown = JSON.parse(gh(['pr', 'list', '--state', 'open', '--json', 'number,headRefName,mergeable,isDraft,labels', '--limit', '1000']));
149
- if (!Array.isArray(observed) || observed.length >= 1000 || observed.some(pr => !pr || !Number.isSafeInteger(pr.number)
150
- || typeof pr.headRefName !== 'string' || typeof pr.mergeable !== 'string' || typeof pr.isDraft !== 'boolean'
151
- || !Array.isArray(pr.labels) || pr.labels.some((label: unknown) => !label || typeof label !== 'object' || typeof (label as { name?: unknown }).name !== 'string')))
152
- throw new Error('incomplete or invalid PR observation');
153
- const candidates = (observed as OpenPr[]).filter(pr => pr.headRefName.startsWith('self-impl/')
154
- && pr.mergeable === 'CONFLICTING'
155
- && (mode === 'shadow' || pr.labels.some(label => label.name === 'elanous:needs-rebase'))
156
- && !pr.labels.some(label => label.name === 'elanous:stalled' || label.name === 'elanous:superseded'))
157
- .sort((a, b) => a.number - b.number).slice(0, max);
219
+ const eligible = listOpenPrs(gh).filter(pr => pr.headRefName.startsWith('self-impl/')
220
+ && (mode === 'shadow' ? pr.mergeable === 'CONFLICTING' : pr.mergeable === 'MERGEABLE')
221
+ && (mode === 'shadow' || pr.labels.some(label => label.name === PR_LABEL_NAMES.needsRebase))
222
+ && !pr.labels.some(label => label.name === PR_LABEL_NAMES.running)
223
+ && !pr.labels.some(label => label.name === PR_LABEL_NAMES.stalled || label.name === PR_LABEL_NAMES.superseded));
224
+ const observeActive = deps.activePrNumbers ?? ((numbers: readonly number[]) => activePrNumbers(
225
+ eligible.filter(pr => numbers.includes(pr.number)), repoRoot, deps.instanceRoot ?? effectiveInstanceRoot()));
226
+ const active = mode === 'live' && eligible.length ? observeActive(eligible.map(pr => pr.number)) : new Set<number>();
227
+ if (active === null) throw new Error('active run observation unavailable — resync stays read-only');
228
+ const candidates = eligible.filter(pr => !active.has(pr.number)).sort((a, b) => a.number - b.number).slice(0, max);
158
229
  const ledger = join(deps.instanceRoot ?? effectiveInstanceRoot(), 'orchestrator', 'resync-shadow.jsonl');
159
230
  let saved = 0;
160
231
  let tried = 0;
@@ -169,6 +240,9 @@ export async function runResyncShadow(max = 3, deps: ResyncShadowDeps = {}, mode
169
240
  // Explicit destination refspecs make origin/<head> and origin/main fresh even without remote.origin.fetch.
170
241
  command('git', ['fetch', 'origin', `+refs/heads/${pr.headRefName}:refs/remotes/origin/${pr.headRefName}`,
171
242
  '+refs/heads/main:refs/remotes/origin/main'], repoRoot);
243
+ entry.beforeHead = command('git', ['rev-parse', `origin/${pr.headRefName}`], repoRoot).trim();
244
+ if (mode === 'live' && !deps.activePrNumbers && entry.beforeHead !== pr.headRefOid)
245
+ throw new Error('PR head changed after owner proof — resync stays read-only');
172
246
  entry.testFiles = changedTests(gh, pr.number);
173
247
  tmp = realpathSync(mkdtempSync(join(tmpdir(), 'resync-shadow-')));
174
248
  command('git', ['worktree', 'add', '--detach', tmp, `origin/${pr.headRefName}`], repoRoot);
@@ -189,10 +263,24 @@ export async function runResyncShadow(max = 3, deps: ResyncShadowDeps = {}, mode
189
263
  } else entry.verdict = outcome.status === 'conflict-unresolved' || outcome.status === 'up-to-date' ? 'unresolved' : 'error';
190
264
  if (mode === 'live' && entry.verdict === 'saved') {
191
265
  try {
266
+ const lateActive = observeActive([pr.number]);
267
+ if (lateActive === null || lateActive.has(pr.number))
268
+ throw new Error('PR ownership changed before push — resync stays read-only');
269
+ const current = listOpenPrs(gh).filter(row => row.number === pr.number);
270
+ if (current.length !== 1 || current[0]!.headRefName !== pr.headRefName
271
+ || current[0]!.headRefOid !== pr.headRefOid || current[0]!.mergeable !== 'MERGEABLE'
272
+ || !current[0]!.labels.some(label => label.name === PR_LABEL_NAMES.needsRebase)
273
+ || current[0]!.labels.some(label => label.name === PR_LABEL_NAMES.running
274
+ || label.name === PR_LABEL_NAMES.stalled || label.name === PR_LABEL_NAMES.superseded))
275
+ throw new Error('PR state changed before push — resync stays read-only');
276
+ const immediateHead = command('git', ['ls-remote', 'origin', `refs/heads/${pr.headRefName}`], repoRoot).trim().split(/\s/)[0];
277
+ if (immediateHead !== entry.beforeHead)
278
+ throw new Error('remote head changed before push — resync stays read-only');
192
279
  (deps.runGit ?? (args => { command('git', args, tmp!); }))(
193
280
  ['push', 'origin', `HEAD:refs/heads/${pr.headRefName}`]);
194
281
  entry.pushed = true;
195
- gh(['pr', 'edit', String(pr.number), '--remove-label', 'elanous:needs-rebase']);
282
+ entry.afterHead = command('git', ['rev-parse', 'HEAD'], tmp).trim();
283
+ gh(['pr', 'edit', String(pr.number), '--remove-label', PR_LABEL_NAMES.needsRebase]);
196
284
  entry.labelRemoved = true;
197
285
  } catch (error) {
198
286
  const message = error instanceof Error ? error.message : String(error);
@@ -1,5 +1,5 @@
1
1
  import { withFileLockSync } from '../../storage/file-lock.js';
2
- import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { CardStore, taskCardsDir, type TaskCard } from '../../task-cards/card-store.js';
5
5
  import { Database } from 'bun:sqlite';
@@ -16,6 +16,7 @@ import { placeOnGrid, type GridHost } from './grid-place.js';
16
16
  import { checkPodPool, parsePodPool, resolvePodPoolSpec } from '../../task-orchestrator/surfaces/pod-pool.js';
17
17
  import { leaseKubectl } from '../../task-orchestrator/surfaces/pod-lease.js';
18
18
  import { readTaskAgentState, TASK_SEATS, type HandTaskOptions, type TaskCard as TaskAgentCard, type HandTaskResult, type TaskLauncher, type TaskSeat } from '../../task-agent/task-hand.js';
19
+ import { runCoordinatorInbox } from './inbox.js';
19
20
 
20
21
  export type Window = '08' | '12' | '18';
21
22
  export type Node = 'intake' | 'split' | 'place' | 'delegate' | 'launch' | 'reconcile' | 'report';
@@ -31,6 +32,10 @@ export interface TickState {
31
32
  /** The highest degradation actually applied in this run — the report keeps it even if the signal recovers before report. */
32
33
  degradationPeak?: { level: 1 | 2; reason: string };
33
34
  deferredCells?: Cell[];
35
+ /** A placement adapter rejected at least one cell; this run cannot claim full completion. */
36
+ placementFailed?: boolean;
37
+ /** Last confirmed live placement write in this run, carried across node processes. */
38
+ boardWrittenAt?: string;
34
39
  /** place ran at L2 (skipped) in this run — its unplaced cells are carried even if the board is back by delegate. */
35
40
  placeDegraded?: boolean;
36
41
  delegatedKeys?: string[];
@@ -67,6 +72,8 @@ export interface TickDeps {
67
72
  handToTaskAgentCell?: string;
68
73
  handTask?: (opts: HandTaskOptions) => Promise<HandTaskResult>;
69
74
  taskLauncher?: TaskLauncher;
75
+ /** Coordinator inbox seam; the real intake reads and replies on the durable message bus. */
76
+ coordinatorInbox?: () => { replied: number; acknowledged: number };
70
77
  }
71
78
 
72
79
  /** ORCH-TA-HAND ledger — one row per handed `orch:<card>:<cell>` key so a cell is handed once across ticks and processes. */
@@ -329,6 +336,41 @@ function stateFile(root: string, runId: string): string {
329
336
  if (!/^[a-zA-Z0-9._-]+$/.test(runId) || runId === '.' || runId === '..') throw new Error('Invalid orchestrator runId');
330
337
  return join(root, 'loop', 'orchestrator', `${runId}.json`);
331
338
  }
339
+ /** COORD-HA ③ 진행 하트비트 — the last tick that finished its work and the last confirmed placement write. The guardian watches this
340
+ * (RFC-loop-agent-map §A8b: «마지막 틱 완료 ⊕ 보드 마지막 쓰기 시각»), not process liveness — a held node keeps pulsing
341
+ * healthy while this record goes stale, which is exactly the stall it exists to expose. */
342
+ export interface CompletionHeartbeat {
343
+ runId: string;
344
+ node: Node;
345
+ window: Window;
346
+ mode: Mode;
347
+ /** last confirmed live placement write; null before the first such write */
348
+ boardWrittenAt: string | null;
349
+ /** last fully completed run, or null while only board writes have succeeded */
350
+ tickCompletedAt: string | null;
351
+ }
352
+ /** Its own subdirectory, so the run-state scanners (`restorePrevious` · `previousDelegations` · `syncQueuedPoolHints`) never parse it as a TickState. */
353
+ function completionHeartbeatPath(root: string): string { return join(root, 'loop', 'orchestrator', 'heartbeat', 'completion.json'); }
354
+ /** COORD-HA ③ — last completed run and board write, independently. Undefined means neither has been recorded. */
355
+ export function readCompletionHeartbeat(root: string): CompletionHeartbeat | undefined {
356
+ const path = completionHeartbeatPath(root);
357
+ if (!existsSync(path)) return undefined;
358
+ try {
359
+ const { runId, node, window, mode, boardWrittenAt, tickCompletedAt } = JSON.parse(readFileSync(path, 'utf8')) as Partial<CompletionHeartbeat>;
360
+ if (typeof runId !== 'string' || typeof node !== 'string' || typeof window !== 'string' || typeof mode !== 'string'
361
+ || (boardWrittenAt !== null && (typeof boardWrittenAt !== 'string' || !Number.isFinite(Date.parse(boardWrittenAt))))
362
+ || (tickCompletedAt !== null && (typeof tickCompletedAt !== 'string' || !Number.isFinite(Date.parse(tickCompletedAt))))) return undefined;
363
+ return { runId, node: node as Node, window: window as Window, mode: mode as Mode, boardWrittenAt, tickCompletedAt };
364
+ } catch { return undefined; }
365
+ }
366
+ /** Temp + rename, so a guardian in another process never reads a torn record. */
367
+ function recordCompletionHeartbeat(root: string, completion: CompletionHeartbeat): void {
368
+ const path = completionHeartbeatPath(root);
369
+ mkdirSync(dirname(path), { recursive: true });
370
+ const temp = `${path}.${process.pid}.tmp`;
371
+ writeFileSync(temp, JSON.stringify(completion));
372
+ renameSync(temp, path);
373
+ }
332
374
  function restorePrevious(root: string, day: string, mode: Mode): Pick<TickState, 'cards' | 'cells' | 'launched'> | null {
333
375
  const dir = join(root, 'loop', 'orchestrator');
334
376
  if (!existsSync(dir)) return null;
@@ -430,6 +472,15 @@ export async function runOrchestratorNode(node: Node, deps: TickDeps = {}): Prom
430
472
  try { (deps.observe ?? ((name, payload) => debug.log('loop.orchestrator', name, payload)))(event, data); }
431
473
  catch { /* observation must not change the run */ }
432
474
  };
475
+ // A graph node may be replayed with the same runId, and 12/18 skip intake.
476
+ // Drain the bus on every tick command; the message transaction prevents duplicate answers.
477
+ if (mode !== 'off') {
478
+ const inbox = (deps.coordinatorInbox ?? (() => runCoordinatorInbox(undefined, 100, join(root, 'msg', 'messages.db'))))();
479
+ if (inbox.replied || inbox.acknowledged) {
480
+ observe('exchange', 'coordinator-inbox-replied', inbox.replied);
481
+ observe('exchange', 'coordinator-inbox-acknowledged', inbox.acknowledged);
482
+ }
483
+ }
433
484
  if (mode !== 'off' && activeNode(node, window) && state.nodes[node] !== 'ok') {
434
485
  if (node === 'intake') {
435
486
  if (mode === 'live' && deps.cards) { state.cards = wishCards(deps.cards()); state.skippedCards = []; }
@@ -495,11 +546,19 @@ export async function runOrchestratorNode(node: Node, deps: TickDeps = {}): Prom
495
546
  if (validVersion(placed?.version)) {
496
547
  cell.version = placed.version;
497
548
  state.placed++;
549
+ // The live placer has returned from its board write. A shadow dry run or null result is not a write.
550
+ if (mode === 'live') state.boardWrittenAt = currentTime().toISOString();
498
551
  observe('exchange', mode === 'shadow' ? `would-place:${cell.id}:${cell.version}` : `placed:${cell.id}:${cell.version}`, 1);
499
- } else { state.unplaced = (state.unplaced ?? 0) + 1; observe('exchange', 'unplaced-no-release', 1); }
552
+ } else {
553
+ state.unplaced = (state.unplaced ?? 0) + 1;
554
+ if (mode === 'live') state.placementFailed = true;
555
+ observe('exchange', 'unplaced-no-release', 1);
556
+ }
500
557
  } catch (error) {
501
558
  state.unplaced = (state.unplaced ?? 0) + 1;
559
+ state.placementFailed = true;
502
560
  observe('exchange', `unplaced:${cell.id}:${String(error).slice(0, 160)}`, 1);
561
+ // Preserve the cell-by-cell recovery contract; the run completion record is withheld at report.
503
562
  }
504
563
  }
505
564
  }
@@ -762,8 +821,20 @@ export async function runOrchestratorNode(node: Node, deps: TickDeps = {}): Prom
762
821
  state.nodes[node] = 'skipped';
763
822
  }
764
823
  observe('tick', state.nodes[node], state.cells.length);
824
+ if (mode === 'live' && node === 'place' && state.boardWrittenAt) {
825
+ // A board write can succeed even when a later cell fails. Advance its clock without claiming run completion.
826
+ const previous = readCompletionHeartbeat(root);
827
+ recordCompletionHeartbeat(root, previous
828
+ ? { ...previous, boardWrittenAt: state.boardWrittenAt }
829
+ : { runId, node, window, mode, boardWrittenAt: state.boardWrittenAt, tickCompletedAt: null });
830
+ }
765
831
  mkdirSync(dirname(path), { recursive: true });
766
832
  writeFileSync(path, JSON.stringify(state));
833
+ if (mode !== 'off' && node === 'report' && !state.placementFailed && !state.placeDegraded) {
834
+ // Only the last node can attest that the whole run completed. Carry the most recent confirmed board write.
835
+ const boardWrittenAt = state.boardWrittenAt ?? readCompletionHeartbeat(root)?.boardWrittenAt ?? null;
836
+ recordCompletionHeartbeat(root, { runId, node, window, mode, boardWrittenAt, tickCompletedAt: currentTime().toISOString() });
837
+ }
767
838
  return state;
768
839
  } catch (error) {
769
840
  nodeError = error;
@@ -62,7 +62,7 @@ export const FEATURE_MATURITY = {
62
62
  preview: 'beta', pty: 'beta', pr: 'beta', repo: 'beta', role: 'beta', machine: 'beta', image: 'beta', 'agent-env': 'beta',
63
63
  where: 'stable', bench: 'beta', shadow: 'beta', llm: 'beta', storage: 'beta', leader: 'beta',
64
64
  pod: 'beta', browser: 'beta', questions: 'beta', usage: 'stable', live: 'beta',
65
- research: 'beta', release: 'beta', know: 'beta', freeze: 'beta', flow: 'beta', seat: 'beta', hq: 'beta', steward: 'beta', coord: 'beta', context: 'beta', 'model-watch': 'beta', doctor: 'stable',
65
+ research: 'beta', release: 'beta', rfc: 'beta', know: 'beta', freeze: 'beta', flow: 'beta', seat: 'beta', hq: 'beta', steward: 'beta', coord: 'beta', context: 'beta', 'model-watch': 'beta', doctor: 'stable',
66
66
  control: 'beta', resources: 'beta', hooks: 'beta', setup: 'stable', start: 'stable',
67
67
  grounding: 'beta', graph: 'beta', loop: 'stable', resource: 'beta', card: 'stable', 'launch-head': 'beta',
68
68
  a2a: 'beta', plugin: 'stable', make: 'beta', skills: 'beta', connect: 'beta', import: 'beta',
@@ -0,0 +1,141 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import { canonicalSeatId, type Message, type MsgStore } from './msg-store.js';
3
+
4
+ type Subscriber = { to: string; lastId: number; heartbeatAt: number; replaying: boolean; controller: ReadableStreamDefaultController<Uint8Array> };
5
+ const encoder = new TextEncoder();
6
+
7
+ function cursor(raw: string | null): number {
8
+ if (raw === null) return 0;
9
+ if (!/^(0|[1-9]\d*)$/.test(raw)) throw new Error('invalid message cursor');
10
+ const id = Number(raw);
11
+ if (!Number.isSafeInteger(id)) throw new Error('invalid message cursor');
12
+ return id;
13
+ }
14
+
15
+ function authorized(request: Request, token: string): boolean {
16
+ const value = request.headers.get('authorization');
17
+ if (!value?.startsWith('Bearer ')) return false;
18
+ const received = Buffer.from(value.slice(7));
19
+ const expected = Buffer.from(token);
20
+ return expected.length > 0 && received.length === expected.length && timingSafeEqual(received, expected);
21
+ }
22
+
23
+ function addressed(message: Message, to: string): boolean {
24
+ return message.to === to || (message.to === 'ALL' && message.from !== to);
25
+ }
26
+
27
+ /** The SQLite ledger remains authoritative; this independent process only accelerates delivery. */
28
+ export function startMsgSseServer(input: {
29
+ store: MsgStore;
30
+ token: string;
31
+ port: number;
32
+ hostname?: string;
33
+ pollMs?: number;
34
+ }): { port: number; stats: () => { subscribers: number; maxQueuedEvents: number }; stop: () => void } {
35
+ if (!input.token) throw new Error('message SSE token is required');
36
+ if (!Number.isSafeInteger(input.port) || input.port < 0 || input.port > 65535) throw new Error('invalid SSE port');
37
+ const hostname = input.hostname ?? '127.0.0.1';
38
+ if (!['127.0.0.1', 'localhost', '::1', '[::1]'].includes(hostname)) throw new Error('message SSE must bind to loopback');
39
+ const pollMs = input.pollMs ?? 250;
40
+ if (!Number.isSafeInteger(pollMs) || pollMs < 1 || pollMs > 500) throw new Error('SSE ledger poll must be 1–500ms');
41
+ const subscribers = new Set<Subscriber>();
42
+ let maxQueuedEvents = 0;
43
+ let scanned = input.store.latestId();
44
+ const send = (subscriber: Subscriber, message: Message) => {
45
+ if (message.id <= subscriber.lastId || !addressed(message, subscriber.to)) return;
46
+ if ((subscriber.controller.desiredSize ?? 0) < -256) throw new Error('slow SSE subscriber; reconnect for ledger replay');
47
+ subscriber.controller.enqueue(encoder.encode(`id: ${message.id}\ndata: ${JSON.stringify(message)}\n\n`));
48
+ maxQueuedEvents = Math.max(maxQueuedEvents, Math.max(0, 1 - (subscriber.controller.desiredSize ?? 0)));
49
+ subscriber.lastId = message.id;
50
+ };
51
+ const replay = (subscriber: Subscriber) => {
52
+ if (!subscriber.replaying) return;
53
+ try {
54
+ while ((subscriber.controller.desiredSize ?? 0) > 0) {
55
+ const batch = input.store.listByRecipient(subscriber.to, subscriber.lastId, 1000);
56
+ if (!batch.length) { subscriber.replaying = false; break; }
57
+ for (const message of batch) {
58
+ if ((subscriber.controller.desiredSize ?? 0) <= 0) break;
59
+ send(subscriber, message);
60
+ }
61
+ }
62
+ } catch (error) {
63
+ subscribers.delete(subscriber);
64
+ subscriber.controller.error(error);
65
+ }
66
+ };
67
+ const tick = () => {
68
+ try {
69
+ let batch: Message[];
70
+ let scans = 0;
71
+ do {
72
+ batch = input.store.listAfter(scanned);
73
+ for (const message of batch) {
74
+ scanned = message.id;
75
+ for (const subscriber of subscribers) {
76
+ if (subscriber.replaying) continue;
77
+ try { send(subscriber, message); } catch (error) {
78
+ subscribers.delete(subscriber);
79
+ try { subscriber.controller.error(error); } catch { /* already disconnected */ }
80
+ }
81
+ }
82
+ }
83
+ scans++;
84
+ } while (batch.length === 1000 && scans < 4);
85
+ const now = Date.now();
86
+ for (const subscriber of subscribers) {
87
+ if (subscriber.replaying || (subscriber.controller.desiredSize ?? 0) <= 0) continue;
88
+ if (now - subscriber.heartbeatAt < 1000) continue;
89
+ try {
90
+ subscriber.controller.enqueue(encoder.encode(': keepalive\n\n'));
91
+ subscriber.heartbeatAt = now;
92
+ } catch { subscribers.delete(subscriber); }
93
+ }
94
+ } catch (error) {
95
+ // The next tick retries from the last scanned ID; the durable ledger is unchanged.
96
+ console.error(`msg SSE scan failed: ${error instanceof Error ? error.message : String(error)}`);
97
+ }
98
+ };
99
+ const server = Bun.serve({
100
+ port: input.port,
101
+ hostname,
102
+ fetch(request) {
103
+ const url = new URL(request.url);
104
+ if (url.pathname !== '/stream' || request.method !== 'GET') return new Response('Not Found', { status: 404 });
105
+ if (!authorized(request, input.token)) return new Response('Unauthorized', { status: 401 });
106
+ let to: string;
107
+ let after: number;
108
+ try {
109
+ to = canonicalSeatId(url.searchParams.get('to') ?? '');
110
+ if (to === 'ALL') throw new Error('ALL is not a recipient');
111
+ after = Math.max(cursor(url.searchParams.get('since')), cursor(request.headers.get('last-event-id')));
112
+ } catch (error) { return new Response(error instanceof Error ? error.message : String(error), { status: 400 }); }
113
+ let subscriber: Subscriber;
114
+ const stream = new ReadableStream<Uint8Array>({
115
+ start(controller) {
116
+ subscriber = { to, lastId: after, heartbeatAt: Date.now(), replaying: true, controller };
117
+ subscribers.add(subscriber);
118
+ controller.enqueue(encoder.encode(': connected\n\n'));
119
+ },
120
+ pull() { replay(subscriber); },
121
+ cancel() { subscribers.delete(subscriber); },
122
+ });
123
+ request.signal.addEventListener('abort', () => subscribers.delete(subscriber), { once: true });
124
+ return new Response(stream, { headers: {
125
+ 'Content-Type': 'text/event-stream; charset=utf-8',
126
+ 'Cache-Control': 'no-cache, no-transform',
127
+ Connection: 'keep-alive',
128
+ } });
129
+ },
130
+ });
131
+ const timer = setInterval(tick, pollMs);
132
+ timer.unref();
133
+ return { port: server.port ?? input.port,
134
+ stats: () => ({ subscribers: subscribers.size, maxQueuedEvents }),
135
+ stop: () => {
136
+ clearInterval(timer);
137
+ for (const subscriber of subscribers) { try { subscriber.controller.close(); } catch { /* disconnected */ } }
138
+ subscribers.clear();
139
+ server.stop(true);
140
+ } };
141
+ }
@@ -8,6 +8,7 @@ export interface MessageEnvelope {
8
8
  to: string;
9
9
  body: string;
10
10
  kind?: string;
11
+ replyTo?: number;
11
12
  }
12
13
 
13
14
  export interface Message extends MessageEnvelope {
@@ -46,8 +47,16 @@ export function validateMessageEnvelope(input: unknown): MessageEnvelope {
46
47
  if (record.kind !== undefined && (typeof record.kind !== 'string' || !/^[a-z][a-z0-9_-]{0,63}$/.test(record.kind))) {
47
48
  throw new Error('invalid message kind');
48
49
  }
49
- if (Object.keys(record).some(key => !['from', 'to', 'body', 'kind'].includes(key))) throw new Error('unknown message field');
50
- return record.kind === undefined ? { from, to, body: record.body } : { from, to, body: record.body, kind: record.kind as string };
50
+ const replyTo = record.replyTo;
51
+ if (replyTo !== undefined && (typeof replyTo !== 'number' || !Number.isSafeInteger(replyTo) || replyTo < 1)) {
52
+ throw new Error('replyTo must be a positive integer message ID');
53
+ }
54
+ if (Object.keys(record).some(key => !['from', 'to', 'body', 'kind', 'replyTo'].includes(key))) throw new Error('unknown message field');
55
+ return {
56
+ from, to, body: record.body,
57
+ ...(record.kind === undefined ? {} : { kind: record.kind as string }),
58
+ ...(replyTo === undefined ? {} : { replyTo }),
59
+ };
51
60
  }
52
61
 
53
62
  function validCursor(cursor: number): number {
@@ -68,28 +77,45 @@ interface MessageRow {
68
77
  recipient: string;
69
78
  body: string;
70
79
  kind: string | null;
80
+ reply_to: number | null;
71
81
  created_at: string;
72
82
  }
73
83
 
74
84
  function fromRow(row: MessageRow): Message {
75
85
  return { id: row.id, from: row.sender, to: row.recipient, body: row.body,
76
- ...(row.kind === null ? {} : { kind: row.kind }), createdAt: row.created_at };
86
+ ...(row.kind === null ? {} : { kind: row.kind }),
87
+ ...(row.reply_to == null ? {} : { replyTo: row.reply_to }),
88
+ createdAt: row.created_at };
77
89
  }
78
90
 
79
91
  /** SQLite owns both append order and per-recipient acknowledgement progress. */
80
92
  export class MsgStore {
81
93
  readonly db: Database;
82
94
 
83
- constructor(path: string = defaultMsgStorePath()) {
84
- if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
85
- this.db = new Database(path);
95
+ // A read-only SSE connection may outlive another process's in-place migration.
96
+ private get hasReplyTo(): boolean {
97
+ return (this.db.query(`SELECT COUNT(*) AS count FROM pragma_table_info('msg_messages') WHERE name = 'reply_to'`)
98
+ .get() as { count: number }).count > 0;
99
+ }
100
+
101
+ private readWithSchema<T>(read: (hasReplyTo: boolean) => T): T {
102
+ // The schema projection and rows must share one SQLite snapshot. Otherwise a writer can
103
+ // migrate between them and a reader could advance past a reply with its link erased.
104
+ return this.db.transaction(() => read(this.hasReplyTo)).deferred();
105
+ }
106
+
107
+ constructor(path: string = defaultMsgStorePath(), options: { readOnly?: boolean } = {}) {
108
+ if (options.readOnly && (path === ':memory:' || !existsSync(path))) throw new Error('message ledger unavailable for read-only SSE service');
109
+ if (!options.readOnly && path !== ':memory:') mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
110
+ this.db = options.readOnly ? new Database(path, { readonly: true }) : new Database(path);
86
111
  this.db.exec('PRAGMA busy_timeout = 5000');
87
- this.db.exec(`CREATE TABLE IF NOT EXISTS msg_messages (
112
+ if (!options.readOnly) this.db.exec(`CREATE TABLE IF NOT EXISTS msg_messages (
88
113
  id INTEGER PRIMARY KEY AUTOINCREMENT,
89
114
  sender TEXT NOT NULL,
90
115
  recipient TEXT NOT NULL,
91
116
  body TEXT NOT NULL,
92
117
  kind TEXT,
118
+ reply_to INTEGER,
93
119
  created_at TEXT NOT NULL
94
120
  );
95
121
  CREATE INDEX IF NOT EXISTS idx_msg_messages_recipient_id ON msg_messages(recipient, id);
@@ -101,21 +127,28 @@ export class MsgStore {
101
127
  provenance_key TEXT PRIMARY KEY,
102
128
  message_id INTEGER NOT NULL REFERENCES msg_messages(id)
103
129
  );`);
130
+ // Serialize the schema check and ALTER across processes opening an old ledger at once.
131
+ if (!options.readOnly) this.db.transaction(() => {
132
+ const replyToColumn = (this.db.query(`SELECT COUNT(*) AS count FROM pragma_table_info('msg_messages') WHERE name = 'reply_to'`)
133
+ .get() as { count: number }).count;
134
+ if (replyToColumn === 0) this.db.exec('ALTER TABLE msg_messages ADD COLUMN reply_to INTEGER');
135
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_msg_messages_reply_to ON msg_messages(reply_to, id)');
136
+ }).immediate();
104
137
  }
105
138
 
106
139
  append(input: MessageEnvelope): Message {
107
140
  return this.insertMessage(input);
108
141
  }
109
142
 
110
- /** Append once for a provenance key such as coord:<commentId>:<recipient>. */
143
+ /** Append once for a coordination comment or a task failure incident. */
111
144
  appendWithProvenance(input: MessageEnvelope, provenanceKey: string): Message {
112
- if (typeof provenanceKey !== 'string' || !/^coord:[^:]+:[^:]+$/.test(provenanceKey)) {
145
+ if (typeof provenanceKey !== 'string' || !/^(?:coord|ta-failure):[^:]+:[^:]+$/.test(provenanceKey)) {
113
146
  throw new Error('invalid message provenance key');
114
147
  }
115
148
  const msg = validateMessageEnvelope(input);
116
149
  if (provenanceKey.split(':')[2] !== msg.to) throw new Error('provenance recipient does not match message recipient');
117
150
  return this.db.transaction(() => {
118
- const existing = this.db.query(`SELECT m.id, m.sender, m.recipient, m.body, m.kind, m.created_at
151
+ const existing = this.db.query(`SELECT m.id, m.sender, m.recipient, m.body, m.kind, m.reply_to, m.created_at
119
152
  FROM msg_provenance p JOIN msg_messages m ON m.id = p.message_id WHERE p.provenance_key = ?`)
120
153
  .get(provenanceKey) as MessageRow | null;
121
154
  if (existing) return fromRow(existing);
@@ -128,9 +161,12 @@ export class MsgStore {
128
161
 
129
162
  private insertMessage(input: MessageEnvelope): Message {
130
163
  const msg = validateMessageEnvelope(input);
164
+ if (msg.replyTo !== undefined && this.db.query('SELECT 1 FROM msg_messages WHERE id = ?').get(msg.replyTo) === null) {
165
+ throw new Error(`reply target not found: #${msg.replyTo}`);
166
+ }
131
167
  const createdAt = new Date().toISOString();
132
- const result = this.db.query(`INSERT INTO msg_messages(sender, recipient, body, kind, created_at)
133
- VALUES (?, ?, ?, ?, ?)`).run(msg.from, msg.to, msg.body, msg.kind ?? null, createdAt);
168
+ const result = this.db.query(`INSERT INTO msg_messages(sender, recipient, body, kind, reply_to, created_at)
169
+ VALUES (?, ?, ?, ?, ?, ?)`).run(msg.from, msg.to, msg.body, msg.kind ?? null, msg.replyTo ?? null, createdAt);
134
170
  return { id: Number(result.lastInsertRowid), ...msg, createdAt };
135
171
  }
136
172
 
@@ -142,15 +178,48 @@ export class MsgStore {
142
178
  if (to === 'ALL') throw new Error('ALL is a topic, not a recipient');
143
179
  validCursor(after);
144
180
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) throw new Error('limit must be between 1 and 1000');
145
- return (this.db.query(`SELECT id, sender, recipient, body, kind, created_at FROM msg_messages
181
+ return this.readWithSchema(hasReplyTo => (this.db.query(`SELECT id, sender, recipient, body, kind, ${hasReplyTo ? 'reply_to' : 'NULL AS reply_to'}, created_at FROM msg_messages
146
182
  WHERE id > ? AND (recipient = ? OR (recipient = 'ALL' AND sender <> ?))
147
- ORDER BY id ASC LIMIT ?`).all(after, to, to, limit) as MessageRow[]).map(fromRow);
183
+ ORDER BY id ASC LIMIT ?`).all(after, to, to, limit) as MessageRow[]).map(fromRow));
184
+ }
185
+
186
+ /** One message by durable ID, or null when absent. */
187
+ getMessage(messageId: number): Message | null {
188
+ if (!Number.isSafeInteger(messageId) || messageId < 1) throw new Error('message ID must be a positive integer');
189
+ return this.readWithSchema(hasReplyTo => {
190
+ const row = this.db.query(`SELECT id, sender, recipient, body, kind, ${hasReplyTo ? 'reply_to' : 'NULL AS reply_to'}, created_at FROM msg_messages
191
+ WHERE id = ?`).get(messageId) as MessageRow | null;
192
+ return row === null ? null : fromRow(row);
193
+ });
194
+ }
195
+
196
+ /** Replies pointing at the supplied message, ordered by durable insertion ID. */
197
+ listReplies(messageId: number, limit: number = 100, afterId: number = 0): Message[] {
198
+ if (!Number.isSafeInteger(messageId) || messageId < 1) throw new Error('message ID must be a positive integer');
199
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) throw new Error('limit must be between 1 and 1000');
200
+ validCursor(afterId);
201
+ return this.readWithSchema(hasReplyTo => hasReplyTo
202
+ ? (this.db.query(`SELECT id, sender, recipient, body, kind, reply_to, created_at FROM msg_messages
203
+ WHERE reply_to = ? AND id > ? ORDER BY id ASC LIMIT ?`).all(messageId, afterId, limit) as MessageRow[]).map(fromRow)
204
+ : []);
148
205
  }
149
206
 
150
207
  list(recipient: string, after: number = 0, limit: number = 100): Message[] {
151
208
  return this.listByRecipient(recipient, after, limit);
152
209
  }
153
210
 
211
+ /** One ordered scan for every SSE subscriber; recipient filtering happens in the server. */
212
+ listAfter(after: number, limit: number = 1000): Message[] {
213
+ validCursor(after);
214
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) throw new Error('limit must be between 1 and 1000');
215
+ return this.readWithSchema(hasReplyTo => (this.db.query(`SELECT id, sender, recipient, body, kind, ${hasReplyTo ? 'reply_to' : 'NULL AS reply_to'}, created_at FROM msg_messages
216
+ WHERE id > ? ORDER BY id ASC LIMIT ?`).all(after, limit) as MessageRow[]).map(fromRow));
217
+ }
218
+
219
+ latestId(): number {
220
+ return (this.db.query('SELECT COALESCE(MAX(id), 0) AS id FROM msg_messages').get() as { id: number }).id;
221
+ }
222
+
154
223
  getCursor(recipient: string): number {
155
224
  const to = canonicalSeatId(recipient);
156
225
  if (to === 'ALL') throw new Error('ALL is a topic, not a recipient');
@@ -199,6 +268,7 @@ export class MsgStore {
199
268
 
200
269
  export function defaultMsgStorePath(): string { return join(elanousStateRoot(), 'msg', 'messages.db'); }
201
270
  export function openMsgStore(path?: string): MsgStore { return new MsgStore(path); }
271
+ export function openMsgStoreReadOnly(path?: string): MsgStore { return new MsgStore(path, { readOnly: true }); }
202
272
 
203
273
  /** Observe one recipient without creating a store or advancing its acknowledgement. */
204
274
  export function readUnreadCount(recipient: string, path: string = defaultMsgStorePath()): number | null {