elanous 0.2.19 → 0.2.20

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 (388) 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/{J5H7BvqgSI8DUjtRRcA_S → Bam1YziK2iH9bN4K0utGk}/_buildManifest.js +1 -1
  4. package/apps/pwa/out/_next/static/chunks/{4323.c5dfb6f503304775.js → 1268.0840ff91e083fcbd.js} +1 -1
  5. package/apps/pwa/out/_next/static/chunks/1307-77fad80cf54477c6.js +1 -0
  6. package/apps/pwa/out/_next/static/chunks/1612-f60c0c93ec773f81.js +1 -0
  7. package/apps/pwa/out/_next/static/chunks/1826-5d279da64bbbc2de.js +1 -0
  8. package/apps/pwa/out/_next/static/chunks/1844-9ec4b1d12f6e80eb.js +1 -0
  9. package/apps/pwa/out/_next/static/chunks/1866-e7a6999f25548b30.js +1 -0
  10. package/apps/pwa/out/_next/static/chunks/1948.15f356b15a625597.js +5 -0
  11. package/apps/pwa/out/_next/static/chunks/195-fb1d7690bf30bab4.js +1 -0
  12. package/apps/pwa/out/_next/static/chunks/1986-33e13d648a9df765.js +1 -0
  13. package/apps/pwa/out/_next/static/chunks/{2977-e38442e86289f1d3.js → 211-9c9090c59e2c5907.js} +1 -1
  14. package/apps/pwa/out/_next/static/chunks/2116-6429c188eb846ab8.js +1 -0
  15. package/apps/pwa/out/_next/static/chunks/2196-4700653d12a44b40.js +1 -0
  16. package/apps/pwa/out/_next/static/chunks/224-e7be8f762fe74986.js +1 -0
  17. package/apps/pwa/out/_next/static/chunks/2489-ff55a35b202c0f94.js +1 -0
  18. package/apps/pwa/out/_next/static/chunks/2537-6c289376caa88ca1.js +1 -0
  19. package/apps/pwa/out/_next/static/chunks/2549-89c518a4c8ed4d00.js +1 -0
  20. package/apps/pwa/out/_next/static/chunks/2699-325cc923ba7b8631.js +1 -0
  21. package/apps/pwa/out/_next/static/chunks/2957-96ffab3d521fb08f.js +1 -0
  22. package/apps/pwa/out/_next/static/chunks/311.e685738f92e6e2ec.js +1 -0
  23. package/apps/pwa/out/_next/static/chunks/3194-807b212fb8eae065.js +1 -0
  24. package/apps/pwa/out/_next/static/chunks/3324-d1d2979821dfa845.js +1 -0
  25. package/apps/pwa/out/_next/static/chunks/3558-bbb5ab553a0471d3.js +1 -0
  26. package/apps/pwa/out/_next/static/chunks/3857-995ea9f5ef33d71e.js +1 -0
  27. package/apps/pwa/out/_next/static/chunks/3916-3d02dd1fc257dd3c.js +1 -0
  28. package/apps/pwa/out/_next/static/chunks/4144-fad095b48a1862ee.js +1 -0
  29. package/apps/pwa/out/_next/static/chunks/4565-3d7e3fdeb43686a8.js +1 -0
  30. package/apps/pwa/out/_next/static/chunks/4795-f372490b178583fc.js +1 -0
  31. package/apps/pwa/out/_next/static/chunks/5360-be1f1d8f9fcc9755.js +1 -0
  32. package/apps/pwa/out/_next/static/chunks/5755-ea7ed88eb956f47b.js +1 -0
  33. package/apps/pwa/out/_next/static/chunks/5977-94ed6f78e9814b2b.js +1 -0
  34. package/apps/pwa/out/_next/static/chunks/{4847.56f5bc454fbcc827.js → 6110.1297d4093fdfbb0d.js} +2 -2
  35. package/apps/pwa/out/_next/static/chunks/6158.af2e93b7adff709b.js +2 -0
  36. package/apps/pwa/out/_next/static/chunks/6877-74399f69c7da1433.js +1 -0
  37. package/apps/pwa/out/_next/static/chunks/{1470.98a6ab61ccb4131c.js → 726.83532d69452ee24b.js} +1 -1
  38. package/apps/pwa/out/_next/static/chunks/7290-f09c928a06c52e2d.js +1 -0
  39. package/apps/pwa/out/_next/static/chunks/{6062-73e68343a9814f6a.js → 7327-2d69fb321ba73389.js} +1 -1
  40. package/apps/pwa/out/_next/static/chunks/{8870-9614a213b990b10b.js → 7748-6e51bfe927019f5c.js} +3 -3
  41. package/apps/pwa/out/_next/static/chunks/7797-df5aa0381fdc078f.js +1 -0
  42. package/apps/pwa/out/_next/static/chunks/8060-cc008cc6fedfa818.js +1 -0
  43. package/apps/pwa/out/_next/static/chunks/8085-d065d0393c1760f5.js +1 -0
  44. package/apps/pwa/out/_next/static/chunks/8401-35e94e9091f5eb8d.js +1 -0
  45. package/apps/pwa/out/_next/static/chunks/8682-7061dd4fa7bd62ed.js +1 -0
  46. package/apps/pwa/out/_next/static/chunks/8695-846034ce44852c70.js +1 -0
  47. package/apps/pwa/out/_next/static/chunks/9070.067c106528ec1ae6.js +1 -0
  48. package/apps/pwa/out/_next/static/chunks/9103-60fbef33e66f0a46.js +1 -0
  49. package/apps/pwa/out/_next/static/chunks/9247-c1753a059e7bac08.js +1 -0
  50. package/apps/pwa/out/_next/static/chunks/{7647.2fcd30d894d5ee30.js → 935.4ddaa99fc1d1126c.js} +1 -1
  51. package/apps/pwa/out/_next/static/chunks/{ae787e4c-e5b1ef00b2dbceca.js → a9f2b54d-33c0689b70ed8cf3.js} +1 -1
  52. package/apps/pwa/out/_next/static/chunks/app/_not-found/page-c1a96669158471ec.js +1 -0
  53. package/apps/pwa/out/_next/static/chunks/app/approvals/page-c855c24de7db74fe.js +1 -0
  54. package/apps/pwa/out/_next/static/chunks/app/autopilot/page-85b316bb4235980b.js +1 -0
  55. package/apps/pwa/out/_next/static/chunks/app/board/page-9b2b26a33cb2cd42.js +1 -0
  56. package/apps/pwa/out/_next/static/chunks/app/botlab/page-c9955fde67b1bcb5.js +1 -0
  57. package/apps/pwa/out/_next/static/chunks/app/bots/page-7a852956c74015a3.js +1 -0
  58. package/apps/pwa/out/_next/static/chunks/app/ceo/page-c5a5c621be0e04ae.js +1 -0
  59. package/apps/pwa/out/_next/static/chunks/app/chat/page-4ba4abe096b6c2a8.js +1 -0
  60. package/apps/pwa/out/_next/static/chunks/app/consult/page-575d4a8b4f7becb2.js +1 -0
  61. package/apps/pwa/out/_next/static/chunks/app/control/page-f5d56ff5f45ad164.js +1 -0
  62. package/apps/pwa/out/_next/static/chunks/app/decisions/page-2260e9eef0357421.js +1 -0
  63. package/apps/pwa/out/_next/static/chunks/app/design-check/page-7c058069c5e7fa63.js +1 -0
  64. package/apps/pwa/out/_next/static/chunks/app/editor/page-dc5da0b8a2edb8a4.js +1 -0
  65. package/apps/pwa/out/_next/static/chunks/app/exec/page-466bf0ee16ad1432.js +1 -0
  66. package/apps/pwa/out/_next/static/chunks/app/field/page-a23bc71713bcc08f.js +1 -0
  67. package/apps/pwa/out/_next/static/chunks/app/healthz/route-c1a96669158471ec.js +1 -0
  68. package/apps/pwa/out/_next/static/chunks/app/inside/{page-5b29d9b06ceadade.js → page-ed4a6f799f005d09.js} +1 -1
  69. package/apps/pwa/out/_next/static/chunks/app/intake/page-9cd565005b93aca6.js +1 -0
  70. package/apps/pwa/out/_next/static/chunks/app/layout-55f0f40c6db95f15.js +1 -0
  71. package/apps/pwa/out/_next/static/chunks/app/live/page-474cb5888ea0e8f3.js +1 -0
  72. package/apps/pwa/out/_next/static/chunks/app/loops/page-35bfec2e631d3eae.js +1 -0
  73. package/apps/pwa/out/_next/static/chunks/app/market/page-9ca21f9ad3208a61.js +1 -0
  74. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-f9ccb1b1adbea14d.js +1 -0
  75. package/apps/pwa/out/_next/static/chunks/app/morning/page-ec102c9b8b691e37.js +1 -0
  76. package/apps/pwa/out/_next/static/chunks/app/not-found-a9c2cfa8dda922a5.js +1 -0
  77. package/apps/pwa/out/_next/static/chunks/app/observatory/page-6da35144878feb6d.js +1 -0
  78. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-f6055495e9ed7ea8.js +1 -0
  79. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-25204694fa9984a5.js +1 -0
  80. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-a60e035f151d84d3.js +1 -0
  81. package/apps/pwa/out/_next/static/chunks/app/outputs/page-189c587f36874538.js +1 -0
  82. package/apps/pwa/out/_next/static/chunks/app/page-c56634bde705a65d.js +1 -0
  83. package/apps/pwa/out/_next/static/chunks/app/reflection/page-db03ae3589a81c9e.js +1 -0
  84. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-4a6463b97f0ad94f.js +1 -0
  85. package/apps/pwa/out/_next/static/chunks/app/sessions/page-75bffecc9aa1004e.js +1 -0
  86. package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-a8d2fe0c669e0de4.js +1 -0
  87. package/apps/pwa/out/_next/static/chunks/app/settings/page-31d5b875760bb62d.js +1 -0
  88. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-c26fa10aac6c21c4.js +1 -0
  89. package/apps/pwa/out/_next/static/chunks/app/setup/layout-c1a96669158471ec.js +1 -0
  90. package/apps/pwa/out/_next/static/chunks/app/setup/page-68793c6e10331d66.js +1 -0
  91. package/apps/pwa/out/_next/static/chunks/app/share/page-46d5201d2207a511.js +1 -0
  92. package/apps/pwa/out/_next/static/chunks/app/showroom/page-d81c07a7b4f618ae.js +1 -0
  93. package/apps/pwa/out/_next/static/chunks/app/tasks/page-be1c4e8697f22360.js +1 -0
  94. package/apps/pwa/out/_next/static/chunks/app/term/page-cf0a4b4abead71ea.js +1 -0
  95. package/apps/pwa/out/_next/static/chunks/app/today/page-a4717e2c4bf7a28f.js +1 -0
  96. package/apps/pwa/out/_next/static/chunks/app/trace/page-0fd895fab9b62c1b.js +1 -0
  97. package/apps/pwa/out/_next/static/chunks/app/vault/page-8b6c1454a8e8f306.js +1 -0
  98. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-c6e4364d9de8f717.js +1 -0
  99. package/apps/pwa/out/_next/static/chunks/app/workflows/page-0acc8e198f7b0dfb.js +1 -0
  100. package/apps/pwa/out/_next/static/chunks/app/workspace/page-34882c84f84021c5.js +1 -0
  101. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-0506a1e0e4bdb2f5.js +1 -0
  102. package/apps/pwa/out/_next/static/chunks/{8046cadf-211ca3c0e00f2b72.js → f6231284-45726550aba08fca.js} +1 -1
  103. package/apps/pwa/out/_next/static/chunks/{2ea96958.e8c7e112de50b067.js → f6b7c9ba.64a895b8b2fedbdc.js} +1 -1
  104. package/apps/pwa/out/_next/static/chunks/framework-5c169757551134ed.js +1 -0
  105. package/apps/pwa/out/_next/static/chunks/main-59fe95fed40c7a01.js +1 -0
  106. package/apps/pwa/out/_next/static/chunks/main-app-4ca74dbbee7057c6.js +1 -0
  107. package/apps/pwa/out/_next/static/chunks/pages/_app-13a0a886df4d97a1.js +1 -0
  108. package/apps/pwa/out/_next/static/chunks/pages/_error-6af8591cd3a3f038.js +1 -0
  109. package/apps/pwa/out/_next/static/chunks/webpack-c260571513bf43c2.js +1 -0
  110. package/apps/pwa/out/_next/static/css/c23d959d3ee15e57.css +1 -0
  111. package/apps/pwa/out/approvals/index.html +1 -1
  112. package/apps/pwa/out/approvals/index.txt +23 -23
  113. package/apps/pwa/out/autopilot/index.html +1 -1
  114. package/apps/pwa/out/autopilot/index.txt +23 -23
  115. package/apps/pwa/out/board/index.html +1 -1
  116. package/apps/pwa/out/board/index.txt +23 -23
  117. package/apps/pwa/out/botlab/index.html +1 -1
  118. package/apps/pwa/out/botlab/index.txt +22 -22
  119. package/apps/pwa/out/bots/index.html +1 -1
  120. package/apps/pwa/out/bots/index.txt +22 -22
  121. package/apps/pwa/out/ceo/index.html +1 -1
  122. package/apps/pwa/out/ceo/index.txt +23 -23
  123. package/apps/pwa/out/chat/index.html +1 -1
  124. package/apps/pwa/out/chat/index.txt +22 -22
  125. package/apps/pwa/out/consult/index.html +1 -1
  126. package/apps/pwa/out/consult/index.txt +23 -23
  127. package/apps/pwa/out/control/index.html +1 -1
  128. package/apps/pwa/out/control/index.txt +23 -23
  129. package/apps/pwa/out/decisions/index.html +1 -1
  130. package/apps/pwa/out/decisions/index.txt +22 -22
  131. package/apps/pwa/out/design-check/index.html +1 -1
  132. package/apps/pwa/out/design-check/index.txt +23 -23
  133. package/apps/pwa/out/editor/index.html +1 -1
  134. package/apps/pwa/out/editor/index.txt +23 -23
  135. package/apps/pwa/out/exec/index.html +1 -1
  136. package/apps/pwa/out/exec/index.txt +23 -23
  137. package/apps/pwa/out/field/index.html +1 -1
  138. package/apps/pwa/out/field/index.txt +23 -23
  139. package/apps/pwa/out/healthz +1 -1
  140. package/apps/pwa/out/index.html +1 -1
  141. package/apps/pwa/out/index.txt +22 -22
  142. package/apps/pwa/out/inside/index.html +1 -1
  143. package/apps/pwa/out/inside/index.txt +22 -22
  144. package/apps/pwa/out/intake/index.html +1 -1
  145. package/apps/pwa/out/intake/index.txt +23 -23
  146. package/apps/pwa/out/live/index.html +1 -1
  147. package/apps/pwa/out/live/index.txt +23 -23
  148. package/apps/pwa/out/loops/index.html +1 -1
  149. package/apps/pwa/out/loops/index.txt +22 -22
  150. package/apps/pwa/out/market/index.html +1 -1
  151. package/apps/pwa/out/market/index.txt +22 -22
  152. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  153. package/apps/pwa/out/missions/placeholder/index.txt +22 -22
  154. package/apps/pwa/out/morning/index.html +1 -1
  155. package/apps/pwa/out/morning/index.txt +23 -23
  156. package/apps/pwa/out/observatory/index.html +1 -1
  157. package/apps/pwa/out/observatory/index.txt +23 -23
  158. package/apps/pwa/out/ops/checklist/index.html +1 -1
  159. package/apps/pwa/out/ops/checklist/index.txt +22 -22
  160. package/apps/pwa/out/ops/release/index.html +1 -1
  161. package/apps/pwa/out/ops/release/index.txt +22 -22
  162. package/apps/pwa/out/ops/seats/index.html +1 -1
  163. package/apps/pwa/out/ops/seats/index.txt +22 -22
  164. package/apps/pwa/out/outputs/index.html +1 -1
  165. package/apps/pwa/out/outputs/index.txt +22 -22
  166. package/apps/pwa/out/reflection/index.html +1 -1
  167. package/apps/pwa/out/reflection/index.txt +23 -23
  168. package/apps/pwa/out/scheduler/index.html +1 -1
  169. package/apps/pwa/out/scheduler/index.txt +23 -23
  170. package/apps/pwa/out/sessions/index.html +1 -1
  171. package/apps/pwa/out/sessions/index.txt +22 -22
  172. package/apps/pwa/out/settings/devices/index.html +1 -1
  173. package/apps/pwa/out/settings/devices/index.txt +23 -23
  174. package/apps/pwa/out/settings/index.html +1 -1
  175. package/apps/pwa/out/settings/index.txt +23 -23
  176. package/apps/pwa/out/setup/done/index.html +1 -1
  177. package/apps/pwa/out/setup/done/index.txt +23 -23
  178. package/apps/pwa/out/setup/index.html +1 -1
  179. package/apps/pwa/out/setup/index.txt +23 -23
  180. package/apps/pwa/out/share/index.html +1 -1
  181. package/apps/pwa/out/share/index.txt +23 -23
  182. package/apps/pwa/out/showroom/index.html +1 -1
  183. package/apps/pwa/out/showroom/index.txt +22 -22
  184. package/apps/pwa/out/tasks/index.html +1 -1
  185. package/apps/pwa/out/tasks/index.txt +23 -23
  186. package/apps/pwa/out/term/index.html +1 -1
  187. package/apps/pwa/out/term/index.txt +23 -23
  188. package/apps/pwa/out/today/index.html +1 -1
  189. package/apps/pwa/out/today/index.txt +23 -23
  190. package/apps/pwa/out/trace/index.html +1 -1
  191. package/apps/pwa/out/trace/index.txt +23 -23
  192. package/apps/pwa/out/vault/index.html +1 -1
  193. package/apps/pwa/out/vault/index.txt +23 -23
  194. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  195. package/apps/pwa/out/workflows/chat-ui/index.txt +23 -23
  196. package/apps/pwa/out/workflows/index.html +1 -1
  197. package/apps/pwa/out/workflows/index.txt +23 -23
  198. package/apps/pwa/out/workspace/index.html +1 -1
  199. package/apps/pwa/out/workspace/index.txt +23 -23
  200. package/apps/pwa/out/worktrees/index.html +1 -1
  201. package/apps/pwa/out/worktrees/index.txt +23 -23
  202. package/graphs/demo/editor-recipes.yaml +22 -0
  203. package/graphs/retro/daily.yaml +25 -0
  204. package/graphs/retro/recipes.yaml +24 -0
  205. package/package.json +1 -1
  206. package/scripts/ask-marker-check.ts +3 -1
  207. package/scripts/land-global-checks.ts +89 -0
  208. package/scripts/lib/pch-regress-map.ts +2 -1
  209. package/scripts/release-loop/gate-node.ts +296 -25
  210. package/scripts/release-loop/gate-timing.ts +116 -7
  211. package/scripts/release-loop/preflight.ts +6 -4
  212. package/scripts/release-loop/publish-window.ts +141 -0
  213. package/scripts/release-loop/release-docs.ts +1 -4
  214. package/scripts/release-loop/release-universe.ts +38 -0
  215. package/scripts/release-loop/resume-release.ts +4 -1
  216. package/scripts/release-loop/unattended-release.ts +10 -2
  217. package/scripts/release-story/draft.ts +5 -1
  218. package/scripts/retro/shadow-compare.ts +71 -0
  219. package/scripts/rhythm/daily.ts +6 -1
  220. package/src/agent-mission/driver.ts +34 -6
  221. package/src/autopilot/build/llm-conflict-merge.ts +132 -7
  222. package/src/autopilot/build/merge-intent.ts +135 -7
  223. package/src/away/release-watch.ts +20 -3
  224. package/src/cli/config-drift.ts +513 -0
  225. package/src/cli/log-event-names.ts +4 -0
  226. package/src/cli/pr-cli.ts +20 -0
  227. package/src/cli/release-cli.ts +20 -5
  228. package/src/cli/retro-cli.ts +78 -0
  229. package/src/cli/tasks-cli.ts +94 -8
  230. package/src/dashboard/slash-runtime/dashboard-handlers.ts +5 -4
  231. package/src/dashboard/slash-runtime/help-from-registry.ts +57 -31
  232. package/src/domains/outbound-log-probe.ts +6 -1
  233. package/src/graph-runner/graph-run-api.ts +205 -0
  234. package/src/graph-runner/runner.ts +11 -1
  235. package/src/harness/harness-cli-command.ts +419 -52
  236. package/src/harness/harness-queue.ts +315 -25
  237. package/src/harness/harness-salvage-cli.ts +62 -0
  238. package/src/hq/fence-outcomes.ts +63 -0
  239. package/src/hq/fence-wrapper.ts +33 -0
  240. package/src/hq/hq.ts +11 -1
  241. package/src/index.ts +69 -6
  242. package/src/maturity/feature-maturity.ts +2 -2
  243. package/src/nexus/api/graph-kinds.ts +10 -4
  244. package/src/nexus/api/graphs-api.ts +178 -6
  245. package/src/nexus/api/http-server.ts +38 -3
  246. package/src/nexus/api/loop-edges.ts +4 -2
  247. package/src/nexus/api/ops-api.ts +22 -7
  248. package/src/nexus/api/ops-board-route.ts +48 -0
  249. package/src/onboarding/entry-hints.ts +5 -0
  250. package/src/onboarding.ts +24 -6
  251. package/src/release-loop/feature-store.ts +14 -11
  252. package/src/release-loop/gate-shards.ts +158 -0
  253. package/src/release-loop/release-schedule.ts +6 -2
  254. package/src/retro/daily.ts +372 -0
  255. package/src/seat-loop/op-decide.ts +173 -0
  256. package/src/seat-loop/seat-loop.ts +60 -3
  257. package/src/seat-loop/seat-report.ts +58 -3
  258. package/src/self-dev/ask-launch-flow.ts +5 -4
  259. package/src/self-dev/ask-launch-io.ts +49 -1
  260. package/src/self-dev/ask-path-candidates.ts +40 -2
  261. package/src/self-dev/draft-sweep.ts +371 -16
  262. package/src/self-dev/draft-triage-rules.ts +19 -4
  263. package/src/self-dev/pr-preview.ts +84 -16
  264. package/src/self-implement/blocked-draft-policy.ts +101 -0
  265. package/src/self-implement/execution-clarification.ts +11 -0
  266. package/src/self-implement/gate-env-retry.ts +83 -0
  267. package/src/self-implement/orchestrator.ts +218 -5
  268. package/src/self-implement/run-stop.ts +123 -0
  269. package/src/self-implement/run-ttl.ts +258 -0
  270. package/src/self-implement/salvage-retention.ts +123 -0
  271. package/src/self-implement/seams.ts +4 -0
  272. package/src/self-implement/self-implement-cli.ts +2 -0
  273. package/src/self-implement/sibling-resync.ts +223 -0
  274. package/src/self-implement/stop-autoheal.ts +204 -0
  275. package/src/task-agent/board.ts +1 -1
  276. package/src/task-agent/card-evidence.ts +99 -10
  277. package/src/task-agent/judge.ts +7 -0
  278. package/src/task-agent/live-moves.ts +120 -20
  279. package/src/task-agent/mission.ts +1 -1
  280. package/src/task-agent/shadow.ts +10 -1
  281. package/src/task-agent/task-hand.ts +7 -2
  282. package/src/task-orchestrator/surfaces/pod-command-job.ts +19 -5
  283. package/src/task-orchestrator/surfaces/pod-failure-reason.ts +4 -0
  284. package/src/task-orchestrator/surfaces/pod-install-slots.ts +89 -0
  285. package/src/task-orchestrator/surfaces/pod-lease.ts +93 -10
  286. package/src/task-orchestrator/surfaces/pod-pool.ts +147 -34
  287. package/src/task-orchestrator/surfaces/self-implement-pod.ts +19 -3
  288. package/src/telegram-commands.ts +41 -0
  289. package/src/testing/real-cli-spawn.ts +106 -0
  290. package/src/user-config.ts +56 -3
  291. package/src/version/packed-revision.json +1 -1
  292. package/apps/pwa/out/_next/static/chunks/1105-c473ed46272fba79.js +0 -1
  293. package/apps/pwa/out/_next/static/chunks/1157-cba0e8553d35a980.js +0 -1
  294. package/apps/pwa/out/_next/static/chunks/171-23a877ed11720de9.js +0 -1
  295. package/apps/pwa/out/_next/static/chunks/216-5acd01cbde4acda7.js +0 -1
  296. package/apps/pwa/out/_next/static/chunks/2330-c2f07246c8788af1.js +0 -1
  297. package/apps/pwa/out/_next/static/chunks/2336-e830c777eb6bce04.js +0 -1
  298. package/apps/pwa/out/_next/static/chunks/2375-e061bb24cd2b5cde.js +0 -1
  299. package/apps/pwa/out/_next/static/chunks/2415.ed6428ff334c7ce2.js +0 -1
  300. package/apps/pwa/out/_next/static/chunks/3094.67339318c7f72a3a.js +0 -2
  301. package/apps/pwa/out/_next/static/chunks/338-ceb956612dcfaa86.js +0 -1
  302. package/apps/pwa/out/_next/static/chunks/3395-835e4ceaa6335ba4.js +0 -1
  303. package/apps/pwa/out/_next/static/chunks/3403.4bc97b4066945a94.js +0 -5
  304. package/apps/pwa/out/_next/static/chunks/3441-be82e3595aef3a20.js +0 -1
  305. package/apps/pwa/out/_next/static/chunks/3689-89905039bf3e7257.js +0 -1
  306. package/apps/pwa/out/_next/static/chunks/371-4ad6af55dad02ced.js +0 -1
  307. package/apps/pwa/out/_next/static/chunks/3822-6f268bc26c6a84ac.js +0 -1
  308. package/apps/pwa/out/_next/static/chunks/4114-3e63117452378807.js +0 -1
  309. package/apps/pwa/out/_next/static/chunks/4457-d7df81c90f9d0268.js +0 -1
  310. package/apps/pwa/out/_next/static/chunks/4551-77dadae76f6cbace.js +0 -1
  311. package/apps/pwa/out/_next/static/chunks/4978.6fd93b974441e87f.js +0 -1
  312. package/apps/pwa/out/_next/static/chunks/5012-f746f6549b869f91.js +0 -1
  313. package/apps/pwa/out/_next/static/chunks/5609-012bb3557211f07e.js +0 -1
  314. package/apps/pwa/out/_next/static/chunks/5791-bb82743fa6299562.js +0 -1
  315. package/apps/pwa/out/_next/static/chunks/62-5bfed1e23790b745.js +0 -1
  316. package/apps/pwa/out/_next/static/chunks/6320-9bc8561fde416e07.js +0 -1
  317. package/apps/pwa/out/_next/static/chunks/649-ca96d1c5d09e02b8.js +0 -1
  318. package/apps/pwa/out/_next/static/chunks/6725-68ab7cc0b1faf00d.js +0 -1
  319. package/apps/pwa/out/_next/static/chunks/6726-85a7db59b288c53f.js +0 -1
  320. package/apps/pwa/out/_next/static/chunks/6735-33267c4630d0d963.js +0 -1
  321. package/apps/pwa/out/_next/static/chunks/6998-a654fe5c58271600.js +0 -1
  322. package/apps/pwa/out/_next/static/chunks/7633-141682015873beb6.js +0 -1
  323. package/apps/pwa/out/_next/static/chunks/7673-2eda1507d38dadb8.js +0 -1
  324. package/apps/pwa/out/_next/static/chunks/7990-e12efba616f46e03.js +0 -1
  325. package/apps/pwa/out/_next/static/chunks/8067-143791231c99bd02.js +0 -1
  326. package/apps/pwa/out/_next/static/chunks/8142-744998d65d7bb17e.js +0 -1
  327. package/apps/pwa/out/_next/static/chunks/8430-a10cfba8ef282d4e.js +0 -1
  328. package/apps/pwa/out/_next/static/chunks/920-f7dceb78f1c585ef.js +0 -1
  329. package/apps/pwa/out/_next/static/chunks/9454-12c2f30dea62291c.js +0 -1
  330. package/apps/pwa/out/_next/static/chunks/976-4a9b4ebdca7c739a.js +0 -1
  331. package/apps/pwa/out/_next/static/chunks/9989-15cdf568bafa9fc8.js +0 -1
  332. package/apps/pwa/out/_next/static/chunks/app/_not-found/page-859cea1570b6227a.js +0 -1
  333. package/apps/pwa/out/_next/static/chunks/app/approvals/page-deef5ee191830f14.js +0 -1
  334. package/apps/pwa/out/_next/static/chunks/app/autopilot/page-b193da485f9a14b2.js +0 -1
  335. package/apps/pwa/out/_next/static/chunks/app/board/page-b6e1a79df0e214f4.js +0 -1
  336. package/apps/pwa/out/_next/static/chunks/app/botlab/page-e84df4d907f703c7.js +0 -1
  337. package/apps/pwa/out/_next/static/chunks/app/bots/page-a5fb9a5db89ae4ea.js +0 -1
  338. package/apps/pwa/out/_next/static/chunks/app/ceo/page-1499f481d7fc52d6.js +0 -1
  339. package/apps/pwa/out/_next/static/chunks/app/chat/page-c7cf753aeb648790.js +0 -1
  340. package/apps/pwa/out/_next/static/chunks/app/consult/page-4118469e514e10fd.js +0 -1
  341. package/apps/pwa/out/_next/static/chunks/app/control/page-e968f7630654332a.js +0 -1
  342. package/apps/pwa/out/_next/static/chunks/app/decisions/page-505915052f947022.js +0 -1
  343. package/apps/pwa/out/_next/static/chunks/app/design-check/page-c2f962fb4a3b06c6.js +0 -1
  344. package/apps/pwa/out/_next/static/chunks/app/editor/page-7b5ed0bd8a589ec4.js +0 -1
  345. package/apps/pwa/out/_next/static/chunks/app/exec/page-cf8193250dd42dd4.js +0 -1
  346. package/apps/pwa/out/_next/static/chunks/app/field/page-cf3f5dfa35dea8e9.js +0 -1
  347. package/apps/pwa/out/_next/static/chunks/app/healthz/route-859cea1570b6227a.js +0 -1
  348. package/apps/pwa/out/_next/static/chunks/app/intake/page-a15e7fcc283e9ca7.js +0 -1
  349. package/apps/pwa/out/_next/static/chunks/app/layout-d2915e7b70ff3d0e.js +0 -1
  350. package/apps/pwa/out/_next/static/chunks/app/live/page-b70cd005d9169e25.js +0 -1
  351. package/apps/pwa/out/_next/static/chunks/app/loops/page-2595f0eba29f57e1.js +0 -1
  352. package/apps/pwa/out/_next/static/chunks/app/market/page-c776322454156e36.js +0 -1
  353. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-77bbd17f4435aa10.js +0 -1
  354. package/apps/pwa/out/_next/static/chunks/app/morning/page-049689e611ecc936.js +0 -1
  355. package/apps/pwa/out/_next/static/chunks/app/not-found-85954a4fb09c44eb.js +0 -1
  356. package/apps/pwa/out/_next/static/chunks/app/observatory/page-e773e552e1068ee6.js +0 -1
  357. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-5633c4bfa7684b4a.js +0 -1
  358. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-d829b92bd01b4994.js +0 -1
  359. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-061c317fc771becf.js +0 -1
  360. package/apps/pwa/out/_next/static/chunks/app/outputs/page-3a45ece8865ed4b6.js +0 -1
  361. package/apps/pwa/out/_next/static/chunks/app/page-0be990618c0d56bd.js +0 -1
  362. package/apps/pwa/out/_next/static/chunks/app/reflection/page-bfea4865dc244b73.js +0 -1
  363. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-0d6ba8e337741484.js +0 -1
  364. package/apps/pwa/out/_next/static/chunks/app/sessions/page-76a88b55d816482e.js +0 -1
  365. package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-bba1542df59f61ce.js +0 -1
  366. package/apps/pwa/out/_next/static/chunks/app/settings/page-4185c95689bb372e.js +0 -1
  367. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-b699e1a8d5d34b8b.js +0 -1
  368. package/apps/pwa/out/_next/static/chunks/app/setup/layout-859cea1570b6227a.js +0 -1
  369. package/apps/pwa/out/_next/static/chunks/app/setup/page-c225e338c31bf274.js +0 -1
  370. package/apps/pwa/out/_next/static/chunks/app/share/page-449353f70507ca1f.js +0 -1
  371. package/apps/pwa/out/_next/static/chunks/app/showroom/page-90b889714102476d.js +0 -1
  372. package/apps/pwa/out/_next/static/chunks/app/tasks/page-f1ee7fbeb7f96ddc.js +0 -1
  373. package/apps/pwa/out/_next/static/chunks/app/term/page-d59a95e1434dfaa0.js +0 -1
  374. package/apps/pwa/out/_next/static/chunks/app/today/page-71491a23414d1a47.js +0 -1
  375. package/apps/pwa/out/_next/static/chunks/app/trace/page-de7112bdb65fa4fe.js +0 -1
  376. package/apps/pwa/out/_next/static/chunks/app/vault/page-feb4d0d88079660d.js +0 -1
  377. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-f17d887b40dd6b1d.js +0 -1
  378. package/apps/pwa/out/_next/static/chunks/app/workflows/page-8da77be2192bcd5d.js +0 -1
  379. package/apps/pwa/out/_next/static/chunks/app/workspace/page-72c3b9a5e9582b29.js +0 -1
  380. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-c3afe4c8d0c48c39.js +0 -1
  381. package/apps/pwa/out/_next/static/chunks/framework-ea806bb57f2b8923.js +0 -1
  382. package/apps/pwa/out/_next/static/chunks/main-8d2302399c2c8f87.js +0 -1
  383. package/apps/pwa/out/_next/static/chunks/main-app-b9308f2d8a931531.js +0 -1
  384. package/apps/pwa/out/_next/static/chunks/pages/_app-b10928c60f0b8488.js +0 -1
  385. package/apps/pwa/out/_next/static/chunks/pages/_error-689039227d443fab.js +0 -1
  386. package/apps/pwa/out/_next/static/chunks/webpack-6f35f9d892fa630b.js +0 -1
  387. package/apps/pwa/out/_next/static/css/85f53c6f35d11382.css +0 -1
  388. /package/apps/pwa/out/_next/static/{J5H7BvqgSI8DUjtRRcA_S → Bam1YziK2iH9bN4K0utGk}/_ssgManifest.js +0 -0
@@ -9,6 +9,8 @@
9
9
  * - @ssh호스트 원격 클러스터면 그 기계 — 이미지 판 대조·반입(`docker load` ⊕ `k3d image import`)에 쓴다
10
10
  * - 상한 이 노드에 동시에 둘 Job 수(기본 2)
11
11
  * Job 마다 «빈 자리 비율»이 가장 큰 노드를 고른다(동률은 앞 순위 · 측정 실패 노드는 측정된 노드가 다 찼을 때만). 다 차면 자리가 날 때까지 기다린다.
12
+ * POOL-LITE(10-07): 골 종류를 주면 lite·문서·측정 골은 상한이 가장 작은 노드(node-c), 구현 골은 가장 큰 노드(node-b)를 먼저 쓰고,
13
+ * 그 노드가 차면 빈 자리 비율로 넘친다. 상한 0 노드는 «꺼짐» — 해석에서 빠지고 절대 고르지 않는다.
12
14
  * 노드 선택의 진행 중 수는 로컬 프로세스 기준이다. 발사 허가는 별도의 lease 측정으로
13
15
  * 다른 호스트의 Running/Pending 및 메모리 예약까지 고려한다.
14
16
  * 풀을 안 주면 종전처럼 «현재 컨텍스트» 하나다(동작 불변).
@@ -21,7 +23,7 @@ import { debug } from '../../debug/log.js';
21
23
  import { PodLeaseAdmission, type PodLeasePredecessor, type PodLeaseRelease } from '../../pod-lease/admission.js';
22
24
  import { predecessorState, type PredecessorState } from '../../pod-lease/dependency-state.js';
23
25
  import { HostPoolLease, leaseHasPendingPod } from '../../pod-lease/host-lease.js';
24
- import { measurePoolLease, recommendConcurrency, type PodLeaseMember, type PoolDnsProbe, type PoolLeaseRecommendation } from './pod-lease.js';
26
+ import { harnessCpuSlotsBesideGate, measurePoolLease, recommendConcurrency, type PodLeaseMember, type PoolDnsProbe, type PoolLeaseRecommendation } from './pod-lease.js';
25
27
 
26
28
  export interface PodPoolMember {
27
29
  readonly context: string;
@@ -111,7 +113,9 @@ export function parsePodPool(spec: string): PodPoolMember[] {
111
113
  const m = MEMBER.exec(part);
112
114
  if (!m) throw new Error(`--pod-pool: 못 읽는 노드 「${part}」 — 형식 컨텍스트[@ssh호스트][:상한][#k3d-레지스트리:포트]`);
113
115
  const capacity = m[3] === undefined ? 2 : Number(m[3]);
114
- if (!Number.isInteger(capacity) || capacity < 1) throw new Error(`--pod-pool: 상한은 1 이상 — 「${part}」`);
116
+ // POOL-LITE (10-07): `:0` means «this member is off» — a `node-c:0` entry once threw here and broke every launch.
117
+ // It is dropped below (never chosen); only a pool with no live member left is an error.
118
+ if (!Number.isInteger(capacity) || capacity < 0) throw new Error(`--pod-pool: 상한은 1 이상 — 「${part}」`);
115
119
  if (m[5] && Number(m[5]) > 65535) throw new Error(`--pod-pool: 레지스트리 포트는 65535 이하 — 「${part}」`);
116
120
  const context = m[1]!;
117
121
  const k3dCluster = context.startsWith('k3d-') ? context.slice(4) : 'elanous-pool';
@@ -119,7 +123,44 @@ export function parsePodPool(spec: string): PodPoolMember[] {
119
123
  });
120
124
  if (members.length === 0) throw new Error('--pod-pool: 노드가 없다');
121
125
  if (new Set(members.map((m) => m.context)).size !== members.length) throw new Error('--pod-pool: 컨텍스트가 겹친다');
122
- return members;
126
+ const live = members.filter((m) => m.capacity > 0);
127
+ if (live.length === 0) throw new Error(`--pod-pool: 상한은 1 이상 — 상한 0 이 아닌 노드가 없다 「${spec}」`);
128
+ if (live.length !== members.length) {
129
+ debug.log('pod.pool', 'member-disabled', { members: members.filter((m) => m.capacity <= 0).map((m) => m.context), reason: 'capacity-0' });
130
+ }
131
+ return live;
132
+ }
133
+
134
+ /**
135
+ * POOL-LITE (10-07) — what kind of Job is being placed. `lite` = lite memory tier or a docs/measurement goal;
136
+ * `standard` = an implementation goal. Absent kind keeps the plain free-share placement unchanged.
137
+ */
138
+ export type PodPlacementKind = 'lite' | 'standard';
139
+
140
+ export interface PodPlacement {
141
+ readonly member: string;
142
+ readonly kind: PodPlacementKind | null;
143
+ /** `preferred` = landed on the kind's preferred member · `overflow` = preferred member(s) full/skipped · `share` = no preference applies. */
144
+ readonly outcome: 'preferred' | 'overflow' | 'share';
145
+ readonly preferred: readonly string[];
146
+ /** One line for launch output and the run ledger. */
147
+ readonly reason: string;
148
+ }
149
+
150
+ /**
151
+ * Preferred members for a placement kind: lite → the smallest-capacity member(s) (node-c), standard → the largest (node-b).
152
+ * Capacity is the operator's own statement of member size (`pool-node-b@node-b:40,pool-node-c@node-c:6`), so no new spec syntax.
153
+ * When every member has the same capacity there is no small/big member and no preference (empty list).
154
+ */
155
+ export function preferredMembers(members: readonly PodPoolMember[], kind: PodPlacementKind | null | undefined): string[] {
156
+ const live = members.filter((m) => m.capacity > 0);
157
+ if (!kind || live.length < 2) return [];
158
+ const caps = live.map((m) => m.capacity);
159
+ const min = Math.min(...caps);
160
+ const max = Math.max(...caps);
161
+ if (min === max) return [];
162
+ const target = kind === 'lite' ? min : max;
163
+ return live.filter((m) => m.capacity === target).map((m) => m.context);
123
164
  }
124
165
 
125
166
  /** 스펙 해석 순서: 명시 인자 → `ELANOUS_POD_POOL` → 없음(null = 현재 컨텍스트 하나). */
@@ -178,12 +219,21 @@ export function podPoolHostLease(members: readonly PodPoolMember[]): HostPoolLea
178
219
  export class PodPoolScheduler {
179
220
  private readonly inflight = new Map<string, number>();
180
221
  private readonly admission: PodLeaseAdmission;
222
+ /**
223
+ * GATE-ADMIT-EXEMPT — the gate's own shard Jobs (GATE-MEM-ADMIT) queue here: memory admission without the
224
+ * GATE-RESERVE-AUTO CPU bound, which exists to protect the gate from harness Pods, not from itself.
225
+ */
226
+ private readonly gateAdmission: PodLeaseAdmission;
181
227
  private readonly outstanding = new Set<PodLeaseRelease>();
182
228
  private lastFree = 0;
183
229
  private budget = 0;
230
+ /** GATE-RESERVE-AUTO — local permits whose Pod was observed (Running/terminal): already inside the CPU reading. */
231
+ private seenActive = 0;
184
232
  private initialized = false;
185
233
  private readonly measure: () => Promise<PoolLeaseRecommendation>;
186
234
  private lastRaw: PoolLeaseRecommendation | null = null;
235
+ /** GATE-ADMIT-EXEMPT — the same reading before the gate CPU cap (memory/limitedBy unchanged); gate callers read it. */
236
+ private lastUncapped: PoolLeaseRecommendation | null = null;
187
237
  private readonly hostLease: HostPoolLease;
188
238
  private readonly pollMs: number;
189
239
  private readonly occupancy: () => Record<string, MemberOccupancy> | Promise<Record<string, MemberOccupancy>>;
@@ -192,19 +242,30 @@ export class PodPoolScheduler {
192
242
  this.pollMs = options.pollMs ?? 15_000;
193
243
  // One lease directory per pool (contexts, not caps) — every CLI process launching into the same clusters shares it.
194
244
  this.hostLease = options.hostLease ?? podPoolHostLease(members);
195
- const measureStatus = options.status ?? (() => {
196
- // POD-ADMIT-BY-USAGE: admission reads observed Pod usage (`kubectl top`); tryAcquire's occupancy read does not.
197
- const measure = measurePoolLease(members, { ...(options.kubectl ? { kubectl: options.kubectl } : {}), ...(options.dns ? { dns: options.dns } : {}), measureUsage: true });
198
- return recommendConcurrency(measure, { capacity: members.reduce((n, m) => n + m.capacity, 0), accounts: 0, perAccount: 0 });
199
- });
200
- this.measure = async () => measureStatus();
245
+ // GATE-RESERVE-AUTO: the gate reservation is returned beside the recommendation, never folded into it — the
246
+ // delta budget below must not see a gate start/end as a free-slot change (that made the result order-dependent).
247
+ const measureStatus: () => Promise<{ status: PoolLeaseRecommendation; gateSlots: number | null | undefined }> = options.status
248
+ ? async () => ({ status: await options.status!(), gateSlots: undefined })
249
+ : async () => {
250
+ // POD-ADMIT-BY-USAGE: admission reads observed Pod usage (`kubectl top`); tryAcquire's occupancy read does not.
251
+ const measure = measurePoolLease(members, { ...(options.kubectl ? { kubectl: options.kubectl } : {}), ...(options.dns ? { dns: options.dns } : {}), measureUsage: true });
252
+ return { status: recommendConcurrency(measure, { capacity: members.reduce((n, m) => n + m.capacity, 0), accounts: 0, perAccount: 0 }), gateSlots: harnessCpuSlotsBesideGate(measure) };
253
+ };
254
+ /** Cluster-level additional slots for the cross-process host lease: the measured free slots, capped by the gate's CPU. */
255
+ const capped = ({ status, gateSlots }: { status: PoolLeaseRecommendation; gateSlots: number | null | undefined }): PoolLeaseRecommendation =>
256
+ gateSlots === undefined || status.recommended === null ? status
257
+ : gateSlots === null ? { ...status, recommended: null, reason: '측정 불가: gate CPU' }
258
+ : { ...status, recommended: Math.min(status.recommended, gateSlots) };
259
+ this.measure = async () => { const reading = await measureStatus(); this.lastUncapped = reading.status; return capped(reading); };
201
260
  // An injected status (tests, callers with their own measurement) without memberMemory never shells out to kubectl here.
202
261
  this.memberMemory = options.memberMemory ?? (options.status ? (() => []) : () => measurePoolLease(members, { ...(options.kubectl ? { kubectl: options.kubectl } : {}), ...(options.dns ? { dns: options.dns } : {}) }).members
203
262
  .map((m: PodLeaseMember) => ({ context: m.context, allocatableMemoryBytes: m.allocatableMemoryBytes })));
204
263
  this.occupancy = options.occupancy ?? (() => measureMemberOccupancy(members, { ...(options.kubectl ? { kubectl: options.kubectl } : {}), ...(options.dns ? { dns: options.dns } : {}) }));
205
- this.admission = new PodLeaseAdmission({ status: async () => {
206
- const measured = await measureStatus();
207
- this.lastRaw = measured;
264
+ const decide = async (gate: boolean): Promise<PoolLeaseRecommendation> => {
265
+ const reading = await measureStatus();
266
+ const measured = reading.status;
267
+ this.lastRaw = capped(reading);
268
+ this.lastUncapped = measured;
208
269
  const pendingJobs = measured.pendingJobs ?? [];
209
270
  const ownStart = this.hostLease.selfStart();
210
271
  const transferId = process.env.ELANOUS_POD_RESERVED_LEASE;
@@ -220,16 +281,40 @@ export class PodPoolScheduler {
220
281
  this.budget = Math.max(0, this.budget + decision.recommended - (this.initialized ? this.lastFree : 0));
221
282
  this.initialized = true;
222
283
  this.lastFree = decision.recommended;
223
- return { ...decision, recommended: this.admission.snapshot().active + this.budget };
224
- }, ...(options.pollMs ? { pollMs: options.pollMs } : {}), dependencyMerged: options.dependencyMerged ?? ((after, signal) => predecessorState(after, {}, signal)) });
284
+ // Each queue's limit is its own permits plus the shared remaining budget (every permit already took one from it).
285
+ const active = (gate ? this.gateAdmission : this.admission).snapshot().active;
286
+ const total = active + this.budget;
287
+ if (reading.gateSlots === undefined) return { ...decision, recommended: total };
288
+ if (gate) {
289
+ debug.log('pod.pool', 'gate-reserve', { gateSlots: reading.gateSlots, active, budget: this.budget, recommended: total, exempt: 'gate-caller' });
290
+ return { ...decision, recommended: total };
291
+ }
292
+ // GATE-RESERVE-AUTO: while a gate shard Job is active, harness admission is also bounded by the CPU left after
293
+ // the gate. The bound is absolute (this reading ⊕ current local permits). Pods already in the reading — observed
294
+ // ones (`seenActive`) and ones measured Pending (own leases with a pending Pod) — are not subtracted again;
295
+ // the remaining local permits and other processes' leases without a measured Pod (`others`) are.
296
+ const ownMeasuredPending = this.hostLease.live().filter((r) => r.pid === process.pid && r.startedAt === ownStart && leaseHasPendingPod(r, pendingJobs)).length;
297
+ const unseen = Math.max(0, active - this.seenActive - ownMeasuredPending);
298
+ const cpuFree = reading.gateSlots === null ? 0 : Math.max(0, reading.gateSlots - others + (transferIsOther ? 1 : 0) - unseen);
299
+ const recommended = Math.min(total, active + cpuFree);
300
+ if (recommended < total) debug.log('pod.pool', 'gate-reserve', { gateSlots: reading.gateSlots, active, unseen, ownMeasuredPending, others, budget: this.budget, recommended });
301
+ return reading.gateSlots === null
302
+ ? { ...decision, recommended, reason: '측정 불가: gate CPU' }
303
+ : { ...decision, recommended };
304
+ };
305
+ const dependencyMerged = options.dependencyMerged ?? ((after: PodLeasePredecessor, signal?: AbortSignal) => predecessorState(after, {}, signal));
306
+ this.admission = new PodLeaseAdmission({ status: () => decide(false), ...(options.pollMs ? { pollMs: options.pollMs } : {}), dependencyMerged });
307
+ this.gateAdmission = new PodLeaseAdmission({ status: () => decide(true), ...(options.pollMs ? { pollMs: options.pollMs } : {}), dependencyMerged });
225
308
  }
226
309
  /** FIFO gate within this process, then a host lease so independent CLI processes cannot over-admit the same pool. */
227
- async acquireAdmission(signal?: AbortSignal, after?: PodLeasePredecessor): Promise<PodLeaseRelease & { applied: (job: string, context: string, namespace: string) => void; observed: () => void }> {
228
- const local = await this.acquireLocalAdmission(signal, after);
310
+ async acquireAdmission(signal?: AbortSignal, after?: PodLeasePredecessor, opts: { gate?: boolean } = {}): Promise<PodLeaseRelease & { applied: (job: string, context: string, namespace: string) => void; observed: () => void }> {
311
+ const gate = opts.gate === true;
312
+ const local = await this.acquireLocalAdmission(signal, after, gate);
229
313
  try {
230
314
  for (;;) {
231
315
  if (signal?.aborted) throw new Error('pod lease admission aborted');
232
- const free = this.lastRaw?.recommended ?? null;
316
+ // GATE-ADMIT-EXEMPT: a gate caller is not bounded by its own gate's CPU reservation (memory still bounds it).
317
+ const free = (gate ? this.lastUncapped : this.lastRaw)?.recommended ?? null;
233
318
  // Same check as the local gate, but atomic across processes: other authoring and pre-Running leases hold slots.
234
319
  const reservedId = process.env.ELANOUS_POD_RESERVED_LEASE;
235
320
  const transferred = reservedId ? this.hostLease.claim(reservedId) : null;
@@ -242,9 +327,16 @@ export class PodPoolScheduler {
242
327
  if (lease) {
243
328
  let hostReleased = false;
244
329
  const releaseHost = () => { if (!hostReleased) { hostReleased = true; lease(); } };
245
- return Object.assign(() => { releaseHost(); local(); }, {
330
+ // GATE-RESERVE-AUTO: an observed Pod (Running or terminal) is in the CPU reading; until then it is subtracted.
331
+ let seen = false, done = false;
332
+ return Object.assign(() => {
333
+ if (done) return;
334
+ done = true;
335
+ if (seen) this.seenActive--;
336
+ releaseHost(); local();
337
+ }, {
246
338
  applied: (job: string, context: string, namespace: string) => lease.applied(job, context, namespace),
247
- observed: releaseHost,
339
+ observed: () => { if (!seen && !done) { seen = true; this.seenActive++; } releaseHost(); },
248
340
  });
249
341
  }
250
342
  await new Promise<void>((resolve, reject) => {
@@ -252,15 +344,15 @@ export class PodPoolScheduler {
252
344
  const onAbort = () => { clearTimeout(timer); reject(new Error('pod lease admission aborted')); };
253
345
  signal?.addEventListener('abort', onAbort, { once: true });
254
346
  });
255
- try { this.lastRaw = await this.measure(); } catch { this.lastRaw = null; }
347
+ try { this.lastRaw = await this.measure(); } catch { this.lastRaw = null; this.lastUncapped = null; }
256
348
  }
257
349
  } catch (error) {
258
350
  local();
259
351
  throw error;
260
352
  }
261
353
  }
262
- private acquireLocalAdmission(signal?: AbortSignal, after?: PodLeasePredecessor): Promise<PodLeaseRelease> {
263
- return this.admission.acquire(signal, after).then((release) => {
354
+ private acquireLocalAdmission(signal?: AbortSignal, after?: PodLeasePredecessor, gate = false): Promise<PodLeaseRelease> {
355
+ return (gate ? this.gateAdmission : this.admission).acquire(signal, after).then((release) => {
264
356
  this.budget = Math.max(0, this.budget - 1);
265
357
  this.outstanding.add(release);
266
358
  return () => {
@@ -290,8 +382,9 @@ export class PodPoolScheduler {
290
382
  if (!r) return null;
291
383
  return `recommended=${r.recommended ?? 'unknown'} limitedBy=${r.limitedBy ?? '-'} memorySlots=${r.memorySlots ?? '?'}${r.reason ? ` reason=${r.reason}` : ''}`;
292
384
  }
293
- admissionSnapshot(): ReturnType<PodLeaseAdmission['snapshot']> {
294
- return this.admission.snapshot();
385
+ /** The harness queue by default; `{ gate: true }` reads the gate-caller queue (GATE-ADMIT-EXEMPT). */
386
+ admissionSnapshot(opts: { gate?: boolean } = {}): ReturnType<PodLeaseAdmission['snapshot']> {
387
+ return (opts.gate ? this.gateAdmission : this.admission).snapshot();
295
388
  }
296
389
  /**
297
390
  * Pick the member with the largest free share: (capacity − measured cluster occupancy − this process's inflight) / capacity.
@@ -304,8 +397,11 @@ export class PodPoolScheduler {
304
397
  * member the 0.2.15 gate launched its shards one at a time.
305
398
  * Ties keep the configured member order. Returns null when every readable member is full.
306
399
  */
307
- async tryAcquire(skip?: ReadonlySet<string>, onEmpty?: (reason: string) => void, mode: { selfCapped?: boolean } = {}): Promise<PodPoolMember | null> {
400
+ async tryAcquire(skip?: ReadonlySet<string>, onEmpty?: (reason: string) => void, mode: { selfCapped?: boolean; kind?: PodPlacementKind | null; onPlacement?: (placement: PodPlacement) => void } = {}): Promise<PodPoolMember | null> {
308
401
  const selfCapped = mode.selfCapped === true;
402
+ const kind = mode.kind ?? null;
403
+ // POOL-LITE: lite goals try the small member first, implementation goals the big one; full → overflow by free share.
404
+ const preferred = new Set(preferredMembers(this.members, kind));
309
405
  let reading: Record<string, MemberOccupancy>;
310
406
  // A self-capped caller's own Jobs are its inflight; other workloads' Pods are not its slots (no cluster read).
311
407
  if (selfCapped) reading = Object.fromEntries(this.members.map((m) => [m.context, { occupied: 0 }]));
@@ -316,13 +412,17 @@ export class PodPoolScheduler {
316
412
  }
317
413
  const free: Record<string, number> = {};
318
414
  const measured: Record<string, boolean> = {};
319
- let best: { member: PodPoolMember; slots: number; measured: boolean } | null = null;
415
+ let best: { member: PodPoolMember; slots: number; measured: boolean; preferred: boolean } | null = null;
320
416
  // A measured member always beats an unmeasured one (its share would read 100% and draw every Job to a node
321
417
  // that did not answer); unmeasured members compete only when no measured member has a free slot.
322
- const better = (cand: { member: PodPoolMember; slots: number; measured: boolean }) => !best
418
+ // Among equally measured members a preferred one (POOL-LITE) beats the rest; then the larger free share wins.
419
+ const better = (cand: { member: PodPoolMember; slots: number; measured: boolean; preferred: boolean }) => !best
323
420
  || (cand.measured !== best.measured ? cand.measured
324
- : cand.slots / cand.member.capacity > best.slots / best.member.capacity);
421
+ : cand.preferred !== best.preferred ? cand.preferred
422
+ : cand.slots / cand.member.capacity > best.slots / best.member.capacity);
325
423
  for (const m of this.members) {
424
+ // A capacity-0 member is off — never chosen (and no division by zero in the share).
425
+ if (m.capacity <= 0) continue;
326
426
  // LAUNCH-STALL: a member this launch cannot fit is skipped for this call only (other launches may fit it).
327
427
  if (skip?.has(m.context)) continue;
328
428
  const inflight = this.inflight.get(m.context) ?? 0;
@@ -333,17 +433,30 @@ export class PodPoolScheduler {
333
433
  const slots = m.capacity - occupied - inflight;
334
434
  free[m.context] = slots;
335
435
  if (slots <= 0) continue;
336
- const cand = { member: m, slots, measured: ok };
436
+ const cand = { member: m, slots, measured: ok, preferred: preferred.has(m.context) };
337
437
  if (better(cand)) best = cand;
338
438
  }
339
439
  if (!best) {
340
- onEmpty?.(`no free slot · free=${JSON.stringify(free)} · measured=${JSON.stringify(measured)}${selfCapped ? ' · selfCapped' : ''}${skip?.size ? ` · skipped=${[...skip].join(',')}` : ''}`);
440
+ onEmpty?.(`no free slot · free=${JSON.stringify(free)} · measured=${JSON.stringify(measured)}${selfCapped ? ' · selfCapped' : ''}${skip?.size ? ` · skipped=${[...skip].join(',')}` : ''}${kind ? ` · kind=${kind}` : ''}`);
341
441
  return null;
342
442
  }
343
- this.inflight.set(best.member.context, (this.inflight.get(best.member.context) ?? 0) + 1);
344
- const share = Object.fromEntries(this.members.map((m) => [m.context, Math.round(Math.max(0, free[m.context] ?? 0) / m.capacity * 100) / 100]));
345
- debug.log('pod.pool', 'member-selected', { member: best.member.context, free, measured, share, ...(selfCapped ? { selfCapped: true } : {}) });
346
- return best.member;
443
+ const chosen: { member: PodPoolMember; slots: number; measured: boolean; preferred: boolean } = best;
444
+ this.inflight.set(chosen.member.context, (this.inflight.get(chosen.member.context) ?? 0) + 1);
445
+ const share = Object.fromEntries(this.members.map((m) => [m.context, m.capacity > 0 ? Math.round(Math.max(0, free[m.context] ?? 0) / m.capacity * 100) / 100 : 0]));
446
+ const outcome: PodPlacement['outcome'] = preferred.size === 0 ? 'share' : chosen.preferred ? 'preferred' : 'overflow';
447
+ const pref = [...preferred];
448
+ const why = (c: string) => skip?.has(c) ? 'skipped' : measured[c] === false ? 'unmeasured' : 'full';
449
+ const reason = outcome === 'share'
450
+ ? `${chosen.member.context} · free share ${share[chosen.member.context]}${kind ? ` · ${kind} 골 · 선호 멤버 없음(${this.members.filter((m) => m.capacity > 0).length < 2 ? '단일 멤버' : '상한이 같다'})` : ''}`
451
+ : outcome === 'preferred'
452
+ ? `${chosen.member.context} · ${kind} 골 선호 멤버 · free share ${share[chosen.member.context]}`
453
+ : `${chosen.member.context} · ${kind} 골 넘침 — 선호 ${pref.map((c) => `${c}(${why(c)})`).join(',')} · free share ${share[chosen.member.context]}`;
454
+ const placement: PodPlacement = { member: chosen.member.context, kind, outcome, preferred: pref, reason };
455
+ debug.log('pod.pool', 'member-selected', { member: chosen.member.context, free, measured, share, ...(selfCapped ? { selfCapped: true } : {}), ...(kind ? { kind, outcome, preferred: pref, reason } : {}) });
456
+ // The callback only observes the placement — a throwing observer must not leak the slot it was told about.
457
+ try { mode.onPlacement?.(placement); }
458
+ catch (error) { debug.log('pod.pool', 'placement-observer-failed', { member: chosen.member.context, reason: error instanceof Error ? error.message : String(error) }, { level: 'warn' }); }
459
+ return chosen.member;
347
460
  }
348
461
  release(member: PodPoolMember): void {
349
462
  this.inflight.set(member.context, Math.max(0, (this.inflight.get(member.context) ?? 0) - 1));
@@ -48,7 +48,7 @@ export const POD_JOB_DEADLINE_SECONDS = 10_800;
48
48
  // POD-DIET (10-06 03:47~05:18 · node-b goal Pods · 89 one-minute samples · 42 Pods): per-Pod observed max median 4.6Gi ·
49
49
  // p75 6.2Gi · p90 11.7Gi — 23/42 went above the old 4Gi request, so the scheduler over-packed. 6Gi ≈ p75; limits stay.
50
50
  // Node allocatable ≈ 343Gi ≫ 25 × 6Gi. One-minute samples can miss the true peak (lower bound).
51
- import type { PodPoolMember, PodPoolScheduler } from './pod-pool.js';
51
+ import type { PodPlacement, PodPlacementKind, PodPoolMember, PodPoolScheduler } from './pod-pool.js';
52
52
  export { POD_CHILD_REQUESTS } from './pod-lease.js';
53
53
  import { measurePoolLease, recommendConcurrency, POD_CHILD_REQUESTS, POD_HOST_LEASE_ANNOTATION, LEASE_KUBECTL_MAX_BUFFER, memoryQuantityBytes } from './pod-lease.js';
54
54
  import { ACTUAL_SUBSTRATE_ENV, RUN_CONTRACT_ENV, carryRunContract, completionFloorFor } from '../../self-implement/graph-run-contract.js';
@@ -654,6 +654,16 @@ export function parsePodMemoryTier(v: string | undefined): PodMemoryTier | null
654
654
  return t && (POD_MEMORY_TIERS as readonly string[]).includes(t) ? (t as PodMemoryTier) : null;
655
655
  }
656
656
 
657
+ /**
658
+ * POOL-LITE (10-07): which pool member a Job prefers. Lite tier, or a docs/measurement goal not raised to `high`,
659
+ * goes small-first (node-c); everything else is an implementation Job and goes big-first (node-b).
660
+ */
661
+ export function podPlacementKind(tier: PodMemoryTier, declared: DeclaredGoalType | null, requestKind: string | null): PodPlacementKind {
662
+ if (tier === 'lite') return 'lite';
663
+ if (tier !== 'high' && (declared === 'document' || declared === 'research' || requestKind === 'docs')) return 'lite';
664
+ return 'standard';
665
+ }
666
+
657
667
  function parsePodGoalType(value: string | undefined): DeclaredGoalType | null {
658
668
  return value === 'implement' || value === 'research' || value === 'document' || value === 'operate' ? value : null;
659
669
  }
@@ -991,6 +1001,8 @@ export function podSelfImplementSpawn(options: PodSpawnOptions = {}): SelfImplem
991
1001
  // pool-slot reason = what THIS launch saw on its last empty tryAcquire; admission reason = the pool-wide lease measurement (one pool, one reading).
992
1002
  const stageReason: { 'pool-slot'?: string | null } = {};
993
1003
  const unfitContexts = new Set<string>();
1004
+ // POOL-LITE (10-07): the last placement of this launch — printed at launch and journaled with the Job.
1005
+ let lastPlacement = null as PodPlacement | null;
994
1006
  const watchStall = (stage: 'admission' | 'pool-slot'): (() => void) => {
995
1007
  const started = Date.now();
996
1008
  const timer = setTimeout(() => {
@@ -1026,9 +1038,12 @@ export function podSelfImplementSpawn(options: PodSpawnOptions = {}): SelfImplem
1026
1038
  for (;;) {
1027
1039
  if (input.signal?.aborted) return null;
1028
1040
  // The reason comes back inside this call, so a concurrent launch cannot overwrite it.
1029
- const acquired = await options.pool.tryAcquire(unfitContexts, (reason) => { stageReason['pool-slot'] = reason; });
1041
+ const kind = podPlacementKind(oomRetried ? retryTier : memoryTier, selectedGoalType, requestKind);
1042
+ const acquired = await options.pool.tryAcquire(unfitContexts, (reason) => { stageReason['pool-slot'] = reason; },
1043
+ { kind, onPlacement: (placement) => { lastPlacement = placement; } });
1030
1044
  if (acquired) {
1031
- debug.log('self-implement.pod', 'pool-slot', { spaceId: input.spaceId, context: acquired.context, inflight: options.pool.snapshot() });
1045
+ debug.log('self-implement.pod', 'pool-slot', { spaceId: input.spaceId, context: acquired.context, inflight: options.pool.snapshot(), ...(lastPlacement ? { placement: lastPlacement } : {}) });
1046
+ if (lastPlacement) console.warn(`[pod] 배치: ${lastPlacement.reason}`);
1032
1047
  return acquired;
1033
1048
  }
1034
1049
  await sleep(options.pollMs ?? 15_000);
@@ -1451,6 +1466,7 @@ export function podSelfImplementSpawn(options: PodSpawnOptions = {}): SelfImplem
1451
1466
  }
1452
1467
  try {
1453
1468
  appendRunLedgerEntry({ runId: ledgerRunId, event: 'pod-child-run', data: { childRunId: liveChildRunId, job: name, attempt: oomRetried ? 2 : 1, ...(goalFile ? { goalFile } : {}) } }, ledgerDir);
1469
+ if (lastPlacement) appendRunLedgerEntry({ runId: ledgerRunId, event: 'pod-placement', data: { job: name, childRunId: liveChildRunId, attempt: oomRetried ? 2 : 1, member: lastPlacement.member, kind: lastPlacement.kind, outcome: lastPlacement.outcome, preferred: lastPlacement.preferred, reason: lastPlacement.reason } }, ledgerDir);
1454
1470
  appendRunLedgerEntry({ runId: ledgerRunId, event: 'pod-memory-selected', data: { job: name, childRunId: liveChildRunId, attempt: oomRetried ? 2 : 1, limit: memoryLimit, ...(appliedMemoryRequest ? { request: appliedMemoryRequest } : {}), tier: oomRetried ? retryTier : memoryTier, source: oomRetried ? 'oom-retry' : memorySource, reason: oomRetried ? 'OOMKilled' : memoryReason, declaredGoalType: selectedGoalType } }, ledgerDir);
1455
1471
  if (oomRetried && retryFromChildRunId) appendRunLedgerEntry({ runId: ledgerRunId, event: 'pod-oom-retry', data: { attempt: 2, from: retryFromMemoryLimit ?? selectedMemoryLimit, to: memoryLimit, tier: retryTier, reason: 'OOMKilled', job: name, childRunId: liveChildRunId, fromChildRunId: retryFromChildRunId } }, ledgerDir);
1456
1472
  } catch (error) {
@@ -55,6 +55,7 @@ import { createWishCard } from './intake-plane/wish-card.js';
55
55
  import { telegramDecisionOwner } from './decisions/telegram-decision-cards.js';
56
56
  import { CardStore } from './task-cards/card-store.js';
57
57
  import type { ContextNowDeps } from './context-bus/context-now.js';
58
+ import type { RunningRunsResult } from './self-implement/running-runs.js';
58
59
 
59
60
  /** How many prior user/assistant turns to pass into `executeSkill` as
60
61
  * the `## Recent conversation` block. Mirrors the dashboard default
@@ -305,6 +306,40 @@ function dropChatSessionReply(ctx: TgIncoming): string {
305
306
  : `_Session \`${sess.id.slice(0, 8)}\` was already gone._`;
306
307
  }
307
308
 
309
+ /** `/runs` — the Telegram twin of TUI `/harness runs`: the same `renderRunningRuns` text over the same
310
+ * production-universe query. The query is injectable so a test can prove the reply equals the renderer
311
+ * output for one data set. Long replies are chunked by the bot's sender, so no run line is dropped here. */
312
+ export async function telegramRunsSlash(args: readonly string[], query?: () => RunningRunsResult,
313
+ runCli: (argv: readonly string[]) => Promise<{ code: number | null; stdout: string }> = runSelfCli): Promise<string> {
314
+ if (args.length > 0) return 'Usage: /runs';
315
+ const { queryRunningRuns, renderRunningRuns } = await import('./self-implement/running-runs.js');
316
+ if (query) return renderRunningRuns(query());
317
+ // The ledger scan is synchronous and took 30–58 s under load (TC 10-07 17:37) — run inside the daemon it would freeze
318
+ // every bot and HTTP reply for that long. Ask the CLI twin (`self running-runs`, same renderer) in a child process,
319
+ // pinned to this daemon's universe; fall back in-process only when the child cannot start.
320
+ const { effectiveInstanceRoot } = await import('./instance/resolve.js');
321
+ try {
322
+ const { code, stdout } = await runCli(['--config-dir', effectiveInstanceRoot(), 'self', 'running-runs']);
323
+ if (code === 0 && stdout.trim()) return stdout.trimEnd();
324
+ } catch { /* fall through */ }
325
+ return renderRunningRuns(queryRunningRuns({ includeTest: false, caller: 'telegram' }));
326
+ }
327
+
328
+ /** Run this same elanous entry as a child (`bun <entry> …`) with a hard time limit. */
329
+ async function runSelfCli(argv: readonly string[]): Promise<{ code: number | null; stdout: string }> {
330
+ const { spawn } = await import('node:child_process');
331
+ const entry = process.argv[1];
332
+ if (!entry) throw new Error('no elanous entry');
333
+ return new Promise((resolve, reject) => {
334
+ const child = spawn(process.execPath, [entry, ...argv], { stdio: ['ignore', 'pipe', 'ignore'] });
335
+ let stdout = '';
336
+ const timer = setTimeout(() => { child.kill('SIGTERM'); reject(new Error('timeout')); }, 120_000);
337
+ child.stdout.setEncoding('utf8').on('data', (chunk: string) => { stdout += chunk; });
338
+ child.on('error', (error) => { clearTimeout(timer); reject(error); });
339
+ child.on('close', (code) => { clearTimeout(timer); resolve({ code, stdout }); });
340
+ });
341
+ }
342
+
308
343
  /** Build the default command set for a Telegram bot wired via
309
344
  * botFromConfig. Returns the array rather than mutating globals so
310
345
  * tests can construct a variant set with stubbed handlers. */
@@ -312,6 +347,7 @@ export function defaultTelegramCommands(
312
347
  nowDeps?: ContextNowDeps,
313
348
  coreCommands: readonly SlashCommand[] = SLASH_COMMANDS,
314
349
  maturity: { tuiSlash: Readonly<Record<string, Maturity>>; telegramCommand: Readonly<Record<string, Maturity>>; discordCommand: Readonly<Record<string, Maturity>> } = FEATURE_MATURITY,
350
+ runningRunsQuery?: () => RunningRunsResult,
315
351
  ): TgSlashCommand[] {
316
352
  const handled: TgSlashCommand[] = [
317
353
  {
@@ -335,6 +371,11 @@ export function defaultTelegramCommands(
335
371
  description: '루프·크론 현황 — 늦음·실패를 먼저 보여줍니다',
336
372
  handler: (args) => telegramLoopsStatus(args),
337
373
  },
374
+ {
375
+ name: 'runs',
376
+ description: 'Running harness runs — same output as TUI /harness runs',
377
+ handler: async (args) => telegramRunsSlash(args, runningRunsQuery),
378
+ },
338
379
  {
339
380
  name: 'release',
340
381
  description: '발행 현황 — 지금 런·노드·막힘을 한 통으로',
@@ -0,0 +1,106 @@
1
+ /**
2
+ * ⏱️ GATE-SPEED ③ — 실물 엔트리 spawn 을 «비동기 ⊕ 상한 있는 동시성»으로 돌리는 테스트 전용 도구.
3
+ *
4
+ * Situation: 배선 시험(`bin/elanous.mjs` 실물 spawn)은 1회 ~3–4초(전 모듈 그래프 콜드 스타트)이고,
5
+ * `spawnSync` 로 줄 세우면 파일 하나가 게이트 임계 경로(수백 초)를 잡는다.
6
+ * Answer: 시험이 문는 값(실물 프로세스의 stdout·stderr·종료 코드·시그널)은 그대로 두고,
7
+ * «기다리는 방식»만 바꾼다 — `describe.concurrent` 안에서 이 함수를 `await` 하면 서로 독립인
8
+ * spawn 들이 `REAL_CLI_SPAWN_CONCURRENCY` 개까지 겹쳐 돈다.
9
+ *
10
+ * ⛔ 반환 모양은 `spawnSync(..., { encoding: 'utf8' })` 와 같다 — 시그널로 죽으면 `status` 는 null 이고
11
+ * `signal` 이 찬다. spawn 자체가 실패하면 `error` 가 찬다(타임아웃 = 0바이트 산출과 구분하려고).
12
+ * ⛔ spawn 의 `timeoutMs` 는 «슬롯을 얻은 뒤»부터 잰다 — 대기열에서 기다린 시간은 실패로 세지 않는다.
13
+ * 그래서 이 도구를 쓰는 시험의 «테스트» 타임아웃은 대기열 몫까지 넉넉히 둔다.
14
+ */
15
+ import { availableParallelism } from 'node:os';
16
+
17
+ export interface RealCliSpawnResult {
18
+ stdout: string;
19
+ stderr: string;
20
+ status: number | null;
21
+ signal: string | null;
22
+ error?: Error;
23
+ }
24
+
25
+ export interface RealCliSpawnOptions {
26
+ cwd?: string;
27
+ env?: Record<string, string | undefined>;
28
+ timeoutMs: number;
29
+ }
30
+
31
+ function resolveConcurrency(): number {
32
+ const fromEnv = Number(process.env.ELANOUS_TEST_REAL_CLI_CONCURRENCY);
33
+ if (Number.isInteger(fromEnv) && fromEnv > 0) return fromEnv;
34
+ return Math.max(2, Math.min(6, Math.floor(availableParallelism() / 3)));
35
+ }
36
+
37
+ export const REAL_CLI_SPAWN_CONCURRENCY = resolveConcurrency();
38
+
39
+ let active = 0;
40
+ const waiters: Array<() => void> = [];
41
+
42
+ async function withSlot<T>(run: () => Promise<T>): Promise<T> {
43
+ while (active >= REAL_CLI_SPAWN_CONCURRENCY) await new Promise<void>((resolve) => waiters.push(resolve));
44
+ active += 1;
45
+ try {
46
+ return await run();
47
+ } finally {
48
+ active -= 1;
49
+ waiters.shift()?.();
50
+ }
51
+ }
52
+
53
+ function definedEnv(env: Record<string, string | undefined>): Record<string, string> {
54
+ const out: Record<string, string> = {};
55
+ for (const [key, value] of Object.entries(env)) if (value !== undefined) out[key] = value;
56
+ return out;
57
+ }
58
+
59
+ /** `spawnSync(cmd[0], cmd.slice(1), { encoding: 'utf8' })` 의 비동기 대응 — 동시성 상한 안에서 돈다. */
60
+ export async function spawnRealCli(cmd: string[], options: RealCliSpawnOptions): Promise<RealCliSpawnResult> {
61
+ const r = await spawnRealCliBytes(cmd, options);
62
+ const decoder = new TextDecoder();
63
+ return {
64
+ stdout: decoder.decode(r.stdout),
65
+ stderr: decoder.decode(r.stderr),
66
+ status: r.exitCode,
67
+ signal: r.signal,
68
+ ...(r.error ? { error: r.error } : {}),
69
+ };
70
+ }
71
+
72
+ export interface RealCliSpawnBytesResult {
73
+ /** `Bun.spawnSync` 와 같은 이름 — 시그널로 죽으면 null. */
74
+ exitCode: number | null;
75
+ stdout: Uint8Array;
76
+ stderr: Uint8Array;
77
+ signal: string | null;
78
+ error?: Error;
79
+ }
80
+
81
+ /** `Bun.spawnSync({ cmd, stdout: 'pipe', stderr: 'pipe' })` 의 비동기 대응 — 바이트 그대로 돌려준다. */
82
+ export function spawnRealCliBytes(cmd: string[], options: RealCliSpawnOptions): Promise<RealCliSpawnBytesResult> {
83
+ return withSlot(async () => {
84
+ let proc: ReturnType<typeof Bun.spawn>;
85
+ try {
86
+ proc = Bun.spawn(cmd, {
87
+ cwd: options.cwd,
88
+ env: definedEnv(options.env ?? { ...process.env }),
89
+ stdin: 'ignore',
90
+ stdout: 'pipe',
91
+ stderr: 'pipe',
92
+ timeout: options.timeoutMs,
93
+ killSignal: 'SIGKILL',
94
+ });
95
+ } catch (error) {
96
+ return { stdout: new Uint8Array(), stderr: new Uint8Array(), exitCode: null, signal: null, error: error instanceof Error ? error : new Error(String(error)) };
97
+ }
98
+ const [stdout, stderr] = await Promise.all([
99
+ new Response(proc.stdout as ReadableStream).arrayBuffer(),
100
+ new Response(proc.stderr as ReadableStream).arrayBuffer(),
101
+ ]);
102
+ await proc.exited;
103
+ const signal = proc.signalCode ?? null;
104
+ return { stdout: new Uint8Array(stdout), stderr: new Uint8Array(stderr), exitCode: signal ? null : proc.exitCode, signal };
105
+ });
106
+ }